diff --git a/.env.example b/.env.example index 7abd1d4..671bff3 100644 --- a/.env.example +++ b/.env.example @@ -37,3 +37,10 @@ # LOOPS_API_KEY=replace-with-your-loops-api-key # LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=replace-with-your-loops-verify-template-id # LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID=replace-with-your-loops-reset-template-id + +# Optional in self-hosted modes. Required if you want Google Search Console +# integration and MCP tools. BETTER_AUTH_SECRET is also required for GSC (it +# encrypts the stored OAuth tokens). See docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md. +# GOOGLE_CLIENT_ID=replace-with-your-google-oauth-client-id +# GOOGLE_CLIENT_SECRET=replace-with-your-google-oauth-client-secret +# BETTER_AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-characters diff --git a/README.md b/README.md index 0b81bce..61d113b 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Easy to self-host, fork and extend, but we have a managed version too: - [Community](#community) - [Pricing / Costs (Free + API costs)](#pricing--costs) - [DataForSEO API Key Setup](#dataforseo-api-key-setup) +- [Google Search Console](#google-search-console) - [Self-hosting](#self-hosting) - [Docker Self Hosting](#docker-self-hosting) - [Cloudflare Self-Hosting](#cloudflare-self-hosting) @@ -203,6 +204,12 @@ printf '%s' 'YOUR_LOGIN:YOUR_PASSWORD' | base64 - Cloudflare: Set it in the workers UI - Local development: `.env.local` +## Google Search Console + +Search Console is optional and works in self-hosted deployments using your own +Google OAuth client. It takes ~10 minutes of one-time setup — see +[`docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md`](./docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md). + ## Self-hosting OpenSEO supports two self-hosting paths: diff --git a/compose.yaml b/compose.yaml index 42b718e..87b1dad 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,6 +9,11 @@ services: - ALLOWED_HOST=${ALLOWED_HOST:-} - AUTH_MODE=local_noauth - DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY} + # Optional: Google Search Console. See + # docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-} - VITE_SHOW_DEVTOOLS=false ports: - "127.0.0.1:${PORT:-3001}:${PORT:-3001}" diff --git a/docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md b/docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md new file mode 100644 index 0000000..a08f148 --- /dev/null +++ b/docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md @@ -0,0 +1,118 @@ +# Self-hosted Google Search Console + +Connecting Google Search Console (GSC) lets OpenSEO pull your real clicks, +impressions, positions, and URL inspection data, straight from Google. + +It's **optional**: OpenSEO runs fine without it, just without Search Console data. + +## What you'll need + +- A Google account with access to your verified Search Console property. +- ~10 minutes in the [Google Cloud Console](https://console.cloud.google.com/). +- Three environment variables set on your deployment (see [step 4](#4-set-environment-variables)). + +## 1) Create a Google Cloud project and enable the API + +1. Open the [Google Cloud Console](https://console.cloud.google.com/) and create + a project (or pick an existing one). +2. Enable the + [Google Search Console API](https://console.cloud.google.com/apis/library/searchconsole.googleapis.com) + for that project. + +## 2) Configure the OAuth consent screen + +Under **APIs & Services → OAuth consent screen**: + +- Pick **External** (unless everyone using it is in your Google Workspace org). +- Fill in the app name, support email, and developer contact email. +- While the app is in **Testing**, add the Google accounts that will connect as + **test users** — otherwise Google blocks the sign-in with `access_denied`. + +For personal or internal use you don't need to submit for verification; testing +mode is enough. + +## 3) Create an OAuth client ID + +Under **APIs & Services → Credentials → Create credentials → OAuth client ID**: + +1. Application type: **Web application**. +2. Add an **Authorized redirect URI** that exactly matches your deployment's + origin plus `/api/gsc/oauth/callback`: + + | Deployment | Redirect URI | + | ------------ | -------------------------------------------------------- | + | Deployed | `https://your-openseo-domain.com/api/gsc/oauth/callback` | + | Local Docker | `http://localhost:3001/api/gsc/oauth/callback` | + + The scheme, host, and port must match exactly, with no trailing slash. + +3. Save, then copy the **Client ID** and **Client secret**. + +## 4) Set environment variables + +Set these three values, then restart OpenSEO: + +| Variable | Value | +| ---------------------- | ----------------------------------------------------------------------- | +| `GOOGLE_CLIENT_ID` | Client ID from step 3. | +| `GOOGLE_CLIENT_SECRET` | Client secret from step 3. | +| `BETTER_AUTH_SECRET` | A random string of **at least 32 characters** (encrypts stored tokens). | + +`BETTER_AUTH_SECRET` is not needed for normal self-hosting — only for Search +Console, because the stored OAuth tokens are encrypted at rest with it. Generate +one with: + +```sh +openssl rand -base64 32 +``` + +Where to set them: + +- **Docker self-hosting:** `.env` +- **Cloudflare:** the Workers dashboard (as secrets) +- **Local development:** `.env.local` + +## 5) Restart and connect + +Restart OpenSEO so it picks up the new variables. For Docker, changing `.env` +means Compose has to recreate the container to reapply it: + +```bash +docker compose up -d --force-recreate open-seo +``` + +Then open **Integrations**, click **Connect with Google**, authorize the Google +account that owns your verified property, and pick the property to bind to your +project. + +## How it works + +- OpenSEO uses your Google client to run the OAuth flow and stores the resulting + grant in its database, with the access and refresh tokens **encrypted at rest** + (keyed by `BETTER_AUTH_SECRET`). +- Access tokens are minted and refreshed on demand — you only authorize once. +- Search Console data comes from your own Google account, so OpenSEO never meters credits for it. + +## Troubleshooting + +**`redirect_uri_mismatch` from Google** — the redirect URI in your OAuth client +must exactly equal `/api/gsc/oauth/callback`. Re-check scheme +(`http` vs `https`), host, port, and that there's no trailing slash. + +**"Google OAuth client not configured" / "not configured for Search Console yet"** +(in the app or via the MCP tools) — one of `GOOGLE_CLIENT_ID`, +`GOOGLE_CLIENT_SECRET`, or `BETTER_AUTH_SECRET` is missing, or the secret is +shorter than 32 characters. Set all three and restart. On Docker, recreate the +container so Compose reapplies `.env`: + +```bash +docker compose up -d --force-recreate open-seo +``` + +**`access_denied` during sign-in** — the Google account isn't listed as a test +user on the OAuth consent screen (while the app is in Testing mode). Add it under +**OAuth consent screen → Test users**. + +**Connected, but no properties to pick** — the Google account you authorized +doesn't have a verified property in Search Console. Verify the site in +[Search Console](https://search.google.com/search-console) first, then reconnect. diff --git a/drizzle/0020_drop_delegated_users.sql b/drizzle/0020_drop_delegated_users.sql new file mode 100644 index 0000000..1d0c005 --- /dev/null +++ b/drizzle/0020_drop_delegated_users.sql @@ -0,0 +1,11 @@ +INSERT OR IGNORE INTO `user` (`id`, `name`, `email`, `email_verified`, `created_at`, `updated_at`) +SELECT + `id`, + coalesce(nullif(substr(`email`, 1, instr(`email`, '@') - 1), ''), `email`), + `email`, + 1, + cast(unixepoch(`created_at`) * 1000 as integer), + cast(unixepoch(`created_at`) * 1000 as integer) +FROM `delegated_users`; +--> statement-breakpoint +DROP TABLE `delegated_users`; diff --git a/drizzle/meta/0020_snapshot.json b/drizzle/meta/0020_snapshot.json new file mode 100644 index 0000000..cfc85bc --- /dev/null +++ b/drizzle/meta/0020_snapshot.json @@ -0,0 +1,2528 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "53cb192d-34e5-4e32-b43a-ed3b6ddfc8ea", + "prevId": "a19ea19a-33fb-4180-bf79-fa704388d24a", + "tables": { + "audit_lighthouse_results": { + "name": "audit_lighthouse_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "performance_score": { + "name": "performance_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessibility_score": { + "name": "accessibility_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "best_practices_score": { + "name": "best_practices_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seo_score": { + "name": "seo_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lcp_ms": { + "name": "lcp_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cls": { + "name": "cls", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inp_ms": { + "name": "inp_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_size_bytes": { + "name": "payload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_lighthouse_results_audit_id_idx": { + "name": "audit_lighthouse_results_audit_id_idx", + "columns": [ + "audit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_lighthouse_results_audit_id_audits_id_fk": { + "name": "audit_lighthouse_results_audit_id_audits_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_lighthouse_results_page_id_audit_pages_id_fk": { + "name": "audit_lighthouse_results_page_id_audit_pages_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_pages": { + "name": "audit_pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "robots_meta": { + "name": "robots_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_title": { + "name": "og_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_description": { + "name": "og_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_image": { + "name": "og_image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h1_count": { + "name": "h1_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h2_count": { + "name": "h2_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h3_count": { + "name": "h3_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h4_count": { + "name": "h4_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h5_count": { + "name": "h5_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h6_count": { + "name": "h6_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "heading_order_json": { + "name": "heading_order_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_total": { + "name": "images_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_missing_alt": { + "name": "images_missing_alt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_json": { + "name": "images_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "internal_link_count": { + "name": "internal_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "external_link_count": { + "name": "external_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "has_structured_data": { + "name": "has_structured_data", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "hreflang_tags_json": { + "name": "hreflang_tags_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_indexable": { + "name": "is_indexable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_pages_audit_id_idx": { + "name": "audit_pages_audit_id_idx", + "columns": [ + "audit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_pages_audit_id_audits_id_fk": { + "name": "audit_pages_audit_id_audits_id_fk", + "tableFrom": "audit_pages", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audits": { + "name": "audits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_url": { + "name": "start_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "pages_crawled": { + "name": "pages_crawled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pages_total": { + "name": "pages_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_total": { + "name": "lighthouse_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_completed": { + "name": "lighthouse_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_failed": { + "name": "lighthouse_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "current_phase": { + "name": "current_phase", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'discovery'" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audits_project_id_idx": { + "name": "audits_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "audits_started_by_user_id_idx": { + "name": "audits_started_by_user_id_idx", + "columns": [ + "started_by_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audits_project_id_projects_id_fk": { + "name": "audits_project_id_projects_id_fk", + "tableFrom": "audits", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "keyword_metrics": { + "name": "keyword_metrics", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competition": { + "name": "competition", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_searches": { + "name": "monthly_searches", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "keyword_metrics_unique_project_keyword_location_language": { + "name": "keyword_metrics_unique_project_keyword_location_language", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code" + ], + "isUnique": true + }, + "keyword_metrics_lookup_idx": { + "name": "keyword_metrics_lookup_idx", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code", + "fetched_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "keyword_metrics_project_id_projects_id_fk": { + "name": "keyword_metrics_project_id_projects_id_fk", + "tableFrom": "keyword_metrics", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "projects_one_default_per_organization_idx": { + "name": "projects_one_default_per_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": true, + "where": "\"projects\".\"name\" = 'Default' AND \"projects\".\"domain\" IS NULL" + } + }, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_check_runs": { + "name": "rank_check_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "keywords_total": { + "name": "keywords_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "keywords_checked": { + "name": "keywords_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_subset_run": { + "name": "is_subset_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rank_check_runs_config_idx": { + "name": "rank_check_runs_config_idx", + "columns": [ + "config_id", + "started_at" + ], + "isUnique": false + }, + "rank_check_runs_project_idx": { + "name": "rank_check_runs_project_idx", + "columns": [ + "project_id", + "started_at" + ], + "isUnique": false + }, + "rank_check_runs_one_active_per_config_idx": { + "name": "rank_check_runs_one_active_per_config_idx", + "columns": [ + "config_id" + ], + "isUnique": true, + "where": "\"rank_check_runs\".\"status\" IN ('pending', 'running')" + } + }, + "foreignKeys": { + "rank_check_runs_config_id_rank_tracking_configs_id_fk": { + "name": "rank_check_runs_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rank_check_runs_project_id_projects_id_fk": { + "name": "rank_check_runs_project_id_projects_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_snapshots": { + "name": "rank_snapshots", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tracking_keyword_id": { + "name": "tracking_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "serp_features": { + "name": "serp_features", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "rank_snapshots_run_idx": { + "name": "rank_snapshots_run_idx", + "columns": [ + "run_id" + ], + "isUnique": false + }, + "rank_snapshots_keyword_device_idx": { + "name": "rank_snapshots_keyword_device_idx", + "columns": [ + "tracking_keyword_id", + "device", + "checked_at" + ], + "isUnique": false + }, + "rank_snapshots_run_keyword_device_idx": { + "name": "rank_snapshots_run_keyword_device_idx", + "columns": [ + "run_id", + "tracking_keyword_id", + "device" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rank_snapshots_run_id_rank_check_runs_id_fk": { + "name": "rank_snapshots_run_id_rank_check_runs_id_fk", + "tableFrom": "rank_snapshots", + "tableTo": "rank_check_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_tracking_configs": { + "name": "rank_tracking_configs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "devices": { + "name": "devices", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'both'" + }, + "serp_depth": { + "name": "serp_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_interval": { + "name": "schedule_interval", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'weekly'" + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_check_at": { + "name": "next_check_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_skip_reason": { + "name": "last_skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "rank_tracking_configs_project_domain_location_idx": { + "name": "rank_tracking_configs_project_domain_location_idx", + "columns": [ + "project_id", + "domain", + "location_code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rank_tracking_configs_project_id_projects_id_fk": { + "name": "rank_tracking_configs_project_id_projects_id_fk", + "tableFrom": "rank_tracking_configs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_tracking_keywords": { + "name": "rank_tracking_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metrics_fetched_at": { + "name": "metrics_fetched_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "rank_tracking_keywords_config_keyword_idx": { + "name": "rank_tracking_keywords_config_keyword_idx", + "columns": [ + "config_id", + "keyword" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk": { + "name": "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_tracking_keywords", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keyword_tag_assignments": { + "name": "saved_keyword_tag_assignments", + "columns": { + "saved_keyword_id": { + "name": "saved_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "saved_keyword_tag_assignments_unique_idx": { + "name": "saved_keyword_tag_assignments_unique_idx", + "columns": [ + "saved_keyword_id", + "tag_id" + ], + "isUnique": true + }, + "saved_keyword_tag_assignments_keyword_idx": { + "name": "saved_keyword_tag_assignments_keyword_idx", + "columns": [ + "saved_keyword_id" + ], + "isUnique": false + }, + "saved_keyword_tag_assignments_tag_idx": { + "name": "saved_keyword_tag_assignments_tag_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk": { + "name": "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keywords", + "columnsFrom": [ + "saved_keyword_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk": { + "name": "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keyword_tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keyword_tags": { + "name": "saved_keyword_tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_name": { + "name": "normalized_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "saved_keyword_tags_project_normalized_name_idx": { + "name": "saved_keyword_tags_project_normalized_name_idx", + "columns": [ + "project_id", + "normalized_name" + ], + "isUnique": true + }, + "saved_keyword_tags_project_name_idx": { + "name": "saved_keyword_tags_project_name_idx", + "columns": [ + "project_id", + "name" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keyword_tags_project_id_projects_id_fk": { + "name": "saved_keyword_tags_project_id_projects_id_fk", + "tableFrom": "saved_keyword_tags", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keywords": { + "name": "saved_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "saved_keywords_unique_project_keyword_location_language": { + "name": "saved_keywords_unique_project_keyword_location_language", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code" + ], + "isUnique": true + }, + "saved_keywords_project_created_idx": { + "name": "saved_keywords_project_created_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keywords_project_id_projects_id_fk": { + "name": "saved_keywords_project_id_projects_id_fk", + "tableFrom": "saved_keywords", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_onboarding_answers": { + "name": "user_onboarding_answers", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interested_features": { + "name": "interested_features", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "work_for": { + "name": "work_for", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_website_count": { + "name": "client_website_count", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "found_via": { + "name": "found_via", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_setup_intent": { + "name": "mcp_setup_intent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gsc_nudge_dismissed_at": { + "name": "gsc_nudge_dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "user_onboarding_answers_organization_idx": { + "name": "user_onboarding_answers_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_onboarding_answers_user_id_user_id_fk": { + "name": "user_onboarding_answers_user_id_user_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_onboarding_answers_organization_id_organization_id_fk": { + "name": "user_onboarding_answers_organization_id_organization_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "analytics_opted_out": { + "name": "analytics_opted_out", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "gsc_connections": { + "name": "gsc_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_account_email": { + "name": "connected_account_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "gsc_connections_project_idx": { + "name": "gsc_connections_project_idx", + "columns": [ + "project_id" + ], + "isUnique": true + }, + "gsc_connections_organization_idx": { + "name": "gsc_connections_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "gsc_connections_project_id_projects_id_fk": { + "name": "gsc_connections_project_id_projects_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gsc_connections_organization_id_organization_id_fk": { + "name": "gsc_connections_organization_id_organization_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reddit_attributions": { + "name": "reddit_attributions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "click_id": { + "name": "click_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "landing_page": { + "name": "landing_page", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signup_sent_at": { + "name": "signup_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purchase_sent_at": { + "name": "purchase_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "reddit_attributions_user_idx": { + "name": "reddit_attributions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": true + }, + "reddit_attributions_organization_idx": { + "name": "reddit_attributions_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "reddit_attributions_user_id_user_id_fk": { + "name": "reddit_attributions_user_id_user_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reddit_attributions_organization_id_organization_id_fk": { + "name": "reddit_attributions_organization_id_organization_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c7c3c1c..7d41d03 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1780519331717, "tag": "0019_true_absorbing_man", "breakpoints": true + }, + { + "idx": 20, + "version": "6", + "when": 1780599087400, + "tag": "0020_drop_delegated_users", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/client/features/gsc/GscReEngagementModal.tsx b/src/client/features/gsc/GscReEngagementModal.tsx index 0cb80ac..0d77382 100644 --- a/src/client/features/gsc/GscReEngagementModal.tsx +++ b/src/client/features/gsc/GscReEngagementModal.tsx @@ -11,9 +11,10 @@ import { dismissGscNudge } from "@/serverFunctions/onboarding"; /** * One-time re-engagement prompt nudging users who finished onboarding *before* - * the Search Console step existed to connect GSC. Hosted-only (the connect flow - * needs Better Auth). Shows once — server-persisted dismissal means it never - * reappears after the user connects or dismisses, on any device. + * the Search Console step existed to connect GSC. Hosted-only because this is + * a hosted onboarding re-engagement nudge. Shows once — server-persisted + * dismissal means it never reappears after the user connects or dismisses, on + * any device. * * `suppressed` lets the layout hide this when another modal (e.g. the missing * DataForSEO key prompt) is already showing so the two never stack. diff --git a/src/client/features/gsc/SearchConsoleConnectionCard.tsx b/src/client/features/gsc/SearchConsoleConnectionCard.tsx index be79adc..7fb915c 100644 --- a/src/client/features/gsc/SearchConsoleConnectionCard.tsx +++ b/src/client/features/gsc/SearchConsoleConnectionCard.tsx @@ -5,6 +5,7 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { captureClientEvent } from "@/client/lib/posthog"; import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph"; +import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning"; import { SitePicker } from "@/client/features/gsc/SitePicker"; import { startGscLink } from "@/client/features/gsc/startGscLink"; import { @@ -30,16 +31,17 @@ export function SearchConsoleConnectionCard({ const connectionQuery = useQuery({ queryKey: connectionKey, queryFn: () => getGscConnection({ data: { projectId } }), - enabled: hosted, }); const connection = connectionQuery.data; const connected = Boolean(connection?.connected); + const selfHostedNeedsSetup = + !hosted && connectionQuery.isSuccess && !connection?.googleOAuthConfigured; const showPicker = picking || (connection?.currentUserHasGrant && !connected); const sitesQuery = useQuery({ queryKey: ["gscSites", projectId], queryFn: () => listGscSites({ data: { projectId } }), - enabled: Boolean(showPicker), + enabled: Boolean(showPicker && !selfHostedNeedsSetup), }); const setSiteMutation = useMutation({ @@ -70,24 +72,16 @@ export function SearchConsoleConnectionCard({ const handleConnect = () => void startGscLink(window.location.href); - if (!hosted) { - return ( - -

