Merge pull request #25 from jesse-ux/codex/self-hosted-identity
feat: add self-hosted identity foundation
This commit is contained in:
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
Generated
+355
@@ -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",
|
||||
|
||||
@@ -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
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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") };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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": {}
|
||||
}
|
||||
]
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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, /<script>/);
|
||||
});
|
||||
|
||||
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("验证码错误或已过期,请重新获取"),
|
||||
);
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user