Add onboarding chat helper

This commit is contained in:
Ben Senescu 2026-06-19 13:20:52 -04:00 committed by GitHub
parent dec7fe68dc
commit e57768a8ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 5005 additions and 250 deletions

View File

@ -0,0 +1,4 @@
ALTER TABLE `projects` ADD `location_code` integer DEFAULT 2840 NOT NULL;--> statement-breakpoint
ALTER TABLE `projects` ADD `language_code` text DEFAULT 'en' NOT NULL;--> statement-breakpoint
ALTER TABLE `projects` ADD `onboarding_run_status` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `onboarding_run_at` text;

File diff suppressed because it is too large Load Diff

View File

@ -169,6 +169,13 @@
"when": 1781467831570, "when": 1781467831570,
"tag": "0023_sad_cobalt_man", "tag": "0023_sad_cobalt_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 24,
"version": "6",
"when": 1781809245835,
"tag": "0024_clumsy_barracuda",
"breakpoints": true
} }
] ]
} }

View File

@ -58,9 +58,11 @@
} }
}, },
"dependencies": { "dependencies": {
"@ai-sdk/react": "^3.0.201",
"@cloudflare/workers-oauth-provider": "^0.4.0", "@cloudflare/workers-oauth-provider": "^0.4.0",
"@every-app/sdk": "^0.1.14", "@every-app/sdk": "^0.1.14",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@openrouter/ai-sdk-provider": "^2.9.0",
"@tanstack/query-core": "^5.90.9", "@tanstack/query-core": "^5.90.9",
"@tanstack/react-form": "^1.25.0", "@tanstack/react-form": "^1.25.0",
"@tanstack/react-query": "^5.90.9", "@tanstack/react-query": "^5.90.9",
@ -69,6 +71,7 @@
"@tanstack/react-start": "^1.167.16", "@tanstack/react-start": "^1.167.16",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"agents": "0.12.3", "agents": "0.12.3",
"ai": "^6.0.199",
"autumn-js": "^1.1.7", "autumn-js": "^1.1.7",
"better-auth": "^1.5.5", "better-auth": "^1.5.5",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",

82
pnpm-lock.yaml generated
View File

@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@ai-sdk/react':
specifier: ^3.0.201
version: 3.0.201(react@19.2.4)(zod@4.3.6)
'@cloudflare/workers-oauth-provider': '@cloudflare/workers-oauth-provider':
specifier: ^0.4.0 specifier: ^0.4.0
version: 0.4.0 version: 0.4.0
@ -17,6 +20,9 @@ importers:
'@modelcontextprotocol/sdk': '@modelcontextprotocol/sdk':
specifier: 1.29.0 specifier: 1.29.0
version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
'@openrouter/ai-sdk-provider':
specifier: ^2.9.0
version: 2.9.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6)
'@tanstack/query-core': '@tanstack/query-core':
specifier: ^5.90.9 specifier: ^5.90.9
version: 5.90.20 version: 5.90.20
@ -40,7 +46,10 @@ importers:
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
agents: agents:
specifier: 0.12.3 specifier: 0.12.3
version: 0.12.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260302.0)(ai@6.0.177(zod@4.3.6))(react@19.2.4)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(zod@4.3.6) version: 0.12.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260302.0)(ai@6.0.199(zod@4.3.6))(react@19.2.4)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(zod@4.3.6)
ai:
specifier: ^6.0.199
version: 6.0.199(zod@4.3.6)
autumn-js: autumn-js:
specifier: ^1.1.7 specifier: ^1.1.7
version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(react@19.2.4) version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(react@19.2.4)
@ -189,8 +198,8 @@ importers:
packages: packages:
'@ai-sdk/gateway@3.0.112': '@ai-sdk/gateway@3.0.127':
resolution: {integrity: sha512-jiBao9pR4owWyjo0BnuNc7WSQBGOD0thysE4AFgZXaG+zMFbISQXUkJr7ePw/phBvePy7jE5FSA2Lf7lwqUiiQ==} resolution: {integrity: sha512-Obmw5hmE5x+ccRrMp/Djx5r0rpFVX87YqE6OY06g5fwYlRI30dA84ARfTzX45ivCvkW4eCnBpOVXVWQ/pjH85w==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.25.76 || ^4.1.8 zod: ^3.25.76 || ^4.1.8
@ -205,6 +214,12 @@ packages:
resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@ai-sdk/react@3.0.201':
resolution: {integrity: sha512-sDYdkPB96aHPrF5WKP0BdjRjymgZOdIMzNt1SlYPCoWWvLnV+UMwnIqvqEG+OAa9n4Sq7J3A3N/JD7rozow+CA==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1
'@babel/code-frame@7.27.1': '@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@ -1268,14 +1283,17 @@ packages:
resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==}
engines: {node: '>=20.0'} engines: {node: '>=20.0'}
'@openrouter/ai-sdk-provider@2.9.0':
resolution: {integrity: sha512-Seva+NCa0WUQnJIUE5GzHsUv1WTIeyqwz0ELl2VtS6NP+eF+77yCXGFVOMbvoCM7QMjlnhv7931e89R+8pJdcQ==}
engines: {node: '>=18'}
peerDependencies:
ai: ^6.0.0
zod: ^3.25.0 || ^4.0.0
'@opentelemetry/api-logs@0.208.0': '@opentelemetry/api-logs@0.208.0':
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
'@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api@1.9.1': '@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@ -2476,8 +2494,8 @@ packages:
vite: vite:
optional: true optional: true
ai@6.0.177: ai@6.0.199:
resolution: {integrity: sha512-1xQtbeWwNcLyyM86ixZhkKvT+WRXc1lvarIKqPVtsyn8F9NDikwUMBqYu+aQKDgMht50SMXh4qboYuU8MeHZZA==} resolution: {integrity: sha512-6H9RPEjzBQECM+eU1JxAh6jHcZPU/6q5QZ8D8QV8agubf0Mm/kcBlwqrFcFtup6RQzmEvMkVaQOoLCZ8bQ13lA==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.25.76 || ^4.1.8 zod: ^3.25.76 || ^4.1.8
@ -4287,6 +4305,11 @@ packages:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'} engines: {node: '>=18'}
swr@2.4.1:
resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==}
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
tailwindcss@4.2.1: tailwindcss@4.2.1:
resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
@ -4294,6 +4317,10 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'} engines: {node: '>=6'}
throttleit@2.1.0:
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
engines: {node: '>=18'}
tiny-invariant@1.3.3: tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@ -4683,7 +4710,7 @@ packages:
snapshots: snapshots:
'@ai-sdk/gateway@3.0.112(zod@4.3.6)': '@ai-sdk/gateway@3.0.127(zod@4.3.6)':
dependencies: dependencies:
'@ai-sdk/provider': 3.0.10 '@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.3.6) '@ai-sdk/provider-utils': 4.0.27(zod@4.3.6)
@ -4701,6 +4728,16 @@ snapshots:
dependencies: dependencies:
json-schema: 0.4.0 json-schema: 0.4.0
'@ai-sdk/react@3.0.201(react@19.2.4)(zod@4.3.6)':
dependencies:
'@ai-sdk/provider-utils': 4.0.27(zod@4.3.6)
ai: 6.0.199(zod@4.3.6)
react: 19.2.4
swr: 2.4.1(react@19.2.4)
throttleit: 2.1.0
transitivePeerDependencies:
- zod
'@babel/code-frame@7.27.1': '@babel/code-frame@7.27.1':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
@ -5532,12 +5569,15 @@ snapshots:
'@oozcitak/util@10.0.0': {} '@oozcitak/util@10.0.0': {}
'@openrouter/ai-sdk-provider@2.9.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6)':
dependencies:
ai: 6.0.199(zod@4.3.6)
zod: 4.3.6
'@opentelemetry/api-logs@0.208.0': '@opentelemetry/api-logs@0.208.0':
dependencies: dependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@opentelemetry/api@1.9.0': {}
'@opentelemetry/api@1.9.1': {} '@opentelemetry/api@1.9.1': {}
'@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.1)': '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.1)':
@ -6554,13 +6594,13 @@ snapshots:
dependencies: dependencies:
humanize-ms: 1.2.1 humanize-ms: 1.2.1
agents@0.12.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260302.0)(ai@6.0.177(zod@4.3.6))(react@19.2.4)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(zod@4.3.6): agents@0.12.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260302.0)(ai@6.0.199(zod@4.3.6))(react@19.2.4)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(zod@4.3.6):
dependencies: dependencies:
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
'@cfworker/json-schema': 4.1.1 '@cfworker/json-schema': 4.1.1
'@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
'@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
ai: 6.0.177(zod@4.3.6) ai: 6.0.199(zod@4.3.6)
cron-schedule: 6.0.0 cron-schedule: 6.0.0
mimetext: 3.0.28 mimetext: 3.0.28
nanoid: 5.1.11 nanoid: 5.1.11
@ -6579,12 +6619,12 @@ snapshots:
- rolldown - rolldown
- supports-color - supports-color
ai@6.0.177(zod@4.3.6): ai@6.0.199(zod@4.3.6):
dependencies: dependencies:
'@ai-sdk/gateway': 3.0.112(zod@4.3.6) '@ai-sdk/gateway': 3.0.127(zod@4.3.6)
'@ai-sdk/provider': 3.0.10 '@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.3.6) '@ai-sdk/provider-utils': 4.0.27(zod@4.3.6)
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.1
zod: 4.3.6 zod: 4.3.6
ajv-formats@3.0.1(ajv@8.20.0): ajv-formats@3.0.1(ajv@8.20.0):
@ -8604,10 +8644,18 @@ snapshots:
supports-color@10.2.2: {} supports-color@10.2.2: {}
swr@2.4.1(react@19.2.4):
dependencies:
dequal: 2.0.3
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
tailwindcss@4.2.1: {} tailwindcss@4.2.1: {}
tapable@2.3.0: {} tapable@2.3.0: {}
throttleit@2.1.0: {}
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
tinybench@2.9.0: {} tinybench@2.9.0: {}

View File

