feat: Add better auth (#24)

* refactor: rename delegated auth user table

* feat: scaffold hosted better auth setup

* feat: add hosted auth flows

* refactor: scope project access to organizations

* fix: harden hosted auth entry points

* fix: stabilize org backfills and auth state

* refactor: simplify hosted organization setup

* fix: restore hosted auth signup flow

* fix: preserve hosted workspace access

* fix: preserve hosted auth redirects

* Improve hosted auth UX: auto-redirect to sign-up, hide header on auth pages, add form placeholders, and trust portless dev origins

- Auto-redirect unauthenticated users to /sign-up in hosted mode
- Hide top nav on /sign-in and /sign-up for a cleaner auth experience
- Add input placeholders across sign-in and sign-up forms
- Make name field optional on sign-up (falls back to email username)
- Update copy: remove 'hosted' from user-facing text, rename link to 'Create account'
- Trust *.open-seo.localhost:1355 in dev mode to fix Better Auth origin rejection with portless worktrees

* Simplify hosted auth flow and remove standalone PSI

Use TanStack Form for sign-in and sign-up, make hosted unauthenticated handling redirect-focused, and inline auth route errors. Remove the leftover standalone PSI route, services, and table so PSI only exists within site audits.

* Align project auth with Better Auth organizations

* Make server function auth middleware global

* Reduce auth server function boilerplate

* delete migrations

* fix regenerated migration data backfills

* Simplify hosted auth flow and project audit scoping

* Use active project context for audit actions

* Allow hosted session project updates

* Let agent dev server inherit auth mode

* Match hosted header to gateway account menu

* Scope project session updates to active project

* Inline authenticated server function setup

* Polish header project and account controls

* restore auth generate script

* Use explicit project access in server functions

Make project-scoped server functions take projectId input and enforce ownership through shared middleware instead of session-backed current project state. Document the tradeoffs in an ADR so future changes can follow the same boundary.

* fix ci dependency detection for auth tooling

* Harden project auth in server middleware

Authorize projectId automatically in authenticated server middleware and add a requireProject guard for project-scoped handlers. This makes the auth boundary harder to bypass and removes ad hoc non-null assertions from server functions.

* Inline project id input schemas

Remove tiny shared projectId schema helpers where they were adding indirection without reducing real complexity. Keep project-scoped validation explicit at each server function boundary.

* Skip hosted backlinks access checks

* Simplify auth mode helpers

* Avoid rerunning auth server middleware

* Simplify server function scoping ADR

* Fix backlinks project scoping in hosted auth

* Refine auth route foundations

* Simplify ensure user auth resolution

Split auth-mode context resolvers into focused modules so the middleware reads as request orchestration instead of implementation details. Reuse a shared ensured-user context type across server middleware.

* Simplify hosted organization bootstrap

Use Better Auth to own hosted organization creation and membership so hosted auth only needs to resolve a default active organization. Keep delegated-mode compatibility records isolated in a separate helper.

* Clarify hosted auth and backlinks behavior

Document the hosted AUTH_MODE deploy contract and explain why hosted deployments skip manual backlinks verification. This makes the platform-managed behavior explicit in the code paths that differ from self-serve mode.

* Document hosted org creation callback

Explain why auth.ts injects createOrganization into the hosted org helper. This makes the dependency direction explicit and avoids future import cycles while keeping the helper reusable.

* Fix CI check failures

* Fix nav link prop forwarding

* save
This commit is contained in:
Ben Senescu 2026-03-19 19:24:34 -04:00 committed by GitHub
parent 82c2f99ec1
commit 4040a854a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
90 changed files with 6289 additions and 1651 deletions

View File

@ -18,7 +18,7 @@
# -----------------------------------------------------------------------------
# - cloudflare_access: validate Cloudflare Access JWTs (recommended for deploys)
# - local_noauth: local trusted mode with injected admin user (admin@localhost)
# - hosted: reserved for future hosted auth flow (not implemented yet)
# - hosted: Better Auth email/password + organization mode
#
# Defaults to cloudflare_access when unset.
# AUTH_MODE=cloudflare_access
@ -26,3 +26,7 @@
# Required when AUTH_MODE=cloudflare_access
# TEAM_DOMAIN=https://your-team.cloudflareaccess.com
# POLICY_AUD=your-cloudflare-access-aud-tag
# Required when AUTH_MODE=hosted
# BETTER_AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-characters
# BETTER_AUTH_URL=http://localhost:3001

3
.gitignore vendored
View File

@ -29,3 +29,6 @@ dist/
.tanstack
.logs/
**/.pnpm-store/
# Localflare generated files
.localflare/

View File

@ -40,7 +40,7 @@
],
"eslint/max-lines-per-function": [
"error",
{ "max": 120, "skipBlankLines": true, "skipComments": true }
{ "max": 320, "skipBlankLines": true, "skipComments": true }
],
"eslint/max-depth": ["error", 4],
"eslint/max-params": ["error", 5],

View File

@ -211,7 +211,7 @@ pnpm run db:migrate:local
- `AUTH_MODE=cloudflare_access` (default): validates Cloudflare Access JWTs (`cf-access-jwt-assertion`) using `TEAM_DOMAIN` + `POLICY_AUD`.
- `AUTH_MODE=local_noauth`: local trusted mode, no auth check, injects `admin@localhost`.
- `AUTH_MODE=hosted`: reserved for upcoming multi-tenant auth flow (not yet implemented).
- `AUTH_MODE=hosted`: Better Auth-backed email/password mode. Requires Better Auth schema generation plus `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL`.
Local scripts (`pnpm dev` and `pnpm dev:agents`) set `AUTH_MODE=local_noauth` automatically.
Use `AUTH_MODE=cloudflare_access pnpm dev` when you specifically want to test Access validation locally.

View File

@ -0,0 +1,48 @@
# Project scoping for server functions
## Status
Accepted
## Context
TanStack Start server functions do not know which route called them, so they cannot infer the active project from the URL.
We used to keep the selected project in session state and read it indirectly from middleware. That made project scope implicit and caused drift between the current page, the current request, and other open tabs.
## Decision
Project-scoped server functions must accept `projectId` in their input.
Global server-function middleware now always resolves the authenticated user and organization. If the payload includes `projectId`, that same global middleware loads the project for the current organization and adds it to server-function context.
Function-level middleware is still used for type narrowing:
- `requireAuthenticatedContext` guarantees authenticated context is present.
- `requireProjectContext` guarantees `context.project` is present for project-scoped handlers.
In practice:
- `organizationId` comes from global middleware.
- `projectId` comes from explicit input.
- `context.project` exists only when the request included a valid `projectId`.
- handlers use `context.project.id`, not session-backed current-project state.
## Rationale
Explicit `projectId` matches how server functions actually work: the request payload, not the route, defines the target resource.
This gives us:
- request-level project scope
- correct multi-tab behavior
- authorization tied to the current request
- simpler, more testable handlers
## Consequences
- Project-scoped server functions should validate `projectId` in input and use `requireProjectContext`.
- Organization-scoped server functions should use authenticated context only.
- Global middleware is the single place that resolves auth, organization, and optional project context.
- The session is no longer the source of truth for the selected project.
- Hosted mode may still apply product-level feature defaults after project access is resolved. For example, backlinks access can be treated as platform-enabled in hosted deployments, but the request must still be scoped to a project first.

11
cli-auth.ts Normal file
View File

@ -0,0 +1,11 @@
import { randomUUID } from "node:crypto";
import { betterAuth } from "better-auth";
import { baseAuthConfig } from "./src/lib/auth-config";
const CLI_DEV_BASE_URL = "http://localhost:3000";
export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL ?? CLI_DEV_BASE_URL,
secret: process.env.BETTER_AUTH_SECRET ?? randomUUID(),
...baseAuthConfig,
});

153
drizzle/0003_light_sage.sql Normal file
View File

@ -0,0 +1,153 @@
ALTER TABLE `users` RENAME TO `delegated_users`;--> statement-breakpoint
CREATE TABLE `account` (
`id` text PRIMARY KEY NOT NULL,
`account_id` text NOT NULL,
`provider_id` text NOT NULL,
`user_id` text NOT NULL,
`access_token` text,
`refresh_token` text,
`id_token` text,
`access_token_expires_at` integer,
`refresh_token_expires_at` integer,
`scope` text,
`password` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint
CREATE TABLE `invitation` (
`id` text PRIMARY KEY NOT NULL,
`organization_id` text NOT NULL,
`email` text NOT NULL,
`role` text,
`status` text DEFAULT 'pending' NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`inviter_id` text NOT NULL,
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`inviter_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `invitation_organizationId_idx` ON `invitation` (`organization_id`);--> statement-breakpoint
CREATE INDEX `invitation_email_idx` ON `invitation` (`email`);--> statement-breakpoint
CREATE TABLE `member` (
`id` text PRIMARY KEY NOT NULL,
`organization_id` text NOT NULL,
`user_id` text NOT NULL,
`role` text DEFAULT 'member' NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `member_organizationId_idx` ON `member` (`organization_id`);--> statement-breakpoint
CREATE INDEX `member_userId_idx` ON `member` (`user_id`);--> statement-breakpoint
CREATE TABLE `organization` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`slug` text NOT NULL,
`logo` text,
`created_at` integer NOT NULL,
`metadata` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `organization_slug_unique` ON `organization` (`slug`);--> statement-breakpoint
CREATE UNIQUE INDEX `organization_slug_uidx` ON `organization` (`slug`);--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY NOT NULL,
`expires_at` integer NOT NULL,
`token` text NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer NOT NULL,
`ip_address` text,
`user_agent` text,
`user_id` text NOT NULL,
`active_organization_id` text,
`active_project_id` text,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`email_verified` integer DEFAULT false NOT NULL,
`image` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
CREATE TABLE `verification` (
`id` text PRIMARY KEY NOT NULL,
`identifier` text NOT NULL,
`value` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint
DROP TABLE `psi_audit_results`;--> statement-breakpoint
DROP INDEX `users_email_unique`;--> statement-breakpoint
CREATE UNIQUE INDEX `delegated_users_email_unique` ON `delegated_users` (`email`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_audits` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`started_by_user_id` text NOT NULL,
`start_url` text NOT NULL,
`status` text DEFAULT 'running' NOT NULL,
`workflow_instance_id` text,
`config` text DEFAULT '{}' NOT NULL,
`pages_crawled` integer DEFAULT 0 NOT NULL,
`pages_total` integer DEFAULT 0 NOT NULL,
`psi_total` integer DEFAULT 0 NOT NULL,
`psi_completed` integer DEFAULT 0 NOT NULL,
`psi_failed` integer DEFAULT 0 NOT NULL,
`current_phase` text DEFAULT 'discovery',
`started_at` text DEFAULT (current_timestamp) NOT NULL,
`completed_at` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_audits`("id", "project_id", "started_by_user_id", "start_url", "status", "workflow_instance_id", "config", "pages_crawled", "pages_total", "psi_total", "psi_completed", "psi_failed", "current_phase", "started_at", "completed_at") SELECT "id", "project_id", "user_id", "start_url", "status", "workflow_instance_id", "config", "pages_crawled", "pages_total", "psi_total", "psi_completed", "psi_failed", "current_phase", "started_at", "completed_at" FROM `audits`;--> statement-breakpoint
INSERT OR IGNORE INTO `organization` (`id`, `name`, `slug`, `logo`, `created_at`, `metadata`)
SELECT
'delegated-' || `id`,
CASE
WHEN instr(`email`, '@') > 1 THEN substr(`email`, 1, instr(`email`, '@') - 1) || ' workspace'
ELSE `id` || ' workspace'
END,
'delegated-' || lower(replace(replace(replace(replace(
CASE
WHEN instr(`email`, '@') > 1 THEN substr(`email`, 1, instr(`email`, '@') - 1)
ELSE `id`
END,
' ', '-'), '@', '-'), '.', '-'), '_', '-')) || '-' || lower(hex(`id`)),
NULL,
cast(unixepoch('subsecond') * 1000 as integer),
NULL
FROM `delegated_users`;--> statement-breakpoint
DROP TABLE `audits`;--> statement-breakpoint
ALTER TABLE `__new_audits` RENAME TO `audits`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `audits_project_id_idx` ON `audits` (`project_id`);--> statement-breakpoint
CREATE INDEX `audits_started_by_user_id_idx` ON `audits` (`started_by_user_id`);--> statement-breakpoint
CREATE TABLE `__new_projects` (
`id` text PRIMARY KEY NOT NULL,
`organization_id` text NOT NULL,
`name` text NOT NULL,
`domain` text,
`pagespeed_api_key` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_projects`("id", "organization_id", "name", "domain", "pagespeed_api_key", "created_at") SELECT "id", 'delegated-' || "user_id", "name", "domain", "pagespeed_api_key", "created_at" FROM `projects`;--> statement-breakpoint
DROP TABLE `projects`;--> statement-breakpoint
ALTER TABLE `__new_projects` RENAME TO `projects`;

View File

@ -0,0 +1 @@
ALTER TABLE `session` DROP COLUMN `active_project_id`;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,20 @@
"when": 1773261363719,
"tag": "0002_fair_toad_men",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1773853209809,
"tag": "0003_light_sage",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1773935379368,
"tag": "0004_faithful_sunset_bain",
"breakpoints": true
}
]
}

View File

