This reverts commita55c69115d, reversing changes made to02c9c9f3d6.
14 KiB
Superseded 2026-07-29: staging browser identity and admin access now use one main-site Better Auth user session; the dual-domain admin surface in this historical plan is inactive.
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=supabaseas 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
identityschema and grant access only toidentity_runtimeandadmin_runtimeas 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:
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<void>;
}
Steps:
- Add tests that prove the provider defaults to
supabase,self-hostedrequires 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. - Run
npm test --prefix frontend -- identity-config.test.tsand confirm failure because the configuration module does not exist. - Implement the typed parser with explicit environment injection and safe error messages.
- Install the exact dependency with
npm install --prefix frontend better-auth@1.6.23and verify the lockfile pins the intended version. - Rerun the focused test, then run
npm run lint --prefix frontend -- frontend/src/modules/identity frontend/tests/identity-config.test.ts. - 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.tsif 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:
- 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.
- Prove
app_runtimecannot read identity tables,identity_runtimecan perform only identity operations, andadmin_runtimehas the documented administrative access. - Run
npm test --prefix frontend -- database-self-hosted-identity.test.tsand confirm failure because the migration is absent. - Add the idempotent SQL migration with explicit grants and revoked public access.
- Run the focused database test twice against the same database to prove migration idempotency, then run
npm run db:migrate:check --prefix frontend. - 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:
- Test the fake sender captures messages without network access.
- Test the Resend adapter sends
POST https://api.resend.com/emailswith bearer authorization,User-Agent, JSON content, andIdempotency-Key; injectfetchso tests never contact Resend. - Test non-2xx responses throw a generic delivery error that excludes the API key, OTP, recipient, and raw provider response.
- Run the focused test and confirm failure because the adapters do not exist.
- Implement the minimum adapters and a small escaped HTML/plain-text OTP template.
- Rerun the focused tests and lint the new files.
- 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:
- Add tests around an injectable factory proving it selects the
identityschema, 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. - Add tests proving user/admin instances use distinct secrets and cookie prefixes (
jyotisha-userandjyotisha-admin) withSecure,HttpOnly, andSameSite=Lax, and do not emit a cookie domain. - 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. - Run
npm test --prefix frontend -- identity-auth-factory.test.tsand confirm failure because the factory is absent. - Implement the factory with
better-auth, the email OTP plugin, the admin plugin, an injectedpg.Pool, and an injectedEmailOtpSender. - Rerun focused tests and
npx tsc --noEmit -p frontend/tsconfig.json. - 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:
- Test exact, port-normalized matching for configured user/admin hosts; reject unknown, suffix-confused, missing, and malformed hosts.
- Test the route dispatches to only the matching handler and returns
421before reading or issuing cookies on unknown hosts. - Test
getIdentitySession,requireIdentityUser, andrequireIdentityAdminreturn narrow DTOs and perform server-side role checks; cookie presence alone must never authorize. - Run focused tests and confirm failure because routing/DAL modules are absent.
- Implement
toNextJsHandlerdispatch and session helpers using awaited Next.jsheaders()at the boundary. - Rerun focused tests, TypeScript, and lint.
- 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:
- 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. - 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.
- Run focused tests and confirm failure because the self-hosted client/form are absent.
- Implement the Better Auth browser client with
emailOTPClientand the gated form. - Add an explicit warning in code/docs that
AUTH_PROVIDER=self-hostedis integration-only until business modules stop relying on Supabase JWT/RLS. - Rerun focused tests, TypeScript, and lint.
- 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:
- Add tests that preserve source UUID, normalized email, verification timestamp, created/updated timestamps, and display metadata.
- 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. - Run the focused test and confirm failure because the importer is absent.
- Implement streaming JSON parsing for the supported export shape, one transaction, deterministic upserts, and a summary containing counts but no emails.
- Rerun focused and database tests.
- 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:
- Add failing tests for the new identity/Resend variables, redacted validation output, exact admin host routing, and required identity tests in the backend gate.
- 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. - Update Caddy so
admin.staging.jyotisha.chatcan reach/api/auth/**and the gated login while other admin paths remain closed until the admin UI milestone. - Ensure deployment preflight refuses
AUTH_PROVIDER=self-hostedunless identity migration, host separation, and Resend configuration validate; keep staging’s checked-in default assupabase. - 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.
- Run deployment tests, database tests, and the backend quality gate.
- Commit as
chore(identity): wire staging identity operations.
Task 9: Final verification and review
Files:
- Review every file changed since
origin/main.
Steps:
- Run
npm test --prefix frontend. - Run
npm run lint --prefix frontend. - Run
npx tsc --noEmit -p frontend/tsconfig.json. - Run
npm run build --prefix frontendusing a non-secret build-safe environment. - Run
/opt/anaconda3/bin/python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45and record any remote visibility limitation accurately. - Search the diff for secrets, database URLs, OTP logging, permissive cookie domains, placeholder text, and accidental Supabase-default changes.
- Perform a code review against
origin/main, fix all high/medium findings, rerun the affected gates, and rungit diff --check. - Push
codex/self-hosted-identity, open a PR targetingmain, and report exact checks plus the deliberate non-cutover status.