@ -0,0 +1,157 @@
# Onboarding agent
## Status
Proposed (June 2026) — v1 product spec, pending technical design.
> **Update (June 2026):** the first PR ships the chat + live strategy generation
> only. Persisting the strategy (the "Project Context" store, R2 versioning) and
> the `get_project_context` MCP tool described below are **deferred to a later
> PR** — the generated strategy is shown in the chat but not yet saved.
## Goal
Turn signup into activation. When a new user onboards, an "agent" analyzes
their actual website live, then proposes a tailored SEO strategy. The strategy
is free and stands on its own; _acting on it_ (rank tracking, content briefs,
the ongoing coach) is the paid surface. The strategy becomes the project's
durable "Project Context" — readable in the app and over MCP.
This is the top activation priority because it gives every new user a concrete,
personalized "here's what to do" moment before they're asked to pay.
## What "agent" means here
A **guided pipeline narrated live**, not an LLM agent loop. The steps are
mostly deterministic (scrape, fetch keyword data); a single LLM call at the end
synthesizes the strategy narrative. The "agent" feeling comes from streaming the
steps in real time ("Reading your homepage… you look like a Notion
alternative… 4 keywords ranking, 30 worth targeting…") and streaming the final
write-up token-by-token. No Anthropic SDK / agent infra is required for v1.
## The experience
**Stage 0 — Profile (extend existing onboarding forms).** Collect: domain,
experience level, and primary goal. **Website and default country are captured
on the same step**, since the country is a property of the site/project being
analyzed — keeping them together makes the relationship obvious and avoids a
stray standalone country field. That step carries a short note that they can add
more projects with different websites (and their own countries) later, so users
don't feel they must cram every site into this first one. Keep the whole stage
to ~4 fields — the onboarding UX audit flagged form friction. Country and
language become the project's default location (see Project defaults) and are
reused for every DataForSEO call going forward.
**Stage 1 — Discover (live).** Sitemap-first using existing robots.txt +
sitemap discovery; fall back to a shallow crawl. Produces a map of the site's
shape from URLs/titles. Discover many URLs (cheap); scrape few.
**Stage 2 — Read (live).** Scrape ~35 key pages (home + top product/nav pages)
to markdown. This is the one net-new capability: page → markdown via Cloudflare
Browser Rendering. Honest "we couldn't read your site" flagging with a manual
"tell us what you do" fallback so the run never dead-ends.
**Stage 3 — Signal (live).** See Stage 3 data below.
**Stage 4 — Synthesize (streaming LLM).** One LLM call over
{profile + scraped markdown + keyword data} produces: a positioning statement,
35 themes/clusters, a starter keyword table (volume/difficulty), and a
prioritized "do this next" list. Must produce a credible strategy even when the
site has zero existing rankings — the cold-start case is the _default_ for the
indie-founder ICP, not an edge case.
**Stage 5 — Persist + gate.** Save as the Project Context artifact (markdown,
MCP-readable). Present the full strategy free; the paywall lands on _executing_
it.
## Stage 3 data (v1 — kept minimal)
Stage 3 is **archetype-conditional**: detect the site type cheaply, then frame
the goal accordingly. For v1 the archetype branches the _narrative_, not a large
matrix of API calls. Two calls baseline:
| Call | Endpoint | Purpose | When |
| ---------------------- | ------------------------------------------------------ | ---------------------------------------------------- | -------------------------------------------------------------------- |
| `domain_rank_overview` | `/v3/dataforseo_labs/google/domain_rank_overview/live` | traffic + # ranking keywords; the archetype detector | always |
| `keyword_ideas` | `/v3/dataforseo_labs/google/keyword_ideas/live` | starter keyword list, seeded from scraped themes | always |
| `ranked_keywords` | `/v3/dataforseo_labs/google/ranked_keywords/live` | what they already rank for | only if overview shows real rankings (usually skipped for new sites) |
Archetypes (narrative only in v1): **new/pre-traffic**, **established content
site**, **local business**, **SaaS/product** (core ICP).
**Clarifying question:** for **beginners**, skip it and use the archetype's
default goal (fewer decisions = less drop-off). For **non-beginners**, ask one
targeted question after detection to confirm intent (e.g. "grow existing
rankings, or expand into new topics?") — agency without a survey.
**Deferred to v2:** `serp_competitors`, local-business tools
(`get_local_serp_results`, `search_local_businesses`,
`get_google_business_questions`), and Google Ads volume for non-Labs countries.
## Cost per free onboarding
The whole run is free (paywall is after), so cost-per-signup must be bounded:
- DataForSEO: ~$0.040.08 (2 Labs live calls; metered from the real `cost`
field in the response envelope, so we can hard-cap).
- Browser Rendering scrape: ~negligible.
- LLM synthesis: ~$0.050.15 (one call over a few pages of markdown).
- **Total ≈ $0.100.25 per onboarding.**
Guardrails: one run per user, results cached hard, and **email verification
gates Stage 3** (the DataForSEO spend) to kill drive-by abuse.
## Project Context artifact (MCP-readable)
The strategy is stored as a **markdown document** — a living "Project Context"
that is the shared source of truth across the in-app agent, the web UI, and any
MCP client.
- Storage: a `project_context` row in D1 (`projectId`, `markdown`, `updatedAt`,
`version`). Small enough for D1; R2 only if it grows.
- MCP: a `get_project_context` tool (and ideally an MCP _resource_ so it
auto-loads into the agent's context). The user's own Claude/Codex reads the
same strategy the app shows.
- The onboarding agent **authors** the context; the paid execution actions
(rank tracking, briefs, coach) **read from and append to** it. That append-back
is the natural shape of the gated surface.
## Project defaults (country/language)
Projects don't currently store a default location — only `rank_tracking_configs`
does (defaults 2840/US, "en"). Add `location_code` + `language_code` to
`projects`, set from the Stage 0 country, and reuse them everywhere project work
needs a location. A small curated country → `location_code` map is enough for
v1.
## Paywall
Free: the full strategy, positioning, themes, capped starter keyword list
(~1520), and the "do next" list. Gated (execution): rank-tracking the proposed
keywords, full keyword expansion, content briefs per theme, and the ongoing
`seo-coach`. Every gated action is a named thing tied to a strategy item the
user already believes in — stronger pull than "refine further." GSC stays behind
the gate; we do **not** prompt for it during onboarding (connecting then hitting
a paywall is exactly the bait-and-switch the UX audit warned against).
## Unify the seo-coach skill
Update the `seo-coach` skill (and `onboarding-checklist`) so the coach and the
onboarding agent are one continuous experience: the coach reads Project Context
via MCP, picks up where onboarding left off ("here's your strategy — let's work
the backlog"), and can answer both SEO questions and OpenSEO product questions.
The onboarding agent sets the backlog; the coach executes it.
## Open questions
- Country → `location_code` source: hand-curate a short list for v1, or reuse an
existing DataForSEO locations dataset?
- Re-run policy: confirmed one strategy per project, regenerate in place; new
domain = new project (a soft upgrade nudge).
- Synthesis model choice (cost vs quality) — decide at technical design.
## Out of scope for v1
Conversational/iterative agent loop, competitor and local-business analysis,
GSC-enriched strategy, multi-language strategies, automatic content generation.
These are incremental improvements once the core activation loop is proven.

View File

@ -0,0 +1,151 @@
# Onboarding agent — implementation plan (chat + seed function)
## Status
Accepted (June 2026) — technical plan for `specs/0005-onboarding-agent.md`.
Supersedes an earlier draft that proposed Cloudflare Project Think + Durable
Objects + a Workflow. We dropped all of that (see "Why not Think / Workflows").
> **Update (June 2026):** to keep the first PR small, strategy **persistence**
> (the `project_context_versions` store + R2 markdown versioning) and the
> `get_project_context` **MCP tool** were deferred to a later PR. `seed.ts` now
> returns the synthesized strategy to the chat without saving it; the
> `claimRun` spend guard still bounds the paid run. Sections below describing
> the store, versioning, and the MCP read tool reflect the original plan, not
> what shipped.
## TL;DR
Onboarding has two simple pieces, no agent framework:
1. **A seed function** (plain async): discover sitemap → scrape 35 pages to
markdown (Browser Rendering) → 2 paid DataForSEO calls → one OpenRouter
synthesis call → save the result as the project's first **Project Context**
version. Runs once when onboarding kicks off.
2. **A normal streaming chat** (Vercel AI SDK `streamText` over OpenRouter):
the user asks questions / refines; an `update_project_context` tool writes a
new version. Backed by a plain API route on the existing better-auth session.
The Project Context is **versioned**: immutable markdown blobs in R2, an
append-only log in D1. Reverts reuse a prior blob's key. It's exposed over MCP
via `get_project_context`.
## Why not Think / Workflows (and why `agents` stays)
- **No durable execution needed.** The paid DataForSEO services are _cache-first_
(`getCached` runs before `createDataforseoClient`/metering), so a crash-and-retry
of the same domain re-hits the 12h R2 cache → no double-spend. That removed the
only reason for fibers/Workflows.
- **No agent host needed.** The capabilities we want (stream answers, a future
docs tool, save/update the artifact) are all plain `streamText({ tools })`.
Think's distinctive value (durable DO sessions, scheduled turns, sub-agents)
isn't used by any of them.
- **The `agents` package stays** — it's used by the MCP handler
(`agents/mcp``createMcpHandler` in `src/server/mcp/transport.ts`), not as an
agent runtime. No version bump.
- **Graduate later** only when a capability genuinely needs durable sessions,
`schedule` (weekly rank tracking), or sub-agents (per-competitor). The seed
function and chat route port straight over.
## Data model
**`projects`** — add `location_code` (int, default 2840), `language_code` (text,
default `'en'`), `onboarding_run_status` (text nullable:
`running|complete|failed`), `onboarding_run_at` (text nullable).
**`project_context_versions`** (new, append-only log):
`id` pk · `project_id` FK · `r2_key` · `author` (`onboarding|chat|user`) ·
`note` nullable · `reverted_from_id` nullable · `created_at`. Current version =
latest row per project. Index on `(project_id, created_at)`.
**R2** — immutable markdown blob per version at
`project-context/{projectId}/{versionId}.md`. **Write R2 first, then the D1
row** (a failure leaves a harmless orphan blob, never a row pointing at nothing).
A revert inserts a new row reusing the target's `r2_key` (no new blob).
## The seed function
`runOnboardingSeed({ projectId, organizationId, userId, userEmail, domain })`:
1. **Admission marker** — atomic `UPDATE projects SET onboarding_run_status =
'running' WHERE id = ? AND onboarding_run_status IS NULL`; proceed only if
one row changed (else a run is already in flight). This is the at-most-once
guard.
2. **Discover**`fetch()` robots.txt + sitemap.xml; shallow fallback.
3. **Read** — scrape 35 key pages to markdown via the `BROWSER` binding,
sequentially. Failure → flag it, never throw the whole run.
4. **Signal**`DomainService.getOverview` (always) + keyword research seeded
from scraped themes, both with `creditFeature: 'onboarding'`;
`getSuggestedKeywords` only if the overview shows real rankings.
5. **Synthesize** — one OpenRouter `generateText`/`streamText` over
{profile + markdown + signal} → strategy markdown.
6. **Persist** — write version `v1` (author `onboarding`); set
`onboarding_run_status = 'complete'`. On any throw → `'failed'` (re-runnable;
retry is cache-backed and cheap).
**Metering** is unchanged: paid calls go through the existing
`createDataforseoClient` seam. A new `'onboarding'` `CreditFeature` tags spend.
A `skipBalanceAssert` flag on the metering path lets a zero-balance new signup
run Stage "Signal" without dead-ending on `INSUFFICIENT_CREDITS`, while still
calling `trackDataforseoCost` (spend metered, not balance-gated). Self-host
already skips Autumn entirely.
## The chat
`POST /api/onboarding/chat` (TanStack `createFileRoute` server handler):
- Auth: resolve the better-auth session from the request; assert the user owns
`projectId` (org scoping via `ProjectRepository.getProjectForOrganization`).
No new transport, so no extra auth surface.
- `streamText({ model: openrouter(MODEL), system: seededWithContext, messages,
tools: { update_project_context } })`, returned as a UI message stream.
- `update_project_context({ markdown, note })` writes a new version (author
`chat`). For v1 it applies directly; the append-only log makes any unwanted
change one revert away.
Client: AI SDK `useChat({ api: '/api/onboarding/chat' })`. The strategy renders
above the chat; the upgrade CTA is a UI state shown once `v1` exists.
## Auth + email-verify
- Surface `emailVerified` on `EnsuredUserContext` (from
`session.user.emailVerified`); self-host = treated verified.
- The seed asserts `emailVerified` before the paid "Signal" stage. Discover +
Read (free) may run unverified.
## MCP
`get_project_context(projectId)` — read-only tool (`readOnlyHint: true`) using
`withMcpProjectAuth`; resolves the latest version → R2 get → returns markdown.
`list_project_context_versions` is a fast-follow.
## Local testing
Real providers, no fixture system:
- Add `OPENROUTER_API_KEY` to `.env.local` (the one new key), `wrangler login`
for the `BROWSER` binding (`remote: true` in dev).
- DataForSEO creds you already have; the cache makes repeat runs free.
- Drive the real onboarding UI with the Playwriter skill, screenshotting each
step. **Test domain: `openseo.so`.**
## Build order (stacked PRs)
1. **Foundation** — schema + migration (`projects` cols, `project_context_versions`);
`emailVerified` on context; `'onboarding'` CreditFeature + label +
`skipBalanceAssert` flag; country→`location_code` map. No behavior.
2. **Stage 0 form** — domain + country on one step → `projects`.
3. **Project Context store** — R2 blob helper + versions repository +
`get_project_context` MCP tool.
4. **Scrape + seed + synthesis**`BROWSER` binding, scrape-to-markdown, the
seed function, OpenRouter dep, the "generating → strategy" UI.
5. **Chat**`/api/onboarding/chat` + `update_project_context` tool +
`useChat` UI + revert.
(There is no fixture-provider PR — we test against real providers.)
## Out of scope (v1)
Think/DO/Workflows, sub-agents, scheduled rank tracking, GSC-enriched strategy,
multi-language, auto content generation, the MCP context _resource_ (tool only).

View File

@ -0,0 +1,141 @@
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
type Props = {
/** Raw Markdown source to render. */
children: string;
className?: string;
};
/**
* Shared Markdown renderer with explicit per-element Tailwind classes.
*
* OpenSEO doesn't ship `@tailwindcss/typography`, so `prose` classes are
* no-ops every block element is styled here instead. Tables use daisyUI's
* `table table-xs` so model- and strategy-generated tables stay readable.
*
* Anchor URLs are sanitized to http(s) only LLMs can be coaxed into
* emitting `javascript:` payloads.
*/
export function Markdown({ children, className }: Props) {
return (
<div className={className}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={MARKDOWN_COMPONENTS}
>
{children}
</ReactMarkdown>
</div>
);
}
type AnchorProps = ComponentPropsWithoutRef<"a">;
function SafeAnchor({ href, children, ...rest }: AnchorProps) {
const safeHref = isHttpUrl(href) ? href : undefined;
if (!safeHref) {
return <span className="underline decoration-dotted">{children}</span>;
}
return (
<a
{...rest}
href={safeHref}
target="_blank"
rel="noreferrer"
className="link link-primary"
>
{children}
</a>
);
}
function isHttpUrl(value: string | undefined): value is string {
if (!value) return false;
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
// Mirror server-side `safeHttpUrl` — a `user:pass@host` URL shows one
// hostname in link text while auth hits another.
if (url.username || url.password) return false;
return true;
} catch {
return false;
}
}
export const MARKDOWN_COMPONENTS = {
h1: ({ children }: { children?: ReactNode }) => (
<h1 className="mt-5 mb-2 text-base font-semibold first:mt-0">{children}</h1>
),
h2: ({ children }: { children?: ReactNode }) => (
<h2 className="mt-5 mb-2 text-sm font-semibold first:mt-0">{children}</h2>
),
h3: ({ children }: { children?: ReactNode }) => (
<h3 className="mt-4 mb-1.5 text-sm font-semibold first:mt-0">{children}</h3>
),
h4: ({ children }: { children?: ReactNode }) => (
<h4 className="mt-3 mb-1 text-sm font-semibold first:mt-0">{children}</h4>
),
p: ({ children }: { children?: ReactNode }) => (
<p className="my-2 leading-relaxed first:mt-0 last:mb-0">{children}</p>
),
ul: ({ children }: { children?: ReactNode }) => (
<ul className="my-2 ml-5 list-disc space-y-1">{children}</ul>
),
ol: ({ children }: { children?: ReactNode }) => (
<ol className="my-2 ml-5 list-decimal space-y-1">{children}</ol>
),
li: ({ children }: { children?: ReactNode }) => (
<li className="leading-relaxed">{children}</li>
),
a: SafeAnchor,
strong: ({ children }: { children?: ReactNode }) => (
<strong className="font-semibold">{children}</strong>
),
em: ({ children }: { children?: ReactNode }) => (
<em className="italic">{children}</em>
),
blockquote: ({ children }: { children?: ReactNode }) => (
<blockquote className="my-2 border-l-2 border-base-300 pl-3 text-base-content/80 italic">
{children}
</blockquote>
),
hr: () => <hr className="my-3 border-base-300" />,
code: ({ children, className }: ComponentPropsWithoutRef<"code">) => {
// Inline code (no `language-*` className from remark) gets the badge style;
// block code is rendered by `pre` with a different shell.
if (typeof className === "string" && className.startsWith("language-")) {
return <code className={className}>{children}</code>;
}
return (
<code className="rounded bg-base-200 px-1 py-0.5 text-xs font-mono">
{children}
</code>
);
},
pre: ({ children }: { children?: ReactNode }) => (
<pre className="my-2 overflow-x-auto rounded-lg bg-base-200 p-3 text-xs font-mono">
{children}
</pre>
),
table: ({ children }: { children?: ReactNode }) => (
<div className="my-3 overflow-x-auto">
<table className="table table-xs border border-base-300">
{children}
</table>
</div>
),
thead: ({ children }: { children?: ReactNode }) => <thead>{children}</thead>,
tbody: ({ children }: { children?: ReactNode }) => <tbody>{children}</tbody>,
tr: ({ children }: { children?: ReactNode }) => (
<tr className="border-b border-base-300 last:border-0">{children}</tr>
),
th: ({ children }: { children?: ReactNode }) => (
<th className="px-2 py-1.5 text-left font-semibold">{children}</th>
),
td: ({ children }: { children?: ReactNode }) => (
<td className="px-2 py-1.5 align-top">{children}</td>
),
};

View File

@ -1,13 +1,8 @@
import { import { useLayoutEffect, useRef, useState } from "react";
useLayoutEffect,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode,
} from "react";
import { ChevronDown, ChevronUp } from "lucide-react"; import { ChevronDown, ChevronUp } from "lucide-react";
import Markdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { MARKDOWN_COMPONENTS } from "@/client/components/Markdown";
type Props = { type Props = {
text: string; text: string;
@ -71,12 +66,12 @@ export function MarkdownAnswer({ text }: Props) {
} }
className={isCollapsed ? "overflow-hidden" : undefined} className={isCollapsed ? "overflow-hidden" : undefined}
> >
<Markdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={MARKDOWN_COMPONENTS} components={MARKDOWN_COMPONENTS}
> >
{normalized} {normalized}
</Markdown> </ReactMarkdown>
</div> </div>
{isCollapsed ? ( {isCollapsed ? (
@ -171,112 +166,3 @@ function normalizeLlmMarkdown(text: string): string {
"$1$2 ", "$1$2 ",
); );
} }
type AnchorProps = ComponentPropsWithoutRef<"a">;
function SafeAnchor({ href, children, ...rest }: AnchorProps) {
const safeHref = isHttpUrl(href) ? href : undefined;
if (!safeHref) {
return <span className="underline decoration-dotted">{children}</span>;
}
return (
<a
{...rest}
href={safeHref}
target="_blank"
rel="noreferrer"
className="link link-primary"
>
{children}
</a>
);
}
function isHttpUrl(value: string | undefined): value is string {
if (!value) return false;
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
// Mirror server-side `safeHttpUrl` — a `user:pass@host` URL shows one
// hostname in link text while auth hits another.
if (url.username || url.password) return false;
return true;
} catch {
return false;
}
}
const MARKDOWN_COMPONENTS = {
h1: ({ children }: { children?: ReactNode }) => (
<h1 className="mt-4 mb-2 text-base font-semibold first:mt-0">{children}</h1>
),
h2: ({ children }: { children?: ReactNode }) => (
<h2 className="mt-4 mb-2 text-sm font-semibold first:mt-0">{children}</h2>
),
h3: ({ children }: { children?: ReactNode }) => (
<h3 className="mt-3 mb-1.5 text-sm font-semibold first:mt-0">{children}</h3>
),
h4: ({ children }: { children?: ReactNode }) => (
<h4 className="mt-3 mb-1 text-sm font-semibold first:mt-0">{children}</h4>
),
p: ({ children }: { children?: ReactNode }) => (
<p className="my-2 first:mt-0 last:mb-0">{children}</p>
),
ul: ({ children }: { children?: ReactNode }) => (
<ul className="my-2 ml-5 list-disc space-y-1">{children}</ul>
),
ol: ({ children }: { children?: ReactNode }) => (
<ol className="my-2 ml-5 list-decimal space-y-1">{children}</ol>
),
li: ({ children }: { children?: ReactNode }) => (
<li className="leading-relaxed">{children}</li>
),
a: SafeAnchor,
strong: ({ children }: { children?: ReactNode }) => (
<strong className="font-semibold">{children}</strong>
),
em: ({ children }: { children?: ReactNode }) => (
<em className="italic">{children}</em>
),
blockquote: ({ children }: { children?: ReactNode }) => (
<blockquote className="my-2 border-l-2 border-base-300 pl-3 text-base-content/80 italic">
{children}
</blockquote>
),
hr: () => <hr className="my-3 border-base-300" />,
code: ({ children, className }: ComponentPropsWithoutRef<"code">) => {
// Inline code (no `language-*` className from remark) gets the badge style;
// block code is rendered by `pre` with a different shell.
if (typeof className === "string" && className.startsWith("language-")) {
return <code className={className}>{children}</code>;
}
return (
<code className="rounded bg-base-200 px-1 py-0.5 text-xs font-mono">
{children}
</code>
);
},
pre: ({ children }: { children?: ReactNode }) => (
<pre className="my-2 overflow-x-auto rounded-lg bg-base-200 p-3 text-xs font-mono">
{children}
</pre>
),
table: ({ children }: { children?: ReactNode }) => (
<div className="my-3 overflow-x-auto">
<table className="table table-xs border border-base-300">
{children}
</table>
</div>
),
thead: ({ children }: { children?: ReactNode }) => <thead>{children}</thead>,
tbody: ({ children }: { children?: ReactNode }) => <tbody>{children}</tbody>,
tr: ({ children }: { children?: ReactNode }) => (
<tr className="border-b border-base-300 last:border-0">{children}</tr>
),
th: ({ children }: { children?: ReactNode }) => (
<th className="px-2 py-1.5 text-left font-semibold">{children}</th>
),
td: ({ children }: { children?: ReactNode }) => (
<td className="px-2 py-1.5 align-top">{children}</td>
),
};

View File

@ -0,0 +1,125 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { AutumnProvider } from "autumn-js/react";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import {
DEFAULT_LOCATION_CODE,
LOCATION_OPTIONS,
} from "@/shared/keyword-locations";
import { saveOnboardingSite } from "@/serverFunctions/onboardingStrategy";
import { StrategyChat } from "./OnboardingStrategyChat";
import {
invalidateStrategyState,
strategyStateQueryOptions,
} from "./onboardingStrategyQueries";
// Full-viewport chat surface. Breaks out of the centered, padded AuthPageShell
// with `fixed inset-0` so the chat fills the screen. There's no header bar —
// the strategy's first message carries the context — and inner content is
// constrained to a narrow column for comfortable reading width.
function StrategyShell({ children }: { children: React.ReactNode }) {
return (
<div className="fixed inset-0 flex flex-col bg-base-100">{children}</div>
);
}
export function OnboardingStrategy() {
const stateQuery = useQuery(strategyStateQueryOptions());
if (stateQuery.isError) {
return (
<StrategyShell>
<div className="flex flex-1 items-center justify-center p-6 text-sm text-error">
Couldnt load your strategy. Please refresh to try again.
</div>
</StrategyShell>
);
}
if (!stateQuery.data) {
return (
<StrategyShell>
<div className="flex flex-1 items-center justify-center gap-2 p-6 text-sm text-base-content/60">
<Loader2 className="size-4 animate-spin" />
Loading
</div>
</StrategyShell>
);
}
const { projectId, domain } = stateQuery.data;
return (
<StrategyShell>
{!domain ? (
<SiteForm projectId={projectId} />
) : (
<AutumnProvider>
<StrategyChat projectId={projectId} domain={domain} />
</AutumnProvider>
)}
</StrategyShell>
);
}
function SiteForm({ projectId }: { projectId: string }) {
const [domain, setDomain] = useState("");
const [locationCode, setLocationCode] = useState(DEFAULT_LOCATION_CODE);
const save = useMutation({
mutationFn: () =>
saveOnboardingSite({ data: { projectId, domain, locationCode } }),
onSuccess: invalidateStrategyState,
});
return (
<div className="flex flex-1 items-center justify-center overflow-y-auto p-6">
<form
className="w-full max-w-sm space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (domain.trim()) {
save.mutate();
}
}}
>
<label className="block space-y-1">
<span className="text-sm font-medium">Your website</span>
<input
type="text"
className="input input-bordered w-full"
placeholder="example.com"
value={domain}
onChange={(event) => setDomain(event.target.value)}
/>
<span className="text-xs text-base-content/50">
You can add more projects with different websites later.
</span>
</label>
<label className="block space-y-1">
<span className="text-sm font-medium">Primary country</span>
<select
className="select select-bordered w-full"
value={locationCode}
onChange={(event) => setLocationCode(Number(event.target.value))}
>
{LOCATION_OPTIONS.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
</label>
<button
type="submit"
className="btn btn-primary w-full"
disabled={!domain.trim() || save.isPending}
>
{save.isPending ? "Saving…" : "Continue"}
</button>
</form>
</div>
);
}

View File

@ -0,0 +1,259 @@
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import { useCustomer } from "autumn-js/react";
import { useEffect, useRef, useState } from "react";
import { Sparkles } from "lucide-react";
import { Markdown } from "@/client/components/Markdown";
import { captureClientEvent } from "@/client/lib/posthog";
import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing";
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
import {
ChatComposer,
ChatGate,
SuggestedQuestions,
UpgradeSidebar,
WelcomeMessage,
} from "./OnboardingStrategyChatParts";
function messageHasText(message: UIMessage): boolean {
return message.parts.some(
(part) => part.type === "text" && part.text.trim().length > 0,
);
}
function ChatBubble({ message }: { message: UIMessage }) {
const isUser = message.role === "user";
if (isUser) {
return (
<div className="flex justify-end pl-8 sm:pl-16">
<div className="rounded-box rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-content">
{message.parts.map((part, index) =>
part.type === "text" ? (
<span key={index} className="whitespace-pre-wrap">
{part.text}
</span>
) : null,
)}
</div>
</div>
);
}
return (
<div className="flex gap-3">
<div className="flex size-7 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Sparkles className="size-4" />
</div>
<div className="min-w-0 flex-1 space-y-2 pt-0.5 text-sm">
{message.parts.map((part, index) =>
part.type === "text" && part.text.trim() ? (
<Markdown key={index}>{part.text}</Markdown>
) : null,
)}
</div>
</div>
);
}
const SUGGESTED_QUESTIONS = [
"How does OpenSEO help me get more traffic?",
"Why is OpenSEO better than Claude?",
"What do I get after I upgrade?",
"How does Google Search Console work in OpenSEO?",
];
// Offered as a highlighted chip only when the user hasn't already asked for
// their strategy via the welcome CTA. Clicking it prompts Sam to draft/show it.
const STRATEGY_SUGGESTION = "What do you recommend for my site?";
export function StrategyChat({
projectId,
domain,
}: {
projectId: string;
domain: string;
}) {
const { messages, sendMessage, status, error } = useChat({
transport: new DefaultChatTransport({
api: "/api/onboarding/chat",
body: { projectId },
}),
});
// This chat is only ever the pre-upgrade free preview: once a user upgrades
// they are routed into the GSC onboarding step and never return here, so
// there's no "paid" state to model — the question cap always applies.
const customerQuery = useCustomer();
const [checkoutError, setCheckoutError] = useState<string | null>(null);
const [isStartingCheckout, setIsStartingCheckout] = useState(false);
const [usedSuggestions, setUsedSuggestions] = useState<string[]>([]);
// Set once the user asks for their strategy (welcome CTA or the strategy
// chip) so we don't keep offering the "What do you recommend" chip.
const [strategyRequested, setStrategyRequested] = useState(false);
const questionsUsed = messages.filter((m) => m.role === "user").length;
const remaining = Math.max(0, FREE_ONBOARDING_QUESTION_LIMIT - questionsUsed);
const isLocked = remaining <= 0;
// Nudge once they're within the last few questions, not from the start.
const showRemainingHint = remaining > 0 && remaining <= 3;
const isBusy = status === "submitted" || status === "streaming";
const sendText = (text: string) => void sendMessage({ text });
async function startCheckout() {
setCheckoutError(null);
setIsStartingCheckout(true);
try {
captureClientEvent("billing:checkout_start");
// After payment, re-enter onboarding at the GSC step (not back into this
// chat) so the user finishes connecting Search Console + MCP.
const successUrl = new URL("/onboarding", window.location.origin);
successUrl.searchParams.set("step", "3");
successUrl.searchParams.set("checkout", "success");
await customerQuery.attach({
planId: AUTUMN_PAID_PLAN_ID,
redirectMode: "always",
successUrl: successUrl.toString(),
});
} catch (checkoutErr) {
console.error("Failed to start checkout", checkoutErr);
setCheckoutError(
"We couldn't start checkout. Please refresh and try again.",
);
setIsStartingCheckout(false);
}
}
// Pin to the bottom while the user is following along; the strategy doc plus
// a streaming reply quickly grows past the viewport.
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages, status]);
// Show a typing indicator until the assistant's reply starts streaming text.
const lastMessage = messages[messages.length - 1];
const suggestionPool = strategyRequested
? SUGGESTED_QUESTIONS
: [STRATEGY_SUGGESTION, ...SUGGESTED_QUESTIONS];
const remainingSuggestions = suggestionPool.filter(
(question) => !usedSuggestions.includes(question),
);
const showTyping = isBusy && (!lastMessage || !messageHasText(lastMessage));
const showSuggestions =
remainingSuggestions.length > 0 &&
!isBusy &&
messages.length > 0 &&
lastMessage?.role === "assistant";
return (
<div className="flex min-h-0 flex-1">
<UpgradeSidebar
domain={domain}
questionsUsed={questionsUsed}
isStartingCheckout={isStartingCheckout}
onUpgrade={() => void startCheckout()}
/>
<div className="flex min-w-0 flex-1 flex-col">
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-6">
<div className="mx-auto max-w-2xl space-y-6">
<WelcomeMessage
domain={domain}
checkoutError={checkoutError}
isStartingCheckout={isStartingCheckout}
onUpgrade={() => void startCheckout()}
onAskAboutOpenSeo={() =>
sendText("I have questions about OpenSEO before I upgrade.")
}
onProposeStrategy={() => {
setStrategyRequested(true);
sendText(
`Please analyze ${domain} and show me my SEO strategy.`,
);
}}
disableActions={isBusy || messages.length > 0}
/>
{messages.map((message) => (
<ChatBubble key={message.id} message={message} />
))}
{showTyping ? (
<div className="flex gap-3">
<div className="flex size-7 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Sparkles className="size-4" />
</div>
<div className="flex items-center gap-1.5 pt-2 text-base-content/40">
<span className="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
<span className="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
<span className="size-1.5 animate-bounce rounded-full bg-current" />
</div>
</div>
) : null}
{status === "error" ? (
<div className="flex gap-3">
<div className="flex size-7 flex-shrink-0 items-center justify-center rounded-full bg-error/10 text-error">
<Sparkles className="size-4" />
</div>
<p className="pt-1 text-sm text-error">
{/* useChat surfaces the failed response body as error.message;
the server's 402 backstop sends the paywall copy below. */}
{(error?.message ?? "").includes("free strategy questions")
? "You've reached the free question limit. Upgrade to continue."
: "Something went wrong. Please refresh and try again."}
</p>
</div>
) : null}
{showSuggestions ? (
<SuggestedQuestions
questions={remainingSuggestions}
primaryQuestion={STRATEGY_SUGGESTION}
onSelect={(question) => {
setUsedSuggestions((current) =>
current.includes(question)
? current
: [...current, question],
);
if (question === STRATEGY_SUGGESTION) {
setStrategyRequested(true);
}
sendText(question);
}}
/>
) : null}
</div>
</div>
{isLocked ? (
<ChatGate
isStartingCheckout={isStartingCheckout}
onUpgrade={() => void startCheckout()}
/>
) : (
<div className="flex-shrink-0 border-t border-base-300 px-5 py-3">
<div className="mx-auto w-full max-w-2xl space-y-2">
{showRemainingHint ? (
<p className="px-1 text-xs text-base-content/50">
{remaining} free question{remaining === 1 ? "" : "s"} left.{" "}
<button
type="button"
className="link link-primary"
disabled={isStartingCheckout}
onClick={() => void startCheckout()}
>
Upgrade for full access
</button>
</p>
) : null}
<ChatComposer busy={isBusy} onSend={sendText} />
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,339 @@
import {
useLayoutEffect,
useRef,
useState,
type FormEvent,
type KeyboardEvent,
} from "react";
import {
ArrowUp,
Check,
Globe,
Loader2,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
const DISCORD_URL = "https://discord.gg/c9uGs3cFXr";
export function SuggestedQuestions({
questions,
primaryQuestion,
onSelect,
}: {
questions: string[];
primaryQuestion?: string;
onSelect: (question: string) => void;
}) {
return (
<div className="ml-10 flex flex-wrap gap-2">
{questions.map((question) =>
question === primaryQuestion ? (
<button
key={question}
type="button"
className="inline-flex items-center gap-1.5 rounded-full border border-primary/40 bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/15"
onClick={() => onSelect(question)}
>
<Sparkles className="size-3.5" />
{question}
</button>
) : (
<button
key={question}
type="button"
className="rounded-full border border-base-300 bg-base-100 px-3 py-1.5 text-xs font-medium text-base-content/70 transition-colors hover:border-primary/50 hover:text-base-content"
onClick={() => onSelect(question)}
>
{question}
</button>
),
)}
</div>
);
}
export function WelcomeMessage({
domain,
checkoutError,
isStartingCheckout,
onUpgrade,
onAskAboutOpenSeo,
onProposeStrategy,
disableActions,
}: {
domain: string;
checkoutError: string | null;
isStartingCheckout: boolean;
onUpgrade: () => void;
onAskAboutOpenSeo: () => void;
onProposeStrategy: () => void;
disableActions: boolean;
}) {
return (
<div className="flex gap-3">
<div className="flex size-7 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Sparkles className="size-4" />
</div>
<div className="min-w-0 flex-1 space-y-3 pt-0.5 text-sm">
<div className="space-y-3 text-base-content/80">
<p>Hey, Im Sam welcome to OpenSEO.</p>
<p>
Youre on the free preview: {FREE_ONBOARDING_QUESTION_LIMIT}{" "}
questions with me before you upgrade. I can draft an SEO strategy
for <span className="font-medium text-base-content">{domain}</span>{" "}
and answer questions about OpenSEO or SEO.
</p>
<p>
Want me to analyze{" "}
<span className="font-medium text-base-content">{domain}</span> and
draft a strategy, or do you have questions first?
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="btn btn-soft btn-sm"
disabled={disableActions}
onClick={onAskAboutOpenSeo}
>
Ask about OpenSEO
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={disableActions}
onClick={onProposeStrategy}
>
Show my strategy
</button>
</div>
<div className="rounded-box border border-base-300 bg-base-200/50 p-3 text-xs lg:hidden">
<p className="font-medium">Want Sam to keep going?</p>
<p className="mt-0.5 text-base-content/70">
Upgrade to run keyword research, rank tracking, and site audits on{" "}
{domain}.
</p>
<button
type="button"
className="btn btn-primary btn-xs mt-2"
disabled={isStartingCheckout}
onClick={onUpgrade}
>
{isStartingCheckout ? "Redirecting..." : "Upgrade"}
</button>
{checkoutError ? (
<p className="mt-2 text-error">{checkoutError}</p>
) : null}
</div>
</div>
</div>
);
}
// Left-rail upgrade CTA. Hidden below `lg` (the inline callout + remaining
// hint cover narrow viewports).
export function UpgradeSidebar({
domain,
questionsUsed,
isStartingCheckout,
onUpgrade,
}: {
domain: string;
questionsUsed: number;
isStartingCheckout: boolean;
onUpgrade: () => void;
}) {
const features = [
"Keyword research, backlinks, rank tracking & site audits",
"Google Search Console — read-only, no credits, no Google Cloud setup",
"Connect Claude, Cursor, Codex & other MCP clients",
"Top-up credits roll over and never expire",
];
const used = Math.min(questionsUsed, FREE_ONBOARDING_QUESTION_LIMIT);
const progress = (used / FREE_ONBOARDING_QUESTION_LIMIT) * 100;
return (
<aside className="hidden w-96 flex-shrink-0 flex-col border-r border-base-300 bg-base-200/20 lg:flex">
<div className="flex items-center gap-2.5 border-b border-base-300 px-6 py-4 text-xs text-base-content/55">
<span className="inline-flex size-8 items-center justify-center rounded-full border border-base-300 bg-base-100 text-primary">
<Globe className="size-4" />
</span>
<div className="min-w-0">
<p className="font-medium text-base-content/80">Previewing OpenSEO</p>
<p className="truncate" title={domain}>
{domain}
</p>
</div>
</div>
<div className="flex flex-1 flex-col gap-5 px-6 py-6">
<div>
<div className="flex items-baseline gap-1.5">
<span className="text-3xl font-semibold tracking-tight">$10</span>
<span className="text-sm text-base-content/55">/month</span>
</div>
<p className="mt-1.5 text-xs leading-relaxed text-base-content/55">
Includes $10 of usage credits every month, plus a 30-day money-back
guarantee.
</p>
</div>
<ul className="space-y-3 border-t border-base-300 pt-5">
{features.map((label) => (
<li
key={label}
className="flex gap-2.5 text-sm leading-snug text-base-content/75"
>
<Check className="mt-0.5 size-4 flex-shrink-0 text-primary" />
<span>{label}</span>
</li>
))}
</ul>
<div className="mt-auto space-y-3 pt-2">
<button
type="button"
className="btn btn-primary w-full"
disabled={isStartingCheckout}
onClick={onUpgrade}
>
{isStartingCheckout ? "Redirecting..." : "Upgrade to continue"}
</button>
<p className="flex items-center justify-center gap-1.5 text-center text-xs text-base-content/45">
<ShieldCheck className="size-3.5" />
Open source self-host for free anytime
</p>
<p className="text-center text-xs leading-relaxed text-base-content/55">
Want advice from other OpenSEO users?{" "}
<a
href={DISCORD_URL}
target="_blank"
rel="noreferrer"
className="link link-primary"
>
Join the Discord
</a>
.
</p>
</div>
</div>
<div className="space-y-1.5 border-t border-base-300 px-6 py-4">
<div className="h-1 w-full overflow-hidden rounded-full bg-base-300">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-xs text-base-content/55">
{used} of {FREE_ONBOARDING_QUESTION_LIMIT} free questions used
</p>
</div>
</aside>
);
}
// Replaces the composer once a free user exhausts their question allowance.
export function ChatGate({
isStartingCheckout,
onUpgrade,
}: {
isStartingCheckout: boolean;
onUpgrade: () => void;
}) {
return (
<div className="flex-shrink-0 border-t border-base-300 px-5 py-4">
<div className="mx-auto w-full max-w-2xl rounded-box border border-primary/30 bg-primary/5 p-4 text-center">
<p className="text-sm font-medium">
Thats all {FREE_ONBOARDING_QUESTION_LIMIT} free questions
</p>
<p className="mx-auto mt-1 max-w-md text-xs text-base-content/70">
Upgrade to keep working with Sam and unlock the full OpenSEO app.
</p>
<button
type="button"
className="btn btn-primary btn-sm mt-3"
disabled={isStartingCheckout}
onClick={onUpgrade}
>
{isStartingCheckout ? "Redirecting..." : "Upgrade to continue"}
</button>
<p className="mt-2 text-xs text-base-content/45">
30-day money-back guarantee
</p>
</div>
</div>
);
}
export function ChatComposer({
busy,
onSend,
}: {
busy: boolean;
onSend: (text: string) => void;
}) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-grow the textarea up to a few lines, then scroll. Resetting height to
// `auto` first lets it shrink as well as grow.
useLayoutEffect(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.style.height = "auto";
ta.style.height = `${Math.min(ta.scrollHeight, 160)}px`;
}, [value]);
function submit() {
const text = value.trim();
if (!text || busy) return;
onSend(text);
setValue("");
}
function handleSubmit(event: FormEvent) {
event.preventDefault();
submit();
}
function handleKey(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit();
}
}
return (
<form
onSubmit={handleSubmit}
className="flex items-end gap-2 rounded-box border border-base-300 bg-base-100 px-3 py-2 focus-within:border-primary"
>
<textarea
ref={textareaRef}
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={handleKey}
rows={1}
placeholder="Ask Sam about your strategy or OpenSEO…"
className="max-h-40 flex-1 resize-none border-0 bg-transparent px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-base-content/50 focus:outline-none"
/>
<button
type="submit"
aria-label="Send message"
disabled={busy || !value.trim()}
className="btn btn-primary btn-circle btn-sm"
>
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ArrowUp className="size-4" />
)}
</button>
</form>
);
}