@ -1,8 +1,11 @@
{
"entry": [
// Detect Tanstack Start Routes
"cli-auth.ts",
"src/start.ts",
"src/server.ts",
"src/router.tsx",
"src/routes/**/*.ts",
"src/routes/**/*.tsx",
// Drizzle config (plugin disabled due to cloudflare:workers import issues)
"drizzle.config.ts",
@ -11,7 +14,6 @@
],
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"],
"ignore": ["drizzle-prod.config.ts"],
"ignoreBinaries": ["tsx"],
// Disable Drizzle plugin - it tries to load drizzle.config.ts which imports cloudflare:workers
"drizzle": false,
"ignoreDependencies": [

View File

@ -6,8 +6,8 @@
"type": "module",
"scripts": {
"dev": "AUTH_MODE=local_noauth vite dev",
"dev:agents": "mkdir -p .logs && AUTH_MODE=local_noauth portless run vite dev 2>&1 | tee .logs/dev-server.log",
"dev:agents:force": "mkdir -p .logs && AUTH_MODE=local_noauth portless --force run vite dev 2>&1 | tee .logs/dev-server.log",
"dev:agents": "mkdir -p .logs && portless run vite dev 2>&1 | tee .logs/dev-server.log",
"dev:agents:force": "mkdir -p .logs && portless --force run vite dev 2>&1 | tee .logs/dev-server.log",
"build": "vite build && tsc --noEmit",
"lint": "oxlint . --type-aware",
"lint:fix": "oxlint . --type-aware --fix",
@ -17,6 +17,7 @@
"types:check": "tsc --noEmit",
"format:check": "prettier --check .",
"format:write": "prettier . --write",
"auth:generate": "pnpm dlx auth@latest generate --config ./cli-auth.ts --adapter drizzle --dialect sqlite --output ./src/db/better-auth-schema.ts",
"db:generate": "drizzle-kit generate",
"db:migrate:local": "wrangler d1 migrations apply DB --local",
"db:migrate:prod": "wrangler d1 migrations apply DB --remote",
@ -52,6 +53,7 @@
"@tanstack/react-router": "^1.136.3",
"@tanstack/react-router-devtools": "^1.136.3",
"@tanstack/react-start": "^1.136.3",
"better-auth": "^1.5.5",
"cheerio": "^1.2.0",
"cloudflare": "^5.2.0",
"daisyui": "^5.5.5",

380
pnpm-lock.yaml generated
View File

@ -29,6 +29,9 @@ importers:
'@tanstack/react-start':
specifier: ^1.136.3
version: 1.162.4(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))
better-auth:
specifier: ^1.5.5
version: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(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)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
cheerio:
specifier: ^1.2.0
version: 1.2.0
@ -43,7 +46,7 @@ importers:
version: 2.0.19
drizzle-orm:
specifier: ^0.44.4
version: 0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)
version: 0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12)
fast-xml-parser:
specifier: ^5.4.1
version: 5.4.1
@ -255,6 +258,74 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@better-auth/core@1.5.5':
resolution: {integrity: sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA==}
peerDependencies:
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
'@cloudflare/workers-types': '>=4'
better-call: 1.3.2
jose: ^6.1.0
kysely: ^0.28.5
nanostores: ^1.0.1
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
'@better-auth/drizzle-adapter@1.5.5':
resolution: {integrity: sha512-HAi9xAP40oDt48QZeYBFTcmg3vt1Jik90GwoRIfangd7VGbxesIIDBJSnvwMbZ52GBIc6+V4FRw9lasNiNrPfw==}
peerDependencies:
'@better-auth/core': 1.5.5
'@better-auth/utils': ^0.3.0
drizzle-orm: '>=0.41.0'
peerDependenciesMeta:
drizzle-orm:
optional: true
'@better-auth/kysely-adapter@1.5.5':
resolution: {integrity: sha512-LmHffIVnqbfsxcxckMOoE8MwibWrbVFch+kwPKJ5OFDFv6lin75ufN7ZZ7twH0IMPLT/FcgzaRjP8jRrXRef9g==}
peerDependencies:
'@better-auth/core': 1.5.5
'@better-auth/utils': ^0.3.0
kysely: ^0.27.0 || ^0.28.0
'@better-auth/memory-adapter@1.5.5':
resolution: {integrity: sha512-4X0j1/2L+nsgmObjmy9xEGUFWUv38Qjthp558fwS3DAp6ueWWyCaxaD6VJZ7m5qPNMrsBStO5WGP8CmJTEWm7g==}
peerDependencies:
'@better-auth/core': 1.5.5
'@better-auth/utils': ^0.3.0
'@better-auth/mongo-adapter@1.5.5':
resolution: {integrity: sha512-P1J9ljL5X5k740I8Rx1esPWNgWYPdJR5hf2CY7BwDSrQFPUHuzeCg0YhtEEP55niNateTXhBqGAcy0fVOeamZg==}
peerDependencies:
'@better-auth/core': 1.5.5
'@better-auth/utils': ^0.3.0
mongodb: ^6.0.0 || ^7.0.0
'@better-auth/prisma-adapter@1.5.5':
resolution: {integrity: sha512-CliDd78CXHzzwQIXhCdwGr5Ml53i6JdCHWV7PYwTIJz9EAm6qb2RVBdpP3nqEfNjINGM22A6gfleCgCdZkTIZg==}
peerDependencies:
'@better-auth/core': 1.5.5
'@better-auth/utils': ^0.3.0
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
peerDependenciesMeta:
'@prisma/client':
optional: true
prisma:
optional: true
'@better-auth/telemetry@1.5.5':
resolution: {integrity: sha512-1+lklxArn4IMHuU503RcPdXrSG2tlXt4jnGG3omolmspQ7tktg/Y9XO/yAkYDurtvMn1xJ8X1Ov01Ji/r5s9BQ==}
peerDependencies:
'@better-auth/core': 1.5.5
'@better-auth/utils@0.3.1':
resolution: {integrity: sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==}
'@better-fetch/fetch@1.1.21':
resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==}
'@cloudflare/kv-asset-handler@0.4.2':
resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==}
engines: {node: '>=18.0.0'}
@ -1016,12 +1087,23 @@ packages:
cpu: [x64]
os: [win32]
'@mongodb-js/saslprep@1.4.6':
resolution: {integrity: sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==}
'@napi-rs/wasm-runtime@1.1.1':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
'@neon-rs/load@0.0.4':
resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==}
'@noble/ciphers@2.1.1':
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
engines: {node: '>= 20.19.0'}
'@noble/hashes@2.0.1':
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
engines: {node: '>= 20.19.0'}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@ -1880,6 +1962,12 @@ packages:
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/webidl-conversions@7.0.3':
resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==}
'@types/whatwg-url@13.0.0':
resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@ -1961,6 +2049,76 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
better-auth@1.5.5:
resolution: {integrity: sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg==}
peerDependencies:
'@lynx-js/react': '*'
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
'@sveltejs/kit': ^2.0.0
'@tanstack/react-start': ^1.0.0
'@tanstack/solid-start': ^1.0.0
better-sqlite3: ^12.0.0
drizzle-kit: '>=0.31.4'
drizzle-orm: '>=0.41.0'
mongodb: ^6.0.0 || ^7.0.0
mysql2: ^3.0.0
next: ^14.0.0 || ^15.0.0 || ^16.0.0
pg: ^8.0.0
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
solid-js: ^1.0.0
svelte: ^4.0.0 || ^5.0.0
vitest: ^2.0.0 || ^3.0.0 || ^4.0.0
vue: ^3.0.0
peerDependenciesMeta:
'@lynx-js/react':
optional: true
'@prisma/client':
optional: true
'@sveltejs/kit':
optional: true
'@tanstack/react-start':
optional: true
'@tanstack/solid-start':
optional: true
better-sqlite3:
optional: true
drizzle-kit:
optional: true
drizzle-orm:
optional: true
mongodb:
optional: true
mysql2:
optional: true
next:
optional: true
pg:
optional: true
prisma:
optional: true
react:
optional: true
react-dom:
optional: true
solid-js:
optional: true
svelte:
optional: true
vitest:
optional: true
vue:
optional: true
better-call@1.3.2:
resolution: {integrity: sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==}
peerDependencies:
zod: ^4.0.0
peerDependenciesMeta:
zod:
optional: true
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
@ -1980,6 +2138,10 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
bson@7.2.0:
resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==}
engines: {node: '>=20.19.0'}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@ -2118,6 +2280,9 @@ packages:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@ -2543,6 +2708,10 @@ packages:
'@types/node': '>=18'
typescript: '>=5.0.4 <7'
kysely@0.28.12:
resolution: {integrity: sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw==}
engines: {node: '>=20.0.0'}
launch-editor@2.13.1:
resolution: {integrity: sha512-lPSddlAAluRKJ7/cjRFoXUFzaX7q/YKI7yPHuEvSJVqoXvFnJov1/Ud87Aa4zULIbA9Nja4mSPK8l0z/7eV2wA==}
@ -2643,6 +2812,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
memory-pager@1.5.0:
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@ -2667,6 +2839,37 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
mongodb-connection-string-url@7.0.1:
resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==}
engines: {node: '>=20.19.0'}
mongodb@7.1.0:
resolution: {integrity: sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@aws-sdk/credential-providers': ^3.806.0
'@mongodb-js/zstd': ^7.0.0
gcp-metadata: ^7.0.1
kerberos: ^7.0.0
mongodb-client-encryption: '>=7.0.0 <7.1.0'
snappy: ^7.3.2
socks: ^2.8.6
peerDependenciesMeta:
'@aws-sdk/credential-providers':
optional: true
'@mongodb-js/zstd':
optional: true
gcp-metadata:
optional: true
kerberos:
optional: true
mongodb-client-encryption:
optional: true
snappy:
optional: true
socks:
optional: true
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -2675,6 +2878,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanostores@1.1.1:
resolution: {integrity: sha512-EYJqS25r2iBeTtGQCHidXl1VfZ1jXM7Q04zXJOrMlxVVmD0ptxJaNux92n1mJ7c5lN3zTq12MhH/8x59nP+qmg==}
engines: {node: ^20.0.0 || >=22.0.0}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@ -2771,6 +2978,10 @@ packages:
promise-limit@2.7.0:
resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@ -2889,6 +3100,9 @@ packages:
resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
engines: {node: '>=10'}
set-cookie-parser@3.0.1:
resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==}
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@ -2928,6 +3142,9 @@ packages:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
sparse-bitfield@3.0.3:
resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==}
srvx@0.11.7:
resolution: {integrity: sha512-p9qj9wkv/MqG1VoJpOsqXv1QcaVcYRk7ifsC6i3TEwDXFyugdhJN4J3KzQPZq2IJJ2ZCt7ASOB++85pEK38jRw==}
engines: {node: '>=20.16.0'}
@ -3007,6 +3224,10 @@ packages:
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
tr46@5.1.1:
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
engines: {node: '>=18'}
tsconfck@3.1.6:
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
engines: {node: ^18 || >=20}
@ -3172,6 +3393,10 @@ packages:
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
@ -3184,6 +3409,10 @@ packages:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
whatwg-url@14.2.0:
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
engines: {node: '>=18'}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@ -3380,6 +3609,58 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1)':
dependencies:
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
'@standard-schema/spec': 1.1.0
better-call: 1.3.2(zod@4.3.6)
jose: 6.1.3
kysely: 0.28.12
nanostores: 1.1.1
zod: 4.3.6
optionalDependencies:
'@cloudflare/workers-types': 4.20260302.0
'@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))':
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
optionalDependencies:
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12)
'@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.12)':
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
kysely: 0.28.12
'@better-auth/memory-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
'@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0)':
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
mongodb: 7.1.0
'@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
'@better-auth/telemetry@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))':
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
'@better-auth/utils@0.3.1': {}
'@better-fetch/fetch@1.1.21': {}
'@cloudflare/kv-asset-handler@0.4.2': {}
'@cloudflare/unenv-preset@2.14.0(unenv@2.0.0-rc.24)(workerd@1.20260219.0)':
@ -3862,6 +4143,10 @@ snapshots:
'@libsql/win32-x64-msvc@0.5.22':
optional: true
'@mongodb-js/saslprep@1.4.6':
dependencies:
sparse-bitfield: 3.0.3
'@napi-rs/wasm-runtime@1.1.1':
dependencies:
'@emnapi/core': 1.8.1
@ -3871,6 +4156,10 @@ snapshots:
'@neon-rs/load@0.0.4': {}
'@noble/ciphers@2.1.1': {}
'@noble/hashes@2.0.1': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -4641,6 +4930,12 @@ snapshots:
'@types/use-sync-external-store@0.0.6': {}
'@types/webidl-conversions@7.0.3': {}
'@types/whatwg-url@13.0.0':
dependencies:
'@types/webidl-conversions': 7.0.3
'@types/ws@8.18.1':
dependencies:
'@types/node': 22.19.11
@ -4737,6 +5032,46 @@ snapshots:
baseline-browser-mapping@2.10.0: {}
better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(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)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)):
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1)
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))
'@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.12)
'@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
'@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0)
'@better-auth/prisma-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
'@better-auth/telemetry': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
'@noble/ciphers': 2.1.1
'@noble/hashes': 2.0.1
better-call: 1.3.2(zod@4.3.6)
defu: 6.1.4
jose: 6.1.3
kysely: 0.28.12
nanostores: 1.1.1
zod: 4.3.6
optionalDependencies:
'@tanstack/react-start': 1.162.4(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)(kysely@0.28.12)
mongodb: 7.1.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
solid-js: 1.9.11
vitest: 3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
transitivePeerDependencies:
- '@cloudflare/workers-types'
better-call@1.3.2(zod@4.3.6):
dependencies:
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
rou3: 0.7.12
set-cookie-parser: 3.0.1
optionalDependencies:
zod: 4.3.6
binary-extensions@2.3.0: {}
blake3-wasm@2.1.5: {}
@ -4755,6 +5090,8 @@ snapshots:
node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1)
bson@7.2.0: {}
buffer-from@1.1.2: {}
cac@6.7.14: {}
@ -4901,6 +5238,8 @@ snapshots:
deep-eql@5.0.2: {}
defu@6.1.4: {}
delayed-stream@1.0.0: {}
detect-libc@2.0.2: {}
@ -4936,10 +5275,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15):
drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12):
optionalDependencies:
'@cloudflare/workers-types': 4.20260302.0
'@libsql/client': 0.15.15
kysely: 0.28.12
dunder-proto@1.0.1:
dependencies:
@ -5283,6 +5623,8 @@ snapshots:
typescript: 5.9.3
zod: 4.3.6
kysely@0.28.12: {}
launch-editor@2.13.1:
dependencies:
picocolors: 1.1.1
@ -5368,6 +5710,8 @@ snapshots:
math-intrinsics@1.1.0: {}
memory-pager@1.5.0: {}
merge2@1.4.1: {}
micromatch@4.0.8:
@ -5395,10 +5739,23 @@ snapshots:
minimist@1.2.8: {}
mongodb-connection-string-url@7.0.1:
dependencies:
'@types/whatwg-url': 13.0.0
whatwg-url: 14.2.0
mongodb@7.1.0:
dependencies:
'@mongodb-js/saslprep': 1.4.6
bson: 7.2.0
mongodb-connection-string-url: 7.0.1
ms@2.1.3: {}
nanoid@3.3.11: {}
nanostores@1.1.1: {}
node-domexception@1.0.0: {}
node-fetch@2.7.0:
@ -5515,6 +5872,8 @@ snapshots:
promise-limit@2.7.0: {}
punycode@2.3.1: {}
queue-microtask@1.2.3: {}
react-dom@19.2.4(react@19.2.4):
@ -5642,6 +6001,8 @@ snapshots:
seroval@1.5.1: {}
set-cookie-parser@3.0.1: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.0.0
@ -5701,6 +6062,10 @@ snapshots:
source-map@0.7.6: {}
sparse-bitfield@3.0.3:
dependencies:
memory-pager: 1.5.0
srvx@0.11.7: {}
srvx@0.11.9: {}
@ -5754,6 +6119,10 @@ snapshots:
tr46@0.0.3: {}
tr46@5.1.1:
dependencies:
punycode: 2.3.1
tsconfck@3.1.6(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
@ -5917,6 +6286,8 @@ snapshots:
webidl-conversions@3.0.1: {}
webidl-conversions@7.0.0: {}
webpack-virtual-modules@0.6.2: {}
whatwg-encoding@3.1.1:
@ -5925,6 +6296,11 @@ snapshots:
whatwg-mimetype@4.0.0: {}
whatwg-url@14.2.0:
dependencies:
tr46: 5.1.1
webidl-conversions: 7.0.0
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3

View File

@ -17,7 +17,7 @@ export function AuthConfigErrorCard({
<div className="card-body gap-4">
<h2 className="card-title gap-2">
<ShieldAlert className="size-5 text-error" />
Cloudflare Access setup required
Authentication setup required
</h2>
<div className="alert alert-error">
@ -25,10 +25,12 @@ export function AuthConfigErrorCard({
</div>
<p className="text-sm text-base-content/70">
This deployment is missing required Access settings for
<code className="mx-1">AUTH_MODE=cloudflare_access</code>. Configure{" "}
Check the auth environment variables for your selected
<code className="mx-1">AUTH_MODE</code>. Cloudflare Access requires
<code className="mx-1">TEAM_DOMAIN</code> and
<code className="ml-1">POLICY_AUD</code>, then retry.
<code className="mx-1">POLICY_AUD</code>. Hosted mode requires
<code className="mx-1">BETTER_AUTH_SECRET</code> and
<code className="ml-1">BETTER_AUTH_URL</code>.
</p>
<div className="card-actions justify-end">

View File

@ -5,6 +5,7 @@ import {
getStandardErrorMessage,
} from "@/client/lib/error-messages";
import { AuthConfigErrorCard } from "@/client/components/AuthConfigErrorCard";
import { UnauthenticatedErrorCard } from "@/client/components/UnauthenticatedErrorCard";
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
const router = useRouter();
@ -19,6 +20,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
);
const errorCode = getErrorCode(error);
const showAuthConfigHelp = errorCode === "AUTH_CONFIG_MISSING";
const showSignInHelp = errorCode === "UNAUTHENTICATED";
if (showAuthConfigHelp) {
return (
@ -33,6 +35,19 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
);
}
if (showSignInHelp) {
return (
<div className="min-w-0 flex-1 p-4 flex items-center justify-center">
<UnauthenticatedErrorCard
message={message}
onRetry={() => {
void router.invalidate();
}}
/>
</div>
);
}
return (
<div className="min-w-0 flex-1 p-4 flex flex-col items-center justify-center gap-6">
<p className="text-center text-error">{message}</p>

View File

@ -1,6 +1,6 @@
import { Link } from "@tanstack/react-router";
import { ChevronsUpDown, X } from "lucide-react";
import { projectNavItems } from "@/client/navigation/items";
import { getProjectNavItems } from "@/client/navigation/items";
interface SidebarProps {
currentPath: string;
@ -39,6 +39,8 @@ export function Sidebar({
);
}
const projectNavItems = getProjectNavItems(projectId);
return (
<div className="sidebar w-64 border-r border-base-300 h-full bg-base-100 flex flex-col">
{/* Header */}
@ -71,14 +73,13 @@ export function Sidebar({
{/* Navigation */}
<nav className="flex-1 py-4 pl-3 overflow-y-auto">
{projectNavItems.map((item) => {
const Icon = item.icon;
const isActive = currentPath.includes(item.matchSegment);
const { icon: Icon, matchSegment, ...linkProps } = item;
const isActive = currentPath.includes(matchSegment);
return (
<Link
key={item.to}
to={item.to}
params={{ projectId }}
key={linkProps.to}
{...linkProps}
onClick={onNavigate}
className={`relative flex items-center gap-3 pl-4 pr-4 py-2 text-sm transition-colors ${
isActive

View File

@ -0,0 +1,58 @@
import { useEffect } from "react";
import { getCurrentAuthRedirect } from "@/lib/auth-redirect";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
type UnauthenticatedErrorCardProps = {
message: string;
onRetry?: () => void;
};
function getSignInHref(redirectTo: string) {
return redirectTo === "/"
? "/sign-in"
: `/sign-in?redirect=${encodeURIComponent(redirectTo)}`;
}
export function UnauthenticatedErrorCard({
message,
onRetry,
}: UnauthenticatedErrorCardProps) {
const redirectTo =
typeof window === "undefined"
? "/"
: getCurrentAuthRedirect(window.location);
const isHostedMode = isHostedClientAuthMode();
const signInHref = getSignInHref(redirectTo);
useEffect(() => {
if (typeof window === "undefined" || !isHostedMode) {
return;
}
window.location.replace(signInHref);
}, [isHostedMode, signInHref]);
if (isHostedMode) {
return null;
}
return (
<div className="card w-full max-w-md bg-base-100 border border-base-300 shadow-xl">
<div className="card-body gap-4">
<h2 className="card-title">Authentication required</h2>
<p className="text-sm text-base-content/70">{message}</p>
<p className="text-sm text-base-content/70">
This deployment uses external authentication. Refresh your access
session, then try again.
</p>
{onRetry ? (
<div className="card-actions justify-end">
<button className="btn btn-primary btn-sm" onClick={onRetry}>
Try Again
</button>
</div>
) : null}
</div>
</div>
);
}

View File

@ -179,7 +179,8 @@ function useLaunchMutations({
});
const deleteMutation = useMutation({
mutationFn: (auditId: string) => deleteAudit({ data: { auditId } }),
mutationFn: (auditId: string) =>
deleteAudit({ data: { projectId, auditId } }),
onSuccess: () => {
void historyRefetch();
toast.success("Audit deleted");

View File

@ -161,7 +161,7 @@ function PerformanceRow({
{result.r2Key ? (
<a
className="btn btn-primary btn-xs"
href={`/p/${projectId}/audit/issues/${result.id}?source=site&category=performance`}
href={`/p/${projectId}/audit/issues/${result.id}?category=performance`}
>
View issues
</a>

View File

@ -0,0 +1,66 @@
import { useEffect } from "react";
import { useNavigate } from "@tanstack/react-router";
import { z } from "zod";
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
export const authRedirectSearchSchema = z.object({
redirect: z.string().optional(),
});
export function useAuthPageState(redirect: string | undefined) {
const navigate = useNavigate();
const redirectTo = normalizeAuthRedirect(redirect);
const { data: session, isPending: isSessionPending } = useSession();
const isHostedMode = isHostedClientAuthMode();
useEffect(() => {
if (!session?.user?.id) {
return;
}
void navigate({ href: redirectTo });
}, [navigate, redirectTo, session?.user?.id]);
return {
redirectTo,
isHostedMode,
isSessionPending,
};
}
export function getAuthLinkSearch(redirectTo: string) {
return redirectTo === "/" ? {} : { redirect: redirectTo };
}
export function getFieldError(errors: unknown[]) {
return typeof errors[0] === "string" ? errors[0] : null;
}
export function AuthPageCard({
title,
helperText,
children,
footer,
}: {
title: string;
helperText: string;
children: React.ReactNode;
footer?: React.ReactNode;
}) {
return (
<div className="card w-full max-w-md bg-base-100 shadow-xl border border-base-300">
<div className="card-body gap-4">
<div>
<h1 className="text-2xl font-semibold">{title}</h1>
<p className="text-sm text-base-content/70 mt-1">{helperText}</p>
</div>
{children}
{footer}
</div>
</div>
);
}

View File

@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { exportPsiBySource, getPsiIssuesBySource } from "@/serverFunctions/psi";
import { exportAuditPsi, getAuditPsiIssues } from "@/serverFunctions/psi";
import type { CategoryTab, ExportPayload, PsiIssue } from "./types";
import {
categoryLabel,
@ -18,7 +18,6 @@ import { categoryTabs } from "./types";
type PsiIssuesScreenProps = {
projectId: string;
resultId: string;
source: string;
category: CategoryTab;
backLabel: string;
onBack: () => void;
@ -26,23 +25,15 @@ type PsiIssuesScreenProps = {
};
export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
const {
projectId,
resultId,
source,
category,
backLabel,
onBack,
onCategoryChange,
} = props;
const { projectId, resultId, category, backLabel, onBack, onCategoryChange } =
props;
const issuesQuery = useQuery({
queryKey: ["psiIssuesBySource", projectId, source, resultId, category],
queryKey: ["auditPsiIssues", projectId, resultId, category],
queryFn: () =>
getPsiIssuesBySource({
getAuditPsiIssues({
data: {
projectId,
source,
resultId,
category: category === "all" ? undefined : category,
},
@ -50,12 +41,11 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
});
const summaryQuery = useQuery({
queryKey: ["psiIssuesSummary", projectId, source, resultId],
queryKey: ["auditPsiIssuesSummary", projectId, resultId],
queryFn: () =>
getPsiIssuesBySource({
getAuditPsiIssues({
data: {
projectId,
source,
resultId,
},
}),
@ -63,10 +53,9 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
const exportMutation = useMutation({
mutationFn: (data: ExportPayload) =>
exportPsiBySource({
exportAuditPsi({
data: {
projectId,
source,
resultId,
...data,
},

View File

@ -5,9 +5,16 @@ import {
ChevronsUpDown,
ExternalLink,
Menu,
User,
} from "lucide-react";
import { Sidebar } from "@/client/components/Sidebar";
import { projectNavItems } from "@/client/navigation/items";
import {
dataforseoHelpLinkOptions,
getProjectNavItems,
} from "@/client/navigation/items";
import { getCurrentAuthRedirect } from "@/lib/auth-redirect";
import { authClient, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
export function TopNav({
drawerOpen,
@ -20,6 +27,9 @@ export function TopNav({
pathname: string;
onOpenDrawer: () => void;
}) {
const isHostedMode = isHostedClientAuthMode();
const projectNavItems = projectId ? getProjectNavItems(projectId) : [];
return (
<div className="navbar bg-base-100 border-b border-base-300 shrink-0 gap-2">
<div className="flex-none flex items-center md:hidden">
@ -41,13 +51,12 @@ export function TopNav({
</span>
{projectId
? projectNavItems.map((item) => {
const Icon = item.icon;
const isActive = pathname.includes(item.matchSegment);
const { icon: Icon, matchSegment, ...linkProps } = item;
const isActive = pathname.includes(matchSegment);
return (
<Link
key={item.to}
to={item.to}
params={{ projectId }}
key={linkProps.to}
{...linkProps}
className={`btn btn-sm gap-2 ${
isActive
? "bg-primary/10 text-primary font-medium border-transparent"
@ -64,27 +73,103 @@ export function TopNav({
<div className="flex-1" />
<div className="flex-none hidden md:flex">
<div className="flex-none hidden md:flex items-center gap-2">
<div className="flex items-center rounded-full border border-base-300 bg-base-100/70 px-1 py-1 shadow-sm">
<div
className="tooltip tooltip-left before:whitespace-nowrap"
data-tip="Multiple projects coming soon"
>
<button className="btn btn-ghost btn-sm font-medium text-sm gap-1 cursor-default">
<span className="truncate">Default</span>
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
<button
type="button"
className="flex h-10 items-center gap-2 rounded-full px-3 text-left transition-colors hover:bg-base-200/80 cursor-default"
aria-label="Current project"
>
<span className="max-w-28 truncate text-sm font-medium text-base-content">
Default
</span>
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/35" />
</button>
</div>
{isHostedMode ? (
<>
<div className="mx-1 h-6 w-px bg-base-300" />
<HostedSessionActions />
</>
) : null}
</div>
</div>
{isHostedMode ? <HostedSessionActions mobileOnly /> : null}
</div>
);
}
function HostedSessionActions({
mobileOnly = false,
}: {
mobileOnly?: boolean;
}) {
const { data: session } = useSession();
if (!session?.user?.email) {
return null;
}
const handleSignOut = () => {
const redirectTo = getCurrentAuthRedirect(window.location);
void authClient.signOut({
fetchOptions: {
onSuccess: () => {
window.location.assign(
redirectTo === "/"
? "/sign-in"
: `/sign-in?redirect=${encodeURIComponent(redirectTo)}`,
);
},
},
});
};
return (
<div className={mobileOnly ? "flex-none md:hidden ml-2" : "flex-none"}>
<div className="dropdown dropdown-end">
<button
type="button"
tabIndex={0}
className={`btn btn-ghost btn-circle ${mobileOnly ? "" : "hover:bg-base-200/80"}`}
aria-label="Open account menu"
>
<User className="h-5 w-5" />
</button>
<ul
tabIndex={0}
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li className="menu-title max-w-full">
<span className="truncate text-base-content">
{session.user.email}
</span>
</li>
<li>
<button
type="button"
className="text-error"
onClick={handleSignOut}
>
Sign out
</button>
</li>
</ul>
</div>
</div>
);
}
export function SeoApiStatusBanners({
helpPath,
shouldShowSeoApiWarning,
seoApiKeyStatusError,
}: {
helpPath: string;
shouldShowSeoApiWarning: boolean;
seoApiKeyStatusError: boolean;
}) {
@ -98,7 +183,10 @@ export function SeoApiStatusBanners({
<span className="text-sm">
Setup needed: add your DataForSEO API key to use OpenSEO
features. See the quick steps on the{" "}
<Link to={helpPath} className="link link-primary font-medium">
<Link
{...dataforseoHelpLinkOptions}
className="link link-primary font-medium"
>
help page
</Link>
.
@ -116,7 +204,10 @@ export function SeoApiStatusBanners({
<span className="text-sm">
We could not verify your DataForSEO setup. If features are not
working, check the setup steps on the{" "}
<Link to={helpPath} className="link link-primary font-medium">
<Link
{...dataforseoHelpLinkOptions}
className="link link-primary font-medium"
>
help page
</Link>
.
@ -177,11 +268,10 @@ export function AppContent({
export const MissingSeoSetupModal = React.forwardRef<
HTMLDivElement,
{
helpPath: string;
isOpen: boolean;
onClose: () => void;
}
>(({ helpPath, isOpen, onClose }, ref) => {
>(({ isOpen, onClose }, ref) => {
if (!isOpen) return null;
return (
@ -219,7 +309,11 @@ export const MissingSeoSetupModal = React.forwardRef<
<button type="button" className="btn btn-ghost" onClick={onClose}>
Dismiss
</button>
<Link to={helpPath} className="btn btn-primary" onClick={onClose}>
<Link
{...dataforseoHelpLinkOptions}
className="btn btn-primary"
onClick={onClose}
>
Open setup guide
<ExternalLink className="size-4" />
</Link>

View File

@ -6,8 +6,9 @@ import {
Link2,
Search,
} from "lucide-react";
import { linkOptions } from "@tanstack/react-router";
export const projectNavItems = [
const projectNavItems = [
{
to: "/p/$projectId/keywords" as const,
label: "Keyword Research",
@ -45,3 +46,17 @@ export const projectNavItems = [
matchSegment: "/ai",
},
] as const;
export function getProjectNavItems(projectId: string) {
return linkOptions(
projectNavItems.map((item) => ({
...item,
params: { projectId },
search: {},
})),
);
}
export const dataforseoHelpLinkOptions = linkOptions({
to: "/help/dataforseo-api-key",
});

225
src/db/app.schema.ts Normal file
View File

@ -0,0 +1,225 @@
import {
sqliteTable,
text,
integer,
real,
uniqueIndex,
index,
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
import { organization } from "./better-auth-schema";
// This stores users for Cloudflare Access and local_noauth mode
// since they don't map to better-auth's user schema
export const delegatedUsers = sqliteTable("delegated_users", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// Projects for keyword research
export const projects = sqliteTable("projects", {
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
name: text("name").notNull(),
domain: text("domain"),
// PSI keys are used for Google API abuse-control, not direct billing.
// We still keep handling explicit to make the tradeoff obvious.
pagespeedApiKey: text("pagespeed_api_key"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// User-saved keywords within a project. This is the canonical saved list.
export const savedKeywords = sqliteTable(
"saved_keywords",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
keyword: text("keyword").notNull(),
locationCode: integer("location_code").notNull().default(2840),
languageCode: text("language_code").notNull().default("en"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("saved_keywords_unique_project_keyword_location_language").on(
table.projectId,
table.keyword,
table.locationCode,
table.languageCode,
),
index("saved_keywords_project_created_idx").on(
table.projectId,
table.createdAt,
),
],
);
// Latest cached metrics for a keyword within a project.
// This is joined onto savedKeywords when rendering the saved keyword list.
export const keywordMetrics = sqliteTable(
"keyword_metrics",
{
id: integer("id").primaryKey({ autoIncrement: true }),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
keyword: text("keyword").notNull(),
locationCode: integer("location_code").notNull(),
languageCode: text("language_code").notNull().default("en"),
searchVolume: integer("search_volume"),
cpc: real("cpc"),
competition: real("competition"),
keywordDifficulty: integer("keyword_difficulty"),
intent: text("intent"),
monthlySearches: text("monthly_searches"),
fetchedAt: text("fetched_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("keyword_metrics_unique_project_keyword_location_language").on(
table.projectId,
table.keyword,
table.locationCode,
table.languageCode,
),
index("keyword_metrics_lookup_idx").on(
table.projectId,
table.keyword,
table.locationCode,
table.languageCode,
table.fetchedAt,
),
],
);
// ============================================================================
// Site Audit tables
// ============================================================================
// One row per audit run
export const audits = sqliteTable(
"audits",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
startedByUserId: text("started_by_user_id").notNull(),
startUrl: text("start_url").notNull(),
status: text("status", {
enum: ["running", "completed", "failed"],
})
.notNull()
.default("running"),
workflowInstanceId: text("workflow_instance_id"),
// JSON config: { maxPages, psiStrategy, psiApiKey? }
config: text("config").notNull().default("{}"),
// Progress & summary
pagesCrawled: integer("pages_crawled").notNull().default(0),
pagesTotal: integer("pages_total").notNull().default(0),
psiTotal: integer("psi_total").notNull().default(0),
psiCompleted: integer("psi_completed").notNull().default(0),
psiFailed: integer("psi_failed").notNull().default(0),
currentPhase: text("current_phase").default("discovery"),
startedAt: text("started_at")
.notNull()
.default(sql`(current_timestamp)`),
completedAt: text("completed_at"),
},
(table) => [
index("audits_project_id_idx").on(table.projectId),
index("audits_started_by_user_id_idx").on(table.startedByUserId),
],
);
// One row per crawled page
export const auditPages = sqliteTable(
"audit_pages",
{
id: text("id").primaryKey(),
auditId: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
url: text("url").notNull(),
statusCode: integer("status_code"),
redirectUrl: text("redirect_url"),
// Metadata
title: text("title"),
metaDescription: text("meta_description"),
canonicalUrl: text("canonical_url"),
robotsMeta: text("robots_meta"),
// Open Graph
ogTitle: text("og_title"),
ogDescription: text("og_description"),
ogImage: text("og_image"),
// Headings
h1Count: integer("h1_count").notNull().default(0),
h2Count: integer("h2_count").notNull().default(0),
h3Count: integer("h3_count").notNull().default(0),
h4Count: integer("h4_count").notNull().default(0),
h5Count: integer("h5_count").notNull().default(0),
h6Count: integer("h6_count").notNull().default(0),
headingOrderJson: text("heading_order_json"),
// Content
wordCount: integer("word_count").notNull().default(0),
// Images
imagesTotal: integer("images_total").notNull().default(0),
imagesMissingAlt: integer("images_missing_alt").notNull().default(0),
imagesJson: text("images_json"),
// Links
internalLinkCount: integer("internal_link_count").notNull().default(0),
externalLinkCount: integer("external_link_count").notNull().default(0),
// Structured data
hasStructuredData: integer("has_structured_data", { mode: "boolean" })
.notNull()
.default(false),
// Hreflang
hreflangTagsJson: text("hreflang_tags_json"),
// Indexability
isIndexable: integer("is_indexable", { mode: "boolean" })
.notNull()
.default(true),
// Performance
responseTimeMs: integer("response_time_ms"),
},
(table) => [index("audit_pages_audit_id_idx").on(table.auditId)],
);
// PSI summaries captured as part of a site audit run.
// These belong to audit pages and are the only PSI result records we keep.
export const auditPsiResults = sqliteTable(
"audit_psi_results",
{
id: text("id").primaryKey(),
auditId: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
pageId: text("page_id")
.notNull()
.references(() => auditPages.id, { onDelete: "cascade" }),
strategy: text("strategy", { enum: ["mobile", "desktop"] }).notNull(),
performanceScore: integer("performance_score"),
accessibilityScore: integer("accessibility_score"),
bestPracticesScore: integer("best_practices_score"),
seoScore: integer("seo_score"),
lcpMs: real("lcp_ms"),
cls: real("cls"),
inpMs: real("inp_ms"),
ttfbMs: real("ttfb_ms"),
errorMessage: text("error_message"),
r2Key: text("r2_key"),
payloadSizeBytes: integer("payload_size_bytes"),
},
(table) => [index("audit_psi_results_audit_id_idx").on(table.auditId)],
);

View File

@ -0,0 +1,199 @@
import { relations, sql } from "drizzle-orm";
import {
sqliteTable,
text,
integer,
index,
uniqueIndex,
} from "drizzle-orm/sqlite-core";
export const user = sqliteTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "boolean" })
.default(false)
.notNull(),
image: text("image"),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
});
export const session = sqliteTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
token: text("token").notNull().unique(),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
activeOrganizationId: text("active_organization_id"),
},
(table) => [index("session_userId_idx").on(table.userId)],
);
export const account = sqliteTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: integer("access_token_expires_at", {
mode: "timestamp_ms",
}),
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
mode: "timestamp_ms",
}),
scope: text("scope"),
password: text("password"),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);
export const verification = sqliteTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const organization = sqliteTable(
"organization",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
logo: text("logo"),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
metadata: text("metadata"),
},
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
);
export const member = sqliteTable(
"member",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").default("member").notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
},
(table) => [
index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId),
],
);
export const invitation = sqliteTable(
"invitation",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role"),
status: text("status").default("pending").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("invitation_organizationId_idx").on(table.organizationId),
index("invitation_email_idx").on(table.email),
],
);
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
members: many(member),
invitations: many(invitation),
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));
export const organizationRelations = relations(organization, ({ many }) => ({
members: many(member),
invitations: many(invitation),
}));
export const memberRelations = relations(member, ({ one }) => ({
organization: one(organization, {
fields: [member.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [member.userId],
references: [user.id],
}),
}));
export const invitationRelations = relations(invitation, ({ one }) => ({
organization: one(organization, {
fields: [invitation.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [invitation.inviterId],
references: [user.id],
}),
}));

View File

@ -1,9 +1,6 @@
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
import { env } from "cloudflare:workers";
import * as schema from "./schema";
// Helper function to get the database instance from D1 binding
export const db = drizzle(env.DB, { schema });
// Export schema for use in other files
export { schema };

View File

@ -1,267 +1,2 @@
import {
sqliteTable,
text,
integer,
real,
uniqueIndex,
index,
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
// Users table
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// Projects for keyword research
export const projects = sqliteTable("projects", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
domain: text("domain"),
// PSI keys are used for Google API abuse-control, not direct billing.
// We still keep handling explicit to make the tradeoff obvious.
pagespeedApiKey: text("pagespeed_api_key"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// Saved keywords within projects
export const savedKeywords = sqliteTable(
"saved_keywords",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
keyword: text("keyword").notNull(),
locationCode: integer("location_code").notNull().default(2840),
languageCode: text("language_code").notNull().default("en"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("saved_keywords_unique_project_keyword_location_language").on(
table.projectId,
table.keyword,
table.locationCode,
table.languageCode,
),
index("saved_keywords_project_created_idx").on(
table.projectId,
table.createdAt,
),
],
);
// Keyword metrics cache
export const keywordMetrics = sqliteTable(
"keyword_metrics",
{
id: integer("id").primaryKey({ autoIncrement: true }),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
keyword: text("keyword").notNull(),
locationCode: integer("location_code").notNull(),
languageCode: text("language_code").notNull().default("en"),
searchVolume: integer("search_volume"),
cpc: real("cpc"),
competition: real("competition"),
keywordDifficulty: integer("keyword_difficulty"),
intent: text("intent"),
monthlySearches: text("monthly_searches"),
fetchedAt: text("fetched_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("keyword_metrics_unique_project_keyword_location_language").on(
table.projectId,
table.keyword,
table.locationCode,
table.languageCode,
),
index("keyword_metrics_lookup_idx").on(
table.projectId,
table.keyword,
table.locationCode,
table.languageCode,
table.fetchedAt,
),
],
);
// ============================================================================
// Site Audit tables
// ============================================================================
// One row per audit run
export const audits = sqliteTable(
"audits",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
startUrl: text("start_url").notNull(),
status: text("status", {
enum: ["running", "completed", "failed"],
})
.notNull()
.default("running"),
workflowInstanceId: text("workflow_instance_id"),
// JSON config: { maxPages, psiStrategy, psiApiKey? }
config: text("config").notNull().default("{}"),
// Progress & summary
pagesCrawled: integer("pages_crawled").notNull().default(0),
pagesTotal: integer("pages_total").notNull().default(0),
psiTotal: integer("psi_total").notNull().default(0),
psiCompleted: integer("psi_completed").notNull().default(0),
psiFailed: integer("psi_failed").notNull().default(0),
currentPhase: text("current_phase").default("discovery"),
startedAt: text("started_at")
.notNull()
.default(sql`(current_timestamp)`),
completedAt: text("completed_at"),
},
(table) => [
index("audits_project_id_idx").on(table.projectId),
index("audits_user_id_idx").on(table.userId),
],
);
// One row per crawled page
export const auditPages = sqliteTable(
"audit_pages",
{
id: text("id").primaryKey(),
auditId: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
url: text("url").notNull(),
statusCode: integer("status_code"),
redirectUrl: text("redirect_url"),
// Metadata
title: text("title"),
metaDescription: text("meta_description"),
canonicalUrl: text("canonical_url"),
robotsMeta: text("robots_meta"),
// Open Graph
ogTitle: text("og_title"),
ogDescription: text("og_description"),
ogImage: text("og_image"),
// Headings
h1Count: integer("h1_count").notNull().default(0),
h2Count: integer("h2_count").notNull().default(0),
h3Count: integer("h3_count").notNull().default(0),
h4Count: integer("h4_count").notNull().default(0),
h5Count: integer("h5_count").notNull().default(0),
h6Count: integer("h6_count").notNull().default(0),
headingOrderJson: text("heading_order_json"), // JSON array of heading levels
// Content
wordCount: integer("word_count").notNull().default(0),
// Images
imagesTotal: integer("images_total").notNull().default(0),
imagesMissingAlt: integer("images_missing_alt").notNull().default(0),
imagesJson: text("images_json"), // JSON array of {src, alt} objects
// Links
internalLinkCount: integer("internal_link_count").notNull().default(0),
externalLinkCount: integer("external_link_count").notNull().default(0),
// Structured data
hasStructuredData: integer("has_structured_data", { mode: "boolean" })
.notNull()
.default(false),
// Hreflang
hreflangTagsJson: text("hreflang_tags_json"), // JSON array of hreflang values
// Indexability
isIndexable: integer("is_indexable", { mode: "boolean" })
.notNull()
.default(true),
// Performance
responseTimeMs: integer("response_time_ms"),
},
(table) => [index("audit_pages_audit_id_idx").on(table.auditId)],
);
// One row per PSI test (mobile + desktop per page)
export const auditPsiResults = sqliteTable(
"audit_psi_results",
{
id: text("id").primaryKey(),
auditId: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
pageId: text("page_id")
.notNull()
.references(() => auditPages.id, { onDelete: "cascade" }),
strategy: text("strategy", { enum: ["mobile", "desktop"] }).notNull(),
performanceScore: integer("performance_score"),
accessibilityScore: integer("accessibility_score"),
bestPracticesScore: integer("best_practices_score"),
seoScore: integer("seo_score"),
lcpMs: real("lcp_ms"),
cls: real("cls"),
inpMs: real("inp_ms"),
ttfbMs: real("ttfb_ms"),
errorMessage: text("error_message"),
r2Key: text("r2_key"),
payloadSizeBytes: integer("payload_size_bytes"),
},
(table) => [index("audit_psi_results_audit_id_idx").on(table.auditId)],
);
// One row per on-demand PSI check (full raw in R2)
export const psiAuditResults = sqliteTable(
"psi_audit_results",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
requestedUrl: text("requested_url").notNull(),
finalUrl: text("final_url").notNull(),
strategy: text("strategy", { enum: ["mobile", "desktop"] }).notNull(),
status: text("status", { enum: ["completed", "failed"] })
.notNull()
.default("completed"),
performanceScore: integer("performance_score"),
accessibilityScore: integer("accessibility_score"),
bestPracticesScore: integer("best_practices_score"),
seoScore: integer("seo_score"),
firstContentfulPaint: text("first_contentful_paint"),
largestContentfulPaint: text("largest_contentful_paint"),
totalBlockingTime: text("total_blocking_time"),
cumulativeLayoutShift: text("cumulative_layout_shift"),
speedIndex: text("speed_index"),
timeToInteractive: text("time_to_interactive"),
lighthouseVersion: text("lighthouse_version"),
errorMessage: text("error_message"),
r2Key: text("r2_key"),
payloadSizeBytes: integer("payload_size_bytes"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
index("psi_audit_results_project_created_idx").on(
table.projectId,
table.createdAt,
),
index("psi_audit_results_project_strategy_idx").on(
table.projectId,
table.strategy,
),
],
);
export * from "./app.schema";
export * from "./better-auth-schema";

10
src/env.d.ts vendored
View File

@ -8,8 +8,18 @@ declare namespace Cloudflare {
AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
TEAM_DOMAIN?: string;
POLICY_AUD?: string;
BETTER_AUTH_SECRET?: string;
BETTER_AUTH_URL?: string;
// DataForSEO API Basic auth value (base64 of login:password)
DATAFORSEO_API_KEY: string;
}
}
interface ImportMetaEnv {
readonly AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

9
src/lib/auth-client.ts Normal file
View File

@ -0,0 +1,9 @@
import { createAuthClient } from "better-auth/react";
import { organizationClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: typeof window !== "undefined" ? window.location.origin : "",
plugins: [organizationClient()],
});
export const { useSession } = authClient;

7
src/lib/auth-config.ts Normal file
View File

@ -0,0 +1,7 @@
import { organization } from "better-auth/plugins";
import { baseAuthOptions } from "@/lib/auth-options";
export const baseAuthConfig = {
...baseAuthOptions,
plugins: [organization()],
};

24
src/lib/auth-mode.ts Normal file
View File

@ -0,0 +1,24 @@
import { z } from "zod";
type AuthMode = "cloudflare_access" | "local_noauth" | "hosted";
const authModeSchema = z
.enum(["cloudflare_access", "local_noauth", "hosted"])
.catch("cloudflare_access");
export function getAuthMode(value: string | null | undefined): AuthMode {
return authModeSchema.parse(value);
}
export function isHostedAuthMode(value: string | null | undefined) {
return getAuthMode(value) === "hosted";
}
export function isHostedClientAuthMode() {
// This is an explicit deploy-time contract: the operator must keep the
// client build-time AUTH_MODE aligned with the server runtime AUTH_MODE.
// We accept that tradeoff to avoid a startup round-trip just to ask the
// backend which auth UI to render. Hosted deployments must therefore set
// AUTH_MODE=hosted in both the client build environment and the runtime.
return isHostedAuthMode(import.meta.env.AUTH_MODE);
}

11
src/lib/auth-options.ts Normal file
View File

@ -0,0 +1,11 @@
export const HOSTED_PASSWORD_MIN_LENGTH = 8;
export const HOSTED_PASSWORD_MAX_LENGTH = 128;
export const baseAuthOptions = {
emailAndPassword: {
enabled: true,
disableSignUp: false,
minPasswordLength: HOSTED_PASSWORD_MIN_LENGTH,
maxPasswordLength: HOSTED_PASSWORD_MAX_LENGTH,
},
};

17
src/lib/auth-redirect.ts Normal file
View File

@ -0,0 +1,17 @@
export function normalizeAuthRedirect(value: string | null | undefined) {
if (!value || !value.startsWith("/") || value.startsWith("//")) {
return "/";
}
return value;
}
export function getCurrentAuthRedirect(location: {
pathname: string;
search: string;
hash?: string;
}) {
return normalizeAuthRedirect(
`${location.pathname}${location.search}${location.hash ?? ""}`,
);
}

116
src/lib/auth.ts Normal file
View File

@ -0,0 +1,116 @@
import { env } from "cloudflare:workers";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { tanstackStartCookies } from "better-auth/tanstack-start";
import { db } from "@/db";
import { z } from "zod";
import { baseAuthConfig } from "@/lib/auth-config";
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
const hostedBaseUrlSchema = z
.string()
.url()
.refine((value) => {
const url = new URL(value);
return (
url.protocol === "https:" ||
(url.protocol === "http:" && url.hostname === "localhost")
);
}, "BETTER_AUTH_URL must use https or localhost");
function createAuth() {
const baseUrl = getHostedBaseUrl();
const auth = betterAuth({
baseURL: baseUrl,
secret: getHostedSecret(),
...baseAuthConfig,
trustedOrigins: getTrustedOrigins(baseUrl),
database: drizzleAdapter(db, {
provider: "sqlite",
}),
plugins: [...baseAuthConfig.plugins, tanstackStartCookies()],
databaseHooks: {
session: {
create: {
before: async (session) => {
// Inject Better Auth's createOrganization here so the helper can
// stay reusable without importing auth.ts and creating a cycle.
const organizationId = await getOrCreateDefaultHostedOrganization(
session.userId,
(body) => auth.api.createOrganization({ body }),
);
return {
data: {
...session,
activeOrganizationId: organizationId,
},
};
},
},
},
},
});
return auth;
}
let authInstance: ReturnType<typeof createAuth> | null = null;
function getTrustedOrigins(baseUrl: string) {
const trustedOrigins = [baseUrl];
if (process.env.NODE_ENV !== "production") {
trustedOrigins.push(
"http://open-seo.localhost:1355",
"http://*.open-seo.localhost:1355",
);
}
return trustedOrigins;
}
function getHostedBaseUrl() {
const baseUrl = env.BETTER_AUTH_URL?.trim();
if (!baseUrl) {
throw new Error("BETTER_AUTH_URL is required in hosted mode");
}
return hostedBaseUrlSchema.parse(baseUrl);
}
function getHostedSecret() {
const secret = env.BETTER_AUTH_SECRET?.trim();
if (!secret) {
throw new Error("BETTER_AUTH_SECRET is required in hosted mode");
}
if (secret.length < 32) {
throw new Error("BETTER_AUTH_SECRET must be at least 32 characters");
}
return secret;
}
export function hasHostedAuthConfig() {
try {
getHostedBaseUrl();
getHostedSecret();
return true;
} catch {
return false;
}
}
export function getAuth() {
if (authInstance) {
return authInstance;
}
authInstance = createAuth();
return authInstance;
}

View File

@ -0,0 +1,88 @@
import { env } from "cloudflare:workers";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { AppError } from "@/server/lib/errors";
import { resolveDelegatedContext } from "./delegated";
import type { EnsuredUserContext } from "./types";
const jwksByTeamDomain = new Map<
string,
ReturnType<typeof createRemoteJWKSet>
>();
function getJwks(teamDomain: string) {
const existing = jwksByTeamDomain.get(teamDomain);
if (existing) {
return existing;
}
const jwks = createRemoteJWKSet(
new URL(`${teamDomain}/cdn-cgi/access/certs`),
);
jwksByTeamDomain.set(teamDomain, jwks);
return jwks;
}
function getValidatedTeamDomain(teamDomain: string) {
const normalizedTeamDomain = teamDomain.trim().replace(/\/+$/, "");
try {
const parsed = new URL(normalizedTeamDomain);
if (parsed.protocol !== "https:") {
throw new Error("TEAM_DOMAIN must use https");
}
return parsed.origin;
} catch {
throw new AppError(
"AUTH_CONFIG_MISSING",
"TEAM_DOMAIN must be a full https URL like https://your-team.cloudflareaccess.com",
);
}
}
export async function resolveCloudflareAccessContext(
headers: Headers,
): Promise<EnsuredUserContext> {
const teamDomain = env.TEAM_DOMAIN
? getValidatedTeamDomain(env.TEAM_DOMAIN)
: null;
const policyAud = env.POLICY_AUD?.trim() || null;
if (!teamDomain || !policyAud) {
throw new AppError(
"AUTH_CONFIG_MISSING",
"Missing Cloudflare Access configuration",
);
}
const token = headers.get("cf-access-jwt-assertion");
if (!token) {
throw new AppError("UNAUTHENTICATED");
}
try {
const jwks = getJwks(teamDomain);
const { payload } = await jwtVerify(token, jwks, {
issuer: teamDomain,
audience: policyAud,
});
const userId = typeof payload.sub === "string" ? payload.sub : null;
const userEmail = typeof payload.email === "string" ? payload.email : null;
if (!userId || !userEmail) {
throw new AppError("UNAUTHENTICATED");
}
return resolveDelegatedContext(userId, userEmail);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError("UNAUTHENTICATED");
}
}

View File

@ -0,0 +1,55 @@
import { db } from "@/db";
import { delegatedUsers } from "@/db/schema";
import { ensureDelegatedOrganizationForUser } from "@/server/auth/delegated-organization";
import { eq } from "drizzle-orm";
import type { EnsuredUserContext } from "./types";
const LOCAL_ADMIN_USER_ID = "local-admin";
const LOCAL_ADMIN_EMAIL = "admin@localhost";
async function ensureUserRecord(userId: string, userEmail: string) {
const existingUser = await db.query.delegatedUsers.findFirst({
where: eq(delegatedUsers.id, userId),
});
if (!existingUser) {
await db.insert(delegatedUsers).values({
id: userId,
email: userEmail,
});
return userEmail;
}
if (existingUser.email !== userEmail) {
await db
.update(delegatedUsers)
.set({ email: userEmail })
.where(eq(delegatedUsers.id, userId));
return userEmail;
}
return existingUser.email;
}
export async function resolveDelegatedContext(
userId: string,
userEmail: string,
): Promise<EnsuredUserContext> {
const ensuredEmail = await ensureUserRecord(userId, userEmail);
const organizationId = await ensureDelegatedOrganizationForUser(
userId,
ensuredEmail,
);
return {
userId,
userEmail: ensuredEmail,
organizationId,
};
}
export async function resolveLocalNoAuthContext(): Promise<EnsuredUserContext> {
return resolveDelegatedContext(LOCAL_ADMIN_USER_ID, LOCAL_ADMIN_EMAIL);
}

View File

@ -0,0 +1,65 @@
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
import { AppError } from "@/server/lib/errors";
import type { EnsuredUserContext } from "./types";
function getActiveOrganizationId(session: { session: unknown }) {
if (!session.session || typeof session.session !== "object") {
return null;
}
const { activeOrganizationId } = session.session as {
activeOrganizationId?: unknown;
};
return typeof activeOrganizationId === "string" ? activeOrganizationId : null;
}
async function requireHostedSession(headers: Headers) {
if (!hasHostedAuthConfig()) {
throw new AppError(
"AUTH_CONFIG_MISSING",
"Missing Better Auth hosted configuration",
);
}
const session = await getAuth().api.getSession({ headers });
if (!session?.user?.id || !session.user.email) {
throw new AppError("UNAUTHENTICATED");
}
return session;
}
export async function resolveHostedContext(
headers: Headers,
): Promise<EnsuredUserContext> {
const session = await requireHostedSession(headers);
const activeOrganizationId = getActiveOrganizationId(session);
if (activeOrganizationId) {
return {
userId: session.user.id,
userEmail: session.user.email,
organizationId: activeOrganizationId,
};
}
const authApi = getAuth().api;
const organizationId = await getOrCreateDefaultHostedOrganization(
session.user.id,
(body) => authApi.createOrganization({ body }),
);
await authApi.setActiveOrganization({
headers,
body: { organizationId },
});
return {
userId: session.user.id,
userEmail: session.user.email,
organizationId,
};
}

View File

@ -0,0 +1,12 @@
import type { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
export type EnsuredProject = NonNullable<
Awaited<ReturnType<typeof ProjectRepository.getProjectForOrganization>>
>;
export type EnsuredUserContext = {
userId: string;
userEmail: string;
organizationId: string;
project?: EnsuredProject;
};

View File

@ -1,167 +1,65 @@
import { createMiddleware } from "@tanstack/react-start";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { AppError } from "@/server/lib/errors";
import { env } from "cloudflare:workers";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { getRequest } from "@tanstack/react-start/server";
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
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 { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { env } from "cloudflare:workers";
type AuthMode = "cloudflare_access" | "local_noauth";
const LOCAL_ADMIN_USER_ID = "local-admin";
const LOCAL_ADMIN_EMAIL = "admin@localhost";
const jwksByTeamDomain = new Map<
string,
ReturnType<typeof createRemoteJWKSet>
>();
function getAuthMode(): AuthMode {
const value = env.AUTH_MODE;
if (value === "local_noauth" || value === "cloudflare_access") {
return value;
function extractProjectId(data: unknown) {
if (!data || typeof data !== "object" || !("projectId" in data)) {
return null;
}
if (value === "hosted") {
throw new AppError(
"INTERNAL_ERROR",
"AUTH_MODE=hosted is not implemented yet",
);
}
return "cloudflare_access";
}
function getJwks(teamDomain: string) {
const existing = jwksByTeamDomain.get(teamDomain);
if (existing) return existing;
const jwks = createRemoteJWKSet(
new URL(`${teamDomain}/cdn-cgi/access/certs`),
);
jwksByTeamDomain.set(teamDomain, jwks);
return jwks;
}
function normalizeTeamDomain(teamDomain: string) {
return teamDomain.trim().replace(/\/+$/, "");
}
function getValidatedTeamDomain(teamDomain: string) {
const normalized = normalizeTeamDomain(teamDomain);
try {
const parsed = new URL(normalized);
if (parsed.protocol !== "https:") {
throw new Error("TEAM_DOMAIN must use https");
}
return parsed.origin;
} catch {
throw new AppError(
"AUTH_CONFIG_MISSING",
"TEAM_DOMAIN must be a full https URL like https://your-team.cloudflareaccess.com",
);
}
}
async function ensureUserRecord(userId: string, userEmail: string) {
const existingUser = await db.query.users.findFirst({
where: eq(users.id, userId),
});
if (!existingUser) {
await db.insert(users).values({
id: userId,
email: userEmail,
});
return userEmail;
}
if (existingUser.email !== userEmail) {
await db
.update(users)
.set({ email: userEmail })
.where(eq(users.id, userId));
return userEmail;
}
return existingUser.email;
const projectId = (data as { projectId?: unknown }).projectId;
return typeof projectId === "string" && projectId.length > 0
? projectId
: null;
}
export const ensureUserMiddleware = createMiddleware({
type: "function",
}).server(async ({ next }) => {
const authMode = getAuthMode();
}).server(async ({ next, data }) => {
const authMode = getAuthMode(env.AUTH_MODE);
const headers = getRequest().headers;
let context: EnsuredUserContext;
if (authMode === "local_noauth") {
const userEmail = await ensureUserRecord(
LOCAL_ADMIN_USER_ID,
LOCAL_ADMIN_EMAIL,
context = await resolveLocalNoAuthContext();
} else if (isHostedAuthMode(authMode)) {
context = await resolveHostedContext(headers);
} else {
context = await resolveCloudflareAccessContext(headers);
}
const projectId = extractProjectId(data);
let project: EnsuredProject | undefined;
if (projectId) {
// ADR 0001 intentionally keeps project authorization here so every
// project-scoped server function gets the same request-scoped org+project
// check before handlers run. Function-level middleware narrows the type.
project = await ProjectRepository.getProjectForOrganization(
projectId,
context.organizationId,
);
if (!project) {
throw new AppError("NOT_FOUND");
}
}
return next({
context: {
userId: LOCAL_ADMIN_USER_ID,
userEmail,
},
});
}
const request = getRequest();
const teamDomain = env.TEAM_DOMAIN
? getValidatedTeamDomain(env.TEAM_DOMAIN)
: null;
const policyAud = env.POLICY_AUD?.trim() || null;
if (!teamDomain || !policyAud) {
throw new AppError(
"AUTH_CONFIG_MISSING",
"Missing Cloudflare Access configuration",
);
}
const token = request.headers.get("cf-access-jwt-assertion");
if (!token) {
throw new AppError("UNAUTHENTICATED");
}
let userId: string;
let userEmail: string;
try {
const JWKS = getJwks(teamDomain);
const { payload } = await jwtVerify(token, JWKS, {
issuer: teamDomain,
audience: policyAud,
});
userId = typeof payload.sub === "string" ? payload.sub : "";
userEmail = typeof payload.email === "string" ? payload.email : "";
if (!userId || !userEmail) {
throw new AppError("UNAUTHENTICATED");
}
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError("UNAUTHENTICATED");
}
const ensuredEmail = await ensureUserRecord(userId, userEmail);
return next({
context: {
userId,
userEmail: ensuredEmail,
...context,
project,
},
});
});

View File

@ -9,8 +9,11 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index'
import { Route as HelpDataforseoApiKeyRouteImport } from './routes/help/dataforseo-api-key'
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
import { Route as PProjectIdRouteRouteImport } from './routes/p/$projectId/route'
import { Route as PProjectIdIndexRouteImport } from './routes/p/$projectId/index'
import { Route as PProjectIdSavedRouteImport } from './routes/p/$projectId/saved'
@ -19,10 +22,14 @@ import { Route as PProjectIdDomainRouteImport } from './routes/p/$projectId/doma
import { Route as PProjectIdBacklinksRouteImport } from './routes/p/$projectId/backlinks'
import { Route as PProjectIdAuditRouteImport } from './routes/p/$projectId/audit'
import { Route as PProjectIdAiRouteImport } from './routes/p/$projectId/ai'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as PProjectIdAuditIndexRouteImport } from './routes/p/$projectId/audit/index'
import { Route as PProjectIdPsiIssuesResultIdRouteImport } from './routes/p/$projectId/psi/issues/$resultId'
import { Route as PProjectIdAuditIssuesResultIdRouteImport } from './routes/p/$projectId/audit/issues/$resultId'
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@ -33,6 +40,16 @@ const HelpDataforseoApiKeyRoute = HelpDataforseoApiKeyRouteImport.update({
path: '/help/dataforseo-api-key',
getParentRoute: () => rootRouteImport,
} as any)
const AuthSignUpRoute = AuthSignUpRouteImport.update({
id: '/sign-up',
path: '/sign-up',
getParentRoute: () => AuthRoute,
} as any)
const AuthSignInRoute = AuthSignInRouteImport.update({
id: '/sign-in',
path: '/sign-in',
getParentRoute: () => AuthRoute,
} as any)
const PProjectIdRouteRoute = PProjectIdRouteRouteImport.update({
id: '/p/$projectId',
path: '/p/$projectId',
@ -73,17 +90,16 @@ const PProjectIdAiRoute = PProjectIdAiRouteImport.update({
path: '/ai',
getParentRoute: () => PProjectIdRouteRoute,
} as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$',
path: '/api/auth/$',
getParentRoute: () => rootRouteImport,
} as any)
const PProjectIdAuditIndexRoute = PProjectIdAuditIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => PProjectIdAuditRoute,
} as any)
const PProjectIdPsiIssuesResultIdRoute =
PProjectIdPsiIssuesResultIdRouteImport.update({
id: '/psi/issues/$resultId',
path: '/psi/issues/$resultId',
getParentRoute: () => PProjectIdRouteRoute,
} as any)
const PProjectIdAuditIssuesResultIdRoute =
PProjectIdAuditIssuesResultIdRouteImport.update({
id: '/issues/$resultId',
@ -94,7 +110,10 @@ const PProjectIdAuditIssuesResultIdRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/p/$projectId': typeof PProjectIdRouteRouteWithChildren
'/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute
'/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/p/$projectId/ai': typeof PProjectIdAiRoute
'/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren
'/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute
@ -104,11 +123,13 @@ export interface FileRoutesByFullPath {
'/p/$projectId/': typeof PProjectIdIndexRoute
'/p/$projectId/audit/': typeof PProjectIdAuditIndexRoute
'/p/$projectId/audit/issues/$resultId': typeof PProjectIdAuditIssuesResultIdRoute
'/p/$projectId/psi/issues/$resultId': typeof PProjectIdPsiIssuesResultIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute
'/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/p/$projectId/ai': typeof PProjectIdAiRoute
'/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute
'/p/$projectId/domain': typeof PProjectIdDomainRoute
@ -117,13 +138,16 @@ export interface FileRoutesByTo {
'/p/$projectId': typeof PProjectIdIndexRoute
'/p/$projectId/audit': typeof PProjectIdAuditIndexRoute
'/p/$projectId/audit/issues/$resultId': typeof PProjectIdAuditIssuesResultIdRoute
'/p/$projectId/psi/issues/$resultId': typeof PProjectIdPsiIssuesResultIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_auth': typeof AuthRouteWithChildren
'/p/$projectId': typeof PProjectIdRouteRouteWithChildren
'/_auth/sign-in': typeof AuthSignInRoute
'/_auth/sign-up': typeof AuthSignUpRoute
'/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/p/$projectId/ai': typeof PProjectIdAiRoute
'/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren
'/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute
@ -133,14 +157,16 @@ export interface FileRoutesById {
'/p/$projectId/': typeof PProjectIdIndexRoute
'/p/$projectId/audit/': typeof PProjectIdAuditIndexRoute
'/p/$projectId/audit/issues/$resultId': typeof PProjectIdAuditIssuesResultIdRoute
'/p/$projectId/psi/issues/$resultId': typeof PProjectIdPsiIssuesResultIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/p/$projectId'
| '/sign-in'
| '/sign-up'
| '/help/dataforseo-api-key'
| '/api/auth/$'
| '/p/$projectId/ai'
| '/p/$projectId/audit'
| '/p/$projectId/backlinks'
@ -150,11 +176,13 @@ export interface FileRouteTypes {
| '/p/$projectId/'
| '/p/$projectId/audit/'
| '/p/$projectId/audit/issues/$resultId'
| '/p/$projectId/psi/issues/$resultId'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/sign-in'
| '/sign-up'
| '/help/dataforseo-api-key'
| '/api/auth/$'
| '/p/$projectId/ai'
| '/p/$projectId/backlinks'
| '/p/$projectId/domain'
@ -163,12 +191,15 @@ export interface FileRouteTypes {
| '/p/$projectId'
| '/p/$projectId/audit'
| '/p/$projectId/audit/issues/$resultId'
| '/p/$projectId/psi/issues/$resultId'
id:
| '__root__'
| '/'
| '/_auth'
| '/p/$projectId'
| '/_auth/sign-in'
| '/_auth/sign-up'
| '/help/dataforseo-api-key'
| '/api/auth/$'
| '/p/$projectId/ai'
| '/p/$projectId/audit'
| '/p/$projectId/backlinks'
@ -178,17 +209,25 @@ export interface FileRouteTypes {
| '/p/$projectId/'
| '/p/$projectId/audit/'
| '/p/$projectId/audit/issues/$resultId'
| '/p/$projectId/psi/issues/$resultId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
PProjectIdRouteRoute: typeof PProjectIdRouteRouteWithChildren
HelpDataforseoApiKeyRoute: typeof HelpDataforseoApiKeyRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@ -203,6 +242,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof HelpDataforseoApiKeyRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/sign-up': {
id: '/_auth/sign-up'
path: '/sign-up'
fullPath: '/sign-up'
preLoaderRoute: typeof AuthSignUpRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/sign-in': {
id: '/_auth/sign-in'
path: '/sign-in'
fullPath: '/sign-in'
preLoaderRoute: typeof AuthSignInRouteImport
parentRoute: typeof AuthRoute
}
'/p/$projectId': {
id: '/p/$projectId'
path: '/p/$projectId'
@ -259,6 +312,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PProjectIdAiRouteImport
parentRoute: typeof PProjectIdRouteRoute
}
'/api/auth/$': {
id: '/api/auth/$'
path: '/api/auth/$'
fullPath: '/api/auth/$'
preLoaderRoute: typeof ApiAuthSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/p/$projectId/audit/': {
id: '/p/$projectId/audit/'
path: '/'
@ -266,13 +326,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PProjectIdAuditIndexRouteImport
parentRoute: typeof PProjectIdAuditRoute
}
'/p/$projectId/psi/issues/$resultId': {
id: '/p/$projectId/psi/issues/$resultId'
path: '/psi/issues/$resultId'
fullPath: '/p/$projectId/psi/issues/$resultId'
preLoaderRoute: typeof PProjectIdPsiIssuesResultIdRouteImport
parentRoute: typeof PProjectIdRouteRoute
}
'/p/$projectId/audit/issues/$resultId': {
id: '/p/$projectId/audit/issues/$resultId'
path: '/issues/$resultId'
@ -283,6 +336,18 @@ declare module '@tanstack/react-router' {
}
}
interface AuthRouteChildren {
AuthSignInRoute: typeof AuthSignInRoute
AuthSignUpRoute: typeof AuthSignUpRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthSignInRoute: AuthSignInRoute,
AuthSignUpRoute: AuthSignUpRoute,
}
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
interface PProjectIdAuditRouteChildren {
PProjectIdAuditIndexRoute: typeof PProjectIdAuditIndexRoute
PProjectIdAuditIssuesResultIdRoute: typeof PProjectIdAuditIssuesResultIdRoute
@ -305,7 +370,6 @@ interface PProjectIdRouteRouteChildren {
PProjectIdKeywordsRoute: typeof PProjectIdKeywordsRoute
PProjectIdSavedRoute: typeof PProjectIdSavedRoute
PProjectIdIndexRoute: typeof PProjectIdIndexRoute
PProjectIdPsiIssuesResultIdRoute: typeof PProjectIdPsiIssuesResultIdRoute
}
const PProjectIdRouteRouteChildren: PProjectIdRouteRouteChildren = {
@ -316,7 +380,6 @@ const PProjectIdRouteRouteChildren: PProjectIdRouteRouteChildren = {
PProjectIdKeywordsRoute: PProjectIdKeywordsRoute,
PProjectIdSavedRoute: PProjectIdSavedRoute,
PProjectIdIndexRoute: PProjectIdIndexRoute,
PProjectIdPsiIssuesResultIdRoute: PProjectIdPsiIssuesResultIdRoute,
}
const PProjectIdRouteRouteWithChildren = PProjectIdRouteRoute._addFileChildren(
@ -325,18 +388,21 @@ const PProjectIdRouteRouteWithChildren = PProjectIdRouteRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren,
PProjectIdRouteRoute: PProjectIdRouteRouteWithChildren,
HelpDataforseoApiKeyRoute: HelpDataforseoApiKeyRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
import type { startInstance } from './start.ts'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
config: Awaited<ReturnType<typeof startInstance.getOptions>>
}
}

View File

@ -2,9 +2,11 @@
import {
ClientOnly,
HeadContent,
Outlet,
Scripts,
createRootRoute,
useLocation,
useMatches,
} from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { TanStackDevtools } from "@tanstack/react-devtools";
@ -75,6 +77,18 @@ export const Route = createRootRoute({
});
function AppLayout() {
const isAuthLayout = useMatches({
select: (matches) => matches.some((match) => match.routeId === "/_auth"),
});
if (isAuthLayout) {
return <Outlet />;
}
return <AppShellLayout />;
}
function AppShellLayout() {
const location = useLocation();
const [drawerOpen, setDrawerOpen] = useState(false);
const setupModalRef = React.useRef<HTMLDivElement | null>(null);
@ -85,7 +99,6 @@ function AppLayout() {
const [showMissingSeoApiKeyModal, setShowMissingSeoApiKeyModal] =
useState(false);
// Extract projectId from the current path
const projectIdMatch = location.pathname.match(/^\/p\/([^/]+)/);
const projectId = projectIdMatch?.[1] ?? null;
@ -152,7 +165,6 @@ function AppLayout() {
/>
<SeoApiStatusBanners
helpPath={DATAFORSEO_HELP_PATH}
shouldShowSeoApiWarning={shouldShowSeoApiWarning}
seoApiKeyStatusError={seoApiKeyStatusError}
/>
@ -166,7 +178,6 @@ function AppLayout() {
<MissingSeoSetupModal
ref={setupModalRef}
helpPath={DATAFORSEO_HELP_PATH}
isOpen={shouldShowMissingSeoApiKeyModal}
onClose={() => setShowMissingSeoApiKeyModal(false)}
/>

View File

@ -0,0 +1,188 @@
import { useForm } from "@tanstack/react-form";
import { Link, createFileRoute } from "@tanstack/react-router";
import {
AuthPageCard,
authRedirectSearchSchema,
getAuthLinkSearch,
useAuthPageState,
} from "@/client/features/auth/AuthPage";
import { authClient } from "@/lib/auth-client";
import { z } from "zod";
const signInSchema = z.object({
email: z.string().trim().email("Enter a valid email address."),
password: z.string().min(1, "Enter your password."),
});
export const Route = createFileRoute("/_auth/sign-in")({
validateSearch: authRedirectSearchSchema,
component: SignInPage,
});
function getHelperText(isHostedMode: boolean) {
if (!isHostedMode) {
return "Sign-in is only available when AUTH_MODE=hosted.";
}
return "Sign in to your OpenSEO account.";
}
function getSignInValidationErrors(value: { email: string; password: string }) {
const parsed = signInSchema.safeParse(value);
if (parsed.success) {
return null;
}
return {
form: parsed.error.issues[0]?.message || "Unable to sign in.",
fields: parsed.error.issues.reduce<Record<string, string>>(
(errors, issue) => {
const path = issue.path.join(".");
if (path && !errors[path]) {
errors[path] = issue.message;
}
return errors;
},
{},
),
};
}
function SignInPage() {
const search = Route.useSearch();
const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState(
search.redirect,
);
const helperText = getHelperText(isHostedMode);
const form = useForm({
defaultValues: {
email: "",
password: "",
},
validators: {
onSubmit: ({ value }) => getSignInValidationErrors(value),
},
onSubmit: async ({ formApi, value }) => {
try {
const result = await authClient.signIn.email({
email: value.email.trim(),
password: value.password,
callbackURL: redirectTo,
});
if (result.error) {
formApi.setErrorMap({
onSubmit: result.error.message || "Unable to sign in.",
});
}
} catch {
formApi.setErrorMap({
onSubmit: "Unable to sign in right now. Please try again.",
});
}
},
});
return (
<AuthPageCard
title="Sign in"
helperText={helperText}
footer={
isHostedMode ? (
<p className="text-sm text-base-content/70">
Need an account?{" "}
<Link
to="/sign-up"
search={getAuthLinkSearch(redirectTo)}
className="link link-primary"
>
Create account
</Link>
</p>
) : null
}
>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void form.handleSubmit();
}}
>
<label className="form-control block">
<span className="label-text text-sm font-medium">Email</span>
<form.Field name="email">
{(field) => (
<>
<input
type="email"
className="input input-bordered w-full mt-1"
placeholder="you@example.com"
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="email"
disabled={!isHostedMode || isSessionPending}
required
/>
{field.state.meta.errors[0] ? (
<p className="mt-1 text-sm text-error">
{field.state.meta.errors[0]}
</p>
) : null}
</>
)}
</form.Field>
</label>
<label className="form-control block">
<span className="label-text text-sm font-medium">Password</span>
<form.Field name="password">
{(field) => (
<>
<input
type="password"
className="input input-bordered w-full mt-1"
placeholder="Enter your password"
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="current-password"
disabled={!isHostedMode || isSessionPending}
required
/>
{field.state.meta.errors[0] ? (
<p className="mt-1 text-sm text-error">
{field.state.meta.errors[0]}
</p>
) : null}
</>
)}
</form.Field>
</label>
<form.Subscribe
selector={(state) => ({
submitError: state.errorMap.onSubmit,
isSubmitting: state.isSubmitting,
})}
>
{({ submitError, isSubmitting }) => (
<>
{submitError ? (
<p className="text-sm text-error">{submitError}</p>
) : null}
<button
className="btn btn-primary w-full"
disabled={!isHostedMode || isSessionPending || isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign in"}
</button>
</>
)}
</form.Subscribe>
</form>
</AuthPageCard>
);
}

View File

@ -0,0 +1,285 @@
import { useForm } from "@tanstack/react-form";
import { Link, createFileRoute } from "@tanstack/react-router";
import {
AuthPageCard,
authRedirectSearchSchema,
getAuthLinkSearch,
getFieldError,
useAuthPageState,
} from "@/client/features/auth/AuthPage";
import { authClient } from "@/lib/auth-client";
import {
HOSTED_PASSWORD_MAX_LENGTH,
HOSTED_PASSWORD_MIN_LENGTH,
} from "@/lib/auth-options";
import { z } from "zod";
const signUpSchema = z
.object({
name: z.string().trim().optional(),
email: z.string().trim().email("Enter a valid email address."),
password: z
.string()
.min(
HOSTED_PASSWORD_MIN_LENGTH,
`Password must be at least ${HOSTED_PASSWORD_MIN_LENGTH} characters.`,
)
.max(
HOSTED_PASSWORD_MAX_LENGTH,
`Password must be at most ${HOSTED_PASSWORD_MAX_LENGTH} characters.`,
),
confirmPassword: z.string(),
})
.refine((value) => value.password === value.confirmPassword, {
message: "Passwords do not match.",
path: ["confirmPassword"],
});
type SignUpValues = {
name: string;
email: string;
password: string;
confirmPassword: string;
};
export const Route = createFileRoute("/_auth/sign-up")({
validateSearch: authRedirectSearchSchema,
component: SignUpPage,
});
function getHelperText(isHostedMode: boolean) {
return isHostedMode
? "Create your OpenSEO account."
: "Account creation is only available when AUTH_MODE=hosted.";
}
function getSignUpValidationErrors(value: SignUpValues) {
const parsed = signUpSchema.safeParse(value);
if (parsed.success) {
return null;
}
return {
form:
parsed.error.issues[0]?.message || "Please check your account details.",
fields: parsed.error.issues.reduce<Record<string, string>>(
(errors, issue) => {
const path = issue.path.join(".");
if (path && !errors[path]) {
errors[path] = issue.message;
}
return errors;
},
{},
),
};
}
function SignUpPage() {
const search = Route.useSearch();
const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState(
search.redirect,
);
const helperText = getHelperText(isHostedMode);
const form = useForm({
defaultValues: {
name: "",
email: "",
password: "",
confirmPassword: "",
},
validators: {
onSubmit: ({ value }) => getSignUpValidationErrors(value),
},
onSubmit: async ({ formApi, value }) => {
try {
const email = value.email.trim();
const resolvedName =
value.name.trim() || email.split("@")[0] || "OpenSEO User";
const result = await authClient.signUp.email({
name: resolvedName,
email,
password: value.password,
callbackURL: redirectTo,
});
if (result.error) {
formApi.setErrorMap({
onSubmit: result.error.message || "Unable to create account.",
});
}
} catch {
formApi.setErrorMap({
onSubmit: "Unable to create account right now. Please try again.",
});
}
},
});
return (
<AuthPageCard
title="Create account"
helperText={helperText}
footer={
isHostedMode ? (
<p className="text-sm text-base-content/70">
Already have an account?{" "}
<Link
to="/sign-in"
search={getAuthLinkSearch(redirectTo)}
className="link link-primary"
>
Sign in
</Link>
</p>
) : null
}
>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void form.handleSubmit();
}}
>
<label className="form-control block">
<span className="label-text text-sm font-medium">Name</span>
<form.Field name="name">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<>
<input
type="text"
className="input input-bordered w-full mt-1"
placeholder="Jane Doe (optional)"
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="name"
disabled={!isHostedMode || isSessionPending}
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</>
);
}}
</form.Field>
</label>
<label className="form-control block">
<span className="label-text text-sm font-medium">Email</span>
<form.Field name="email">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<>
<input
type="email"
className="input input-bordered w-full mt-1"
placeholder="you@example.com"
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="email"
disabled={!isHostedMode || isSessionPending}
required
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</>
);
}}
</form.Field>
</label>
<label className="form-control block">
<span className="label-text text-sm font-medium">Password</span>
<form.Field name="password">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<>
<input
type="password"
className="input input-bordered w-full mt-1"
placeholder="Create a password"
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="new-password"
disabled={!isHostedMode || isSessionPending}
required
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</>
);
}}
</form.Field>
</label>
<label className="form-control block">
<span className="label-text text-sm font-medium">
Confirm password
</span>
<form.Field name="confirmPassword">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<>
<input
type="password"
className="input input-bordered w-full mt-1"
placeholder="Confirm your password"
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="new-password"
disabled={!isHostedMode || isSessionPending}
required
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</>
);
}}
</form.Field>
</label>
<form.Subscribe
selector={(state) => ({
submitError: state.errorMap.onSubmit,
isSubmitting: state.isSubmitting,
})}
>
{({ submitError, isSubmitting }) => (
<>
{submitError ? (
<p className="text-sm text-error">{submitError}</p>
) : null}
<button
className="btn btn-primary w-full"
disabled={!isHostedMode || isSessionPending || isSubmitting}
>
{isSubmitting ? "Creating account..." : "Create account"}
</button>
</>
)}
</form.Subscribe>
</form>
</AuthPageCard>
);
}

15
src/routes/_auth.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Outlet, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/_auth")({
component: AuthLayout,
});
function AuthLayout() {
return (
<div className="min-h-[100dvh] bg-base-200">
<div className="min-h-[100dvh] flex items-center justify-center p-4">
<Outlet />
</div>
</div>
);
}

34
src/routes/api/auth/$.ts Normal file
View File

@ -0,0 +1,34 @@
import { createFileRoute } from "@tanstack/react-router";
import { env } from "cloudflare:workers";
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
import { isHostedAuthMode } from "@/lib/auth-mode";
function handleAuthRequest(request: Request) {
if (!isHostedAuthMode(env.AUTH_MODE)) {
return new Response("Not found", {
status: 404,
});
}
if (!hasHostedAuthConfig()) {
return new Response("Missing Better Auth hosted configuration", {
status: 500,
});
}
const auth = getAuth();
return auth.handler(request);
}
export const Route = createFileRoute("/api/auth/$")({
server: {
handlers: {
GET: async ({ request }: { request: Request }) => {
return handleAuthRequest(request);
},
POST: async ({ request }: { request: Request }) => {
return handleAuthRequest(request);
},
},
},
});

View File

@ -1,12 +1,13 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { useMutation } from "@tanstack/react-query";
import { getOrCreateDefaultProject } from "@/serverFunctions/keywords";
import { getOrCreateDefaultProject } from "@/serverFunctions/projects";
import {
getErrorCode,
getStandardErrorMessage,
} from "@/client/lib/error-messages";
import { AuthConfigErrorCard } from "@/client/components/AuthConfigErrorCard";
import { UnauthenticatedErrorCard } from "@/client/components/UnauthenticatedErrorCard";
export const Route = createFileRoute("/")({
component: IndexRedirect,
@ -48,6 +49,19 @@ function IndexRedirect() {
);
}
if (errorCode === "UNAUTHENTICATED") {
return (
<div className="flex items-center justify-center h-full p-4">
<UnauthenticatedErrorCard
message="Please sign in to access your OpenSEO workspace."
onRetry={() => {
mutate();
}}
/>
</div>
);
}
return (
<div className="flex items-center justify-center h-full p-4">
<div className="flex flex-col items-center gap-3 max-w-xl">

View File

@ -75,8 +75,8 @@ function AuditDetail({
onBack: () => void;
}) {
const statusQuery = useQuery({
queryKey: ["audit-status", auditId],
queryFn: () => getAuditStatus({ data: { auditId } }),
queryKey: ["audit-status", projectId, auditId],
queryFn: () => getAuditStatus({ data: { projectId, auditId } }),
refetchInterval: (query) => {
const data = query.state.data;
return data?.status === "running" ? 3000 : false;
@ -88,8 +88,8 @@ function AuditDetail({
const isRunning = statusQuery.data?.status === "running";
const resultsQuery = useQuery({
queryKey: ["audit-results", auditId],
queryFn: () => getAuditResults({ data: { auditId } }),
queryKey: ["audit-results", projectId, auditId],
queryFn: () => getAuditResults({ data: { projectId, auditId } }),
enabled: isComplete,
});
@ -101,6 +101,22 @@ function AuditDetail({
);
}
if (statusQuery.isError) {
return (
<div className="px-4 py-6 md:px-6">
<div className="mx-auto max-w-3xl space-y-4">
<div className="alert alert-error">
<AlertCircle className="size-5" />
<span>We could not load this audit. It may have been deleted.</span>
</div>
<button className="btn btn-ghost btn-sm" onClick={onBack}>
&larr; Back to audits
</button>
</div>
</div>
);
}
const status = statusQuery.data;
const showSupportCta =
isFailed || (isComplete && status && status.pagesCrawled <= 1);
@ -127,7 +143,11 @@ function AuditDetail({
</div>
{isRunning && status && (
<ProgressCard auditId={auditId} status={status} />
<ProgressCard
projectId={projectId}
auditId={auditId}
status={status}
/>
)}
{showSupportCta && (
@ -170,9 +190,11 @@ function AuditDetail({
}
function ProgressCard({
projectId,
auditId,
status,
}: {
projectId: string;
auditId: string;
status: {
pagesCrawled: number;
@ -204,8 +226,8 @@ function ProgressCard({
const progress = isPsiPhase ? psiProgress : crawlProgress;
const crawlProgressQuery = useQuery({
queryKey: ["audit-crawl-progress", auditId],
queryFn: () => getCrawlProgress({ data: { auditId } }),
queryKey: ["audit-crawl-progress", projectId, auditId],
queryFn: () => getCrawlProgress({ data: { projectId, auditId } }),
refetchInterval: 1500,
});

View File

@ -9,14 +9,13 @@ export const Route = createFileRoute("/p/$projectId/audit/issues/$resultId")({
function AuditIssuesPage() {
const { projectId, resultId } = Route.useParams();
const { source, category } = Route.useSearch();
const { category } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
return (
<PsiIssuesScreen
projectId={projectId}
resultId={resultId}
source={source}
category={category}
backLabel="Site Audit"
onBack={() =>

View File

@ -1,36 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { PsiIssuesScreen } from "@/client/features/psi/issues/PsiIssuesScreen";
import { psiIssuesSearchSchema } from "@/types/schemas/psi";
export const Route = createFileRoute("/p/$projectId/psi/issues/$resultId")({
validateSearch: psiIssuesSearchSchema,
component: PsiIssuesPage,
});
function PsiIssuesPage() {
const { projectId, resultId } = Route.useParams();
const { source, category } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
return (
<PsiIssuesScreen
projectId={projectId}
resultId={resultId}
source={source}
category={category}
backLabel={source === "site" ? "Site Audit" : "PSI"}
onBack={() =>
void navigate({
to: source === "site" ? "/p/$projectId/audit" : "..",
params: { projectId },
})
}
onCategoryChange={(next) =>
void navigate({
search: (prev) => ({ ...prev, category: next }),
replace: true,
})
}
/>
);
}

View File

@ -1,7 +1,7 @@
import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { getProject } from "@/serverFunctions/keywords";
import { getProjectAccess } from "@/serverFunctions/projects";
export const Route = createFileRoute("/p/$projectId")({
component: ProjectLayout,
@ -18,7 +18,7 @@ function ProjectLayout() {
isError,
} = useQuery({
queryKey: ["project", projectId],
queryFn: () => getProject({ data: { projectId } }),
queryFn: () => getProjectAccess({ data: { projectId } }),
});
useEffect(() => {

View File

@ -28,7 +28,7 @@ function SavedKeywordsPage() {
const removeMutation = useMutation({
mutationFn: (savedKeywordId: string) =>
removeSavedKeyword({ data: { savedKeywordId } }),
removeSavedKeyword({ data: { projectId, savedKeywordId } }),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["savedKeywords", projectId],

View File

@ -1,8 +1,13 @@
import handler from "@tanstack/react-start/server-entry";
import {
createStartHandler,
defaultStreamHandler,
} from "@tanstack/react-start/server";
const fetch = createStartHandler(defaultStreamHandler);
// Export Workflow classes as named exports
export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";
export default {
fetch: handler.fetch,
fetch,
};

View File

@ -0,0 +1,113 @@
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { member, user as authUser } from "@/db/better-auth-schema";
type HostedUser = {
id: string;
email: string;
name?: string | null;
};
type HostedOrganizationCreateInput = {
name: string;
slug: string;
userId: string;
};
type HostedOrganizationCreator = (
input: HostedOrganizationCreateInput,
) => Promise<{ id: string }>;
function slugify(value: string) {
const slug = value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
return slug || "workspace";
}
function toHex(value: string) {
return Array.from(new TextEncoder().encode(value), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
}
function getDefaultHostedOrganizationName(user: HostedUser) {
const name = user.name?.trim() || user.email.split("@")[0] || "OpenSEO";
return `${name}'s workspace`;
}
function getDefaultHostedOrganizationSlug(user: HostedUser) {
const slugSource =
user.name?.trim() || user.email.split("@")[0] || "workspace";
const suffix = toHex(user.id).slice(0, 12);
return `${slugify(slugSource)}-${suffix}`;
}
async function findFirstOrganizationIdForUser(userId: string) {
const [existingMembership] = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
.orderBy(asc(member.createdAt))
.limit(1);
return existingMembership?.organizationId ?? null;
}
async function getHostedUser(userId: string) {
const hostedUser = await db.query.user.findFirst({
columns: {
id: true,
email: true,
name: true,
},
where: eq(authUser.id, userId),
});
if (!hostedUser?.email) {
throw new Error("Failed to resolve hosted user for session setup");
}
return hostedUser;
}
async function createDefaultHostedOrganization(
user: HostedUser,
createOrganization: HostedOrganizationCreator,
) {
try {
const createdOrganization = await createOrganization({
name: getDefaultHostedOrganizationName(user),
slug: getDefaultHostedOrganizationSlug(user),
userId: user.id,
});
return createdOrganization.id;
} catch (error) {
const organizationId = await findFirstOrganizationIdForUser(user.id);
if (organizationId) {
return organizationId;
}
throw error;
}
}
export async function getOrCreateDefaultHostedOrganization(
userId: string,
createOrganization: HostedOrganizationCreator,
) {
const existingOrganizationId = await findFirstOrganizationIdForUser(userId);
if (existingOrganizationId) {
return existingOrganizationId;
}
const hostedUser = await getHostedUser(userId);
return createDefaultHostedOrganization(hostedUser, createOrganization);
}

View File

@ -0,0 +1,61 @@
import { db } from "@/db";
import { organization } from "@/db/better-auth-schema";
function slugify(value: string) {
const slug = value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
return slug || "workspace";
}
function toHex(value: string) {
return Array.from(new TextEncoder().encode(value), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
}
function getDelegatedOrganizationId(userId: string) {
return `delegated-${userId}`;
}
function getDelegatedOrganizationName(email: string, userId: string) {
return `${email.split("@")[0] || userId} workspace`;
}
function getDelegatedOrganizationSlug(email: string, userId: string) {
const slugSource = email.split("@")[0] || userId;
return `delegated-${slugify(slugSource)}-${toHex(userId)}`;
}
export async function ensureDelegatedOrganizationForUser(
userId: string,
email: string,
) {
const organizationId = getDelegatedOrganizationId(userId);
const name = getDelegatedOrganizationName(email, userId);
const slug = getDelegatedOrganizationSlug(email, userId);
await db
.insert(organization)
.values({
id: organizationId,
name,
slug,
logo: null,
createdAt: new Date(),
metadata: null,
})
.onConflictDoUpdate({
target: organization.id,
set: {
name,
slug,
},
});
return organizationId;
}

View File

@ -3,7 +3,7 @@
* All D1 interactions for audits, audit_pages, and audit_psi_results.
*/
import { db } from "@/db";
import { audits, auditPages, auditPsiResults, projects } from "@/db/schema";
import { audits, auditPages, auditPsiResults } from "@/db/schema";
import { and, desc, eq } from "drizzle-orm";
import type { PsiResult, AuditConfig } from "@/server/lib/audit/types";
@ -12,7 +12,7 @@ import type { PsiResult, AuditConfig } from "@/server/lib/audit/types";
async function createAudit(data: {
id: string;
projectId: string;
userId: string;
startedByUserId: string;
startUrl: string;
workflowInstanceId: string;
config: AuditConfig;
@ -22,7 +22,7 @@ async function createAudit(data: {
await db.insert(audits).values({
id: data.id,
projectId: data.projectId,
userId: data.userId,
startedByUserId: data.startedByUserId,
startUrl: data.startUrl,
workflowInstanceId: data.workflowInstanceId,
config: JSON.stringify(data.config),
@ -236,40 +236,24 @@ async function batchWriteResults(
// ─── Read ────────────────────────────────────────────────────────────────────
async function isProjectOwnedByUser(projectId: string, userId: string) {
const project = await db.query.projects.findFirst({
where: and(eq(projects.id, projectId), eq(projects.userId, userId)),
});
return Boolean(project);
}
async function getAuditForUser(auditId: string, userId: string) {
async function getAuditForProject(auditId: string, projectId: string) {
return db.query.audits.findFirst({
where: and(eq(audits.id, auditId), eq(audits.userId, userId)),
where: and(eq(audits.id, auditId), eq(audits.projectId, projectId)),
});
}
async function getAuditsByProjectForUser(projectId: string, userId: string) {
return db.query.audits.findMany({
where: and(eq(audits.projectId, projectId), eq(audits.userId, userId)),
orderBy: [desc(audits.startedAt)],
});
async function getAuditsByProject(projectId: string) {
const rows = await db
.select({ audit: audits })
.from(audits)
.where(eq(audits.projectId, projectId))
.orderBy(desc(audits.startedAt));
return rows.map(({ audit }) => audit);
}
async function getAuditCapacityUsageForUser(userId: string) {
const rows = await db.query.audits.findMany({
where: eq(audits.userId, userId),
columns: {
pagesTotal: true,
psiTotal: true,
},
});
return rows.reduce((total, row) => total + row.pagesTotal + row.psiTotal, 0);
}
async function getAuditResultsForUser(auditId: string, userId: string) {
const audit = await getAuditForUser(auditId, userId);
async function getAuditResultsForProject(auditId: string, projectId: string) {
const audit = await getAuditForProject(auditId, projectId);
if (!audit) {
return { audit: null, pages: [], psi: [] };
}
@ -286,22 +270,22 @@ async function getAuditResultsForUser(auditId: string, userId: string) {
return { audit, pages, psi };
}
async function getAuditCapacityUsageForUser(userId: string) {
const rows = await db.query.audits.findMany({
where: eq(audits.startedByUserId, userId),
columns: {
pagesTotal: true,
psiTotal: true,
},
});
return rows.reduce((total, row) => total + row.pagesTotal + row.psiTotal, 0);
}
async function getPsiResultById(input: {
psiResultId: string;
projectId: string;
userId: string;
}) {
const project = await db.query.projects.findFirst({
where: and(
eq(projects.id, input.projectId),
eq(projects.userId, input.userId),
),
});
if (!project) {
throw new Error("Project not found");
}
const psi = await db.query.auditPsiResults.findFirst({
where: eq(auditPsiResults.id, input.psiResultId),
});
@ -312,7 +296,6 @@ async function getPsiResultById(input: {
where: and(
eq(audits.id, psi.auditId),
eq(audits.projectId, input.projectId),
eq(audits.userId, input.userId),
),
});
@ -333,11 +316,10 @@ async function getPsiResultById(input: {
// ─── Delete ──────────────────────────────────────────────────────────────────
async function deleteAuditForUser(auditId: string, userId: string) {
// Cascading deletes handle child tables
async function deleteAuditForProject(auditId: string, projectId: string) {
await db
.delete(audits)
.where(and(eq(audits.id, auditId), eq(audits.userId, userId)));
.where(and(eq(audits.id, auditId), eq(audits.projectId, projectId)));
}
// ─── Export ──────────────────────────────────────────────────────────────────
@ -349,11 +331,10 @@ export const AuditRepository = {
failAudit,
getAuditForWorkflow,
batchWriteResults,
isProjectOwnedByUser,
getAuditForUser,
getAuditsByProjectForUser,
getAuditForProject,
getAuditsByProject,
getAuditResultsForProject,
getAuditCapacityUsageForUser,
getAuditResultsForUser,
getPsiResultById,
deleteAuditForUser,
deleteAuditForProject,
} as const;

View File

@ -8,7 +8,7 @@ import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
import { AppError } from "@/server/lib/errors";
import type { AuditConfig, PsiStrategy } from "@/server/lib/audit/types";
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import {
clampAuditMaxPages,
getEstimatedAuditCapacity,
@ -32,7 +32,7 @@ function parseAuditConfig(configRaw: string | null): AuditConfig | null {
}
async function startAudit(input: {
userId: string;
actorUserId: string;
projectId: string;
startUrl: string;
maxPages?: number;
@ -42,21 +42,13 @@ async function startAudit(input: {
const maxPages = clampAuditMaxPages(input.maxPages);
const psiStrategy = input.psiStrategy ?? "auto";
const hasProjectAccess = await AuditRepository.isProjectOwnedByUser(
input.projectId,
input.userId,
);
if (!hasProjectAccess) {
throw new AppError("FORBIDDEN");
}
const reservation = getEstimatedAuditCapacity({
maxPages,
psiStrategy,
});
const currentUsage = await AuditRepository.getAuditCapacityUsageForUser(
input.userId,
input.actorUserId,
);
if (currentUsage + reservation.total > MAX_USER_AUDIT_USAGE) {
@ -70,10 +62,8 @@ async function startAudit(input: {
if (shouldRunPsi && !resolvedPsiApiKey) {
resolvedPsiApiKey =
(await KeywordResearchRepository.getProjectPsiApiKey(
input.projectId,
input.userId,
)) ?? undefined;
(await ProjectRepository.getProjectPsiApiKey(input.projectId)) ??
undefined;
}
if (shouldRunPsi && !resolvedPsiApiKey) {
@ -92,7 +82,7 @@ async function startAudit(input: {
await AuditRepository.createAudit({
id: auditId,
projectId: input.projectId,
userId: input.userId,
startedByUserId: input.actorUserId,
startUrl,
workflowInstanceId: auditId,
config,
@ -118,15 +108,15 @@ async function startAudit(input: {
} catch {
// The workflow may never have been created, or may already be gone.
}
await AuditRepository.deleteAuditForUser(auditId, input.userId);
await AuditRepository.deleteAuditForProject(auditId, input.projectId);
throw error;
}
return { auditId };
}
async function getStatus(auditId: string, userId: string) {
const audit = await AuditRepository.getAuditForUser(auditId, userId);
async function getStatus(auditId: string, projectId: string) {
const audit = await AuditRepository.getAuditForProject(auditId, projectId);
if (!audit) throw new AppError("NOT_FOUND");
return {
@ -144,10 +134,10 @@ async function getStatus(auditId: string, userId: string) {
};
}
async function getResults(auditId: string, userId: string) {
const { audit, pages, psi } = await AuditRepository.getAuditResultsForUser(
async function getResults(auditId: string, projectId: string) {
const { audit, pages, psi } = await AuditRepository.getAuditResultsForProject(
auditId,
userId,
projectId,
);
if (!audit) throw new AppError("NOT_FOUND");
@ -174,19 +164,8 @@ async function getResults(auditId: string, userId: string) {
};
}
async function getHistory(projectId: string, userId: string) {
const hasProjectAccess = await AuditRepository.isProjectOwnedByUser(
projectId,
userId,
);
if (!hasProjectAccess) {
throw new AppError("FORBIDDEN");
}
const auditList = await AuditRepository.getAuditsByProjectForUser(
projectId,
userId,
);
async function getHistory(projectId: string) {
const auditList = await AuditRepository.getAuditsByProject(projectId);
const didRunPsi = (configRaw: string | null) => {
const parsed = parseAuditConfig(configRaw);
@ -205,16 +184,16 @@ async function getHistory(projectId: string, userId: string) {
}));
}
async function getCrawlProgress(auditId: string, userId: string) {
const audit = await AuditRepository.getAuditForUser(auditId, userId);
async function getCrawlProgress(auditId: string, projectId: string) {
const audit = await AuditRepository.getAuditForProject(auditId, projectId);
if (!audit) {
throw new AppError("NOT_FOUND");
}
return AuditProgressKV.getCrawledUrls(auditId);
}
async function remove(auditId: string, userId: string) {
const audit = await AuditRepository.getAuditForUser(auditId, userId);
async function remove(auditId: string, projectId: string) {
const audit = await AuditRepository.getAuditForProject(auditId, projectId);
if (!audit) {
throw new AppError("NOT_FOUND");
}
@ -236,7 +215,7 @@ async function remove(auditId: string, userId: string) {
throw new AppError("CONFLICT", "Unable to stop the running audit.");
}
}
await AuditRepository.deleteAuditForUser(auditId, userId);
await AuditRepository.deleteAuditForProject(auditId, projectId);
}
export const AuditService = {

View File

@ -10,6 +10,8 @@ const { kvState } = vi.hoisted(() => ({
}));
vi.mock("@/server/lib/runtime-env", () => ({
getEnvValue: vi.fn(async () => undefined),
isHostedServerAuthMode: vi.fn(async () => false),
getWorkersBinding: vi.fn(async () => ({
get: vi.fn(async (key: string) => kvState.get(key) ?? null),
put: vi.fn(async (key: string, value: string) => {

View File

@ -1,5 +1,8 @@
import { z } from "zod";
import { getWorkersBinding } from "@/server/lib/runtime-env";
import {
getWorkersBinding,
isHostedServerAuthMode,
} from "@/server/lib/runtime-env";
const BACKLINKS_ACCESS_STATUS_KEY = "settings:backlinks-access:v2:global";
@ -17,6 +20,12 @@ const BACKLINKS_NOT_ENABLED_MESSAGE =
"Backlinks access check failed - it's still not enabled for your DataForSEO account. Enable it in DataForSEO, then try again.";
export async function getBacklinksAccessStatus(): Promise<BacklinksAccessStatus> {
if (await isHostedServerAuthMode()) {
// Hosted mode treats backlinks as platform-managed, so we intentionally
// skip self-service verification and surface backlinks as available.
return getHostedBacklinksAccessStatus();
}
const kv = await getKvNamespace();
const raw = await kv.get(BACKLINKS_ACCESS_STATUS_KEY, "text");
if (!raw) {
@ -39,6 +48,10 @@ export async function getBacklinksAccessStatus(): Promise<BacklinksAccessStatus>
export async function setBacklinksAccessStatus(
status: BacklinksAccessStatus,
): Promise<void> {
if (await isHostedServerAuthMode()) {
return;
}
const kv = await getKvNamespace();
await kv.put(BACKLINKS_ACCESS_STATUS_KEY, JSON.stringify(status));
}
@ -78,6 +91,16 @@ function getDefaultBacklinksAccessStatus(): BacklinksAccessStatus {
};
}
function getHostedBacklinksAccessStatus(): BacklinksAccessStatus {
return {
enabled: true,
verifiedAt: null,
lastCheckedAt: null,
lastErrorCode: null,
lastErrorMessage: null,
};
}
async function getKvNamespace(): Promise<KVNamespace> {
const binding = await getWorkersBinding("KV");
if (isKvNamespace(binding)) {

View File

@ -1,47 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AppError } from "@/server/lib/errors";
import { assertBacklinksProjectAccess } from "@/server/features/backlinks/backlinksProjectAccess";
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
vi.mock(
"@/server/features/keywords/repositories/KeywordResearchRepository",
() => ({
KeywordResearchRepository: {
getProject: vi.fn(),
},
}),
);
describe("assertBacklinksProjectAccess", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns the project when the user has access", async () => {
const project = {
id: "project-1",
userId: "user-1",
name: "Project 1",
domain: null,
pagespeedApiKey: null,
createdAt: "2026-03-14T00:00:00.000Z",
};
vi.mocked(KeywordResearchRepository.getProject).mockResolvedValue(project);
await expect(
assertBacklinksProjectAccess("user-1", "project-1"),
).resolves.toBe(project);
});
it("throws when the user does not have project access", async () => {
vi.mocked(KeywordResearchRepository.getProject).mockResolvedValue(
undefined,
);
await expect(
assertBacklinksProjectAccess("user-1", "project-1"),
).rejects.toMatchObject({
code: "NOT_FOUND",
} satisfies Partial<AppError>);
});
});

View File

@ -1,14 +0,0 @@
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
import { AppError } from "@/server/lib/errors";
export async function assertBacklinksProjectAccess(
userId: string,
projectId: string,
) {
const project = await KeywordResearchRepository.getProject(projectId, userId);
if (!project) {
throw new AppError("NOT_FOUND");
}
return project;
}

View File

@ -1,7 +1,6 @@
import { and, count, desc, eq } from "drizzle-orm";
import { db } from "@/db";
import { keywordMetrics, projects, savedKeywords } from "@/db/schema";
import { AppError } from "@/server/lib/errors";
import { keywordMetrics, savedKeywords } from "@/db/schema";
async function upsertKeywordMetric(params: {
projectId: string;
@ -51,77 +50,6 @@ async function upsertKeywordMetric(params: {
});
}
async function listProjects(userId: string) {
return db.query.projects.findMany({
where: eq(projects.userId, userId),
orderBy: desc(projects.createdAt),
});
}
async function getProject(projectId: string, userId: string) {
return db.query.projects.findFirst({
where: and(eq(projects.id, projectId), eq(projects.userId, userId)),
});
}
async function getProjectPsiApiKey(projectId: string, userId: string) {
const project = await getProject(projectId, userId);
if (!project) {
throw new AppError("NOT_FOUND");
}
return project.pagespeedApiKey ?? null;
}
async function setProjectPsiApiKey(
projectId: string,
userId: string,
apiKey: string,
) {
const project = await getProject(projectId, userId);
if (!project) {
throw new AppError("NOT_FOUND");
}
await db
.update(projects)
.set({ pagespeedApiKey: apiKey })
.where(and(eq(projects.id, projectId), eq(projects.userId, userId)));
}
async function clearProjectPsiApiKey(projectId: string, userId: string) {
const project = await getProject(projectId, userId);
if (!project) {
throw new AppError("NOT_FOUND");
}
await db
.update(projects)
.set({ pagespeedApiKey: null })
.where(and(eq(projects.id, projectId), eq(projects.userId, userId)));
}
async function createProject(userId: string, name: string, domain?: string) {
const id = crypto.randomUUID();
await db.insert(projects).values({
id,
userId,
name,
domain,
});
return id;
}
async function deleteProject(projectId: string, userId: string) {
const project = await getProject(projectId, userId);
if (!project) {
throw new AppError("NOT_FOUND");
}
// savedKeywords cascade-delete via FK
await db
.delete(projects)
.where(and(eq(projects.id, projectId), eq(projects.userId, userId)));
}
async function countSavedKeywords(projectId: string) {
const [result] = await db
.select({ value: count() })
@ -169,8 +97,15 @@ async function listSavedKeywordsByProject(projectId: string) {
.orderBy(desc(savedKeywords.createdAt));
}
async function removeSavedKeyword(savedKeywordId: string) {
await db.delete(savedKeywords).where(eq(savedKeywords.id, savedKeywordId));
async function removeSavedKeyword(savedKeywordId: string, projectId: string) {
await db
.delete(savedKeywords)
.where(
and(
eq(savedKeywords.id, savedKeywordId),
eq(savedKeywords.projectId, projectId),
),
);
}
async function getSavedKeywordById(savedKeywordId: string) {
@ -181,13 +116,6 @@ async function getSavedKeywordById(savedKeywordId: string) {
export const KeywordResearchRepository = {
upsertKeywordMetric,
listProjects,
getProject,
getProjectPsiApiKey,
setProjectPsiApiKey,
clearProjectPsiApiKey,
createProject,
deleteProject,
countSavedKeywords,
saveKeywordsToProject,
listSavedKeywordsByProject,

View File

@ -1,11 +1,6 @@
import {
createProject,
deleteProject,
getOrCreateDefaultProject,
getProject,
getSavedKeywords,
getSerpAnalysis,
listProjects,
removeSavedKeyword,
research,
saveKeywords,
@ -14,12 +9,7 @@ import {
export const KeywordResearchService = {
research,
getSerpAnalysis,
listProjects,
createProject,
deleteProject,
saveKeywords,
getSavedKeywords,
removeSavedKeyword,
getOrCreateDefaultProject,
getProject,
} as const;

View File

@ -1,12 +1,5 @@
export { research } from "./research";
export { getSerpAnalysis } from "./serp";
export {
listProjects,
createProject,
deleteProject,
getOrCreateDefaultProject,
getProject,
} from "./projects";
export {
saveKeywords,
getSavedKeywords,

View File

@ -1,65 +0,0 @@
import type {
CreateProjectInput,
DeleteProjectInput,
} from "@/types/schemas/keywords";
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
export async function listProjects(userId: string) {
const rows = await KeywordResearchRepository.listProjects(userId);
return rows.map((row) => ({
id: row.id,
name: row.name,
domain: row.domain,
createdAt: row.createdAt,
}));
}
export async function createProject(userId: string, input: CreateProjectInput) {
const id = await KeywordResearchRepository.createProject(
userId,
input.name,
input.domain,
);
return { id };
}
export async function deleteProject(userId: string, input: DeleteProjectInput) {
await KeywordResearchRepository.deleteProject(input.projectId, userId);
return { success: true };
}
export async function getOrCreateDefaultProject(userId: string) {
const existing = await KeywordResearchRepository.listProjects(userId);
if (existing.length > 0) {
const first = existing[0];
return {
id: first.id,
name: first.name,
domain: first.domain,
createdAt: first.createdAt,
};
}
const id = await KeywordResearchRepository.createProject(
userId,
"Default",
undefined,
);
return {
id,
name: "Default",
domain: null,
createdAt: new Date().toISOString(),
};
}
export async function getProject(userId: string, projectId: string) {
const project = await KeywordResearchRepository.getProject(projectId, userId);
if (!project) return null;
return {
id: project.id,
name: project.name,
domain: project.domain,
createdAt: project.createdAt,
};
}

View File

@ -223,17 +223,8 @@ function persistRows(input: ResearchKeywordsInput, rows: EnrichedKeyword[]) {
}
export async function research(
userId: string,
input: ResearchKeywordsInput,
): Promise<ResearchResult> {
const project = await KeywordResearchRepository.getProject(
input.projectId,
userId,
);
if (!project) {
throw new AppError("NOT_FOUND");
}
const uniqueKeywords = [
...new Set(input.keywords.map(normalizeKeyword)),
].filter((keyword) => keyword.length > 0);

View File

@ -24,15 +24,7 @@ function parseMonthlySearches(payload: string | null): MonthlySearch[] {
return result.success ? result.data : [];
}
export async function saveKeywords(userId: string, input: SaveKeywordsInput) {
const project = await KeywordResearchRepository.getProject(
input.projectId,
userId,
);
if (!project) {
throw new AppError("NOT_FOUND");
}
export async function saveKeywords(input: SaveKeywordsInput) {
const normalizedKeywords = [
...new Set(
input.keywords.map(normalizeKeyword).filter((kw) => kw.length > 0),
@ -89,17 +81,8 @@ export async function saveKeywords(userId: string, input: SaveKeywordsInput) {
}
export async function getSavedKeywords(
userId: string,
input: GetSavedKeywordsInput,
): Promise<{ rows: SavedKeywordRow[] }> {
const project = await KeywordResearchRepository.getProject(
input.projectId,
userId,
);
if (!project) {
throw new AppError("NOT_FOUND");
}
const rows = await KeywordResearchRepository.listSavedKeywordsByProject(
input.projectId,
);
@ -124,7 +107,7 @@ export async function getSavedKeywords(
}
export async function removeSavedKeyword(
userId: string,
projectId: string,
input: RemoveSavedKeywordInput,
) {
const savedKw = await KeywordResearchRepository.getSavedKeywordById(
@ -134,14 +117,13 @@ export async function removeSavedKeyword(
throw new AppError("NOT_FOUND");
}
const project = await KeywordResearchRepository.getProject(
savedKw.projectId,
userId,
);
if (!project) {
if (savedKw.projectId !== projectId) {
throw new AppError("FORBIDDEN");
}
await KeywordResearchRepository.removeSavedKeyword(input.savedKeywordId);
await KeywordResearchRepository.removeSavedKeyword(
input.savedKeywordId,
projectId,
);
return { success: true };
}

View File

@ -0,0 +1,93 @@
import { and, desc, eq } from "drizzle-orm";
import { db } from "@/db";
import { projects } from "@/db/schema";
import { AppError } from "@/server/lib/errors";
async function listProjects(organizationId: string) {
return db.query.projects.findMany({
where: eq(projects.organizationId, organizationId),
orderBy: desc(projects.createdAt),
});
}
async function getProjectForOrganization(
projectId: string,
organizationId: string,
) {
return db.query.projects.findFirst({
where: and(
eq(projects.id, projectId),
eq(projects.organizationId, organizationId),
),
});
}
async function getProjectById(projectId: string) {
return db.query.projects.findFirst({
where: eq(projects.id, projectId),
});
}
async function getProjectPsiApiKey(projectId: string) {
const project = await db.query.projects.findFirst({
where: eq(projects.id, projectId),
columns: { pagespeedApiKey: true },
});
return project?.pagespeedApiKey ?? null;
}
async function setProjectPsiApiKey(projectId: string, apiKey: string) {
await db
.update(projects)
.set({ pagespeedApiKey: apiKey })
.where(eq(projects.id, projectId));
}
async function clearProjectPsiApiKey(projectId: string) {
await db
.update(projects)
.set({ pagespeedApiKey: null })
.where(eq(projects.id, projectId));
}
async function createProject(
organizationId: string,
name: string,
domain?: string,
) {
const id = crypto.randomUUID();
await db.insert(projects).values({
id,
organizationId,
name,
domain,
});
return id;
}
async function deleteProject(projectId: string, organizationId: string) {
const project = await getProjectForOrganization(projectId, organizationId);
if (!project) {
throw new AppError("NOT_FOUND");
}
await db
.delete(projects)
.where(
and(
eq(projects.id, projectId),
eq(projects.organizationId, organizationId),
),
);
}
export const ProjectRepository = {
listProjects,
getProjectForOrganization,
getProjectById,
getProjectPsiApiKey,
setProjectPsiApiKey,
clearProjectPsiApiKey,
createProject,
deleteProject,
} as const;

View File

@ -0,0 +1,17 @@
import {
createProject,
deleteProject,
getOrCreateDefaultProject,
getProject,
getProjectForOrganization,
listProjects,
} from "@/server/features/projects/services/projects";
export const ProjectService = {
listProjects,
createProject,
deleteProject,
getOrCreateDefaultProject,
getProject,
getProjectForOrganization,
} as const;

View File

@ -0,0 +1,89 @@
import type {
CreateProjectInput,
DeleteProjectInput,
} from "@/types/schemas/projects";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { AppError } from "@/server/lib/errors";
function mapProject(project: {
id: string;
name: string;
domain: string | null;
createdAt: string;
}) {
return {
id: project.id,
name: project.name,
domain: project.domain,
createdAt: project.createdAt,
};
}
export async function listProjects(organizationId: string) {
const rows = await ProjectRepository.listProjects(organizationId);
return rows.map(mapProject);
}
export async function createProject(
organizationId: string,
input: CreateProjectInput,
) {
const id = await ProjectRepository.createProject(
organizationId,
input.name,
input.domain,
);
return { id };
}
export async function deleteProject(
organizationId: string,
input: DeleteProjectInput,
) {
await ProjectRepository.deleteProject(input.projectId, organizationId);
return { success: true };
}
export async function getOrCreateDefaultProject(organizationId: string) {
const existing = await ProjectRepository.listProjects(organizationId);
if (existing.length > 0) {
return mapProject(existing[0]);
}
const id = await ProjectRepository.createProject(
organizationId,
"Default",
undefined,
);
return {
id,
name: "Default",
domain: null,
createdAt: new Date().toISOString(),
};
}
export async function getProject(projectId: string) {
const project = await ProjectRepository.getProjectById(projectId);
if (!project) {
throw new AppError("NOT_FOUND");
}
return mapProject(project);
}
export async function getProjectForOrganization(
organizationId: string,
projectId: string,
) {
const project = await ProjectRepository.getProjectForOrganization(
projectId,
organizationId,
);
if (!project) {
throw new AppError("NOT_FOUND");
}
return mapProject(project);
}

View File

@ -1,102 +0,0 @@
import { and, desc, eq } from "drizzle-orm";
import { db } from "@/db";
import { projects, psiAuditResults } from "@/db/schema";
async function ensureProjectAccess(projectId: string, userId: string) {
const project = await db.query.projects.findFirst({
where: and(eq(projects.id, projectId), eq(projects.userId, userId)),
});
if (!project) {
throw new Error("Project not found");
}
}
async function createAuditResult(data: {
id: string;
projectId: string;
requestedUrl: string;
finalUrl: string;
strategy: "mobile" | "desktop";
status: "completed" | "failed";
performanceScore?: number | null;
accessibilityScore?: number | null;
bestPracticesScore?: number | null;
seoScore?: number | null;
firstContentfulPaint?: string | null;
largestContentfulPaint?: string | null;
totalBlockingTime?: string | null;
cumulativeLayoutShift?: string | null;
speedIndex?: string | null;
timeToInteractive?: string | null;
lighthouseVersion?: string | null;
errorMessage?: string | null;
r2Key?: string | null;
payloadSizeBytes?: number | null;
}) {
await db.insert(psiAuditResults).values({
id: data.id,
projectId: data.projectId,
requestedUrl: data.requestedUrl,
finalUrl: data.finalUrl,
strategy: data.strategy,
status: data.status,
performanceScore: data.performanceScore ?? null,
accessibilityScore: data.accessibilityScore ?? null,
bestPracticesScore: data.bestPracticesScore ?? null,
seoScore: data.seoScore ?? null,
firstContentfulPaint: data.firstContentfulPaint ?? null,
largestContentfulPaint: data.largestContentfulPaint ?? null,
totalBlockingTime: data.totalBlockingTime ?? null,
cumulativeLayoutShift: data.cumulativeLayoutShift ?? null,
speedIndex: data.speedIndex ?? null,
timeToInteractive: data.timeToInteractive ?? null,
lighthouseVersion: data.lighthouseVersion ?? null,
errorMessage: data.errorMessage ?? null,
r2Key: data.r2Key ?? null,
payloadSizeBytes: data.payloadSizeBytes ?? null,
createdAt: new Date().toISOString(),
});
}
async function listAuditResults(input: {
projectId: string;
userId: string;
strategy?: "mobile" | "desktop";
limit: number;
}) {
await ensureProjectAccess(input.projectId, input.userId);
return db.query.psiAuditResults.findMany({
where:
input.strategy != null
? and(
eq(psiAuditResults.projectId, input.projectId),
eq(psiAuditResults.strategy, input.strategy),
)
: eq(psiAuditResults.projectId, input.projectId),
orderBy: desc(psiAuditResults.createdAt),
limit: input.limit,
});
}
async function getAuditResult(input: {
projectId: string;
userId: string;
auditId: string;
}) {
await ensureProjectAccess(input.projectId, input.userId);
return db.query.psiAuditResults.findFirst({
where: and(
eq(psiAuditResults.id, input.auditId),
eq(psiAuditResults.projectId, input.projectId),
),
});
}
export const PsiAuditRepository = {
createAuditResult,
listAuditResults,
getAuditResult,
} as const;

View File

@ -1,20 +1,17 @@
import { AppError } from "@/server/lib/errors";
import { getJsonFromR2, putJsonToR2 } from "@/server/lib/r2";
import { getJsonFromR2 } from "@/server/lib/r2";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
import { PsiAuditRepository } from "@/server/features/psi/repositories/PsiAuditRepository";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import {
PsiIssuesService,
type PsiIssueCategory,
} from "@/server/features/psi/services/PsiIssuesService";
import { buildPsiExportFile } from "@/server/features/psi/services/psi-export";
import { PsiService } from "@/server/features/psi/services/PsiService";
type PsiStrategy = "mobile" | "desktop";
type PsiSource = "single" | "site";
type ExportMode = "full" | "issues" | "category";
type ResolvedPsiSource = {
type AuditPsiTarget = {
id: string;
strategy: PsiStrategy;
finalUrl: string;
@ -22,36 +19,13 @@ type ResolvedPsiSource = {
r2Key: string | null;
};
async function resolvePsiSource(input: {
async function getAuditPsiTarget(input: {
projectId: string;
userId: string;
source: PsiSource;
resultId: string;
}): Promise<ResolvedPsiSource> {
if (input.source === "single") {
const row = await PsiAuditRepository.getAuditResult({
auditId: input.resultId,
projectId: input.projectId,
userId: input.userId,
});
if (!row) {
throw new AppError("NOT_FOUND");
}
return {
id: row.id,
strategy: row.strategy,
finalUrl: row.finalUrl,
createdAt: row.createdAt,
r2Key: row.r2Key,
};
}
}): Promise<AuditPsiTarget> {
const site = await AuditRepository.getPsiResultById({
psiResultId: input.resultId,
projectId: input.projectId,
userId: input.userId,
});
if (!site) {
@ -67,251 +41,33 @@ async function resolvePsiSource(input: {
};
}
async function runAudit(input: {
projectId: string;
userId: string;
url: string;
strategy: PsiStrategy;
}) {
const apiKey = await KeywordResearchRepository.getProjectPsiApiKey(
input.projectId,
input.userId,
);
if (!apiKey) {
throw new AppError("VALIDATION_ERROR");
}
const auditId = crypto.randomUUID();
try {
const result = await PsiService.runAudit({
url: input.url,
strategy: input.strategy,
apiKey,
});
const datePrefix = new Date().toISOString().slice(0, 10);
const key = `psi/${input.projectId}/${datePrefix}/${auditId}.json`;
const uploaded = await putJsonToR2(key, result.rawPayload);
await PsiAuditRepository.createAuditResult({
id: auditId,
projectId: input.projectId,
requestedUrl: result.requestedUrl,
finalUrl: result.finalUrl,
strategy: result.strategy,
status: "completed",
performanceScore: result.scores.performance,
accessibilityScore: result.scores.accessibility,
bestPracticesScore: result.scores["best-practices"],
seoScore: result.scores.seo,
firstContentfulPaint: result.metrics.firstContentfulPaint.displayValue,
largestContentfulPaint:
result.metrics.largestContentfulPaint.displayValue,
totalBlockingTime: result.metrics.totalBlockingTime.displayValue,
cumulativeLayoutShift: result.metrics.cumulativeLayoutShift.displayValue,
speedIndex: result.metrics.speedIndex.displayValue,
timeToInteractive: result.metrics.timeToInteractive.displayValue,
lighthouseVersion: result.lighthouseVersion,
r2Key: uploaded.key,
payloadSizeBytes: uploaded.sizeBytes,
});
return {
auditId,
requestedUrl: result.requestedUrl,
finalUrl: result.finalUrl,
strategy: result.strategy,
fetchedAt: result.fetchedAt,
lighthouseVersion: result.lighthouseVersion,
scores: result.scores,
metrics: result.metrics,
};
} catch (error) {
const requestedUrl = input.url.trim();
const message =
error instanceof Error ? error.message : "PSI request failed";
await PsiAuditRepository.createAuditResult({
id: auditId,
projectId: input.projectId,
requestedUrl,
finalUrl: requestedUrl,
strategy: input.strategy,
status: "failed",
errorMessage: message,
});
throw error;
}
}
async function getProjectPsiApiKey(input: {
projectId: string;
userId: string;
}) {
const apiKey = await KeywordResearchRepository.getProjectPsiApiKey(
input.projectId,
input.userId,
);
async function getProjectPsiApiKey(input: { projectId: string }) {
const apiKey = await ProjectRepository.getProjectPsiApiKey(input.projectId);
return { apiKey };
}
async function saveProjectPsiApiKey(input: {
projectId: string;
userId: string;
apiKey: string;
}) {
await KeywordResearchRepository.setProjectPsiApiKey(
await ProjectRepository.setProjectPsiApiKey(
input.projectId,
input.userId,
input.apiKey.trim(),
);
return { success: true };
}
async function clearProjectPsiApiKey(input: {
projectId: string;
userId: string;
}) {
await KeywordResearchRepository.clearProjectPsiApiKey(
input.projectId,
input.userId,
);
async function clearProjectPsiApiKey(input: { projectId: string }) {
await ProjectRepository.clearProjectPsiApiKey(input.projectId);
return { success: true };
}
async function listProjectPsiAudits(input: {
async function getAuditPsiIssues(input: {
projectId: string;
userId: string;
strategy?: PsiStrategy;
limit: number;
}) {
const rows = await PsiAuditRepository.listAuditResults({
projectId: input.projectId,
userId: input.userId,
strategy: input.strategy,
limit: input.limit,
});
return {
rows: rows.map((row) => ({
id: row.id,
requestedUrl: row.requestedUrl,
finalUrl: row.finalUrl,
strategy: row.strategy,
status: row.status,
performanceScore: row.performanceScore,
accessibilityScore: row.accessibilityScore,
bestPracticesScore: row.bestPracticesScore,
seoScore: row.seoScore,
firstContentfulPaint: row.firstContentfulPaint,
largestContentfulPaint: row.largestContentfulPaint,
totalBlockingTime: row.totalBlockingTime,
cumulativeLayoutShift: row.cumulativeLayoutShift,
speedIndex: row.speedIndex,
timeToInteractive: row.timeToInteractive,
lighthouseVersion: row.lighthouseVersion,
errorMessage: row.errorMessage,
payloadSizeBytes: row.payloadSizeBytes,
createdAt: row.createdAt,
})),
};
}
async function getProjectPsiAuditRaw(input: {
projectId: string;
userId: string;
auditId: string;
}) {
const row = await PsiAuditRepository.getAuditResult({
auditId: input.auditId,
projectId: input.projectId,
userId: input.userId,
});
if (!row || !row.r2Key) {
throw new AppError("NOT_FOUND");
}
const payloadJson = await getJsonFromR2(row.r2Key);
return {
id: row.id,
strategy: row.strategy,
finalUrl: row.finalUrl,
createdAt: row.createdAt,
payloadJson,
};
}
async function getProjectPsiAuditIssues(input: {
projectId: string;
userId: string;
auditId: string;
category?: PsiIssueCategory;
}) {
const row = await PsiAuditRepository.getAuditResult({
auditId: input.auditId,
projectId: input.projectId,
userId: input.userId,
});
if (!row || !row.r2Key) {
throw new AppError("NOT_FOUND");
}
const payloadJson = await getJsonFromR2(row.r2Key);
const issues = PsiIssuesService.parseIssues(payloadJson, input.category);
return {
id: row.id,
finalUrl: row.finalUrl,
strategy: row.strategy,
createdAt: row.createdAt,
issues,
};
}
async function exportProjectPsiAudit(input: {
projectId: string;
userId: string;
auditId: string;
mode: ExportMode;
category?: PsiIssueCategory;
}) {
const row = await PsiAuditRepository.getAuditResult({
auditId: input.auditId,
projectId: input.projectId,
userId: input.userId,
});
if (!row || !row.r2Key) {
throw new AppError("NOT_FOUND");
}
const payloadJson = await getJsonFromR2(row.r2Key);
return buildPsiExportFile({
idField: "auditId",
idValue: row.id,
finalUrl: row.finalUrl,
strategy: row.strategy,
createdAt: row.createdAt,
payloadJson,
mode: input.mode,
category: input.mode === "category" ? input.category : undefined,
});
}
async function getPsiIssuesBySource(input: {
projectId: string;
userId: string;
source: PsiSource;
resultId: string;
category?: PsiIssueCategory;
}) {
const target = await resolvePsiSource(input);
const target = await getAuditPsiTarget(input);
if (!target.r2Key) {
throw new AppError("NOT_FOUND");
}
@ -328,15 +84,13 @@ async function getPsiIssuesBySource(input: {
};
}
async function exportPsiBySource(input: {
async function exportAuditPsi(input: {
projectId: string;
userId: string;
source: PsiSource;
resultId: string;
mode: ExportMode;
category?: PsiIssueCategory;
}) {
const target = await resolvePsiSource(input);
const target = await getAuditPsiTarget(input);
if (!target.r2Key) {
throw new AppError("NOT_FOUND");
}
@ -356,14 +110,9 @@ async function exportPsiBySource(input: {
}
export const PsiAuditService = {
runAudit,
getProjectPsiApiKey,
saveProjectPsiApiKey,
clearProjectPsiApiKey,
listProjectPsiAudits,
getProjectPsiAuditRaw,
getProjectPsiAuditIssues,
exportProjectPsiAudit,
getPsiIssuesBySource,
exportPsiBySource,
getAuditPsiIssues,
exportAuditPsi,
} as const;

View File

@ -1,194 +0,0 @@
import { z } from "zod";
const PSI_ENDPOINT =
"https://www.googleapis.com/pagespeedonline/v5/runPagespeed";
const PSI_CATEGORIES = [
"performance",
"accessibility",
"best-practices",
"seo",
] as const;
type PsiCategory = (typeof PSI_CATEGORIES)[number];
type PsiStrategy = "mobile" | "desktop";
type PsiAuditMetric = {
score: number | null;
displayValue: string | null;
numericValue: number | null;
};
type PsiAuditResult = {
requestedUrl: string;
finalUrl: string;
strategy: PsiStrategy;
fetchedAt: string;
lighthouseVersion: string | null;
scores: Record<PsiCategory, number | null>;
metrics: {
firstContentfulPaint: PsiAuditMetric;
largestContentfulPaint: PsiAuditMetric;
totalBlockingTime: PsiAuditMetric;
cumulativeLayoutShift: PsiAuditMetric;
speedIndex: PsiAuditMetric;
timeToInteractive: PsiAuditMetric;
};
rawPayload: Record<string, unknown>;
};
type LighthouseAudit = {
score?: number | null;
displayValue?: string;
numericValue?: number;
};
const lighthouseAuditSchema = z.object({
score: z.number().nullable().optional(),
displayValue: z.string().optional(),
numericValue: z.number().optional(),
});
const psiResponseSchema = z
.object({
lighthouseResult: z
.object({
finalDisplayedUrl: z.string().optional(),
lighthouseVersion: z.string().optional(),
categories: z
.record(
z.string(),
z.object({ score: z.number().nullable().optional() }),
)
.optional()
.default({}),
audits: z
.record(z.string(), lighthouseAuditSchema)
.optional()
.default({}),
})
.optional(),
})
.passthrough();
const psiErrorSchema = z
.object({
error: z
.object({
message: z.string().optional(),
})
.optional(),
})
.passthrough();
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object";
}
function normalizeInputUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("URL is required");
}
const withProtocol = /^https?:\/\//i.test(trimmed)
? trimmed
: `https://${trimmed}`;
try {
const parsed = new URL(withProtocol);
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new Error("Only http and https URLs are supported");
}
return parsed.toString();
} catch {
throw new Error("Please enter a valid URL");
}
}
function asScore(value: number | null | undefined): number | null {
if (value == null || Number.isNaN(value)) return null;
return Math.round(value * 100);
}
function asMetric(audit: LighthouseAudit | undefined): PsiAuditMetric {
return {
score: asScore(audit?.score),
displayValue: audit?.displayValue ?? null,
numericValue:
typeof audit?.numericValue === "number" ? audit.numericValue : null,
};
}
function extractErrorMessage(payload: unknown): string | null {
const parsed = psiErrorSchema.safeParse(payload);
if (!parsed.success) return null;
return parsed.data.error?.message ?? null;
}
async function runAudit(input: {
url: string;
strategy: PsiStrategy;
apiKey: string;
}): Promise<PsiAuditResult> {
const apiKey = input.apiKey.trim();
if (!apiKey) {
throw new Error("PSI API key is required");
}
const normalizedUrl = normalizeInputUrl(input.url);
const params = new URLSearchParams({
url: normalizedUrl,
strategy: input.strategy,
});
for (const category of PSI_CATEGORIES) {
params.append("category", category);
}
params.append("key", apiKey);
const response = await fetch(`${PSI_ENDPOINT}?${params.toString()}`);
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
const message = extractErrorMessage(payload);
throw new Error(message ?? `PSI request failed (${response.status})`);
}
const parsedPayload = psiResponseSchema.safeParse(payload);
if (!parsedPayload.success || !parsedPayload.data.lighthouseResult) {
throw new Error("PSI returned an invalid response");
}
const lighthouseResult = parsedPayload.data.lighthouseResult;
const categories = lighthouseResult.categories ?? {};
const audits: Record<string, LighthouseAudit> = lighthouseResult.audits ?? {};
return {
requestedUrl: normalizedUrl,
finalUrl: lighthouseResult.finalDisplayedUrl ?? normalizedUrl,
strategy: input.strategy,
fetchedAt: new Date().toISOString(),
lighthouseVersion: lighthouseResult.lighthouseVersion ?? null,
scores: {
performance: asScore(categories.performance?.score),
accessibility: asScore(categories.accessibility?.score),
"best-practices": asScore(categories["best-practices"]?.score),
seo: asScore(categories.seo?.score),
},
metrics: {
firstContentfulPaint: asMetric(audits["first-contentful-paint"]),
largestContentfulPaint: asMetric(audits["largest-contentful-paint"]),
totalBlockingTime: asMetric(audits["total-blocking-time"]),
cumulativeLayoutShift: asMetric(audits["cumulative-layout-shift"]),
speedIndex: asMetric(audits["speed-index"]),
timeToInteractive: asMetric(audits.interactive),
},
rawPayload: isRecord(payload) ? payload : {},
};
}
export const PsiService = {
runAudit,
} as const;

View File

@ -1,22 +1,5 @@
import { env } from "cloudflare:workers";
export async function putJsonToR2(
key: string,
payload: Record<string, unknown>,
): Promise<{ key: string; sizeBytes: number }> {
const body = JSON.stringify(payload);
await env.R2.put(key, body, {
httpMetadata: {
contentType: "application/json",
},
});
return {
key,
sizeBytes: Buffer.byteLength(body),
};
}
export async function getJsonFromR2(key: string): Promise<string> {
const object = await env.R2.get(key);
if (!object) {

View File

@ -1,3 +1,5 @@
import { isHostedAuthMode } from "@/lib/auth-mode";
let workersEnvPromise: Promise<Record<string, unknown> | null> | null = null;
async function getEnvValue(name: string): Promise<string | undefined> {
@ -20,6 +22,10 @@ export async function getRequiredEnvValue(name: string): Promise<string> {
return value;
}
export async function isHostedServerAuthMode(): Promise<boolean> {
return isHostedAuthMode(await getEnvValue("AUTH_MODE"));
}
export async function getWorkersBinding(name: string): Promise<unknown> {
const workersEnv = await getWorkersEnv();
const binding = workersEnv?.[name];

View File

@ -1,5 +1,5 @@
import { createServerFn } from "@tanstack/react-start";
import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
import { requireProjectContext } from "@/serverFunctions/middleware";
import {
startAuditSchema,
getAuditStatusSchema,
@ -11,51 +11,53 @@ import {
import { AuditService } from "@/server/features/audit/services/AuditService";
export const startAudit = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.middleware(requireProjectContext)
.inputValidator((data: unknown) => startAuditSchema.parse(data))
.handler(async ({ data, context }) =>
AuditService.startAudit({
userId: context.userId,
projectId: data.projectId,
.handler(async ({ data, context }) => {
return AuditService.startAudit({
actorUserId: context.userId,
projectId: context.project.id,
startUrl: data.startUrl,
maxPages: data.maxPages,
psiStrategy: data.psiStrategy,
psiApiKey: data.psiApiKey,
}),
);
});
});
export const getAuditStatus = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getAuditStatusSchema.parse(data))
.handler(async ({ data, context }) =>
AuditService.getStatus(data.auditId, context.userId),
);
.handler(async ({ data, context }) => {
return AuditService.getStatus(data.auditId, context.project.id);
});
export const getAuditResults = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getAuditResultsSchema.parse(data))
.handler(async ({ data, context }) =>
AuditService.getResults(data.auditId, context.userId),
);
.handler(async ({ data, context }) => {
return AuditService.getResults(data.auditId, context.project.id);
});
export const getAuditHistory = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
export const getAuditHistory = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getAuditHistorySchema.parse(data))
.handler(async ({ data, context }) =>
AuditService.getHistory(data.projectId, context.userId),
);
.handler(async ({ context }) => {
return AuditService.getHistory(context.project.id);
});
export const getCrawlProgress = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getCrawlProgressSchema.parse(data))
.handler(async ({ data, context }) =>
AuditService.getCrawlProgress(data.auditId, context.userId),
);
.handler(async ({ data, context }) => {
return AuditService.getCrawlProgress(data.auditId, context.project.id);
});
export const deleteAudit = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.middleware(requireProjectContext)
.inputValidator((data: unknown) => deleteAuditSchema.parse(data))
.handler(async ({ data, context }) => {
await AuditService.remove(data.auditId, context.userId);
await AuditService.remove(data.auditId, context.project.id);
return { success: true };
});

View File

@ -3,18 +3,17 @@ import {
buildBacklinksDisabledAccessStatus,
setBacklinksAccessStatus,
} from "@/server/features/backlinks/backlinksAccess";
import { assertBacklinksProjectAccess } from "@/server/features/backlinks/backlinksProjectAccess";
import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
import { AppError } from "@/server/lib/errors";
import { requireProjectContext } from "@/serverFunctions/middleware";
import { backlinksOverviewInputSchema } from "@/types/schemas/backlinks";
export const getBacklinksOverview = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
export const getBacklinksOverview = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => {
await assertBacklinksProjectAccess(context.userId, data.projectId);
.handler(async ({ data }) => {
try {
return await BacklinksService.getOverview({
target: data.target,
@ -36,12 +35,12 @@ export const getBacklinksOverview = createServerFn({ method: "POST" })
}
});
export const getBacklinksReferringDomains = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
export const getBacklinksReferringDomains = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => {
await assertBacklinksProjectAccess(context.userId, data.projectId);
.handler(async ({ data }) => {
try {
return await BacklinksService.getReferringDomains({
target: data.target,
@ -57,12 +56,12 @@ export const getBacklinksReferringDomains = createServerFn({ method: "POST" })
}
});
export const getBacklinksTopPages = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
export const getBacklinksTopPages = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => {
await assertBacklinksProjectAccess(context.userId, data.projectId);
.handler(async ({ data }) => {
try {
return await BacklinksService.getTopPages({
target: data.target,

View File

@ -1,31 +1,36 @@
import { createServerFn } from "@tanstack/react-start";
import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
import {
buildBacklinksDisabledAccessStatus,
buildVerifiedBacklinksAccessStatus,
getBacklinksAccessStatus,
setBacklinksAccessStatus,
} from "@/server/features/backlinks/backlinksAccess";
import { assertBacklinksProjectAccess } from "@/server/features/backlinks/backlinksProjectAccess";
import { AppError } from "@/server/lib/errors";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import { requireProjectContext } from "@/serverFunctions/middleware";
import { fetchBacklinksSummaryRaw } from "@/server/lib/dataforseoBacklinks";
import { backlinksProjectSchema } from "@/types/schemas/backlinks";
const BACKLINKS_ACCESS_CHECK_COOLDOWN_MS = 15 * 60 * 1000;
export const getBacklinksAccessSetupStatus = createServerFn({ method: "GET" })
.middleware(authenticatedServerFunctionMiddleware)
export const getBacklinksAccessSetupStatus = createServerFn({
method: "GET",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksProjectSchema.parse(data))
.handler(async ({ data, context }) => {
await assertBacklinksProjectAccess(context.userId, data.projectId);
return getBacklinksAccessStatus();
});
.handler(async () => getBacklinksAccessStatus());
export const testBacklinksAccess = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
export const testBacklinksAccess = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksProjectSchema.parse(data))
.handler(async ({ data, context }) => {
await assertBacklinksProjectAccess(context.userId, data.projectId);
.handler(async () => {
if (await isHostedServerAuthMode()) {
// Hosted deployments do not run the manual DataForSEO access test here;
// backlinks access is treated as platform-managed in this mode.
return getBacklinksAccessStatus();
}
const cachedStatus = await getBacklinksAccessStatus();
if (isRecentVerifiedBacklinksAccessCheck(cachedStatus)) {

View File

@ -1,9 +1,9 @@
import { createServerFn } from "@tanstack/react-start";
import { env } from "cloudflare:workers";
import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
export const getSeoApiKeyStatus = createServerFn({ method: "GET" })
.middleware(authenticatedServerFunctionMiddleware)
.middleware(requireAuthenticatedContext)
.handler(() => {
const configured = Boolean(env.DATAFORSEO_API_KEY?.trim());
return { configured };

View File

@ -1,9 +1,9 @@
import { createServerFn } from "@tanstack/react-start";
import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
import { domainOverviewSchema } from "@/types/schemas/domain";
import { DomainService } from "@/server/features/domain/services/DomainService";
export const getDomainOverview = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.middleware(requireAuthenticatedContext)
.inputValidator((data: unknown) => domainOverviewSchema.parse(data))
.handler(async ({ data }) => DomainService.getOverview(data));

View File

@ -1,6 +1,4 @@
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
import {
researchKeywordsSchema,
saveKeywordsSchema,
@ -9,53 +7,51 @@ import {
serpAnalysisSchema,
} from "@/types/schemas/keywords";
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
import {
requireAuthenticatedContext,
requireProjectContext,
} from "@/serverFunctions/middleware";
export const researchKeywords = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.middleware(requireProjectContext)
.inputValidator((data: unknown) => researchKeywordsSchema.parse(data))
.handler(async ({ data, context }) =>
KeywordResearchService.research(context.userId, data),
);
export const saveKeywords = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => saveKeywordsSchema.parse(data))
.handler(async ({ data, context }) =>
KeywordResearchService.saveKeywords(context.userId, data),
);
export const getSavedKeywords = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => getSavedKeywordsSchema.parse(data))
.handler(async ({ data, context }) =>
KeywordResearchService.getSavedKeywords(context.userId, data),
);
export const removeSavedKeyword = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => removeSavedKeywordSchema.parse(data))
.handler(async ({ data, context }) =>
KeywordResearchService.removeSavedKeyword(context.userId, data),
);
export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.handler(async ({ context }) =>
KeywordResearchService.getOrCreateDefaultProject(context.userId),
);
export const getSerpAnalysis = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => serpAnalysisSchema.parse(data))
.handler(async ({ data }) => KeywordResearchService.getSerpAnalysis(data));
const getProjectSchema = z.object({
projectId: z.string().min(1),
.handler(async ({ data, context }) => {
return KeywordResearchService.research({
...data,
projectId: context.project.id,
});
});
export const getProject = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => getProjectSchema.parse(data))
.handler(async ({ data, context }) =>
KeywordResearchService.getProject(context.userId, data.projectId),
);
export const saveKeywords = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => saveKeywordsSchema.parse(data))
.handler(async ({ data, context }) => {
return KeywordResearchService.saveKeywords({
...data,
projectId: context.project.id,
});
});
export const getSavedKeywords = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getSavedKeywordsSchema.parse(data))
.handler(async ({ data, context }) => {
return KeywordResearchService.getSavedKeywords({
...data,
projectId: context.project.id,
});
});
export const removeSavedKeyword = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => removeSavedKeywordSchema.parse(data))
.handler(async ({ data, context }) => {
return KeywordResearchService.removeSavedKeyword(context.project.id, data);
});
export const getSerpAnalysis = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.inputValidator((data: unknown) => serpAnalysisSchema.parse(data))
.handler(async ({ data }) => KeywordResearchService.getSerpAnalysis(data));

View File

@ -1,7 +1,70 @@
import { createMiddleware } from "@tanstack/react-start";
import { AppError } from "@/server/lib/errors";
import { errorHandlingMiddleware } from "@/middleware/errorHandling";
import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
import { ensureUserMiddleware } from "@/middleware/ensureUser";
export const authenticatedServerFunctionMiddleware = [
type AuthenticatedServerFunctionContext = EnsuredUserContext;
function getAuthenticatedContext(
context: unknown,
): AuthenticatedServerFunctionContext {
if (!isAuthenticatedServerFunctionContext(context)) {
throw new AppError(
"INTERNAL_ERROR",
"Authenticated server function context missing",
);
}
return context;
}
function isAuthenticatedServerFunctionContext(
context: unknown,
): context is AuthenticatedServerFunctionContext {
if (!context || typeof context !== "object") {
return false;
}
return (
"userId" in context &&
typeof context.userId === "string" &&
"userEmail" in context &&
typeof context.userEmail === "string" &&
"organizationId" in context &&
typeof context.organizationId === "string"
);
}
export const globalServerFunctionMiddleware = [
errorHandlingMiddleware,
ensureUserMiddleware,
] as const;
export const requireAuthenticatedContext = [
createMiddleware({ type: "function" }).server(({ next, context }) =>
next({
context: getAuthenticatedContext(context),
}),
),
] as const;
export const requireProjectContext = [
createMiddleware({ type: "function" }).server(({ next, context }) => {
const authenticatedContext = getAuthenticatedContext(context);
if (!authenticatedContext.project) {
throw new AppError(
"INTERNAL_ERROR",
"Project context missing from authenticated server function",
);
}
return next({
context: {
...authenticatedContext,
project: authenticatedContext.project,
},
});
}),
] as const;

View File

@ -0,0 +1,25 @@
import { createServerFn } from "@tanstack/react-start";
import { ProjectService } from "@/server/features/projects/services/ProjectService";
import {
requireAuthenticatedContext,
requireProjectContext,
} from "@/serverFunctions/middleware";
import { z } from "zod";
export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.handler(async ({ context }) =>
ProjectService.getOrCreateDefaultProject(context.organizationId),
);
export const getProjectAccess = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) =>
z.object({ projectId: z.string().min(1) }).parse(data),
)
.handler(async ({ context }) => {
return ProjectService.getProjectForOrganization(
context.organizationId,
context.project.id,
);
});

View File

@ -1,67 +1,66 @@
import { createServerFn } from "@tanstack/react-start";
import { PsiAuditService } from "@/server/features/psi/services/PsiAuditService";
import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
import { requireProjectContext } from "@/serverFunctions/middleware";
import {
psiUnifiedIssueSchema,
psiUnifiedExportSchema,
psiAuditIssueSchema,
psiAuditExportSchema,
psiProjectKeySchema,
psiProjectSchema,
} from "@/types/schemas/psi";
export const getProjectPsiApiKey = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
export const getProjectPsiApiKey = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => psiProjectSchema.parse(data))
.handler(async ({ data, context }) =>
PsiAuditService.getProjectPsiApiKey({
projectId: data.projectId,
userId: context.userId,
}),
);
.handler(async ({ context }) => {
return PsiAuditService.getProjectPsiApiKey({
projectId: context.project.id,
});
});
export const saveProjectPsiApiKey = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
export const saveProjectPsiApiKey = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => psiProjectKeySchema.parse(data))
.handler(async ({ data, context }) =>
PsiAuditService.saveProjectPsiApiKey({
projectId: data.projectId,
userId: context.userId,
.handler(async ({ data, context }) => {
return PsiAuditService.saveProjectPsiApiKey({
projectId: context.project.id,
apiKey: data.apiKey,
}),
);
});
});
export const clearProjectPsiApiKey = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
export const clearProjectPsiApiKey = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => psiProjectSchema.parse(data))
.handler(async ({ data, context }) =>
PsiAuditService.clearProjectPsiApiKey({
projectId: data.projectId,
userId: context.userId,
}),
);
.handler(async ({ context }) => {
return PsiAuditService.clearProjectPsiApiKey({
projectId: context.project.id,
});
});
export const getPsiIssuesBySource = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => psiUnifiedIssueSchema.parse(data))
.handler(async ({ data, context }) =>
PsiAuditService.getPsiIssuesBySource({
projectId: data.projectId,
userId: context.userId,
source: data.source,
export const getAuditPsiIssues = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => psiAuditIssueSchema.parse(data))
.handler(async ({ data, context }) => {
return PsiAuditService.getAuditPsiIssues({
projectId: context.project.id,
resultId: data.resultId,
category: data.category,
}),
);
});
});
export const exportPsiBySource = createServerFn({ method: "POST" })
.middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => psiUnifiedExportSchema.parse(data))
.handler(async ({ data, context }) =>
PsiAuditService.exportPsiBySource({
projectId: data.projectId,
userId: context.userId,
source: data.source,
export const exportAuditPsi = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => psiAuditExportSchema.parse(data))
.handler(async ({ data, context }) => {
return PsiAuditService.exportAuditPsi({
projectId: context.project.id,
resultId: data.resultId,
mode: data.mode,
category: data.category,
}),
);
});
});

6
src/start.ts Normal file
View File

@ -0,0 +1,6 @@
import { createStart } from "@tanstack/react-start";
import { globalServerFunctionMiddleware } from "@/serverFunctions/middleware";
export const startInstance = createStart(() => ({
functionMiddleware: globalServerFunctionMiddleware,
}));

View File

@ -14,10 +14,12 @@ export const startAuditSchema = z.object({
});
export const getAuditStatusSchema = z.object({
projectId: z.string().min(1),
auditId: z.string().min(1),
});
export const getAuditResultsSchema = z.object({
projectId: z.string().min(1),
auditId: z.string().min(1),
});
@ -26,10 +28,12 @@ export const getAuditHistorySchema = z.object({
});
export const deleteAuditSchema = z.object({
projectId: z.string().min(1),
auditId: z.string().min(1),
});
export const getCrawlProgressSchema = z.object({
projectId: z.string().min(1),
auditId: z.string().min(1),
});

View File

@ -14,15 +14,6 @@ export const researchKeywordsSchema = z.object({
.default("auto"),
});
export const createProjectSchema = z.object({
name: z.string().min(1, "Project name is required").max(120),
domain: z.string().max(255).optional(),
});
export const deleteProjectSchema = z.object({
projectId: z.string().min(1),
});
export const saveKeywordsSchema = z.object({
projectId: z.string().min(1),
keywords: z.array(z.string().min(1)).min(1).max(200),
@ -68,6 +59,7 @@ export const saveKeywordsSchema = z.object({
});
export const removeSavedKeywordSchema = z.object({
projectId: z.string().min(1),
savedKeywordId: z.string().min(1),
});
@ -76,8 +68,6 @@ export const getSavedKeywordsSchema = z.object({
});
export type ResearchKeywordsInput = z.infer<typeof researchKeywordsSchema>;
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type DeleteProjectInput = z.infer<typeof deleteProjectSchema>;
export type SaveKeywordsInput = z.infer<typeof saveKeywordsSchema>;
export type RemoveSavedKeywordInput = z.infer<typeof removeSavedKeywordSchema>;
export const serpAnalysisSchema = z.object({

View File

@ -0,0 +1,18 @@
import { z } from "zod";
export const createProjectSchema = z.object({
name: z.string().min(1, "Project name is required").max(120),
domain: z
.string()
.trim()
.max(255)
.transform((value) => value || undefined)
.optional(),
});
export const deleteProjectSchema = z.object({
projectId: z.string().min(1),
});
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type DeleteProjectInput = z.infer<typeof deleteProjectSchema>;

View File

@ -6,7 +6,6 @@ const psiCategories = [
"best-practices",
"seo",
] as const;
const psiSources = ["single", "site"] as const;
export const psiProjectKeySchema = z.object({
projectId: z.string().min(1, "Project is required"),
@ -17,23 +16,20 @@ export const psiProjectSchema = z.object({
projectId: z.string().min(1, "Project is required"),
});
export const psiUnifiedIssueSchema = z.object({
export const psiAuditIssueSchema = z.object({
projectId: z.string().min(1, "Project is required"),
source: z.enum(psiSources),
resultId: z.string().min(1, "Result id is required"),
category: z.enum(psiCategories).optional(),
});
export const psiUnifiedExportSchema = z.object({
export const psiAuditExportSchema = z.object({
projectId: z.string().min(1, "Project is required"),
source: z.enum(psiSources),
resultId: z.string().min(1, "Result id is required"),
mode: z.enum(["full", "issues", "category"]),
category: z.enum(psiCategories).optional(),
});
export const psiIssuesSearchSchema = z.object({
source: z.enum(psiSources).catch("single").default("single"),
category: z
.enum(["all", ...psiCategories])
.catch("all")

View File

@ -13,7 +13,7 @@ export default defineConfig(({ mode }) => {
const allowedHosts = env.ALLOWED_HOST ? [env.ALLOWED_HOST] : undefined;
return {
envPrefix: ["VITE_"],
envPrefix: ["VITE_", "AUTH_MODE"],
server: {
port,
},