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 49a40b74..e0328d92 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -167,8 +167,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 9c1521a0..d03f0c2f 100644 --- a/deploy/docker-compose.server.yml +++ b/deploy/docker-compose.server.yml @@ -53,6 +53,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/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6d1005d9..8d7c88b0 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -218,7 +218,55 @@ - 复发自:无 - 修复版本:待提交(本地可测) -## BUG-012 | 分钟校正 safeguards PR 与主线证据契约分叉 +## BUG-012 | 自动生时流程重构后再次直出实现层错误 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:生时校正自动出题与评分轮询、`use-birth-time-automatic-journey-effects` +- 用户现象:自动流程失败时可能再次显示底层异常原文,而不是稳定、可操作的中文提示。 +- 触发条件:自动出题或评分轮询 Promise 进入异常分支。 +- 根因:出生时间流程重构保留了 automatic effect 中直接读取 `caught.message` 的旧分支;原回归测试后来只检查 guided hook,因此没有锁定 automatic hook 的两个入口。 +- 修复:automatic effect 的出题与轮询异常统一经过 `birthTimeUserError`;回归测试同时检查 guided 与 automatic 两个 hook,并禁止两种直接展示 `caught.message` 的写法。 +- 验证:`frontend/tests/birth-time-user-errors.test.ts`。 +- 防复发:错误归一化测试按入口文件验证安全不变量,不再依赖单文件精确调用次数。 +- 相关记录:BUG-003 +- 复发自:BUG-003 +- 修复版本:待提交(PR #25) + +## BUG-013 | 精简生时校正文案后真实 Chromium 测试等待已删除标题 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:`frontend/tests/conversational-rectification-component.test.ts`、前端完整质量门禁 +- 用户现象:页面已经正确渲染候选时间、经历和输入控件,但测试持续等待并最终报 `Timed out waiting for async initial turn`。 +- 触发条件:运行真实 Chromium 390px 组件回归测试,并把 active turn 注入精简后的生时校正界面。 +- 根因:产品把重复的“当前判断”叙事改成“已记录”行动文案后,浏览器测试仍以旧标题作为异步渲染完成信号。 +- 修复:测试改为等待候选代表时间与已记录经历两个稳定结构信号,不再绑定可变标题文案。 +- 验证:`frontend/tests/conversational-rectification-component.test.ts`。 +- 防复发:真实浏览器等待条件优先绑定语义结构和状态数据,不用已批准可调整的展示标题充当加载边界。 +- 相关记录:BUG-004 +- 复发自:无 +- 修复版本:待提交(PR #25) + +## BUG-014 | 能力审计新增案例验证主题后质量门禁仍断言旧主题集合 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:`tests/test_api_server_security.py`、Python quick quality gate +- 用户现象:能力审计正确返回 `Case Validation` 并将案例验证评为可产品化,但质量门禁仍按旧主题集合和 `thin` 等级失败。 +- 触发条件:运行能力审计测试,且应用可见主题已包含案例验证入口。 +- 根因:案例验证能力进入 `_app_visible_topics` 后,对应主题集合和 UX 等级断言都没有同步更新。 +- 修复:把 `Case Validation` 纳入预期可见主题,并把 `case_validator` 从 `thin` 队列移入 `excellent` 断言,保持测试与同一审计规则一致。 +- 验证:`tests/test_api_server_security.py::test_capability_audit_scans_registry_and_local_sources`;Python quick quality gate。 +- 防复发:扩展应用可见主题时必须同时更新能力审计契约测试;精确集合断言继续用于发现意外增删。 +- 相关记录:无 +- 复发自:无 +- 修复版本:待提交(PR #25) + +## BUG-015 | 分钟校正 safeguards PR 与主线证据契约分叉 - 状态:resolved - 首次发现:2026-07-22 @@ -230,6 +278,6 @@ - 修复:以主线为准新增兼容的输入指纹、邻近分钟探针和语义哈希;新增 v4 独立审核、日级事件、假分钟承诺门禁及非生产 intake;不恢复旧自动评估循环。 - 验证:分钟校正聚焦回归、脚本直接执行检查、Ruff、Python compilation 和 quick quality gate。 - 防复发:新 safeguards 必须以当前 schema 向前升级;候选身份必须继承产品计算默认;PR 验收必须包含 workflow 实际执行的 quick quality gate。 -- 相关记录:BUG-009、ERR-045、ERR-053、ERR-086 +- 相关记录:BUG-009、BUG-014、ERR-045、ERR-053、ERR-086 - 复发自:无 -- 修复版本:待提交(本地可测) +- 修复版本:d7d9703 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/docs/superpowers/plans/2026-07-21-self-hosted-identity.md b/docs/superpowers/plans/2026-07-21-self-hosted-identity.md new file mode 100644 index 00000000..aaa37b31 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-self-hosted-identity.md @@ -0,0 +1,228 @@ +# Self-Hosted Identity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a staging-ready, self-hosted email-OTP identity service backed by local PostgreSQL and Resend, while keeping the current Supabase identity path as the default until business data migration is complete. + +**Architecture:** Better Auth is isolated behind a small identity boundary and uses only `IDENTITY_DATABASE_URL`. User and admin surfaces share identity records but use different cookie namespaces, allowed hosts, and authorization rules. A host-aware Next.js route exposes Better Auth only on the configured user/admin hosts. Existing Supabase callers remain unchanged in this milestone; switching the application-wide provider is a later, explicit migration step. + +**Tech Stack:** Next.js 16 App Router, Better Auth 1.6.23, PostgreSQL 17, `pg`, Resend HTTP API, Node test runner via `tsx`. + +## Global Constraints + +- Keep `AUTH_PROVIDER=supabase` as the default and reject unknown provider values. +- Never expose a self-hosted session to an existing Supabase-backed business route as though it were a Supabase JWT. +- Use separate host-only cookie prefixes for user and admin surfaces; do not set a shared cookie `Domain`. +- Create all identity objects under the `identity` schema and grant access only to `identity_runtime` and `admin_runtime` as required. +- OTP values, API keys, database URLs, and raw email delivery responses must not be logged. +- CI and local tests use a fake mail sender. Real Resend calls occur only when an explicit API key and verified sender are configured. +- Every implementation task follows RED → GREEN → refactor and runs the smallest relevant test before the broader suite. +- Preserve the Supabase-exit boundaries in `docs/superpowers/specs/2026-07-20-supabase-exit-backend-design.md`; business modules, admin UI, and final cutover are outside this milestone. + +--- + +## Task 1: Pin Better Auth and define identity configuration + +**Files:** + +- Modify: `frontend/package.json` +- Modify: `frontend/package-lock.json` +- Create: `frontend/src/modules/identity/config.ts` +- Create: `frontend/src/modules/identity/contracts.ts` +- Test: `frontend/tests/identity-config.test.ts` + +**Interfaces:** + +```ts +export type IdentitySurface = "user" | "admin"; + +export interface IdentityConfig { + provider: "supabase" | "self-hosted"; + databaseUrl: string; + userOrigin: string; + adminOrigin: string; + userSecret: string; + adminSecret: string; + resendApiKey: string; + resendFrom: string; +} + +export interface EmailOtpMessage { + email: string; + otp: string; + type: "sign-in" | "email-verification" | "forget-password"; + idempotencyKey: string; +} + +export interface EmailOtpSender { + send(message: EmailOtpMessage): Promise; +} +``` + +**Steps:** + +1. Add tests that prove the provider defaults to `supabase`, `self-hosted` requires every identity/Resend setting, URLs must be HTTPS outside localhost, secrets must meet the configured minimum length, and malformed/unknown values fail without printing secret contents. +2. Run `npm test --prefix frontend -- identity-config.test.ts` and confirm failure because the configuration module does not exist. +3. Implement the typed parser with explicit environment injection and safe error messages. +4. Install the exact dependency with `npm install --prefix frontend better-auth@1.6.23` and verify the lockfile pins the intended version. +5. Rerun the focused test, then run `npm run lint --prefix frontend -- frontend/src/modules/identity frontend/tests/identity-config.test.ts`. +6. Commit as `feat(identity): define self-hosted identity configuration`. + +## Task 2: Create least-privilege identity database objects + +**Files:** + +- Create: `frontend/db/migrations/20260721000100_self_hosted_identity.sql` +- Create: `frontend/tests/database-self-hosted-identity.test.ts` +- Modify: `frontend/tests/database-postgres-test-helper.ts` if the existing helper needs a schema query utility + +**Database objects:** + +- `identity.users`: UUID primary key, canonical unique email, display name/image, verified timestamp, admin role/ban fields, created/updated timestamps. +- `identity.sessions`: UUID primary key, opaque unique token, user foreign key with cascade delete, expiry, IP/user-agent, impersonation metadata, timestamps. +- `identity.accounts`: UUID primary key, provider/account identity, user foreign key, credential/token columns required by Better Auth, timestamps, unique provider/account pair. +- `identity.verifications`: UUID primary key, identifier/value, expiry, timestamps, lookup index. +- `identity.otp_rate_limits`: normalized email plus IP hash, window timestamps and attempt counters, with no plaintext OTP storage. + +**Steps:** + +1. Add a PostgreSQL integration test that runs the foundation and identity migrations from a clean database and asserts UUID defaults, foreign keys, canonical email uniqueness, indexes, schema ownership, and grants. +2. Prove `app_runtime` cannot read identity tables, `identity_runtime` can perform only identity operations, and `admin_runtime` has the documented administrative access. +3. Run `npm test --prefix frontend -- database-self-hosted-identity.test.ts` and confirm failure because the migration is absent. +4. Add the idempotent SQL migration with explicit grants and revoked public access. +5. Run the focused database test twice against the same database to prove migration idempotency, then run `npm run db:migrate:check --prefix frontend`. +6. Commit as `feat(identity): add local postgres identity schema`. + +## Task 3: Implement fake and Resend OTP mail adapters + +**Files:** + +- Create: `frontend/src/modules/identity/email/fake-email-otp-sender.ts` +- Create: `frontend/src/modules/identity/email/resend-email-otp-sender.ts` +- Test: `frontend/tests/identity-email-sender.test.ts` + +**Steps:** + +1. Test the fake sender captures messages without network access. +2. Test the Resend adapter sends `POST https://api.resend.com/emails` with bearer authorization, `User-Agent`, JSON content, and `Idempotency-Key`; inject `fetch` so tests never contact Resend. +3. Test non-2xx responses throw a generic delivery error that excludes the API key, OTP, recipient, and raw provider response. +4. Run the focused test and confirm failure because the adapters do not exist. +5. Implement the minimum adapters and a small escaped HTML/plain-text OTP template. +6. Rerun the focused tests and lint the new files. +7. Commit as `feat(identity): add resend otp delivery adapter`. + +## Task 4: Build Better Auth user/admin instances + +**Files:** + +- Create: `frontend/src/modules/identity/auth-factory.ts` +- Create: `frontend/src/modules/identity/auth.ts` +- Create: `frontend/src/modules/identity/model.ts` +- Test: `frontend/tests/identity-auth-factory.test.ts` + +**Steps:** + +1. Add tests around an injectable factory proving it selects the `identity` schema, maps Better Auth models/fields to the migration, generates UUIDs, hashes stored OTPs, uses six-digit five-minute OTPs, rotates resend codes, and caps attempts at three. +2. Add tests proving user/admin instances use distinct secrets and cookie prefixes (`jyotisha-user` and `jyotisha-admin`) with `Secure`, `HttpOnly`, and `SameSite=Lax`, and do not emit a cookie domain. +3. Add an admin authorization hook that refuses admin-surface session creation unless the persisted role includes `admin`; test ordinary users remain able to use the user surface. +4. Run `npm test --prefix frontend -- identity-auth-factory.test.ts` and confirm failure because the factory is absent. +5. Implement the factory with `better-auth`, the email OTP plugin, the admin plugin, an injected `pg.Pool`, and an injected `EmailOtpSender`. +6. Rerun focused tests and `npx tsc --noEmit -p frontend/tsconfig.json`. +7. Commit as `feat(identity): configure better auth surfaces`. + +## Task 5: Add host-isolated auth routing and session DAL + +**Files:** + +- Create: `frontend/src/modules/identity/host.ts` +- Create: `frontend/src/modules/identity/session.ts` +- Create: `frontend/src/app/api/auth/[...all]/route.ts` +- Test: `frontend/tests/identity-host-routing.test.ts` +- Test: `frontend/tests/identity-session.test.ts` + +**Steps:** + +1. Test exact, port-normalized matching for configured user/admin hosts; reject unknown, suffix-confused, missing, and malformed hosts. +2. Test the route dispatches to only the matching handler and returns `421` before reading or issuing cookies on unknown hosts. +3. Test `getIdentitySession`, `requireIdentityUser`, and `requireIdentityAdmin` return narrow DTOs and perform server-side role checks; cookie presence alone must never authorize. +4. Run focused tests and confirm failure because routing/DAL modules are absent. +5. Implement `toNextJsHandler` dispatch and session helpers using awaited Next.js `headers()` at the boundary. +6. Rerun focused tests, TypeScript, and lint. +7. Commit as `feat(identity): isolate auth routes by host`. + +## Task 6: Add a gated self-hosted OTP client without switching the app + +**Files:** + +- Create: `frontend/src/modules/identity/client.ts` +- Create: `frontend/src/components/self-hosted-login-form.tsx` +- Modify: `frontend/src/app/login/page.tsx` +- Test: `frontend/tests/identity-login-provider.test.ts` + +**Steps:** + +1. Add tests proving the existing Supabase login renders when the provider is absent/default and the Better Auth form renders only when the validated server configuration explicitly selects `self-hosted`. +2. Test send/verify flows use Better Auth email OTP endpoints, preserve generic account-enumeration-safe messages, prevent double submission, and never store OTP/session tokens in local storage. +3. Run focused tests and confirm failure because the self-hosted client/form are absent. +4. Implement the Better Auth browser client with `emailOTPClient` and the gated form. +5. Add an explicit warning in code/docs that `AUTH_PROVIDER=self-hosted` is integration-only until business modules stop relying on Supabase JWT/RLS. +6. Rerun focused tests, TypeScript, and lint. +7. Commit as `feat(identity): add gated self-hosted otp login`. + +## Task 7: Add deterministic Supabase-auth user import tooling + +**Files:** + +- Create: `frontend/scripts/import-supabase-auth-users.mjs` +- Create: `frontend/tests/fixtures/supabase-auth-users.json` +- Create: `frontend/tests/identity-user-import.test.ts` + +**Steps:** + +1. Add tests that preserve source UUID, normalized email, verification timestamp, created/updated timestamps, and display metadata. +2. Test dry-run is the default, apply requires an explicit flag and `IDENTITY_DATABASE_URL`, duplicate canonical emails abort the entire import, reruns are idempotent, and sessions/JWTs/password hashes/provider secrets are ignored. +3. Run the focused test and confirm failure because the importer is absent. +4. Implement streaming JSON parsing for the supported export shape, one transaction, deterministic upserts, and a summary containing counts but no emails. +5. Rerun focused and database tests. +6. Commit as `feat(identity): add auth user import tool`. + +## Task 8: Wire staging configuration, quality gates, and operator documentation + +**Files:** + +- Modify: `frontend/scripts/validate-database-env.mjs` +- Modify: `frontend/tests/database-env-validator.test.ts` +- Modify: `frontend/scripts/backend-quality-gate.mjs` +- Modify: `.github/workflows/staging-deploy.yml` +- Modify: `deploy/staging/Caddyfile` +- Modify: `deploy/staging/.env.staging.example` +- Modify: `docs/operations/staging-backend.md` +- Create: `docs/operations/self-hosted-identity.md` +- Test: `frontend/tests/staging-backend-workflows.test.ts` + +**Steps:** + +1. Add failing tests for the new identity/Resend variables, redacted validation output, exact admin host routing, and required identity tests in the backend gate. +2. Extend the validator and staging example with `AUTH_PROVIDER`, identity database URL, user/admin origins and secrets, and Resend settings. Document safe generation commands and verified-sender requirements without example secrets. +3. Update Caddy so `admin.staging.jyotisha.chat` can reach `/api/auth/**` and the gated login while other admin paths remain closed until the admin UI milestone. +4. Ensure deployment preflight refuses `AUTH_PROVIDER=self-hosted` unless identity migration, host separation, and Resend configuration validate; keep staging’s checked-in default as `supabase`. +5. Document smoke tests, rollback to Supabase provider, import dry-run/apply, session revocation, secret rotation, and the fact that business data remains on Supabase in this milestone. +6. Run deployment tests, database tests, and the backend quality gate. +7. Commit as `chore(identity): wire staging identity operations`. + +## Task 9: Final verification and review + +**Files:** + +- Review every file changed since `origin/main`. + +**Steps:** + +1. Run `npm test --prefix frontend`. +2. Run `npm run lint --prefix frontend`. +3. Run `npx tsc --noEmit -p frontend/tsconfig.json`. +4. Run `npm run build --prefix frontend` using a non-secret build-safe environment. +5. Run `/opt/anaconda3/bin/python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` and record any remote visibility limitation accurately. +6. Search the diff for secrets, database URLs, OTP logging, permissive cookie domains, placeholder text, and accidental Supabase-default changes. +7. Perform a code review against `origin/main`, fix all high/medium findings, rerun the affected gates, and run `git diff --check`. +8. Push `codex/self-hosted-identity`, open a PR targeting `main`, and report exact checks plus the deliberate non-cutover status. diff --git a/frontend/db/migrations/20260721000100_self_hosted_identity.sql b/frontend/db/migrations/20260721000100_self_hosted_identity.sql new file mode 100644 index 00000000..6ce78ddc --- /dev/null +++ b/frontend/db/migrations/20260721000100_self_hosted_identity.sql @@ -0,0 +1,99 @@ +create table if not exists identity.users ( + id uuid primary key default gen_random_uuid(), + name text not null, + email text not null, + email_verified boolean not null default false, + email_verified_at timestamptz, + image text, + role text not null default 'user', + banned boolean not null default false, + ban_reason text, + ban_expires timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists identity_users_email_canonical_key + on identity.users (lower(btrim(email))); + +create table if not exists identity.sessions ( + id uuid primary key default gen_random_uuid(), + token text not null unique, + user_id uuid not null references identity.users(id) on delete cascade, + expires_at timestamptz not null, + ip_address text, + user_agent text, + impersonated_by uuid references identity.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists identity_sessions_user_id_idx + on identity.sessions (user_id); +create index if not exists identity_sessions_expires_at_idx + on identity.sessions (expires_at); + +create table if not exists identity.accounts ( + id uuid primary key default gen_random_uuid(), + account_id text not null, + provider_id text not null, + user_id uuid not null references identity.users(id) on delete cascade, + access_token text, + refresh_token text, + id_token text, + access_token_expires_at timestamptz, + refresh_token_expires_at timestamptz, + scope text, + password text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (provider_id, account_id) +); + +create index if not exists identity_accounts_user_id_idx + on identity.accounts (user_id); + +create table if not exists identity.verifications ( + id uuid primary key default gen_random_uuid(), + identifier text not null, + value text not null, + expires_at timestamptz not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists identity_verifications_identifier_idx + on identity.verifications (identifier); +create index if not exists identity_verifications_expires_at_idx + on identity.verifications (expires_at); + +create table if not exists identity.otp_rate_limits ( + id uuid primary key default gen_random_uuid(), + key text not null unique, + count integer not null default 0 check (count >= 0), + last_request bigint not null +); + +revoke all on table + identity.users, + identity.sessions, + identity.accounts, + identity.verifications, + identity.otp_rate_limits +from public, app_runtime, backup_reader, migration_runner; + +grant select, insert, update, delete on table + identity.users, + identity.sessions, + identity.accounts, + identity.verifications, + identity.otp_rate_limits +to identity_runtime; + +grant select on table + identity.users, + identity.sessions, + identity.accounts, + identity.verifications, + identity.otp_rate_limits +to admin_runtime; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5fc738e0..052b5920 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@supabase/ssr": "^0.12.3", "@supabase/supabase-js": "^2.110.5", "@tailwindcss/postcss": "^4.3.2", + "better-auth": "1.6.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", @@ -581,6 +582,33 @@ } } }, + "node_modules/@better-auth/utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-auth/utils/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.1.tgz", + "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==", + "license": "MIT" + }, "node_modules/@date-fns/tz": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", @@ -2306,6 +2334,15 @@ "node": ">=12.4.0" } }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@posthog/core": { "version": "1.40.2", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.40.2.tgz", @@ -4085,6 +4122,282 @@ "node": ">=6.0.0" } }, + "node_modules/better-auth": { + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.23.tgz", + "integrity": "sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.23", + "@better-auth/drizzle-adapter": "1.6.23", + "@better-auth/kysely-adapter": "1.6.23", + "@better-auth/memory-adapter": "1.6.23", + "@better-auth/mongo-adapter": "1.6.23", + "@better-auth/prisma-adapter": "1.6.23", + "@better-auth/telemetry": "1.6.23", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "better-call": "1.3.7", + "defu": "^6.1.4", + "jose": "^6.1.3", + "kysely": "^0.28.17 || ^0.29.0", + "nanostores": "^1.1.1", + "zod": "^4.3.6" + }, + "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.45.2", + "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 + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/core": { + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.23.tgz", + "integrity": "sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.3.7", + "jose": "^6.1.0", + "kysely": "^0.28.5 || ^0.29.0", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/drizzle-adapter": { + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.23.tgz", + "integrity": "sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2", + "drizzle-orm": "^0.45.2" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/kysely-adapter": { + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.23.tgz", + "integrity": "sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2", + "kysely": "^0.28.17 || ^0.29.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/memory-adapter": { + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.23.tgz", + "integrity": "sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2" + } + }, + "node_modules/better-auth/node_modules/@better-auth/mongo-adapter": { + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.23.tgz", + "integrity": "sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/prisma-adapter": { + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.23.tgz", + "integrity": "sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2", + "@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 + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/telemetry": { + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.23.tgz", + "integrity": "sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1" + } + }, + "node_modules/better-auth/node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/better-auth/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/better-auth/node_modules/better-call": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.7.tgz", + "integrity": "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.4.0", + "@better-fetch/fetch": "^1.1.21", + "rou3": "^0.7.12", + "set-cookie-parser": "^3.0.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -4681,6 +4994,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -7275,6 +7594,15 @@ "node": ">=0.10.0" } }, + "node_modules/kysely": { + "version": "0.29.4", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.4.tgz", + "integrity": "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -8625,6 +8953,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.4.1.tgz", + "integrity": "sha512-PGd3uPojJB9Z07d5NX3Db/SOSBbyy3wLMUGq0GpnEEJfVzY9mq7daPMAZ3jObV5D3Jn+YKND636eI5ULg7F80Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -9714,6 +10057,12 @@ "node": ">=0.10.0" } }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -9895,6 +10244,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 868d0f07..090aca40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@supabase/ssr": "^0.12.3", "@supabase/supabase-js": "^2.110.5", "@tailwindcss/postcss": "^4.3.2", + "better-auth": "1.6.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", diff --git a/frontend/scripts/import-supabase-auth-users.mjs b/frontend/scripts/import-supabase-auth-users.mjs new file mode 100644 index 00000000..06afbcc9 --- /dev/null +++ b/frontend/scripts/import-supabase-auth-users.mjs @@ -0,0 +1,195 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { Pool } from "pg"; + +class SafeImportError extends Error {} + +function requiredDate(value, field) { + const date = new Date(value); + if (!value || !Number.isFinite(date.getTime())) { + throw new SafeImportError(`source contains an invalid ${field}`); + } + return date; +} + +function optionalDate(value, field) { + if (value === null || value === undefined || value === "") return null; + return requiredDate(value, field); +} + +function metadataValue(metadata, key) { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + return null; + } + const value = metadata[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function normalizeSupabaseUsers(source) { + if (!Array.isArray(source)) { + throw new SafeImportError("source must be a JSON array of auth users"); + } + + const emails = new Set(); + return source.map((record) => { + if (!record || typeof record !== "object" || Array.isArray(record)) { + throw new SafeImportError("source contains an invalid auth user"); + } + + const id = typeof record.id === "string" ? record.id.trim().toLowerCase() : ""; + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) { + throw new SafeImportError("source contains an invalid user id"); + } + const email = + typeof record.email === "string" ? record.email.trim().toLowerCase() : ""; + if (!/^[^\s@]+@[^\s@]+$/.test(email)) { + throw new SafeImportError("source contains an invalid user email"); + } + if (emails.has(email)) { + throw new SafeImportError("source contains duplicate canonical emails"); + } + emails.add(email); + + const emailVerifiedAt = optionalDate( + record.email_confirmed_at, + "email confirmation timestamp", + ); + const metadata = record.raw_user_meta_data; + const name = + metadataValue(metadata, "full_name") ?? + metadataValue(metadata, "name") ?? + email.slice(0, email.indexOf("@")); + + return { + id, + email, + emailVerified: emailVerifiedAt !== null, + emailVerifiedAt, + name, + image: + metadataValue(metadata, "avatar_url") ?? + metadataValue(metadata, "picture"), + createdAt: requiredDate(record.created_at, "creation timestamp"), + updatedAt: requiredDate(record.updated_at, "update timestamp"), + }; + }); +} + +export async function applyIdentityUsers(client, users) { + await client.query("BEGIN"); + try { + for (const user of users) { + await client.query( + ` + insert into identity.users ( + id, name, email, email_verified, email_verified_at, image, + created_at, updated_at + ) values ($1, $2, $3, $4, $5, $6, $7, $8) + on conflict (id) do update set + name = excluded.name, + email = excluded.email, + email_verified = excluded.email_verified, + email_verified_at = excluded.email_verified_at, + image = excluded.image, + created_at = excluded.created_at, + updated_at = excluded.updated_at + `, + [ + user.id, + user.name, + user.email, + user.emailVerified, + user.emailVerifiedAt, + user.image, + user.createdAt, + user.updatedAt, + ], + ); + } + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } +} + +function parseArguments(arguments_) { + const apply = arguments_.includes("--apply"); + const positional = arguments_.filter((argument) => argument !== "--apply"); + if (positional.length !== 1 || arguments_.some((argument) => argument.startsWith("--") && argument !== "--apply")) { + throw new SafeImportError( + "usage: node scripts/import-supabase-auth-users.mjs [--apply]", + ); + } + return { apply, sourcePath: positional[0] }; +} + +async function loadUsers(sourcePath) { + let contents; + try { + contents = await readFile(sourcePath, "utf8"); + } catch { + throw new SafeImportError("unable to read source file"); + } + + try { + return normalizeSupabaseUsers(JSON.parse(contents)); + } catch (error) { + if (error instanceof SafeImportError) throw error; + throw new SafeImportError("source file is not valid JSON"); + } +} + +export async function main(arguments_, env) { + const { apply, sourcePath } = parseArguments(arguments_); + const users = await loadUsers(sourcePath); + const summary = { + mode: apply ? "apply" : "dry-run", + users: users.length, + verified: users.filter((user) => user.emailVerified).length, + }; + + if (!apply) { + process.stdout.write(`${JSON.stringify(summary)}\n`); + return; + } + + const databaseUrl = env.IDENTITY_DATABASE_URL?.trim(); + if (!databaseUrl) { + throw new SafeImportError("IDENTITY_DATABASE_URL is required for --apply"); + } + if (!databaseUrl.startsWith("postgresql://")) { + throw new SafeImportError("IDENTITY_DATABASE_URL must be a PostgreSQL URL"); + } + + const pool = new Pool({ + connectionString: databaseUrl, + options: "-c search_path=identity,pg_catalog", + application_name: "jyotisha-identity-import", + max: 1, + }); + try { + const client = await pool.connect(); + try { + await applyIdentityUsers(client, users); + } finally { + client.release(); + } + } catch { + throw new SafeImportError("identity user import failed"); + } finally { + await pool.end(); + } + process.stdout.write(`${JSON.stringify(summary)}\n`); +} + +const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ""; +if (import.meta.url === invokedPath) { + main(process.argv.slice(2), process.env).catch((error) => { + const message = + error instanceof SafeImportError ? error.message : "identity user import failed"; + process.stderr.write(`${message}\n`); + process.exitCode = 1; + }); +} diff --git a/frontend/src/app/api/auth/[...all]/route.ts b/frontend/src/app/api/auth/[...all]/route.ts new file mode 100644 index 00000000..2a25646d --- /dev/null +++ b/frontend/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,38 @@ +import { toNextJsHandler } from "better-auth/next-js"; + +import { getIdentityAuthServices } from "@/modules/identity/auth"; +import { + isSelfHostedIdentityEnabled, + readSelfHostedIdentityConfig, +} from "@/modules/identity/config"; +import { + createHostIsolatedAuthHandlers, + type IdentityAuthHandlers, +} from "@/modules/identity/host"; + +export const dynamic = "force-dynamic"; + +async function dispatch( + method: keyof IdentityAuthHandlers, + request: Request, +): Promise { + if (!isSelfHostedIdentityEnabled(process.env)) { + return new Response("Not found", { status: 404 }); + } + const config = readSelfHostedIdentityConfig(process.env); + + const services = getIdentityAuthServices(); + const handlers = createHostIsolatedAuthHandlers(config, { + user: toNextJsHandler(services.user), + admin: toNextJsHandler(services.admin), + }); + return handlers[method](request); +} + +export function GET(request: Request): Promise { + return dispatch("GET", request); +} + +export function POST(request: Request): Promise { + return dispatch("POST", request); +} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 0d092d76..6cc08837 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -1,118 +1,25 @@ -"use client"; +import { headers } from "next/headers"; -import Image from "next/image"; -import { FormEvent, useState } from "react"; -import { createBrowserSupabaseClient } from "@/lib/supabase/client"; +import { EmailOtpLogin } from "@/components/email-otp-login"; +import { + isSelfHostedIdentityEnabled, + readIdentityConfig, + readSelfHostedIdentityConfig, +} from "@/modules/identity/config"; +import { resolveIdentitySurface } from "@/modules/identity/host"; -function authMessage(caught: unknown) { - const message = caught instanceof Error ? caught.message : "暂时无法登录"; - const lower = message.toLowerCase(); - if (message.includes("Supabase") || message.includes("environment") || message.includes("URL")) return "Supabase 尚未配置"; - if (lower.includes("expired") || lower.includes("invalid")) return "验证码错误或已过期,请重新获取"; - if (lower.includes("rate limit")) return "发送过于频繁,请稍后再试"; - return message; -} - -export default function LoginPage() { - const [email, setEmail] = useState(""); - const [token, setToken] = useState(""); - const [sent, setSent] = useState(false); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [notice, setNotice] = useState(""); - - async function sendOtp(event?: FormEvent) { - event?.preventDefault(); - const normalizedEmail = email.trim(); - if (!normalizedEmail || busy) return; - setBusy(true); - setError(""); - setNotice(""); - try { - const { error: otpError } = await createBrowserSupabaseClient().auth.signInWithOtp({ - email: normalizedEmail, - options: { shouldCreateUser: true }, - }); - if (otpError) throw otpError; - setSent(true); - setNotice(`验证码已发送至 ${normalizedEmail}`); - } catch (caught) { - if (!(caught instanceof Error)) throw caught; - setError(authMessage(caught)); - } finally { - setBusy(false); - } - } - - async function verifyOtp(event: FormEvent) { - event.preventDefault(); - if (!token || busy) return; - setBusy(true); - setError(""); - try { - const { error: otpError } = await createBrowserSupabaseClient().auth.verifyOtp({ - email: email.trim(), - token, - type: "email", - }); - if (otpError) throw otpError; - window.location.assign("/"); - } catch (caught) { - if (!(caught instanceof Error)) throw caught; - setError(authMessage(caught)); - setBusy(false); - } - } - - function changeEmail() { - setSent(false); - setToken(""); - setError(""); - setNotice(""); - } - - return ( -
-
- - -
-
-