View File

@ -23,6 +23,7 @@ type PostSignupOnboardingProps = {
onBack: () => void; onBack: () => void;
onSkip: () => void; onSkip: () => void;
onFinish: (mcpSetupIntent: "yes" | "no") => void; onFinish: (mcpSetupIntent: "yes" | "no") => void;
onUpgradeAcknowledged: () => void;
isSaving: boolean; isSaving: boolean;
accountMenu: ReactNode; accountMenu: ReactNode;
}; };
@ -38,6 +39,7 @@ export function PostSignupOnboarding({
onBack, onBack,
onSkip, onSkip,
onFinish, onFinish,
onUpgradeAcknowledged,
isSaving, isSaving,
accountMenu, accountMenu,
}: PostSignupOnboardingProps) { }: PostSignupOnboardingProps) {
@ -53,6 +55,55 @@ export function PostSignupOnboarding({
const updateAnswers = (patch: Partial<OnboardingAnswers>) => const updateAnswers = (patch: Partial<OnboardingAnswers>) =>
onAnswersChange({ ...answers, ...patch }); onAnswersChange({ ...answers, ...patch });
// After a successful checkout the user lands on the GSC step with
// `?checkout=success`. Show a one-time "you're in" screen (same layout as the
// steps) and only reveal the actual GSC step once they continue, which drops
// the param.
const justUpgraded =
step === 3 &&
typeof window !== "undefined" &&
new URLSearchParams(window.location.search).get("checkout") === "success";
if (justUpgraded) {
return (
<div className="w-full max-w-md space-y-6">
{accountMenu}
<div className="text-center space-y-3">
<img
src="/transparent-logo.png"
alt="OpenSEO"
className="mx-auto size-10 rounded-lg"
/>
<h1 className="text-xl font-semibold">Youre in! 🎉</h1>
<p className="text-sm text-base-content/60">
Your subscriptions active.
</p>
</div>
<div className="rounded-lg border border-base-300 bg-base-100 p-5 shadow-sm">
<h2 className="text-lg font-semibold">
Finish setting up your account
</h2>
<p className="mt-1.5 text-sm leading-relaxed text-base-content/70">
Two quick steps left connect Google Search Console, then set up
MCP for your agent.
</p>
<div className="mt-5 flex justify-end">
<button
type="button"
className="btn btn-soft"
onClick={onUpgradeAcknowledged}
>
Continue
<ArrowRight className="size-4" />
</button>
</div>
</div>
</div>
);
}
return ( return (
<div className="w-full max-w-md space-y-6"> <div className="w-full max-w-md space-y-6">
{accountMenu} {accountMenu}

View File

@ -14,8 +14,8 @@ export const INTEREST_OPTIONS = [
] as const; ] as const;
export const WORK_FOR_OPTIONS = [ export const WORK_FOR_OPTIONS = [
"My clients",
"My own startup or business", "My own startup or business",
"My clients",
"My employer's website", "My employer's website",
"My own side project", "My own side project",
"I'm exploring before choosing a project", "I'm exploring before choosing a project",

View File

@ -0,0 +1,13 @@
import { queryOptions } from "@tanstack/react-query";
import { queryClient } from "@/client/tanstack-db";
import { getOnboardingStrategyState } from "@/serverFunctions/onboardingStrategy";
export const strategyStateQueryOptions = () =>
queryOptions({
queryKey: ["onboardingStrategyState"],
queryFn: () => getOnboardingStrategyState(),
});
export function invalidateStrategyState() {
void queryClient.invalidateQueries({ queryKey: ["onboardingStrategyState"] });
}

View File

@ -5,7 +5,6 @@ import {
ChevronDown, ChevronDown,
CircleHelp, CircleHelp,
CreditCard, CreditCard,
FolderCog,
Menu, Menu,
Settings, Settings,
User, User,
@ -16,10 +15,7 @@ import {
SeoApiStatusBanners, SeoApiStatusBanners,
} from "@/client/layout/AppShellParts"; } from "@/client/layout/AppShellParts";
import { GscReEngagementModal } from "@/client/features/gsc/GscReEngagementModal"; import { GscReEngagementModal } from "@/client/features/gsc/GscReEngagementModal";
import { import { getProjectNavGroups } from "@/client/navigation/items";
getProjectNavGroups,
projectSettingsLinkOptions,
} from "@/client/navigation/items";
import { signOutAndRedirect, useSession } from "@/lib/auth-client"; import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { BILLING_ROUTE } from "@/shared/billing"; import { BILLING_ROUTE } from "@/shared/billing";
@ -302,22 +298,16 @@ function TopNav({
<div className="flex items-center rounded-full border border-base-300 bg-base-100/70 px-1 py-1 shadow-sm"> <div className="flex items-center rounded-full border border-base-300 bg-base-100/70 px-1 py-1 shadow-sm">
<ProjectSwitcher activeProjectId={projectId} variant="topbar" /> <ProjectSwitcher activeProjectId={projectId} variant="topbar" />
<AccountMenu projectId={projectId} /> <AccountMenu />
</div> </div>
</div> </div>
<AccountMenu mobileOnly projectId={projectId} /> <AccountMenu mobileOnly />
</div> </div>
); );
} }
function AccountMenu({ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
mobileOnly = false,
projectId,
}: {
mobileOnly?: boolean;
projectId?: string | null;
}) {
const { data: session } = useSession(); const { data: session } = useSession();
const isHostedMode = isHostedClientAuthMode(); const isHostedMode = isHostedClientAuthMode();
const email = session?.user?.email; const email = session?.user?.email;
@ -362,17 +352,6 @@ function AccountMenu({
</Link> </Link>
</li> </li>
) : null} ) : null}
{projectId ? (
<li>
<Link
{...projectSettingsLinkOptions(projectId)}
className="flex items-center gap-2"
>
<FolderCog className="h-4 w-4" />
Project settings
</Link>
</li>
) : null}
<li> <li>
<Link to="/settings" className="flex items-center gap-2"> <Link to="/settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />

View File

@ -120,15 +120,6 @@ export function getProjectNavGroups(projectId: string) {
]; ];
} }
// Per-project configuration (name, domain, Search Console, delete) lives on the
// project settings page rather than the workflow nav, hence the projectId param.
export function projectSettingsLinkOptions(projectId: string) {
return linkOptions({
to: "/p/$projectId/settings" as const,
params: { projectId },
});
}
export const dataforseoHelpLinkOptions = linkOptions({ export const dataforseoHelpLinkOptions = linkOptions({
to: "/help/dataforseo-api-key", to: "/help/dataforseo-api-key",
}); });

