From 4040a854a7d0828adf2ffdaf07af87db1f6f3102 Mon Sep 17 00:00:00 2001
From: Ben Senescu <44480372+bensenescu@users.noreply.github.com>
Date: Thu, 19 Mar 2026 19:24:34 -0400
Subject: [PATCH] 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
---
.env.example | 6 +-
.gitignore | 3 +
.oxlintrc.json | 2 +-
README.md | 2 +-
...01-project-scoping-for-server-functions.md | 48 +
cli-auth.ts | 11 +
drizzle/0003_light_sage.sql | 153 ++
drizzle/0004_faithful_sunset_bain.sql | 1 +
drizzle/meta/0003_snapshot.json | 1531 +++++++++++++++++
drizzle/meta/0004_snapshot.json | 1520 ++++++++++++++++
drizzle/meta/_journal.json | 14 +
knip.jsonc | 4 +-
package.json | 6 +-
pnpm-lock.yaml | 380 +++-
src/client/components/AuthConfigErrorCard.tsx | 10 +-
.../components/DefaultCatchBoundary.tsx | 15 +
src/client/components/Sidebar.tsx | 13 +-
.../components/UnauthenticatedErrorCard.tsx | 58 +
.../audit/launch/useLaunchController.ts | 3 +-
.../features/audit/results/ResultsTables.tsx | 2 +-
src/client/features/auth/AuthPage.tsx | 66 +
.../features/psi/issues/PsiIssuesScreen.tsx | 27 +-
src/client/layout/AppShell.tsx | 138 +-
src/client/navigation/items.ts | 17 +-
src/db/app.schema.ts | 225 +++
src/db/better-auth-schema.ts | 199 +++
src/db/index.ts | 5 +-
src/db/schema.ts | 269 +--
src/env.d.ts | 10 +
src/lib/auth-client.ts | 9 +
src/lib/auth-config.ts | 7 +
src/lib/auth-mode.ts | 24 +
src/lib/auth-options.ts | 11 +
src/lib/auth-redirect.ts | 17 +
src/lib/auth.ts | 116 ++
.../ensure-user/cloudflareAccess.ts | 88 +
src/middleware/ensure-user/delegated.ts | 55 +
src/middleware/ensure-user/hosted.ts | 65 +
src/middleware/ensure-user/types.ts | 12 +
src/middleware/ensureUser.ts | 190 +-
src/routeTree.gen.ts | 112 +-
src/routes/__root.tsx | 17 +-
src/routes/_auth.sign-in.tsx | 188 ++
src/routes/_auth.sign-up.tsx | 285 +++
src/routes/_auth.tsx | 15 +
src/routes/api/auth/$.ts | 34 +
src/routes/index.tsx | 16 +-
src/routes/p/$projectId/audit/index.tsx | 36 +-
.../p/$projectId/audit/issues/$resultId.tsx | 3 +-
.../p/$projectId/psi/issues/$resultId.tsx | 36 -
src/routes/p/$projectId/route.tsx | 4 +-
src/routes/p/$projectId/saved.tsx | 2 +-
src/server.ts | 9 +-
.../auth/default-hosted-organization.ts | 113 ++
src/server/auth/delegated-organization.ts | 61 +
.../audit/repositories/AuditRepository.ts | 85 +-
.../features/audit/services/AuditService.ts | 59 +-
.../backlinks/backlinksAccess.test.ts | 2 +
.../features/backlinks/backlinksAccess.ts | 25 +-
.../backlinks/backlinksProjectAccess.test.ts | 47 -
.../backlinks/backlinksProjectAccess.ts | 14 -
.../repositories/KeywordResearchRepository.ts | 92 +-
.../services/KeywordResearchService.ts | 10 -
.../keywords/services/research/index.ts | 7 -
.../keywords/services/research/projects.ts | 65 -
.../keywords/services/research/research.ts | 9 -
.../services/research/saved-keywords.ts | 32 +-
.../repositories/ProjectRepository.ts | 93 +
.../projects/services/ProjectService.ts | 17 +
.../features/projects/services/projects.ts | 89 +
.../psi/repositories/PsiAuditRepository.ts | 102 --
.../features/psi/services/PsiAuditService.ts | 283 +--
.../features/psi/services/PsiService.ts | 194 ---
src/server/lib/r2.ts | 17 -
src/server/lib/runtime-env.ts | 6 +
src/serverFunctions/audit.ts | 56 +-
src/serverFunctions/backlinks.ts | 33 +-
src/serverFunctions/backlinksAccess.ts | 29 +-
src/serverFunctions/config.ts | 4 +-
src/serverFunctions/domain.ts | 4 +-
src/serverFunctions/keywords.ts | 70 +-
src/serverFunctions/middleware.ts | 65 +-
src/serverFunctions/projects.ts | 25 +
src/serverFunctions/psi.ts | 93 +-
src/start.ts | 6 +
src/types/schemas/audit.ts | 4 +
src/types/schemas/keywords.ts | 12 +-
src/types/schemas/projects.ts | 18 +
src/types/schemas/psi.ts | 8 +-
vite.config.ts | 2 +-
90 files changed, 6289 insertions(+), 1651 deletions(-)
create mode 100644 adr/0001-project-scoping-for-server-functions.md
create mode 100644 cli-auth.ts
create mode 100644 drizzle/0003_light_sage.sql
create mode 100644 drizzle/0004_faithful_sunset_bain.sql
create mode 100644 drizzle/meta/0003_snapshot.json
create mode 100644 drizzle/meta/0004_snapshot.json
create mode 100644 src/client/components/UnauthenticatedErrorCard.tsx
create mode 100644 src/client/features/auth/AuthPage.tsx
create mode 100644 src/db/app.schema.ts
create mode 100644 src/db/better-auth-schema.ts
create mode 100644 src/lib/auth-client.ts
create mode 100644 src/lib/auth-config.ts
create mode 100644 src/lib/auth-mode.ts
create mode 100644 src/lib/auth-options.ts
create mode 100644 src/lib/auth-redirect.ts
create mode 100644 src/lib/auth.ts
create mode 100644 src/middleware/ensure-user/cloudflareAccess.ts
create mode 100644 src/middleware/ensure-user/delegated.ts
create mode 100644 src/middleware/ensure-user/hosted.ts
create mode 100644 src/middleware/ensure-user/types.ts
create mode 100644 src/routes/_auth.sign-in.tsx
create mode 100644 src/routes/_auth.sign-up.tsx
create mode 100644 src/routes/_auth.tsx
create mode 100644 src/routes/api/auth/$.ts
delete mode 100644 src/routes/p/$projectId/psi/issues/$resultId.tsx
create mode 100644 src/server/auth/default-hosted-organization.ts
create mode 100644 src/server/auth/delegated-organization.ts
delete mode 100644 src/server/features/backlinks/backlinksProjectAccess.test.ts
delete mode 100644 src/server/features/backlinks/backlinksProjectAccess.ts
delete mode 100644 src/server/features/keywords/services/research/projects.ts
create mode 100644 src/server/features/projects/repositories/ProjectRepository.ts
create mode 100644 src/server/features/projects/services/ProjectService.ts
create mode 100644 src/server/features/projects/services/projects.ts
delete mode 100644 src/server/features/psi/repositories/PsiAuditRepository.ts
delete mode 100644 src/server/features/psi/services/PsiService.ts
create mode 100644 src/serverFunctions/projects.ts
create mode 100644 src/start.ts
create mode 100644 src/types/schemas/projects.ts
diff --git a/.env.example b/.env.example
index 3fbb104..c7a328c 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/.gitignore b/.gitignore
index 2d9a124..7033a06 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,6 @@ dist/
.tanstack
.logs/
**/.pnpm-store/
+
+# Localflare generated files
+.localflare/
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 0d43aa4..2d4e5b5 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -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],
diff --git a/README.md b/README.md
index 4bdea2a..f13ecde 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/adr/0001-project-scoping-for-server-functions.md b/adr/0001-project-scoping-for-server-functions.md
new file mode 100644
index 0000000..40d3ace
--- /dev/null
+++ b/adr/0001-project-scoping-for-server-functions.md
@@ -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.
diff --git a/cli-auth.ts b/cli-auth.ts
new file mode 100644
index 0000000..5b69641
--- /dev/null
+++ b/cli-auth.ts
@@ -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,
+});
diff --git a/drizzle/0003_light_sage.sql b/drizzle/0003_light_sage.sql
new file mode 100644
index 0000000..af84efd
--- /dev/null
+++ b/drizzle/0003_light_sage.sql
@@ -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`;
diff --git a/drizzle/0004_faithful_sunset_bain.sql b/drizzle/0004_faithful_sunset_bain.sql
new file mode 100644
index 0000000..648ea1f
--- /dev/null
+++ b/drizzle/0004_faithful_sunset_bain.sql
@@ -0,0 +1 @@
+ALTER TABLE `session` DROP COLUMN `active_project_id`;
diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json
new file mode 100644
index 0000000..14c8d40
--- /dev/null
+++ b/drizzle/meta/0003_snapshot.json
@@ -0,0 +1,1531 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "fa88f515-0a12-4b85-bbd0-e13e61b1bab4",
+ "prevId": "fa6ce21f-5b93-4b40-9323-7a568c7370ce",
+ "tables": {
+ "audit_pages": {
+ "name": "audit_pages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "audit_id": {
+ "name": "audit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status_code": {
+ "name": "status_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "redirect_url": {
+ "name": "redirect_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "meta_description": {
+ "name": "meta_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "canonical_url": {
+ "name": "canonical_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "robots_meta": {
+ "name": "robots_meta",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "og_title": {
+ "name": "og_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "og_description": {
+ "name": "og_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "og_image": {
+ "name": "og_image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "h1_count": {
+ "name": "h1_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h2_count": {
+ "name": "h2_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h3_count": {
+ "name": "h3_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h4_count": {
+ "name": "h4_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h5_count": {
+ "name": "h5_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h6_count": {
+ "name": "h6_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "heading_order_json": {
+ "name": "heading_order_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "word_count": {
+ "name": "word_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "images_total": {
+ "name": "images_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "images_missing_alt": {
+ "name": "images_missing_alt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "images_json": {
+ "name": "images_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "internal_link_count": {
+ "name": "internal_link_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "external_link_count": {
+ "name": "external_link_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "has_structured_data": {
+ "name": "has_structured_data",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "hreflang_tags_json": {
+ "name": "hreflang_tags_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_indexable": {
+ "name": "is_indexable",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "response_time_ms": {
+ "name": "response_time_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "audit_pages_audit_id_idx": {
+ "name": "audit_pages_audit_id_idx",
+ "columns": [
+ "audit_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_pages_audit_id_audits_id_fk": {
+ "name": "audit_pages_audit_id_audits_id_fk",
+ "tableFrom": "audit_pages",
+ "tableTo": "audits",
+ "columnsFrom": [
+ "audit_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_psi_results": {
+ "name": "audit_psi_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "audit_id": {
+ "name": "audit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "page_id": {
+ "name": "page_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "strategy": {
+ "name": "strategy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "performance_score": {
+ "name": "performance_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "accessibility_score": {
+ "name": "accessibility_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "best_practices_score": {
+ "name": "best_practices_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "seo_score": {
+ "name": "seo_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lcp_ms": {
+ "name": "lcp_ms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cls": {
+ "name": "cls",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inp_ms": {
+ "name": "inp_ms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ttfb_ms": {
+ "name": "ttfb_ms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "payload_size_bytes": {
+ "name": "payload_size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "audit_psi_results_audit_id_idx": {
+ "name": "audit_psi_results_audit_id_idx",
+ "columns": [
+ "audit_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_psi_results_audit_id_audits_id_fk": {
+ "name": "audit_psi_results_audit_id_audits_id_fk",
+ "tableFrom": "audit_psi_results",
+ "tableTo": "audits",
+ "columnsFrom": [
+ "audit_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "audit_psi_results_page_id_audit_pages_id_fk": {
+ "name": "audit_psi_results_page_id_audit_pages_id_fk",
+ "tableFrom": "audit_psi_results",
+ "tableTo": "audit_pages",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audits": {
+ "name": "audits",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_by_user_id": {
+ "name": "started_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_url": {
+ "name": "start_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'running'"
+ },
+ "workflow_instance_id": {
+ "name": "workflow_instance_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "config": {
+ "name": "config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{}'"
+ },
+ "pages_crawled": {
+ "name": "pages_crawled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "pages_total": {
+ "name": "pages_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "psi_total": {
+ "name": "psi_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "psi_completed": {
+ "name": "psi_completed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "psi_failed": {
+ "name": "psi_failed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "current_phase": {
+ "name": "current_phase",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'discovery'"
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "audits_project_id_idx": {
+ "name": "audits_project_id_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": false
+ },
+ "audits_started_by_user_id_idx": {
+ "name": "audits_started_by_user_id_idx",
+ "columns": [
+ "started_by_user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audits_project_id_projects_id_fk": {
+ "name": "audits_project_id_projects_id_fk",
+ "tableFrom": "audits",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "delegated_users": {
+ "name": "delegated_users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "delegated_users_email_unique": {
+ "name": "delegated_users_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "keyword_metrics": {
+ "name": "keyword_metrics",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location_code": {
+ "name": "location_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "language_code": {
+ "name": "language_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ },
+ "search_volume": {
+ "name": "search_volume",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cpc": {
+ "name": "cpc",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "competition": {
+ "name": "competition",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "keyword_difficulty": {
+ "name": "keyword_difficulty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "intent": {
+ "name": "intent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "monthly_searches": {
+ "name": "monthly_searches",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "keyword_metrics_unique_project_keyword_location_language": {
+ "name": "keyword_metrics_unique_project_keyword_location_language",
+ "columns": [
+ "project_id",
+ "keyword",
+ "location_code",
+ "language_code"
+ ],
+ "isUnique": true
+ },
+ "keyword_metrics_lookup_idx": {
+ "name": "keyword_metrics_lookup_idx",
+ "columns": [
+ "project_id",
+ "keyword",
+ "location_code",
+ "language_code",
+ "fetched_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "keyword_metrics_project_id_projects_id_fk": {
+ "name": "keyword_metrics_project_id_projects_id_fk",
+ "tableFrom": "keyword_metrics",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "projects": {
+ "name": "projects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pagespeed_api_key": {
+ "name": "pagespeed_api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "projects_organization_id_organization_id_fk": {
+ "name": "projects_organization_id_organization_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "saved_keywords": {
+ "name": "saved_keywords",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location_code": {
+ "name": "location_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 2840
+ },
+ "language_code": {
+ "name": "language_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "saved_keywords_unique_project_keyword_location_language": {
+ "name": "saved_keywords_unique_project_keyword_location_language",
+ "columns": [
+ "project_id",
+ "keyword",
+ "location_code",
+ "language_code"
+ ],
+ "isUnique": true
+ },
+ "saved_keywords_project_created_idx": {
+ "name": "saved_keywords_project_created_idx",
+ "columns": [
+ "project_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "saved_keywords_project_id_projects_id_fk": {
+ "name": "saved_keywords_project_id_projects_id_fk",
+ "tableFrom": "saved_keywords",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "account": {
+ "name": "account",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invitation": {
+ "name": "invitation",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invitation_organizationId_idx": {
+ "name": "invitation_organizationId_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "user",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "member": {
+ "name": "member",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "member_organizationId_idx": {
+ "name": "member_organizationId_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ },
+ "member_userId_idx": {
+ "name": "member_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "organization": {
+ "name": "organization",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "organization_slug_uidx": {
+ "name": "organization_slug_uidx",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "active_project_id": {
+ "name": "active_project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verification": {
+ "name": "verification",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ "identifier"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {
+ "\"users\"": "\"delegated_users\""
+ },
+ "columns": {
+ "\"audits\".\"user_id\"": "\"audits\".\"started_by_user_id\""
+ }
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json
new file mode 100644
index 0000000..d44afc1
--- /dev/null
+++ b/drizzle/meta/0004_snapshot.json
@@ -0,0 +1,1520 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "5faf81e6-c97c-4fe8-9d9d-b654c6dfe9de",
+ "prevId": "fa88f515-0a12-4b85-bbd0-e13e61b1bab4",
+ "tables": {
+ "audit_pages": {
+ "name": "audit_pages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "audit_id": {
+ "name": "audit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status_code": {
+ "name": "status_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "redirect_url": {
+ "name": "redirect_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "meta_description": {
+ "name": "meta_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "canonical_url": {
+ "name": "canonical_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "robots_meta": {
+ "name": "robots_meta",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "og_title": {
+ "name": "og_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "og_description": {
+ "name": "og_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "og_image": {
+ "name": "og_image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "h1_count": {
+ "name": "h1_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h2_count": {
+ "name": "h2_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h3_count": {
+ "name": "h3_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h4_count": {
+ "name": "h4_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h5_count": {
+ "name": "h5_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h6_count": {
+ "name": "h6_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "heading_order_json": {
+ "name": "heading_order_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "word_count": {
+ "name": "word_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "images_total": {
+ "name": "images_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "images_missing_alt": {
+ "name": "images_missing_alt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "images_json": {
+ "name": "images_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "internal_link_count": {
+ "name": "internal_link_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "external_link_count": {
+ "name": "external_link_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "has_structured_data": {
+ "name": "has_structured_data",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "hreflang_tags_json": {
+ "name": "hreflang_tags_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_indexable": {
+ "name": "is_indexable",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "response_time_ms": {
+ "name": "response_time_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "audit_pages_audit_id_idx": {
+ "name": "audit_pages_audit_id_idx",
+ "columns": [
+ "audit_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_pages_audit_id_audits_id_fk": {
+ "name": "audit_pages_audit_id_audits_id_fk",
+ "tableFrom": "audit_pages",
+ "tableTo": "audits",
+ "columnsFrom": [
+ "audit_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_psi_results": {
+ "name": "audit_psi_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "audit_id": {
+ "name": "audit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "page_id": {
+ "name": "page_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "strategy": {
+ "name": "strategy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "performance_score": {
+ "name": "performance_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "accessibility_score": {
+ "name": "accessibility_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "best_practices_score": {
+ "name": "best_practices_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "seo_score": {
+ "name": "seo_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lcp_ms": {
+ "name": "lcp_ms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cls": {
+ "name": "cls",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inp_ms": {
+ "name": "inp_ms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ttfb_ms": {
+ "name": "ttfb_ms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "payload_size_bytes": {
+ "name": "payload_size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "audit_psi_results_audit_id_idx": {
+ "name": "audit_psi_results_audit_id_idx",
+ "columns": [
+ "audit_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_psi_results_audit_id_audits_id_fk": {
+ "name": "audit_psi_results_audit_id_audits_id_fk",
+ "tableFrom": "audit_psi_results",
+ "tableTo": "audits",
+ "columnsFrom": [
+ "audit_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "audit_psi_results_page_id_audit_pages_id_fk": {
+ "name": "audit_psi_results_page_id_audit_pages_id_fk",
+ "tableFrom": "audit_psi_results",
+ "tableTo": "audit_pages",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audits": {
+ "name": "audits",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_by_user_id": {
+ "name": "started_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_url": {
+ "name": "start_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'running'"
+ },
+ "workflow_instance_id": {
+ "name": "workflow_instance_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "config": {
+ "name": "config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{}'"
+ },
+ "pages_crawled": {
+ "name": "pages_crawled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "pages_total": {
+ "name": "pages_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "psi_total": {
+ "name": "psi_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "psi_completed": {
+ "name": "psi_completed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "psi_failed": {
+ "name": "psi_failed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "current_phase": {
+ "name": "current_phase",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'discovery'"
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "audits_project_id_idx": {
+ "name": "audits_project_id_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": false
+ },
+ "audits_started_by_user_id_idx": {
+ "name": "audits_started_by_user_id_idx",
+ "columns": [
+ "started_by_user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audits_project_id_projects_id_fk": {
+ "name": "audits_project_id_projects_id_fk",
+ "tableFrom": "audits",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "delegated_users": {
+ "name": "delegated_users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "delegated_users_email_unique": {
+ "name": "delegated_users_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "keyword_metrics": {
+ "name": "keyword_metrics",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location_code": {
+ "name": "location_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "language_code": {
+ "name": "language_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ },
+ "search_volume": {
+ "name": "search_volume",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cpc": {
+ "name": "cpc",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "competition": {
+ "name": "competition",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "keyword_difficulty": {
+ "name": "keyword_difficulty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "intent": {
+ "name": "intent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "monthly_searches": {
+ "name": "monthly_searches",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "keyword_metrics_unique_project_keyword_location_language": {
+ "name": "keyword_metrics_unique_project_keyword_location_language",
+ "columns": [
+ "project_id",
+ "keyword",
+ "location_code",
+ "language_code"
+ ],
+ "isUnique": true
+ },
+ "keyword_metrics_lookup_idx": {
+ "name": "keyword_metrics_lookup_idx",
+ "columns": [
+ "project_id",
+ "keyword",
+ "location_code",
+ "language_code",
+ "fetched_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "keyword_metrics_project_id_projects_id_fk": {
+ "name": "keyword_metrics_project_id_projects_id_fk",
+ "tableFrom": "keyword_metrics",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "projects": {
+ "name": "projects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pagespeed_api_key": {
+ "name": "pagespeed_api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "projects_organization_id_organization_id_fk": {
+ "name": "projects_organization_id_organization_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "saved_keywords": {
+ "name": "saved_keywords",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location_code": {
+ "name": "location_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 2840
+ },
+ "language_code": {
+ "name": "language_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "saved_keywords_unique_project_keyword_location_language": {
+ "name": "saved_keywords_unique_project_keyword_location_language",
+ "columns": [
+ "project_id",
+ "keyword",
+ "location_code",
+ "language_code"
+ ],
+ "isUnique": true
+ },
+ "saved_keywords_project_created_idx": {
+ "name": "saved_keywords_project_created_idx",
+ "columns": [
+ "project_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "saved_keywords_project_id_projects_id_fk": {
+ "name": "saved_keywords_project_id_projects_id_fk",
+ "tableFrom": "saved_keywords",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "account": {
+ "name": "account",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invitation": {
+ "name": "invitation",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invitation_organizationId_idx": {
+ "name": "invitation_organizationId_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "user",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "member": {
+ "name": "member",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "member_organizationId_idx": {
+ "name": "member_organizationId_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ },
+ "member_userId_idx": {
+ "name": "member_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "organization": {
+ "name": "organization",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "organization_slug_uidx": {
+ "name": "organization_slug_uidx",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verification": {
+ "name": "verification",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ "identifier"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index bb5f668..4474950 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -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
}
]
}
\ No newline at end of file
diff --git a/knip.jsonc b/knip.jsonc
index 64dbca7..8104a22 100644
--- a/knip.jsonc
+++ b/knip.jsonc
@@ -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": [
diff --git a/package.json b/package.json
index 1299a55..db36eda 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 057d8b0..a316fce 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/src/client/components/AuthConfigErrorCard.tsx b/src/client/components/AuthConfigErrorCard.tsx
index f08989d..f0fbeb5 100644
--- a/src/client/components/AuthConfigErrorCard.tsx
+++ b/src/client/components/AuthConfigErrorCard.tsx
@@ -17,7 +17,7 @@ export function AuthConfigErrorCard({
- Cloudflare Access setup required
+ Authentication setup required
@@ -25,10 +25,12 @@ export function AuthConfigErrorCard({
- This deployment is missing required Access settings for
- AUTH_MODE=cloudflare_access. Configure{" "}
+ Check the auth environment variables for your selected
+ AUTH_MODE. Cloudflare Access requires
TEAM_DOMAIN and
- POLICY_AUD, then retry.
+ POLICY_AUD. Hosted mode requires
+ BETTER_AUTH_SECRET and
+ BETTER_AUTH_URL.
diff --git a/src/client/components/DefaultCatchBoundary.tsx b/src/client/components/DefaultCatchBoundary.tsx
index 3a73622..67090c9 100644
--- a/src/client/components/DefaultCatchBoundary.tsx
+++ b/src/client/components/DefaultCatchBoundary.tsx
@@ -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 (
+
+ {
+ void router.invalidate();
+ }}
+ />
+
+ );
+ }
+
return (
{message}
diff --git a/src/client/components/Sidebar.tsx b/src/client/components/Sidebar.tsx
index eb02b5a..8383828 100644
--- a/src/client/components/Sidebar.tsx
+++ b/src/client/components/Sidebar.tsx
@@ -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 (
{/* Header */}
@@ -71,14 +73,13 @@ export function Sidebar({
{/* Navigation */}
{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 (
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 (
+
+
+
Authentication required
+
{message}
+
+ This deployment uses external authentication. Refresh your access
+ session, then try again.
+
+ {onRetry ? (
+
+
+ Try Again
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/client/features/audit/launch/useLaunchController.ts b/src/client/features/audit/launch/useLaunchController.ts
index 7c9ffd9..879a9e1 100644
--- a/src/client/features/audit/launch/useLaunchController.ts
+++ b/src/client/features/audit/launch/useLaunchController.ts
@@ -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");
diff --git a/src/client/features/audit/results/ResultsTables.tsx b/src/client/features/audit/results/ResultsTables.tsx
index 36a8d65..578a638 100644
--- a/src/client/features/audit/results/ResultsTables.tsx
+++ b/src/client/features/audit/results/ResultsTables.tsx
@@ -161,7 +161,7 @@ function PerformanceRow({
{result.r2Key ? (
View issues
diff --git a/src/client/features/auth/AuthPage.tsx b/src/client/features/auth/AuthPage.tsx
new file mode 100644
index 0000000..b1aaacf
--- /dev/null
+++ b/src/client/features/auth/AuthPage.tsx
@@ -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 (
+
+
+
+
{title}
+
{helperText}
+
+
+ {children}
+
+ {footer}
+
+
+ );
+}
diff --git a/src/client/features/psi/issues/PsiIssuesScreen.tsx b/src/client/features/psi/issues/PsiIssuesScreen.tsx
index 95265c9..416987c 100644
--- a/src/client/features/psi/issues/PsiIssuesScreen.tsx
+++ b/src/client/features/psi/issues/PsiIssuesScreen.tsx
@@ -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,
},
diff --git a/src/client/layout/AppShell.tsx b/src/client/layout/AppShell.tsx
index 6ed5c95..0a44b7e 100644
--- a/src/client/layout/AppShell.tsx
+++ b/src/client/layout/AppShell.tsx
@@ -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 (
@@ -41,13 +51,12 @@ export function TopNav({
{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 (
-
-
-
- Default
-
-
+
+
+
+
+
+ Default
+
+
+
+
+
+ {isHostedMode ? (
+ <>
+
+
+ >
+ ) : null}
+
+ {isHostedMode ?
: null}
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+
+
+ {session.user.email}
+
+
+
+
+ Sign out
+
+
+
+
);
}
export function SeoApiStatusBanners({
- helpPath,
shouldShowSeoApiWarning,
seoApiKeyStatusError,
}: {
- helpPath: string;
shouldShowSeoApiWarning: boolean;
seoApiKeyStatusError: boolean;
}) {
@@ -98,7 +183,10 @@ export function SeoApiStatusBanners({
Setup needed: add your DataForSEO API key to use OpenSEO
features. See the quick steps on the{" "}
-
+
help page
.
@@ -116,7 +204,10 @@ export function SeoApiStatusBanners({
We could not verify your DataForSEO setup. If features are not
working, check the setup steps on the{" "}
-
+
help page
.
@@ -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<
Dismiss
-
+
Open setup guide
diff --git a/src/client/navigation/items.ts b/src/client/navigation/items.ts
index 6a1abdd..229c7ab 100644
--- a/src/client/navigation/items.ts
+++ b/src/client/navigation/items.ts
@@ -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",
+});
diff --git a/src/db/app.schema.ts b/src/db/app.schema.ts
new file mode 100644
index 0000000..b6e1370
--- /dev/null
+++ b/src/db/app.schema.ts
@@ -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)],
+);
diff --git a/src/db/better-auth-schema.ts b/src/db/better-auth-schema.ts
new file mode 100644
index 0000000..3d48147
--- /dev/null
+++ b/src/db/better-auth-schema.ts
@@ -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],
+ }),
+}));
diff --git a/src/db/index.ts b/src/db/index.ts
index 09be1d7..16c7bb1 100644
--- a/src/db/index.ts
+++ b/src/db/index.ts
@@ -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 };
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 934c72c..789dbcd 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -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";
diff --git a/src/env.d.ts b/src/env.d.ts
index a57e93f..78da687 100644
--- a/src/env.d.ts
+++ b/src/env.d.ts
@@ -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;
+}
diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts
new file mode 100644
index 0000000..4aa88a5
--- /dev/null
+++ b/src/lib/auth-client.ts
@@ -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;
diff --git a/src/lib/auth-config.ts b/src/lib/auth-config.ts
new file mode 100644
index 0000000..68deac0
--- /dev/null
+++ b/src/lib/auth-config.ts
@@ -0,0 +1,7 @@
+import { organization } from "better-auth/plugins";
+import { baseAuthOptions } from "@/lib/auth-options";
+
+export const baseAuthConfig = {
+ ...baseAuthOptions,
+ plugins: [organization()],
+};
diff --git a/src/lib/auth-mode.ts b/src/lib/auth-mode.ts
new file mode 100644
index 0000000..602c42a
--- /dev/null
+++ b/src/lib/auth-mode.ts
@@ -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);
+}
diff --git a/src/lib/auth-options.ts b/src/lib/auth-options.ts
new file mode 100644
index 0000000..898f256
--- /dev/null
+++ b/src/lib/auth-options.ts
@@ -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,
+ },
+};
diff --git a/src/lib/auth-redirect.ts b/src/lib/auth-redirect.ts
new file mode 100644
index 0000000..e2fa1b1
--- /dev/null
+++ b/src/lib/auth-redirect.ts
@@ -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 ?? ""}`,
+ );
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..d8ec04a
--- /dev/null
+++ b/src/lib/auth.ts
@@ -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 | 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;
+}
diff --git a/src/middleware/ensure-user/cloudflareAccess.ts b/src/middleware/ensure-user/cloudflareAccess.ts
new file mode 100644
index 0000000..54b7394
--- /dev/null
+++ b/src/middleware/ensure-user/cloudflareAccess.ts
@@ -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
+>();
+
+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 {
+ 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");
+ }
+}
diff --git a/src/middleware/ensure-user/delegated.ts b/src/middleware/ensure-user/delegated.ts
new file mode 100644
index 0000000..910f747
--- /dev/null
+++ b/src/middleware/ensure-user/delegated.ts
@@ -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 {
+ const ensuredEmail = await ensureUserRecord(userId, userEmail);
+ const organizationId = await ensureDelegatedOrganizationForUser(
+ userId,
+ ensuredEmail,
+ );
+
+ return {
+ userId,
+ userEmail: ensuredEmail,
+ organizationId,
+ };
+}
+
+export async function resolveLocalNoAuthContext(): Promise {
+ return resolveDelegatedContext(LOCAL_ADMIN_USER_ID, LOCAL_ADMIN_EMAIL);
+}
diff --git a/src/middleware/ensure-user/hosted.ts b/src/middleware/ensure-user/hosted.ts
new file mode 100644
index 0000000..13f057a
--- /dev/null
+++ b/src/middleware/ensure-user/hosted.ts
@@ -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 {
+ 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,
+ };
+}
diff --git a/src/middleware/ensure-user/types.ts b/src/middleware/ensure-user/types.ts
new file mode 100644
index 0000000..88c91ed
--- /dev/null
+++ b/src/middleware/ensure-user/types.ts
@@ -0,0 +1,12 @@
+import type { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
+
+export type EnsuredProject = NonNullable<
+ Awaited>
+>;
+
+export type EnsuredUserContext = {
+ userId: string;
+ userEmail: string;
+ organizationId: string;
+ project?: EnsuredProject;
+};
diff --git a/src/middleware/ensureUser.ts b/src/middleware/ensureUser.ts
index d1be6fa..9f56397 100644
--- a/src/middleware/ensureUser.ts
+++ b/src/middleware/ensureUser.ts
@@ -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
->();
-
-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,
);
- 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");
+ if (!project) {
+ throw new AppError("NOT_FOUND");
}
- } 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,
},
});
});
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index f70b012..0ec5f7f 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -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()
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>
+ config: Awaited>
}
}
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index 1aef004..86ea64d 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -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 ;
+ }
+
+ return ;
+}
+
+function AppShellLayout() {
const location = useLocation();
const [drawerOpen, setDrawerOpen] = useState(false);
const setupModalRef = React.useRef(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() {
/>
@@ -166,7 +178,6 @@ function AppLayout() {
setShowMissingSeoApiKeyModal(false)}
/>
diff --git a/src/routes/_auth.sign-in.tsx b/src/routes/_auth.sign-in.tsx
new file mode 100644
index 0000000..5e13911
--- /dev/null
+++ b/src/routes/_auth.sign-in.tsx
@@ -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>(
+ (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 (
+
+ Need an account?{" "}
+
+ Create account
+
+
+ ) : null
+ }
+ >
+ ({
+ submitError: state.errorMap.onSubmit,
+ isSubmitting: state.isSubmitting,
+ })}
+ >
+ {({ submitError, isSubmitting }) => (
+ <>
+ {submitError ? (
+ {submitError}
+ ) : null}
+
+ {isSubmitting ? "Signing in..." : "Sign in"}
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/routes/_auth.sign-up.tsx b/src/routes/_auth.sign-up.tsx
new file mode 100644
index 0000000..77a6b8a
--- /dev/null
+++ b/src/routes/_auth.sign-up.tsx
@@ -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>(
+ (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 (
+
+ Already have an account?{" "}
+
+ Sign in
+
+
+ ) : null
+ }
+ >
+ ({
+ submitError: state.errorMap.onSubmit,
+ isSubmitting: state.isSubmitting,
+ })}
+ >
+ {({ submitError, isSubmitting }) => (
+ <>
+ {submitError ? (
+ {submitError}
+ ) : null}
+
+ {isSubmitting ? "Creating account..." : "Create account"}
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/routes/_auth.tsx b/src/routes/_auth.tsx
new file mode 100644
index 0000000..15788cc
--- /dev/null
+++ b/src/routes/_auth.tsx
@@ -0,0 +1,15 @@
+import { Outlet, createFileRoute } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/_auth")({
+ component: AuthLayout,
+});
+
+function AuthLayout() {
+ return (
+
+ );
+}
diff --git a/src/routes/api/auth/$.ts b/src/routes/api/auth/$.ts
new file mode 100644
index 0000000..c59b28b
--- /dev/null
+++ b/src/routes/api/auth/$.ts
@@ -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);
+ },
+ },
+ },
+});
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 9f38d08..632e84c 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -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 (
+
+ {
+ mutate();
+ }}
+ />
+
+ );
+ }
+
return (
diff --git a/src/routes/p/$projectId/audit/index.tsx b/src/routes/p/$projectId/audit/index.tsx
index 2e0a3bf..ec13749 100644
--- a/src/routes/p/$projectId/audit/index.tsx
+++ b/src/routes/p/$projectId/audit/index.tsx
@@ -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 (
+
+
+
+
+
We could not load this audit. It may have been deleted.
+
+
+ ← Back to audits
+
+
+
+ );
+ }
+
const status = statusQuery.data;
const showSupportCta =
isFailed || (isComplete && status && status.pagesCrawled <= 1);
@@ -127,7 +143,11 @@ function AuditDetail({
{isRunning && 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,
});
diff --git a/src/routes/p/$projectId/audit/issues/$resultId.tsx b/src/routes/p/$projectId/audit/issues/$resultId.tsx
index c621d94..ea79843 100644
--- a/src/routes/p/$projectId/audit/issues/$resultId.tsx
+++ b/src/routes/p/$projectId/audit/issues/$resultId.tsx
@@ -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 (
diff --git a/src/routes/p/$projectId/psi/issues/$resultId.tsx b/src/routes/p/$projectId/psi/issues/$resultId.tsx
deleted file mode 100644
index ab73227..0000000
--- a/src/routes/p/$projectId/psi/issues/$resultId.tsx
+++ /dev/null
@@ -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 (
-
- void navigate({
- to: source === "site" ? "/p/$projectId/audit" : "..",
- params: { projectId },
- })
- }
- onCategoryChange={(next) =>
- void navigate({
- search: (prev) => ({ ...prev, category: next }),
- replace: true,
- })
- }
- />
- );
-}
diff --git a/src/routes/p/$projectId/route.tsx b/src/routes/p/$projectId/route.tsx
index 25ef767..7403a32 100644
--- a/src/routes/p/$projectId/route.tsx
+++ b/src/routes/p/$projectId/route.tsx
@@ -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(() => {
diff --git a/src/routes/p/$projectId/saved.tsx b/src/routes/p/$projectId/saved.tsx
index 4418577..b6cba1d 100644
--- a/src/routes/p/$projectId/saved.tsx
+++ b/src/routes/p/$projectId/saved.tsx
@@ -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],
diff --git a/src/server.ts b/src/server.ts
index df8c1c6..c378cfb 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -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,
};
diff --git a/src/server/auth/default-hosted-organization.ts b/src/server/auth/default-hosted-organization.ts
new file mode 100644
index 0000000..2419806
--- /dev/null
+++ b/src/server/auth/default-hosted-organization.ts
@@ -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);
+}
diff --git a/src/server/auth/delegated-organization.ts b/src/server/auth/delegated-organization.ts
new file mode 100644
index 0000000..276acfc
--- /dev/null
+++ b/src/server/auth/delegated-organization.ts
@@ -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;
+}
diff --git a/src/server/features/audit/repositories/AuditRepository.ts b/src/server/features/audit/repositories/AuditRepository.ts
index 7531fda..6165724 100644
--- a/src/server/features/audit/repositories/AuditRepository.ts
+++ b/src/server/features/audit/repositories/AuditRepository.ts
@@ -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;
diff --git a/src/server/features/audit/services/AuditService.ts b/src/server/features/audit/services/AuditService.ts
index ee8aa1c..4a74f86 100644
--- a/src/server/features/audit/services/AuditService.ts
+++ b/src/server/features/audit/services/AuditService.ts
@@ -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 = {
diff --git a/src/server/features/backlinks/backlinksAccess.test.ts b/src/server/features/backlinks/backlinksAccess.test.ts
index 916af15..c9d0c3a 100644
--- a/src/server/features/backlinks/backlinksAccess.test.ts
+++ b/src/server/features/backlinks/backlinksAccess.test.ts
@@ -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) => {
diff --git a/src/server/features/backlinks/backlinksAccess.ts b/src/server/features/backlinks/backlinksAccess.ts
index fcf8f51..6402eb0 100644
--- a/src/server/features/backlinks/backlinksAccess.ts
+++ b/src/server/features/backlinks/backlinksAccess.ts
@@ -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 {
+ 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
export async function setBacklinksAccessStatus(
status: BacklinksAccessStatus,
): Promise {
+ 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 {
const binding = await getWorkersBinding("KV");
if (isKvNamespace(binding)) {
diff --git a/src/server/features/backlinks/backlinksProjectAccess.test.ts b/src/server/features/backlinks/backlinksProjectAccess.test.ts
deleted file mode 100644
index a1800de..0000000
--- a/src/server/features/backlinks/backlinksProjectAccess.test.ts
+++ /dev/null
@@ -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);
- });
-});
diff --git a/src/server/features/backlinks/backlinksProjectAccess.ts b/src/server/features/backlinks/backlinksProjectAccess.ts
deleted file mode 100644
index fa67aeb..0000000
--- a/src/server/features/backlinks/backlinksProjectAccess.ts
+++ /dev/null
@@ -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;
-}
diff --git a/src/server/features/keywords/repositories/KeywordResearchRepository.ts b/src/server/features/keywords/repositories/KeywordResearchRepository.ts
index 17926f6..3cee641 100644
--- a/src/server/features/keywords/repositories/KeywordResearchRepository.ts
+++ b/src/server/features/keywords/repositories/KeywordResearchRepository.ts
@@ -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,
diff --git a/src/server/features/keywords/services/KeywordResearchService.ts b/src/server/features/keywords/services/KeywordResearchService.ts
index c05c8fb..5a726a4 100644
--- a/src/server/features/keywords/services/KeywordResearchService.ts
+++ b/src/server/features/keywords/services/KeywordResearchService.ts
@@ -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;
diff --git a/src/server/features/keywords/services/research/index.ts b/src/server/features/keywords/services/research/index.ts
index 43a7ab9..c05df5a 100644
--- a/src/server/features/keywords/services/research/index.ts
+++ b/src/server/features/keywords/services/research/index.ts
@@ -1,12 +1,5 @@
export { research } from "./research";
export { getSerpAnalysis } from "./serp";
-export {
- listProjects,
- createProject,
- deleteProject,
- getOrCreateDefaultProject,
- getProject,
-} from "./projects";
export {
saveKeywords,
getSavedKeywords,
diff --git a/src/server/features/keywords/services/research/projects.ts b/src/server/features/keywords/services/research/projects.ts
deleted file mode 100644
index 774dd22..0000000
--- a/src/server/features/keywords/services/research/projects.ts
+++ /dev/null
@@ -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,
- };
-}
diff --git a/src/server/features/keywords/services/research/research.ts b/src/server/features/keywords/services/research/research.ts
index afe2593..b5b8a5d 100644
--- a/src/server/features/keywords/services/research/research.ts
+++ b/src/server/features/keywords/services/research/research.ts
@@ -223,17 +223,8 @@ function persistRows(input: ResearchKeywordsInput, rows: EnrichedKeyword[]) {
}
export async function research(
- userId: string,
input: ResearchKeywordsInput,
): Promise {
- 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);
diff --git a/src/server/features/keywords/services/research/saved-keywords.ts b/src/server/features/keywords/services/research/saved-keywords.ts
index 6ef8299..d8113ba 100644
--- a/src/server/features/keywords/services/research/saved-keywords.ts
+++ b/src/server/features/keywords/services/research/saved-keywords.ts
@@ -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 };
}
diff --git a/src/server/features/projects/repositories/ProjectRepository.ts b/src/server/features/projects/repositories/ProjectRepository.ts
new file mode 100644
index 0000000..8a61294
--- /dev/null
+++ b/src/server/features/projects/repositories/ProjectRepository.ts
@@ -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;
diff --git a/src/server/features/projects/services/ProjectService.ts b/src/server/features/projects/services/ProjectService.ts
new file mode 100644
index 0000000..6adbea6
--- /dev/null
+++ b/src/server/features/projects/services/ProjectService.ts
@@ -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;
diff --git a/src/server/features/projects/services/projects.ts b/src/server/features/projects/services/projects.ts
new file mode 100644
index 0000000..952ed1a
--- /dev/null
+++ b/src/server/features/projects/services/projects.ts
@@ -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);
+}
diff --git a/src/server/features/psi/repositories/PsiAuditRepository.ts b/src/server/features/psi/repositories/PsiAuditRepository.ts
deleted file mode 100644
index 63ef256..0000000
--- a/src/server/features/psi/repositories/PsiAuditRepository.ts
+++ /dev/null
@@ -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;
diff --git a/src/server/features/psi/services/PsiAuditService.ts b/src/server/features/psi/services/PsiAuditService.ts
index 0b51b47..f8dc50d 100644
--- a/src/server/features/psi/services/PsiAuditService.ts
+++ b/src/server/features/psi/services/PsiAuditService.ts
@@ -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 {
- 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 {
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;
diff --git a/src/server/features/psi/services/PsiService.ts b/src/server/features/psi/services/PsiService.ts
deleted file mode 100644
index 3dc23ab..0000000
--- a/src/server/features/psi/services/PsiService.ts
+++ /dev/null
@@ -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;
- metrics: {
- firstContentfulPaint: PsiAuditMetric;
- largestContentfulPaint: PsiAuditMetric;
- totalBlockingTime: PsiAuditMetric;
- cumulativeLayoutShift: PsiAuditMetric;
- speedIndex: PsiAuditMetric;
- timeToInteractive: PsiAuditMetric;
- };
- rawPayload: Record;
-};
-
-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 {
- 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 {
- 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 = 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;
diff --git a/src/server/lib/r2.ts b/src/server/lib/r2.ts
index eb4c0a2..4eef897 100644
--- a/src/server/lib/r2.ts
+++ b/src/server/lib/r2.ts
@@ -1,22 +1,5 @@
import { env } from "cloudflare:workers";
-export async function putJsonToR2(
- key: string,
- payload: Record,
-): 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 {
const object = await env.R2.get(key);
if (!object) {
diff --git a/src/server/lib/runtime-env.ts b/src/server/lib/runtime-env.ts
index b44b7a8..05369d1 100644
--- a/src/server/lib/runtime-env.ts
+++ b/src/server/lib/runtime-env.ts
@@ -1,3 +1,5 @@
+import { isHostedAuthMode } from "@/lib/auth-mode";
+
let workersEnvPromise: Promise | null> | null = null;
async function getEnvValue(name: string): Promise {
@@ -20,6 +22,10 @@ export async function getRequiredEnvValue(name: string): Promise {
return value;
}
+export async function isHostedServerAuthMode(): Promise {
+ return isHostedAuthMode(await getEnvValue("AUTH_MODE"));
+}
+
export async function getWorkersBinding(name: string): Promise {
const workersEnv = await getWorkersEnv();
const binding = workersEnv?.[name];
diff --git a/src/serverFunctions/audit.ts b/src/serverFunctions/audit.ts
index 716455f..278cc69 100644
--- a/src/serverFunctions/audit.ts
+++ b/src/serverFunctions/audit.ts
@@ -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 };
});
diff --git a/src/serverFunctions/backlinks.ts b/src/serverFunctions/backlinks.ts
index 4680272..accde9c 100644
--- a/src/serverFunctions/backlinks.ts
+++ b/src/serverFunctions/backlinks.ts
@@ -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,
diff --git a/src/serverFunctions/backlinksAccess.ts b/src/serverFunctions/backlinksAccess.ts
index 35e101b..b612631 100644
--- a/src/serverFunctions/backlinksAccess.ts
+++ b/src/serverFunctions/backlinksAccess.ts
@@ -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)) {
diff --git a/src/serverFunctions/config.ts b/src/serverFunctions/config.ts
index 35593e1..1b46e33 100644
--- a/src/serverFunctions/config.ts
+++ b/src/serverFunctions/config.ts
@@ -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 };
diff --git a/src/serverFunctions/domain.ts b/src/serverFunctions/domain.ts
index 171ed0f..8f090ea 100644
--- a/src/serverFunctions/domain.ts
+++ b/src/serverFunctions/domain.ts
@@ -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));
diff --git a/src/serverFunctions/keywords.ts b/src/serverFunctions/keywords.ts
index 3c94ea0..619ac33 100644
--- a/src/serverFunctions/keywords.ts
+++ b/src/serverFunctions/keywords.ts
@@ -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),
- );
+ .handler(async ({ data, context }) => {
+ return KeywordResearchService.research({
+ ...data,
+ projectId: context.project.id,
+ });
+ });
export const saveKeywords = createServerFn({ method: "POST" })
- .middleware(authenticatedServerFunctionMiddleware)
+ .middleware(requireProjectContext)
.inputValidator((data: unknown) => saveKeywordsSchema.parse(data))
- .handler(async ({ data, context }) =>
- KeywordResearchService.saveKeywords(context.userId, data),
- );
+ .handler(async ({ data, context }) => {
+ return KeywordResearchService.saveKeywords({
+ ...data,
+ projectId: context.project.id,
+ });
+ });
export const getSavedKeywords = createServerFn({ method: "POST" })
- .middleware(authenticatedServerFunctionMiddleware)
+ .middleware(requireProjectContext)
.inputValidator((data: unknown) => getSavedKeywordsSchema.parse(data))
- .handler(async ({ data, context }) =>
- KeywordResearchService.getSavedKeywords(context.userId, data),
- );
+ .handler(async ({ data, context }) => {
+ return KeywordResearchService.getSavedKeywords({
+ ...data,
+ projectId: context.project.id,
+ });
+ });
-export const removeSavedKeyword = createServerFn({ method: "POST" })
- .middleware(authenticatedServerFunctionMiddleware)
+export const removeSavedKeyword = createServerFn({
+ method: "POST",
+})
+ .middleware(requireProjectContext)
.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),
- );
+ .handler(async ({ data, context }) => {
+ return KeywordResearchService.removeSavedKeyword(context.project.id, data);
+ });
export const getSerpAnalysis = createServerFn({ method: "POST" })
- .middleware(authenticatedServerFunctionMiddleware)
+ .middleware(requireAuthenticatedContext)
.inputValidator((data: unknown) => serpAnalysisSchema.parse(data))
.handler(async ({ data }) => KeywordResearchService.getSerpAnalysis(data));
-
-const getProjectSchema = z.object({
- projectId: z.string().min(1),
-});
-
-export const getProject = createServerFn({ method: "POST" })
- .middleware(authenticatedServerFunctionMiddleware)
- .inputValidator((data: unknown) => getProjectSchema.parse(data))
- .handler(async ({ data, context }) =>
- KeywordResearchService.getProject(context.userId, data.projectId),
- );
diff --git a/src/serverFunctions/middleware.ts b/src/serverFunctions/middleware.ts
index 16d6693..7775457 100644
--- a/src/serverFunctions/middleware.ts
+++ b/src/serverFunctions/middleware.ts
@@ -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;
diff --git a/src/serverFunctions/projects.ts b/src/serverFunctions/projects.ts
new file mode 100644
index 0000000..6aec5a6
--- /dev/null
+++ b/src/serverFunctions/projects.ts
@@ -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,
+ );
+ });
diff --git a/src/serverFunctions/psi.ts b/src/serverFunctions/psi.ts
index 11076bb..1d0edd9 100644
--- a/src/serverFunctions/psi.ts
+++ b/src/serverFunctions/psi.ts
@@ -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,
- }),
- );
+ });
+ });
diff --git a/src/start.ts b/src/start.ts
new file mode 100644
index 0000000..26c6eeb
--- /dev/null
+++ b/src/start.ts
@@ -0,0 +1,6 @@
+import { createStart } from "@tanstack/react-start";
+import { globalServerFunctionMiddleware } from "@/serverFunctions/middleware";
+
+export const startInstance = createStart(() => ({
+ functionMiddleware: globalServerFunctionMiddleware,
+}));
diff --git a/src/types/schemas/audit.ts b/src/types/schemas/audit.ts
index c4d9939..996350b 100644
--- a/src/types/schemas/audit.ts
+++ b/src/types/schemas/audit.ts
@@ -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),
});
diff --git a/src/types/schemas/keywords.ts b/src/types/schemas/keywords.ts
index 0cbe6af..c78a3c5 100644
--- a/src/types/schemas/keywords.ts
+++ b/src/types/schemas/keywords.ts
@@ -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;
-export type CreateProjectInput = z.infer;
-export type DeleteProjectInput = z.infer;
export type SaveKeywordsInput = z.infer;
export type RemoveSavedKeywordInput = z.infer;
export const serpAnalysisSchema = z.object({
diff --git a/src/types/schemas/projects.ts b/src/types/schemas/projects.ts
new file mode 100644
index 0000000..666a94a
--- /dev/null
+++ b/src/types/schemas/projects.ts
@@ -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;
+export type DeleteProjectInput = z.infer;
diff --git a/src/types/schemas/psi.ts b/src/types/schemas/psi.ts
index c9312c8..b0d9209 100644
--- a/src/types/schemas/psi.ts
+++ b/src/types/schemas/psi.ts
@@ -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")
diff --git a/vite.config.ts b/vite.config.ts
index 04bc689..08f9376 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -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,
},