feat: Add better auth (#24)
* refactor: rename delegated auth user table * feat: scaffold hosted better auth setup * feat: add hosted auth flows * refactor: scope project access to organizations * fix: harden hosted auth entry points * fix: stabilize org backfills and auth state * refactor: simplify hosted organization setup * fix: restore hosted auth signup flow * fix: preserve hosted workspace access * fix: preserve hosted auth redirects * Improve hosted auth UX: auto-redirect to sign-up, hide header on auth pages, add form placeholders, and trust portless dev origins - Auto-redirect unauthenticated users to /sign-up in hosted mode - Hide top nav on /sign-in and /sign-up for a cleaner auth experience - Add input placeholders across sign-in and sign-up forms - Make name field optional on sign-up (falls back to email username) - Update copy: remove 'hosted' from user-facing text, rename link to 'Create account' - Trust *.open-seo.localhost:1355 in dev mode to fix Better Auth origin rejection with portless worktrees * Simplify hosted auth flow and remove standalone PSI Use TanStack Form for sign-in and sign-up, make hosted unauthenticated handling redirect-focused, and inline auth route errors. Remove the leftover standalone PSI route, services, and table so PSI only exists within site audits. * Align project auth with Better Auth organizations * Make server function auth middleware global * Reduce auth server function boilerplate * delete migrations * fix regenerated migration data backfills * Simplify hosted auth flow and project audit scoping * Use active project context for audit actions * Allow hosted session project updates * Let agent dev server inherit auth mode * Match hosted header to gateway account menu * Scope project session updates to active project * Inline authenticated server function setup * Polish header project and account controls * restore auth generate script * Use explicit project access in server functions Make project-scoped server functions take projectId input and enforce ownership through shared middleware instead of session-backed current project state. Document the tradeoffs in an ADR so future changes can follow the same boundary. * fix ci dependency detection for auth tooling * Harden project auth in server middleware Authorize projectId automatically in authenticated server middleware and add a requireProject guard for project-scoped handlers. This makes the auth boundary harder to bypass and removes ad hoc non-null assertions from server functions. * Inline project id input schemas Remove tiny shared projectId schema helpers where they were adding indirection without reducing real complexity. Keep project-scoped validation explicit at each server function boundary. * Skip hosted backlinks access checks * Simplify auth mode helpers * Avoid rerunning auth server middleware * Simplify server function scoping ADR * Fix backlinks project scoping in hosted auth * Refine auth route foundations * Simplify ensure user auth resolution Split auth-mode context resolvers into focused modules so the middleware reads as request orchestration instead of implementation details. Reuse a shared ensured-user context type across server middleware. * Simplify hosted organization bootstrap Use Better Auth to own hosted organization creation and membership so hosted auth only needs to resolve a default active organization. Keep delegated-mode compatibility records isolated in a separate helper. * Clarify hosted auth and backlinks behavior Document the hosted AUTH_MODE deploy contract and explain why hosted deployments skip manual backlinks verification. This makes the platform-managed behavior explicit in the code paths that differ from self-serve mode. * Document hosted org creation callback Explain why auth.ts injects createOrganization into the hosted org helper. This makes the dependency direction explicit and avoids future import cycles while keeping the helper reusable. * Fix CI check failures * Fix nav link prop forwarding * save
This commit is contained in:
parent
82c2f99ec1
commit
4040a854a7
@ -18,7 +18,7 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# - cloudflare_access: validate Cloudflare Access JWTs (recommended for deploys)
|
||||
# - local_noauth: local trusted mode with injected admin user (admin@localhost)
|
||||
# - hosted: reserved for future hosted auth flow (not implemented yet)
|
||||
# - hosted: Better Auth email/password + organization mode
|
||||
#
|
||||
# Defaults to cloudflare_access when unset.
|
||||
# AUTH_MODE=cloudflare_access
|
||||
@ -26,3 +26,7 @@
|
||||
# Required when AUTH_MODE=cloudflare_access
|
||||
# TEAM_DOMAIN=https://your-team.cloudflareaccess.com
|
||||
# POLICY_AUD=your-cloudflare-access-aud-tag
|
||||
|
||||
# Required when AUTH_MODE=hosted
|
||||
# BETTER_AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-characters
|
||||
# BETTER_AUTH_URL=http://localhost:3001
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -29,3 +29,6 @@ dist/
|
||||
.tanstack
|
||||
.logs/
|
||||
**/.pnpm-store/
|
||||
|
||||
# Localflare generated files
|
||||
.localflare/
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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.
|
||||
|
||||
48
adr/0001-project-scoping-for-server-functions.md
Normal file
48
adr/0001-project-scoping-for-server-functions.md
Normal file
@ -0,0 +1,48 @@
|
||||
# Project scoping for server functions
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
TanStack Start server functions do not know which route called them, so they cannot infer the active project from the URL.
|
||||
|
||||
We used to keep the selected project in session state and read it indirectly from middleware. That made project scope implicit and caused drift between the current page, the current request, and other open tabs.
|
||||
|
||||
## Decision
|
||||
|
||||
Project-scoped server functions must accept `projectId` in their input.
|
||||
|
||||
Global server-function middleware now always resolves the authenticated user and organization. If the payload includes `projectId`, that same global middleware loads the project for the current organization and adds it to server-function context.
|
||||
|
||||
Function-level middleware is still used for type narrowing:
|
||||
|
||||
- `requireAuthenticatedContext` guarantees authenticated context is present.
|
||||
- `requireProjectContext` guarantees `context.project` is present for project-scoped handlers.
|
||||
|
||||
In practice:
|
||||
|
||||
- `organizationId` comes from global middleware.
|
||||
- `projectId` comes from explicit input.
|
||||
- `context.project` exists only when the request included a valid `projectId`.
|
||||
- handlers use `context.project.id`, not session-backed current-project state.
|
||||
|
||||
## Rationale
|
||||
|
||||
Explicit `projectId` matches how server functions actually work: the request payload, not the route, defines the target resource.
|
||||
|
||||
This gives us:
|
||||
|
||||
- request-level project scope
|
||||
- correct multi-tab behavior
|
||||
- authorization tied to the current request
|
||||
- simpler, more testable handlers
|
||||
|
||||
## Consequences
|
||||
|
||||
- Project-scoped server functions should validate `projectId` in input and use `requireProjectContext`.
|
||||
- Organization-scoped server functions should use authenticated context only.
|
||||
- Global middleware is the single place that resolves auth, organization, and optional project context.
|
||||
- The session is no longer the source of truth for the selected project.
|
||||
- Hosted mode may still apply product-level feature defaults after project access is resolved. For example, backlinks access can be treated as platform-enabled in hosted deployments, but the request must still be scoped to a project first.
|
||||
11
cli-auth.ts
Normal file
11
cli-auth.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { baseAuthConfig } from "./src/lib/auth-config";
|
||||
|
||||
const CLI_DEV_BASE_URL = "http://localhost:3000";
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: process.env.BETTER_AUTH_URL ?? CLI_DEV_BASE_URL,
|
||||
secret: process.env.BETTER_AUTH_SECRET ?? randomUUID(),
|
||||
...baseAuthConfig,
|
||||
});
|
||||
153
drizzle/0003_light_sage.sql
Normal file
153
drizzle/0003_light_sage.sql
Normal file
@ -0,0 +1,153 @@
|
||||
ALTER TABLE `users` RENAME TO `delegated_users`;--> statement-breakpoint
|
||||
CREATE TABLE `account` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`account_id` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`access_token` text,
|
||||
`refresh_token` text,
|
||||
`id_token` text,
|
||||
`access_token_expires_at` integer,
|
||||
`refresh_token_expires_at` integer,
|
||||
`scope` text,
|
||||
`password` text,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `invitation` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`role` text,
|
||||
`status` text DEFAULT 'pending' NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`inviter_id` text NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`inviter_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `invitation_organizationId_idx` ON `invitation` (`organization_id`);--> statement-breakpoint
|
||||
CREATE INDEX `invitation_email_idx` ON `invitation` (`email`);--> statement-breakpoint
|
||||
CREATE TABLE `member` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`role` text DEFAULT 'member' NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `member_organizationId_idx` ON `member` (`organization_id`);--> statement-breakpoint
|
||||
CREATE INDEX `member_userId_idx` ON `member` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `organization` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`slug` text NOT NULL,
|
||||
`logo` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`metadata` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `organization_slug_unique` ON `organization` (`slug`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `organization_slug_uidx` ON `organization` (`slug`);--> statement-breakpoint
|
||||
CREATE TABLE `session` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`ip_address` text,
|
||||
`user_agent` text,
|
||||
`user_id` text NOT NULL,
|
||||
`active_organization_id` text,
|
||||
`active_project_id` text,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `user` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`email_verified` integer DEFAULT false NOT NULL,
|
||||
`image` text,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
|
||||
CREATE TABLE `verification` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`identifier` text NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint
|
||||
DROP TABLE `psi_audit_results`;--> statement-breakpoint
|
||||
DROP INDEX `users_email_unique`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `delegated_users_email_unique` ON `delegated_users` (`email`);--> statement-breakpoint
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_audits` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`started_by_user_id` text NOT NULL,
|
||||
`start_url` text NOT NULL,
|
||||
`status` text DEFAULT 'running' NOT NULL,
|
||||
`workflow_instance_id` text,
|
||||
`config` text DEFAULT '{}' NOT NULL,
|
||||
`pages_crawled` integer DEFAULT 0 NOT NULL,
|
||||
`pages_total` integer DEFAULT 0 NOT NULL,
|
||||
`psi_total` integer DEFAULT 0 NOT NULL,
|
||||
`psi_completed` integer DEFAULT 0 NOT NULL,
|
||||
`psi_failed` integer DEFAULT 0 NOT NULL,
|
||||
`current_phase` text DEFAULT 'discovery',
|
||||
`started_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
`completed_at` text,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_audits`("id", "project_id", "started_by_user_id", "start_url", "status", "workflow_instance_id", "config", "pages_crawled", "pages_total", "psi_total", "psi_completed", "psi_failed", "current_phase", "started_at", "completed_at") SELECT "id", "project_id", "user_id", "start_url", "status", "workflow_instance_id", "config", "pages_crawled", "pages_total", "psi_total", "psi_completed", "psi_failed", "current_phase", "started_at", "completed_at" FROM `audits`;--> statement-breakpoint
|
||||
INSERT OR IGNORE INTO `organization` (`id`, `name`, `slug`, `logo`, `created_at`, `metadata`)
|
||||
SELECT
|
||||
'delegated-' || `id`,
|
||||
CASE
|
||||
WHEN instr(`email`, '@') > 1 THEN substr(`email`, 1, instr(`email`, '@') - 1) || ' workspace'
|
||||
ELSE `id` || ' workspace'
|
||||
END,
|
||||
'delegated-' || lower(replace(replace(replace(replace(
|
||||
CASE
|
||||
WHEN instr(`email`, '@') > 1 THEN substr(`email`, 1, instr(`email`, '@') - 1)
|
||||
ELSE `id`
|
||||
END,
|
||||
' ', '-'), '@', '-'), '.', '-'), '_', '-')) || '-' || lower(hex(`id`)),
|
||||
NULL,
|
||||
cast(unixepoch('subsecond') * 1000 as integer),
|
||||
NULL
|
||||
FROM `delegated_users`;--> statement-breakpoint
|
||||
DROP TABLE `audits`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_audits` RENAME TO `audits`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE INDEX `audits_project_id_idx` ON `audits` (`project_id`);--> statement-breakpoint
|
||||
CREATE INDEX `audits_started_by_user_id_idx` ON `audits` (`started_by_user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `__new_projects` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`domain` text,
|
||||
`pagespeed_api_key` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_projects`("id", "organization_id", "name", "domain", "pagespeed_api_key", "created_at") SELECT "id", 'delegated-' || "user_id", "name", "domain", "pagespeed_api_key", "created_at" FROM `projects`;--> statement-breakpoint
|
||||
DROP TABLE `projects`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_projects` RENAME TO `projects`;
|
||||
1
drizzle/0004_faithful_sunset_bain.sql
Normal file
1
drizzle/0004_faithful_sunset_bain.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE `session` DROP COLUMN `active_project_id`;
|
||||
1531
drizzle/meta/0003_snapshot.json
Normal file
1531
drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1520
drizzle/meta/0004_snapshot.json
Normal file
1520
drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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": [
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "AUTH_MODE=local_noauth vite dev",
|
||||
"dev:agents": "mkdir -p .logs && AUTH_MODE=local_noauth portless run vite dev 2>&1 | tee .logs/dev-server.log",
|
||||
"dev:agents:force": "mkdir -p .logs && AUTH_MODE=local_noauth portless --force run vite dev 2>&1 | tee .logs/dev-server.log",
|
||||
"dev:agents": "mkdir -p .logs && portless run vite dev 2>&1 | tee .logs/dev-server.log",
|
||||
"dev:agents:force": "mkdir -p .logs && portless --force run vite dev 2>&1 | tee .logs/dev-server.log",
|
||||
"build": "vite build && tsc --noEmit",
|
||||
"lint": "oxlint . --type-aware",
|
||||
"lint:fix": "oxlint . --type-aware --fix",
|
||||
@ -17,6 +17,7 @@
|
||||
"types:check": "tsc --noEmit",
|
||||
"format:check": "prettier --check .",
|
||||
"format:write": "prettier . --write",
|
||||
"auth:generate": "pnpm dlx auth@latest generate --config ./cli-auth.ts --adapter drizzle --dialect sqlite --output ./src/db/better-auth-schema.ts",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate:local": "wrangler d1 migrations apply DB --local",
|
||||
"db:migrate:prod": "wrangler d1 migrations apply DB --remote",
|
||||
@ -52,6 +53,7 @@
|
||||
"@tanstack/react-router": "^1.136.3",
|
||||
"@tanstack/react-router-devtools": "^1.136.3",
|
||||
"@tanstack/react-start": "^1.136.3",
|
||||
"better-auth": "^1.5.5",
|
||||
"cheerio": "^1.2.0",
|
||||
"cloudflare": "^5.2.0",
|
||||
"daisyui": "^5.5.5",
|
||||
|
||||
380
pnpm-lock.yaml
generated
380
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -17,7 +17,7 @@ export function AuthConfigErrorCard({
|
||||
<div className="card-body gap-4">
|
||||
<h2 className="card-title gap-2">
|
||||
<ShieldAlert className="size-5 text-error" />
|
||||
Cloudflare Access setup required
|
||||
Authentication setup required
|
||||
</h2>
|
||||
|
||||
<div className="alert alert-error">
|
||||
@ -25,10 +25,12 @@ export function AuthConfigErrorCard({
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-base-content/70">
|
||||
This deployment is missing required Access settings for
|
||||
<code className="mx-1">AUTH_MODE=cloudflare_access</code>. Configure{" "}
|
||||
Check the auth environment variables for your selected
|
||||
<code className="mx-1">AUTH_MODE</code>. Cloudflare Access requires
|
||||
<code className="mx-1">TEAM_DOMAIN</code> and
|
||||
<code className="ml-1">POLICY_AUD</code>, then retry.
|
||||
<code className="mx-1">POLICY_AUD</code>. Hosted mode requires
|
||||
<code className="mx-1">BETTER_AUTH_SECRET</code> and
|
||||
<code className="ml-1">BETTER_AUTH_URL</code>.
|
||||
</p>
|
||||
|
||||
<div className="card-actions justify-end">
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
getStandardErrorMessage,
|
||||
} from "@/client/lib/error-messages";
|
||||
import { AuthConfigErrorCard } from "@/client/components/AuthConfigErrorCard";
|
||||
import { UnauthenticatedErrorCard } from "@/client/components/UnauthenticatedErrorCard";
|
||||
|
||||
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
const router = useRouter();
|
||||
@ -19,6 +20,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
);
|
||||
const errorCode = getErrorCode(error);
|
||||
const showAuthConfigHelp = errorCode === "AUTH_CONFIG_MISSING";
|
||||
const showSignInHelp = errorCode === "UNAUTHENTICATED";
|
||||
|
||||
if (showAuthConfigHelp) {
|
||||
return (
|
||||
@ -33,6 +35,19 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (showSignInHelp) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1 p-4 flex items-center justify-center">
|
||||
<UnauthenticatedErrorCard
|
||||
message={message}
|
||||
onRetry={() => {
|
||||
void router.invalidate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex-1 p-4 flex flex-col items-center justify-center gap-6">
|
||||
<p className="text-center text-error">{message}</p>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronsUpDown, X } from "lucide-react";
|
||||
import { projectNavItems } from "@/client/navigation/items";
|
||||
import { getProjectNavItems } from "@/client/navigation/items";
|
||||
|
||||
interface SidebarProps {
|
||||
currentPath: string;
|
||||
@ -39,6 +39,8 @@ export function Sidebar({
|
||||
);
|
||||
}
|
||||
|
||||
const projectNavItems = getProjectNavItems(projectId);
|
||||
|
||||
return (
|
||||
<div className="sidebar w-64 border-r border-base-300 h-full bg-base-100 flex flex-col">
|
||||
{/* Header */}
|
||||
@ -71,14 +73,13 @@ export function Sidebar({
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-4 pl-3 overflow-y-auto">
|
||||
{projectNavItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentPath.includes(item.matchSegment);
|
||||
const { icon: Icon, matchSegment, ...linkProps } = item;
|
||||
const isActive = currentPath.includes(matchSegment);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
params={{ projectId }}
|
||||
key={linkProps.to}
|
||||
{...linkProps}
|
||||
onClick={onNavigate}
|
||||
className={`relative flex items-center gap-3 pl-4 pr-4 py-2 text-sm transition-colors ${
|
||||
isActive
|
||||
|
||||
58
src/client/components/UnauthenticatedErrorCard.tsx
Normal file
58
src/client/components/UnauthenticatedErrorCard.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { useEffect } from "react";
|
||||
import { getCurrentAuthRedirect } from "@/lib/auth-redirect";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
|
||||
type UnauthenticatedErrorCardProps = {
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
};
|
||||
|
||||
function getSignInHref(redirectTo: string) {
|
||||
return redirectTo === "/"
|
||||
? "/sign-in"
|
||||
: `/sign-in?redirect=${encodeURIComponent(redirectTo)}`;
|
||||
}
|
||||
|
||||
export function UnauthenticatedErrorCard({
|
||||
message,
|
||||
onRetry,
|
||||
}: UnauthenticatedErrorCardProps) {
|
||||
const redirectTo =
|
||||
typeof window === "undefined"
|
||||
? "/"
|
||||
: getCurrentAuthRedirect(window.location);
|
||||
const isHostedMode = isHostedClientAuthMode();
|
||||
const signInHref = getSignInHref(redirectTo);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !isHostedMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.replace(signInHref);
|
||||
}, [isHostedMode, signInHref]);
|
||||
|
||||
if (isHostedMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card w-full max-w-md bg-base-100 border border-base-300 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h2 className="card-title">Authentication required</h2>
|
||||
<p className="text-sm text-base-content/70">{message}</p>
|
||||
<p className="text-sm text-base-content/70">
|
||||
This deployment uses external authentication. Refresh your access
|
||||
session, then try again.
|
||||
</p>
|
||||
{onRetry ? (
|
||||
<div className="card-actions justify-end">
|
||||
<button className="btn btn-primary btn-sm" onClick={onRetry}>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -161,7 +161,7 @@ function PerformanceRow({
|
||||
{result.r2Key ? (
|
||||
<a
|
||||
className="btn btn-primary btn-xs"
|
||||
href={`/p/${projectId}/audit/issues/${result.id}?source=site&category=performance`}
|
||||
href={`/p/${projectId}/audit/issues/${result.id}?category=performance`}
|
||||
>
|
||||
View issues
|
||||
</a>
|
||||
|
||||
66
src/client/features/auth/AuthPage.tsx
Normal file
66
src/client/features/auth/AuthPage.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
|
||||
export const authRedirectSearchSchema = z.object({
|
||||
redirect: z.string().optional(),
|
||||
});
|
||||
|
||||
export function useAuthPageState(redirect: string | undefined) {
|
||||
const navigate = useNavigate();
|
||||
const redirectTo = normalizeAuthRedirect(redirect);
|
||||
const { data: session, isPending: isSessionPending } = useSession();
|
||||
const isHostedMode = isHostedClientAuthMode();
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigate({ href: redirectTo });
|
||||
}, [navigate, redirectTo, session?.user?.id]);
|
||||
|
||||
return {
|
||||
redirectTo,
|
||||
isHostedMode,
|
||||
isSessionPending,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAuthLinkSearch(redirectTo: string) {
|
||||
return redirectTo === "/" ? {} : { redirect: redirectTo };
|
||||
}
|
||||
|
||||
export function getFieldError(errors: unknown[]) {
|
||||
return typeof errors[0] === "string" ? errors[0] : null;
|
||||
}
|
||||
|
||||
export function AuthPageCard({
|
||||
title,
|
||||
helperText,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
helperText: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="card w-full max-w-md bg-base-100 shadow-xl border border-base-300">
|
||||
<div className="card-body gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||
<p className="text-sm text-base-content/70 mt-1">{helperText}</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -5,9 +5,16 @@ import {
|
||||
ChevronsUpDown,
|
||||
ExternalLink,
|
||||
Menu,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { Sidebar } from "@/client/components/Sidebar";
|
||||
import { projectNavItems } from "@/client/navigation/items";
|
||||
import {
|
||||
dataforseoHelpLinkOptions,
|
||||
getProjectNavItems,
|
||||
} from "@/client/navigation/items";
|
||||
import { getCurrentAuthRedirect } from "@/lib/auth-redirect";
|
||||
import { authClient, useSession } from "@/lib/auth-client";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
|
||||
export function TopNav({
|
||||
drawerOpen,
|
||||
@ -20,6 +27,9 @@ export function TopNav({
|
||||
pathname: string;
|
||||
onOpenDrawer: () => void;
|
||||
}) {
|
||||
const isHostedMode = isHostedClientAuthMode();
|
||||
const projectNavItems = projectId ? getProjectNavItems(projectId) : [];
|
||||
|
||||
return (
|
||||
<div className="navbar bg-base-100 border-b border-base-300 shrink-0 gap-2">
|
||||
<div className="flex-none flex items-center md:hidden">
|
||||
@ -41,13 +51,12 @@ export function TopNav({
|
||||
</span>
|
||||
{projectId
|
||||
? projectNavItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname.includes(item.matchSegment);
|
||||
const { icon: Icon, matchSegment, ...linkProps } = item;
|
||||
const isActive = pathname.includes(matchSegment);
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
params={{ projectId }}
|
||||
key={linkProps.to}
|
||||
{...linkProps}
|
||||
className={`btn btn-sm gap-2 ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium border-transparent"
|
||||
@ -64,27 +73,103 @@ export function TopNav({
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex-none hidden md:flex">
|
||||
<div
|
||||
className="tooltip tooltip-left before:whitespace-nowrap"
|
||||
data-tip="Multiple projects coming soon"
|
||||
>
|
||||
<button className="btn btn-ghost btn-sm font-medium text-sm gap-1 cursor-default">
|
||||
<span className="truncate">Default</span>
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
|
||||
</button>
|
||||
<div className="flex-none hidden md:flex items-center gap-2">
|
||||
<div className="flex items-center rounded-full border border-base-300 bg-base-100/70 px-1 py-1 shadow-sm">
|
||||
<div
|
||||
className="tooltip tooltip-left before:whitespace-nowrap"
|
||||
data-tip="Multiple projects coming soon"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 items-center gap-2 rounded-full px-3 text-left transition-colors hover:bg-base-200/80 cursor-default"
|
||||
aria-label="Current project"
|
||||
>
|
||||
<span className="max-w-28 truncate text-sm font-medium text-base-content">
|
||||
Default
|
||||
</span>
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/35" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isHostedMode ? (
|
||||
<>
|
||||
<div className="mx-1 h-6 w-px bg-base-300" />
|
||||
<HostedSessionActions />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isHostedMode ? <HostedSessionActions mobileOnly /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HostedSessionActions({
|
||||
mobileOnly = false,
|
||||
}: {
|
||||
mobileOnly?: boolean;
|
||||
}) {
|
||||
const { data: session } = useSession();
|
||||
|
||||
if (!session?.user?.email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSignOut = () => {
|
||||
const redirectTo = getCurrentAuthRedirect(window.location);
|
||||
void authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
window.location.assign(
|
||||
redirectTo === "/"
|
||||
? "/sign-in"
|
||||
: `/sign-in?redirect=${encodeURIComponent(redirectTo)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={mobileOnly ? "flex-none md:hidden ml-2" : "flex-none"}>
|
||||
<div className="dropdown dropdown-end">
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className={`btn btn-ghost btn-circle ${mobileOnly ? "" : "hover:bg-base-200/80"}`}
|
||||
aria-label="Open account menu"
|
||||
>
|
||||
<User className="h-5 w-5" />
|
||||
</button>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
||||
>
|
||||
<li className="menu-title max-w-full">
|
||||
<span className="truncate text-base-content">
|
||||
{session.user.email}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="text-error"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SeoApiStatusBanners({
|
||||
helpPath,
|
||||
shouldShowSeoApiWarning,
|
||||
seoApiKeyStatusError,
|
||||
}: {
|
||||
helpPath: string;
|
||||
shouldShowSeoApiWarning: boolean;
|
||||
seoApiKeyStatusError: boolean;
|
||||
}) {
|
||||
@ -98,7 +183,10 @@ export function SeoApiStatusBanners({
|
||||
<span className="text-sm">
|
||||
Setup needed: add your DataForSEO API key to use OpenSEO
|
||||
features. See the quick steps on the{" "}
|
||||
<Link to={helpPath} className="link link-primary font-medium">
|
||||
<Link
|
||||
{...dataforseoHelpLinkOptions}
|
||||
className="link link-primary font-medium"
|
||||
>
|
||||
help page
|
||||
</Link>
|
||||
.
|
||||
@ -116,7 +204,10 @@ export function SeoApiStatusBanners({
|
||||
<span className="text-sm">
|
||||
We could not verify your DataForSEO setup. If features are not
|
||||
working, check the setup steps on the{" "}
|
||||
<Link to={helpPath} className="link link-primary font-medium">
|
||||
<Link
|
||||
{...dataforseoHelpLinkOptions}
|
||||
className="link link-primary font-medium"
|
||||
>
|
||||
help page
|
||||
</Link>
|
||||
.
|
||||
@ -177,11 +268,10 @@ export function AppContent({
|
||||
export const MissingSeoSetupModal = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
helpPath: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
>(({ helpPath, isOpen, onClose }, ref) => {
|
||||
>(({ isOpen, onClose }, ref) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@ -219,7 +309,11 @@ export const MissingSeoSetupModal = React.forwardRef<
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||
Dismiss
|
||||
</button>
|
||||
<Link to={helpPath} className="btn btn-primary" onClick={onClose}>
|
||||
<Link
|
||||
{...dataforseoHelpLinkOptions}
|
||||
className="btn btn-primary"
|
||||
onClick={onClose}
|
||||
>
|
||||
Open setup guide
|
||||
<ExternalLink className="size-4" />
|
||||
</Link>
|
||||
|
||||
@ -6,8 +6,9 @@ import {
|
||||
Link2,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { linkOptions } from "@tanstack/react-router";
|
||||
|
||||
export const projectNavItems = [
|
||||
const projectNavItems = [
|
||||
{
|
||||
to: "/p/$projectId/keywords" as const,
|
||||
label: "Keyword Research",
|
||||
@ -45,3 +46,17 @@ export const projectNavItems = [
|
||||
matchSegment: "/ai",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getProjectNavItems(projectId: string) {
|
||||
return linkOptions(
|
||||
projectNavItems.map((item) => ({
|
||||
...item,
|
||||
params: { projectId },
|
||||
search: {},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export const dataforseoHelpLinkOptions = linkOptions({
|
||||
to: "/help/dataforseo-api-key",
|
||||
});
|
||||
|
||||
225
src/db/app.schema.ts
Normal file
225
src/db/app.schema.ts
Normal file
@ -0,0 +1,225 @@
|
||||
import {
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
real,
|
||||
uniqueIndex,
|
||||
index,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { organization } from "./better-auth-schema";
|
||||
|
||||
// This stores users for Cloudflare Access and local_noauth mode
|
||||
// since they don't map to better-auth's user schema
|
||||
export const delegatedUsers = sqliteTable("delegated_users", {
|
||||
id: text("id").primaryKey(),
|
||||
email: text("email").notNull().unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// Projects for keyword research
|
||||
export const projects = sqliteTable("projects", {
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
domain: text("domain"),
|
||||
// PSI keys are used for Google API abuse-control, not direct billing.
|
||||
// We still keep handling explicit to make the tradeoff obvious.
|
||||
pagespeedApiKey: text("pagespeed_api_key"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// User-saved keywords within a project. This is the canonical saved list.
|
||||
export const savedKeywords = sqliteTable(
|
||||
"saved_keywords",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
keyword: text("keyword").notNull(),
|
||||
locationCode: integer("location_code").notNull().default(2840),
|
||||
languageCode: text("language_code").notNull().default("en"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("saved_keywords_unique_project_keyword_location_language").on(
|
||||
table.projectId,
|
||||
table.keyword,
|
||||
table.locationCode,
|
||||
table.languageCode,
|
||||
),
|
||||
index("saved_keywords_project_created_idx").on(
|
||||
table.projectId,
|
||||
table.createdAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Latest cached metrics for a keyword within a project.
|
||||
// This is joined onto savedKeywords when rendering the saved keyword list.
|
||||
export const keywordMetrics = sqliteTable(
|
||||
"keyword_metrics",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
keyword: text("keyword").notNull(),
|
||||
locationCode: integer("location_code").notNull(),
|
||||
languageCode: text("language_code").notNull().default("en"),
|
||||
searchVolume: integer("search_volume"),
|
||||
cpc: real("cpc"),
|
||||
competition: real("competition"),
|
||||
keywordDifficulty: integer("keyword_difficulty"),
|
||||
intent: text("intent"),
|
||||
monthlySearches: text("monthly_searches"),
|
||||
fetchedAt: text("fetched_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("keyword_metrics_unique_project_keyword_location_language").on(
|
||||
table.projectId,
|
||||
table.keyword,
|
||||
table.locationCode,
|
||||
table.languageCode,
|
||||
),
|
||||
index("keyword_metrics_lookup_idx").on(
|
||||
table.projectId,
|
||||
table.keyword,
|
||||
table.locationCode,
|
||||
table.languageCode,
|
||||
table.fetchedAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Site Audit tables
|
||||
// ============================================================================
|
||||
|
||||
// One row per audit run
|
||||
export const audits = sqliteTable(
|
||||
"audits",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
startedByUserId: text("started_by_user_id").notNull(),
|
||||
startUrl: text("start_url").notNull(),
|
||||
status: text("status", {
|
||||
enum: ["running", "completed", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("running"),
|
||||
workflowInstanceId: text("workflow_instance_id"),
|
||||
// JSON config: { maxPages, psiStrategy, psiApiKey? }
|
||||
config: text("config").notNull().default("{}"),
|
||||
// Progress & summary
|
||||
pagesCrawled: integer("pages_crawled").notNull().default(0),
|
||||
pagesTotal: integer("pages_total").notNull().default(0),
|
||||
psiTotal: integer("psi_total").notNull().default(0),
|
||||
psiCompleted: integer("psi_completed").notNull().default(0),
|
||||
psiFailed: integer("psi_failed").notNull().default(0),
|
||||
currentPhase: text("current_phase").default("discovery"),
|
||||
startedAt: text("started_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
completedAt: text("completed_at"),
|
||||
},
|
||||
(table) => [
|
||||
index("audits_project_id_idx").on(table.projectId),
|
||||
index("audits_started_by_user_id_idx").on(table.startedByUserId),
|
||||
],
|
||||
);
|
||||
|
||||
// One row per crawled page
|
||||
export const auditPages = sqliteTable(
|
||||
"audit_pages",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
auditId: text("audit_id")
|
||||
.notNull()
|
||||
.references(() => audits.id, { onDelete: "cascade" }),
|
||||
url: text("url").notNull(),
|
||||
statusCode: integer("status_code"),
|
||||
redirectUrl: text("redirect_url"),
|
||||
// Metadata
|
||||
title: text("title"),
|
||||
metaDescription: text("meta_description"),
|
||||
canonicalUrl: text("canonical_url"),
|
||||
robotsMeta: text("robots_meta"),
|
||||
// Open Graph
|
||||
ogTitle: text("og_title"),
|
||||
ogDescription: text("og_description"),
|
||||
ogImage: text("og_image"),
|
||||
// Headings
|
||||
h1Count: integer("h1_count").notNull().default(0),
|
||||
h2Count: integer("h2_count").notNull().default(0),
|
||||
h3Count: integer("h3_count").notNull().default(0),
|
||||
h4Count: integer("h4_count").notNull().default(0),
|
||||
h5Count: integer("h5_count").notNull().default(0),
|
||||
h6Count: integer("h6_count").notNull().default(0),
|
||||
headingOrderJson: text("heading_order_json"),
|
||||
// Content
|
||||
wordCount: integer("word_count").notNull().default(0),
|
||||
// Images
|
||||
imagesTotal: integer("images_total").notNull().default(0),
|
||||
imagesMissingAlt: integer("images_missing_alt").notNull().default(0),
|
||||
imagesJson: text("images_json"),
|
||||
// Links
|
||||
internalLinkCount: integer("internal_link_count").notNull().default(0),
|
||||
externalLinkCount: integer("external_link_count").notNull().default(0),
|
||||
// Structured data
|
||||
hasStructuredData: integer("has_structured_data", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
// Hreflang
|
||||
hreflangTagsJson: text("hreflang_tags_json"),
|
||||
// Indexability
|
||||
isIndexable: integer("is_indexable", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
// Performance
|
||||
responseTimeMs: integer("response_time_ms"),
|
||||
},
|
||||
(table) => [index("audit_pages_audit_id_idx").on(table.auditId)],
|
||||
);
|
||||
|
||||
// PSI summaries captured as part of a site audit run.
|
||||
// These belong to audit pages and are the only PSI result records we keep.
|
||||
export const auditPsiResults = sqliteTable(
|
||||
"audit_psi_results",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
auditId: text("audit_id")
|
||||
.notNull()
|
||||
.references(() => audits.id, { onDelete: "cascade" }),
|
||||
pageId: text("page_id")
|
||||
.notNull()
|
||||
.references(() => auditPages.id, { onDelete: "cascade" }),
|
||||
strategy: text("strategy", { enum: ["mobile", "desktop"] }).notNull(),
|
||||
performanceScore: integer("performance_score"),
|
||||
accessibilityScore: integer("accessibility_score"),
|
||||
bestPracticesScore: integer("best_practices_score"),
|
||||
seoScore: integer("seo_score"),
|
||||
lcpMs: real("lcp_ms"),
|
||||
cls: real("cls"),
|
||||
inpMs: real("inp_ms"),
|
||||
ttfbMs: real("ttfb_ms"),
|
||||
errorMessage: text("error_message"),
|
||||
r2Key: text("r2_key"),
|
||||
payloadSizeBytes: integer("payload_size_bytes"),
|
||||
},
|
||||
(table) => [index("audit_psi_results_audit_id_idx").on(table.auditId)],
|
||||
);
|
||||
199
src/db/better-auth-schema.ts
Normal file
199
src/db/better-auth-schema.ts
Normal file
@ -0,0 +1,199 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import {
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const user = sqliteTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: integer("email_verified", { mode: "boolean" })
|
||||
.default(false)
|
||||
.notNull(),
|
||||
image: text("image"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const session = sqliteTable(
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
},
|
||||
(table) => [index("session_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const account = sqliteTable(
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: integer("access_token_expires_at", {
|
||||
mode: "timestamp_ms",
|
||||
}),
|
||||
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
|
||||
mode: "timestamp_ms",
|
||||
}),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("account_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const verification = sqliteTable(
|
||||
"verification",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||
);
|
||||
|
||||
export const organization = sqliteTable(
|
||||
"organization",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
logo: text("logo"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
|
||||
);
|
||||
|
||||
export const member = sqliteTable(
|
||||
"member",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
role: text("role").default("member").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("member_organizationId_idx").on(table.organizationId),
|
||||
index("member_userId_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const invitation = sqliteTable(
|
||||
"invitation",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
role: text("role"),
|
||||
status: text("status").default("pending").notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
inviterId: text("inviter_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
index("invitation_organizationId_idx").on(table.organizationId),
|
||||
index("invitation_email_idx").on(table.email),
|
||||
],
|
||||
);
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
}));
|
||||
|
||||
export const sessionRelations = relations(session, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const organizationRelations = relations(organization, ({ many }) => ({
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
}));
|
||||
|
||||
export const memberRelations = relations(member, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [member.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [member.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const invitationRelations = relations(invitation, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [invitation.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [invitation.inviterId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
@ -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 };
|
||||
|
||||
269
src/db/schema.ts
269
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";
|
||||
|
||||
10
src/env.d.ts
vendored
10
src/env.d.ts
vendored
@ -8,8 +8,18 @@ declare namespace Cloudflare {
|
||||
AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
|
||||
TEAM_DOMAIN?: string;
|
||||
POLICY_AUD?: string;
|
||||
BETTER_AUTH_SECRET?: string;
|
||||
BETTER_AUTH_URL?: string;
|
||||
|
||||
// DataForSEO API Basic auth value (base64 of login:password)
|
||||
DATAFORSEO_API_KEY: string;
|
||||
}
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
9
src/lib/auth-client.ts
Normal file
9
src/lib/auth-client.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { organizationClient } from "better-auth/client/plugins";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
||||
plugins: [organizationClient()],
|
||||
});
|
||||
|
||||
export const { useSession } = authClient;
|
||||
7
src/lib/auth-config.ts
Normal file
7
src/lib/auth-config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { organization } from "better-auth/plugins";
|
||||
import { baseAuthOptions } from "@/lib/auth-options";
|
||||
|
||||
export const baseAuthConfig = {
|
||||
...baseAuthOptions,
|
||||
plugins: [organization()],
|
||||
};
|
||||
24
src/lib/auth-mode.ts
Normal file
24
src/lib/auth-mode.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
type AuthMode = "cloudflare_access" | "local_noauth" | "hosted";
|
||||
|
||||
const authModeSchema = z
|
||||
.enum(["cloudflare_access", "local_noauth", "hosted"])
|
||||
.catch("cloudflare_access");
|
||||
|
||||
export function getAuthMode(value: string | null | undefined): AuthMode {
|
||||
return authModeSchema.parse(value);
|
||||
}
|
||||
|
||||
export function isHostedAuthMode(value: string | null | undefined) {
|
||||
return getAuthMode(value) === "hosted";
|
||||
}
|
||||
|
||||
export function isHostedClientAuthMode() {
|
||||
// This is an explicit deploy-time contract: the operator must keep the
|
||||
// client build-time AUTH_MODE aligned with the server runtime AUTH_MODE.
|
||||
// We accept that tradeoff to avoid a startup round-trip just to ask the
|
||||
// backend which auth UI to render. Hosted deployments must therefore set
|
||||
// AUTH_MODE=hosted in both the client build environment and the runtime.
|
||||
return isHostedAuthMode(import.meta.env.AUTH_MODE);
|
||||
}
|
||||
11
src/lib/auth-options.ts
Normal file
11
src/lib/auth-options.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export const HOSTED_PASSWORD_MIN_LENGTH = 8;
|
||||
export const HOSTED_PASSWORD_MAX_LENGTH = 128;
|
||||
|
||||
export const baseAuthOptions = {
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
disableSignUp: false,
|
||||
minPasswordLength: HOSTED_PASSWORD_MIN_LENGTH,
|
||||
maxPasswordLength: HOSTED_PASSWORD_MAX_LENGTH,
|
||||
},
|
||||
};
|
||||
17
src/lib/auth-redirect.ts
Normal file
17
src/lib/auth-redirect.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export function normalizeAuthRedirect(value: string | null | undefined) {
|
||||
if (!value || !value.startsWith("/") || value.startsWith("//")) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getCurrentAuthRedirect(location: {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash?: string;
|
||||
}) {
|
||||
return normalizeAuthRedirect(
|
||||
`${location.pathname}${location.search}${location.hash ?? ""}`,
|
||||
);
|
||||
}
|
||||
116
src/lib/auth.ts
Normal file
116
src/lib/auth.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
||||
import { db } from "@/db";
|
||||
import { z } from "zod";
|
||||
import { baseAuthConfig } from "@/lib/auth-config";
|
||||
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
||||
|
||||
const hostedBaseUrlSchema = z
|
||||
.string()
|
||||
.url()
|
||||
.refine((value) => {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
url.protocol === "https:" ||
|
||||
(url.protocol === "http:" && url.hostname === "localhost")
|
||||
);
|
||||
}, "BETTER_AUTH_URL must use https or localhost");
|
||||
|
||||
function createAuth() {
|
||||
const baseUrl = getHostedBaseUrl();
|
||||
|
||||
const auth = betterAuth({
|
||||
baseURL: baseUrl,
|
||||
secret: getHostedSecret(),
|
||||
...baseAuthConfig,
|
||||
trustedOrigins: getTrustedOrigins(baseUrl),
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "sqlite",
|
||||
}),
|
||||
plugins: [...baseAuthConfig.plugins, tanstackStartCookies()],
|
||||
databaseHooks: {
|
||||
session: {
|
||||
create: {
|
||||
before: async (session) => {
|
||||
// Inject Better Auth's createOrganization here so the helper can
|
||||
// stay reusable without importing auth.ts and creating a cycle.
|
||||
const organizationId = await getOrCreateDefaultHostedOrganization(
|
||||
session.userId,
|
||||
(body) => auth.api.createOrganization({ body }),
|
||||
);
|
||||
|
||||
return {
|
||||
data: {
|
||||
...session,
|
||||
activeOrganizationId: organizationId,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
let authInstance: ReturnType<typeof createAuth> | null = null;
|
||||
|
||||
function getTrustedOrigins(baseUrl: string) {
|
||||
const trustedOrigins = [baseUrl];
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
trustedOrigins.push(
|
||||
"http://open-seo.localhost:1355",
|
||||
"http://*.open-seo.localhost:1355",
|
||||
);
|
||||
}
|
||||
|
||||
return trustedOrigins;
|
||||
}
|
||||
|
||||
function getHostedBaseUrl() {
|
||||
const baseUrl = env.BETTER_AUTH_URL?.trim();
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error("BETTER_AUTH_URL is required in hosted mode");
|
||||
}
|
||||
|
||||
return hostedBaseUrlSchema.parse(baseUrl);
|
||||
}
|
||||
|
||||
function getHostedSecret() {
|
||||
const secret = env.BETTER_AUTH_SECRET?.trim();
|
||||
|
||||
if (!secret) {
|
||||
throw new Error("BETTER_AUTH_SECRET is required in hosted mode");
|
||||
}
|
||||
|
||||
if (secret.length < 32) {
|
||||
throw new Error("BETTER_AUTH_SECRET must be at least 32 characters");
|
||||
}
|
||||
|
||||
return secret;
|
||||
}
|
||||
|
||||
export function hasHostedAuthConfig() {
|
||||
try {
|
||||
getHostedBaseUrl();
|
||||
getHostedSecret();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuth() {
|
||||
if (authInstance) {
|
||||
return authInstance;
|
||||
}
|
||||
|
||||
authInstance = createAuth();
|
||||
|
||||
return authInstance;
|
||||
}
|
||||
88
src/middleware/ensure-user/cloudflareAccess.ts
Normal file
88
src/middleware/ensure-user/cloudflareAccess.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { createRemoteJWKSet, jwtVerify } from "jose";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { resolveDelegatedContext } from "./delegated";
|
||||
import type { EnsuredUserContext } from "./types";
|
||||
|
||||
const jwksByTeamDomain = new Map<
|
||||
string,
|
||||
ReturnType<typeof createRemoteJWKSet>
|
||||
>();
|
||||
|
||||
function getJwks(teamDomain: string) {
|
||||
const existing = jwksByTeamDomain.get(teamDomain);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const jwks = createRemoteJWKSet(
|
||||
new URL(`${teamDomain}/cdn-cgi/access/certs`),
|
||||
);
|
||||
|
||||
jwksByTeamDomain.set(teamDomain, jwks);
|
||||
|
||||
return jwks;
|
||||
}
|
||||
|
||||
function getValidatedTeamDomain(teamDomain: string) {
|
||||
const normalizedTeamDomain = teamDomain.trim().replace(/\/+$/, "");
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalizedTeamDomain);
|
||||
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new Error("TEAM_DOMAIN must use https");
|
||||
}
|
||||
|
||||
return parsed.origin;
|
||||
} catch {
|
||||
throw new AppError(
|
||||
"AUTH_CONFIG_MISSING",
|
||||
"TEAM_DOMAIN must be a full https URL like https://your-team.cloudflareaccess.com",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveCloudflareAccessContext(
|
||||
headers: Headers,
|
||||
): Promise<EnsuredUserContext> {
|
||||
const teamDomain = env.TEAM_DOMAIN
|
||||
? getValidatedTeamDomain(env.TEAM_DOMAIN)
|
||||
: null;
|
||||
const policyAud = env.POLICY_AUD?.trim() || null;
|
||||
|
||||
if (!teamDomain || !policyAud) {
|
||||
throw new AppError(
|
||||
"AUTH_CONFIG_MISSING",
|
||||
"Missing Cloudflare Access configuration",
|
||||
);
|
||||
}
|
||||
|
||||
const token = headers.get("cf-access-jwt-assertion");
|
||||
|
||||
if (!token) {
|
||||
throw new AppError("UNAUTHENTICATED");
|
||||
}
|
||||
|
||||
try {
|
||||
const jwks = getJwks(teamDomain);
|
||||
const { payload } = await jwtVerify(token, jwks, {
|
||||
issuer: teamDomain,
|
||||
audience: policyAud,
|
||||
});
|
||||
const userId = typeof payload.sub === "string" ? payload.sub : null;
|
||||
const userEmail = typeof payload.email === "string" ? payload.email : null;
|
||||
|
||||
if (!userId || !userEmail) {
|
||||
throw new AppError("UNAUTHENTICATED");
|
||||
}
|
||||
|
||||
return resolveDelegatedContext(userId, userEmail);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError("UNAUTHENTICATED");
|
||||
}
|
||||
}
|
||||
55
src/middleware/ensure-user/delegated.ts
Normal file
55
src/middleware/ensure-user/delegated.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { db } from "@/db";
|
||||
import { delegatedUsers } from "@/db/schema";
|
||||
import { ensureDelegatedOrganizationForUser } from "@/server/auth/delegated-organization";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { EnsuredUserContext } from "./types";
|
||||
|
||||
const LOCAL_ADMIN_USER_ID = "local-admin";
|
||||
const LOCAL_ADMIN_EMAIL = "admin@localhost";
|
||||
|
||||
async function ensureUserRecord(userId: string, userEmail: string) {
|
||||
const existingUser = await db.query.delegatedUsers.findFirst({
|
||||
where: eq(delegatedUsers.id, userId),
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
await db.insert(delegatedUsers).values({
|
||||
id: userId,
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
if (existingUser.email !== userEmail) {
|
||||
await db
|
||||
.update(delegatedUsers)
|
||||
.set({ email: userEmail })
|
||||
.where(eq(delegatedUsers.id, userId));
|
||||
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
return existingUser.email;
|
||||
}
|
||||
|
||||
export async function resolveDelegatedContext(
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
): Promise<EnsuredUserContext> {
|
||||
const ensuredEmail = await ensureUserRecord(userId, userEmail);
|
||||
const organizationId = await ensureDelegatedOrganizationForUser(
|
||||
userId,
|
||||
ensuredEmail,
|
||||
);
|
||||
|
||||
return {
|
||||
userId,
|
||||
userEmail: ensuredEmail,
|
||||
organizationId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveLocalNoAuthContext(): Promise<EnsuredUserContext> {
|
||||
return resolveDelegatedContext(LOCAL_ADMIN_USER_ID, LOCAL_ADMIN_EMAIL);
|
||||
}
|
||||
65
src/middleware/ensure-user/hosted.ts
Normal file
65
src/middleware/ensure-user/hosted.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
||||
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import type { EnsuredUserContext } from "./types";
|
||||
|
||||
function getActiveOrganizationId(session: { session: unknown }) {
|
||||
if (!session.session || typeof session.session !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { activeOrganizationId } = session.session as {
|
||||
activeOrganizationId?: unknown;
|
||||
};
|
||||
|
||||
return typeof activeOrganizationId === "string" ? activeOrganizationId : null;
|
||||
}
|
||||
|
||||
async function requireHostedSession(headers: Headers) {
|
||||
if (!hasHostedAuthConfig()) {
|
||||
throw new AppError(
|
||||
"AUTH_CONFIG_MISSING",
|
||||
"Missing Better Auth hosted configuration",
|
||||
);
|
||||
}
|
||||
|
||||
const session = await getAuth().api.getSession({ headers });
|
||||
|
||||
if (!session?.user?.id || !session.user.email) {
|
||||
throw new AppError("UNAUTHENTICATED");
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function resolveHostedContext(
|
||||
headers: Headers,
|
||||
): Promise<EnsuredUserContext> {
|
||||
const session = await requireHostedSession(headers);
|
||||
const activeOrganizationId = getActiveOrganizationId(session);
|
||||
|
||||
if (activeOrganizationId) {
|
||||
return {
|
||||
userId: session.user.id,
|
||||
userEmail: session.user.email,
|
||||
organizationId: activeOrganizationId,
|
||||
};
|
||||
}
|
||||
|
||||
const authApi = getAuth().api;
|
||||
const organizationId = await getOrCreateDefaultHostedOrganization(
|
||||
session.user.id,
|
||||
(body) => authApi.createOrganization({ body }),
|
||||
);
|
||||
|
||||
await authApi.setActiveOrganization({
|
||||
headers,
|
||||
body: { organizationId },
|
||||
});
|
||||
|
||||
return {
|
||||
userId: session.user.id,
|
||||
userEmail: session.user.email,
|
||||
organizationId,
|
||||
};
|
||||
}
|
||||
12
src/middleware/ensure-user/types.ts
Normal file
12
src/middleware/ensure-user/types.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
||||
|
||||
export type EnsuredProject = NonNullable<
|
||||
Awaited<ReturnType<typeof ProjectRepository.getProjectForOrganization>>
|
||||
>;
|
||||
|
||||
export type EnsuredUserContext = {
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
organizationId: string;
|
||||
project?: EnsuredProject;
|
||||
};
|
||||
@ -1,167 +1,65 @@
|
||||
import { createMiddleware } from "@tanstack/react-start";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { createRemoteJWKSet, jwtVerify } from "jose";
|
||||
import { getRequest } from "@tanstack/react-start/server";
|
||||
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
|
||||
import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
|
||||
import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
|
||||
import { resolveHostedContext } from "@/middleware/ensure-user/hosted";
|
||||
import type {
|
||||
EnsuredProject,
|
||||
EnsuredUserContext,
|
||||
} from "@/middleware/ensure-user/types";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
||||
import { env } from "cloudflare:workers";
|
||||
|
||||
type AuthMode = "cloudflare_access" | "local_noauth";
|
||||
|
||||
const LOCAL_ADMIN_USER_ID = "local-admin";
|
||||
const LOCAL_ADMIN_EMAIL = "admin@localhost";
|
||||
|
||||
const jwksByTeamDomain = new Map<
|
||||
string,
|
||||
ReturnType<typeof createRemoteJWKSet>
|
||||
>();
|
||||
|
||||
function getAuthMode(): AuthMode {
|
||||
const value = env.AUTH_MODE;
|
||||
|
||||
if (value === "local_noauth" || value === "cloudflare_access") {
|
||||
return value;
|
||||
function extractProjectId(data: unknown) {
|
||||
if (!data || typeof data !== "object" || !("projectId" in data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value === "hosted") {
|
||||
throw new AppError(
|
||||
"INTERNAL_ERROR",
|
||||
"AUTH_MODE=hosted is not implemented yet",
|
||||
);
|
||||
}
|
||||
|
||||
return "cloudflare_access";
|
||||
}
|
||||
|
||||
function getJwks(teamDomain: string) {
|
||||
const existing = jwksByTeamDomain.get(teamDomain);
|
||||
if (existing) return existing;
|
||||
|
||||
const jwks = createRemoteJWKSet(
|
||||
new URL(`${teamDomain}/cdn-cgi/access/certs`),
|
||||
);
|
||||
jwksByTeamDomain.set(teamDomain, jwks);
|
||||
return jwks;
|
||||
}
|
||||
|
||||
function normalizeTeamDomain(teamDomain: string) {
|
||||
return teamDomain.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function getValidatedTeamDomain(teamDomain: string) {
|
||||
const normalized = normalizeTeamDomain(teamDomain);
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new Error("TEAM_DOMAIN must use https");
|
||||
}
|
||||
|
||||
return parsed.origin;
|
||||
} catch {
|
||||
throw new AppError(
|
||||
"AUTH_CONFIG_MISSING",
|
||||
"TEAM_DOMAIN must be a full https URL like https://your-team.cloudflareaccess.com",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureUserRecord(userId: string, userEmail: string) {
|
||||
const existingUser = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
if (existingUser.email !== userEmail) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ email: userEmail })
|
||||
.where(eq(users.id, userId));
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
return existingUser.email;
|
||||
const projectId = (data as { projectId?: unknown }).projectId;
|
||||
return typeof projectId === "string" && projectId.length > 0
|
||||
? projectId
|
||||
: null;
|
||||
}
|
||||
|
||||
export const ensureUserMiddleware = createMiddleware({
|
||||
type: "function",
|
||||
}).server(async ({ next }) => {
|
||||
const authMode = getAuthMode();
|
||||
}).server(async ({ next, data }) => {
|
||||
const authMode = getAuthMode(env.AUTH_MODE);
|
||||
const headers = getRequest().headers;
|
||||
let context: EnsuredUserContext;
|
||||
|
||||
if (authMode === "local_noauth") {
|
||||
const userEmail = await ensureUserRecord(
|
||||
LOCAL_ADMIN_USER_ID,
|
||||
LOCAL_ADMIN_EMAIL,
|
||||
context = await resolveLocalNoAuthContext();
|
||||
} else if (isHostedAuthMode(authMode)) {
|
||||
context = await resolveHostedContext(headers);
|
||||
} else {
|
||||
context = await resolveCloudflareAccessContext(headers);
|
||||
}
|
||||
|
||||
const projectId = extractProjectId(data);
|
||||
|
||||
let project: EnsuredProject | undefined;
|
||||
|
||||
if (projectId) {
|
||||
// ADR 0001 intentionally keeps project authorization here so every
|
||||
// project-scoped server function gets the same request-scoped org+project
|
||||
// check before handlers run. Function-level middleware narrows the type.
|
||||
project = await ProjectRepository.getProjectForOrganization(
|
||||
projectId,
|
||||
context.organizationId,
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,8 +9,11 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as AuthRouteImport } from './routes/_auth'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as HelpDataforseoApiKeyRouteImport } from './routes/help/dataforseo-api-key'
|
||||
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
|
||||
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
|
||||
import { Route as PProjectIdRouteRouteImport } from './routes/p/$projectId/route'
|
||||
import { Route as PProjectIdIndexRouteImport } from './routes/p/$projectId/index'
|
||||
import { Route as PProjectIdSavedRouteImport } from './routes/p/$projectId/saved'
|
||||
@ -19,10 +22,14 @@ import { Route as PProjectIdDomainRouteImport } from './routes/p/$projectId/doma
|
||||
import { Route as PProjectIdBacklinksRouteImport } from './routes/p/$projectId/backlinks'
|
||||
import { Route as PProjectIdAuditRouteImport } from './routes/p/$projectId/audit'
|
||||
import { Route as PProjectIdAiRouteImport } from './routes/p/$projectId/ai'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||
import { Route as PProjectIdAuditIndexRouteImport } from './routes/p/$projectId/audit/index'
|
||||
import { Route as PProjectIdPsiIssuesResultIdRouteImport } from './routes/p/$projectId/psi/issues/$resultId'
|
||||
import { Route as PProjectIdAuditIssuesResultIdRouteImport } from './routes/p/$projectId/audit/issues/$resultId'
|
||||
|
||||
const AuthRoute = AuthRouteImport.update({
|
||||
id: '/_auth',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@ -33,6 +40,16 @@ const HelpDataforseoApiKeyRoute = HelpDataforseoApiKeyRouteImport.update({
|
||||
path: '/help/dataforseo-api-key',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthSignUpRoute = AuthSignUpRouteImport.update({
|
||||
id: '/sign-up',
|
||||
path: '/sign-up',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthSignInRoute = AuthSignInRouteImport.update({
|
||||
id: '/sign-in',
|
||||
path: '/sign-in',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const PProjectIdRouteRoute = PProjectIdRouteRouteImport.update({
|
||||
id: '/p/$projectId',
|
||||
path: '/p/$projectId',
|
||||
@ -73,17 +90,16 @@ const PProjectIdAiRoute = PProjectIdAiRouteImport.update({
|
||||
path: '/ai',
|
||||
getParentRoute: () => PProjectIdRouteRoute,
|
||||
} as any)
|
||||
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||
id: '/api/auth/$',
|
||||
path: '/api/auth/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PProjectIdAuditIndexRoute = PProjectIdAuditIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => PProjectIdAuditRoute,
|
||||
} as any)
|
||||
const PProjectIdPsiIssuesResultIdRoute =
|
||||
PProjectIdPsiIssuesResultIdRouteImport.update({
|
||||
id: '/psi/issues/$resultId',
|
||||
path: '/psi/issues/$resultId',
|
||||
getParentRoute: () => PProjectIdRouteRoute,
|
||||
} as any)
|
||||
const PProjectIdAuditIssuesResultIdRoute =
|
||||
PProjectIdAuditIssuesResultIdRouteImport.update({
|
||||
id: '/issues/$resultId',
|
||||
@ -94,7 +110,10 @@ const PProjectIdAuditIssuesResultIdRoute =
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/p/$projectId': typeof PProjectIdRouteRouteWithChildren
|
||||
'/sign-in': typeof AuthSignInRoute
|
||||
'/sign-up': typeof AuthSignUpRoute
|
||||
'/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/p/$projectId/ai': typeof PProjectIdAiRoute
|
||||
'/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren
|
||||
'/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute
|
||||
@ -104,11 +123,13 @@ export interface FileRoutesByFullPath {
|
||||
'/p/$projectId/': typeof PProjectIdIndexRoute
|
||||
'/p/$projectId/audit/': typeof PProjectIdAuditIndexRoute
|
||||
'/p/$projectId/audit/issues/$resultId': typeof PProjectIdAuditIssuesResultIdRoute
|
||||
'/p/$projectId/psi/issues/$resultId': typeof PProjectIdPsiIssuesResultIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/sign-in': typeof AuthSignInRoute
|
||||
'/sign-up': typeof AuthSignUpRoute
|
||||
'/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/p/$projectId/ai': typeof PProjectIdAiRoute
|
||||
'/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute
|
||||
'/p/$projectId/domain': typeof PProjectIdDomainRoute
|
||||
@ -117,13 +138,16 @@ export interface FileRoutesByTo {
|
||||
'/p/$projectId': typeof PProjectIdIndexRoute
|
||||
'/p/$projectId/audit': typeof PProjectIdAuditIndexRoute
|
||||
'/p/$projectId/audit/issues/$resultId': typeof PProjectIdAuditIssuesResultIdRoute
|
||||
'/p/$projectId/psi/issues/$resultId': typeof PProjectIdPsiIssuesResultIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/_auth': typeof AuthRouteWithChildren
|
||||
'/p/$projectId': typeof PProjectIdRouteRouteWithChildren
|
||||
'/_auth/sign-in': typeof AuthSignInRoute
|
||||
'/_auth/sign-up': typeof AuthSignUpRoute
|
||||
'/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/p/$projectId/ai': typeof PProjectIdAiRoute
|
||||
'/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren
|
||||
'/p/$projectId/backlinks': typeof PProjectIdBacklinksRoute
|
||||
@ -133,14 +157,16 @@ export interface FileRoutesById {
|
||||
'/p/$projectId/': typeof PProjectIdIndexRoute
|
||||
'/p/$projectId/audit/': typeof PProjectIdAuditIndexRoute
|
||||
'/p/$projectId/audit/issues/$resultId': typeof PProjectIdAuditIssuesResultIdRoute
|
||||
'/p/$projectId/psi/issues/$resultId': typeof PProjectIdPsiIssuesResultIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/p/$projectId'
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/p/$projectId/ai'
|
||||
| '/p/$projectId/audit'
|
||||
| '/p/$projectId/backlinks'
|
||||
@ -150,11 +176,13 @@ export interface FileRouteTypes {
|
||||
| '/p/$projectId/'
|
||||
| '/p/$projectId/audit/'
|
||||
| '/p/$projectId/audit/issues/$resultId'
|
||||
| '/p/$projectId/psi/issues/$resultId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/p/$projectId/ai'
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/domain'
|
||||
@ -163,12 +191,15 @@ export interface FileRouteTypes {
|
||||
| '/p/$projectId'
|
||||
| '/p/$projectId/audit'
|
||||
| '/p/$projectId/audit/issues/$resultId'
|
||||
| '/p/$projectId/psi/issues/$resultId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/_auth'
|
||||
| '/p/$projectId'
|
||||
| '/_auth/sign-in'
|
||||
| '/_auth/sign-up'
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/p/$projectId/ai'
|
||||
| '/p/$projectId/audit'
|
||||
| '/p/$projectId/backlinks'
|
||||
@ -178,17 +209,25 @@ export interface FileRouteTypes {
|
||||
| '/p/$projectId/'
|
||||
| '/p/$projectId/audit/'
|
||||
| '/p/$projectId/audit/issues/$resultId'
|
||||
| '/p/$projectId/psi/issues/$resultId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AuthRoute: typeof AuthRouteWithChildren
|
||||
PProjectIdRouteRoute: typeof PProjectIdRouteRouteWithChildren
|
||||
HelpDataforseoApiKeyRoute: typeof HelpDataforseoApiKeyRoute
|
||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/_auth': {
|
||||
id: '/_auth'
|
||||
path: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AuthRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@ -203,6 +242,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof HelpDataforseoApiKeyRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth/sign-up': {
|
||||
id: '/_auth/sign-up'
|
||||
path: '/sign-up'
|
||||
fullPath: '/sign-up'
|
||||
preLoaderRoute: typeof AuthSignUpRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/sign-in': {
|
||||
id: '/_auth/sign-in'
|
||||
path: '/sign-in'
|
||||
fullPath: '/sign-in'
|
||||
preLoaderRoute: typeof AuthSignInRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/p/$projectId': {
|
||||
id: '/p/$projectId'
|
||||
path: '/p/$projectId'
|
||||
@ -259,6 +312,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof PProjectIdAiRouteImport
|
||||
parentRoute: typeof PProjectIdRouteRoute
|
||||
}
|
||||
'/api/auth/$': {
|
||||
id: '/api/auth/$'
|
||||
path: '/api/auth/$'
|
||||
fullPath: '/api/auth/$'
|
||||
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/p/$projectId/audit/': {
|
||||
id: '/p/$projectId/audit/'
|
||||
path: '/'
|
||||
@ -266,13 +326,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof PProjectIdAuditIndexRouteImport
|
||||
parentRoute: typeof PProjectIdAuditRoute
|
||||
}
|
||||
'/p/$projectId/psi/issues/$resultId': {
|
||||
id: '/p/$projectId/psi/issues/$resultId'
|
||||
path: '/psi/issues/$resultId'
|
||||
fullPath: '/p/$projectId/psi/issues/$resultId'
|
||||
preLoaderRoute: typeof PProjectIdPsiIssuesResultIdRouteImport
|
||||
parentRoute: typeof PProjectIdRouteRoute
|
||||
}
|
||||
'/p/$projectId/audit/issues/$resultId': {
|
||||
id: '/p/$projectId/audit/issues/$resultId'
|
||||
path: '/issues/$resultId'
|
||||
@ -283,6 +336,18 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthRouteChildren {
|
||||
AuthSignInRoute: typeof AuthSignInRoute
|
||||
AuthSignUpRoute: typeof AuthSignUpRoute
|
||||
}
|
||||
|
||||
const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthSignInRoute: AuthSignInRoute,
|
||||
AuthSignUpRoute: AuthSignUpRoute,
|
||||
}
|
||||
|
||||
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
||||
|
||||
interface PProjectIdAuditRouteChildren {
|
||||
PProjectIdAuditIndexRoute: typeof PProjectIdAuditIndexRoute
|
||||
PProjectIdAuditIssuesResultIdRoute: typeof PProjectIdAuditIssuesResultIdRoute
|
||||
@ -305,7 +370,6 @@ interface PProjectIdRouteRouteChildren {
|
||||
PProjectIdKeywordsRoute: typeof PProjectIdKeywordsRoute
|
||||
PProjectIdSavedRoute: typeof PProjectIdSavedRoute
|
||||
PProjectIdIndexRoute: typeof PProjectIdIndexRoute
|
||||
PProjectIdPsiIssuesResultIdRoute: typeof PProjectIdPsiIssuesResultIdRoute
|
||||
}
|
||||
|
||||
const PProjectIdRouteRouteChildren: PProjectIdRouteRouteChildren = {
|
||||
@ -316,7 +380,6 @@ const PProjectIdRouteRouteChildren: PProjectIdRouteRouteChildren = {
|
||||
PProjectIdKeywordsRoute: PProjectIdKeywordsRoute,
|
||||
PProjectIdSavedRoute: PProjectIdSavedRoute,
|
||||
PProjectIdIndexRoute: PProjectIdIndexRoute,
|
||||
PProjectIdPsiIssuesResultIdRoute: PProjectIdPsiIssuesResultIdRoute,
|
||||
}
|
||||
|
||||
const PProjectIdRouteRouteWithChildren = PProjectIdRouteRoute._addFileChildren(
|
||||
@ -325,18 +388,21 @@ const PProjectIdRouteRouteWithChildren = PProjectIdRouteRoute._addFileChildren(
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AuthRoute: AuthRouteWithChildren,
|
||||
PProjectIdRouteRoute: PProjectIdRouteRouteWithChildren,
|
||||
HelpDataforseoApiKeyRoute: HelpDataforseoApiKeyRoute,
|
||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
import type { startInstance } from './start.ts'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
config: Awaited<ReturnType<typeof startInstance.getOptions>>
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,9 +2,11 @@
|
||||
import {
|
||||
ClientOnly,
|
||||
HeadContent,
|
||||
Outlet,
|
||||
Scripts,
|
||||
createRootRoute,
|
||||
useLocation,
|
||||
useMatches,
|
||||
} from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
||||
import { TanStackDevtools } from "@tanstack/react-devtools";
|
||||
@ -75,6 +77,18 @@ export const Route = createRootRoute({
|
||||
});
|
||||
|
||||
function AppLayout() {
|
||||
const isAuthLayout = useMatches({
|
||||
select: (matches) => matches.some((match) => match.routeId === "/_auth"),
|
||||
});
|
||||
|
||||
if (isAuthLayout) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
return <AppShellLayout />;
|
||||
}
|
||||
|
||||
function AppShellLayout() {
|
||||
const location = useLocation();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const setupModalRef = React.useRef<HTMLDivElement | null>(null);
|
||||
@ -85,7 +99,6 @@ function AppLayout() {
|
||||
const [showMissingSeoApiKeyModal, setShowMissingSeoApiKeyModal] =
|
||||
useState(false);
|
||||
|
||||
// Extract projectId from the current path
|
||||
const projectIdMatch = location.pathname.match(/^\/p\/([^/]+)/);
|
||||
const projectId = projectIdMatch?.[1] ?? null;
|
||||
|
||||
@ -152,7 +165,6 @@ function AppLayout() {
|
||||
/>
|
||||
|
||||
<SeoApiStatusBanners
|
||||
helpPath={DATAFORSEO_HELP_PATH}
|
||||
shouldShowSeoApiWarning={shouldShowSeoApiWarning}
|
||||
seoApiKeyStatusError={seoApiKeyStatusError}
|
||||
/>
|
||||
@ -166,7 +178,6 @@ function AppLayout() {
|
||||
|
||||
<MissingSeoSetupModal
|
||||
ref={setupModalRef}
|
||||
helpPath={DATAFORSEO_HELP_PATH}
|
||||
isOpen={shouldShowMissingSeoApiKeyModal}
|
||||
onClose={() => setShowMissingSeoApiKeyModal(false)}
|
||||
/>
|
||||
|
||||
188
src/routes/_auth.sign-in.tsx
Normal file
188
src/routes/_auth.sign-in.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
AuthPageCard,
|
||||
authRedirectSearchSchema,
|
||||
getAuthLinkSearch,
|
||||
useAuthPageState,
|
||||
} from "@/client/features/auth/AuthPage";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { z } from "zod";
|
||||
|
||||
const signInSchema = z.object({
|
||||
email: z.string().trim().email("Enter a valid email address."),
|
||||
password: z.string().min(1, "Enter your password."),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/_auth/sign-in")({
|
||||
validateSearch: authRedirectSearchSchema,
|
||||
component: SignInPage,
|
||||
});
|
||||
|
||||
function getHelperText(isHostedMode: boolean) {
|
||||
if (!isHostedMode) {
|
||||
return "Sign-in is only available when AUTH_MODE=hosted.";
|
||||
}
|
||||
|
||||
return "Sign in to your OpenSEO account.";
|
||||
}
|
||||
|
||||
function getSignInValidationErrors(value: { email: string; password: string }) {
|
||||
const parsed = signInSchema.safeParse(value);
|
||||
|
||||
if (parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
form: parsed.error.issues[0]?.message || "Unable to sign in.",
|
||||
fields: parsed.error.issues.reduce<Record<string, string>>(
|
||||
(errors, issue) => {
|
||||
const path = issue.path.join(".");
|
||||
|
||||
if (path && !errors[path]) {
|
||||
errors[path] = issue.message;
|
||||
}
|
||||
|
||||
return errors;
|
||||
},
|
||||
{},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function SignInPage() {
|
||||
const search = Route.useSearch();
|
||||
const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState(
|
||||
search.redirect,
|
||||
);
|
||||
const helperText = getHelperText(isHostedMode);
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
validators: {
|
||||
onSubmit: ({ value }) => getSignInValidationErrors(value),
|
||||
},
|
||||
onSubmit: async ({ formApi, value }) => {
|
||||
try {
|
||||
const result = await authClient.signIn.email({
|
||||
email: value.email.trim(),
|
||||
password: value.password,
|
||||
callbackURL: redirectTo,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
formApi.setErrorMap({
|
||||
onSubmit: result.error.message || "Unable to sign in.",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
formApi.setErrorMap({
|
||||
onSubmit: "Unable to sign in right now. Please try again.",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AuthPageCard
|
||||
title="Sign in"
|
||||
helperText={helperText}
|
||||
footer={
|
||||
isHostedMode ? (
|
||||
<p className="text-sm text-base-content/70">
|
||||
Need an account?{" "}
|
||||
<Link
|
||||
to="/sign-up"
|
||||
search={getAuthLinkSearch(redirectTo)}
|
||||
className="link link-primary"
|
||||
>
|
||||
Create account
|
||||
</Link>
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<label className="form-control block">
|
||||
<span className="label-text text-sm font-medium">Email</span>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<>
|
||||
<input
|
||||
type="email"
|
||||
className="input input-bordered w-full mt-1"
|
||||
placeholder="you@example.com"
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
autoComplete="email"
|
||||
disabled={!isHostedMode || isSessionPending}
|
||||
required
|
||||
/>
|
||||
{field.state.meta.errors[0] ? (
|
||||
<p className="mt-1 text-sm text-error">
|
||||
{field.state.meta.errors[0]}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</form.Field>
|
||||
</label>
|
||||
|
||||
<label className="form-control block">
|
||||
<span className="label-text text-sm font-medium">Password</span>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<>
|
||||
<input
|
||||
type="password"
|
||||
className="input input-bordered w-full mt-1"
|
||||
placeholder="Enter your password"
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
disabled={!isHostedMode || isSessionPending}
|
||||
required
|
||||
/>
|
||||
{field.state.meta.errors[0] ? (
|
||||
<p className="mt-1 text-sm text-error">
|
||||
{field.state.meta.errors[0]}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</form.Field>
|
||||
</label>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
submitError: state.errorMap.onSubmit,
|
||||
isSubmitting: state.isSubmitting,
|
||||
})}
|
||||
>
|
||||
{({ submitError, isSubmitting }) => (
|
||||
<>
|
||||
{submitError ? (
|
||||
<p className="text-sm text-error">{submitError}</p>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-primary w-full"
|
||||
disabled={!isHostedMode || isSessionPending || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
</AuthPageCard>
|
||||
);
|
||||
}
|
||||
285
src/routes/_auth.sign-up.tsx
Normal file
285
src/routes/_auth.sign-up.tsx
Normal file
@ -0,0 +1,285 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
AuthPageCard,
|
||||
authRedirectSearchSchema,
|
||||
getAuthLinkSearch,
|
||||
getFieldError,
|
||||
useAuthPageState,
|
||||
} from "@/client/features/auth/AuthPage";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import {
|
||||
HOSTED_PASSWORD_MAX_LENGTH,
|
||||
HOSTED_PASSWORD_MIN_LENGTH,
|
||||
} from "@/lib/auth-options";
|
||||
import { z } from "zod";
|
||||
|
||||
const signUpSchema = z
|
||||
.object({
|
||||
name: z.string().trim().optional(),
|
||||
email: z.string().trim().email("Enter a valid email address."),
|
||||
password: z
|
||||
.string()
|
||||
.min(
|
||||
HOSTED_PASSWORD_MIN_LENGTH,
|
||||
`Password must be at least ${HOSTED_PASSWORD_MIN_LENGTH} characters.`,
|
||||
)
|
||||
.max(
|
||||
HOSTED_PASSWORD_MAX_LENGTH,
|
||||
`Password must be at most ${HOSTED_PASSWORD_MAX_LENGTH} characters.`,
|
||||
),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((value) => value.password === value.confirmPassword, {
|
||||
message: "Passwords do not match.",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
type SignUpValues = {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/_auth/sign-up")({
|
||||
validateSearch: authRedirectSearchSchema,
|
||||
component: SignUpPage,
|
||||
});
|
||||
|
||||
function getHelperText(isHostedMode: boolean) {
|
||||
return isHostedMode
|
||||
? "Create your OpenSEO account."
|
||||
: "Account creation is only available when AUTH_MODE=hosted.";
|
||||
}
|
||||
|
||||
function getSignUpValidationErrors(value: SignUpValues) {
|
||||
const parsed = signUpSchema.safeParse(value);
|
||||
|
||||
if (parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
form:
|
||||
parsed.error.issues[0]?.message || "Please check your account details.",
|
||||
fields: parsed.error.issues.reduce<Record<string, string>>(
|
||||
(errors, issue) => {
|
||||
const path = issue.path.join(".");
|
||||
|
||||
if (path && !errors[path]) {
|
||||
errors[path] = issue.message;
|
||||
}
|
||||
|
||||
return errors;
|
||||
},
|
||||
{},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function SignUpPage() {
|
||||
const search = Route.useSearch();
|
||||
const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState(
|
||||
search.redirect,
|
||||
);
|
||||
const helperText = getHelperText(isHostedMode);
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
validators: {
|
||||
onSubmit: ({ value }) => getSignUpValidationErrors(value),
|
||||
},
|
||||
onSubmit: async ({ formApi, value }) => {
|
||||
try {
|
||||
const email = value.email.trim();
|
||||
const resolvedName =
|
||||
value.name.trim() || email.split("@")[0] || "OpenSEO User";
|
||||
const result = await authClient.signUp.email({
|
||||
name: resolvedName,
|
||||
email,
|
||||
password: value.password,
|
||||
callbackURL: redirectTo,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
formApi.setErrorMap({
|
||||
onSubmit: result.error.message || "Unable to create account.",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
formApi.setErrorMap({
|
||||
onSubmit: "Unable to create account right now. Please try again.",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AuthPageCard
|
||||
title="Create account"
|
||||
helperText={helperText}
|
||||
footer={
|
||||
isHostedMode ? (
|
||||
<p className="text-sm text-base-content/70">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
to="/sign-in"
|
||||
search={getAuthLinkSearch(redirectTo)}
|
||||
className="link link-primary"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<label className="form-control block">
|
||||
<span className="label-text text-sm font-medium">Name</span>
|
||||
<form.Field name="name">
|
||||
{(field) => {
|
||||
const error = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered w-full mt-1"
|
||||
placeholder="Jane Doe (optional)"
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
autoComplete="name"
|
||||
disabled={!isHostedMode || isSessionPending}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="mt-1 text-sm text-error">{error}</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
</label>
|
||||
|
||||
<label className="form-control block">
|
||||
<span className="label-text text-sm font-medium">Email</span>
|
||||
<form.Field name="email">
|
||||
{(field) => {
|
||||
const error = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="email"
|
||||
className="input input-bordered w-full mt-1"
|
||||
placeholder="you@example.com"
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
autoComplete="email"
|
||||
disabled={!isHostedMode || isSessionPending}
|
||||
required
|
||||
/>
|
||||
{error ? (
|
||||
<p className="mt-1 text-sm text-error">{error}</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
</label>
|
||||
|
||||
<label className="form-control block">
|
||||
<span className="label-text text-sm font-medium">Password</span>
|
||||
<form.Field name="password">
|
||||
{(field) => {
|
||||
const error = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="password"
|
||||
className="input input-bordered w-full mt-1"
|
||||
placeholder="Create a password"
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={!isHostedMode || isSessionPending}
|
||||
required
|
||||
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
||||
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="mt-1 text-sm text-error">{error}</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
</label>
|
||||
|
||||
<label className="form-control block">
|
||||
<span className="label-text text-sm font-medium">
|
||||
Confirm password
|
||||
</span>
|
||||
<form.Field name="confirmPassword">
|
||||
{(field) => {
|
||||
const error = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="password"
|
||||
className="input input-bordered w-full mt-1"
|
||||
placeholder="Confirm your password"
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={!isHostedMode || isSessionPending}
|
||||
required
|
||||
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
||||
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="mt-1 text-sm text-error">{error}</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
</label>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
submitError: state.errorMap.onSubmit,
|
||||
isSubmitting: state.isSubmitting,
|
||||
})}
|
||||
>
|
||||
{({ submitError, isSubmitting }) => (
|
||||
<>
|
||||
{submitError ? (
|
||||
<p className="text-sm text-error">{submitError}</p>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-primary w-full"
|
||||
disabled={!isHostedMode || isSessionPending || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Creating account..." : "Create account"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
</AuthPageCard>
|
||||
);
|
||||
}
|
||||
15
src/routes/_auth.tsx
Normal file
15
src/routes/_auth.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/_auth")({
|
||||
component: AuthLayout,
|
||||
});
|
||||
|
||||
function AuthLayout() {
|
||||
return (
|
||||
<div className="min-h-[100dvh] bg-base-200">
|
||||
<div className="min-h-[100dvh] flex items-center justify-center p-4">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/routes/api/auth/$.ts
Normal file
34
src/routes/api/auth/$.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||
|
||||
function handleAuthRequest(request: Request) {
|
||||
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
||||
return new Response("Not found", {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasHostedAuthConfig()) {
|
||||
return new Response("Missing Better Auth hosted configuration", {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
||||
const auth = getAuth();
|
||||
return auth.handler(request);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/auth/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
return handleAuthRequest(request);
|
||||
},
|
||||
POST: async ({ request }: { request: Request }) => {
|
||||
return handleAuthRequest(request);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -1,12 +1,13 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { getOrCreateDefaultProject } from "@/serverFunctions/keywords";
|
||||
import { getOrCreateDefaultProject } from "@/serverFunctions/projects";
|
||||
import {
|
||||
getErrorCode,
|
||||
getStandardErrorMessage,
|
||||
} from "@/client/lib/error-messages";
|
||||
import { AuthConfigErrorCard } from "@/client/components/AuthConfigErrorCard";
|
||||
import { UnauthenticatedErrorCard } from "@/client/components/UnauthenticatedErrorCard";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: IndexRedirect,
|
||||
@ -48,6 +49,19 @@ function IndexRedirect() {
|
||||
);
|
||||
}
|
||||
|
||||
if (errorCode === "UNAUTHENTICATED") {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<UnauthenticatedErrorCard
|
||||
message="Please sign in to access your OpenSEO workspace."
|
||||
onRetry={() => {
|
||||
mutate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="flex flex-col items-center gap-3 max-w-xl">
|
||||
|
||||
@ -75,8 +75,8 @@ function AuditDetail({
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["audit-status", auditId],
|
||||
queryFn: () => getAuditStatus({ data: { auditId } }),
|
||||
queryKey: ["audit-status", projectId, auditId],
|
||||
queryFn: () => getAuditStatus({ data: { projectId, auditId } }),
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
return data?.status === "running" ? 3000 : false;
|
||||
@ -88,8 +88,8 @@ function AuditDetail({
|
||||
const isRunning = statusQuery.data?.status === "running";
|
||||
|
||||
const resultsQuery = useQuery({
|
||||
queryKey: ["audit-results", auditId],
|
||||
queryFn: () => getAuditResults({ data: { auditId } }),
|
||||
queryKey: ["audit-results", projectId, auditId],
|
||||
queryFn: () => getAuditResults({ data: { projectId, auditId } }),
|
||||
enabled: isComplete,
|
||||
});
|
||||
|
||||
@ -101,6 +101,22 @@ function AuditDetail({
|
||||
);
|
||||
}
|
||||
|
||||
if (statusQuery.isError) {
|
||||
return (
|
||||
<div className="px-4 py-6 md:px-6">
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<div className="alert alert-error">
|
||||
<AlertCircle className="size-5" />
|
||||
<span>We could not load this audit. It may have been deleted.</span>
|
||||
</div>
|
||||
<button className="btn btn-ghost btn-sm" onClick={onBack}>
|
||||
← Back to audits
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const status = statusQuery.data;
|
||||
const showSupportCta =
|
||||
isFailed || (isComplete && status && status.pagesCrawled <= 1);
|
||||
@ -127,7 +143,11 @@ function AuditDetail({
|
||||
</div>
|
||||
|
||||
{isRunning && status && (
|
||||
<ProgressCard auditId={auditId} status={status} />
|
||||
<ProgressCard
|
||||
projectId={projectId}
|
||||
auditId={auditId}
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSupportCta && (
|
||||
@ -170,9 +190,11 @@ function AuditDetail({
|
||||
}
|
||||
|
||||
function ProgressCard({
|
||||
projectId,
|
||||
auditId,
|
||||
status,
|
||||
}: {
|
||||
projectId: string;
|
||||
auditId: string;
|
||||
status: {
|
||||
pagesCrawled: number;
|
||||
@ -204,8 +226,8 @@ function ProgressCard({
|
||||
const progress = isPsiPhase ? psiProgress : crawlProgress;
|
||||
|
||||
const crawlProgressQuery = useQuery({
|
||||
queryKey: ["audit-crawl-progress", auditId],
|
||||
queryFn: () => getCrawlProgress({ data: { auditId } }),
|
||||
queryKey: ["audit-crawl-progress", projectId, auditId],
|
||||
queryFn: () => getCrawlProgress({ data: { projectId, auditId } }),
|
||||
refetchInterval: 1500,
|
||||
});
|
||||
|
||||
|
||||
@ -9,14 +9,13 @@ export const Route = createFileRoute("/p/$projectId/audit/issues/$resultId")({
|
||||
|
||||
function AuditIssuesPage() {
|
||||
const { projectId, resultId } = Route.useParams();
|
||||
const { source, category } = Route.useSearch();
|
||||
const { category } = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
|
||||
return (
|
||||
<PsiIssuesScreen
|
||||
projectId={projectId}
|
||||
resultId={resultId}
|
||||
source={source}
|
||||
category={category}
|
||||
backLabel="Site Audit"
|
||||
onBack={() =>
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { PsiIssuesScreen } from "@/client/features/psi/issues/PsiIssuesScreen";
|
||||
import { psiIssuesSearchSchema } from "@/types/schemas/psi";
|
||||
|
||||
export const Route = createFileRoute("/p/$projectId/psi/issues/$resultId")({
|
||||
validateSearch: psiIssuesSearchSchema,
|
||||
component: PsiIssuesPage,
|
||||
});
|
||||
|
||||
function PsiIssuesPage() {
|
||||
const { projectId, resultId } = Route.useParams();
|
||||
const { source, category } = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
|
||||
return (
|
||||
<PsiIssuesScreen
|
||||
projectId={projectId}
|
||||
resultId={resultId}
|
||||
source={source}
|
||||
category={category}
|
||||
backLabel={source === "site" ? "Site Audit" : "PSI"}
|
||||
onBack={() =>
|
||||
void navigate({
|
||||
to: source === "site" ? "/p/$projectId/audit" : "..",
|
||||
params: { projectId },
|
||||
})
|
||||
}
|
||||
onCategoryChange={(next) =>
|
||||
void navigate({
|
||||
search: (prev) => ({ ...prev, category: next }),
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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(() => {
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
113
src/server/auth/default-hosted-organization.ts
Normal file
113
src/server/auth/default-hosted-organization.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { member, user as authUser } from "@/db/better-auth-schema";
|
||||
|
||||
type HostedUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
type HostedOrganizationCreateInput = {
|
||||
name: string;
|
||||
slug: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
type HostedOrganizationCreator = (
|
||||
input: HostedOrganizationCreateInput,
|
||||
) => Promise<{ id: string }>;
|
||||
|
||||
function slugify(value: string) {
|
||||
const slug = value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48);
|
||||
|
||||
return slug || "workspace";
|
||||
}
|
||||
|
||||
function toHex(value: string) {
|
||||
return Array.from(new TextEncoder().encode(value), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
}
|
||||
|
||||
function getDefaultHostedOrganizationName(user: HostedUser) {
|
||||
const name = user.name?.trim() || user.email.split("@")[0] || "OpenSEO";
|
||||
return `${name}'s workspace`;
|
||||
}
|
||||
|
||||
function getDefaultHostedOrganizationSlug(user: HostedUser) {
|
||||
const slugSource =
|
||||
user.name?.trim() || user.email.split("@")[0] || "workspace";
|
||||
const suffix = toHex(user.id).slice(0, 12);
|
||||
return `${slugify(slugSource)}-${suffix}`;
|
||||
}
|
||||
|
||||
async function findFirstOrganizationIdForUser(userId: string) {
|
||||
const [existingMembership] = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
.orderBy(asc(member.createdAt))
|
||||
.limit(1);
|
||||
|
||||
return existingMembership?.organizationId ?? null;
|
||||
}
|
||||
|
||||
async function getHostedUser(userId: string) {
|
||||
const hostedUser = await db.query.user.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
where: eq(authUser.id, userId),
|
||||
});
|
||||
|
||||
if (!hostedUser?.email) {
|
||||
throw new Error("Failed to resolve hosted user for session setup");
|
||||
}
|
||||
|
||||
return hostedUser;
|
||||
}
|
||||
|
||||
async function createDefaultHostedOrganization(
|
||||
user: HostedUser,
|
||||
createOrganization: HostedOrganizationCreator,
|
||||
) {
|
||||
try {
|
||||
const createdOrganization = await createOrganization({
|
||||
name: getDefaultHostedOrganizationName(user),
|
||||
slug: getDefaultHostedOrganizationSlug(user),
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return createdOrganization.id;
|
||||
} catch (error) {
|
||||
const organizationId = await findFirstOrganizationIdForUser(user.id);
|
||||
|
||||
if (organizationId) {
|
||||
return organizationId;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrCreateDefaultHostedOrganization(
|
||||
userId: string,
|
||||
createOrganization: HostedOrganizationCreator,
|
||||
) {
|
||||
const existingOrganizationId = await findFirstOrganizationIdForUser(userId);
|
||||
|
||||
if (existingOrganizationId) {
|
||||
return existingOrganizationId;
|
||||
}
|
||||
|
||||
const hostedUser = await getHostedUser(userId);
|
||||
return createDefaultHostedOrganization(hostedUser, createOrganization);
|
||||
}
|
||||
61
src/server/auth/delegated-organization.ts
Normal file
61
src/server/auth/delegated-organization.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { db } from "@/db";
|
||||
import { organization } from "@/db/better-auth-schema";
|
||||
|
||||
function slugify(value: string) {
|
||||
const slug = value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48);
|
||||
|
||||
return slug || "workspace";
|
||||
}
|
||||
|
||||
function toHex(value: string) {
|
||||
return Array.from(new TextEncoder().encode(value), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
}
|
||||
|
||||
function getDelegatedOrganizationId(userId: string) {
|
||||
return `delegated-${userId}`;
|
||||
}
|
||||
|
||||
function getDelegatedOrganizationName(email: string, userId: string) {
|
||||
return `${email.split("@")[0] || userId} workspace`;
|
||||
}
|
||||
|
||||
function getDelegatedOrganizationSlug(email: string, userId: string) {
|
||||
const slugSource = email.split("@")[0] || userId;
|
||||
return `delegated-${slugify(slugSource)}-${toHex(userId)}`;
|
||||
}
|
||||
|
||||
export async function ensureDelegatedOrganizationForUser(
|
||||
userId: string,
|
||||
email: string,
|
||||
) {
|
||||
const organizationId = getDelegatedOrganizationId(userId);
|
||||
const name = getDelegatedOrganizationName(email, userId);
|
||||
const slug = getDelegatedOrganizationSlug(email, userId);
|
||||
|
||||
await db
|
||||
.insert(organization)
|
||||
.values({
|
||||
id: organizationId,
|
||||
name,
|
||||
slug,
|
||||
logo: null,
|
||||
createdAt: new Date(),
|
||||
metadata: null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: organization.id,
|
||||
set: {
|
||||
name,
|
||||
slug,
|
||||
},
|
||||
});
|
||||
|
||||
return organizationId;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { getWorkersBinding } from "@/server/lib/runtime-env";
|
||||
import {
|
||||
getWorkersBinding,
|
||||
isHostedServerAuthMode,
|
||||
} from "@/server/lib/runtime-env";
|
||||
|
||||
const BACKLINKS_ACCESS_STATUS_KEY = "settings:backlinks-access:v2:global";
|
||||
|
||||
@ -17,6 +20,12 @@ const BACKLINKS_NOT_ENABLED_MESSAGE =
|
||||
"Backlinks access check failed - it's still not enabled for your DataForSEO account. Enable it in DataForSEO, then try again.";
|
||||
|
||||
export async function getBacklinksAccessStatus(): Promise<BacklinksAccessStatus> {
|
||||
if (await isHostedServerAuthMode()) {
|
||||
// Hosted mode treats backlinks as platform-managed, so we intentionally
|
||||
// skip self-service verification and surface backlinks as available.
|
||||
return getHostedBacklinksAccessStatus();
|
||||
}
|
||||
|
||||
const kv = await getKvNamespace();
|
||||
const raw = await kv.get(BACKLINKS_ACCESS_STATUS_KEY, "text");
|
||||
if (!raw) {
|
||||
@ -39,6 +48,10 @@ export async function getBacklinksAccessStatus(): Promise<BacklinksAccessStatus>
|
||||
export async function setBacklinksAccessStatus(
|
||||
status: BacklinksAccessStatus,
|
||||
): Promise<void> {
|
||||
if (await isHostedServerAuthMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const kv = await getKvNamespace();
|
||||
await kv.put(BACKLINKS_ACCESS_STATUS_KEY, JSON.stringify(status));
|
||||
}
|
||||
@ -78,6 +91,16 @@ function getDefaultBacklinksAccessStatus(): BacklinksAccessStatus {
|
||||
};
|
||||
}
|
||||
|
||||
function getHostedBacklinksAccessStatus(): BacklinksAccessStatus {
|
||||
return {
|
||||
enabled: true,
|
||||
verifiedAt: null,
|
||||
lastCheckedAt: null,
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getKvNamespace(): Promise<KVNamespace> {
|
||||
const binding = await getWorkersBinding("KV");
|
||||
if (isKvNamespace(binding)) {
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AppError } from "@/server/lib/errors";
|
||||
import { assertBacklinksProjectAccess } from "@/server/features/backlinks/backlinksProjectAccess";
|
||||
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
|
||||
|
||||
vi.mock(
|
||||
"@/server/features/keywords/repositories/KeywordResearchRepository",
|
||||
() => ({
|
||||
KeywordResearchRepository: {
|
||||
getProject: vi.fn(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
describe("assertBacklinksProjectAccess", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns the project when the user has access", async () => {
|
||||
const project = {
|
||||
id: "project-1",
|
||||
userId: "user-1",
|
||||
name: "Project 1",
|
||||
domain: null,
|
||||
pagespeedApiKey: null,
|
||||
createdAt: "2026-03-14T00:00:00.000Z",
|
||||
};
|
||||
vi.mocked(KeywordResearchRepository.getProject).mockResolvedValue(project);
|
||||
|
||||
await expect(
|
||||
assertBacklinksProjectAccess("user-1", "project-1"),
|
||||
).resolves.toBe(project);
|
||||
});
|
||||
|
||||
it("throws when the user does not have project access", async () => {
|
||||
vi.mocked(KeywordResearchRepository.getProject).mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
|
||||
await expect(
|
||||
assertBacklinksProjectAccess("user-1", "project-1"),
|
||||
).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
} satisfies Partial<AppError>);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,12 +1,5 @@
|
||||
export { research } from "./research";
|
||||
export { getSerpAnalysis } from "./serp";
|
||||
export {
|
||||
listProjects,
|
||||
createProject,
|
||||
deleteProject,
|
||||
getOrCreateDefaultProject,
|
||||
getProject,
|
||||
} from "./projects";
|
||||
export {
|
||||
saveKeywords,
|
||||
getSavedKeywords,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -223,17 +223,8 @@ function persistRows(input: ResearchKeywordsInput, rows: EnrichedKeyword[]) {
|
||||
}
|
||||
|
||||
export async function research(
|
||||
userId: string,
|
||||
input: ResearchKeywordsInput,
|
||||
): Promise<ResearchResult> {
|
||||
const project = await KeywordResearchRepository.getProject(
|
||||
input.projectId,
|
||||
userId,
|
||||
);
|
||||
if (!project) {
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
const uniqueKeywords = [
|
||||
...new Set(input.keywords.map(normalizeKeyword)),
|
||||
].filter((keyword) => keyword.length > 0);
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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;
|
||||
17
src/server/features/projects/services/ProjectService.ts
Normal file
17
src/server/features/projects/services/ProjectService.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import {
|
||||
createProject,
|
||||
deleteProject,
|
||||
getOrCreateDefaultProject,
|
||||
getProject,
|
||||
getProjectForOrganization,
|
||||
listProjects,
|
||||
} from "@/server/features/projects/services/projects";
|
||||
|
||||
export const ProjectService = {
|
||||
listProjects,
|
||||
createProject,
|
||||
deleteProject,
|
||||
getOrCreateDefaultProject,
|
||||
getProject,
|
||||
getProjectForOrganization,
|
||||
} as const;
|
||||
89
src/server/features/projects/services/projects.ts
Normal file
89
src/server/features/projects/services/projects.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import type {
|
||||
CreateProjectInput,
|
||||
DeleteProjectInput,
|
||||
} from "@/types/schemas/projects";
|
||||
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
|
||||
function mapProject(project: {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string | null;
|
||||
createdAt: string;
|
||||
}) {
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
domain: project.domain,
|
||||
createdAt: project.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listProjects(organizationId: string) {
|
||||
const rows = await ProjectRepository.listProjects(organizationId);
|
||||
return rows.map(mapProject);
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
organizationId: string,
|
||||
input: CreateProjectInput,
|
||||
) {
|
||||
const id = await ProjectRepository.createProject(
|
||||
organizationId,
|
||||
input.name,
|
||||
input.domain,
|
||||
);
|
||||
return { id };
|
||||
}
|
||||
|
||||
export async function deleteProject(
|
||||
organizationId: string,
|
||||
input: DeleteProjectInput,
|
||||
) {
|
||||
await ProjectRepository.deleteProject(input.projectId, organizationId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getOrCreateDefaultProject(organizationId: string) {
|
||||
const existing = await ProjectRepository.listProjects(organizationId);
|
||||
if (existing.length > 0) {
|
||||
return mapProject(existing[0]);
|
||||
}
|
||||
|
||||
const id = await ProjectRepository.createProject(
|
||||
organizationId,
|
||||
"Default",
|
||||
undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
name: "Default",
|
||||
domain: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProject(projectId: string) {
|
||||
const project = await ProjectRepository.getProjectById(projectId);
|
||||
if (!project) {
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
return mapProject(project);
|
||||
}
|
||||
|
||||
export async function getProjectForOrganization(
|
||||
organizationId: string,
|
||||
projectId: string,
|
||||
) {
|
||||
const project = await ProjectRepository.getProjectForOrganization(
|
||||
projectId,
|
||||
organizationId,
|
||||
);
|
||||
if (!project) {
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
return mapProject(project);
|
||||
}
|
||||
@ -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;
|
||||
@ -1,20 +1,17 @@
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { getJsonFromR2, putJsonToR2 } from "@/server/lib/r2";
|
||||
import { getJsonFromR2 } from "@/server/lib/r2";
|
||||
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
||||
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
|
||||
import { PsiAuditRepository } from "@/server/features/psi/repositories/PsiAuditRepository";
|
||||
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
||||
import {
|
||||
PsiIssuesService,
|
||||
type PsiIssueCategory,
|
||||
} from "@/server/features/psi/services/PsiIssuesService";
|
||||
import { buildPsiExportFile } from "@/server/features/psi/services/psi-export";
|
||||
import { PsiService } from "@/server/features/psi/services/PsiService";
|
||||
|
||||
type PsiStrategy = "mobile" | "desktop";
|
||||
type PsiSource = "single" | "site";
|
||||
type ExportMode = "full" | "issues" | "category";
|
||||
|
||||
type ResolvedPsiSource = {
|
||||
type AuditPsiTarget = {
|
||||
id: string;
|
||||
strategy: PsiStrategy;
|
||||
finalUrl: string;
|
||||
@ -22,36 +19,13 @@ type ResolvedPsiSource = {
|
||||
r2Key: string | null;
|
||||
};
|
||||
|
||||
async function resolvePsiSource(input: {
|
||||
async function getAuditPsiTarget(input: {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
source: PsiSource;
|
||||
resultId: string;
|
||||
}): Promise<ResolvedPsiSource> {
|
||||
if (input.source === "single") {
|
||||
const row = await PsiAuditRepository.getAuditResult({
|
||||
auditId: input.resultId,
|
||||
projectId: input.projectId,
|
||||
userId: input.userId,
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
strategy: row.strategy,
|
||||
finalUrl: row.finalUrl,
|
||||
createdAt: row.createdAt,
|
||||
r2Key: row.r2Key,
|
||||
};
|
||||
}
|
||||
|
||||
}): Promise<AuditPsiTarget> {
|
||||
const site = await AuditRepository.getPsiResultById({
|
||||
psiResultId: input.resultId,
|
||||
projectId: input.projectId,
|
||||
userId: input.userId,
|
||||
});
|
||||
|
||||
if (!site) {
|
||||
@ -67,251 +41,33 @@ async function resolvePsiSource(input: {
|
||||
};
|
||||
}
|
||||
|
||||
async function runAudit(input: {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
url: string;
|
||||
strategy: PsiStrategy;
|
||||
}) {
|
||||
const apiKey = await KeywordResearchRepository.getProjectPsiApiKey(
|
||||
input.projectId,
|
||||
input.userId,
|
||||
);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new AppError("VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
const auditId = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
const result = await PsiService.runAudit({
|
||||
url: input.url,
|
||||
strategy: input.strategy,
|
||||
apiKey,
|
||||
});
|
||||
|
||||
const datePrefix = new Date().toISOString().slice(0, 10);
|
||||
const key = `psi/${input.projectId}/${datePrefix}/${auditId}.json`;
|
||||
const uploaded = await putJsonToR2(key, result.rawPayload);
|
||||
|
||||
await PsiAuditRepository.createAuditResult({
|
||||
id: auditId,
|
||||
projectId: input.projectId,
|
||||
requestedUrl: result.requestedUrl,
|
||||
finalUrl: result.finalUrl,
|
||||
strategy: result.strategy,
|
||||
status: "completed",
|
||||
performanceScore: result.scores.performance,
|
||||
accessibilityScore: result.scores.accessibility,
|
||||
bestPracticesScore: result.scores["best-practices"],
|
||||
seoScore: result.scores.seo,
|
||||
firstContentfulPaint: result.metrics.firstContentfulPaint.displayValue,
|
||||
largestContentfulPaint:
|
||||
result.metrics.largestContentfulPaint.displayValue,
|
||||
totalBlockingTime: result.metrics.totalBlockingTime.displayValue,
|
||||
cumulativeLayoutShift: result.metrics.cumulativeLayoutShift.displayValue,
|
||||
speedIndex: result.metrics.speedIndex.displayValue,
|
||||
timeToInteractive: result.metrics.timeToInteractive.displayValue,
|
||||
lighthouseVersion: result.lighthouseVersion,
|
||||
r2Key: uploaded.key,
|
||||
payloadSizeBytes: uploaded.sizeBytes,
|
||||
});
|
||||
|
||||
return {
|
||||
auditId,
|
||||
requestedUrl: result.requestedUrl,
|
||||
finalUrl: result.finalUrl,
|
||||
strategy: result.strategy,
|
||||
fetchedAt: result.fetchedAt,
|
||||
lighthouseVersion: result.lighthouseVersion,
|
||||
scores: result.scores,
|
||||
metrics: result.metrics,
|
||||
};
|
||||
} catch (error) {
|
||||
const requestedUrl = input.url.trim();
|
||||
const message =
|
||||
error instanceof Error ? error.message : "PSI request failed";
|
||||
|
||||
await PsiAuditRepository.createAuditResult({
|
||||
id: auditId,
|
||||
projectId: input.projectId,
|
||||
requestedUrl,
|
||||
finalUrl: requestedUrl,
|
||||
strategy: input.strategy,
|
||||
status: "failed",
|
||||
errorMessage: message,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getProjectPsiApiKey(input: {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const apiKey = await KeywordResearchRepository.getProjectPsiApiKey(
|
||||
input.projectId,
|
||||
input.userId,
|
||||
);
|
||||
async function getProjectPsiApiKey(input: { projectId: string }) {
|
||||
const apiKey = await ProjectRepository.getProjectPsiApiKey(input.projectId);
|
||||
return { apiKey };
|
||||
}
|
||||
|
||||
async function saveProjectPsiApiKey(input: {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
apiKey: string;
|
||||
}) {
|
||||
await KeywordResearchRepository.setProjectPsiApiKey(
|
||||
await ProjectRepository.setProjectPsiApiKey(
|
||||
input.projectId,
|
||||
input.userId,
|
||||
input.apiKey.trim(),
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function clearProjectPsiApiKey(input: {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
await KeywordResearchRepository.clearProjectPsiApiKey(
|
||||
input.projectId,
|
||||
input.userId,
|
||||
);
|
||||
async function clearProjectPsiApiKey(input: { projectId: string }) {
|
||||
await ProjectRepository.clearProjectPsiApiKey(input.projectId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function listProjectPsiAudits(input: {
|
||||
async function getAuditPsiIssues(input: {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
strategy?: PsiStrategy;
|
||||
limit: number;
|
||||
}) {
|
||||
const rows = await PsiAuditRepository.listAuditResults({
|
||||
projectId: input.projectId,
|
||||
userId: input.userId,
|
||||
strategy: input.strategy,
|
||||
limit: input.limit,
|
||||
});
|
||||
|
||||
return {
|
||||
rows: rows.map((row) => ({
|
||||
id: row.id,
|
||||
requestedUrl: row.requestedUrl,
|
||||
finalUrl: row.finalUrl,
|
||||
strategy: row.strategy,
|
||||
status: row.status,
|
||||
performanceScore: row.performanceScore,
|
||||
accessibilityScore: row.accessibilityScore,
|
||||
bestPracticesScore: row.bestPracticesScore,
|
||||
seoScore: row.seoScore,
|
||||
firstContentfulPaint: row.firstContentfulPaint,
|
||||
largestContentfulPaint: row.largestContentfulPaint,
|
||||
totalBlockingTime: row.totalBlockingTime,
|
||||
cumulativeLayoutShift: row.cumulativeLayoutShift,
|
||||
speedIndex: row.speedIndex,
|
||||
timeToInteractive: row.timeToInteractive,
|
||||
lighthouseVersion: row.lighthouseVersion,
|
||||
errorMessage: row.errorMessage,
|
||||
payloadSizeBytes: row.payloadSizeBytes,
|
||||
createdAt: row.createdAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function getProjectPsiAuditRaw(input: {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
auditId: string;
|
||||
}) {
|
||||
const row = await PsiAuditRepository.getAuditResult({
|
||||
auditId: input.auditId,
|
||||
projectId: input.projectId,
|
||||
userId: input.userId,
|
||||
});
|
||||
|
||||
if (!row || !row.r2Key) {
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
const payloadJson = await getJsonFromR2(row.r2Key);
|
||||
return {
|
||||
id: row.id,
|
||||
strategy: row.strategy,
|
||||
finalUrl: row.finalUrl,
|
||||
createdAt: row.createdAt,
|
||||
payloadJson,
|
||||
};
|
||||
}
|
||||
|
||||
async function getProjectPsiAuditIssues(input: {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
auditId: string;
|
||||
category?: PsiIssueCategory;
|
||||
}) {
|
||||
const row = await PsiAuditRepository.getAuditResult({
|
||||
auditId: input.auditId,
|
||||
projectId: input.projectId,
|
||||
userId: input.userId,
|
||||
});
|
||||
|
||||
if (!row || !row.r2Key) {
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
const payloadJson = await getJsonFromR2(row.r2Key);
|
||||
const issues = PsiIssuesService.parseIssues(payloadJson, input.category);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
finalUrl: row.finalUrl,
|
||||
strategy: row.strategy,
|
||||
createdAt: row.createdAt,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
async function exportProjectPsiAudit(input: {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
auditId: string;
|
||||
mode: ExportMode;
|
||||
category?: PsiIssueCategory;
|
||||
}) {
|
||||
const row = await PsiAuditRepository.getAuditResult({
|
||||
auditId: input.auditId,
|
||||
projectId: input.projectId,
|
||||
userId: input.userId,
|
||||
});
|
||||
|
||||
if (!row || !row.r2Key) {
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
const payloadJson = await getJsonFromR2(row.r2Key);
|
||||
|
||||
return buildPsiExportFile({
|
||||
idField: "auditId",
|
||||
idValue: row.id,
|
||||
finalUrl: row.finalUrl,
|
||||
strategy: row.strategy,
|
||||
createdAt: row.createdAt,
|
||||
payloadJson,
|
||||
mode: input.mode,
|
||||
category: input.mode === "category" ? input.category : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function getPsiIssuesBySource(input: {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
source: PsiSource;
|
||||
resultId: string;
|
||||
category?: PsiIssueCategory;
|
||||
}) {
|
||||
const target = await resolvePsiSource(input);
|
||||
const target = await getAuditPsiTarget(input);
|
||||
if (!target.r2Key) {
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
@ -328,15 +84,13 @@ async function getPsiIssuesBySource(input: {
|
||||
};
|
||||
}
|
||||
|
||||
async function exportPsiBySource(input: {
|
||||
async function exportAuditPsi(input: {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
source: PsiSource;
|
||||
resultId: string;
|
||||
mode: ExportMode;
|
||||
category?: PsiIssueCategory;
|
||||
}) {
|
||||
const target = await resolvePsiSource(input);
|
||||
const target = await getAuditPsiTarget(input);
|
||||
if (!target.r2Key) {
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
@ -356,14 +110,9 @@ async function exportPsiBySource(input: {
|
||||
}
|
||||
|
||||
export const PsiAuditService = {
|
||||
runAudit,
|
||||
getProjectPsiApiKey,
|
||||
saveProjectPsiApiKey,
|
||||
clearProjectPsiApiKey,
|
||||
listProjectPsiAudits,
|
||||
getProjectPsiAuditRaw,
|
||||
getProjectPsiAuditIssues,
|
||||
exportProjectPsiAudit,
|
||||
getPsiIssuesBySource,
|
||||
exportPsiBySource,
|
||||
getAuditPsiIssues,
|
||||
exportAuditPsi,
|
||||
} as const;
|
||||
|
||||
@ -1,194 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const PSI_ENDPOINT =
|
||||
"https://www.googleapis.com/pagespeedonline/v5/runPagespeed";
|
||||
const PSI_CATEGORIES = [
|
||||
"performance",
|
||||
"accessibility",
|
||||
"best-practices",
|
||||
"seo",
|
||||
] as const;
|
||||
|
||||
type PsiCategory = (typeof PSI_CATEGORIES)[number];
|
||||
type PsiStrategy = "mobile" | "desktop";
|
||||
|
||||
type PsiAuditMetric = {
|
||||
score: number | null;
|
||||
displayValue: string | null;
|
||||
numericValue: number | null;
|
||||
};
|
||||
|
||||
type PsiAuditResult = {
|
||||
requestedUrl: string;
|
||||
finalUrl: string;
|
||||
strategy: PsiStrategy;
|
||||
fetchedAt: string;
|
||||
lighthouseVersion: string | null;
|
||||
scores: Record<PsiCategory, number | null>;
|
||||
metrics: {
|
||||
firstContentfulPaint: PsiAuditMetric;
|
||||
largestContentfulPaint: PsiAuditMetric;
|
||||
totalBlockingTime: PsiAuditMetric;
|
||||
cumulativeLayoutShift: PsiAuditMetric;
|
||||
speedIndex: PsiAuditMetric;
|
||||
timeToInteractive: PsiAuditMetric;
|
||||
};
|
||||
rawPayload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type LighthouseAudit = {
|
||||
score?: number | null;
|
||||
displayValue?: string;
|
||||
numericValue?: number;
|
||||
};
|
||||
|
||||
const lighthouseAuditSchema = z.object({
|
||||
score: z.number().nullable().optional(),
|
||||
displayValue: z.string().optional(),
|
||||
numericValue: z.number().optional(),
|
||||
});
|
||||
|
||||
const psiResponseSchema = z
|
||||
.object({
|
||||
lighthouseResult: z
|
||||
.object({
|
||||
finalDisplayedUrl: z.string().optional(),
|
||||
lighthouseVersion: z.string().optional(),
|
||||
categories: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({ score: z.number().nullable().optional() }),
|
||||
)
|
||||
.optional()
|
||||
.default({}),
|
||||
audits: z
|
||||
.record(z.string(), lighthouseAuditSchema)
|
||||
.optional()
|
||||
.default({}),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const psiErrorSchema = z
|
||||
.object({
|
||||
error: z
|
||||
.object({
|
||||
message: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object";
|
||||
}
|
||||
|
||||
function normalizeInputUrl(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("URL is required");
|
||||
}
|
||||
|
||||
const withProtocol = /^https?:\/\//i.test(trimmed)
|
||||
? trimmed
|
||||
: `https://${trimmed}`;
|
||||
|
||||
try {
|
||||
const parsed = new URL(withProtocol);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
throw new Error("Only http and https URLs are supported");
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
throw new Error("Please enter a valid URL");
|
||||
}
|
||||
}
|
||||
|
||||
function asScore(value: number | null | undefined): number | null {
|
||||
if (value == null || Number.isNaN(value)) return null;
|
||||
return Math.round(value * 100);
|
||||
}
|
||||
|
||||
function asMetric(audit: LighthouseAudit | undefined): PsiAuditMetric {
|
||||
return {
|
||||
score: asScore(audit?.score),
|
||||
displayValue: audit?.displayValue ?? null,
|
||||
numericValue:
|
||||
typeof audit?.numericValue === "number" ? audit.numericValue : null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: unknown): string | null {
|
||||
const parsed = psiErrorSchema.safeParse(payload);
|
||||
if (!parsed.success) return null;
|
||||
return parsed.data.error?.message ?? null;
|
||||
}
|
||||
|
||||
async function runAudit(input: {
|
||||
url: string;
|
||||
strategy: PsiStrategy;
|
||||
apiKey: string;
|
||||
}): Promise<PsiAuditResult> {
|
||||
const apiKey = input.apiKey.trim();
|
||||
if (!apiKey) {
|
||||
throw new Error("PSI API key is required");
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizeInputUrl(input.url);
|
||||
const params = new URLSearchParams({
|
||||
url: normalizedUrl,
|
||||
strategy: input.strategy,
|
||||
});
|
||||
|
||||
for (const category of PSI_CATEGORIES) {
|
||||
params.append("category", category);
|
||||
}
|
||||
|
||||
params.append("key", apiKey);
|
||||
|
||||
const response = await fetch(`${PSI_ENDPOINT}?${params.toString()}`);
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = extractErrorMessage(payload);
|
||||
throw new Error(message ?? `PSI request failed (${response.status})`);
|
||||
}
|
||||
|
||||
const parsedPayload = psiResponseSchema.safeParse(payload);
|
||||
if (!parsedPayload.success || !parsedPayload.data.lighthouseResult) {
|
||||
throw new Error("PSI returned an invalid response");
|
||||
}
|
||||
|
||||
const lighthouseResult = parsedPayload.data.lighthouseResult;
|
||||
|
||||
const categories = lighthouseResult.categories ?? {};
|
||||
const audits: Record<string, LighthouseAudit> = lighthouseResult.audits ?? {};
|
||||
|
||||
return {
|
||||
requestedUrl: normalizedUrl,
|
||||
finalUrl: lighthouseResult.finalDisplayedUrl ?? normalizedUrl,
|
||||
strategy: input.strategy,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
lighthouseVersion: lighthouseResult.lighthouseVersion ?? null,
|
||||
scores: {
|
||||
performance: asScore(categories.performance?.score),
|
||||
accessibility: asScore(categories.accessibility?.score),
|
||||
"best-practices": asScore(categories["best-practices"]?.score),
|
||||
seo: asScore(categories.seo?.score),
|
||||
},
|
||||
metrics: {
|
||||
firstContentfulPaint: asMetric(audits["first-contentful-paint"]),
|
||||
largestContentfulPaint: asMetric(audits["largest-contentful-paint"]),
|
||||
totalBlockingTime: asMetric(audits["total-blocking-time"]),
|
||||
cumulativeLayoutShift: asMetric(audits["cumulative-layout-shift"]),
|
||||
speedIndex: asMetric(audits["speed-index"]),
|
||||
timeToInteractive: asMetric(audits.interactive),
|
||||
},
|
||||
rawPayload: isRecord(payload) ? payload : {},
|
||||
};
|
||||
}
|
||||
|
||||
export const PsiService = {
|
||||
runAudit,
|
||||
} as const;
|
||||
@ -1,22 +1,5 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
|
||||
export async function putJsonToR2(
|
||||
key: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<{ key: string; sizeBytes: number }> {
|
||||
const body = JSON.stringify(payload);
|
||||
await env.R2.put(key, body, {
|
||||
httpMetadata: {
|
||||
contentType: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
key,
|
||||
sizeBytes: Buffer.byteLength(body),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getJsonFromR2(key: string): Promise<string> {
|
||||
const object = await env.R2.get(key);
|
||||
if (!object) {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||
|
||||
let workersEnvPromise: Promise<Record<string, unknown> | null> | null = null;
|
||||
|
||||
async function getEnvValue(name: string): Promise<string | undefined> {
|
||||
@ -20,6 +22,10 @@ export async function getRequiredEnvValue(name: string): Promise<string> {
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function isHostedServerAuthMode(): Promise<boolean> {
|
||||
return isHostedAuthMode(await getEnvValue("AUTH_MODE"));
|
||||
}
|
||||
|
||||
export async function getWorkersBinding(name: string): Promise<unknown> {
|
||||
const workersEnv = await getWorkersEnv();
|
||||
const binding = workersEnv?.[name];
|
||||
|
||||
@ -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 };
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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),
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
25
src/serverFunctions/projects.ts
Normal file
25
src/serverFunctions/projects.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
||||
import {
|
||||
requireAuthenticatedContext,
|
||||
requireProjectContext,
|
||||
} from "@/serverFunctions/middleware";
|
||||
import { z } from "zod";
|
||||
|
||||
export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.handler(async ({ context }) =>
|
||||
ProjectService.getOrCreateDefaultProject(context.organizationId),
|
||||
);
|
||||
|
||||
export const getProjectAccess = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) =>
|
||||
z.object({ projectId: z.string().min(1) }).parse(data),
|
||||
)
|
||||
.handler(async ({ context }) => {
|
||||
return ProjectService.getProjectForOrganization(
|
||||
context.organizationId,
|
||||
context.project.id,
|
||||
);
|
||||
});
|
||||
@ -1,67 +1,66 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { PsiAuditService } from "@/server/features/psi/services/PsiAuditService";
|
||||
import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
|
||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||
import {
|
||||
psiUnifiedIssueSchema,
|
||||
psiUnifiedExportSchema,
|
||||
psiAuditIssueSchema,
|
||||
psiAuditExportSchema,
|
||||
psiProjectKeySchema,
|
||||
psiProjectSchema,
|
||||
} from "@/types/schemas/psi";
|
||||
|
||||
export const getProjectPsiApiKey = createServerFn({ method: "POST" })
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
export const getProjectPsiApiKey = createServerFn({
|
||||
method: "POST",
|
||||
})
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => psiProjectSchema.parse(data))
|
||||
.handler(async ({ data, context }) =>
|
||||
PsiAuditService.getProjectPsiApiKey({
|
||||
projectId: data.projectId,
|
||||
userId: context.userId,
|
||||
}),
|
||||
);
|
||||
.handler(async ({ context }) => {
|
||||
return PsiAuditService.getProjectPsiApiKey({
|
||||
projectId: context.project.id,
|
||||
});
|
||||
});
|
||||
|
||||
export const saveProjectPsiApiKey = createServerFn({ method: "POST" })
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
export const saveProjectPsiApiKey = createServerFn({
|
||||
method: "POST",
|
||||
})
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => psiProjectKeySchema.parse(data))
|
||||
.handler(async ({ data, context }) =>
|
||||
PsiAuditService.saveProjectPsiApiKey({
|
||||
projectId: data.projectId,
|
||||
userId: context.userId,
|
||||
.handler(async ({ data, context }) => {
|
||||
return PsiAuditService.saveProjectPsiApiKey({
|
||||
projectId: context.project.id,
|
||||
apiKey: data.apiKey,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
export const clearProjectPsiApiKey = createServerFn({ method: "POST" })
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
export const clearProjectPsiApiKey = createServerFn({
|
||||
method: "POST",
|
||||
})
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => psiProjectSchema.parse(data))
|
||||
.handler(async ({ data, context }) =>
|
||||
PsiAuditService.clearProjectPsiApiKey({
|
||||
projectId: data.projectId,
|
||||
userId: context.userId,
|
||||
}),
|
||||
);
|
||||
.handler(async ({ context }) => {
|
||||
return PsiAuditService.clearProjectPsiApiKey({
|
||||
projectId: context.project.id,
|
||||
});
|
||||
});
|
||||
|
||||
export const getPsiIssuesBySource = createServerFn({ method: "POST" })
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => psiUnifiedIssueSchema.parse(data))
|
||||
.handler(async ({ data, context }) =>
|
||||
PsiAuditService.getPsiIssuesBySource({
|
||||
projectId: data.projectId,
|
||||
userId: context.userId,
|
||||
source: data.source,
|
||||
export const getAuditPsiIssues = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => psiAuditIssueSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
return PsiAuditService.getAuditPsiIssues({
|
||||
projectId: context.project.id,
|
||||
resultId: data.resultId,
|
||||
category: data.category,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
export const exportPsiBySource = createServerFn({ method: "POST" })
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => psiUnifiedExportSchema.parse(data))
|
||||
.handler(async ({ data, context }) =>
|
||||
PsiAuditService.exportPsiBySource({
|
||||
projectId: data.projectId,
|
||||
userId: context.userId,
|
||||
source: data.source,
|
||||
export const exportAuditPsi = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => psiAuditExportSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
return PsiAuditService.exportAuditPsi({
|
||||
projectId: context.project.id,
|
||||
resultId: data.resultId,
|
||||
mode: data.mode,
|
||||
category: data.category,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
6
src/start.ts
Normal file
6
src/start.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { createStart } from "@tanstack/react-start";
|
||||
import { globalServerFunctionMiddleware } from "@/serverFunctions/middleware";
|
||||
|
||||
export const startInstance = createStart(() => ({
|
||||
functionMiddleware: globalServerFunctionMiddleware,
|
||||
}));
|
||||
@ -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),
|
||||
});
|
||||
|
||||
|
||||
@ -14,15 +14,6 @@ export const researchKeywordsSchema = z.object({
|
||||
.default("auto"),
|
||||
});
|
||||
|
||||
export const createProjectSchema = z.object({
|
||||
name: z.string().min(1, "Project name is required").max(120),
|
||||
domain: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
export const deleteProjectSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const saveKeywordsSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
keywords: z.array(z.string().min(1)).min(1).max(200),
|
||||
@ -68,6 +59,7 @@ export const saveKeywordsSchema = z.object({
|
||||
});
|
||||
|
||||
export const removeSavedKeywordSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
savedKeywordId: z.string().min(1),
|
||||
});
|
||||
|
||||
@ -76,8 +68,6 @@ export const getSavedKeywordsSchema = z.object({
|
||||
});
|
||||
|
||||
export type ResearchKeywordsInput = z.infer<typeof researchKeywordsSchema>;
|
||||
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
||||
export type DeleteProjectInput = z.infer<typeof deleteProjectSchema>;
|
||||
export type SaveKeywordsInput = z.infer<typeof saveKeywordsSchema>;
|
||||
export type RemoveSavedKeywordInput = z.infer<typeof removeSavedKeywordSchema>;
|
||||
export const serpAnalysisSchema = z.object({
|
||||
|
||||
18
src/types/schemas/projects.ts
Normal file
18
src/types/schemas/projects.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const createProjectSchema = z.object({
|
||||
name: z.string().min(1, "Project name is required").max(120),
|
||||
domain: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(255)
|
||||
.transform((value) => value || undefined)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const deleteProjectSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
});
|
||||
|
||||
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
||||
export type DeleteProjectInput = z.infer<typeof deleteProjectSchema>;
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user