View File

@ -50,6 +50,14 @@ export const projects = sqliteTable(
.references(() => organization.id, { onDelete: "cascade" }), .references(() => organization.id, { onDelete: "cascade" }),
name: text("name").notNull(), name: text("name").notNull(),
domain: text("domain"), domain: text("domain"),
// Default DataForSEO location/language for the project, set during
// onboarding and reused by every project-scoped data call.
locationCode: integer("location_code").notNull().default(2840),
languageCode: text("language_code").notNull().default("en"),
// Onboarding strategy generation run state. Null = never run; the atomic
// transition null -> 'running' is the at-most-once guard for the (free) run.
onboardingRunStatus: text("onboarding_run_status"),
onboardingRunAt: text("onboarding_run_at"),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),

10
src/env.d.ts vendored
View File

@ -24,6 +24,11 @@ declare namespace Cloudflare {
// DataForSEO API Basic auth value (base64 of login:password) // DataForSEO API Basic auth value (base64 of login:password)
DATAFORSEO_API_KEY: string; DATAFORSEO_API_KEY: string;
// OpenRouter API key for onboarding strategy synthesis + chat.
OPENROUTER_API_KEY?: string;
// Optional OpenRouter model slug override (defaults in openrouter.ts).
OPENROUTER_MODEL?: string;
} }
} }
@ -39,3 +44,8 @@ interface ImportMetaEnv {
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv; readonly env: ImportMetaEnv;
} }
declare module "*.md?raw" {
const content: string;
export default content;
}

