From be83b17e0213744328964d5e544759ba4f521a19 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 10:49:06 +0800 Subject: [PATCH 01/11] docs: plan self-hosted identity milestone --- .../plans/2026-07-21-self-hosted-identity.md | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-21-self-hosted-identity.md 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. From d3a98d54a4f2a7558327640f47b16782bcc42bd8 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 11:13:21 +0800 Subject: [PATCH 02/11] feat(identity): define self-hosted identity configuration --- frontend/package-lock.json | 355 +++++++++++++++++++++ frontend/package.json | 1 + frontend/src/modules/identity/config.ts | 115 +++++++ frontend/src/modules/identity/contracts.ts | 31 ++ frontend/tests/identity-config.test.ts | 119 +++++++ 5 files changed, 621 insertions(+) create mode 100644 frontend/src/modules/identity/config.ts create mode 100644 frontend/src/modules/identity/contracts.ts create mode 100644 frontend/tests/identity-config.test.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 27b51bf3..bbd7cd22 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", @@ -580,6 +581,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", @@ -2305,6 +2333,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", @@ -4084,6 +4121,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", @@ -4680,6 +4993,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", @@ -7274,6 +7593,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", @@ -8624,6 +8952,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", @@ -9713,6 +10056,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", @@ -9894,6 +10243,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 fd4f53ed..8fed1bba 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/src/modules/identity/config.ts b/frontend/src/modules/identity/config.ts new file mode 100644 index 00000000..303de958 --- /dev/null +++ b/frontend/src/modules/identity/config.ts @@ -0,0 +1,115 @@ +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; + +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 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"); + } + + 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, + databaseUrl: readPostgresUrl(env), + userOrigin, + adminOrigin, + userSecret, + adminSecret, + resendApiKey: required(env, "RESEND_API_KEY"), + resendFrom: readSender(env), + }; +} diff --git a/frontend/src/modules/identity/contracts.ts b/frontend/src/modules/identity/contracts.ts new file mode 100644 index 00000000..b6e7d9e6 --- /dev/null +++ b/frontend/src/modules/identity/contracts.ts @@ -0,0 +1,31 @@ +export type IdentitySurface = "user" | "admin"; + +export type EmailOtpType = + | "sign-in" + | "email-verification" + | "forget-password"; + +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/tests/identity-config.test.ts b/frontend/tests/identity-config.test.ts new file mode 100644 index 00000000..9a7585f8 --- /dev/null +++ b/frontend/tests/identity-config.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { readIdentityConfig } from "../src/modules/identity/config.ts"; + +const selfHostedEnvironment = { + AUTH_PROVIDER: "self-hosted", + 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" }); +}); + +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 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"); +}); From 5da3dd38ffdedd1885779bf42fdd076a54118fdb Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 17:27:36 +0800 Subject: [PATCH 03/11] feat(identity): add local postgres identity schema --- .../20260721000100_self_hosted_identity.sql | 99 ++++++++++ .../database-self-hosted-identity.test.ts | 185 ++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 frontend/db/migrations/20260721000100_self_hosted_identity.sql create mode 100644 frontend/tests/database-self-hosted-identity.test.ts 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/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(); + } +}); From 43213f080a96cd3148949b622ae95b0584a92ed3 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 17:28:59 +0800 Subject: [PATCH 04/11] feat(identity): add resend otp delivery adapter --- .../identity/email/fake-email-otp-sender.ts | 12 ++ .../identity/email/resend-email-otp-sender.ts | 73 +++++++++++ frontend/tests/identity-email-sender.test.ts | 113 ++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 frontend/src/modules/identity/email/fake-email-otp-sender.ts create mode 100644 frontend/src/modules/identity/email/resend-email-otp-sender.ts create mode 100644 frontend/tests/identity-email-sender.test.ts 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..772b4b66 --- /dev/null +++ b/frontend/src/modules/identity/email/resend-email-otp-sender.ts @@ -0,0 +1,73 @@ +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", +}; + +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/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, /