欢迎回来

-

邮箱验证码登录,新邮箱将自动创建账户。

- - {!sent ? ( -
- - { setEmail(event.target.value); setError(""); setNotice(""); }} placeholder="you@example.com" /> - -
- ) : ( -
- - { setToken(event.target.value.replace(/\D/g, "").slice(0, 6)); setError(""); }} /> - -
- - -
-
- )} - {error &&

{error}

} - {notice &&

{notice}

} -
-
-
- ); +export const dynamic = "force-dynamic"; + +export default async function LoginPage() { + const config = readIdentityConfig(process.env); + 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/components/email-otp-login.tsx b/frontend/src/components/email-otp-login.tsx new file mode 100644 index 00000000..a2688746 --- /dev/null +++ b/frontend/src/components/email-otp-login.tsx @@ -0,0 +1,209 @@ +"use client"; + +import Image from "next/image"; +import { FormEvent, useState } from "react"; + +import { createBrowserSupabaseClient } from "@/lib/supabase/client"; +import { selfHostedOtpActions } from "@/modules/identity/client"; + +type AuthProvider = "supabase" | "self-hosted"; + +function authMessage(caught: unknown) { + const message = caught instanceof Error ? caught.message : "暂时无法登录"; + const lower = message.toLowerCase(); + if ( + message.includes("Supabase") || + message.includes("environment") || + message.includes("URL") + ) + return "Supabase 尚未配置"; + if (lower.includes("expired") || lower.includes("invalid")) + return "验证码错误或已过期,请重新获取"; + if (lower.includes("rate limit")) return "发送过于频繁,请稍后再试"; + return message; +} + +export function EmailOtpLogin({ provider }: { provider: AuthProvider }) { + const [email, setEmail] = useState(""); + const [token, setToken] = useState(""); + const [sent, setSent] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + + async function sendOtp(event?: FormEvent) { + event?.preventDefault(); + const normalizedEmail = email.trim(); + if (!normalizedEmail || busy) return; + setBusy(true); + setError(""); + setNotice(""); + try { + if (provider === "self-hosted") { + await selfHostedOtpActions.send(normalizedEmail); + } else { + const { error: otpError } = + await createBrowserSupabaseClient().auth.signInWithOtp({ + email: normalizedEmail, + options: { shouldCreateUser: true }, + }); + if (otpError) throw otpError; + } + setSent(true); + setNotice(`验证码已发送至 ${normalizedEmail}`); + } catch (caught) { + if (!(caught instanceof Error)) throw caught; + setError(authMessage(caught)); + } finally { + setBusy(false); + } + } + + async function verifyOtp(event: FormEvent) { + event.preventDefault(); + if (!token || busy) return; + setBusy(true); + setError(""); + try { + if (provider === "self-hosted") { + await selfHostedOtpActions.verify(email, token); + } else { + const { error: otpError } = + await createBrowserSupabaseClient().auth.verifyOtp({ + email: email.trim(), + token, + type: "email", + }); + if (otpError) throw otpError; + } + window.location.assign("/"); + } catch (caught) { + if (!(caught instanceof Error)) throw caught; + setError(authMessage(caught)); + setBusy(false); + } + } + + function changeEmail() { + setSent(false); + setToken(""); + setError(""); + setNotice(""); + } + + return ( +
+
+ + +
+
+
+

欢迎回来

+

+ 邮箱验证码登录, + 新邮箱将自动创建账户。 +

+ + {!sent ? ( +
+ + { + setEmail(event.target.value); + setError(""); + setNotice(""); + }} + placeholder="you@example.com" + /> + +
+ ) : ( +
+ + { + setToken(event.target.value.replace(/\D/g, "").slice(0, 6)); + setError(""); + }} + /> + +
+ + +
+
+ )} + {error && ( +

+ {error} +

+ )} + {notice && ( +

+ {notice} +

+ )} +
+
+
+ ); +} diff --git a/frontend/src/hooks/use-birth-time-automatic-journey-effects.ts b/frontend/src/hooks/use-birth-time-automatic-journey-effects.ts index 6cb311b4..35b30b07 100644 --- a/frontend/src/hooks/use-birth-time-automatic-journey-effects.ts +++ b/frontend/src/hooks/use-birth-time-automatic-journey-effects.ts @@ -16,6 +16,7 @@ import { } from "@/lib/birth-time-guided-effect-coordinator"; import type { StableActionIdentityRegistry } from "@/lib/birth-time-guided-effect-coordinator"; import { runBirthTimeScoringPoll, scoringPollDelay } from "@/lib/birth-time-guided-polling"; +import { birthTimeUserError } from "@/lib/birth-time-user-error"; type AutomaticEffectsInput = { readonly journey: JourneyClientResponse | null; @@ -82,7 +83,7 @@ export function useBirthTimeAutomaticJourneyEffects(input: AutomaticEffectsInput if (publishCurrentJourney({ expected, current: latest.current, next, publish: onJourney })) latest.current = next; }).catch((caught: unknown) => { if (latest.current?.caseId === expected.caseId && latest.current.turnVersion === expected.turnVersion) { - setError(caught instanceof Error ? caught.message : "暂时无法生成下一题,请重试。"); + setError(birthTimeUserError(caught)); } }); }, [actionRegistry, generationIdentity, generationRequests, generationRun, latest, onJourney, preview, setError]); @@ -114,7 +115,7 @@ export function useBirthTimeAutomaticJourneyEffects(input: AutomaticEffectsInput latest.current = result.turn; if (result.kind === "exhausted") setError("评分仍在进行。你可以稍后继续,或重新检查状态。"); }).catch((caught: unknown) => { - if (!controller.signal.aborted) setError(caught instanceof Error ? caught.message : "暂时无法读取评分进度,请稍后重试。"); + if (!controller.signal.aborted) setError(birthTimeUserError(caught)); }); }); return () => { cancelStart(); controller.abort(); }; diff --git a/frontend/src/modules/identity/auth-factory.ts b/frontend/src/modules/identity/auth-factory.ts new file mode 100644 index 00000000..dbd9f8ec --- /dev/null +++ b/frontend/src/modules/identity/auth-factory.ts @@ -0,0 +1,129 @@ +import { createHmac } from "node:crypto"; +import type { Pool } from "pg"; +import { APIError, type BetterAuthOptions } from "better-auth"; +import { admin, emailOTP, type EmailOTPOptions } from "better-auth/plugins"; + +import type { SelfHostedIdentityConfig } from "./config.ts"; +import type { EmailOtpSender, IdentitySurface } from "./contracts.ts"; +import { identityModelMapping } from "./model.ts"; + +export type AdminUserAuthorizer = (userId: string) => Promise; + +interface BuildAuthOptionsInput { + surface: IdentitySurface; + config: SelfHostedIdentityConfig; + database: Pool; + emailSender: EmailOtpSender; + authorizeAdminUser?: AdminUserAuthorizer; +} + +function otpIdempotencyKey( + secret: string, + email: string, + otp: string, + type: string, +): string { + const digest = createHmac("sha256", secret) + .update(type) + .update("\0") + .update(email.trim().toLowerCase()) + .update("\0") + .update(otp) + .digest("hex"); + return `otp-${digest}`; +} + +export function createEmailOtpOptions( + sender: EmailOtpSender, + secret: string, + disableSignUp: boolean, +): EmailOTPOptions { + return { + otpLength: 6, + expiresIn: 300, + allowedAttempts: 3, + resendStrategy: "rotate", + storeOTP: "hashed", + disableSignUp, + rateLimit: { window: 60, max: 3 }, + async sendVerificationOTP({ email, otp, type }) { + await sender.send({ + email, + otp, + type, + idempotencyKey: otpIdempotencyKey(secret, email, otp, type), + }); + }, + }; +} + +export function buildAuthOptions({ + surface, + config, + database, + emailSender, + authorizeAdminUser, +}: BuildAuthOptionsInput): BetterAuthOptions { + if (surface === "admin" && !authorizeAdminUser) { + throw new Error("admin user authorizer is required"); + } + + const origin = surface === "user" ? config.userOrigin : config.adminOrigin; + const secret = surface === "user" ? config.userSecret : config.adminSecret; + + return { + appName: "Jyotisha", + baseURL: origin, + basePath: "/api/auth", + secret, + database, + trustedOrigins: [origin], + telemetry: { enabled: false }, + user: identityModelMapping.user, + session: identityModelMapping.session, + account: identityModelMapping.account, + verification: identityModelMapping.verification, + rateLimit: { + storage: "database", + window: 60, + max: 30, + ...identityModelMapping.rateLimit, + }, + advanced: { + database: { generateId: "uuid" }, + cookiePrefix: + surface === "user" ? "jyotisha-user" : "jyotisha-admin", + defaultCookieAttributes: { + secure: true, + httpOnly: true, + sameSite: "lax", + path: "/", + }, + }, + plugins: [ + emailOTP(createEmailOtpOptions(emailSender, secret, surface === "admin")), + admin({ + defaultRole: "user", + adminRoles: ["admin"], + schema: identityModelMapping.admin, + }), + ], + ...(surface === "admin" + ? { + databaseHooks: { + session: { + create: { + async before(session: { userId: string }) { + if (!(await authorizeAdminUser!(session.userId))) { + throw new APIError("FORBIDDEN", { + message: "Administrator access required", + }); + } + }, + }, + }, + }, + } + : {}), + }; +} diff --git a/frontend/src/modules/identity/auth.ts b/frontend/src/modules/identity/auth.ts new file mode 100644 index 00000000..d6217029 --- /dev/null +++ b/frontend/src/modules/identity/auth.ts @@ -0,0 +1,121 @@ +import { betterAuth } from "better-auth"; +import { Pool } from "pg"; + +import { buildAuthOptions, type AdminUserAuthorizer } from "./auth-factory.ts"; +import { + isSelfHostedIdentityEnabled, + readSelfHostedIdentityConfig, + type SelfHostedIdentityConfig, +} from "./config.ts"; +import type { EmailOtpSender } from "./contracts.ts"; +import { ResendEmailOtpSender } from "./email/resend-email-otp-sender.ts"; + +interface AdminRoleRow { + role: string; + banned: boolean; + ban_expires: Date | null; +} + +export function createIdentityPool(databaseUrl: string): Pool { + return new Pool({ + connectionString: databaseUrl, + options: "-c search_path=identity,pg_catalog", + application_name: "jyotisha-identity", + max: 10, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 5_000, + }); +} + +export function createDatabaseAdminAuthorizer( + pool: Pool, +): AdminUserAuthorizer { + return async (userId) => { + const result = await pool.query( + ` + select role, banned, ban_expires + from identity.users + where id = $1 + limit 1 + `, + [userId], + ); + const user = result.rows[0]; + if (!user) return false; + + if (user.banned) { + const banExpiry = user.ban_expires?.getTime(); + if (banExpiry === undefined || !Number.isFinite(banExpiry)) return false; + if (banExpiry > Date.now()) return false; + } + + return user.role + .split(",") + .map((role) => role.trim()) + .includes("admin"); + }; +} + +export interface IdentityAuthServices { + pool: Pool; + user: ReturnType; + admin: ReturnType; +} + +interface IdentityAuthDependencies { + pool?: Pool; + emailSender?: EmailOtpSender; + authorizeAdminUser?: AdminUserAuthorizer; +} + +export function createIdentityAuthServices( + config: SelfHostedIdentityConfig, + dependencies: IdentityAuthDependencies = {}, +): IdentityAuthServices { + const pool = dependencies.pool ?? createIdentityPool(config.databaseUrl); + const emailSender = + dependencies.emailSender ?? + new ResendEmailOtpSender({ + apiKey: config.resendApiKey, + from: config.resendFrom, + }); + const authorizeAdminUser = + dependencies.authorizeAdminUser ?? createDatabaseAdminAuthorizer(pool); + + return { + pool, + user: betterAuth( + buildAuthOptions({ + surface: "user", + config, + database: pool, + emailSender, + }), + ), + admin: betterAuth( + buildAuthOptions({ + surface: "admin", + config, + database: pool, + emailSender, + authorizeAdminUser, + }), + ), + }; +} + +const identityGlobal = globalThis as typeof globalThis & { + jyotishaIdentityAuth?: IdentityAuthServices; +}; + +export function getIdentityAuthServices( + env: NodeJS.ProcessEnv = process.env, +): IdentityAuthServices { + 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/client.ts b/frontend/src/modules/identity/client.ts new file mode 100644 index 00000000..a58d7d5e --- /dev/null +++ b/frontend/src/modules/identity/client.ts @@ -0,0 +1,58 @@ +import { createAuthClient } from "better-auth/react"; +import { emailOTPClient } from "better-auth/client/plugins"; + +interface OtpClientResult { + data: unknown; + error: unknown; +} + +export interface SelfHostedOtpClient { + emailOtp: { + sendVerificationOtp(input: { + email: string; + type: "sign-in"; + }): Promise; + }; + signIn: { + emailOtp(input: { + email: string; + otp: string; + }): Promise; + }; +} + +export interface SelfHostedOtpActions { + send(email: string): Promise; + verify(email: string, otp: string): Promise; +} + +export function createSelfHostedOtpActions( + client: SelfHostedOtpClient, +): SelfHostedOtpActions { + return { + async send(email) { + const result = await client.emailOtp.sendVerificationOtp({ + email: email.trim().toLowerCase(), + type: "sign-in", + }); + if (result.error) { + throw new Error("暂时无法发送验证码,请稍后再试"); + } + }, + async verify(email, otp) { + const result = await client.signIn.emailOtp({ + email: email.trim().toLowerCase(), + otp, + }); + if (result.error) { + throw new Error("验证码错误或已过期,请重新获取"); + } + }, + }; +} + +const authClient = createAuthClient({ plugins: [emailOTPClient()] }); + +export const selfHostedOtpActions = createSelfHostedOtpActions( + authClient as SelfHostedOtpClient, +); diff --git a/frontend/src/modules/identity/config.ts b/frontend/src/modules/identity/config.ts new file mode 100644 index 00000000..ca1eae26 --- /dev/null +++ b/frontend/src/modules/identity/config.ts @@ -0,0 +1,135 @@ +type IdentityEnvironment = Record; + +export interface SupabaseIdentityConfig { + provider: "supabase"; +} + +export interface SelfHostedIdentityConfig { + provider: "self-hosted"; + databaseUrl: string; + userOrigin: string; + adminOrigin: string; + userSecret: string; + adminSecret: string; + resendApiKey: string; + resendFrom: string; +} + +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`); + return value; +} + +function readPostgresUrl(env: IdentityEnvironment): string { + const value = required(env, "IDENTITY_DATABASE_URL"); + if (!value.startsWith("postgresql://")) { + throw new Error("IDENTITY_DATABASE_URL must be a PostgreSQL URL"); + } + + try { + const url = new URL(value); + if (!url.hostname || !url.pathname || url.pathname === "/") { + throw new Error("invalid PostgreSQL URL"); + } + } catch { + throw new Error("IDENTITY_DATABASE_URL must be a PostgreSQL URL"); + } + + return value; +} + +function readOrigin(env: IdentityEnvironment, key: string): string { + const value = required(env, key); + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${key} must be a valid origin`); + } + + const isLocalhost = + url.hostname === "localhost" || url.hostname.endsWith(".localhost"); + if (url.protocol !== "https:" && !(isLocalhost && url.protocol === "http:")) { + throw new Error(`${key} must use HTTPS outside localhost`); + } + if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) { + throw new Error(`${key} must be an origin without a path`); + } + + return url.origin; +} + +function readSecret(env: IdentityEnvironment, key: string): string { + const value = required(env, key); + if (value.length < 32) { + throw new Error(`${key} must be at least 32 characters`); + } + return value; +} + +function readSender(env: IdentityEnvironment): string { + const value = required(env, "RESEND_FROM_EMAIL"); + const match = value.match(/(?:^|<)([^<>\s]+@[^<>\s]+)(?:>|$)/); + if (!match) { + throw new Error("RESEND_FROM_EMAIL must contain a valid email address"); + } + return value; +} + +export function readSelfHostedIdentityConfig( + env: IdentityEnvironment, +): SelfHostedIdentityConfig { + const userOrigin = readOrigin(env, "AUTH_USER_ORIGIN"); + const adminOrigin = readOrigin(env, "AUTH_ADMIN_ORIGIN"); + if (userOrigin === adminOrigin) { + throw new Error("user and admin origins must be different"); + } + + const userSecret = readSecret(env, "BETTER_AUTH_USER_SECRET"); + const adminSecret = readSecret(env, "BETTER_AUTH_ADMIN_SECRET"); + if (userSecret === adminSecret) { + throw new Error("user and admin secrets must be different"); + } + + return { + provider: "self-hosted", + databaseUrl: readPostgresUrl(env), + userOrigin, + adminOrigin, + userSecret, + adminSecret, + resendApiKey: required(env, "RESEND_API_KEY"), + 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/src/modules/identity/contracts.ts b/frontend/src/modules/identity/contracts.ts new file mode 100644 index 00000000..c78ce393 --- /dev/null +++ b/frontend/src/modules/identity/contracts.ts @@ -0,0 +1,32 @@ +export type IdentitySurface = "user" | "admin"; + +export type EmailOtpType = + | "sign-in" + | "email-verification" + | "forget-password" + | "change-email"; + +export interface EmailOtpMessage { + email: string; + otp: string; + type: EmailOtpType; + idempotencyKey: string; +} + +export interface EmailOtpSender { + send(message: EmailOtpMessage): Promise; +} + +export interface IdentityUser { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null; + role: string[]; +} + +export interface IdentitySession { + user: IdentityUser; + expiresAt: Date; +} diff --git a/frontend/src/modules/identity/email/fake-email-otp-sender.ts b/frontend/src/modules/identity/email/fake-email-otp-sender.ts new file mode 100644 index 00000000..1923f001 --- /dev/null +++ b/frontend/src/modules/identity/email/fake-email-otp-sender.ts @@ -0,0 +1,12 @@ +import type { + EmailOtpMessage, + EmailOtpSender, +} from "../contracts.ts"; + +export class FakeEmailOtpSender implements EmailOtpSender { + readonly messages: EmailOtpMessage[] = []; + + async send(message: EmailOtpMessage): Promise { + this.messages.push({ ...message }); + } +} diff --git a/frontend/src/modules/identity/email/resend-email-otp-sender.ts b/frontend/src/modules/identity/email/resend-email-otp-sender.ts new file mode 100644 index 00000000..0cac51cc --- /dev/null +++ b/frontend/src/modules/identity/email/resend-email-otp-sender.ts @@ -0,0 +1,74 @@ +import type { + EmailOtpMessage, + EmailOtpSender, + EmailOtpType, +} from "../contracts.ts"; + +const resendEndpoint = "https://api.resend.com/emails"; +const safeDeliveryError = "OTP email delivery failed"; + +const subjectByType: Record = { + "sign-in": "Your Jyotisha sign-in code", + "email-verification": "Verify your Jyotisha email", + "forget-password": "Reset your Jyotisha password", + "change-email": "Confirm your new Jyotisha email", +}; + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (character) => { + const entities: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return entities[character]; + }); +} + +export interface ResendEmailOtpSenderOptions { + apiKey: string; + from: string; + fetchImpl?: typeof fetch; +} + +export class ResendEmailOtpSender implements EmailOtpSender { + private readonly apiKey: string; + private readonly from: string; + private readonly fetchImpl: typeof fetch; + + constructor(options: ResendEmailOtpSenderOptions) { + this.apiKey = options.apiKey; + this.from = options.from; + this.fetchImpl = options.fetchImpl ?? fetch; + } + + async send(message: EmailOtpMessage): Promise { + const escapedOtp = escapeHtml(message.otp); + const subject = subjectByType[message.type]; + + try { + const response = await this.fetchImpl(resendEndpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + "Idempotency-Key": message.idempotencyKey, + "User-Agent": "jyotisha-identity/1.0", + }, + body: JSON.stringify({ + from: this.from, + to: [message.email], + subject, + text: `${subject}: ${message.otp}. This code expires in five minutes.`, + html: `

${escapeHtml(subject)}

${escapedOtp}

This code expires in five minutes.

`, + }), + }); + + if (!response.ok) throw new Error(safeDeliveryError); + } catch { + throw new Error(safeDeliveryError); + } + } +} diff --git a/frontend/src/modules/identity/host.ts b/frontend/src/modules/identity/host.ts new file mode 100644 index 00000000..7a7435da --- /dev/null +++ b/frontend/src/modules/identity/host.ts @@ -0,0 +1,74 @@ +import type { SelfHostedIdentityConfig } from "./config.ts"; +import type { IdentitySurface } from "./contracts.ts"; + +export type IdentityRequestHandler = ( + request: Request, +) => Response | Promise; + +export interface IdentityAuthHandlers { + GET: IdentityRequestHandler; + POST: IdentityRequestHandler; +} + +function normalizeHost(value: string | null): string | null { + if (!value || value !== value.trim() || /[\s,@/\\]/.test(value)) return null; + + try { + const url = new URL(`https://${value}`); + if ( + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) { + return null; + } + return url.host.toLowerCase(); + } catch { + return null; + } +} + +export function resolveIdentitySurface( + hostHeader: string | null, + config: SelfHostedIdentityConfig, +): IdentitySurface | null { + const host = normalizeHost(hostHeader); + if (!host) return null; + + const userHost = new URL(config.userOrigin).host.toLowerCase(); + const adminHost = new URL(config.adminOrigin).host.toLowerCase(); + if (host === userHost) return "user"; + if (host === adminHost) return "admin"; + return null; +} + +function isAdminEndpoint(request: Request): boolean { + try { + const path = decodeURIComponent(new URL(request.url).pathname); + return /^\/api\/auth\/+admin(?:\/|$)/i.test(path); + } catch { + return true; + } +} + +export function createHostIsolatedAuthHandlers( + config: SelfHostedIdentityConfig, + handlers: Record, +): IdentityAuthHandlers { + const dispatch = + (method: keyof IdentityAuthHandlers): IdentityRequestHandler => + async (request) => { + const surface = resolveIdentitySurface(request.headers.get("host"), config); + if (!surface) { + return new Response("Unrecognized identity host", { status: 421 }); + } + if (surface === "user" && isAdminEndpoint(request)) { + return new Response("Not found", { status: 404 }); + } + return handlers[surface][method](request); + }; + + return { GET: dispatch("GET"), POST: dispatch("POST") }; +} diff --git a/frontend/src/modules/identity/model.ts b/frontend/src/modules/identity/model.ts new file mode 100644 index 00000000..59bdbb7a --- /dev/null +++ b/frontend/src/modules/identity/model.ts @@ -0,0 +1,65 @@ +export const identityModelMapping = { + user: { + modelName: "users", + fields: { + emailVerified: "email_verified", + createdAt: "created_at", + updatedAt: "updated_at", + }, + }, + session: { + modelName: "sessions", + fields: { + expiresAt: "expires_at", + createdAt: "created_at", + updatedAt: "updated_at", + ipAddress: "ip_address", + userAgent: "user_agent", + userId: "user_id", + }, + }, + account: { + modelName: "accounts", + fields: { + accountId: "account_id", + providerId: "provider_id", + userId: "user_id", + accessToken: "access_token", + refreshToken: "refresh_token", + idToken: "id_token", + accessTokenExpiresAt: "access_token_expires_at", + refreshTokenExpiresAt: "refresh_token_expires_at", + createdAt: "created_at", + updatedAt: "updated_at", + }, + }, + verification: { + modelName: "verifications", + fields: { + expiresAt: "expires_at", + createdAt: "created_at", + updatedAt: "updated_at", + }, + }, + rateLimit: { + modelName: "otp_rate_limits", + fields: { + lastRequest: "last_request", + }, + }, + admin: { + user: { + fields: { + role: "role", + banned: "banned", + banReason: "ban_reason", + banExpires: "ban_expires", + }, + }, + session: { + fields: { + impersonatedBy: "impersonated_by", + }, + }, + }, +} as const; diff --git a/frontend/src/modules/identity/session.ts b/frontend/src/modules/identity/session.ts new file mode 100644 index 00000000..0c2fd6e1 --- /dev/null +++ b/frontend/src/modules/identity/session.ts @@ -0,0 +1,80 @@ +import type { IdentitySession, IdentityUser } from "./contracts.ts"; + +interface RawIdentitySession { + session: { expiresAt: Date | string }; + user: { + id: string; + email: string; + emailVerified: boolean; + name: string; + image?: string | null; + role?: string | null; + }; +} + +export interface IdentitySessionReader { + getSession(input: { headers: Headers }): Promise; +} + +export class IdentityAuthorizationError extends Error { + constructor( + message: string, + readonly status: 401 | 403, + ) { + super(message); + this.name = "IdentityAuthorizationError"; + } +} + +function parseRoles(role: string | null | undefined): string[] { + const roles = (role ?? "user") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + return [...new Set(roles.length ? roles : ["user"])]; +} + +export async function readIdentitySession( + reader: IdentitySessionReader, + requestHeaders: Headers, +): Promise { + const value = await reader.getSession({ headers: requestHeaders }); + if (!value) return null; + + const expiresAt = new Date(value.session.expiresAt); + if (!Number.isFinite(expiresAt.getTime())) { + throw new Error("identity session has an invalid expiry"); + } + + return { + expiresAt, + user: { + id: value.user.id, + email: value.user.email.trim().toLowerCase(), + emailVerified: value.user.emailVerified, + name: value.user.name, + image: value.user.image ?? null, + role: parseRoles(value.user.role), + }, + }; +} + +export async function requireIdentityUser( + reader: IdentitySessionReader, + requestHeaders: Headers, +): Promise { + const session = await readIdentitySession(reader, requestHeaders); + if (!session) throw new IdentityAuthorizationError("Authentication required", 401); + return session.user; +} + +export async function requireIdentityAdmin( + reader: IdentitySessionReader, + requestHeaders: Headers, +): Promise { + const user = await requireIdentityUser(reader, requestHeaders); + if (!user.role.includes("admin")) { + throw new IdentityAuthorizationError("Administrator access required", 403); + } + return user; +} diff --git a/frontend/tests/birth-time-user-errors.test.ts b/frontend/tests/birth-time-user-errors.test.ts index 445a3d44..0d1ca607 100644 --- a/frontend/tests/birth-time-user-errors.test.ts +++ b/frontend/tests/birth-time-user-errors.test.ts @@ -14,8 +14,14 @@ test("birth-time errors preserve a safe server message", () => { assert.equal(birthTimeUserError(new Error("候选结果已变化")), "候选结果已变化"); }); -test("all guided journey mutations normalize implementation errors", () => { - const source = readFileSync(new URL("../src/hooks/use-birth-time-guided-journey.ts", import.meta.url), "utf8"); - assert.ok((source.match(/setError\(birthTimeUserError\(caught\)\)/g) ?? []).length >= 1); - assert.equal(source.includes("setError(caught.message"), false); +test("all guided and automatic journey mutations normalize implementation errors", () => { + const sources = [ + "../src/hooks/use-birth-time-guided-journey.ts", + "../src/hooks/use-birth-time-automatic-journey-effects.ts", + ].map((path) => readFileSync(new URL(path, import.meta.url), "utf8")); + for (const source of sources) { + assert.ok((source.match(/setError\(birthTimeUserError\(caught\)\)/g) ?? []).length >= 1); + assert.equal(source.includes("setError(caught.message"), false); + assert.equal(source.includes("setError(caught instanceof Error ? caught.message"), false); + } }); diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts index a9da96a0..acb6914b 100644 --- a/frontend/tests/conversational-rectification-component.test.ts +++ b/frontend/tests/conversational-rectification-component.test.ts @@ -727,8 +727,9 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro ); await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA1')"); await waitFor( - () => cdp?.evaluate("document.body.textContent.includes('当前判断')") ?? Promise.resolve(false), - "async initial turn", + () => cdp?.evaluate(`document.querySelector('.conversational-candidate time')?.textContent === '05:18' + && document.body.textContent.includes('已记录:2021-07')`) ?? Promise.resolve(false), + "streamlined async initial turn", ); const layout = await cdp.evaluate<{ diff --git a/frontend/tests/database-self-hosted-identity.test.ts b/frontend/tests/database-self-hosted-identity.test.ts new file mode 100644 index 00000000..3266315b --- /dev/null +++ b/frontend/tests/database-self-hosted-identity.test.ts @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath( + new URL("../scripts/db-migrate.mjs", import.meta.url), +); +const migrationsDirectory = fileURLToPath( + new URL("../db/migrations", import.meta.url), +); +const identityMigration = fileURLToPath( + new URL( + "../db/migrations/20260721000100_self_hosted_identity.sql", + import.meta.url, + ), +); + +test("self-hosted identity migration creates Better Auth tables with least privilege", () => { + const migrationSource = readFileSync(identityMigration, "utf8"); + assert.doesNotMatch(migrationSource, /grant all/i); + + const fixture = startPostgresFixture(); + const schemaUrl = fixture.connectionUrl( + "schema_owner", + "schema-owner-test-password", + ); + const migrate = () => + spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { + ...process.env, + MIGRATIONS_DIRECTORY: migrationsDirectory, + SCHEMA_DATABASE_URL: schemaUrl, + }, + }); + + try { + const firstRun = migrate(); + assert.equal(firstRun.status, 0, firstRun.stderr); + assert.match( + firstRun.stdout, + /applied 20260721000100_self_hosted_identity\.sql/, + ); + + const secondRun = migrate(); + assert.equal(secondRun.status, 0, secondRun.stderr); + assert.match( + secondRun.stdout, + /already applied 20260721000100_self_hosted_identity\.sql/, + ); + + assert.equal( + fixture.psql(` + select string_agg(tablename, ',' order by tablename) + from pg_tables + where schemaname = 'identity' + `), + "accounts,otp_rate_limits,sessions,users,verifications", + ); + assert.equal( + fixture.psql(` + select string_agg(tablename || ':' || tableowner, ',' order by tablename) + from pg_tables + where schemaname = 'identity' + `), + [ + "accounts:schema_owner", + "otp_rate_limits:schema_owner", + "sessions:schema_owner", + "users:schema_owner", + "verifications:schema_owner", + ].join(","), + ); + + assert.equal( + fixture.psql(` + select data_type || ':' || coalesce(column_default, '') + from information_schema.columns + where table_schema = 'identity' + and table_name = 'users' + and column_name = 'id' + `), + "uuid:gen_random_uuid()", + ); + assert.equal( + fixture.psql(` + select is_nullable || ':' || data_type + from information_schema.columns + where table_schema = 'identity' + and table_name = 'users' + and column_name = 'email_verified' + `), + "NO:boolean", + ); + + for (const table of [ + "users", + "sessions", + "accounts", + "verifications", + "otp_rate_limits", + ]) { + assert.equal( + fixture.psql( + `select has_table_privilege('identity_runtime', 'identity.${table}', 'select,insert,update,delete')`, + ), + "t", + ); + assert.equal( + fixture.psql( + `select has_table_privilege('app_runtime', 'identity.${table}', 'select')`, + ), + "f", + ); + assert.equal( + fixture.psql( + `select has_table_privilege('admin_runtime', 'identity.${table}', 'select')`, + ), + "t", + ); + } + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + ` + insert into identity.users (name, email) + values ('Migration User', 'migration@example.com') + `, + ); + const userId = fixture.psql( + "select id from identity.users where email = 'migration@example.com'", + ); + assert.match(userId, /^[0-9a-f-]{36}$/); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + ` + insert into identity.sessions (token, user_id, expires_at) + values ('opaque-session-token', '${userId}', now() + interval '1 hour') + `, + ); + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + `delete from identity.users where id = '${userId}'`, + ); + assert.equal(fixture.psql("select count(*) from identity.sessions"), "0"); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + "insert into identity.users (name, email) values ('One', 'Case@Example.com')", + ); + assert.throws(() => + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + "insert into identity.users (name, email) values ('Two', 'case@example.com')", + ), + ); + assert.throws(() => + fixture.psqlAs( + "app_runtime", + "app-runtime-test-password", + "select count(*) from identity.users", + ), + ); + assert.equal( + fixture.psqlAs( + "admin_runtime", + "admin-runtime-test-password", + "select count(*) from identity.users", + ), + "1", + ); + } finally { + fixture.stop(); + } +}); diff --git a/frontend/tests/fixtures/supabase-auth-users.json b/frontend/tests/fixtures/supabase-auth-users.json new file mode 100644 index 00000000..608f6cf9 --- /dev/null +++ b/frontend/tests/fixtures/supabase-auth-users.json @@ -0,0 +1,23 @@ +[ + { + "id": "018f4e6d-7a11-7000-8000-000000000001", + "email": " Person@Example.com ", + "email_confirmed_at": "2026-07-01T01:02:03.000Z", + "created_at": "2026-06-01T01:02:03.000Z", + "updated_at": "2026-07-02T01:02:03.000Z", + "raw_user_meta_data": { + "full_name": "Person One", + "avatar_url": "https://example.com/person.png" + }, + "encrypted_password": "must-not-be-imported", + "last_sign_in_at": "2026-07-10T01:02:03.000Z" + }, + { + "id": "018f4e6d-7a11-7000-8000-000000000002", + "email": "second@example.com", + "email_confirmed_at": null, + "created_at": "2026-06-02T01:02:03.000Z", + "updated_at": "2026-06-02T01:02:03.000Z", + "raw_user_meta_data": {} + } +] diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index f542872f..ee0fff41 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -104,6 +104,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", () => { @@ -152,14 +156,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/); }); @@ -223,6 +233,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" }); @@ -302,6 +322,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-auth-factory.test.ts b/frontend/tests/identity-auth-factory.test.ts new file mode 100644 index 00000000..5e4bf342 --- /dev/null +++ b/frontend/tests/identity-auth-factory.test.ts @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Pool } from "pg"; + +import { + buildAuthOptions, + createEmailOtpOptions, + type AdminUserAuthorizer, +} from "../src/modules/identity/auth-factory.ts"; +import { + createDatabaseAdminAuthorizer, + createIdentityPool, +} from "../src/modules/identity/auth.ts"; +import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts"; +import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts"; + +const config: SelfHostedIdentityConfig = { + provider: "self-hosted", + databaseUrl: + "postgresql://identity_runtime:test-password@postgres:5432/jyotisha", + userOrigin: "https://staging.jyotisha.chat", + adminOrigin: "https://admin.staging.jyotisha.chat", + userSecret: "user-secret-that-is-at-least-32-bytes-long", + adminSecret: "admin-secret-that-is-at-least-32-bytes-long", + resendApiKey: "re_test_key", + resendFrom: "Jyotisha ", +}; + +const database = { kind: "pool" } as unknown as Pool; + +test("Better Auth model mappings match the identity migration", () => { + const options = buildAuthOptions({ + surface: "user", + config, + database, + emailSender: new FakeEmailOtpSender(), + }); + + assert.equal(options.database, database); + assert.equal(options.user?.modelName, "users"); + assert.deepEqual(options.user?.fields, { + emailVerified: "email_verified", + createdAt: "created_at", + updatedAt: "updated_at", + }); + assert.equal(options.session?.modelName, "sessions"); + assert.deepEqual(options.session?.fields, { + expiresAt: "expires_at", + createdAt: "created_at", + updatedAt: "updated_at", + ipAddress: "ip_address", + userAgent: "user_agent", + userId: "user_id", + }); + assert.equal(options.account?.modelName, "accounts"); + assert.equal(options.account?.fields?.accountId, "account_id"); + assert.equal(options.account?.fields?.providerId, "provider_id"); + assert.equal(options.account?.fields?.accessTokenExpiresAt, "access_token_expires_at"); + assert.equal(options.verification?.modelName, "verifications"); + assert.equal(options.verification?.fields?.expiresAt, "expires_at"); + assert.equal(options.rateLimit?.modelName, "otp_rate_limits"); + assert.equal(options.rateLimit?.storage, "database"); + assert.equal(options.advanced?.database?.generateId, "uuid"); +}); + +test("OTP policy hashes values, rotates resends, and builds opaque idempotency keys", async () => { + const sender = new FakeEmailOtpSender(); + const otpOptions = createEmailOtpOptions(sender, config.userSecret, false); + + assert.equal(otpOptions.otpLength, 6); + assert.equal(otpOptions.expiresIn, 300); + assert.equal(otpOptions.allowedAttempts, 3); + assert.equal(otpOptions.resendStrategy, "rotate"); + assert.equal(otpOptions.storeOTP, "hashed"); + assert.deepEqual(otpOptions.rateLimit, { window: 60, max: 3 }); + assert.equal(otpOptions.disableSignUp, false); + + await otpOptions.sendVerificationOTP({ + email: "person@example.com", + otp: "123456", + type: "sign-in", + }); + assert.equal(sender.messages.length, 1); + assert.match(sender.messages[0].idempotencyKey, /^otp-[0-9a-f]{64}$/); + assert.doesNotMatch(sender.messages[0].idempotencyKey, /123456|person/); +}); + +test("user and admin auth surfaces have host-only isolated cookies", () => { + const authorizer: AdminUserAuthorizer = async () => true; + const userOptions = buildAuthOptions({ + surface: "user", + config, + database, + emailSender: new FakeEmailOtpSender(), + }); + const adminOptions = buildAuthOptions({ + surface: "admin", + config, + database, + emailSender: new FakeEmailOtpSender(), + authorizeAdminUser: authorizer, + }); + + assert.equal(userOptions.baseURL, config.userOrigin); + assert.equal(adminOptions.baseURL, config.adminOrigin); + assert.equal(userOptions.secret, config.userSecret); + assert.equal(adminOptions.secret, config.adminSecret); + assert.equal(userOptions.advanced?.cookiePrefix, "jyotisha-user"); + assert.equal(adminOptions.advanced?.cookiePrefix, "jyotisha-admin"); + for (const options of [userOptions, adminOptions]) { + const attributes = options.advanced?.defaultCookieAttributes; + assert.equal(attributes?.secure, true); + assert.equal(attributes?.httpOnly, true); + assert.equal(attributes?.sameSite, "lax"); + assert.equal(attributes?.path, "/"); + assert.equal(attributes && "domain" in attributes, false); + assert.equal(options.advanced?.crossSubDomainCookies, undefined); + } +}); + +test("admin surface disables sign-up and rejects non-admin session creation", async () => { + const checkedUserIds: string[] = []; + const options = buildAuthOptions({ + surface: "admin", + config, + database, + emailSender: new FakeEmailOtpSender(), + authorizeAdminUser: async (userId) => { + checkedUserIds.push(userId); + return userId === "admin-user-id"; + }, + }); + const emailPlugin = options.plugins?.find( + (plugin) => plugin.id === "email-otp", + ); + assert.ok(emailPlugin); + + const before = options.databaseHooks?.session?.create?.before; + assert.ok(before); + const session = { + id: "session-id", + token: "session-token", + userId: "ordinary-user-id", + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }; + await assert.rejects( + before(session, null), + /Administrator access required/, + ); + assert.equal( + await before({ ...session, userId: "admin-user-id" }, null), + undefined, + ); + assert.deepEqual(checkedUserIds, ["ordinary-user-id", "admin-user-id"]); + + const otpOptions = createEmailOtpOptions( + new FakeEmailOtpSender(), + config.adminSecret, + true, + ); + assert.equal(otpOptions.disableSignUp, true); +}); + +test("admin surface requires a server-side persisted-role authorizer", () => { + assert.throws( + () => + buildAuthOptions({ + surface: "admin", + config, + database, + emailSender: new FakeEmailOtpSender(), + }), + /admin user authorizer is required/, + ); +}); + +test("identity pool forces the identity search path", async () => { + const pool = createIdentityPool(config.databaseUrl); + + try { + assert.equal(pool.options.connectionString, config.databaseUrl); + assert.equal(pool.options.options, "-c search_path=identity,pg_catalog"); + assert.equal(pool.options.max, 10); + } finally { + await pool.end(); + } +}); + +test("database admin authorizer requires a current persisted admin role", async () => { + const rowsByUser = new Map>([ + ["admin", { role: "user,admin", banned: false, ban_expires: null }], + ["user", { role: "user", banned: false, ban_expires: null }], + ["banned", { role: "admin", banned: true, ban_expires: null }], + [ + "expired-ban", + { + role: "admin", + banned: true, + ban_expires: new Date(Date.now() - 60_000), + }, + ], + ]); + const queries: Array<{ sql: string; values: unknown[] }> = []; + const pool = { + async query(sql: string, values: unknown[]) { + queries.push({ sql, values }); + const row = rowsByUser.get(String(values[0])); + return { rows: row ? [row] : [] }; + }, + } as unknown as Pool; + const authorize = createDatabaseAdminAuthorizer(pool); + + assert.equal(await authorize("admin"), true); + assert.equal(await authorize("user"), false); + assert.equal(await authorize("banned"), false); + assert.equal(await authorize("expired-ban"), true); + assert.equal(await authorize("missing"), false); + assert.equal(queries.length, 5); + assert.match(queries[0].sql, /from identity\.users/); + assert.deepEqual(queries[0].values, ["admin"]); +}); diff --git a/frontend/tests/identity-auth-integration.test.ts b/frontend/tests/identity-auth-integration.test.ts new file mode 100644 index 00000000..1021f569 --- /dev/null +++ b/frontend/tests/identity-auth-integration.test.ts @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { toNextJsHandler } from "better-auth/next-js"; + +import { + createIdentityAuthServices, + createIdentityPool, +} from "../src/modules/identity/auth.ts"; +import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts"; +import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts"; +import { createHostIsolatedAuthHandlers } from "../src/modules/identity/host.ts"; +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath( + new URL("../scripts/db-migrate.mjs", import.meta.url), +); +const migrationsDirectory = fileURLToPath( + new URL("../db/migrations", import.meta.url), +); + +function request( + host: string, + path: string, + body: Record, +): Request { + return new Request(`https://${host}${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + host, + origin: `https://${host}`, + }, + body: JSON.stringify(body), + }); +} + +test("Better Auth completes OTP sign-in against the migrated identity schema with isolated cookies", async () => { + const fixture = startPostgresFixture(); + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { + ...process.env, + MIGRATIONS_DIRECTORY: migrationsDirectory, + SCHEMA_DATABASE_URL: fixture.connectionUrl( + "schema_owner", + "schema-owner-test-password", + ), + }, + }); + assert.equal(migration.status, 0, migration.stderr); + + const config: SelfHostedIdentityConfig = { + provider: "self-hosted", + databaseUrl: fixture.connectionUrl( + "identity_runtime", + "identity-runtime-test-password", + ), + userOrigin: "https://staging.jyotisha.chat", + adminOrigin: "https://admin.staging.jyotisha.chat", + userSecret: "user-secret-that-is-at-least-32-bytes-long", + adminSecret: "admin-secret-that-is-at-least-32-bytes-long", + resendApiKey: "re_test", + resendFrom: "Jyotisha ", + }; + const sender = new FakeEmailOtpSender(); + const pool = createIdentityPool(config.databaseUrl); + const services = createIdentityAuthServices(config, { + pool, + emailSender: sender, + }); + const handlers = createHostIsolatedAuthHandlers(config, { + user: toNextJsHandler(services.user), + admin: toNextJsHandler(services.admin), + }); + + try { + const userSend = await handlers.POST( + request( + "staging.jyotisha.chat", + "/api/auth/email-otp/send-verification-otp", + { email: "person@example.com", type: "sign-in" }, + ), + ); + assert.equal(userSend.status, 200, await userSend.text()); + assert.equal(sender.messages.length, 1); + + const userSignIn = await handlers.POST( + request("staging.jyotisha.chat", "/api/auth/sign-in/email-otp", { + email: "person@example.com", + otp: sender.messages[0].otp, + }), + ); + const userCookie = userSignIn.headers.get("set-cookie") ?? ""; + assert.equal(userSignIn.status, 200, await userSignIn.text()); + assert.match(userCookie, /jyotisha-user\.session_token=/); + assert.doesNotMatch(userCookie, /jyotisha-admin/); + assert.match(userCookie, /HttpOnly/i); + assert.match(userCookie, /Secure/i); + assert.match(userCookie, /SameSite=Lax/i); + assert.doesNotMatch(userCookie, /Domain=/i); + assert.equal(fixture.psql("select count(*) from identity.users"), "1"); + assert.equal(fixture.psql("select count(*) from identity.sessions"), "1"); + + const adminSend = await handlers.POST( + request( + "admin.staging.jyotisha.chat", + "/api/auth/email-otp/send-verification-otp", + { email: "person@example.com", type: "sign-in" }, + ), + ); + assert.equal(adminSend.status, 200, await adminSend.text()); + const deniedAdminSignIn = await handlers.POST( + request( + "admin.staging.jyotisha.chat", + "/api/auth/sign-in/email-otp", + { email: "person@example.com", otp: sender.messages[1].otp }, + ), + ); + assert.equal(deniedAdminSignIn.status, 403); + assert.equal(deniedAdminSignIn.headers.has("set-cookie"), false); + assert.equal(fixture.psql("select count(*) from identity.sessions"), "1"); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + "update identity.users set role = 'user,admin' where email = 'person@example.com'", + ); + const promotedSend = await handlers.POST( + request( + "admin.staging.jyotisha.chat", + "/api/auth/email-otp/send-verification-otp", + { email: "person@example.com", type: "sign-in" }, + ), + ); + assert.equal(promotedSend.status, 200, await promotedSend.text()); + const adminSignIn = await handlers.POST( + request( + "admin.staging.jyotisha.chat", + "/api/auth/sign-in/email-otp", + { email: "person@example.com", otp: sender.messages[2].otp }, + ), + ); + const adminCookie = adminSignIn.headers.get("set-cookie") ?? ""; + assert.equal(adminSignIn.status, 200, await adminSignIn.text()); + assert.match(adminCookie, /jyotisha-admin\.session_token=/); + assert.doesNotMatch(adminCookie, /jyotisha-user/); + assert.doesNotMatch(adminCookie, /Domain=/i); + assert.equal(fixture.psql("select count(*) from identity.sessions"), "2"); + } finally { + await pool.end(); + fixture.stop(); + } +}); diff --git a/frontend/tests/identity-config.test.ts b/frontend/tests/identity-config.test.ts new file mode 100644 index 00000000..60f049c3 --- /dev/null +++ b/frontend/tests/identity-config.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +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", + AUTH_ADMIN_ORIGIN: "https://admin.staging.jyotisha.chat", + 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 ", +}; + +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", () => { + const config = readIdentityConfig(selfHostedEnvironment); + + assert.equal(config.provider, "self-hosted"); + if (config.provider !== "self-hosted") { + assert.fail("expected self-hosted identity configuration"); + } + assert.equal(config.userOrigin, "https://staging.jyotisha.chat"); + assert.equal(config.adminOrigin, "https://admin.staging.jyotisha.chat"); + assert.equal(config.resendFrom, selfHostedEnvironment.RESEND_FROM_EMAIL); +}); + +test("identity config rejects unknown providers", () => { + assert.throws( + () => readIdentityConfig({ AUTH_PROVIDER: "firebase" }), + /AUTH_PROVIDER must be supabase or self-hosted/, + ); +}); + +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"; + + assert.throws( + () => + readIdentityConfig({ + ...selfHostedEnvironment, + BETTER_AUTH_USER_SECRET: secret, + RESEND_API_KEY: "", + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /RESEND_API_KEY is required/); + assert.doesNotMatch(error.message, new RegExp(secret)); + return true; + }, + ); +}); + +test("self-hosted identity validates database URL, origins, secrets, and sender", () => { + const invalidCases: Array<[string, Record, RegExp]> = [ + [ + "database URL", + { IDENTITY_DATABASE_URL: "https://database.invalid" }, + /IDENTITY_DATABASE_URL must be a PostgreSQL URL/, + ], + [ + "production HTTP origin", + { AUTH_USER_ORIGIN: "http://staging.jyotisha.chat" }, + /AUTH_USER_ORIGIN must use HTTPS/, + ], + [ + "origin path", + { AUTH_ADMIN_ORIGIN: "https://admin.staging.jyotisha.chat/login" }, + /AUTH_ADMIN_ORIGIN must be an origin without a path/, + ], + [ + "short secret", + { BETTER_AUTH_ADMIN_SECRET: "too-short" }, + /BETTER_AUTH_ADMIN_SECRET must be at least 32 characters/, + ], + [ + "shared secret", + { + BETTER_AUTH_ADMIN_SECRET: + selfHostedEnvironment.BETTER_AUTH_USER_SECRET, + }, + /user and admin secrets must be different/, + ], + [ + "shared origin", + { AUTH_ADMIN_ORIGIN: selfHostedEnvironment.AUTH_USER_ORIGIN }, + /user and admin origins must be different/, + ], + [ + "invalid sender", + { RESEND_FROM_EMAIL: "Jyotisha Staging" }, + /RESEND_FROM_EMAIL must contain a valid email address/, + ], + ]; + + for (const [name, override, expected] of invalidCases) { + assert.throws( + () => readIdentityConfig({ ...selfHostedEnvironment, ...override }), + expected, + name, + ); + } +}); + +test("localhost origins may use HTTP for local development", () => { + const config = readIdentityConfig({ + ...selfHostedEnvironment, + AUTH_USER_ORIGIN: "http://localhost:3000", + AUTH_ADMIN_ORIGIN: "http://admin.localhost:3000", + }); + + assert.equal(config.provider, "self-hosted"); +}); diff --git a/frontend/tests/identity-email-sender.test.ts b/frontend/tests/identity-email-sender.test.ts new file mode 100644 index 00000000..0a44f0e7 --- /dev/null +++ b/frontend/tests/identity-email-sender.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts"; +import { ResendEmailOtpSender } from "../src/modules/identity/email/resend-email-otp-sender.ts"; +import type { EmailOtpMessage } from "../src/modules/identity/contracts.ts"; + +const message: EmailOtpMessage = { + email: "person@example.com", + otp: "123456", + type: "sign-in", + idempotencyKey: "otp-request-018f4e6d", +}; + +test("fake OTP sender records messages without network access", async () => { + const sender = new FakeEmailOtpSender(); + + await sender.send(message); + + assert.deepEqual(sender.messages, [message]); + assert.notEqual(sender.messages[0], message); +}); + +test("Resend OTP sender emits an idempotent authenticated request", async () => { + const requests: Array<{ input: string | URL | Request; init?: RequestInit }> = []; + const fetchImpl: typeof fetch = async (input, init) => { + requests.push({ input, init }); + return Response.json({ id: "email_123" }, { status: 200 }); + }; + const sender = new ResendEmailOtpSender({ + apiKey: "re_test_secret_value", + from: "Jyotisha ", + fetchImpl, + }); + + await sender.send(message); + + assert.equal(requests.length, 1); + assert.equal(requests[0].input, "https://api.resend.com/emails"); + assert.equal(requests[0].init?.method, "POST"); + const headers = new Headers(requests[0].init?.headers); + assert.equal(headers.get("authorization"), "Bearer re_test_secret_value"); + assert.equal(headers.get("content-type"), "application/json"); + assert.equal(headers.get("idempotency-key"), message.idempotencyKey); + assert.equal(headers.get("user-agent"), "jyotisha-identity/1.0"); + + const body = JSON.parse(String(requests[0].init?.body)) as Record< + string, + unknown + >; + assert.equal(body.from, "Jyotisha "); + assert.deepEqual(body.to, [message.email]); + assert.equal(body.subject, "Your Jyotisha sign-in code"); + assert.match(String(body.text), /123456/); + assert.match(String(body.html), /123456/); +}); + +test("Resend OTP sender escapes template values", async () => { + let body = ""; + const sender = new ResendEmailOtpSender({ + apiKey: "re_test_secret_value", + from: "Jyotisha ", + fetchImpl: async (_input, init) => { + body = String(init?.body); + return Response.json({ id: "email_123" }); + }, + }); + + await sender.send({ ...message, otp: "" }); + + const parsed = JSON.parse(body) as { html: string }; + assert.doesNotMatch(parsed.html, /