View File

@ -65,6 +65,8 @@ export async function resolveDelegatedContext(
return { return {
userId, userId,
userEmail: ensuredEmail, userEmail: ensuredEmail,
// Delegated auth (Cloudflare Access / local) has no unverified state.
emailVerified: true,
organizationId, organizationId,
}; };
} }

View File

@ -31,6 +31,7 @@ export async function resolveHostedContext(
return { return {
userId: session.user.id, userId: session.user.id,
userEmail: session.user.email, userEmail: session.user.email,
emailVerified: session.user.emailVerified ?? false,
organizationId: activeOrganizationId, organizationId: activeOrganizationId,
}; };
} }
@ -49,6 +50,7 @@ export async function resolveHostedContext(
return { return {
userId: session.user.id, userId: session.user.id,
userEmail: session.user.email, userEmail: session.user.email,
emailVerified: session.user.emailVerified ?? false,
organizationId, organizationId,
}; };
} }

View File

@ -0,0 +1,22 @@
import { env } from "cloudflare:workers";
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
import { resolveCloudflareAccessContext } from "./cloudflareAccess";
import { resolveLocalNoAuthContext } from "./delegated";
import { resolveHostedContext } from "./hosted";
import type { EnsuredUserContext } from "./types";
// Resolves the authenticated user for a request's headers across every auth
// mode. Shared by ensureUserMiddleware (server functions) and raw API routes,
// which can't use function middleware.
export async function resolveUserContextFromHeaders(
headers: Headers,
): Promise<EnsuredUserContext> {
const authMode = getAuthMode(env.AUTH_MODE);
if (authMode === "local_noauth") {
return resolveLocalNoAuthContext();
}
if (isHostedAuthMode(authMode)) {
return resolveHostedContext(headers);
}
return resolveCloudflareAccessContext(headers);
}

View File

@ -7,6 +7,10 @@ export type EnsuredProject = NonNullable<
export type EnsuredUserContext = { export type EnsuredUserContext = {
userId: string; userId: string;
userEmail: string; userEmail: string;
// True when the user's email is verified (hosted) or auth is delegated
// (Cloudflare Access / local), where there is no unverified state. Used to
// gate paid onboarding spend behind verification.
emailVerified: boolean;
organizationId: string; organizationId: string;
project?: EnsuredProject; project?: EnsuredProject;
}; };

View File

