Merge pull request #25 from jesse-ux/codex/self-hosted-identity

feat: add self-hosted identity foundation
This commit is contained in:
jesse-ux
2026-07-22 09:55:10 +08:00
committed by GitHub
41 changed files with 3468 additions and 124 deletions
+18
View File
@@ -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:<percent-encoded-identity-runtime-password>@postgres:5432/jyotisha
BETTER_AUTH_USER_SECRET=<independent-openssl-rand-base64-32-output>
BETTER_AUTH_ADMIN_SECRET=<different-openssl-rand-base64-32-output>
RESEND_API_KEY=<staging-only-resend-api-key>
RESEND_FROM_EMAIL=Jyotisha Staging <login@staging.jyotisha.chat>
+10
View File
@@ -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
}
+3
View File
@@ -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
+1
View File
@@ -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"
+8
View File
@@ -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`);
+51
View File
@@ -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"
+48
View File
@@ -217,3 +217,51 @@
- 相关记录:BUG-009
- 复发自:无
- 修复版本:待提交(本地可测)
## 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)
+80
View File
@@ -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.
@@ -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<void>;
}
```
**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.
@@ -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;
+355
View File
@@ -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",
+1
View File
@@ -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",
@@ -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 <export.json> [--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;
});
}
@@ -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<Response> {
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<Response> {
return dispatch("GET", request);
}
export function POST(request: Request): Promise<Response> {
return dispatch("POST", request);
}
+21 -114
View File
@@ -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<HTMLFormElement>) {
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);
}
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";
}
async function verifyOtp(event: FormEvent<HTMLFormElement>) {
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 (
<main className="standalone-page auth-page">
<div className="auth-shell">
<aside className="auth-story" aria-label="Jyotisha 简介">
<div className="auth-story-brand">
<Image src="/jyotish-logo.png" alt="" width={32} height={32} sizes="32px" />
<strong>Jyotisha</strong>
</div>
<div>
<p className="auth-kicker">Vedic astrology · 印度占星</p>
<h2>在星图与当下之间,<br />找到可以行动的<span className="phrase-nowrap">线索。</span></h2>
<p>以出生资料为起点,讨论事业、关系与时间窗口。每一次解读都保留证据,也保留你的选择。</p>
</div>
<p className="auth-footnote">私密对话 · 云端同步 · 随时继续</p>
</aside>
<section className="auth-panel" aria-labelledby="login-title">
<div className="auth-brand"><span aria-hidden="true" /><strong>Jyotisha</strong></div>
<h1 id="login-title">欢迎回来</h1>
<p className="page-intro">邮箱验证码登录,<span className="phrase-nowrap">新邮箱将自动创建账户。</span></p>
{!sent ? (
<form key="email" className="stack-form auth-step" onSubmit={sendOtp}>
<label htmlFor="login-email">邮箱</label>
<input id="login-email" type="email" autoComplete="email" inputMode="email" required autoFocus value={email} onChange={(event) => { setEmail(event.target.value); setError(""); setNotice(""); }} placeholder="you@example.com" />
<button className="button-primary" type="submit" disabled={!email.trim() || busy}>{busy ? "发送中" : "发送验证码"}</button>
</form>
) : (
<form key="otp" className="stack-form auth-step" onSubmit={verifyOtp}>
<label htmlFor="login-token">邮箱验证码</label>
<input id="login-token" className="otp-input" type="text" autoComplete="one-time-code" inputMode="numeric" pattern="[0-9]*" minLength={6} maxLength={6} required autoFocus value={token} onChange={(event) => { setToken(event.target.value.replace(/\D/g, "").slice(0, 6)); setError(""); }} />
<button className="button-primary" type="submit" disabled={!token || busy}>{busy ? "验证中" : "验证并登录"}</button>
<div className="inline-actions">
<button type="button" disabled={busy} onClick={() => void sendOtp()}>{busy ? "发送中" : "重新发送验证码"}</button>
<button type="button" disabled={busy} onClick={changeEmail}>更换邮箱</button>
</div>
</form>
)}
{error && <p className="form-error" role="alert">{error}</p>}
{notice && <p className="form-success" role="status">{notice}</p>}
</section>
</div>
</main>
);
return <EmailOtpLogin provider={provider} />;
}
+209
View File
@@ -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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<main className="standalone-page auth-page">
<div className="auth-shell">
<aside className="auth-story" aria-label="Jyotisha 简介">
<div className="auth-story-brand">
<Image
src="/jyotish-logo.png"
alt=""
width={32}
height={32}
sizes="32px"
/>
<strong>Jyotisha</strong>
</div>
<div>
<p className="auth-kicker">Vedic astrology · 印度占星</p>
<h2>
在星图与当下之间,<br />找到可以行动的
<span className="phrase-nowrap">线索。</span>
</h2>
<p>
以出生资料为起点,讨论事业、关系与时间窗口。每一次解读都保留证据,也保留你的选择。
</p>
</div>
<p className="auth-footnote">私密对话 · 云端同步 · 随时继续</p>
</aside>
<section className="auth-panel" aria-labelledby="login-title">
<div className="auth-brand">
<span aria-hidden="true" />
<strong>Jyotisha</strong>
</div>
<h1 id="login-title">欢迎回来</h1>
<p className="page-intro">
邮箱验证码登录,
<span className="phrase-nowrap">新邮箱将自动创建账户。</span>
</p>
{!sent ? (
<form key="email" className="stack-form auth-step" onSubmit={sendOtp}>
<label htmlFor="login-email">邮箱</label>
<input
id="login-email"
type="email"
autoComplete="email"
inputMode="email"
required
autoFocus
value={email}
onChange={(event) => {
setEmail(event.target.value);
setError("");
setNotice("");
}}
placeholder="you@example.com"
/>
<button
className="button-primary"
type="submit"
disabled={!email.trim() || busy}
>
{busy ? "发送中" : "发送验证码"}
</button>
</form>
) : (
<form key="otp" className="stack-form auth-step" onSubmit={verifyOtp}>
<label htmlFor="login-token">邮箱验证码</label>
<input
id="login-token"
className="otp-input"
type="text"
autoComplete="one-time-code"
inputMode="numeric"
pattern="[0-9]*"
minLength={6}
maxLength={6}
required
autoFocus
value={token}
onChange={(event) => {
setToken(event.target.value.replace(/\D/g, "").slice(0, 6));
setError("");
}}
/>
<button
className="button-primary"
type="submit"
disabled={!token || busy}
>
{busy ? "验证中" : "验证并登录"}
</button>
<div className="inline-actions">
<button type="button" disabled={busy} onClick={() => void sendOtp()}>
{busy ? "发送中" : "重新发送验证码"}
</button>
<button type="button" disabled={busy} onClick={changeEmail}>
更换邮箱
</button>
</div>
</form>
)}
{error && (
<p className="form-error" role="alert">
{error}
</p>
)}
{notice && (
<p className="form-success" role="status">
{notice}
</p>
)}
</section>
</div>
</main>
);
}
@@ -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(); };
@@ -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<boolean>;
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",
});
}
},
},
},
},
}
: {}),
};
}
+121
View File
@@ -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<AdminRoleRow>(
`
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<typeof betterAuth>;
admin: ReturnType<typeof betterAuth>;
}
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;
}
+58
View File
@@ -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<OtpClientResult>;
};
signIn: {
emailOtp(input: {
email: string;
otp: string;
}): Promise<OtpClientResult>;
};
}
export interface SelfHostedOtpActions {
send(email: string): Promise<void>;
verify(email: string, otp: string): Promise<void>;
}
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,
);
+135
View File
@@ -0,0 +1,135 @@
type IdentityEnvironment = Record<string, string | undefined>;
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);
}
@@ -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<void>;
}
export interface IdentityUser {
id: string;
email: string;
emailVerified: boolean;
name: string;
image: string | null;
role: string[];
}
export interface IdentitySession {
user: IdentityUser;
expiresAt: Date;
}
@@ -0,0 +1,12 @@
import type {
EmailOtpMessage,
EmailOtpSender,
} from "../contracts.ts";
export class FakeEmailOtpSender implements EmailOtpSender {
readonly messages: EmailOtpMessage[] = [];
async send(message: EmailOtpMessage): Promise<void> {
this.messages.push({ ...message });
}
}
@@ -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<EmailOtpType, string> = {
"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<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
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<void> {
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: `<p>${escapeHtml(subject)}</p><p><strong>${escapedOtp}</strong></p><p>This code expires in five minutes.</p>`,
}),
});
if (!response.ok) throw new Error(safeDeliveryError);
} catch {
throw new Error(safeDeliveryError);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import type { SelfHostedIdentityConfig } from "./config.ts";
import type { IdentitySurface } from "./contracts.ts";
export type IdentityRequestHandler = (
request: Request,
) => Response | Promise<Response>;
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<IdentitySurface, IdentityAuthHandlers>,
): 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") };
}
+65
View File
@@ -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;
+80
View File
@@ -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<RawIdentitySession | null>;
}
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<IdentitySession | null> {
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<IdentityUser> {
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<IdentityUser> {
const user = await requireIdentityUser(reader, requestHeaders);
if (!user.role.includes("admin")) {
throw new IdentityAuthorizationError("Administrator access required", 403);
}
return user;
}
+10 -4
View File
@@ -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);
}
});
@@ -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<boolean>("document.body.textContent.includes('当前判断')") ?? Promise.resolve(false),
"async initial turn",
() => cdp?.evaluate<boolean>(`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<{
@@ -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();
}
});
+23
View File
@@ -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": {}
}
]
+41 -1
View File
@@ -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 <login@staging.jyotisha.chat>",
];
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 {
@@ -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 <login@staging.jyotisha.chat>",
};
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<string, Record<string, unknown>>([
["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"]);
});
@@ -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<string, unknown>,
): 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 <login@staging.jyotisha.chat>",
};
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();
}
});
+154
View File
@@ -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 <login@staging.jyotisha.chat>",
};
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<string, string>, 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");
});
@@ -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 <login@staging.jyotisha.chat>",
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 <login@staging.jyotisha.chat>");
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 <login@staging.jyotisha.chat>",
fetchImpl: async (_input, init) => {
body = String(init?.body);
return Response.json({ id: "email_123" });
},
});
await sender.send({ ...message, otp: "<script>alert(1)</script>" });
const parsed = JSON.parse(body) as { html: string };
assert.doesNotMatch(parsed.html, /<script>/);
assert.match(parsed.html, /&lt;script&gt;/);
});
test("Resend errors exclude secrets, OTPs, recipients, and provider bodies", async () => {
const apiKey = "re_secret_should_not_leak";
const rawProviderError = "provider diagnostic should not leak";
const sender = new ResendEmailOtpSender({
apiKey,
from: "Jyotisha <login@staging.jyotisha.chat>",
fetchImpl: async () =>
new Response(rawProviderError, {
status: 429,
statusText: "Too Many Requests",
}),
});
await assert.rejects(sender.send(message), (error: unknown) => {
assert.ok(error instanceof Error);
assert.equal(error.message, "OTP email delivery failed");
assert.doesNotMatch(error.message, new RegExp(apiKey));
assert.doesNotMatch(error.message, new RegExp(message.otp));
assert.doesNotMatch(error.message, new RegExp(message.email));
assert.doesNotMatch(error.message, new RegExp(rawProviderError));
return true;
});
});
test("network errors use the same safe delivery error", async () => {
const sender = new ResendEmailOtpSender({
apiKey: "re_test_secret_value",
from: "Jyotisha <login@staging.jyotisha.chat>",
fetchImpl: async () => {
throw new Error("socket includes recipient person@example.com");
},
});
await assert.rejects(
sender.send(message),
new Error("OTP email delivery failed"),
);
});
@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts";
import {
createHostIsolatedAuthHandlers,
resolveIdentitySurface,
} from "../src/modules/identity/host.ts";
const config: SelfHostedIdentityConfig = {
provider: "self-hosted",
databaseUrl: "postgresql://identity_runtime:test@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",
resendFrom: "Jyotisha <login@staging.jyotisha.chat>",
};
test("identity surface matching is exact, case-insensitive, and port-normalized", () => {
assert.equal(resolveIdentitySurface("staging.jyotisha.chat", config), "user");
assert.equal(resolveIdentitySurface("STAGING.JYOTISHA.CHAT:443", config), "user");
assert.equal(
resolveIdentitySurface("admin.staging.jyotisha.chat", config),
"admin",
);
for (const host of [
null,
"",
"evil-staging.jyotisha.chat",
"staging.jyotisha.chat.evil.example",
"staging.jyotisha.chat,evil.example",
"staging.jyotisha.chat/path",
"user@staging.jyotisha.chat",
" staging.jyotisha.chat",
]) {
assert.equal(resolveIdentitySurface(host, config), null, String(host));
}
});
test("auth route dispatches only to the exact matching host", async () => {
const calls: string[] = [];
const handlers = createHostIsolatedAuthHandlers(config, {
user: {
GET: async () => {
calls.push("user:get");
return new Response("user");
},
POST: async () => {
calls.push("user:post");
return new Response("user");
},
},
admin: {
GET: async () => {
calls.push("admin:get");
return new Response("admin");
},
POST: async () => {
calls.push("admin:post");
return new Response("admin");
},
},
});
const userResponse = await handlers.POST(
new Request("https://internal/api/auth/email-otp/send-verification-otp", {
method: "POST",
headers: { host: "staging.jyotisha.chat" },
}),
);
assert.equal(await userResponse.text(), "user");
const adminResponse = await handlers.GET(
new Request("https://internal/api/auth/get-session", {
headers: { host: "admin.staging.jyotisha.chat" },
}),
);
assert.equal(await adminResponse.text(), "admin");
assert.deepEqual(calls, ["user:post", "admin:get"]);
});
test("unknown hosts fail closed before an auth handler reads cookies", async () => {
let calls = 0;
const handler = async () => {
calls += 1;
return new Response("unexpected");
};
const handlers = createHostIsolatedAuthHandlers(config, {
user: { GET: handler, POST: handler },
admin: { GET: handler, POST: handler },
});
const response = await handlers.GET(
new Request("https://internal/api/auth/get-session", {
headers: {
host: "staging.jyotisha.chat.evil.example",
cookie: "jyotisha-admin.session_token=attacker-controlled",
},
}),
);
assert.equal(response.status, 421);
assert.equal(calls, 0);
assert.equal(response.headers.has("set-cookie"), false);
});
test("user host cannot reach Better Auth admin endpoints", async () => {
let calls = 0;
const handler = async () => {
calls += 1;
return new Response("unexpected");
};
const handlers = createHostIsolatedAuthHandlers(config, {
user: { GET: handler, POST: handler },
admin: { GET: handler, POST: handler },
});
const response = await handlers.POST(
new Request("https://internal/api/auth/admin/set-role", {
method: "POST",
headers: { host: "staging.jyotisha.chat" },
}),
);
assert.equal(response.status, 404);
assert.equal(calls, 0);
});
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createSelfHostedOtpActions } from "../src/modules/identity/client.ts";
test("login page selects the auth provider from server-only validated config", () => {
const page = readFileSync(
new URL("../src/app/login/page.tsx", import.meta.url),
"utf8",
);
assert.doesNotMatch(page, /["']use client["']/);
assert.match(page, /export const dynamic = "force-dynamic"/);
assert.match(page, /readIdentityConfig\(process\.env\)/);
assert.match(page, /isSelfHostedIdentityEnabled\(process\.env\)/);
assert.match(page, /resolveIdentitySurface/);
assert.match(page, /surface === "admin"/);
assert.match(page, /provider=\{provider\}/);
assert.doesNotMatch(page, /NEXT_PUBLIC_AUTH_PROVIDER/);
});
test("self-hosted OTP actions call Better Auth without browser token storage", async () => {
const calls: Array<{ operation: string; input: Record<string, string> }> = [];
const actions = createSelfHostedOtpActions({
emailOtp: {
async sendVerificationOtp(input) {
calls.push({ operation: "send", input });
return { data: { success: true }, error: null };
},
},
signIn: {
async emailOtp(input) {
calls.push({ operation: "verify", input });
return { data: { user: { id: "user-id" } }, error: null };
},
},
});
await actions.send(" Person@Example.com ");
await actions.verify(" Person@Example.com ", "123456");
assert.deepEqual(calls, [
{
operation: "send",
input: { email: "person@example.com", type: "sign-in" },
},
{
operation: "verify",
input: { email: "person@example.com", otp: "123456" },
},
]);
const clientSource = readFileSync(
new URL("../src/modules/identity/client.ts", import.meta.url),
"utf8",
);
assert.doesNotMatch(clientSource, /localStorage|sessionStorage/);
});
test("self-hosted OTP actions expose generic enumeration-safe errors", async () => {
const actions = createSelfHostedOtpActions({
emailOtp: {
async sendVerificationOtp() {
return {
data: null,
error: { message: "database says account does not exist" },
};
},
},
signIn: {
async emailOtp() {
return {
data: null,
error: { message: "internal OTP hash 123456 mismatch" },
};
},
},
});
await assert.rejects(
actions.send("missing@example.com"),
new Error("暂时无法发送验证码,请稍后再试"),
);
await assert.rejects(
actions.verify("missing@example.com", "123456"),
new Error("验证码错误或已过期,请重新获取"),
);
});
+77
View File
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
IdentityAuthorizationError,
readIdentitySession,
requireIdentityAdmin,
requireIdentityUser,
type IdentitySessionReader,
} from "../src/modules/identity/session.ts";
function readerFor(role: string | null): IdentitySessionReader {
return {
async getSession() {
if (role === null) return null;
return {
session: { expiresAt: new Date("2030-01-01T00:00:00.000Z") },
user: {
id: "018f4e6d-7a11-7000-8000-000000000001",
email: "Person@Example.com",
emailVerified: true,
name: "Person",
image: null,
role,
},
};
},
};
}
test("identity session mapper returns a narrow normalized DTO", async () => {
const session = await readIdentitySession(readerFor("user,admin"), new Headers());
assert.deepEqual(session, {
expiresAt: new Date("2030-01-01T00:00:00.000Z"),
user: {
id: "018f4e6d-7a11-7000-8000-000000000001",
email: "person@example.com",
emailVerified: true,
name: "Person",
image: null,
role: ["user", "admin"],
},
});
assert.equal("token" in (session ?? {}), false);
});
test("require user rejects a missing server-side session", async () => {
await assert.rejects(
requireIdentityUser(readerFor(null), new Headers({ cookie: "present=1" })),
(error: unknown) => {
assert.ok(error instanceof IdentityAuthorizationError);
assert.equal(error.status, 401);
return true;
},
);
});
test("require admin checks persisted session roles, not cookie presence", async () => {
await assert.rejects(
requireIdentityAdmin(
readerFor("user"),
new Headers({ cookie: "jyotisha-admin.session_token=present" }),
),
(error: unknown) => {
assert.ok(error instanceof IdentityAuthorizationError);
assert.equal(error.status, 403);
return true;
},
);
const admin = await requireIdentityAdmin(
readerFor("user,admin"),
new Headers(),
);
assert.deepEqual(admin.role, ["user", "admin"]);
});
+116
View File
@@ -0,0 +1,116 @@
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 {
applyIdentityUsers,
normalizeSupabaseUsers,
} from "../scripts/import-supabase-auth-users.mjs";
const fixturePath = fileURLToPath(
new URL("./fixtures/supabase-auth-users.json", import.meta.url),
);
const scriptPath = fileURLToPath(
new URL("../scripts/import-supabase-auth-users.mjs", import.meta.url),
);
test("Supabase user transform preserves portable identity fields only", () => {
const source = JSON.parse(readFileSync(fixturePath, "utf8"));
const users = normalizeSupabaseUsers(source);
assert.deepEqual(users, [
{
id: "018f4e6d-7a11-7000-8000-000000000001",
email: "person@example.com",
emailVerified: true,
emailVerifiedAt: new Date("2026-07-01T01:02:03.000Z"),
name: "Person One",
image: "https://example.com/person.png",
createdAt: new Date("2026-06-01T01:02:03.000Z"),
updatedAt: new Date("2026-07-02T01:02:03.000Z"),
},
{
id: "018f4e6d-7a11-7000-8000-000000000002",
email: "second@example.com",
emailVerified: false,
emailVerifiedAt: null,
name: "second",
image: null,
createdAt: new Date("2026-06-02T01:02:03.000Z"),
updatedAt: new Date("2026-06-02T01:02:03.000Z"),
},
]);
const serialized = JSON.stringify(users);
assert.doesNotMatch(
serialized,
/encrypted_password|last_sign_in_at|raw_user_meta_data|must-not-be-imported/,
);
});
test("Supabase user transform aborts duplicate canonical emails", () => {
const source = JSON.parse(readFileSync(fixturePath, "utf8"));
source.push({
...source[1],
id: "018f4e6d-7a11-7000-8000-000000000003",
email: " SECOND@example.com ",
});
assert.throws(
() => normalizeSupabaseUsers(source),
new Error("source contains duplicate canonical emails"),
);
});
test("identity user apply is transactional, parameterized, and rerunnable", async () => {
const users = normalizeSupabaseUsers(
JSON.parse(readFileSync(fixturePath, "utf8")),
);
const calls: Array<{ text: string; values?: unknown[] }> = [];
const client = {
async query(text: string, values?: unknown[]) {
calls.push({ text, values });
return { rowCount: 1 };
},
};
await applyIdentityUsers(client, users);
await applyIdentityUsers(client, users);
assert.equal(calls.filter((call) => call.text === "BEGIN").length, 2);
assert.equal(calls.filter((call) => call.text === "COMMIT").length, 2);
const upserts = calls.filter((call) => /insert into identity\.users/.test(call.text));
assert.equal(upserts.length, 4);
assert.ok(upserts.every((call) => /on conflict \(id\) do update/.test(call.text)));
assert.ok(upserts.every((call) => /\$1/.test(call.text)));
assert.ok(upserts.every((call) => !call.text.includes("person@example.com")));
});
test("CLI defaults to a redacted dry-run without a database URL", () => {
const env = { ...process.env };
delete env.IDENTITY_DATABASE_URL;
const result = spawnSync(process.execPath, [scriptPath, fixturePath], {
encoding: "utf8",
env,
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /"mode":"dry-run"/);
assert.match(result.stdout, /"users":2/);
assert.doesNotMatch(result.stdout, /person@example\.com|second@example\.com/);
});
test("CLI apply mode requires an identity database URL without printing inputs", () => {
const env = { ...process.env };
delete env.IDENTITY_DATABASE_URL;
const result = spawnSync(
process.execPath,
[scriptPath, fixturePath, "--apply"],
{ encoding: "utf8", env },
);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /IDENTITY_DATABASE_URL is required for --apply/);
assert.doesNotMatch(result.stderr, /person@example\.com|must-not-be-imported/);
});
+2 -1
View File
@@ -1570,6 +1570,7 @@ def test_capability_audit_scans_registry_and_local_sources() -> None:
assert audit['surfaces']['app_routes'] == ['admin/codes', 'home', 'login']
assert set(audit['surfaces']['app_visible_topics']) == {
'Birth Rectification',
'Case Validation',
'Synastry 16-factor',
}
assert '/api/deep_varga_avastha' in audit['surfaces']['api_endpoints']
@@ -1599,13 +1600,13 @@ def test_capability_audit_scans_registry_and_local_sources() -> None:
assert all('ux_next_action' in row for row in ux['next_queue'])
ux_by_id = {row['id']: row for row in ux['rows']}
assert ux_by_id['birth_time_rectifier']['ux_level'] in {'excellent', 'usable'}
assert ux_by_id['case_validator']['ux_level'] == 'excellent'
assert ux_by_id['synastry_16factor']['ux_level'] == 'excellent'
for technique_id in [
'ashtakavarga_pav',
'ashtakavarga_sodhita',
'bhava_bala',
'career_engine',
'case_validator',
'deep_varga_avastha',
'divisional_yoga',
'kakshya',