From 20f5a6c1d19c73341e3f38297879a8602066edda Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 17:48:50 +0800 Subject: [PATCH] chore(identity): wire staging identity operations --- deploy/.env.staging.identity.example | 18 +++++ deploy/Caddyfile.staging | 10 +++ deploy/README.md | 3 + deploy/docker-compose.server.yml | 1 + deploy/run-staging-deploy.sh | 8 ++ deploy/validate-staging-env.sh | 51 ++++++++++++ docs/operations/self-hosted-identity.md | 80 +++++++++++++++++++ frontend/src/app/api/auth/[...all]/route.ts | 9 ++- frontend/src/app/login/page.tsx | 24 +++++- frontend/src/modules/identity/auth.ts | 7 +- frontend/src/modules/identity/config.ts | 38 ++++++--- frontend/tests/health-deployment.test.ts | 42 +++++++++- frontend/tests/identity-config.test.ts | 37 ++++++++- .../tests/identity-login-provider.test.ts | 5 +- 14 files changed, 311 insertions(+), 22 deletions(-) create mode 100644 deploy/.env.staging.identity.example create mode 100644 docs/operations/self-hosted-identity.md diff --git a/deploy/.env.staging.identity.example b/deploy/.env.staging.identity.example new file mode 100644 index 00000000..5dee8521 --- /dev/null +++ b/deploy/.env.staging.identity.example @@ -0,0 +1,18 @@ +# Copy these names into /opt/jyotisha-staging/.env.staging, replace every +# bracketed value locally, keep the file mode 0600, and do not commit it. +APP_ENV_FILE=../.env.staging +CADDYFILE_PATH=./Caddyfile.staging +SITE_ADDRESS=https://staging.jyotisha.chat +ADMIN_SITE_ADDRESS=https://admin.staging.jyotisha.chat + +# Coexistence mode: the public site still uses Supabase-backed business routes, +# while the self-hosted identity API and the admin-host login can be tested. +AUTH_PROVIDER=supabase +SELF_HOSTED_IDENTITY_ENABLED=true +AUTH_USER_ORIGIN=https://staging.jyotisha.chat +AUTH_ADMIN_ORIGIN=https://admin.staging.jyotisha.chat +IDENTITY_DATABASE_URL=postgresql://identity_runtime:@postgres:5432/jyotisha +BETTER_AUTH_USER_SECRET= +BETTER_AUTH_ADMIN_SECRET= +RESEND_API_KEY= +RESEND_FROM_EMAIL=Jyotisha Staging diff --git a/deploy/Caddyfile.staging b/deploy/Caddyfile.staging index 66bbb59c..ebeb3caf 100644 --- a/deploy/Caddyfile.staging +++ b/deploy/Caddyfile.staging @@ -2,3 +2,13 @@ encode zstd gzip reverse_proxy web:3000 } + +{$ADMIN_SITE_ADDRESS:https://admin.staging.jyotisha.chat} { + encode zstd gzip + + @identity path /login /api/auth/* /_next/* /jyotish-logo.png /favicon.ico + handle @identity { + reverse_proxy web:3000 + } + respond "Not found" 404 +} diff --git a/deploy/README.md b/deploy/README.md index 704e59a5..1d157929 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -157,8 +157,11 @@ The staging env file must include these non-secret selectors so Compose cannot f APP_ENV_FILE=../.env.staging CADDYFILE_PATH=./Caddyfile.staging SITE_ADDRESS=https://staging.jyotisha.chat +ADMIN_SITE_ADDRESS=https://admin.staging.jyotisha.chat ``` +The self-hosted identity milestone runs in coexistence mode: keep `AUTH_PROVIDER=supabase` and set `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the server-only identity database, separate user/admin Better Auth secrets, origins, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. The public login remains on Supabase while the admin host and identity API are exercised. Do not set `AUTH_PROVIDER=self-hosted` until the Supabase-backed business modules have migrated. See `docs/operations/self-hosted-identity.md` for validation, import, smoke, and rollback commands. + After source sync and before `up`, the workflow validates `.env.staging` mode/selectors, explicitly pins the three staging selectors against ambient shell overrides, and runs `docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet`. For later manual inspections, run the same checks only after the tracked deployment files exist on the server. Do not use a manual gate run from `main` as the first publishing path: publishing requires a successful push to `staging`, while manual `Deploy staging` requires a successful gate run for the exact SHA. ### First-deploy sequence diff --git a/deploy/docker-compose.server.yml b/deploy/docker-compose.server.yml index 9e660a16..a5859a0d 100644 --- a/deploy/docker-compose.server.yml +++ b/deploy/docker-compose.server.yml @@ -44,6 +44,7 @@ services: restart: unless-stopped environment: SITE_ADDRESS: ${SITE_ADDRESS:-https://jyotisha.chat} + ADMIN_SITE_ADDRESS: ${ADMIN_SITE_ADDRESS:-https://admin.staging.jyotisha.chat} ports: - "80:80" - "443:443" diff --git a/deploy/run-staging-deploy.sh b/deploy/run-staging-deploy.sh index 153d658a..2a4b978e 100755 --- a/deploy/run-staging-deploy.sh +++ b/deploy/run-staging-deploy.sh @@ -125,6 +125,7 @@ export APP_ENV_FILE='../.env.staging' export DATABASE_ENV_FILE='../.env.staging.database' export CADDYFILE_PATH='./Caddyfile.staging' export SITE_ADDRESS='https://staging.jyotisha.chat' +export ADMIN_SITE_ADDRESS='https://admin.staging.jyotisha.chat' export GITHUB_SHA="$DEPLOY_SHA" "${compose[@]}" config --quiet @@ -180,6 +181,7 @@ verify_container_image web "$WEB_IMAGE" "${compose[@]}" exec -T \ -e EXPECTED_SHA="$DEPLOY_SHA" -e STAGING_URL="$STAGING_URL" \ + -e STAGING_ADMIN_URL="https://admin.staging.jyotisha.chat" \ web node --input-type=module <<'NODE' const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); let login; @@ -191,6 +193,12 @@ for (let attempt = 0; attempt < 12; attempt += 1) { await delay(5_000); } if (!login?.ok) process.exit(1); +const adminLogin = await fetch(`${process.env.STAGING_ADMIN_URL}/login`); +if (!adminLogin.ok) process.exit(1); +const adminRoot = await fetch(process.env.STAGING_ADMIN_URL, { redirect: "manual" }); +if (adminRoot.status !== 404) process.exit(1); +const adminSession = await fetch(`${process.env.STAGING_ADMIN_URL}/api/auth/get-session`); +if (!adminSession.ok) process.exit(1); const account = await fetch(`${process.env.STAGING_URL}/api/account`); if (account.status !== 401) process.exit(1); const publicHealth = await fetch(`${process.env.STAGING_URL}/api/health`); diff --git a/deploy/validate-staging-env.sh b/deploy/validate-staging-env.sh index 0c82f578..9ba55d99 100755 --- a/deploy/validate-staging-env.sh +++ b/deploy/validate-staging-env.sh @@ -8,6 +8,11 @@ if [ ! -f "$ENV_FILE" ]; then exit 1 fi +if [ -L "$ENV_FILE" ]; then + echo "staging environment file must not be a symlink" >&2 + exit 1 +fi + if MODE="$(stat -c '%a' "$ENV_FILE" 2>/dev/null)"; then : else @@ -36,5 +41,51 @@ require_selector() { require_selector APP_ENV_FILE ../.env.staging require_selector CADDYFILE_PATH ./Caddyfile.staging require_selector SITE_ADDRESS https://staging.jyotisha.chat +require_selector ADMIN_SITE_ADDRESS https://admin.staging.jyotisha.chat +require_selector AUTH_PROVIDER supabase +require_selector SELF_HOSTED_IDENTITY_ENABLED true +require_selector AUTH_USER_ORIGIN https://staging.jyotisha.chat +require_selector AUTH_ADMIN_ORIGIN https://admin.staging.jyotisha.chat + +require_literal() { + local key="$1" + local minimum_length="$2" + local count value + count="$(grep -Ec "^${key}=" "$ENV_FILE" || true)" + if [ "$count" -ne 1 ]; then + echo "invalid staging identity setting: $key" >&2 + exit 1 + fi + value="$(grep -E "^${key}=" "$ENV_FILE")" + value="${value#*=}" + if [ "${#value}" -lt "$minimum_length" ] || + [[ "$value" == *'$'* || "$value" == *'"'* || "$value" == *"'"* ]]; then + echo "invalid staging identity setting: $key" >&2 + exit 1 + fi + LITERAL_VALUE="$value" +} + +require_literal IDENTITY_DATABASE_URL 50 +identity_database_url="$LITERAL_VALUE" +if ! [[ "$identity_database_url" =~ ^postgresql://identity_runtime:([A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+@postgres:5432/jyotisha$ ]]; then + echo "invalid staging identity setting: IDENTITY_DATABASE_URL" >&2 + exit 1 +fi + +require_literal BETTER_AUTH_USER_SECRET 32 +user_secret="$LITERAL_VALUE" +require_literal BETTER_AUTH_ADMIN_SECRET 32 +admin_secret="$LITERAL_VALUE" +if [ "$user_secret" = "$admin_secret" ]; then + echo "staging identity secrets must be different" >&2 + exit 1 +fi +require_literal RESEND_API_KEY 10 +require_literal RESEND_FROM_EMAIL 5 +if [[ "$LITERAL_VALUE" != *@* ]]; then + echo "invalid staging identity setting: RESEND_FROM_EMAIL" >&2 + exit 1 +fi echo "staging environment selectors: valid" diff --git a/docs/operations/self-hosted-identity.md b/docs/operations/self-hosted-identity.md new file mode 100644 index 00000000..8292ec10 --- /dev/null +++ b/docs/operations/self-hosted-identity.md @@ -0,0 +1,80 @@ +# Self-hosted identity operations + +This milestone deploys Better Auth beside the existing Supabase login. It does not authorize the final authentication cutover or removal of Supabase-backed business routes. + +## Safe staging mode + +Keep these two values exactly as shown while profile, consultation, credits, chat, and report routes still rely on Supabase JWT/RLS: + +```dotenv +AUTH_PROVIDER=supabase +SELF_HOSTED_IDENTITY_ENABLED=true +``` + +This combination keeps `staging.jyotisha.chat/login` on Supabase, enables `/api/auth/**` for integration tests, and makes `admin.staging.jyotisha.chat/login` use the isolated Better Auth admin surface. The public and admin sessions have different secrets and host-only cookie prefixes. The staging validator deliberately rejects `AUTH_PROVIDER=self-hosted` in this milestone. + +Use [the tracked staging identity example](../../deploy/.env.staging.identity.example) as a list of names only. Replace bracketed values directly on the server and keep `/opt/jyotisha-staging/.env.staging` owned by `deploy` with mode `0600`. + +Generate separate secrets locally on the server: + +```bash +openssl rand -base64 32 +openssl rand -base64 32 +``` + +Do not reuse either value as a PostgreSQL password. `IDENTITY_DATABASE_URL` uses the existing `IDENTITY_RUNTIME_PASSWORD` from `.env.staging.database`, percent-encoded only in the URL password component. It must point to the private Compose hostname `postgres:5432/jyotisha`; never publish PostgreSQL on a host port. + +The Resend key must be staging-only. `RESEND_FROM_EMAIL` must use a sender/domain verified in Resend. CI never receives this key and uses an in-memory sender. + +Validate without printing values: + +```bash +cd /opt/jyotisha-staging +chmod 600 .env.staging +bash deploy/validate-staging-env.sh .env.staging +``` + +## Migration and smoke checks + +Apply the reviewed PostgreSQL migrations through the existing `Migrate Staging Database` workflow before deploying the web image. The identity migration creates `identity.users`, `identity.sessions`, `identity.accounts`, `identity.verifications`, and `identity.otp_rate_limits` under least-privilege roles. + +After deployment: + +```bash +curl -fsS https://admin.staging.jyotisha.chat/login >/dev/null +curl -fsS https://admin.staging.jyotisha.chat/api/auth/get-session +test "$(curl -sS -o /dev/null -w '%{http_code}' https://admin.staging.jyotisha.chat/)" = 404 +``` + +An unknown or unpromoted email cannot create an admin session. Promote an imported staging user only through a reviewed database/admin operation; the persisted `identity.users.role` value must include `admin` before the admin OTP flow can issue a cookie. + +## Import rehearsal + +Export Supabase Auth users to a JSON array in the supported fixture shape, then run a redacted dry-run first: + +```bash +cd /opt/jyotisha-staging/frontend +node scripts/import-supabase-auth-users.mjs /secure/path/auth-users.json +``` + +The summary contains only counts. It preserves UUID, normalized email, verification timestamps, display metadata, and created/updated timestamps. It intentionally ignores passwords, sessions, JWTs, provider secrets, and Supabase platform fields. + +Apply only after reviewing the dry-run and taking a local encrypted staging backup: + +```bash +set -a +. ../.env.staging +set +a +node scripts/import-supabase-auth-users.mjs /secure/path/auth-users.json --apply +unset IDENTITY_DATABASE_URL +``` + +Reruns are idempotent by UUID and the whole import is transactional. Duplicate canonical emails abort before database writes. + +## Rollback and rotation + +To disable the new identity service without touching Supabase login, set `SELF_HOSTED_IDENTITY_ENABLED=false`, remove the identity-only smoke check for that separately reviewed rollback revision, and redeploy. Existing self-hosted sessions become unreachable; do not delete identity rows during application rollback. + +Rotating either Better Auth secret invalidates only that surface's existing sessions. Rotate user and admin secrets separately, restart the web service, and verify the corresponding host. Rotate a leaked Resend key in Resend first, replace the server value, then restart. Never print the old or new values. + +Final `AUTH_PROVIDER=self-hosted` cutover is blocked until all business modules authorize with the self-hosted session boundary, reconciliation passes, production backups and restore drills exist, and a separate reviewed cutover plan is approved. diff --git a/frontend/src/app/api/auth/[...all]/route.ts b/frontend/src/app/api/auth/[...all]/route.ts index b7f0834d..2a25646d 100644 --- a/frontend/src/app/api/auth/[...all]/route.ts +++ b/frontend/src/app/api/auth/[...all]/route.ts @@ -1,7 +1,10 @@ import { toNextJsHandler } from "better-auth/next-js"; import { getIdentityAuthServices } from "@/modules/identity/auth"; -import { readIdentityConfig } from "@/modules/identity/config"; +import { + isSelfHostedIdentityEnabled, + readSelfHostedIdentityConfig, +} from "@/modules/identity/config"; import { createHostIsolatedAuthHandlers, type IdentityAuthHandlers, @@ -13,10 +16,10 @@ async function dispatch( method: keyof IdentityAuthHandlers, request: Request, ): Promise { - const config = readIdentityConfig(process.env); - if (config.provider !== "self-hosted") { + if (!isSelfHostedIdentityEnabled(process.env)) { return new Response("Not found", { status: 404 }); } + const config = readSelfHostedIdentityConfig(process.env); const services = getIdentityAuthServices(); const handlers = createHostIsolatedAuthHandlers(config, { diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 9c21e7cf..638ea271 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -1,7 +1,23 @@ -import { EmailOtpLogin } from "@/components/email-otp-login"; -import { readIdentityConfig } from "@/modules/identity/config"; +import { headers } from "next/headers"; -export default function LoginPage() { +import { EmailOtpLogin } from "@/components/email-otp-login"; +import { + isSelfHostedIdentityEnabled, + readIdentityConfig, + readSelfHostedIdentityConfig, +} from "@/modules/identity/config"; +import { resolveIdentitySurface } from "@/modules/identity/host"; + +export default async function LoginPage() { const config = readIdentityConfig(process.env); - return ; + let provider = config.provider; + if (isSelfHostedIdentityEnabled(process.env)) { + const selfHosted = readSelfHostedIdentityConfig(process.env); + const surface = resolveIdentitySurface( + (await headers()).get("host"), + selfHosted, + ); + if (surface === "admin") provider = "self-hosted"; + } + return ; } diff --git a/frontend/src/modules/identity/auth.ts b/frontend/src/modules/identity/auth.ts index dcc9a678..d6217029 100644 --- a/frontend/src/modules/identity/auth.ts +++ b/frontend/src/modules/identity/auth.ts @@ -3,7 +3,8 @@ import { Pool } from "pg"; import { buildAuthOptions, type AdminUserAuthorizer } from "./auth-factory.ts"; import { - readIdentityConfig, + isSelfHostedIdentityEnabled, + readSelfHostedIdentityConfig, type SelfHostedIdentityConfig, } from "./config.ts"; import type { EmailOtpSender } from "./contracts.ts"; @@ -110,10 +111,10 @@ const identityGlobal = globalThis as typeof globalThis & { export function getIdentityAuthServices( env: NodeJS.ProcessEnv = process.env, ): IdentityAuthServices { - const config = readIdentityConfig(env); - if (config.provider !== "self-hosted") { + if (!isSelfHostedIdentityEnabled(env)) { throw new Error("self-hosted identity is not enabled"); } + const config = readSelfHostedIdentityConfig(env); identityGlobal.jyotishaIdentityAuth ??= createIdentityAuthServices(config); return identityGlobal.jyotishaIdentityAuth; diff --git a/frontend/src/modules/identity/config.ts b/frontend/src/modules/identity/config.ts index 303de958..ca1eae26 100644 --- a/frontend/src/modules/identity/config.ts +++ b/frontend/src/modules/identity/config.ts @@ -19,6 +19,16 @@ export type IdentityConfig = | SupabaseIdentityConfig | SelfHostedIdentityConfig; +export function isSelfHostedIdentityEnabled( + env: IdentityEnvironment, +): boolean { + const value = env.SELF_HOSTED_IDENTITY_ENABLED?.trim() || "false"; + if (value !== "true" && value !== "false") { + throw new Error("SELF_HOSTED_IDENTITY_ENABLED must be true or false"); + } + return value === "true"; +} + function required(env: IdentityEnvironment, key: string): string { const value = env[key]?.trim(); if (!value) throw new Error(`${key} is required`); @@ -81,15 +91,9 @@ function readSender(env: IdentityEnvironment): string { return value; } -export function readIdentityConfig( +export function readSelfHostedIdentityConfig( env: IdentityEnvironment, -): IdentityConfig { - const provider = env.AUTH_PROVIDER?.trim() || "supabase"; - if (provider === "supabase") return { provider }; - if (provider !== "self-hosted") { - throw new Error("AUTH_PROVIDER must be supabase or self-hosted"); - } - +): SelfHostedIdentityConfig { const userOrigin = readOrigin(env, "AUTH_USER_ORIGIN"); const adminOrigin = readOrigin(env, "AUTH_ADMIN_ORIGIN"); if (userOrigin === adminOrigin) { @@ -103,7 +107,7 @@ export function readIdentityConfig( } return { - provider, + provider: "self-hosted", databaseUrl: readPostgresUrl(env), userOrigin, adminOrigin, @@ -113,3 +117,19 @@ export function readIdentityConfig( resendFrom: readSender(env), }; } + +export function readIdentityConfig( + env: IdentityEnvironment, +): IdentityConfig { + const provider = env.AUTH_PROVIDER?.trim() || "supabase"; + if (provider === "supabase") return { provider }; + if (provider !== "self-hosted") { + throw new Error("AUTH_PROVIDER must be supabase or self-hosted"); + } + if (!isSelfHostedIdentityEnabled(env)) { + throw new Error( + "SELF_HOSTED_IDENTITY_ENABLED must be true when AUTH_PROVIDER is self-hosted", + ); + } + return readSelfHostedIdentityConfig(env); +} diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index 5eb6ac80..6a2a48d4 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -64,6 +64,10 @@ test("server compose accepts staging paths while preserving production defaults" compose, /SITE_ADDRESS: \$\{SITE_ADDRESS:-https:\/\/jyotisha\.chat\}/, ); + assert.match( + compose, + /ADMIN_SITE_ADDRESS: \$\{ADMIN_SITE_ADDRESS:-https:\/\/admin\.staging\.jyotisha\.chat\}/, + ); }); test("server compose defaults to local images without removing either build", () => { @@ -112,14 +116,20 @@ test("server compose defaults to local images without removing either build", () } }); -test("staging Caddy configuration serves only the configured staging address", () => { +test("staging Caddy isolates the public and identity-only admin hosts", () => { const caddy = readFileSync( new URL("../../deploy/Caddyfile.staging", import.meta.url), "utf8", ); assert.match(caddy, /\{\$SITE_ADDRESS:https:\/\/staging\.jyotisha\.chat\}/); + assert.match( + caddy, + /\{\$ADMIN_SITE_ADDRESS:https:\/\/admin\.staging\.jyotisha\.chat\}/, + ); assert.match(caddy, /reverse_proxy web:3000/); + assert.match(caddy, /@identity path \/login \/api\/auth\/\*/); + assert.match(caddy, /respond "Not found" 404/); assert.doesNotMatch(caddy, /www\.jyotisha\.chat/); }); @@ -183,6 +193,16 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi "APP_ENV_FILE=../.env.staging", "CADDYFILE_PATH=./Caddyfile.staging", "SITE_ADDRESS=https://staging.jyotisha.chat", + "ADMIN_SITE_ADDRESS=https://admin.staging.jyotisha.chat", + "AUTH_PROVIDER=supabase", + "SELF_HOSTED_IDENTITY_ENABLED=true", + "AUTH_USER_ORIGIN=https://staging.jyotisha.chat", + "AUTH_ADMIN_ORIGIN=https://admin.staging.jyotisha.chat", + "IDENTITY_DATABASE_URL=postgresql://identity_runtime:identity-runtime-test-password@postgres:5432/jyotisha", + "BETTER_AUTH_USER_SECRET=user-secret-that-is-at-least-32-bytes-long", + "BETTER_AUTH_ADMIN_SECRET=admin-secret-that-is-at-least-32-bytes-long", + "RESEND_API_KEY=re_test_key_that_must_not_be_printed", + "RESEND_FROM_EMAIL=Jyotisha Staging ", ]; const run = () => spawnSync("bash", [validator, envFile], { encoding: "utf8" }); @@ -262,6 +282,26 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi writeEnv([...validSelectors, "SITE_ADDRESS"]); assert.notEqual(run().status, 0); + writeEnv( + validSelectors.map((line) => + line.startsWith("AUTH_PROVIDER=") + ? "AUTH_PROVIDER=self-hosted" + : line, + ), + ); + assert.notEqual(run().status, 0); + + writeEnv([ + ...validSelectors, + "BETTER_AUTH_USER_SECRET=duplicate-secret-that-must-not-be-printed", + ]); + const duplicateSecret = run(); + assert.notEqual(duplicateSecret.status, 0); + assert.doesNotMatch( + `${duplicateSecret.stdout}${duplicateSecret.stderr}`, + /duplicate-secret-that-must-not-be-printed|re_test_key_that_must_not_be_printed/, + ); + writeEnv(validSelectors, 0o644); assert.notEqual(run().status, 0); } finally { diff --git a/frontend/tests/identity-config.test.ts b/frontend/tests/identity-config.test.ts index 9a7585f8..60f049c3 100644 --- a/frontend/tests/identity-config.test.ts +++ b/frontend/tests/identity-config.test.ts @@ -1,10 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { readIdentityConfig } from "../src/modules/identity/config.ts"; +import { + isSelfHostedIdentityEnabled, + readIdentityConfig, + readSelfHostedIdentityConfig, +} from "../src/modules/identity/config.ts"; const selfHostedEnvironment = { AUTH_PROVIDER: "self-hosted", + SELF_HOSTED_IDENTITY_ENABLED: "true", IDENTITY_DATABASE_URL: "postgresql://identity_runtime:test-password@postgres:5432/jyotisha?options=-csearch_path%3Didentity", AUTH_USER_ORIGIN: "https://staging.jyotisha.chat", @@ -17,6 +22,21 @@ const selfHostedEnvironment = { test("identity provider defaults to supabase without self-hosted settings", () => { assert.deepEqual(readIdentityConfig({}), { provider: "supabase" }); + assert.equal(isSelfHostedIdentityEnabled({}), false); +}); + +test("self-hosted identity can be enabled alongside the Supabase default", () => { + const environment = { + ...selfHostedEnvironment, + AUTH_PROVIDER: "supabase", + }; + + assert.deepEqual(readIdentityConfig(environment), { provider: "supabase" }); + assert.equal(isSelfHostedIdentityEnabled(environment), true); + assert.equal( + readSelfHostedIdentityConfig(environment).databaseUrl, + selfHostedEnvironment.IDENTITY_DATABASE_URL, + ); }); test("identity config accepts a complete self-hosted environment", () => { @@ -38,6 +58,21 @@ test("identity config rejects unknown providers", () => { ); }); +test("self-hosted provider requires its independent service flag", () => { + assert.throws( + () => + readIdentityConfig({ + ...selfHostedEnvironment, + SELF_HOSTED_IDENTITY_ENABLED: "false", + }), + /SELF_HOSTED_IDENTITY_ENABLED must be true/, + ); + assert.throws( + () => isSelfHostedIdentityEnabled({ SELF_HOSTED_IDENTITY_ENABLED: "yes" }), + /must be true or false/, + ); +}); + test("self-hosted identity reports missing keys without leaking configured secrets", () => { const secret = "this-secret-must-never-appear-in-an-error"; diff --git a/frontend/tests/identity-login-provider.test.ts b/frontend/tests/identity-login-provider.test.ts index 0d69aeb1..c7e6c40c 100644 --- a/frontend/tests/identity-login-provider.test.ts +++ b/frontend/tests/identity-login-provider.test.ts @@ -12,7 +12,10 @@ test("login page selects the auth provider from server-only validated config", ( assert.doesNotMatch(page, /["']use client["']/); assert.match(page, /readIdentityConfig\(process\.env\)/); - assert.match(page, /provider=\{config\.provider\}/); + assert.match(page, /isSelfHostedIdentityEnabled\(process\.env\)/); + assert.match(page, /resolveIdentitySurface/); + assert.match(page, /surface === "admin"/); + assert.match(page, /provider=\{provider\}/); assert.doesNotMatch(page, /NEXT_PUBLIC_AUTH_PROVIDER/); });