- Available on hosted OpenSEO. Self-hosted? Use a CSV export. -

-
- ); - } - return ( {connectionQuery.isLoading ? ( @@ -95,6 +89,8 @@ export function SearchConsoleConnectionCard({ Checking… + ) : selfHostedNeedsSetup ? ( + ) : connected && !picking ? ( - {connected ? "Connected" : "Not connected"} + {connected + ? "Connected" + : setupRequired + ? "Setup required" + : "Not connected"} ); } diff --git a/src/client/features/gsc/SelfHostedSetupWarning.tsx b/src/client/features/gsc/SelfHostedSetupWarning.tsx new file mode 100644 index 0000000..e664120 --- /dev/null +++ b/src/client/features/gsc/SelfHostedSetupWarning.tsx @@ -0,0 +1,27 @@ +import { AlertTriangle } from "lucide-react"; +import { SafeExternalLink } from "@/client/components/SafeExternalLink"; +import { GSC_SELF_HOSTED_SETUP_DOCS_URL } from "@/shared/gsc"; + +/** + * Shown in self-hosted deployments that haven't set GOOGLE_CLIENT_ID/SECRET yet + * — in both the Integrations card and the onboarding step. + */ +export function SelfHostedSetupWarning() { + return ( +
+ +
+

Google OAuth client not configured

+

+ Add your Google client ID and secret to this OpenSEO deployment before + connecting Search Console. +

+ +
+
+ ); +} diff --git a/src/client/features/gsc/startGscLink.ts b/src/client/features/gsc/startGscLink.ts index 4de8837..71a7b42 100644 --- a/src/client/features/gsc/startGscLink.ts +++ b/src/client/features/gsc/startGscLink.ts @@ -1,6 +1,8 @@ import { toast } from "sonner"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { authClient } from "@/lib/auth-client"; +import { isHostedClientAuthMode } from "@/lib/auth-mode"; +import { startSelfHostedGscLink } from "@/serverFunctions/gsc"; import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc"; /** @@ -12,6 +14,12 @@ import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc"; */ export async function startGscLink(callbackURL: string): Promise { try { + if (!isHostedClientAuthMode()) { + const res = await startSelfHostedGscLink({ data: { callbackURL } }); + window.location.href = res.url; + return; + } + const res = await authClient.oauth2.link({ providerId: GSC_OAUTH_PROVIDER_ID, callbackURL, diff --git a/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx b/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx index 506bce9..3b7104e 100644 --- a/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx +++ b/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx @@ -3,11 +3,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check } from "lucide-react"; import { toast } from "sonner"; import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph"; +import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning"; import { SitePicker } from "@/client/features/gsc/SitePicker"; import { startGscLink } from "@/client/features/gsc/startGscLink"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { captureClientEvent } from "@/client/lib/posthog"; -import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { getGscConnection, listGscSites, @@ -19,14 +19,12 @@ import { getOrCreateDefaultProject } from "@/serverFunctions/projects"; * Onboarding step for connecting Google Search Console: link the account-level * OAuth grant, then bind a verified property to the user's default project — * the same binding the project's Integrations page does — so it's done in one - * place. Hosted-only (the connect flow needs Better Auth). + * place. */ export function SearchConsoleOnboardingStep() { - const hosted = isHostedClientAuthMode(); const projectQuery = useQuery({ queryKey: ["defaultProject"], queryFn: () => getOrCreateDefaultProject(), - enabled: hosted, }); return ( @@ -35,11 +33,7 @@ export function SearchConsoleOnboardingStep() { Connect with Google Search Console now? - {!hosted ? ( -

- Available on hosted OpenSEO. Self-hosted? Use a CSV export. -

- ) : projectQuery.data ? ( + {projectQuery.data ? ( ) : ( @@ -66,11 +60,13 @@ function GscConnect({ projectId }: { projectId: string }) { const connection = connectionQuery.data; const connected = Boolean(connection?.connected); const hasGrant = Boolean(connection?.currentUserHasGrant); + const needsSetup = + connectionQuery.isSuccess && !connection?.googleOAuthConfigured; const sitesQuery = useQuery({ queryKey: ["gscSites", projectId], queryFn: () => listGscSites({ data: { projectId } }), - enabled: hasGrant && !connected, + enabled: hasGrant && !connected && !needsSetup, }); const setSiteMutation = useMutation({ @@ -90,6 +86,10 @@ function GscConnect({ projectId }: { projectId: string }) { if (connectionQuery.isLoading) return ; + if (needsSetup) { + return ; + } + if (connected) { return (
diff --git a/src/db/app.schema.ts b/src/db/app.schema.ts index 3a1cbf9..c75deb0 100644 --- a/src/db/app.schema.ts +++ b/src/db/app.schema.ts @@ -9,16 +9,6 @@ import { import { sql } from "drizzle-orm"; import { organization, user } from "./better-auth-schema"; -// This stores users for Cloudflare Access and local_noauth mode -// since they don't map to better-auth's user schema -export const delegatedUsers = sqliteTable("delegated_users", { - id: text("id").primaryKey(), - email: text("email").notNull().unique(), - createdAt: text("created_at") - .notNull() - .default(sql`(current_timestamp)`), -}); - export const userOnboardingAnswers = sqliteTable( "user_onboarding_answers", { diff --git a/src/db/gsc.schema.ts b/src/db/gsc.schema.ts index 6257581..8e5596e 100644 --- a/src/db/gsc.schema.ts +++ b/src/db/gsc.schema.ts @@ -3,7 +3,7 @@ import { sql } from "drizzle-orm"; import { organization } from "./better-auth-schema"; import { projects } from "./app.schema"; -// Connected Google Search Console property per project (hosted-only). +// Connected Google Search Console property per project. // OAuth tokens live in the better-auth `account` table under providerId // "google-search-console"; this row only records which verified property maps // to a project and whose grant to use when calling the GSC API. diff --git a/src/lib/auth-config.ts b/src/lib/auth-config.ts index a53abb2..d34d77c 100644 --- a/src/lib/auth-config.ts +++ b/src/lib/auth-config.ts @@ -1,16 +1,7 @@ import { env } from "cloudflare:workers"; import { genericOAuth, organization } from "better-auth/plugins"; import { baseAuthOptions } from "@/lib/auth-options"; -import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc"; - -/** Read-only Search Console scope. openid/email/profile are also required — - * the genericOAuth callback rejects with `name_is_missing` without a name claim. */ -const GSC_OAUTH_SCOPES = [ - "openid", - "email", - "profile", - "https://www.googleapis.com/auth/webmasters.readonly", -]; +import { GSC_OAUTH_PROVIDER_ID, GSC_OAUTH_SCOPES } from "@/shared/gsc"; export function createBaseAuthConfig() { return { @@ -35,7 +26,7 @@ export function createBaseAuthConfig() { clientSecret: env.GOOGLE_CLIENT_SECRET?.trim() ?? "", discoveryUrl: "https://accounts.google.com/.well-known/openid-configuration", - scopes: GSC_OAUTH_SCOPES, + scopes: [...GSC_OAUTH_SCOPES], accessType: "offline", // request a refresh token prompt: "consent", // force refresh-token issuance on re-consent pkce: true, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 98791c1..2ed3d8a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -4,6 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { tanstackStartCookies } from "better-auth/tanstack-start"; import { db } from "@/db"; import { z } from "zod"; +import { isHostedAuthMode } from "@/lib/auth-mode"; import { createBaseAuthConfig } from "@/lib/auth-config"; import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization"; import { @@ -24,7 +25,12 @@ const hostedBaseUrlSchema = z }, "BETTER_AUTH_URL must use https or localhost"); function createAuth() { - const baseUrl = getHostedBaseUrl(); + // Hosted needs the real configured URL (cookies, callbacks, /api/auth routes + // all use it). Self-hosted only builds this instance to mint/refresh Search + // Console tokens, which never read baseURL — so a placeholder is fine there. + const baseUrl = isHostedAuthMode(env.AUTH_MODE) + ? getHostedBaseUrl() + : "http://localhost"; const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true"; const baseAuthConfig = createBaseAuthConfig(); @@ -142,11 +148,14 @@ export function getHostedBaseUrl() { return hostedBaseUrlSchema.parse(baseUrl); } +// Required in hosted mode, and in self-hosted mode when Search Console is +// enabled (it keys the OAuth-token encryption and is needed to build the auth +// instance that mints/refreshes Search Console tokens). function getHostedSecret() { const secret = env.BETTER_AUTH_SECRET?.trim(); if (!secret) { - throw new Error("BETTER_AUTH_SECRET is required in hosted mode"); + throw new Error("BETTER_AUTH_SECRET is required"); } if (secret.length < 32) { @@ -157,6 +166,15 @@ function getHostedSecret() { } function getSocialProviders() { + // Google social login is hosted-only. Self-hosted builds the auth instance + // solely for Search Console token ops, which use the genericOAuth provider + // (createBaseAuthConfig) with its own creds — so it must NOT require the + // social-login config here, otherwise getAuth() construction would be coupled + // to GSC creds rather than just BETTER_AUTH_SECRET. + if (!isHostedAuthMode(env.AUTH_MODE)) { + return {}; + } + return { google: getGoogleSocialProviderConfig(), }; diff --git a/src/middleware/ensure-user/delegated.ts b/src/middleware/ensure-user/delegated.ts index 910f747..ebadc61 100644 --- a/src/middleware/ensure-user/delegated.ts +++ b/src/middleware/ensure-user/delegated.ts @@ -1,5 +1,5 @@ import { db } from "@/db"; -import { delegatedUsers } from "@/db/schema"; +import { user } from "@/db/schema"; import { ensureDelegatedOrganizationForUser } from "@/server/auth/delegated-organization"; import { eq } from "drizzle-orm"; import type { EnsuredUserContext } from "./types"; @@ -7,30 +7,49 @@ import type { EnsuredUserContext } from "./types"; const LOCAL_ADMIN_USER_ID = "local-admin"; const LOCAL_ADMIN_EMAIL = "admin@localhost"; +// Externally-authenticated users (Cloudflare Access, local_noauth) are stored +// in better-auth's `user` table just like hosted users — only the way we +// authenticate them differs (per-request, no better-auth session). Keeping a +// single user table means the OAuth `account` grant and every app table that +// references `user.id` resolve the same way in all auth modes. +function deriveUserName(email: string) { + return email.split("@")[0] || "OpenSEO"; +} + async function ensureUserRecord(userId: string, userEmail: string) { - const existingUser = await db.query.delegatedUsers.findFirst({ - where: eq(delegatedUsers.id, userId), + const existing = await db.query.user.findFirst({ + columns: { email: true }, + where: eq(user.id, userId), }); - if (!existingUser) { - await db.insert(delegatedUsers).values({ - id: userId, - email: userEmail, - }); - - return userEmail; - } - - if (existingUser.email !== userEmail) { + if (!existing) { + // Concurrent first-load requests can all see "no row" and race to insert + // the same id; onConflictDoNothing on the PK makes the losers no-ops instead + // of failing. Scoped to the id so a genuine email-unique collision (two + // distinct ids sharing an email) still surfaces loudly. await db - .update(delegatedUsers) - .set({ email: userEmail }) - .where(eq(delegatedUsers.id, userId)); + .insert(user) + .values({ + id: userId, + name: deriveUserName(userEmail), + email: userEmail, + emailVerified: true, + }) + .onConflictDoNothing({ target: user.id }); return userEmail; } - return existingUser.email; + if (existing.email !== userEmail) { + await db + .update(user) + .set({ email: userEmail, name: deriveUserName(userEmail) }) + .where(eq(user.id, userId)); + + return userEmail; + } + + return existing.email; } export async function resolveDelegatedContext( diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b4fa29f..64eabf0 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -29,6 +29,7 @@ import { Route as AppAiRouteImport } from './routes/_app/ai' import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge' import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' +import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback' import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key' import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route' import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index' @@ -144,6 +145,11 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ path: '/api/auth/$', getParentRoute: () => rootRouteImport, } as any) +const ApiGscOauthCallbackRoute = ApiGscOauthCallbackRouteImport.update({ + id: '/api/gsc/oauth/callback', + path: '/api/gsc/oauth/callback', + getParentRoute: () => rootRouteImport, +} as any) const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({ id: '/help/dataforseo-api-key', path: '/help/dataforseo-api-key', @@ -254,6 +260,7 @@ export interface FileRoutesByFullPath { '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute + '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute @@ -287,6 +294,7 @@ export interface FileRoutesByTo { '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute + '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute @@ -324,6 +332,7 @@ export interface FileRoutesById { '/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute + '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute @@ -360,6 +369,7 @@ export interface FileRouteTypes { | '/help/dataforseo-api-key' | '/api/auth/$' | '/api/autumn/$' + | '/api/gsc/oauth/callback' | '/p/$projectId/audit' | '/p/$projectId/backlinks' | '/p/$projectId/brand-lookup' @@ -393,6 +403,7 @@ export interface FileRouteTypes { | '/help/dataforseo-api-key' | '/api/auth/$' | '/api/autumn/$' + | '/api/gsc/oauth/callback' | '/p/$projectId/backlinks' | '/p/$projectId/brand-lookup' | '/p/$projectId/domain' @@ -429,6 +440,7 @@ export interface FileRouteTypes { | '/_app/help/dataforseo-api-key' | '/api/auth/$' | '/api/autumn/$' + | '/api/gsc/oauth/callback' | '/_project/p/$projectId/audit' | '/_project/p/$projectId/backlinks' | '/_project/p/$projectId/brand-lookup' @@ -456,6 +468,7 @@ export interface RootRouteChildren { Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute + ApiGscOauthCallbackRoute: typeof ApiGscOauthCallbackRoute } declare module '@tanstack/react-router' { @@ -600,6 +613,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAuthSplatRouteImport parentRoute: typeof rootRouteImport } + '/api/gsc/oauth/callback': { + id: '/api/gsc/oauth/callback' + path: '/api/gsc/oauth/callback' + fullPath: '/api/gsc/oauth/callback' + preLoaderRoute: typeof ApiGscOauthCallbackRouteImport + parentRoute: typeof rootRouteImport + } '/_app/help/dataforseo-api-key': { id: '/_app/help/dataforseo-api-key' path: '/help/dataforseo-api-key' @@ -857,6 +877,7 @@ const rootRouteChildren: RootRouteChildren = { Char91DotwellKnownChar93OpenaiAppsChallengeRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAutumnSplatRoute: ApiAutumnSplatRoute, + ApiGscOauthCallbackRoute: ApiGscOauthCallbackRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/api/gsc/oauth/callback.ts b/src/routes/api/gsc/oauth/callback.ts new file mode 100644 index 0000000..2b2ebb7 --- /dev/null +++ b/src/routes/api/gsc/oauth/callback.ts @@ -0,0 +1,62 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { env } from "cloudflare:workers"; +import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode"; +import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess"; +import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated"; +import { AppError } from "@/server/lib/errors"; +import { handleSelfHostedGscOAuthCallback } from "@/server/features/gsc/selfHostedOAuth"; +import { getPublicOrigin } from "@/server/mcp/public-origin"; + +async function resolveSelfHostedContext(request: Request) { + const authMode = getAuthMode(env.AUTH_MODE); + + if (isHostedAuthMode(authMode)) return null; + + return authMode === "local_noauth" + ? resolveLocalNoAuthContext() + : resolveCloudflareAccessContext(request.headers); +} + +function responseForError(error: unknown) { + if (error instanceof AppError) { + const status = + error.code === "UNAUTHENTICATED" + ? 401 + : error.code === "FORBIDDEN" + ? 403 + : error.code === "VALIDATION_ERROR" + ? 400 + : 500; + return new Response(error.message, { status }); + } + + return new Response("Search Console OAuth failed", { status: 500 }); +} + +async function handleCallbackRequest(request: Request) { + try { + const context = await resolveSelfHostedContext(request); + if (!context) return new Response("Not found", { status: 404 }); + + return await handleSelfHostedGscOAuthCallback({ + request, + user: { + userId: context.userId, + userEmail: context.userEmail, + }, + publicOrigin: getPublicOrigin(request), + }); + } catch (error) { + return responseForError(error); + } +} + +export const Route = createFileRoute("/api/gsc/oauth/callback")({ + server: { + handlers: { + GET: async ({ request }: { request: Request }) => { + return handleCallbackRequest(request); + }, + }, + }, +}); diff --git a/src/server/features/gsc/oauth-config.ts b/src/server/features/gsc/oauth-config.ts new file mode 100644 index 0000000..cc0e2ab --- /dev/null +++ b/src/server/features/gsc/oauth-config.ts @@ -0,0 +1,28 @@ +import { getOptionalEnvValue } from "@/server/lib/runtime-env"; + +type GscOAuthClientConfig = { + clientId: string; + clientSecret: string; +}; + +export async function getGscOAuthClientConfig(): Promise { + const clientId = (await getOptionalEnvValue("GOOGLE_CLIENT_ID"))?.trim(); + const clientSecret = ( + await getOptionalEnvValue("GOOGLE_CLIENT_SECRET") + )?.trim(); + + if (!clientId || !clientSecret) return null; + + return { clientId, clientSecret }; +} + +// Self-hosted Search Console needs the Google OAuth client AND BETTER_AUTH_SECRET +// (>=32 chars): the secret keys OAuth-token encryption and lets us build the +// Better Auth instance that mints/refreshes tokens. Both must be set before we +// surface the connect flow. +export async function hasSelfHostedGscConfig(): Promise { + if (!(await getGscOAuthClientConfig())) return false; + + const secret = (await getOptionalEnvValue("BETTER_AUTH_SECRET"))?.trim(); + return Boolean(secret && secret.length >= 32); +} diff --git a/src/server/features/gsc/selfHostedOAuth.ts b/src/server/features/gsc/selfHostedOAuth.ts new file mode 100644 index 0000000..67dbea3 --- /dev/null +++ b/src/server/features/gsc/selfHostedOAuth.ts @@ -0,0 +1,334 @@ +import { symmetricEncrypt } from "better-auth/crypto"; +import { and, eq } from "drizzle-orm"; +import { decodeJwt } from "jose"; +import { z } from "zod"; +import { db } from "@/db"; +import { account } from "@/db/schema"; +import { getAuth } from "@/lib/auth"; +import { AppError } from "@/server/lib/errors"; +import { GSC_OAUTH_PROVIDER_ID, GSC_OAUTH_SCOPES } from "@/shared/gsc"; +import { + getGscOAuthClientConfig, + hasSelfHostedGscConfig, +} from "./oauth-config"; + +const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; + +type SelfHostedGscUser = { + userId: string; + userEmail: string; +}; + +const oauthStateSchema = z.object({ + userId: z.string().min(1), + callbackPath: z.string().min(1), + exp: z.number().int(), +}); + +const googleTokenResponseSchema = z.object({ + access_token: z.string().min(1), + expires_in: z.number().optional(), + refresh_token: z.string().optional(), + scope: z.string().optional(), + id_token: z.string().optional(), + token_type: z.string().optional(), +}); + +const googleIdTokenSchema = z.object({ + sub: z.string().min(1), +}); + +type GoogleTokenResponse = z.infer; + +function bytesToBase64Url(bytes: Uint8Array) { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); +} + +function base64UrlToBytes(value: string) { + const padded = `${value}${"=".repeat((4 - (value.length % 4)) % 4)}`; + const binary = atob(padded.replaceAll("-", "+").replaceAll("_", "/")); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} + +async function getStateKey(clientSecret: string) { + return crypto.subtle.importKey( + "raw", + new TextEncoder().encode(`openseo:gsc:${clientSecret}`), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); +} + +async function signState(payload: string, clientSecret: string) { + const signature = await crypto.subtle.sign( + "HMAC", + await getStateKey(clientSecret), + new TextEncoder().encode(payload), + ); + return bytesToBase64Url(new Uint8Array(signature)); +} + +function getSafeCallbackPath(callbackURL: string, publicOrigin: string) { + try { + const url = new URL(callbackURL, publicOrigin); + if (url.origin !== publicOrigin) return "/"; + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return "/"; + } +} + +async function createState(input: { + clientSecret: string; + userId: string; + callbackURL: string; + publicOrigin: string; +}) { + const payload = bytesToBase64Url( + new TextEncoder().encode( + JSON.stringify({ + userId: input.userId, + callbackPath: getSafeCallbackPath( + input.callbackURL, + input.publicOrigin, + ), + exp: Date.now() + 10 * 60 * 1_000, + }), + ), + ); + const signature = await signState(payload, input.clientSecret); + return `${payload}.${signature}`; +} + +async function verifyState(state: string, clientSecret: string) { + const [payload, signature] = state.split("."); + if (!payload || !signature) { + throw new AppError("VALIDATION_ERROR", "Invalid Search Console state"); + } + + const ok = await crypto.subtle.verify( + "HMAC", + await getStateKey(clientSecret), + base64UrlToBytes(signature), + new TextEncoder().encode(payload), + ); + if (!ok) { + throw new AppError("VALIDATION_ERROR", "Invalid Search Console state"); + } + + const parsed = oauthStateSchema.parse( + JSON.parse(new TextDecoder().decode(base64UrlToBytes(payload))), + ); + if (parsed.exp < Date.now()) { + throw new AppError("VALIDATION_ERROR", "Expired Search Console state"); + } + + return parsed; +} + +function getRedirectUri(publicOrigin: string) { + return `${publicOrigin}/api/gsc/oauth/callback`; +} + +function accessTokenExpiresAt(tokens: GoogleTokenResponse) { + return new Date(Date.now() + (tokens.expires_in ?? 3600) * 1_000); +} + +function storedScope(tokens: GoogleTokenResponse) { + return tokens.scope + ? tokens.scope.trim().split(/\s+/).join(",") + : GSC_OAUTH_SCOPES.join(","); +} + +function getGoogleAccountId(tokens: GoogleTokenResponse) { + if (!tokens.id_token) { + throw new AppError( + "VALIDATION_ERROR", + "Google did not return an ID token for Search Console.", + ); + } + + return googleIdTokenSchema.parse(decodeJwt(tokens.id_token)).sub; +} + +async function upsertGrant(input: { + user: SelfHostedGscUser; + tokens: GoogleTokenResponse; +}) { + // Encrypt tokens at rest exactly the way Better Auth's setTokenUtil does + // (same key from BETTER_AUTH_SECRET, same crypto, same encryptOAuthTokens + // gate), so getAccessToken decrypts them on read — and so flipping the flag + // can never desync the write and read paths. + const ctx = await getAuth().$context; + const encrypt = (value: string) => + ctx.options.account?.encryptOAuthTokens + ? symmetricEncrypt({ key: ctx.secretConfig, data: value }) + : value; + + const existing = await db + .select({ id: account.id, refreshToken: account.refreshToken }) + .from(account) + .where( + and( + eq(account.userId, input.user.userId), + eq(account.providerId, GSC_OAUTH_PROVIDER_ID), + ), + ) + .limit(1); + + const accountValues = { + accountId: getGoogleAccountId(input.tokens), + providerId: GSC_OAUTH_PROVIDER_ID, + userId: input.user.userId, + accessToken: await encrypt(input.tokens.access_token), + // A fresh refresh token is encrypted here; an absent one falls back to the + // already-encrypted value stored on the existing grant. + refreshToken: input.tokens.refresh_token + ? await encrypt(input.tokens.refresh_token) + : (existing[0]?.refreshToken ?? null), + idToken: input.tokens.id_token + ? await encrypt(input.tokens.id_token) + : null, + accessTokenExpiresAt: accessTokenExpiresAt(input.tokens), + refreshTokenExpiresAt: null, + scope: storedScope(input.tokens), + password: null, + }; + + if (existing[0]) { + await db + .update(account) + .set({ ...accountValues, updatedAt: new Date() }) + .where(eq(account.id, existing[0].id)); + return; + } + + await db.insert(account).values({ + id: crypto.randomUUID(), + ...accountValues, + createdAt: new Date(), + updatedAt: new Date(), + }); +} + +async function exchangeCode(input: { + code: string; + clientId: string; + clientSecret: string; + redirectUri: string; +}) { + const response = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code: input.code, + client_id: input.clientId, + client_secret: input.clientSecret, + redirect_uri: input.redirectUri, + grant_type: "authorization_code", + }), + }); + + if (!response.ok) { + throw new AppError( + "VALIDATION_ERROR", + "Google rejected the Search Console authorization code.", + ); + } + + return googleTokenResponseSchema.parse(await response.json()); +} + +export async function createSelfHostedGscAuthorizationUrl(input: { + user: SelfHostedGscUser; + callbackURL: string; + publicOrigin: string; +}) { + const config = await getGscOAuthClientConfig(); + if (!config || !(await hasSelfHostedGscConfig())) { + throw new AppError( + "AUTH_CONFIG_MISSING", + "Search Console is not configured. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET.", + ); + } + + const redirectUri = getRedirectUri(input.publicOrigin); + const state = await createState({ + clientSecret: config.clientSecret, + userId: input.user.userId, + callbackURL: input.callbackURL, + publicOrigin: input.publicOrigin, + }); + const url = new URL(GOOGLE_AUTH_URL); + url.searchParams.set("client_id", config.clientId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", GSC_OAUTH_SCOPES.join(" ")); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + url.searchParams.set("state", state); + + return url.toString(); +} + +export async function handleSelfHostedGscOAuthCallback(input: { + request: Request; + user: SelfHostedGscUser; + publicOrigin: string; +}) { + const config = await getGscOAuthClientConfig(); + if (!config) { + return new Response("Missing Google Search Console OAuth configuration", { + status: 500, + }); + } + + const url = new URL(input.request.url); + const stateParam = url.searchParams.get("state"); + if (!stateParam) { + return new Response("Missing Search Console OAuth state", { status: 400 }); + } + + const state = await verifyState(stateParam, config.clientSecret); + if (state.userId !== input.user.userId) { + return new Response("Search Console OAuth user mismatch", { status: 403 }); + } + + // state.callbackPath is a validated same-origin relative path + // (getSafeCallbackPath). Redirect with a *relative* Location so the browser + // resolves it against the real request origin — this avoids trusting + // x-forwarded-host for the final hop. + const redirectToCallback = () => + new Response(null, { + status: 303, + headers: { Location: state.callbackPath }, + }); + + if (url.searchParams.get("error")) { + return redirectToCallback(); + } + + const code = url.searchParams.get("code"); + if (!code) { + return new Response("Missing Search Console OAuth code", { status: 400 }); + } + + const tokens = await exchangeCode({ + code, + clientId: config.clientId, + clientSecret: config.clientSecret, + redirectUri: getRedirectUri(input.publicOrigin), + }); + await upsertGrant({ user: input.user, tokens }); + + return redirectToCallback(); +} diff --git a/src/server/lib/gscClient.ts b/src/server/lib/gscClient.ts index 9cb85ef..80f95c7 100644 --- a/src/server/lib/gscClient.ts +++ b/src/server/lib/gscClient.ts @@ -103,8 +103,10 @@ export function createGscClient(opts: { userId: string }) { async function getToken(): Promise { let result: { accessToken?: string } | undefined; try { - // Headerless call: getAccessToken trusts body.userId only when no request + // Headerless call: getAccessToken trusts body.userId when no request // session is present, and auto-refreshes via the genericOAuth provider. + // Works in every auth mode — self-hosted builds the same Better Auth + // instance once BETTER_AUTH_SECRET is set. result = await getAuth().api.getAccessToken({ body: { providerId: GSC_OAUTH_PROVIDER_ID, userId: opts.userId }, }); diff --git a/src/server/lib/runtime-env.ts b/src/server/lib/runtime-env.ts index eb3d4a2..13499b3 100644 --- a/src/server/lib/runtime-env.ts +++ b/src/server/lib/runtime-env.ts @@ -2,7 +2,9 @@ import { isHostedAuthMode } from "@/lib/auth-mode"; let workersEnvPromise: Promise | null> | null = null; -async function getEnvValue(name: string): Promise { +export async function getOptionalEnvValue( + name: string, +): Promise { const processValue = typeof process !== "undefined" ? process.env?.[name] : undefined; if (processValue) { @@ -15,7 +17,7 @@ async function getEnvValue(name: string): Promise { } export async function getRequiredEnvValue(name: string): Promise { - const value = await getEnvValue(name); + const value = await getOptionalEnvValue(name); if (!value) { throw new Error(`Missing required environment variable: ${name}`); } @@ -23,7 +25,7 @@ export async function getRequiredEnvValue(name: string): Promise { } export async function isHostedServerAuthMode(): Promise { - return isHostedAuthMode(await getEnvValue("AUTH_MODE")); + return isHostedAuthMode(await getOptionalEnvValue("AUTH_MODE")); } async function getWorkersEnv(): Promise | null> { diff --git a/src/server/mcp/tools/search-console-tools.test.ts b/src/server/mcp/tools/search-console-tools.test.ts index 9f07b07..9c8f9df 100644 --- a/src/server/mcp/tools/search-console-tools.test.ts +++ b/src/server/mcp/tools/search-console-tools.test.ts @@ -6,6 +6,7 @@ import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; const mocks = vi.hoisted(() => ({ getProjectForOrganization: vi.fn(), isHostedServerAuthMode: vi.fn(), + hasSelfHostedGscConfig: vi.fn(), GscService: { getPerformance: vi.fn(), inspectUrls: vi.fn(), @@ -33,6 +34,9 @@ vi.mock("cloudflare:workers", () => ({ env: {} })); vi.mock("@/server/lib/runtime-env", () => ({ isHostedServerAuthMode: mocks.isHostedServerAuthMode, })); +vi.mock("@/server/features/gsc/oauth-config", () => ({ + hasSelfHostedGscConfig: mocks.hasSelfHostedGscConfig, +})); vi.mock("@/server/features/projects/services/ProjectService", () => ({ ProjectService: { getProjectForOrganization: mocks.getProjectForOrganization, @@ -75,6 +79,8 @@ describe("search console MCP tools", () => { mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" }); mocks.isHostedServerAuthMode.mockReset(); mocks.isHostedServerAuthMode.mockResolvedValue(true); + mocks.hasSelfHostedGscConfig.mockReset(); + mocks.hasSelfHostedGscConfig.mockResolvedValue(false); mocks.GscService.getPerformance.mockReset(); mocks.GscService.inspectUrls.mockReset(); }); @@ -211,8 +217,9 @@ describe("search console MCP tools", () => { expect(mocks.GscService.getPerformance).not.toHaveBeenCalled(); }); - it("returns a hosted-only message in self-hosted mode", async () => { + it("returns a setup message in self-hosted mode without a Google client", async () => { mocks.isHostedServerAuthMode.mockResolvedValue(false); + mocks.hasSelfHostedGscConfig.mockResolvedValue(false); const { getSearchConsolePerformanceTool } = await import("./search-console-tools"); @@ -221,10 +228,40 @@ describe("search console MCP tools", () => { toolExtra, ); - expect(result.structuredContent).toMatchObject({ reason: "hosted_only" }); + expect(result.structuredContent).toMatchObject({ + reason: "gsc_oauth_not_configured", + }); expect(mocks.GscService.getPerformance).not.toHaveBeenCalled(); }); + it("allows performance queries in self-hosted mode with a Google client", async () => { + mocks.isHostedServerAuthMode.mockResolvedValue(false); + mocks.hasSelfHostedGscConfig.mockResolvedValue(true); + mocks.GscService.getPerformance.mockResolvedValue({ + siteUrl: "https://example.com/", + connectedBy: "alice@example.com", + request: { + dimensions: ["query"], + startDate: "2026-04-27", + endDate: "2026-05-25", + rowLimit: 1000, + }, + rows: [], + }); + const { getSearchConsolePerformanceTool } = + await import("./search-console-tools"); + + const result = await getSearchConsolePerformanceTool.handler( + { projectId: "project_1" }, + toolExtra, + ); + + expect(mocks.GscService.getPerformance).toHaveBeenCalledWith( + expect.objectContaining({ projectId: "project_1" }), + ); + expect(result.structuredContent).toMatchObject({ ok: true }); + }); + it("inspects multiple URLs and reports partial failures inline", async () => { mocks.GscService.inspectUrls.mockResolvedValue({ siteUrl: "sc-domain:example.com", @@ -285,8 +322,9 @@ describe("search console MCP tools", () => { }); }); - it("returns a hosted-only message for inspect_urls in self-hosted mode", async () => { + it("returns a setup message for inspect_urls in self-hosted mode without a Google client", async () => { mocks.isHostedServerAuthMode.mockResolvedValue(false); + mocks.hasSelfHostedGscConfig.mockResolvedValue(false); const { inspectUrlsTool } = await import("./search-console-tools"); const result = await inspectUrlsTool.handler( @@ -294,7 +332,9 @@ describe("search console MCP tools", () => { toolExtra, ); - expect(result.structuredContent).toMatchObject({ reason: "hosted_only" }); + expect(result.structuredContent).toMatchObject({ + reason: "gsc_oauth_not_configured", + }); expect(mocks.GscService.inspectUrls).not.toHaveBeenCalled(); }); }); diff --git a/src/server/mcp/tools/search-console-tools.ts b/src/server/mcp/tools/search-console-tools.ts index 88dbb4e..28521ed 100644 --- a/src/server/mcp/tools/search-console-tools.ts +++ b/src/server/mcp/tools/search-console-tools.ts @@ -6,6 +6,7 @@ import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { projectIdSchema } from "@/server/mcp/schemas"; import { buildDashboardUrl } from "@/server/mcp/urls"; +import { hasSelfHostedGscConfig } from "@/server/features/gsc/oauth-config"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { GscNotConnectedError, @@ -21,6 +22,7 @@ import { type GscPerformanceInput, } from "@/server/features/gsc/searchAnalytics"; import { GscApiError, GscTokenError } from "@/server/lib/gscClient"; +import { GSC_SELF_HOSTED_SETUP_DOCS_URL } from "@/shared/gsc"; const TEXT_SUMMARY_ROWS = 15; @@ -33,17 +35,28 @@ function integrationsUrl(baseUrl: string, projectId: string): string { return buildDashboardUrl(baseUrl, `/p/${projectId}/integrations`); } -/** GSC connect requires Better Auth, which only runs in hosted mode. In - * self-hosted deployments the tools return this instead of a broken flow. */ -async function hostedOnlyResponse( +/** Self-hosted GSC requires the operator to provide a Google OAuth client and + * BETTER_AUTH_SECRET. Hosted mode always has both; self-hosted tools return this + * setup nudge before attempting a token lookup when either is missing. */ +async function missingSelfHostedGoogleClientResponse( context: ProjectAuthContext, projectId: string, ) { - if (await isHostedServerAuthMode()) return null; + const [hosted, configured] = await Promise.all([ + isHostedServerAuthMode(), + hasSelfHostedGscConfig(), + ]); + if (hosted || configured) return null; + return mcpResponse({ - text: "Google Search Console connect is only available on the hosted OpenSEO service, not in self-hosted mode. Use a GSC CSV export instead.", + text: `This self-hosted OpenSEO deployment is not configured for Search Console yet. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET, then reconnect Search Console from Integrations. Setup docs: ${GSC_SELF_HOSTED_SETUP_DOCS_URL}`, meta: buildProjectMeta(context, projectId), - structuredContent: { ok: false, connected: false, reason: "hosted_only" }, + structuredContent: { + ok: false, + connected: false, + reason: "gsc_oauth_not_configured", + setupDocsUrl: GSC_SELF_HOSTED_SETUP_DOCS_URL, + }, }); } @@ -147,6 +160,7 @@ export const getSearchConsolePerformanceTool = { ok: z.boolean(), reason: z.string().optional(), connectUrl: z.string().optional(), + setupDocsUrl: z.string().optional(), siteUrl: z.string().optional(), startDate: z.string().optional(), endDate: z.string().optional(), @@ -176,7 +190,10 @@ export const getSearchConsolePerformanceTool = { }, }, handler: withMcpProjectAuth(async (args: PerfArgs, context) => { - const blocked = await hostedOnlyResponse(context, args.projectId); + const blocked = await missingSelfHostedGoogleClientResponse( + context, + args.projectId, + ); if (blocked) return blocked; const connectUrl = integrationsUrl(context.baseUrl, args.projectId); @@ -292,6 +309,7 @@ export const inspectUrlsTool = { ok: z.boolean(), reason: z.string().optional(), connectUrl: z.string().optional(), + setupDocsUrl: z.string().optional(), siteUrl: z.string().optional(), results: z .array( @@ -313,7 +331,10 @@ export const inspectUrlsTool = { }, }, handler: withMcpProjectAuth(async (args: InspectArgs, context) => { - const blocked = await hostedOnlyResponse(context, args.projectId); + const blocked = await missingSelfHostedGoogleClientResponse( + context, + args.projectId, + ); if (blocked) return blocked; const connectUrl = integrationsUrl(context.baseUrl, args.projectId); diff --git a/src/serverFunctions/gsc.ts b/src/serverFunctions/gsc.ts index 546f1c5..9ba7f7d 100644 --- a/src/serverFunctions/gsc.ts +++ b/src/serverFunctions/gsc.ts @@ -1,8 +1,13 @@ import { createServerFn } from "@tanstack/react-start"; +import { getRequest } from "@tanstack/react-start/server"; import { waitUntil } from "cloudflare:workers"; import { z } from "zod"; import { GscService } from "@/server/features/gsc/services/GscService"; +import { hasSelfHostedGscConfig } from "@/server/features/gsc/oauth-config"; +import { createSelfHostedGscAuthorizationUrl } from "@/server/features/gsc/selfHostedOAuth"; import { captureServerEvent } from "@/server/lib/posthog"; +import { getPublicOrigin } from "@/server/mcp/public-origin"; +import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { requireAuthenticatedContext, requireProjectContext, @@ -12,6 +17,9 @@ const projectScopedSchema = z.object({ projectId: z.string().min(1) }); const setSiteSchema = projectScopedSchema.extend({ siteUrl: z.string().min(1), }); +const startSelfHostedLinkSchema = z.object({ + callbackURL: z.string().min(1), +}); // Account-level grant check (no project needed) for surfaces like onboarding // where the user hasn't picked a project yet. The OAuth grant is per-account; @@ -26,13 +34,17 @@ export const getGscConnection = createServerFn({ method: "POST" }) .middleware(requireProjectContext) .inputValidator((data: unknown) => projectScopedSchema.parse(data)) .handler(async ({ context }) => { - const [connection, currentUserHasGrant] = await Promise.all([ - GscService.getConnection(context.projectId), - GscService.userHasGrant(context.userId), - ]); + const [connection, currentUserHasGrant, hosted, gscConfigured] = + await Promise.all([ + GscService.getConnection(context.projectId), + GscService.userHasGrant(context.userId), + isHostedServerAuthMode(), + hasSelfHostedGscConfig(), + ]); return { connected: Boolean(connection), currentUserHasGrant, + googleOAuthConfigured: hosted || gscConfigured, siteUrl: connection?.siteUrl ?? null, connectedByEmail: connection?.connectedAccountEmail ?? null, connectedAt: connection?.createdAt ?? null, @@ -97,3 +109,20 @@ export const disconnectGsc = createServerFn({ method: "POST" }) ); return { connected: false as const }; }); + +export const startSelfHostedGscLink = createServerFn({ method: "POST" }) + .middleware(requireAuthenticatedContext) + .inputValidator((data: unknown) => startSelfHostedLinkSchema.parse(data)) + .handler(async ({ data, context }) => { + const publicOrigin = getPublicOrigin(getRequest()); + const url = await createSelfHostedGscAuthorizationUrl({ + user: { + userId: context.userId, + userEmail: context.userEmail, + }, + callbackURL: data.callbackURL, + publicOrigin, + }); + + return { url }; + }); diff --git a/src/serverFunctions/onboarding.ts b/src/serverFunctions/onboarding.ts index 2414470..f39fa62 100644 --- a/src/serverFunctions/onboarding.ts +++ b/src/serverFunctions/onboarding.ts @@ -29,7 +29,7 @@ export const getOnboardingAnswers = createServerFn({ method: "GET" }) }, where: eq(userOnboardingAnswers.userId, context.userId), }); - const hostedUser = await db.query.user.findFirst({ + const userRecord = await db.query.user.findFirst({ columns: { createdAt: true, }, @@ -53,7 +53,7 @@ export const getOnboardingAnswers = createServerFn({ method: "GET" }) return { completedAt: answers?.completedAt ?? null, gscNudgeDismissedAt: answers?.gscNudgeDismissedAt ?? null, - userCreatedAt: hostedUser?.createdAt?.toISOString() ?? null, + userCreatedAt: userRecord?.createdAt?.toISOString() ?? null, answers: { interestedFeatures, workFor: answers?.workFor ?? null, diff --git a/src/shared/gsc.ts b/src/shared/gsc.ts index 46e8af3..c41ef21 100644 --- a/src/shared/gsc.ts +++ b/src/shared/gsc.ts @@ -2,3 +2,13 @@ * Kept in `shared` so both server (auth config, GSC client) and client (connect * button) can reference it without importing the server-only auth config. */ export const GSC_OAUTH_PROVIDER_ID = "google-search-console"; + +export const GSC_OAUTH_SCOPES = [ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/webmasters.readonly", +] as const; + +export const GSC_SELF_HOSTED_SETUP_DOCS_URL = + "https://github.com/every-app/open-seo/blob/main/docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md";