@ -1,16 +1,9 @@
import { createMiddleware } from "@tanstack/react-start"; import { createMiddleware } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server"; import { getRequest } from "@tanstack/react-start/server";
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode"; import { resolveUserContextFromHeaders } from "@/middleware/ensure-user/resolve";
import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess"; import type { EnsuredProject } from "@/middleware/ensure-user/types";
import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
import { resolveHostedContext } from "@/middleware/ensure-user/hosted";
import type {
EnsuredProject,
EnsuredUserContext,
} from "@/middleware/ensure-user/types";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository"; import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { env } from "cloudflare:workers";
function extractProjectId(data: unknown) { function extractProjectId(data: unknown) {
if (!data || typeof data !== "object" || !("projectId" in data)) { if (!data || typeof data !== "object" || !("projectId" in data)) {
@ -26,17 +19,7 @@ function extractProjectId(data: unknown) {
export const ensureUserMiddleware = createMiddleware({ export const ensureUserMiddleware = createMiddleware({
type: "function", type: "function",
}).server(async ({ next, data }) => { }).server(async ({ next, data }) => {
const authMode = getAuthMode(env.AUTH_MODE); const context = await resolveUserContextFromHeaders(getRequest().headers);
const headers = getRequest().headers;
let context: EnsuredUserContext;
if (authMode === "local_noauth") {
context = await resolveLocalNoAuthContext();
} else if (isHostedAuthMode(authMode)) {
context = await resolveHostedContext(headers);
} else {
context = await resolveCloudflareAccessContext(headers);
}
const projectId = extractProjectId(data); const projectId = extractProjectId(data);

View File

@ -18,7 +18,6 @@ import { Route as ProjectRouteRouteImport } from './routes/_project/route'
import { Route as AppRouteRouteImport } from './routes/_app/route' import { Route as AppRouteRouteImport } from './routes/_app/route'
import { Route as AppIndexRouteImport } from './routes/_app/index' import { Route as AppIndexRouteImport } from './routes/_app/index'
import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe' import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe'
import { Route as AuthenticatedOnboardingRouteImport } from './routes/_authenticated.onboarding'
import { Route as AuthenticatedOauthConsentRouteImport } from './routes/_authenticated.oauth-consent' import { Route as AuthenticatedOauthConsentRouteImport } from './routes/_authenticated.oauth-consent'
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up' import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in' import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
@ -28,8 +27,11 @@ import { Route as AppProjectsRouteImport } from './routes/_app/projects'
import { Route as AppBillingRouteImport } from './routes/_app/billing' import { Route as AppBillingRouteImport } from './routes/_app/billing'
import { Route as AppAiRouteImport } from './routes/_app/ai' import { Route as AppAiRouteImport } from './routes/_app/ai'
import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge' import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge'
import { Route as AuthenticatedOnboardingIndexRouteImport } from './routes/_authenticated.onboarding.index'
import { Route as ApiOnboardingChatRouteImport } from './routes/api/onboarding/chat'
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$' import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat'
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key' import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route' import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index' import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
@ -89,11 +91,6 @@ const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({
path: '/subscribe', path: '/subscribe',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedOnboardingRoute = AuthenticatedOnboardingRouteImport.update({
id: '/onboarding',
path: '/onboarding',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedOauthConsentRoute = const AuthenticatedOauthConsentRoute =
AuthenticatedOauthConsentRouteImport.update({ AuthenticatedOauthConsentRouteImport.update({
id: '/oauth-consent', id: '/oauth-consent',
@ -141,6 +138,17 @@ const Char91DotwellKnownChar93OpenaiAppsChallengeRoute =
path: '/.well-known/openai-apps-challenge', path: '/.well-known/openai-apps-challenge',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AuthenticatedOnboardingIndexRoute =
AuthenticatedOnboardingIndexRouteImport.update({
id: '/onboarding/',
path: '/onboarding/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const ApiOnboardingChatRoute = ApiOnboardingChatRouteImport.update({
id: '/api/onboarding/chat',
path: '/api/onboarding/chat',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({ const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
id: '/api/autumn/$', id: '/api/autumn/$',
path: '/api/autumn/$', path: '/api/autumn/$',
@ -151,6 +159,12 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
path: '/api/auth/$', path: '/api/auth/$',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AuthenticatedOnboardingChatRoute =
AuthenticatedOnboardingChatRouteImport.update({
id: '/onboarding/chat',
path: '/onboarding/chat',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({ const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({
id: '/help/dataforseo-api-key', id: '/help/dataforseo-api-key',
path: '/help/dataforseo-api-key', path: '/help/dataforseo-api-key',
@ -261,12 +275,14 @@ export interface FileRoutesByFullPath {
'/sign-in': typeof AuthSignInRoute '/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute '/sign-up': typeof AuthSignUpRoute
'/oauth-consent': typeof AuthenticatedOauthConsentRoute '/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/onboarding': typeof AuthenticatedOnboardingRoute
'/subscribe': typeof AuthenticatedSubscribeRoute '/subscribe': typeof AuthenticatedSubscribeRoute
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/onboarding/chat': typeof ApiOnboardingChatRoute
'/onboarding/': typeof AuthenticatedOnboardingIndexRoute
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
@ -297,11 +313,13 @@ export interface FileRoutesByTo {
'/sign-in': typeof AuthSignInRoute '/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute '/sign-up': typeof AuthSignUpRoute
'/oauth-consent': typeof AuthenticatedOauthConsentRoute '/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/onboarding': typeof AuthenticatedOnboardingRoute
'/subscribe': typeof AuthenticatedSubscribeRoute '/subscribe': typeof AuthenticatedSubscribeRoute
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/onboarding/chat': typeof ApiOnboardingChatRoute
'/onboarding': typeof AuthenticatedOnboardingIndexRoute
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute '/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
@ -334,13 +352,15 @@ export interface FileRoutesById {
'/_auth/sign-in': typeof AuthSignInRoute '/_auth/sign-in': typeof AuthSignInRoute
'/_auth/sign-up': typeof AuthSignUpRoute '/_auth/sign-up': typeof AuthSignUpRoute
'/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute '/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/_authenticated/onboarding': typeof AuthenticatedOnboardingRoute
'/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute '/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute
'/_app/': typeof AppIndexRoute '/_app/': typeof AppIndexRoute
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/onboarding/chat': typeof ApiOnboardingChatRoute
'/_authenticated/onboarding/': typeof AuthenticatedOnboardingIndexRoute
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
@ -373,12 +393,14 @@ export interface FileRouteTypes {
| '/sign-in' | '/sign-in'
| '/sign-up' | '/sign-up'
| '/oauth-consent' | '/oauth-consent'
| '/onboarding'
| '/subscribe' | '/subscribe'
| '/p/$projectId' | '/p/$projectId'
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/onboarding/chat'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/api/onboarding/chat'
| '/onboarding/'
| '/p/$projectId/audit' | '/p/$projectId/audit'
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
| '/p/$projectId/brand-lookup' | '/p/$projectId/brand-lookup'
@ -409,11 +431,13 @@ export interface FileRouteTypes {
| '/sign-in' | '/sign-in'
| '/sign-up' | '/sign-up'
| '/oauth-consent' | '/oauth-consent'
| '/onboarding'
| '/subscribe' | '/subscribe'
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/onboarding/chat'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/api/onboarding/chat'
| '/onboarding'
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
| '/p/$projectId/brand-lookup' | '/p/$projectId/brand-lookup'
| '/p/$projectId/domain' | '/p/$projectId/domain'
@ -445,13 +469,15 @@ export interface FileRouteTypes {
| '/_auth/sign-in' | '/_auth/sign-in'
| '/_auth/sign-up' | '/_auth/sign-up'
| '/_authenticated/oauth-consent' | '/_authenticated/oauth-consent'
| '/_authenticated/onboarding'
| '/_authenticated/subscribe' | '/_authenticated/subscribe'
| '/_app/' | '/_app/'
| '/_project/p/$projectId' | '/_project/p/$projectId'
| '/_app/help/dataforseo-api-key' | '/_app/help/dataforseo-api-key'
| '/_authenticated/onboarding/chat'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/api/onboarding/chat'
| '/_authenticated/onboarding/'
| '/_project/p/$projectId/audit' | '/_project/p/$projectId/audit'
| '/_project/p/$projectId/backlinks' | '/_project/p/$projectId/backlinks'
| '/_project/p/$projectId/brand-lookup' | '/_project/p/$projectId/brand-lookup'
@ -480,6 +506,7 @@ export interface RootRouteChildren {
Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
ApiOnboardingChatRoute: typeof ApiOnboardingChatRoute
ApiGscOauthCallbackRoute: typeof ApiGscOauthCallbackRoute ApiGscOauthCallbackRoute: typeof ApiGscOauthCallbackRoute
} }
@ -548,13 +575,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSubscribeRouteImport preLoaderRoute: typeof AuthenticatedSubscribeRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_authenticated/onboarding': {
id: '/_authenticated/onboarding'
path: '/onboarding'
fullPath: '/onboarding'
preLoaderRoute: typeof AuthenticatedOnboardingRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/oauth-consent': { '/_authenticated/oauth-consent': {
id: '/_authenticated/oauth-consent' id: '/_authenticated/oauth-consent'
path: '/oauth-consent' path: '/oauth-consent'
@ -618,6 +638,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport preLoaderRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/_authenticated/onboarding/': {
id: '/_authenticated/onboarding/'
path: '/onboarding'
fullPath: '/onboarding/'
preLoaderRoute: typeof AuthenticatedOnboardingIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/api/onboarding/chat': {
id: '/api/onboarding/chat'
path: '/api/onboarding/chat'
fullPath: '/api/onboarding/chat'
preLoaderRoute: typeof ApiOnboardingChatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/autumn/$': { '/api/autumn/$': {
id: '/api/autumn/$' id: '/api/autumn/$'
path: '/api/autumn/$' path: '/api/autumn/$'
@ -632,6 +666,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiAuthSplatRouteImport preLoaderRoute: typeof ApiAuthSplatRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/_authenticated/onboarding/chat': {
id: '/_authenticated/onboarding/chat'
path: '/onboarding/chat'
fullPath: '/onboarding/chat'
preLoaderRoute: typeof AuthenticatedOnboardingChatRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_app/help/dataforseo-api-key': { '/_app/help/dataforseo-api-key': {
id: '/_app/help/dataforseo-api-key' id: '/_app/help/dataforseo-api-key'
path: '/help/dataforseo-api-key' path: '/help/dataforseo-api-key'
@ -872,14 +913,16 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
interface AuthenticatedRouteChildren { interface AuthenticatedRouteChildren {
AuthenticatedOauthConsentRoute: typeof AuthenticatedOauthConsentRoute AuthenticatedOauthConsentRoute: typeof AuthenticatedOauthConsentRoute
AuthenticatedOnboardingRoute: typeof AuthenticatedOnboardingRoute
AuthenticatedSubscribeRoute: typeof AuthenticatedSubscribeRoute AuthenticatedSubscribeRoute: typeof AuthenticatedSubscribeRoute
AuthenticatedOnboardingChatRoute: typeof AuthenticatedOnboardingChatRoute
AuthenticatedOnboardingIndexRoute: typeof AuthenticatedOnboardingIndexRoute
} }
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedOauthConsentRoute: AuthenticatedOauthConsentRoute, AuthenticatedOauthConsentRoute: AuthenticatedOauthConsentRoute,
AuthenticatedOnboardingRoute: AuthenticatedOnboardingRoute,
AuthenticatedSubscribeRoute: AuthenticatedSubscribeRoute, AuthenticatedSubscribeRoute: AuthenticatedSubscribeRoute,
AuthenticatedOnboardingChatRoute: AuthenticatedOnboardingChatRoute,
AuthenticatedOnboardingIndexRoute: AuthenticatedOnboardingIndexRoute,
} }
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren( const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
@ -898,6 +941,7 @@ const rootRouteChildren: RootRouteChildren = {
Char91DotwellKnownChar93OpenaiAppsChallengeRoute, Char91DotwellKnownChar93OpenaiAppsChallengeRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiAutumnSplatRoute: ApiAutumnSplatRoute, ApiAutumnSplatRoute: ApiAutumnSplatRoute,
ApiOnboardingChatRoute: ApiOnboardingChatRoute,
ApiGscOauthCallbackRoute: ApiGscOauthCallbackRoute, ApiGscOauthCallbackRoute: ApiGscOauthCallbackRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport

View File

@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { OnboardingStrategy } from "@/client/features/onboarding/OnboardingStrategy";
export const Route = createFileRoute("/_authenticated/onboarding/chat")({
component: OnboardingStrategy,
});

View File

@ -21,16 +21,10 @@ import { saveOnboardingAnswers } from "@/serverFunctions/onboarding";
const ONBOARDING_EXISTING_USER_CUTOFF = "2026-05-27T00:00:00.000Z"; const ONBOARDING_EXISTING_USER_CUTOFF = "2026-05-27T00:00:00.000Z";
// First step that requires a subscription. The earlier steps (interests, who
// you work for, how you found us) collect profiling answers we want even from
// users who bounce at the paywall, so the gate sits here — after them, before
// Search Console + MCP setup.
const SUBSCRIBE_GATE_STEP = 3;
const clampStep = (step: number) => const clampStep = (step: number) =>
Math.min(Math.max(0, Math.trunc(step)), ONBOARDING_LAST_STEP); Math.min(Math.max(0, Math.trunc(step)), ONBOARDING_LAST_STEP);
export const Route = createFileRoute("/_authenticated/onboarding")({ export const Route = createFileRoute("/_authenticated/onboarding/")({
// Step lives in the URL so it survives refresh and works with back/forward. // Step lives in the URL so it survives refresh and works with back/forward.
validateSearch: (search: Record<string, unknown>): { step: number } => { validateSearch: (search: Record<string, unknown>): { step: number } => {
const raw = Number(search.step); const raw = Number(search.step);
@ -90,9 +84,8 @@ function OnboardingFlow({
const { step } = Route.useSearch(); const { step } = Route.useSearch();
const [answers, setAnswers] = useState<OnboardingAnswers>(initialAnswers); const [answers, setAnswers] = useState<OnboardingAnswers>(initialAnswers);
// Self-hosted has no paywall; hosted users must subscribe before the gated // Self-hosted has no paywall. Hosted users now get a short strategy chat
// steps. Answers from earlier steps are already saved, so a user who pays // before the subscribe gate, so this only feeds later paid onboarding steps.
// returns to the gated step with everything intact.
const isHostedMode = isHostedClientAuthMode(); const isHostedMode = isHostedClientAuthMode();
const accessQuery = useQuery({ const accessQuery = useQuery({
...managedAccessQueryOptions(), ...managedAccessQueryOptions(),
@ -117,14 +110,17 @@ function OnboardingFlow({
const goToStep = (next: number) => const goToStep = (next: number) =>
void navigate({ to: "/onboarding", search: { step: clampStep(next) } }); void navigate({ to: "/onboarding", search: { step: clampStep(next) } });
// Advance to the next step, but divert to the paywall when crossing into the
// first gated step. The just-saved answers let the user resume here on return.
const advanceFromCurrentStep = () => { const advanceFromCurrentStep = () => {
if (step === 2) {
void navigate({ to: "/onboarding/chat", replace: true });
return;
}
const next = clampStep(step + 1); const next = clampStep(step + 1);
if (next >= SUBSCRIBE_GATE_STEP && needsSubscription) { if (step >= 3 && needsSubscription) {
void navigate({ void navigate({
to: SUBSCRIBE_ROUTE, to: SUBSCRIBE_ROUTE,
search: { redirect: `/onboarding?step=${SUBSCRIBE_GATE_STEP}` }, search: { redirect: `/onboarding?step=${next}` },
replace: true, replace: true,
}); });
return; return;
@ -187,6 +183,9 @@ function OnboardingFlow({
onBack={() => goToStep(step - 1)} onBack={() => goToStep(step - 1)}
onSkip={handleSkip} onSkip={handleSkip}
onFinish={handleFinish} onFinish={handleFinish}
onUpgradeAcknowledged={() =>
void navigate({ to: "/onboarding", search: { step }, replace: true })
}
isSaving={saveMutation.isPending} isSaving={saveMutation.isPending}
accountMenu={<OnboardingAccountMenu email={email} />} accountMenu={<OnboardingAccountMenu email={email} />}
/> />

View File

@ -3,7 +3,7 @@ import { env } from "cloudflare:workers";
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode"; import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess"; import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated"; import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
import { AppError } from "@/server/lib/errors"; import { responseForAppError } from "@/server/lib/http-errors";
import { handleSelfHostedGscOAuthCallback } from "@/server/features/gsc/selfHostedOAuth"; import { handleSelfHostedGscOAuthCallback } from "@/server/features/gsc/selfHostedOAuth";
import { getPublicOrigin } from "@/server/mcp/public-origin"; import { getPublicOrigin } from "@/server/mcp/public-origin";
@ -17,22 +17,6 @@ async function resolveSelfHostedContext(request: Request) {
: resolveCloudflareAccessContext(request.headers); : 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) { async function handleCallbackRequest(request: Request) {
try { try {
const context = await resolveSelfHostedContext(request); const context = await resolveSelfHostedContext(request);
@ -47,7 +31,7 @@ async function handleCallbackRequest(request: Request) {
publicOrigin: getPublicOrigin(request), publicOrigin: getPublicOrigin(request),
}); });
} catch (error) { } catch (error) {
return responseForError(error); return responseForAppError(error, "Search Console OAuth failed");
} }
} }

View File

@ -0,0 +1,162 @@
import { createFileRoute } from "@tanstack/react-router";
import {
convertToModelMessages,
stepCountIs,
streamText,
tool,
type UIMessage,
} from "ai";
import { z } from "zod";
import { resolveUserContextFromHeaders } from "@/middleware/ensure-user/resolve";
import { AppError } from "@/server/lib/errors";
import { responseForAppError } from "@/server/lib/http-errors";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { runOnboardingSeed } from "@/server/features/onboarding/seed";
import { getOnboardingModel } from "@/server/lib/openrouter";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import {
customerHasManagedAccess,
getOrCreateOrganizationCustomer,
} from "@/server/billing/subscription";
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
import { LOCATIONS } from "@/shared/keyword-locations";
import openSeoFactSheet from "@/server/features/onboarding/openseo-fact-sheet.md?raw";
// Bound the conversation so a single authed user can't drive an unbounded LLM
// context (the chat is a free, pre-paywall surface).
const MAX_MESSAGES = 40;
const bodySchema = z.object({
projectId: z.string().min(1),
// Validate `role` structurally (keeping the UIMessage type) so a caller can't
// smuggle in messages with a bogus role to dodge the free-question count.
messages: z
.array(
z
.custom<UIMessage>()
.and(z.object({ role: z.enum(["user", "assistant", "tool"]) })),
)
.max(MAX_MESSAGES),
});
function buildSystemPrompt(domain: string | null): string {
return [
"You are Sam, the SEO onboarding agent inside OpenSEO. Introduce yourself as Sam if the user asks who you are.",
"Answer SEO questions concisely and practically.",
"Only answer questions related to SEO, OpenSEO, OpenSEO setup, MCP/AI-agent SEO workflows, Google Search Console in OpenSEO, or open-source/self-hosting topics. If the user asks about anything else, politely say you're here to help them get up and running with OpenSEO and ask what they want to know about OpenSEO or SEO.",
"For OpenSEO product questions, use the OpenSEO Fact Sheet below as your source of truth. Do not invent product facts, feature details, pricing, limits, integrations, or support claims. If the fact sheet does not support the answer, say you are not sure and suggest contacting ben@openseo.so.",
"When users want advice from people in the community, a second opinion, or help beyond this onboarding chat, mention the OpenSEO Discord from the fact sheet.",
"When the user asks how OpenSEO helps them get traffic or rank higher, lead with the fact sheet's SEO strategy framing: positioning, topical authority, focused early topics, then expansion into broader searches. Do not answer as only a feature list.",
"OpenSEO is limited until the user upgrades to the paid plan. Be direct about that, but do not hard-sell.",
"When the user asks you to show, propose, generate, draft, or analyze an SEO strategy, call generate_initial_strategy.",
domain
? `The user's website is ${domain}.`
: "If you need the user's website before answering, ask for it briefly.",
`OpenSEO Fact Sheet:\n\n${openSeoFactSheet}`,
].join("\n\n");
}
async function handleChat(request: Request): Promise<Response> {
const context = await resolveUserContextFromHeaders(request.headers);
const parsed = bodySchema.safeParse(await request.json());
if (!parsed.success) {
throw new AppError("VALIDATION_ERROR", "Invalid chat request");
}
const body = parsed.data;
// Authorize the requested project against the caller's organization.
const project = await ProjectRepository.getProjectForOrganization(
body.projectId,
context.organizationId,
);
if (!project) {
throw new AppError("FORBIDDEN");
}
// Free-plan cap. The client disables the composer once the limit is reached,
// so this is the server-side backstop for a request that bypassed it. Only
// pay for the (network) access check once a request is actually over the
// limit — the common case stays a single round-trip.
// Count only user-role messages to match the client gate (roles are already
// validated by bodySchema above).
const questionCount = body.messages.filter((m) => m.role === "user").length;
if (questionCount > FREE_ONBOARDING_QUESTION_LIMIT) {
const hosted = await isHostedServerAuthMode();
if (hosted) {
const customer = await getOrCreateOrganizationCustomer(context);
if (!(await customerHasManagedAccess(customer.id))) {
throw new AppError(
"PAYMENT_REQUIRED",
"You've used all your free strategy questions. Subscribe to continue.",
);
}
}
}
const model = await getOnboardingModel();
const modelMessages = await convertToModelMessages(body.messages);
const result = streamText({
model,
system: buildSystemPrompt(project.domain),
messages: modelMessages,
abortSignal: request.signal,
maxOutputTokens: 1600,
stopWhen: stepCountIs(4),
tools: {
generate_initial_strategy: tool({
description:
"Read the user's website, gather available SEO signals, and synthesize an onboarding SEO strategy to present to the user. Use when the user asks Sam to propose a strategy or analyze their site.",
inputSchema: z.object({}),
execute: async () => {
if (!project.domain) {
throw new AppError(
"VALIDATION_ERROR",
"Set a website domain first",
);
}
const seed = await runOnboardingSeed({
projectId: project.id,
billingCustomer: {
userId: context.userId,
userEmail: context.userEmail,
organizationId: context.organizationId,
projectId: project.id,
},
emailVerified: context.emailVerified,
domain: project.domain,
countryName: LOCATIONS[project.locationCode] ?? "your market",
locationCode: project.locationCode,
languageCode: project.languageCode,
});
if (seed.status === "complete") {
return { status: "complete", markdown: seed.markdown };
}
return {
status: "skipped",
note: "A strategy was already generated for this site during onboarding. Present the one shown earlier in this conversation, or suggest the user upgrade to keep refining it in the app.",
};
},
}),
},
});
return result.toUIMessageStreamResponse({
onError: () => "The assistant hit an error. Please try again.",
});
}
export const Route = createFileRoute("/api/onboarding/chat")({
server: {
handlers: {
POST: async ({ request }: { request: Request }) => {
try {
return await handleChat(request);
} catch (error) {
return responseForAppError(error, "Chat failed");
}
},
},
},
});

View File

@ -1,12 +1,21 @@
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache"; import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
import { z } from "zod"; import { z } from "zod";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import type { CreditFeature } from "@/shared/billing-credit-features";
import { createDataforseoClient } from "@/server/lib/dataforseo"; import { createDataforseoClient } from "@/server/lib/dataforseo";
import { normalizeDomainInput } from "@/server/lib/domainUtils"; import { normalizeDomainInput } from "@/server/lib/domainUtils";
import { mapKeywordItem } from "@/server/features/domain/services/domainKeywordMapper"; import { mapKeywordItem } from "@/server/features/domain/services/domainKeywordMapper";
import { getKeywordsPage } from "@/server/features/domain/services/domainKeywordsPage"; import { getKeywordsPage } from "@/server/features/domain/services/domainKeywordsPage";
import { getPagesPage } from "@/server/features/domain/services/domainPagesPage"; import { getPagesPage } from "@/server/features/domain/services/domainPagesPage";
// Lets a caller (e.g. the onboarding seed) attribute spend to its own feature
// and bypass the balance gate. Applied to the DataForSEO call, not the cache
// key, so cached results are shared across callers.
type MeteringOverrides = {
creditFeature?: CreditFeature;
skipBalanceAssert?: boolean;
};
/** Domain overview data is refreshed every 12 hours. */ /** Domain overview data is refreshed every 12 hours. */
const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60; const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60;
@ -31,6 +40,7 @@ async function getOverview(
languageCode: string; languageCode: string;
}, },
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
metering: MeteringOverrides = {},
): Promise<DomainOverviewResult> { ): Promise<DomainOverviewResult> {
const domain = normalizeDomainInput(input.domain, input.includeSubdomains); const domain = normalizeDomainInput(input.domain, input.includeSubdomains);
@ -56,6 +66,7 @@ async function getOverview(
target: domain, target: domain,
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode, languageCode: input.languageCode,
...metering,
}); });
const metrics = metricsResponse[0]; const metrics = metricsResponse[0];
@ -99,6 +110,7 @@ async function getSuggestedKeywords(
projectId: string; projectId: string;
}, },
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
metering: MeteringOverrides = {},
): Promise< ): Promise<
Array<{ Array<{
keyword: string; keyword: string;
@ -144,6 +156,7 @@ async function getSuggestedKeywords(
languageCode: input.languageCode, languageCode: input.languageCode,
limit: 100, limit: 100,
orderBy: ["ranked_serp_element.serp_item.etv,desc"], orderBy: ["ranked_serp_element.serp_item.etv,desc"],
...metering,
}); });
const keywords = rankedKeywordsResponse.items const keywords = rankedKeywordsResponse.items

View File

@ -0,0 +1,129 @@
# OpenSEO Fact Sheet
This is the factual product reference for Sam, the OpenSEO onboarding agent. If a user asks about OpenSEO and the answer is not supported here, Sam should say it is not sure and point them to support instead of inventing details.
## What OpenSEO is
OpenSEO is an open-source SEO platform for keyword research, domain research, backlinks, rank tracking, site audits, Google Search Console, and AI-agent SEO workflows.
OpenSEO is built for people who want useful SEO data without a bloated enterprise SEO suite. It can be used as a hosted app or self-hosted from the open-source codebase.
OpenSEO is AI-native. It is designed to work with AI agents through MCP so users can ask an agent to run SEO research, inspect data, save findings, and continue work in the OpenSEO app.
OpenSEO does not claim to fully automate SEO. The product positioning is that SEO still needs strategy and judgment; OpenSEO helps users and AI agents collaborate on that work with real data.
## How OpenSEO helps with SEO strategy
SEO and marketing are intertwined. Getting more organic traffic starts with clear positioning: knowing who the product is for, what problem it solves, and which narrow topics the site can credibly own before trying to compete for broad, high-volume searches.
OpenSEO helps users turn that positioning into an SEO plan. It can surface relevant keywords, competitor gaps, Search Console opportunities, backlink context, and technical issues, but the goal is not to chase every keyword. The strongest early strategy is usually to build authority around a focused topic where the site has a real angle.
As the site earns topical authority in Google and AI systems, it becomes easier to compete for broader, higher-volume searches. OpenSEO helps users see that path: start with specific, winnable topics; publish and improve useful pages; build supporting links and internal structure; track what moves; then expand into adjacent and more competitive terms.
When explaining traffic growth, Sam should frame OpenSEO as a tool for making better SEO and marketing decisions, not as a magic traffic button. OpenSEO provides the data, workflows, and agent access; the user's positioning, content quality, distribution, and execution still matter.
## Hosted plan and credits
The managed OpenSEO app costs $10/month.
The managed plan includes:
- Keyword research, backlinks, rank tracking, and site audits.
- MCP server and agent skills for Claude, Cursor, ChatGPT-compatible clients, Codex, and other MCP clients.
- Google Search Console integration that does not use credits.
- $10.00 of usage credits each billing cycle.
- A 30-day money-back guarantee for the first charge.
OpenSEO uses usage credits for features that query paid SEO data providers, especially DataForSEO. Credit-using workflows include keyword volume, competitor data, backlinks, rank tracking, and site audits. Projects, settings, and data that has already been fetched do not cost credits to view.
Top-up credits can be purchased if monthly credits run out. Top-up credits roll over and do not expire. Monthly included credits reset each billing cycle.
Hosted users need an active subscription to use OpenSEO. If credits run out, OpenSEO should not create unexpected bills; users can buy more credits.
## Self-hosting
OpenSEO is open source and can be self-hosted for free.
Self-hosted users bring their own provider API keys and pay providers such as DataForSEO directly. Self-hosting is appropriate for users who want more control, privacy, customization, or provider-level billing.
The open-source repository is at `https://github.com/every-app/open-seo`.
## Data sources
OpenSEO uses DataForSEO as its main SEO data provider. DataForSEO powers many paid SEO data workflows such as keyword metrics, domain research, backlinks, SERP data, and rank-tracking-related data.
Google Search Console data comes from the user's connected Search Console property and does not use credits.
## Google Search Console
Hosted OpenSEO can connect to Google Search Console without requiring the user to create a Google Cloud project or OAuth client.
Search Console access is read-only. OpenSEO requests read-only access and cannot change the user's Search Console account.
Search Console features include:
- Search performance data: clicks, impressions, CTR, and average position.
- Breakdown by query, page, country, device, and date.
- Up to 16 months of available Search Console history.
- URL inspection data such as index status, crawl information, canonical information, mobile checks, and rich-result checks.
- Up to 10 URLs per URL inspection call.
Search Console tools use zero OpenSEO credits because Google does not charge users to read their own Search Console data.
## MCP and AI agents
OpenSEO exposes an MCP server so compatible AI clients can call OpenSEO tools.
Hosted MCP endpoint:
```txt
https://app.openseo.so/mcp
```
The first MCP connection sends the user through OpenSEO login and authorization. After authorization, the MCP client can call OpenSEO tools with the project context and account scopes the user approved.
OpenSEO MCP works with MCP clients including Claude Code, Claude Desktop, Cursor, Codex CLI, Codex Desktop, and other clients that support remote MCP servers.
OpenSEO MCP tools cover workflows such as:
- Keyword research with volume, difficulty, CPC, intent, and trends.
- Live Google organic SERP inspection.
- Domain and page ranked keyword research.
- SERP competitor comparisons.
- Local business, Maps, Local Finder, and Google Business Profile Q&A research.
- Saved keyword listing and saving.
- Rank tracker config and latest position reads.
- Domain organic footprint summaries.
- Backlink and referring-domain overview data.
- Google Search Console performance reads.
- Google URL inspection reads.
OpenSEO also provides agent skills for workflows such as SEO project setup, SEO coaching, keyword research, competitive landscape analysis, competitor analysis, keyword clustering, and link prospecting.
## App workflows
OpenSEO's app includes these practical workflows:
- Keyword research: expand seed topics into keyword ideas, compare search volume, difficulty, CPC, intent, and SERP context, then save useful opportunities.
- Domain overview: understand a domain's organic footprint and ranking keywords.
- Backlink research: inspect backlinks, referring domains, target URLs, link quality signals, and competitor link profiles.
- Rank tracking: track keyword positions over time.
- Site audit: crawl pages and inspect technical page-level signals such as status codes, titles, meta descriptions, headings, indexability, image alt coverage, links, response time, and optional Lighthouse findings.
- Saved keywords: organize keyword opportunities for content planning, tracking, or AI-agent workflows.
- AI and MCP setup: connect OpenSEO to agents and install OpenSEO skills.
## What users can do after subscribing
After subscribing, a hosted user can:
- Set up Google Search Console from onboarding or the app.
- Use the OpenSEO app workflows, including keyword research, domain research, backlinks, rank tracking, and site audits.
- Connect OpenSEO to an AI client through MCP.
- Install OpenSEO skills for agent-driven SEO workflows.
- Use the monthly included credits and buy top-up credits if needed.
## Support and uncertainty
If Sam is unsure about a product detail, current pricing, account-specific billing status, provider limits, or a feature not listed here, it should say it does not know from the product fact sheet and suggest contacting `ben@openseo.so`.
Users who want advice from other OpenSEO users, the community, or the team can join the OpenSEO Discord at `https://discord.gg/c9uGs3cFXr`.

View File

@ -0,0 +1,28 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readSite } from "@/server/features/onboarding/scrape";
describe("readSite SSRF guard", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("blocks a metadata/private host without fetching it", async () => {
const result = await readSite("169.254.169.254");
expect(result.blocked).toBe(true);
expect(result.pages).toEqual([]);
// The blocked host must be rejected before any outbound page fetch.
expect(fetch).not.toHaveBeenCalled();
});
it("blocks localhost-style targets", async () => {
const result = await readSite("localhost:3000");
expect(result.blocked).toBe(true);
expect(fetch).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,175 @@
// Lightweight, dependency-free site reading for onboarding: discover a few URLs
// from the sitemap (falling back to the homepage) and extract readable text from
// each page via plain fetch. This is enough to let the model infer what a site
// does. JS-heavy sites degrade gracefully (less text); a Browser Rendering
// upgrade can slot in behind this same interface later.
import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
const MAX_PAGES = 5;
const PER_PAGE_CHAR_LIMIT = 4000;
const FETCH_TIMEOUT_MS = 10_000;
const MAX_RESPONSE_BYTES = 2_000_000;
const USER_AGENT = "OpenSEO-Onboarding/1.0 (+https://openseo.so)";
export type ScrapedPage = {
url: string;
title: string | null;
text: string;
};
type SiteReadResult = {
rootUrl: string;
pages: ScrapedPage[];
/** True when we couldn't read any page (blocked, offline, etc.). */
blocked: boolean;
};
// Bounded read: accumulate up to MAX_RESPONSE_BYTES regardless of whether
// content-length is present (chunked / CDN responses often omit it).
async function readBoundedText(response: Response): Promise<string | null> {
const reader = response.body?.getReader();
if (!reader) return null;
const decoder = new TextDecoder();
let result = "";
let bytesRead = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytesRead += value.byteLength;
if (bytesRead > MAX_RESPONSE_BYTES) {
await reader.cancel();
return null;
}
result += decoder.decode(value, { stream: true });
}
result += decoder.decode();
return result;
}
async function fetchText(url: string): Promise<string | null> {
try {
const response = await fetch(url, {
headers: { "user-agent": USER_AGENT, accept: "text/html,*/*" },
// Manual redirects so we can re-validate each hop against the SSRF guard;
// redirect:"follow" would let a 30x to an internal host bypass it.
redirect: "manual",
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) return null;
let redirectUrl: string;
try {
// Re-validates host, blocks private IPs, and does DoH DNS resolution.
redirectUrl = await normalizeAndValidateStartUrl(
new URL(location, url).toString(),
);
} catch {
return null; // blocked or invalid redirect destination
}
// One hop only; fetch the validated destination without following further.
const redirected = await fetch(redirectUrl, {
headers: { "user-agent": USER_AGENT, accept: "text/html,*/*" },
redirect: "manual",
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!redirected.ok) return null;
return await readBoundedText(redirected);
}
if (!response.ok) return null;
return await readBoundedText(response);
} catch {
return null;
}
}
/**
* Pulls page URLs from a sitemap body. Resolves relative/protocol-relative
* <loc> entries against the origin and keeps same-origin HTML pages only. Nested
* sitemap files (a sitemap index) are skipped rather than fetched as pages
* good enough for v1; we fall back to the homepage if nothing usable is found.
*/
function parseSitemapUrls(xml: string, origin: string): string[] {
const urls: string[] = [];
const regex = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
let match: RegExpExecArray | null;
while ((match = regex.exec(xml)) !== null) {
let resolved: string;
try {
resolved = new URL(match[1], origin).toString();
} catch {
continue;
}
if (resolved.startsWith(origin) && !resolved.endsWith(".xml")) {
urls.push(resolved);
}
}
return urls;
}
function extractTitle(html: string): string | null {
const match = /<title[^>]*>([^<]*)<\/title>/i.exec(html);
return match ? decodeEntities(match[1].trim()) : null;
}
/** Strips scripts/styles/tags and collapses whitespace into readable text. */
function htmlToText(html: string): string {
const withoutBlocks = html
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
.replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, " ")
.replace(/<!--[\s\S]*?-->/g, " ");
const text = withoutBlocks
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
return decodeEntities(text);
}
function decodeEntities(value: string): string {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ");
}
/** Discovers and reads up to MAX_PAGES pages of a site as plain text. */
export async function readSite(domain: string): Promise<SiteReadResult> {
let rootUrl: string;
try {
rootUrl = await normalizeAndValidateStartUrl(domain);
} catch {
// Blocked (private/metadata host) or unparseable domain — nothing to read.
return { rootUrl: `https://${domain}`, pages: [], blocked: true };
}
const origin = new URL(rootUrl).origin;
// Prefer the sitemap for representative URLs; always include the homepage.
const sitemap = await fetchText(`${origin}/sitemap.xml`);
const discovered = sitemap ? parseSitemapUrls(sitemap, origin) : [];
const targets = [
rootUrl,
...discovered.filter((url) => url !== rootUrl),
].slice(0, MAX_PAGES);
const pages: ScrapedPage[] = [];
for (const url of targets) {
const html = await fetchText(url);
if (!html) {
continue;
}
const text = htmlToText(html).slice(0, PER_PAGE_CHAR_LIMIT);
if (text.length === 0) {
continue;
}
pages.push({ url, title: extractTitle(html), text });
}
return { rootUrl, pages, blocked: pages.length === 0 };
}

View File

@ -0,0 +1,144 @@
import { and, eq, isNull, or } from "drizzle-orm";
import { db } from "@/db";
import { projects } from "@/db/schema";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { DomainService } from "@/server/features/domain/services/DomainService";
import { isLabsLocationCode } from "@/shared/keyword-locations";
import { readSite } from "@/server/features/onboarding/scrape";
import {
synthesizeStrategy,
type RankedKeyword,
} from "@/server/features/onboarding/synthesis";
type OnboardingSeedResult =
| { status: "complete"; markdown: string }
| { status: "skipped" };
type OnboardingSeedInput = {
projectId: string;
billingCustomer: BillingCustomerContext;
emailVerified: boolean;
domain: string;
countryName: string;
locationCode: number;
languageCode: string;
};
// Atomic claim: one free strategy run per project. Claims only when no run has
// succeeded yet (status null) or a prior run failed (retry). A 'complete' run is
// NOT re-claimable — this is a free, balance-gate-bypassed run, so we don't let
// it be looped to amplify spend; regenerating later is a separate paid action.
// A concurrent 'running' run is also blocked.
async function claimRun(projectId: string): Promise<boolean> {
const updated = await db
.update(projects)
.set({
onboardingRunStatus: "running",
onboardingRunAt: new Date().toISOString(),
})
.where(
and(
eq(projects.id, projectId),
or(
isNull(projects.onboardingRunStatus),
eq(projects.onboardingRunStatus, "failed"),
),
),
)
.returning({ id: projects.id });
return updated.length > 0;
}
async function setRunStatus(projectId: string, status: "complete" | "failed") {
await db
.update(projects)
.set({ onboardingRunStatus: status })
.where(eq(projects.id, projectId));
}
/**
* Runs the onboarding strategy generation for a project: read the site, pull
* (paid) ranking signal, and synthesize a strategy, returned to the chat (not
* persisted persistence is deferred to a later PR). Paid DataForSEO calls are
* attributed to the 'onboarding' feature and skip the balance gate so a
* zero-balance new signup still completes; the underlying services are
* cache-first, so a re-run rarely re-spends.
*/
export async function runOnboardingSeed(
input: OnboardingSeedInput,
): Promise<OnboardingSeedResult> {
const claimed = await claimRun(input.projectId);
if (!claimed) {
return { status: "skipped" };
}
try {
const site = await readSite(input.domain);
let organicTraffic: number | null = null;
let organicKeywords: number | null = null;
let rankedKeywords: RankedKeyword[] = [];
// Free stages (read) run for everyone; paid DataForSEO signal is gated on a
// verified email (abuse surface) and a Labs-supported location — the domain
// endpoints used here are Labs-only, so a Google-Ads-only country gets a
// content-only strategy rather than a failed run.
if (input.emailVerified && isLabsLocationCode(input.locationCode)) {
const metering = {
creditFeature: "onboarding" as const,
skipBalanceAssert: true,
};
const overview = await DomainService.getOverview(
{
projectId: input.projectId,
domain: input.domain,
includeSubdomains: false,
locationCode: input.locationCode,
languageCode: input.languageCode,
},
input.billingCustomer,
metering,
);
organicTraffic = overview.organicTraffic;
organicKeywords = overview.organicKeywords;
if (overview.hasData) {
const ranked = await DomainService.getSuggestedKeywords(
{
domain: input.domain,
locationCode: input.locationCode,
languageCode: input.languageCode,
organizationId: input.billingCustomer.organizationId,
projectId: input.projectId,
},
input.billingCustomer,
metering,
);
rankedKeywords = ranked.map((kw) => ({
keyword: kw.keyword,
position: kw.position,
searchVolume: kw.searchVolume,
keywordDifficulty: kw.keywordDifficulty,
}));
}
}
const markdown = await synthesizeStrategy({
domain: input.domain,
countryName: input.countryName,
pages: site.pages,
scrapeBlocked: site.blocked,
organicTraffic,
organicKeywords,
rankedKeywords,
});
await setRunStatus(input.projectId, "complete");
return { status: "complete", markdown };
} catch (error) {
// Reset to a re-runnable state so the user can retry (cache-backed, cheap).
await setRunStatus(input.projectId, "failed");
throw error;
}
}

View File

@ -0,0 +1,91 @@
import { generateText } from "ai";
import { getOnboardingModel } from "@/server/lib/openrouter";
import type { ScrapedPage } from "@/server/features/onboarding/scrape";
export type RankedKeyword = {
keyword: string;
position: number | null;
searchVolume: number | null;
keywordDifficulty: number | null;
};
type StrategyInput = {
domain: string;
countryName: string;
pages: ScrapedPage[];
scrapeBlocked: boolean;
organicTraffic: number | null;
organicKeywords: number | null;
rankedKeywords: RankedKeyword[];
};
const SYSTEM_PROMPT = `You are an SEO strategist onboarding a new user to OpenSEO.
From what you can see of their website plus the data provided, write a concise,
practical, and honest first SEO strategy in Markdown. Be specific to THIS site
never generic. Structure it as:
## Positioning
One paragraph on what the site does and how it should position itself in search.
## Themes
3-5 content/topic themes worth owning, each a bullet with a one-line rationale.
## Target keywords
A short markdown table of starter keywords (Keyword | Why it fits). If ranking
data is provided, prefer and mark keywords they already rank for. If the site is
brand new with no rankings, say so plainly and propose keywords from the content.
## Do this next
A numbered list of 3-5 concrete next actions.
Keep it under ~400 words. Do not invent metrics you weren't given.`;
function buildPrompt(input: StrategyInput): string {
const lines: string[] = [];
lines.push(`Domain: ${input.domain}`);
lines.push(`Primary market: ${input.countryName}`);
lines.push(
`Organic traffic estimate: ${input.organicTraffic ?? "unknown"}; ` +
`ranking keywords: ${input.organicKeywords ?? "unknown"}.`,
);
if (input.rankedKeywords.length > 0) {
lines.push("\nKeywords the site already ranks for (top by traffic):");
for (const kw of input.rankedKeywords.slice(0, 20)) {
lines.push(
`- ${kw.keyword} (pos ${kw.position ?? "?"}, vol ${kw.searchVolume ?? "?"}, KD ${kw.keywordDifficulty ?? "?"})`,
);
}
} else {
lines.push(
"\nThe site has no meaningful organic rankings yet (treat as brand new).",
);
}
if (input.scrapeBlocked) {
lines.push(
"\nWe could not read the site's pages. Ask the user to describe what they do, and keep the strategy high-level.",
);
} else {
lines.push("\nPages read from the site:");
for (const page of input.pages) {
lines.push(`\n### ${page.title ?? page.url} (${page.url})`);
lines.push(page.text);
}
}
return lines.join("\n");
}
/** Generates the initial onboarding strategy markdown for a site. */
export async function synthesizeStrategy(
input: StrategyInput,
): Promise<string> {
const model = await getOnboardingModel();
const { text } = await generateText({
model,
system: SYSTEM_PROMPT,
prompt: buildPrompt(input),
});
return text.trim();
}

View File

@ -69,19 +69,26 @@ export { mapDataforseoPathToCreditFeature };
* *
* `defaultFeature` is the fallback credit feature; a caller can override it per * `defaultFeature` is the fallback credit feature; a caller can override it per
* call by passing `creditFeature` in the input (e.g. an MCP tool attributing * call by passing `creditFeature` in the input (e.g. an MCP tool attributing
* spend to its own feature). The extra field is ignored by the fetchers, which * spend to its own feature). The extra fields are ignored by the fetchers, which
* read named fields rather than spreading the input. * read named fields rather than spreading the input.
*
* `skipBalanceAssert` (in the input) lets a caller spend without first gating on
* the org's remaining balance used by the free onboarding seed so a
* zero-balance new signup still gets its strategy. Cost is still tracked.
*/ */
function meter<I, T>( function meter<I, T>(
customer: BillingCustomerContext, customer: BillingCustomerContext,
fetcher: (input: I) => Promise<DataforseoApiResponse<T>>, fetcher: (input: I) => Promise<DataforseoApiResponse<T>>,
defaultFeature?: CreditFeature, defaultFeature?: CreditFeature,
): (input: I & { creditFeature?: CreditFeature }) => Promise<T> { ): (
input: I & { creditFeature?: CreditFeature; skipBalanceAssert?: boolean },
) => Promise<T> {
return (input) => return (input) =>
meterDataforseoCall( meterDataforseoCall(
customer, customer,
() => fetcher(input), () => fetcher(input),
input.creditFeature ?? defaultFeature, input.creditFeature ?? defaultFeature,
input.skipBalanceAssert ?? false,
); );
} }
@ -148,6 +155,7 @@ async function meterDataforseoCall<T>(
customer: BillingCustomerContext, customer: BillingCustomerContext,
execute: () => Promise<DataforseoApiResponse<T>>, execute: () => Promise<DataforseoApiResponse<T>>,
creditFeature?: CreditFeature, creditFeature?: CreditFeature,
skipBalanceAssert = false,
): Promise<T> { ): Promise<T> {
const isHostedMode = await isHostedServerAuthMode(); const isHostedMode = await isHostedServerAuthMode();
@ -158,9 +166,11 @@ async function meterDataforseoCall<T>(
const billingCustomer = await getOrCreateOrganizationCustomer(customer); const billingCustomer = await getOrCreateOrganizationCustomer(customer);
const { monthlyRemaining } = await assertSeoDataBalanceAvailable( // The onboarding seed skips the balance gate so a zero-balance new signup
billingCustomer.id, // still gets a strategy; spend is tracked against monthly balance below.
); const { monthlyRemaining } = skipBalanceAssert
? { monthlyRemaining: 0 }
: await assertSeoDataBalanceAvailable(billingCustomer.id);
let result: DataforseoApiResponse<T>; let result: DataforseoApiResponse<T>;
try { try {

View File

@ -0,0 +1,33 @@
import { AppError } from "@/server/lib/errors";
function statusForAppError(code: string): number {
switch (code) {
case "UNAUTHENTICATED":
return 401;
case "FORBIDDEN":
return 403;
case "NOT_FOUND":
return 404;
case "VALIDATION_ERROR":
return 400;
case "PAYMENT_REQUIRED":
return 402;
default:
return 500;
}
}
// Maps a thrown error to a Response for raw API routes (which, unlike server
// functions, have no error middleware). AppErrors carry their status; anything
// else becomes a 500 with the given fallback message.
export function responseForAppError(
error: unknown,
fallbackMessage: string,
): Response {
if (error instanceof AppError) {
return new Response(error.message, {
status: statusForAppError(error.code),
});
}
return new Response(fallbackMessage, { status: 500 });
}

View File

@ -0,0 +1,18 @@
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import type { LanguageModel } from "ai";
import {
getOptionalEnvValue,
getRequiredEnvValue,
} from "@/server/lib/runtime-env";
// OpenRouter model slug used for onboarding strategy synthesis + chat. Override
// with OPENROUTER_MODEL to swap models without a code change.
const DEFAULT_ONBOARDING_MODEL = "anthropic/claude-sonnet-4.6";
/** Returns the AI SDK LanguageModel for onboarding, wired through OpenRouter. */
export async function getOnboardingModel(): Promise<LanguageModel> {
const apiKey = await getRequiredEnvValue("OPENROUTER_API_KEY");
const model =
(await getOptionalEnvValue("OPENROUTER_MODEL")) ?? DEFAULT_ONBOARDING_MODEL;
return createOpenRouter({ apiKey })(model);
}

View File

@ -8,6 +8,7 @@ import { ensureUserMiddleware } from "@/middleware/ensureUser";
const ensuredUserContextSchema: z.ZodType<EnsuredUserContext> = z.object({ const ensuredUserContextSchema: z.ZodType<EnsuredUserContext> = z.object({
userId: z.string(), userId: z.string(),
userEmail: z.string(), userEmail: z.string(),
emailVerified: z.boolean(),
organizationId: z.string(), organizationId: z.string(),
project: z.any().optional(), project: z.any().optional(),
}); });

View File

@ -0,0 +1,78 @@
import { createServerFn } from "@tanstack/react-start";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/db";
import { projects } from "@/db/schema";
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
import { AppError } from "@/server/lib/errors";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { ProjectService } from "@/server/features/projects/services/ProjectService";
import { normalizeDomainInput } from "@/server/lib/domainUtils";
import {
getLanguageCode,
isSupportedLocationCode,
} from "@/shared/keyword-locations";
// Returns the onboarding project (id + domain). Uses the org's default project;
// onboarding targets a single project in v1.
export const getOnboardingStrategyState = createServerFn({ method: "GET" })
.middleware(requireAuthenticatedContext)
.handler(async ({ context }) => {
const [project] = await ProjectService.listProjectsEnsuringOne(
context.organizationId,
);
if (!project) {
throw new AppError("NOT_FOUND");
}
return {
projectId: project.id,
domain: project.domain,
};
});
const saveSiteSchema = z.object({
projectId: z.string().min(1),
domain: z.string().min(1),
locationCode: z.number().int(),
});
// Persists the site + default location for the project before generation.
export const saveOnboardingSite = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.inputValidator((data: unknown) => saveSiteSchema.parse(data))
.handler(async ({ data, context }) => {
const project = await ProjectRepository.getProjectForOrganization(
data.projectId,
context.organizationId,
);
if (!project) {
throw new AppError("NOT_FOUND");
}
if (!isSupportedLocationCode(data.locationCode)) {
throw new AppError("VALIDATION_ERROR", "Unsupported location");
}
const newDomain = normalizeDomainInput(data.domain, false);
const domainChanged = newDomain !== project.domain;
await db
.update(projects)
.set({
domain: newDomain,
locationCode: data.locationCode,
languageCode: getLanguageCode(data.locationCode),
// A new domain is a different site, so reset the one-free-run claim
// guard; otherwise a 'complete' status blocks the free seed for the
// corrected domain. Same-domain edits keep the guard intact.
...(domainChanged
? { onboardingRunStatus: null, onboardingRunAt: null }
: {}),
})
.where(
and(
eq(projects.id, data.projectId),
eq(projects.organizationId, context.organizationId),
),
);
return { ok: true };
});

View File

@ -6,7 +6,8 @@ export type CreditFeature =
| "rank_tracking" | "rank_tracking"
| "ai_citations" | "ai_citations"
| "ai_prompt_responses" | "ai_prompt_responses"
| "local_seo"; | "local_seo"
| "onboarding";
const CREDIT_FEATURE_LABELS: Record<string, string> = { const CREDIT_FEATURE_LABELS: Record<string, string> = {
keyword_research: "Keyword Research", keyword_research: "Keyword Research",
@ -18,6 +19,7 @@ const CREDIT_FEATURE_LABELS: Record<string, string> = {
ai_prompt_responses: "AI Prompt Responses", ai_prompt_responses: "AI Prompt Responses",
ai_search: "AI Search", ai_search: "AI Search",
local_seo: "Local SEO", local_seo: "Local SEO",
onboarding: "Onboarding",
}; };
/** /**

View File

@ -0,0 +1,5 @@
// Free-plan users get a bounded number of strategy-refinement questions in the
// onboarding chat before they're asked to subscribe. Shared so the client gate
// (disables the composer, shows "N left") and the server backstop (rejects an
// over-limit request that bypassed the client) agree on the same number.
export const FREE_ONBOARDING_QUESTION_LIMIT = 7;