diff --git a/.gitea/workflows/backend-quality-gate.yml b/.gitea/workflows/backend-quality-gate.yml index f5013a5c..41f464a3 100644 --- a/.gitea/workflows/backend-quality-gate.yml +++ b/.gitea/workflows/backend-quality-gate.yml @@ -82,7 +82,6 @@ jobs: DATABASE_ENV_FILE='../.env.staging.database' \ CADDYFILE_PATH='./Caddyfile.staging' \ SITE_ADDRESS='https://staging.jyotisha.chat' \ - ADMIN_SITE_ADDRESS='https://admin.staging.jyotisha.chat' \ docker compose -p jyotisha-staging --env-file .env.staging \ -f deploy/docker-compose.server.yml \ -f deploy/docker-compose.postgres.yml \ @@ -96,7 +95,6 @@ jobs: DATABASE_ENV_FILE='../.env.staging.database' \ CADDYFILE_PATH='./Caddyfile.staging' \ SITE_ADDRESS='https://staging.jyotisha.chat' \ - ADMIN_SITE_ADDRESS='https://admin.staging.jyotisha.chat' \ docker compose -p jyotisha-staging --env-file .env.staging \ -f deploy/docker-compose.server.yml \ -f deploy/docker-compose.postgres.yml \ diff --git a/deploy/.env.staging.identity.example b/deploy/.env.staging.identity.example index c74373bb..36f6a653 100644 --- a/deploy/.env.staging.identity.example +++ b/deploy/.env.staging.identity.example @@ -3,19 +3,16 @@ APP_ENV_FILE=../.env.staging CADDYFILE_PATH=./Caddyfile.staging SITE_ADDRESS=https://staging.jyotisha.chat -ADMIN_SITE_ADDRESS=https://admin.staging.jyotisha.chat # Staging-only cutover: identity and business data both use the private local # PostgreSQL service. Production remains on Supabase until a separate cutover. AUTH_PROVIDER=self-hosted SELF_HOSTED_IDENTITY_ENABLED=true AUTH_USER_ORIGIN=https://staging.jyotisha.chat -AUTH_ADMIN_ORIGIN=https://admin.staging.jyotisha.chat IDENTITY_DATABASE_URL=postgresql://identity_runtime:@postgres:5432/jyotisha APP_DATABASE_URL=postgresql://app_runtime:@postgres:5432/jyotisha ADMIN_DATABASE_URL=postgresql://admin_runtime:@postgres:5432/jyotisha BETTER_AUTH_USER_SECRET= -BETTER_AUTH_ADMIN_SECRET= RESEND_API_KEY= RESEND_FROM_EMAIL=Jyotisha Staging ADMIN_EMAILS= diff --git a/deploy/Caddyfile.staging b/deploy/Caddyfile.staging index 0daab86e..66bbb59c 100644 --- a/deploy/Caddyfile.staging +++ b/deploy/Caddyfile.staging @@ -1,21 +1,4 @@ {$SITE_ADDRESS:https://staging.jyotisha.chat} { encode zstd gzip - - @adminPaths path /admin /admin/* /api/admin/* - respond @adminPaths "Not found" 404 - reverse_proxy web:3000 } - -{$ADMIN_SITE_ADDRESS:https://admin.staging.jyotisha.chat} { - encode zstd gzip - - @adminRoot path / - redir @adminRoot /admin/codes 302 - - @adminSurface path /login /admin /admin/* /api/admin/* /api/auth/* /_next/* /jyotish-logo.png /favicon.ico - handle @adminSurface { - reverse_proxy web:3000 - } - respond "Not found" 404 -} diff --git a/deploy/README.md b/deploy/README.md index fc9142b9..74dd1f97 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -176,10 +176,9 @@ 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 ``` -Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the three role-specific server-only database URLs, separate user/admin Better Auth secrets, origins, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production remains on Supabase and is not changed by the staging workflow. See `docs/operations/self-hosted-identity.md` for validation and rollback commands. +Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the three role-specific server-only database URLs, the single `AUTH_USER_ORIGIN` and `BETTER_AUTH_USER_SECRET`, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. The main-site Better Auth user session is also used by `/admin`; persisted `identity.users.role=admin` is the only self-hosted backend role, while `viewer` and ordinary users are denied. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production remains on Supabase and is not changed by the staging workflow. See `docs/operations/self-hosted-identity.md` for validation 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. diff --git a/deploy/configure-staging-rectification-rollout.sh b/deploy/configure-staging-rectification-rollout.sh index 4edfbf9b..c276877b 100755 --- a/deploy/configure-staging-rectification-rollout.sh +++ b/deploy/configure-staging-rectification-rollout.sh @@ -140,7 +140,6 @@ 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="$EXPECTED_DEPLOY_SHA" compose=(docker compose -p jyotisha-staging --env-file .env.staging "${compose_files[@]}") diff --git a/deploy/docker-compose.server.yml b/deploy/docker-compose.server.yml index 2ceab51d..04222ca2 100644 --- a/deploy/docker-compose.server.yml +++ b/deploy/docker-compose.server.yml @@ -53,7 +53,6 @@ services: restart: unless-stopped environment: SITE_ADDRESS: ${SITE_ADDRESS:-https://jyotisha.chat} - ADMIN_SITE_ADDRESS: ${ADMIN_SITE_ADDRESS:-https://admin.staging.jyotisha.chat} ports: - "80:80" - "443:443" diff --git a/deploy/run-staging-deploy.sh b/deploy/run-staging-deploy.sh index 931ad5d7..3b95cb54 100755 --- a/deploy/run-staging-deploy.sh +++ b/deploy/run-staging-deploy.sh @@ -128,7 +128,6 @@ 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 @@ -186,7 +185,6 @@ verify_container_image rectification-v4-worker "$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; @@ -198,15 +196,10 @@ 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 !== 302 || - adminRoot.headers.get("location") !== "/admin/codes" -) process.exit(1); -const adminSession = await fetch(`${process.env.STAGING_ADMIN_URL}/api/auth/get-session`); -if (!adminSession.ok) process.exit(1); +const adminPage = await fetch(`${process.env.STAGING_URL}/admin`, { redirect: "manual" }); +if (adminPage.status !== 307 || adminPage.headers.get("location") !== "/login") process.exit(1); +const adminApi = await fetch(`${process.env.STAGING_URL}/api/admin/session`); +if (adminApi.status !== 401) process.exit(1); const account = await fetch(`${process.env.STAGING_URL}/api/account`); if (account.status !== 401) process.exit(1); const publicHealth = await fetch(`${process.env.STAGING_URL}/api/health`); diff --git a/deploy/validate-staging-env.sh b/deploy/validate-staging-env.sh index d54605cb..93bd3ec7 100755 --- a/deploy/validate-staging-env.sh +++ b/deploy/validate-staging-env.sh @@ -41,11 +41,9 @@ 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 self-hosted 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" @@ -88,13 +86,6 @@ if ! [[ "$admin_database_url" =~ ^postgresql://admin_runtime:([A-Za-z0-9._~-]|%[ 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 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 31188ccf..81ff0854 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1581,7 +1581,7 @@ ## BUG-087 | self-hosted staging 管理员看不到独立后台入口 -- 状态:resolved +- 状态:superseded by BUG-092 - 首次发现:2026-07-29 - 最近更新:2026-07-29 - 影响面:self-hosted staging 账户菜单、`GET /api/account`、独立后台入口;不影响后台独立登录与 `requireAdminSession` @@ -1591,6 +1591,23 @@ - 修复:self-hosted 分支通过现有 `ADMIN_DATABASE_URL` 管理只读连接查询当前用户的 `identity.users.role`,仅 `admin` 或 `viewer` 可见入口,且不使用 `ADMIN_EMAILS` 替代角色授权;`GET /api/account` 在服务端解析身份配置并返回 `AUTH_ADMIN_ORIGIN + /admin/codes`,Supabase 模式继续返回 `/admin/codes`;账户与侧栏类型透传该 URL,并将文案改为“后台管理”。后台独立登录和 `requireAdminSession` 保持不变。 - 验证:`frontend/tests/admin-contracts.test.ts`、`frontend/tests/admin-users-contract.test.ts`、`frontend/tests/account-api.test.ts`、`frontend/tests/sidebar-contract.test.ts` 锁定持久化角色、独立后台 URL、服务端环境边界和后台写权限门禁;目标 TypeScript、构建与 staging 登录态 smoke 结果另行记录。 - 防复发:self-hosted 主站入口发现必须以 `identity.users.role` 为授权事实,不能退回邮箱 allowlist;客户端不得读取后台 origin 环境变量或硬编码主站 `/admin` 路径;后台 API 必须继续独立执行 `requireAdminSession`,入口可见性不得被当作授权。 -- 相关记录:BUG-010、BUG-083、BUG-084 +- 相关记录:BUG-010、BUG-083、BUG-084、BUG-092 - 复发自:BUG-010 -- 修复版本:待提交(本地可测) +- 修复版本:已由 BUG-092 的同域单会话架构取代 + +## BUG-092 | self-hosted staging 双域后台与主站会话模型冲突 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:staging Better Auth 配置、后台页面与 API、登录、账户入口、Caddy、部署校验和 smoke;生产配置不变。 +- 用户现象:管理员需要第二套后台域名和浏览器会话才能进入后台,主站登录态不能直接使用;`viewer` 还被当作后台只读角色,与仅数据库 `admin` 可进入的产品合同冲突。 +- 触发条件:self-hosted staging 同时配置用户与后台 origin/secret、Caddy 拆分两个站点,并按 Host 选择 Better Auth 实例。 +- 根因:早期隔离设计把后台浏览器 surface 当成第二套身份系统,导致入口发现、登录、Cookie、部署变量和授权策略重复;同时把入口可见性与 API 权限错误扩展到 `viewer`。 +- 架构决策:后台复用主站 Better Auth user session;`identity.users.role` 的持久化 `admin` 是唯一后台授权事实。Better Auth 插件的 `/api/auth/admin` endpoint 继续在主站 fail-closed `404`,未知 Host 继续 `421`。 +- 修复:删除活动运行时后台 origin/secret 与 `services.admin`,服务端数据 client 和 `requireAdminSession` 统一读取 user session;后台 layout 增加服务端 gate,匿名转 `/login`、非 admin 不渲染;所有后台 API 保留独立 guard,payments/packages 改用 `requireAdminSession`;`isAdminUser`、账户入口和 Refine policy 收敛为 admin-only;登录取消 Host 分流;staging Caddy、Compose、环境校验、部署脚本、工作流和 smoke 收敛为同域。 +- 验证:身份 config/host/auth、admin policy/contracts、account/sidebar/login、部署/工作流与 admin layout/API guard 合同更新;针对性测试、TypeScript、Next build 与 `git diff --check` 结果记录在本次交付报告。生产部署未执行。 +- 防复发:活动运行配置和测试不得重新引入独立后台域名、`AUTH_ADMIN_ORIGIN`、`BETTER_AUTH_ADMIN_SECRET` 或浏览器 admin auth service;`viewer` 对后台入口、页面、读 API 和写 API 均必须为 `403`;入口可见性不能替代 route guard。 +- 相关记录:BUG-010、BUG-083、BUG-084、BUG-087 +- 复发自:BUG-087 +- 修复版本:本地实现,待 staging 验收 diff --git a/docs/operations/self-hosted-identity.md b/docs/operations/self-hosted-identity.md index 2e7009cb..0b1e634d 100644 --- a/docs/operations/self-hosted-identity.md +++ b/docs/operations/self-hosted-identity.md @@ -4,29 +4,27 @@ Staging uses Better Auth and the private local PostgreSQL cluster for both ident ## Staging mode -Keep these two values exactly as shown: +Keep these values exactly as shown: ```dotenv AUTH_PROVIDER=self-hosted SELF_HOSTED_IDENTITY_ENABLED=true +AUTH_USER_ORIGIN=https://staging.jyotisha.chat ``` -This makes both login hosts use isolated Better Auth surfaces. Public and admin sessions have different secrets and host-only cookie prefixes. Server routes translate the Better Auth session into PostgreSQL request claims and use the reviewed existing RLS/RPC business contract. The browser uses only same-origin APIs and does not need Supabase configuration. +Staging has one browser identity surface on the main site. The same Better Auth user session serves ordinary pages and `/admin`; there is no independent admin origin, secret, cookie, or login host. Server routes translate that session into PostgreSQL request claims. Admin authorization then reads the persisted `identity.users.role` value and permits only `admin`; `viewer` and ordinary users receive `403`. The main auth route continues to return `404` for Better Auth `/api/auth/admin` plugin endpoints, and unknown hosts fail closed with `421`. 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: +Generate `BETTER_AUTH_USER_SECRET` 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`, `APP_DATABASE_URL`, and `ADMIN_DATABASE_URL` use their matching passwords from `.env.staging.database`, percent-encoded only in each URL password component. All three must point to the private Compose hostname `postgres:5432/jyotisha`; never publish PostgreSQL on a host port. +Do not reuse it as a PostgreSQL password. `IDENTITY_DATABASE_URL`, `APP_DATABASE_URL`, and `ADMIN_DATABASE_URL` use their matching passwords from `.env.staging.database`, percent-encoded only in each URL password component. All three 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. - -Set `ADMIN_EMAILS` to the staging administrator allowlist. Generate an independent `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN` and place the same value in the shared application env consumed by the web and private API containers; do not reuse a database or Better Auth secret. +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. `ADMIN_EMAILS` remains relevant only to the legacy Supabase production path; it is not self-hosted admin authorization. Validate without printing values: @@ -38,17 +36,17 @@ 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 workflow first ensures the compatibility roles exist, then applies the identity schema, the local `auth` compatibility layer, and all reviewed business migrations under the migration ledger. Better Auth users are transactionally projected into `auth.users`, which creates their business profile through the existing trigger. +Apply the reviewed PostgreSQL migrations through the existing `Migrate Staging Database` workflow before deploying the web image. Better Auth users are transactionally projected into `auth.users`, which creates their business profile through the existing trigger. -After deployment: +After deployment, verify the single-domain contract: ```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://staging.jyotisha.chat/admin/codes)" = 404 +curl -fsS https://staging.jyotisha.chat/login >/dev/null +test "$(curl -sS -o /dev/null -w '%{http_code}' https://staging.jyotisha.chat/api/admin/session)" = 401 +test "$(curl -sS -o /dev/null -w '%{http_code}' https://staging.jyotisha.chat/api/account)" = 401 ``` -The admin root redirects to `/admin/codes`; the public host rejects `/admin` and `/api/admin` paths. 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. +An anonymous `/admin` request redirects to `/login`. An authenticated non-admin, including a persisted `viewer`, must not render the admin layout and every `/api/admin/*` route must independently return `403`. Promote a staging user only through a reviewed database operation; the persisted role must include `admin` before the main-site session can enter the backend. ## Optional import rehearsal @@ -75,8 +73,8 @@ Reruns are idempotent by UUID and the whole import is transactional. Duplicate c ## Rollback and rotation -An application rollback must use a previously validated staging image and does not reverse database migrations. Existing self-hosted sessions and data remain in PostgreSQL; do not delete identity or business rows during application rollback. Returning staging to Supabase would require a separate reviewed data-reconciliation and provider-switch change, not an environment-only toggle. +An application rollback must use a previously validated staging image and does not reverse database migrations. Existing self-hosted sessions and data remain in PostgreSQL; do not delete identity or business rows during application rollback. Returning staging to Supabase requires a separate reviewed data-reconciliation and provider-switch change. -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. +Rotating `BETTER_AUTH_USER_SECRET` invalidates all self-hosted browser sessions, including admins. Restart the web service and verify login, anonymous admin API rejection, admin access, and viewer rejection. Rotate a leaked Resend key in Resend first, replace the server value, then restart. Never print old or new values. Production `AUTH_PROVIDER=self-hosted` remains blocked until data reconciliation passes, production backups and restore drills exist, operational monitoring is ready, and a separate reviewed production cutover plan is approved. diff --git a/docs/superpowers/plans/2026-07-21-self-hosted-identity.md b/docs/superpowers/plans/2026-07-21-self-hosted-identity.md index aaa37b31..53c61c26 100644 --- a/docs/superpowers/plans/2026-07-21-self-hosted-identity.md +++ b/docs/superpowers/plans/2026-07-21-self-hosted-identity.md @@ -1,3 +1,5 @@ +> Superseded 2026-07-29: staging browser identity and admin access now use one main-site Better Auth user session; the dual-domain admin surface in this historical plan is inactive. + # Self-Hosted Identity Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. diff --git a/docs/superpowers/specs/2026-07-20-supabase-exit-backend-design.md b/docs/superpowers/specs/2026-07-20-supabase-exit-backend-design.md index 1484b232..cb86551c 100644 --- a/docs/superpowers/specs/2026-07-20-supabase-exit-backend-design.md +++ b/docs/superpowers/specs/2026-07-20-supabase-exit-backend-design.md @@ -1,3 +1,5 @@ +> Superseded 2026-07-29: staging browser identity and admin access now use one main-site Better Auth user session; the dual-domain admin surface in this historical specification is inactive. + # Jyotisha Supabase Exit and Self-Hosted Backend Design Date: 2026-07-20 diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index b331ca7b..c7e13d4b 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -1,11 +1,21 @@ import "@refinedev/antd/dist/reset.css"; import "antd/dist/reset.css"; import type { ReactNode } from "react"; +import { redirect } from "next/navigation"; import { AdminApp } from "@/components/admin/admin-app"; +import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth"; export const dynamic = "force-dynamic"; -export default function AdminLayout({ children }: { children: ReactNode }) { +export default async function AdminLayout({ children }: { children: ReactNode }) { + try { + await requireAdminSession("read"); + } catch (error) { + if (error instanceof AdminAuthorizationError) { + redirect(error.status === 401 ? "/login" : "/"); + } + throw error; + } return {children}; } diff --git a/frontend/src/app/admin/route.ts b/frontend/src/app/admin/route.ts index 43b0bd97..cc04f81d 100644 --- a/frontend/src/app/admin/route.ts +++ b/frontend/src/app/admin/route.ts @@ -1,6 +1,19 @@ -export function GET() { - return new Response(null, { - status: 307, - headers: { location: "/admin/codes" }, - }); +import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth"; + +export async function GET() { + try { + await requireAdminSession("read"); + return new Response(null, { + status: 307, + headers: { location: "/admin/codes" }, + }); + } catch (error) { + if (error instanceof AdminAuthorizationError) { + return new Response(null, { + status: 307, + headers: { location: error.status === 401 ? "/login" : "/" }, + }); + } + throw error; + } } diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index d3b594ee..d40a93e1 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -13,7 +13,6 @@ import { isSupabaseConfigurationError, } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; -import { readIdentityConfig } from "@/modules/identity/config"; export const runtime = "nodejs"; @@ -111,12 +110,7 @@ export async function GET() { Array.isArray(rectificationCaseRows) ? rectificationCaseRows : [], ); const isAdmin = await isAdminUser(user); - const identityConfig = readIdentityConfig(process.env); - const adminUrl = isAdmin - ? identityConfig.provider === "self-hosted" - ? new URL("/admin/codes", identityConfig.adminOrigin).toString() - : "/admin/codes" - : null; + const adminUrl = isAdmin ? "/admin/codes" : null; return NextResponse.json({ user: { id: user.id, email: user.email ?? null }, diff --git a/frontend/src/app/api/admin/packages/route.ts b/frontend/src/app/api/admin/packages/route.ts index f99607a0..bc10b7e6 100644 --- a/frontend/src/app/api/admin/packages/route.ts +++ b/frontend/src/app/api/admin/packages/route.ts @@ -1,19 +1,13 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin"; -import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { requireAdminSession } from "@/lib/admin/auth"; +import { adminErrorResponse } from "@/lib/admin/http"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; export const runtime = "nodejs"; const schema = z.object({ name: z.string().trim().min(1).max(80), description: z.string().trim().max(500), priceCents: z.number().int().positive().max(100_000_000), credits: z.number().int().positive().max(10_000_000), sortOrder: z.number().int().min(-100_000).max(100_000), enabled: z.boolean() }); -async function requireAdmin() { - const client = await createServerSupabaseClient(); - const { data: { user } } = await client.auth.getUser(); - if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); - if (!(await isAdminUser(user))) return NextResponse.json({ error: "无管理员权限" }, { status: 403 }); - return user; -} function output(row: Record) { return { id: row.id, name: row.name, description: row.description, priceCents: row.price_cents, credits: row.credits, sortOrder: row.sort_order, enabled: row.enabled, createdAt: row.created_at, updatedAt: row.updated_at }; } -export async function GET() { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; const { data, error } = await createAdminSupabaseClient().from("payment_packages").select("*").order("sort_order").order("created_at"); if (error) return NextResponse.json({ error: "暂时无法读取套餐" }, { status: 500 }); return NextResponse.json({ packages: (data || []).map(output) }); } -export async function POST(request: Request) { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; const parsed = schema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const p = parsed.data; const { data, error } = await createAdminSupabaseClient().from("payment_packages").insert({ name: p.name, description: p.description, price_cents: p.priceCents, credits: p.credits, sort_order: p.sortOrder, enabled: p.enabled, created_by: auth.id }).select().single(); if (error) return NextResponse.json({ error: "创建套餐失败" }, { status: 500 }); return NextResponse.json({ package: output(data) }, { status: 201 }); } -export async function PATCH(request: Request) { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; const body = await request.json().catch(() => null); const id = typeof body?.id === "string" ? body.id : ""; const parsed = schema.safeParse(body); if (!id || !parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const p = parsed.data; const { data, error } = await createAdminSupabaseClient().from("payment_packages").update({ name: p.name, description: p.description, price_cents: p.priceCents, credits: p.credits, sort_order: p.sortOrder, enabled: p.enabled, updated_at: new Date().toISOString() }).eq("id", id).select().single(); if (error) return NextResponse.json({ error: "更新套餐失败" }, { status: 500 }); return NextResponse.json({ package: output(data) }); } -export async function DELETE(request: Request) { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; const body = await request.json().catch(() => null); if (typeof body?.id !== "string") return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const { error } = await createAdminSupabaseClient().from("payment_packages").update({ enabled: false, updated_at: new Date().toISOString() }).eq("id", body.id); if (error) return NextResponse.json({ error: "停用套餐失败" }, { status: 500 }); return NextResponse.json({ ok: true }); } +export async function GET() { try { await requireAdminSession("read"); const { data, error } = await createAdminSupabaseClient().from("payment_packages").select("*").order("sort_order").order("created_at"); if (error) return NextResponse.json({ error: "暂时无法读取套餐" }, { status: 500 }); return NextResponse.json({ packages: (data || []).map(output) }); } catch (error) { return adminErrorResponse(error); } } +export async function POST(request: Request) { try { const auth = await requireAdminSession("write"); const parsed = schema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const p = parsed.data; const { data, error } = await createAdminSupabaseClient().from("payment_packages").insert({ name: p.name, description: p.description, price_cents: p.priceCents, credits: p.credits, sort_order: p.sortOrder, enabled: p.enabled, created_by: auth.user.id }).select().single(); if (error) return NextResponse.json({ error: "创建套餐失败" }, { status: 500 }); return NextResponse.json({ package: output(data) }, { status: 201 }); } catch (error) { return adminErrorResponse(error); } } +export async function PATCH(request: Request) { try { await requireAdminSession("write"); const body = await request.json().catch(() => null); const id = typeof body?.id === "string" ? body.id : ""; const parsed = schema.safeParse(body); if (!id || !parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const p = parsed.data; const { data, error } = await createAdminSupabaseClient().from("payment_packages").update({ name: p.name, description: p.description, price_cents: p.priceCents, credits: p.credits, sort_order: p.sortOrder, enabled: p.enabled, updated_at: new Date().toISOString() }).eq("id", id).select().single(); if (error) return NextResponse.json({ error: "更新套餐失败" }, { status: 500 }); return NextResponse.json({ package: output(data) }); } catch (error) { return adminErrorResponse(error); } } +export async function DELETE(request: Request) { try { await requireAdminSession("write"); const body = await request.json().catch(() => null); if (typeof body?.id !== "string") return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const { error } = await createAdminSupabaseClient().from("payment_packages").update({ enabled: false, updated_at: new Date().toISOString() }).eq("id", body.id); if (error) return NextResponse.json({ error: "停用套餐失败" }, { status: 500 }); return NextResponse.json({ ok: true }); } catch (error) { return adminErrorResponse(error); } } diff --git a/frontend/src/app/api/admin/payments/route.ts b/frontend/src/app/api/admin/payments/route.ts index b0e136ca..829569c1 100644 --- a/frontend/src/app/api/admin/payments/route.ts +++ b/frontend/src/app/api/admin/payments/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin"; +import { requireAdminSession } from "@/lib/admin/auth"; +import { adminErrorResponse } from "@/lib/admin/http"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; -import { createServerSupabaseClient } from "@/lib/supabase/server"; export const runtime = "nodejs"; @@ -14,18 +15,9 @@ const querySchema = z.object({ offset: z.coerce.number().int().min(0).default(0), }); -async function requireAdmin() { - const supabase = await createServerSupabaseClient(); - const { data: { user }, error } = await supabase.auth.getUser(); - if (error || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); - if (!(await isAdminUser(user))) return NextResponse.json({ error: "无管理员权限" }, { status: 403 }); - return user; -} - export async function GET(request: Request) { try { - const auth = await requireAdmin(); - if (auth instanceof NextResponse) return auth; + await requireAdminSession("read"); const url = new URL(request.url); const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams)); @@ -83,6 +75,8 @@ export async function GET(request: Request) { return NextResponse.json({ orders, stats, pagination: { limit, offset, total, hasMore: offset + orders.length < total } }); } catch (error) { if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + const authError = adminErrorResponse(error); + if (authError.status === 401 || authError.status === 403) return authError; return NextResponse.json({ error: "支付记录服务暂时不可用" }, { status: 500 }); } } diff --git a/frontend/src/app/api/auth/[...all]/route.ts b/frontend/src/app/api/auth/[...all]/route.ts index 2a25646d..21755f8f 100644 --- a/frontend/src/app/api/auth/[...all]/route.ts +++ b/frontend/src/app/api/auth/[...all]/route.ts @@ -24,7 +24,6 @@ async function dispatch( const services = getIdentityAuthServices(); const handlers = createHostIsolatedAuthHandlers(config, { user: toNextJsHandler(services.user), - admin: toNextJsHandler(services.admin), }); return handlers[method](request); } diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 38692169..63196fc2 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -1,35 +1,14 @@ -import { headers } from "next/headers"; - import { EmailOtpLogin } from "@/components/email-otp-login"; -import { - isSelfHostedIdentityEnabled, - readIdentityConfig, - readSelfHostedIdentityConfig, -} from "@/modules/identity/config"; -import { resolveIdentitySurface } from "@/modules/identity/host"; +import { readIdentityConfig } from "@/modules/identity/config"; export const dynamic = "force-dynamic"; export default async function LoginPage() { const config = readIdentityConfig(process.env); - let provider = config.provider; - let passwordEnabled = false; - let passwordOnly = false; - if (isSelfHostedIdentityEnabled(process.env)) { - const selfHosted = readSelfHostedIdentityConfig(process.env); - const surface = resolveIdentitySurface( - (await headers()).get("host"), - selfHosted, - ); - if (surface === "admin") provider = "self-hosted"; - passwordEnabled = provider === "self-hosted"; - passwordOnly = surface === "admin"; - } return ( ); } diff --git a/frontend/src/components/admin/codes-resource.tsx b/frontend/src/components/admin/codes-resource.tsx index da2ef5d8..28e14515 100644 --- a/frontend/src/components/admin/codes-resource.tsx +++ b/frontend/src/components/admin/codes-resource.tsx @@ -38,7 +38,7 @@ const statusColors: Record = { }; export default function CodesPage() { - const { data: role } = usePermissions<"admin" | "viewer">({}); + const { data: role } = usePermissions<"admin">({}); const { data: identity } = useGetIdentity(); const { mutate: createCodes, mutation: createMutation } = useCreate<{ id: string; generated: CodeRecord[] }>(); const { mutate: updateCode, mutation: updateMutation } = useUpdate(); @@ -137,7 +137,7 @@ export default function CodesPage() { { label: "已兑换", value: "redeemed" }, { label: "已撤销", value: "revoked" }, ]} - extra={writable ? : viewer 只读} + extra={writable ? : null} /> setCreateOpen(false)} footer={null} destroyOnHidden> diff --git a/frontend/src/components/email-otp-login.tsx b/frontend/src/components/email-otp-login.tsx index c4796825..9e849d7b 100644 --- a/frontend/src/components/email-otp-login.tsx +++ b/frontend/src/components/email-otp-login.tsx @@ -132,7 +132,7 @@ export function EmailOtpLogin({ }); if (otpError) throw otpError; } - window.location.assign(window.location.hostname.startsWith("admin.") && window.location.hostname.includes("staging") ? "/admin" : "/"); + window.location.assign("/"); } catch (caught) { if (!(caught instanceof Error)) throw caught; setError(authMessage(caught)); @@ -149,7 +149,7 @@ export function EmailOtpLogin({ setNotice(""); try { await selfHostedAuthActions.signInWithPassword(email, password); - window.location.assign(window.location.hostname.startsWith("admin.") && window.location.hostname.includes("staging") ? "/admin" : "/"); + window.location.assign("/"); } catch (caught) { if (!(caught instanceof Error)) throw caught; setError(authMessage(caught)); diff --git a/frontend/src/lib/admin/auth-policy.ts b/frontend/src/lib/admin/auth-policy.ts index 08026aef..a9bda407 100644 --- a/frontend/src/lib/admin/auth-policy.ts +++ b/frontend/src/lib/admin/auth-policy.ts @@ -1,6 +1,6 @@ import type { IdentityUser } from "@/modules/identity/contracts"; -export type AdminRole = "admin" | "viewer"; +export type AdminRole = "admin"; export type AdminAccessResult = | { allowed: true; role: AdminRole } @@ -11,13 +11,8 @@ export function authorizeAdminAccess( access: "read" | "write", ): AdminAccessResult { if (!user) return { allowed: false, status: 401 }; - const role: AdminRole | null = user.role.includes("admin") - ? "admin" - : user.role.includes("viewer") - ? "viewer" - : null; - if (!role || (access === "write" && role !== "admin")) { + if (!user.role.includes("admin")) { return { allowed: false, status: 403 }; } - return { allowed: true, role }; + return { allowed: true, role: "admin" }; } diff --git a/frontend/src/lib/admin/auth.ts b/frontend/src/lib/admin/auth.ts index fa28eb8d..57501f19 100644 --- a/frontend/src/lib/admin/auth.ts +++ b/frontend/src/lib/admin/auth.ts @@ -34,7 +34,7 @@ export async function requireAdminSession( try { const user = await requireIdentityUser( - getIdentityAuthServices().admin.api, + getIdentityAuthServices().user.api, new Headers(await headers()), ); const authorization = authorizeAdminAccess(user, access); diff --git a/frontend/src/lib/admin/providers.ts b/frontend/src/lib/admin/providers.ts index d154bba1..b7ec5edb 100644 --- a/frontend/src/lib/admin/providers.ts +++ b/frontend/src/lib/admin/providers.ts @@ -18,7 +18,7 @@ export type AdminIdentity = { id: string; email: string; name: string; - role: "admin" | "viewer"; + role: "admin"; }; const apiBase = "/api/admin"; @@ -165,7 +165,7 @@ export const adminAccessControlProvider: AccessControlProvider = { } return role === "admin" ? { can: true } - : { can: false, reason: "viewer 仅可查看" }; + : { can: false, reason: "无管理员权限" }; }, options: { buttons: { enableAccessControl: true, hideIfUnauthorized: true }, diff --git a/frontend/src/lib/supabase/admin.ts b/frontend/src/lib/supabase/admin.ts index 8286a0dc..7b4e1da5 100644 --- a/frontend/src/lib/supabase/admin.ts +++ b/frontend/src/lib/supabase/admin.ts @@ -19,7 +19,7 @@ export async function isAdminUser(user: { id?: string; email?: string | null }) return rows[0]?.role .split(",") .map((role) => role.trim()) - .some((role) => role === "admin" || role === "viewer") ?? false; + .some((role) => role === "admin") ?? false; } catch { return false; } diff --git a/frontend/src/lib/supabase/server.ts b/frontend/src/lib/supabase/server.ts index fd7e9f21..7d7b984d 100644 --- a/frontend/src/lib/supabase/server.ts +++ b/frontend/src/lib/supabase/server.ts @@ -7,20 +7,15 @@ import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client"; import { readDatabaseUrl } from "@/lib/db/config"; import { getIdentityAuthServices } from "@/modules/identity/auth"; import { readIdentitySession } from "@/modules/identity/session"; -import { readSelfHostedIdentityConfig } from "@/modules/identity/config"; -import { resolveIdentitySurface } from "@/modules/identity/host"; import { getSupabasePublicConfig } from "./config"; export async function createServerSupabaseClient() { if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") { const requestHeaders = new Headers(await headers()); - const services = getIdentityAuthServices(); - const surface = resolveIdentitySurface( - requestHeaders.get("host"), - readSelfHostedIdentityConfig(process.env), + const session = await readIdentitySession( + getIdentityAuthServices().user.api, + requestHeaders, ); - const auth = surface === "admin" ? services.admin : services.user; - const session = await readIdentitySession(auth.api, requestHeaders); return createLocalPostgresDataClient( readDatabaseUrl(process.env, "APP_DATABASE_URL"), session ? { id: session.user.id, email: session.user.email } : null, diff --git a/frontend/src/modules/identity/auth-factory.ts b/frontend/src/modules/identity/auth-factory.ts index 4367c23f..f82cd940 100644 --- a/frontend/src/modules/identity/auth-factory.ts +++ b/frontend/src/modules/identity/auth-factory.ts @@ -1,20 +1,18 @@ import { createHmac } from "node:crypto"; import type { Pool } from "pg"; -import { APIError, type BetterAuthOptions } from "better-auth"; +import 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 type { EmailOtpSender } from "./contracts.ts"; import { identityModelMapping } from "./model.ts"; export type AdminUserAuthorizer = (userId: string) => Promise; interface BuildAuthOptionsInput { - surface: IdentitySurface; config: SelfHostedIdentityConfig; database: Pool; emailSender: EmailOtpSender; - authorizeAdminUser?: AdminUserAuthorizer; } function otpIdempotencyKey( @@ -58,26 +56,17 @@ export function createEmailOtpOptions( } 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, + baseURL: config.userOrigin, basePath: "/api/auth", - secret, + secret: config.userSecret, database, - trustedOrigins: [origin], + trustedOrigins: [config.userOrigin], telemetry: { enabled: false }, user: identityModelMapping.user, session: identityModelMapping.session, @@ -91,8 +80,7 @@ export function buildAuthOptions({ }, advanced: { database: { generateId: "uuid" }, - cookiePrefix: - surface === "user" ? "jyotisha-user" : "jyotisha-admin", + cookiePrefix: "jyotisha-user", defaultCookieAttributes: { secure: true, httpOnly: true, @@ -108,31 +96,12 @@ export function buildAuthOptions({ revokeSessionsOnPasswordReset: true, }, plugins: [ - ...(surface === "user" - ? [emailOTP(createEmailOtpOptions(emailSender, secret, false))] - : []), + emailOTP(createEmailOtpOptions(emailSender, config.userSecret, false)), admin({ defaultRole: "user", adminRoles: ["admin"], schema: identityModelMapping.admin, }), ], - ...(surface === "admin" - ? { - databaseHooks: { - session: { - create: { - async before(session: { userId: string }) { - if (!(await authorizeAdminUser!(session.userId))) { - throw new APIError("FORBIDDEN", { - message: "Administrator access required", - }); - } - }, - }, - }, - }, - } - : {}), }; } diff --git a/frontend/src/modules/identity/auth.ts b/frontend/src/modules/identity/auth.ts index 2c7818aa..e315c283 100644 --- a/frontend/src/modules/identity/auth.ts +++ b/frontend/src/modules/identity/auth.ts @@ -16,7 +16,7 @@ interface AdminRoleRow { ban_expires: Date | null; } -export type IdentityAdminSurfaceRole = "admin" | "viewer"; +export type IdentityAdminSurfaceRole = "admin"; export function createIdentityPool(databaseUrl: string): Pool { return new Pool({ @@ -68,19 +68,17 @@ export function createDatabaseAdminAuthorizer( export function createDatabaseAdminSurfaceAuthorizer( pool: Pool, ): AdminUserAuthorizer { - return createDatabaseRoleAuthorizer(pool, new Set(["admin", "viewer"])); + return createDatabaseAdminAuthorizer(pool); } export interface IdentityAuthServices { pool: Pool; user: ReturnType; - admin: ReturnType; } interface IdentityAuthDependencies { pool?: Pool; emailSender?: EmailOtpSender; - authorizeAdminUser?: AdminUserAuthorizer; } export function createIdentityAuthServices( @@ -94,28 +92,15 @@ export function createIdentityAuthServices( apiKey: config.resendApiKey, from: config.resendFrom, }); - const authorizeAdminUser = - dependencies.authorizeAdminUser ?? createDatabaseAdminSurfaceAuthorizer(pool); - return { pool, user: betterAuth( buildAuthOptions({ - surface: "user", config, database: pool, emailSender, }), ), - admin: betterAuth( - buildAuthOptions({ - surface: "admin", - config, - database: pool, - emailSender, - authorizeAdminUser, - }), - ), }; } diff --git a/frontend/src/modules/identity/config.ts b/frontend/src/modules/identity/config.ts index ca1eae26..a0e29009 100644 --- a/frontend/src/modules/identity/config.ts +++ b/frontend/src/modules/identity/config.ts @@ -8,9 +8,7 @@ export interface SelfHostedIdentityConfig { provider: "self-hosted"; databaseUrl: string; userOrigin: string; - adminOrigin: string; userSecret: string; - adminSecret: string; resendApiKey: string; resendFrom: string; } @@ -95,24 +93,13 @@ 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), }; diff --git a/frontend/src/modules/identity/contracts.ts b/frontend/src/modules/identity/contracts.ts index c78ce393..d30920ef 100644 --- a/frontend/src/modules/identity/contracts.ts +++ b/frontend/src/modules/identity/contracts.ts @@ -1,5 +1,3 @@ -export type IdentitySurface = "user" | "admin"; - export type EmailOtpType = | "sign-in" | "email-verification" diff --git a/frontend/src/modules/identity/host.ts b/frontend/src/modules/identity/host.ts index 7a7435da..33b563e9 100644 --- a/frontend/src/modules/identity/host.ts +++ b/frontend/src/modules/identity/host.ts @@ -1,5 +1,4 @@ import type { SelfHostedIdentityConfig } from "./config.ts"; -import type { IdentitySurface } from "./contracts.ts"; export type IdentityRequestHandler = ( request: Request, @@ -33,15 +32,11 @@ function normalizeHost(value: string | null): string | null { export function resolveIdentitySurface( hostHeader: string | null, config: SelfHostedIdentityConfig, -): IdentitySurface | null { +): "user" | 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; + return host === new URL(config.userOrigin).host.toLowerCase() ? "user" : null; } function isAdminEndpoint(request: Request): boolean { @@ -55,7 +50,7 @@ function isAdminEndpoint(request: Request): boolean { export function createHostIsolatedAuthHandlers( config: SelfHostedIdentityConfig, - handlers: Record, + handlers: { user: IdentityAuthHandlers }, ): IdentityAuthHandlers { const dispatch = (method: keyof IdentityAuthHandlers): IdentityRequestHandler => @@ -64,10 +59,10 @@ export function createHostIsolatedAuthHandlers( if (!surface) { return new Response("Unrecognized identity host", { status: 421 }); } - if (surface === "user" && isAdminEndpoint(request)) { + if (isAdminEndpoint(request)) { return new Response("Not found", { status: 404 }); } - return handlers[surface][method](request); + return handlers.user[method](request); }; return { GET: dispatch("GET"), POST: dispatch("POST") }; diff --git a/frontend/tests/account-api.test.ts b/frontend/tests/account-api.test.ts index 8138b1e9..3b328242 100644 --- a/frontend/tests/account-api.test.ts +++ b/frontend/tests/account-api.test.ts @@ -27,13 +27,10 @@ test("account API reads and returns the server-configured rectification price", assert.doesNotMatch(source, /RECTIFICATION_PRICE_CREDITS[^\n]*\?\?\s*["']1["']/); }); -test("account API returns a server-resolved admin entry URL", () => { - assert.match(source, /readIdentityConfig\(process\.env\)/); - assert.match(source, /new URL\("\/admin\/codes", identityConfig\.adminOrigin\)\.toString\(\)/); - assert.match(source, /identityConfig\.provider === "self-hosted"/); - assert.match(source, /: "\/admin\/codes"/); +test("account API returns the same-origin admin entry URL", () => { + assert.match(source, /const adminUrl = isAdmin \? "\/admin\/codes" : null/); assert.match(source, /adminUrl,/); - assert.doesNotMatch(source, /NEXT_PUBLIC_ADMIN|process\.env\.AUTH_ADMIN_ORIGIN/); + assert.doesNotMatch(source, /NEXT_PUBLIC_ADMIN|AUTH_ADMIN_ORIGIN|adminOrigin/); }); test("account API projects only the minimum case state needed by the homepage", () => { diff --git a/frontend/tests/admin-auth.test.ts b/frontend/tests/admin-auth.test.ts index f3831bfb..75fa5b94 100644 --- a/frontend/tests/admin-auth.test.ts +++ b/frontend/tests/admin-auth.test.ts @@ -22,10 +22,10 @@ test("anonymous admin access is 401", () => { }); }); -test("viewer may read but may not write", () => { +test("viewer may neither read nor write", () => { assert.deepEqual(authorizeAdminAccess(user(["user", "viewer"]), "read"), { - allowed: true, - role: "viewer", + allowed: false, + status: 403, }); assert.deepEqual(authorizeAdminAccess(user(["viewer"]), "write"), { allowed: false, diff --git a/frontend/tests/admin-contracts.test.ts b/frontend/tests/admin-contracts.test.ts index 8ac911bc..6036ea41 100644 --- a/frontend/tests/admin-contracts.test.ts +++ b/frontend/tests/admin-contracts.test.ts @@ -12,23 +12,25 @@ const adminUser = readFileSync(new URL("../src/lib/supabase/admin.ts", import.me const codesRoute = readFileSync(new URL("../src/app/api/admin/codes/route.ts", import.meta.url), "utf8"); const codeRoute = readFileSync(new URL("../src/app/api/admin/codes/[id]/route.ts", import.meta.url), "utf8"); const providers = readFileSync(new URL("../src/lib/admin/providers.ts", import.meta.url), "utf8"); +const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8"); +const adminRootRoute = readFileSync(new URL("../src/app/admin/route.ts", import.meta.url), "utf8"); const readonlyRoutes = ["users", "credit-transactions", "consultations", "audit-logs"].map((resource) => readFileSync(new URL(`../src/app/api/admin/${resource}/route.ts`, import.meta.url), "utf8"), ); const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); -test("admin APIs use persisted Better Auth roles with admin and viewer boundaries", () => { +test("admin APIs use persisted Better Auth roles with admin-only boundaries", () => { assert.match(auth, /requireIdentityUser/); + assert.match(auth, /getIdentityAuthServices\(\)\.user\.api/); assert.match(authPolicy, /user\.role\.includes\("admin"\)/); - assert.match(authPolicy, /user\.role\.includes\("viewer"\)/); - assert.match(authPolicy, /access === "write" && role !== "admin"/); + assert.doesNotMatch(authPolicy, /viewer/); assert.doesNotMatch(auth, /ADMIN_EMAILS|isAdminEmail/); assert.match(auth, /APP_ENV\?\.trim\(\) === "production"/); assert.match(codesRoute, /requireAdminSession\("write"\)/); assert.match(codeRoute, /requireAdminSession\("write"\)/g); }); -test("self-hosted account entry checks persisted admin or viewer roles", () => { +test("self-hosted account entry checks only the persisted admin role", () => { const selfHostedBranch = adminUser.slice( adminUser.indexOf('process.env.AUTH_PROVIDER?.trim() === "self-hosted"'), adminUser.indexOf("if (isAdminEmail"), @@ -36,12 +38,20 @@ test("self-hosted account entry checks persisted admin or viewer roles", () => { assert.match(selfHostedBranch, /queryAdminRows/); assert.match(selfHostedBranch, /select role from identity\.users where id = \$1 limit 1/); - assert.match(selfHostedBranch, /role === "admin" \|\| role === "viewer"/); - assert.doesNotMatch(selfHostedBranch, /isAdminEmail|ADMIN_EMAILS/); - assert.match(auth, /getIdentityAuthServices\(\)\.admin\.api/); + assert.match(selfHostedBranch, /role === "admin"/); + assert.doesNotMatch(selfHostedBranch, /viewer|isAdminEmail|ADMIN_EMAILS/); assert.match(auth, /authorizeAdminAccess\(user, access\)/); }); +test("admin pages and root route are server-gated before rendering or redirecting", () => { + assert.match(adminLayout, /await requireAdminSession\("read"\)/); + assert.match(adminLayout, /error\.status === 401 \? "\/login" : "\/"/); + assert.match(adminLayout, /redirect\(/); + assert.match(adminRootRoute, /await requireAdminSession\("read"\)/); + assert.match(adminRootRoute, /error\.status === 401 \? "\/login" : "\/"/); + assert.match(adminRootRoute, /headers: \{ location: "\/admin\/codes" \}/); +}); + test("readonly resources cannot be mutated through Refine access control", () => { for (const resource of ["users", "credit-transactions", "consultations", "audit-logs"]) { assert.match(providers, new RegExp(`"${resource}"`)); diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index cfe10d89..1e15bce6 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -104,10 +104,7 @@ 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\}/, - ); + assert.doesNotMatch(compose, /ADMIN_SITE_ADDRESS|AUTH_ADMIN_ORIGIN|BETTER_AUTH_ADMIN_SECRET/); }); test("server compose defaults to local images without removing either build", () => { @@ -156,22 +153,15 @@ test("server compose defaults to local images without removing either build", () } }); -test("staging Caddy isolates the business admin surface from the public host", () => { +test("staging Caddy serves admin on the main site", () => { 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, /@adminPaths path \/admin \/admin\/\* \/api\/admin\/\*/); - assert.match(caddy, /redir @adminRoot \/admin\/codes 302/); - assert.match(caddy, /@adminSurface path \/login \/admin \/admin\/\* \/api\/admin\/\* \/api\/auth\/\*/); - assert.match(caddy, /respond "Not found" 404/); + assert.doesNotMatch(caddy, /ADMIN_SITE_ADDRESS|admin\.staging\.jyotisha\.chat|@adminSurface|@adminPaths/); assert.doesNotMatch(caddy, /www\.jyotisha\.chat/); }); @@ -246,16 +236,13 @@ 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=self-hosted", "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", "APP_DATABASE_URL=postgresql://app_runtime:app-runtime-test-password@postgres:5432/jyotisha", "ADMIN_DATABASE_URL=postgresql://admin_runtime:admin-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 ", "ADMIN_EMAILS=admin@example.com", diff --git a/frontend/tests/identity-auth-factory.test.ts b/frontend/tests/identity-auth-factory.test.ts index 8320d600..9851f4d8 100644 --- a/frontend/tests/identity-auth-factory.test.ts +++ b/frontend/tests/identity-auth-factory.test.ts @@ -1,250 +1,46 @@ 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 { buildAuthOptions, createEmailOtpOptions } from "../src/modules/identity/auth-factory.ts"; +import { createDatabaseAdminAuthorizer, createIdentityPool } from "../src/modules/identity/auth.ts"; import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts"; import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts"; -const config: SelfHostedIdentityConfig = { - provider: "self-hosted", - databaseUrl: - "postgresql://identity_runtime:test-password@postgres:5432/jyotisha", - userOrigin: "https://staging.jyotisha.chat", - adminOrigin: "https://admin.staging.jyotisha.chat", - userSecret: "user-secret-that-is-at-least-32-bytes-long", - adminSecret: "admin-secret-that-is-at-least-32-bytes-long", - resendApiKey: "re_test_key", - resendFrom: "Jyotisha ", -}; - +const config: SelfHostedIdentityConfig = { provider: "self-hosted", databaseUrl: "postgresql://identity_runtime:test-password@postgres:5432/jyotisha", userOrigin: "https://staging.jyotisha.chat", userSecret: "user-secret-that-is-at-least-32-bytes-long", resendApiKey: "re_test", resendFrom: "Jyotisha " }; const database = { kind: "pool" } as unknown as Pool; -test("Better Auth model mappings match the identity migration", () => { - const options = buildAuthOptions({ - surface: "user", - config, - database, - emailSender: new FakeEmailOtpSender(), - }); - - assert.equal(options.database, database); - assert.equal(options.user?.modelName, "users"); - assert.deepEqual(options.user?.fields, { - emailVerified: "email_verified", - createdAt: "created_at", - updatedAt: "updated_at", - }); - assert.equal(options.session?.modelName, "sessions"); - assert.deepEqual(options.session?.fields, { - expiresAt: "expires_at", - createdAt: "created_at", - updatedAt: "updated_at", - ipAddress: "ip_address", - userAgent: "user_agent", - userId: "user_id", - }); - assert.equal(options.account?.modelName, "accounts"); - assert.equal(options.account?.fields?.accountId, "account_id"); - assert.equal(options.account?.fields?.providerId, "provider_id"); - assert.equal(options.account?.fields?.accessTokenExpiresAt, "access_token_expires_at"); - assert.equal(options.verification?.modelName, "verifications"); - assert.equal(options.verification?.fields?.expiresAt, "expires_at"); - assert.equal(options.rateLimit?.modelName, "otp_rate_limits"); - assert.equal(options.rateLimit?.storage, "database"); - assert.equal(options.advanced?.database?.generateId, "uuid"); +test("Better Auth uses one user origin, secret, and cookie", () => { + const options = buildAuthOptions({ config, database, emailSender: new FakeEmailOtpSender() }); + assert.equal(options.baseURL, config.userOrigin); + assert.equal(options.secret, config.userSecret); + assert.equal(options.advanced?.cookiePrefix, "jyotisha-user"); + assert.deepEqual(options.trustedOrigins, [config.userOrigin]); + assert.ok(options.plugins?.some((plugin) => plugin.id === "email-otp")); + assert.ok(options.plugins?.some((plugin) => plugin.id === "admin")); + assert.equal(options.databaseHooks, undefined); }); -test("OTP policy hashes values, rotates resends, and builds opaque idempotency keys", async () => { +test("OTP policy remains hashed and bounded", 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); + const options = createEmailOtpOptions(sender, config.userSecret, false); + assert.equal(options.storeOTP, "hashed"); + assert.equal(options.allowedAttempts, 3); + await options.sendVerificationOTP({ email: "person@example.com", otp: "123456", type: "sign-in" }); 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"); - assert.deepEqual(userOptions.emailAndPassword, { - enabled: true, - disableSignUp: true, - minPasswordLength: 8, - maxPasswordLength: 128, - revokeSessionsOnPasswordReset: true, - }); - assert.deepEqual( - adminOptions.emailAndPassword, - userOptions.emailAndPassword, - ); - 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 is password-only 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.equal(emailPlugin, undefined); - assert.equal(options.emailAndPassword?.enabled, true); - assert.equal(options.emailAndPassword?.disableSignUp, true); - - 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"]); - -}); - -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(); - } + try { assert.equal(pool.options.options, "-c search_path=identity,pg_catalog"); } finally { await pool.end(); } }); -test("database admin authorizer requires a current persisted admin role", async () => { - const rowsByUser = new Map>([ +test("database admin authorizer accepts only persisted admin", async () => { + const rows = new Map>([ ["admin", { role: "user,admin", banned: false, ban_expires: null }], - ["user", { role: "user", banned: false, ban_expires: null }], - ["banned", { role: "admin", banned: true, ban_expires: null }], - [ - "expired-ban", - { - role: "admin", - banned: true, - ban_expires: new Date(Date.now() - 60_000), - }, - ], + ["viewer", { role: "viewer", banned: false, ban_expires: null }], ]); - 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 pool = { async query(_sql: string, values: unknown[]) { const row = rows.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"]); -}); - -test("admin surface authorizer allows persisted admin and viewer roles", async () => { - const { createDatabaseAdminSurfaceAuthorizer } = await import("../src/modules/identity/auth.ts"); - const rowsByUser = new Map>([ - ["admin", { role: "admin", banned: false, ban_expires: null }], - ["viewer", { role: "user,viewer", banned: false, ban_expires: null }], - ["user", { role: "user", banned: false, ban_expires: null }], - ]); - const pool = { - async query(_sql: string, values: unknown[]) { - const row = rowsByUser.get(String(values[0])); - return { rows: row ? [row] : [] }; - }, - } as unknown as Pool; - const authorize = createDatabaseAdminSurfaceAuthorizer(pool); - - assert.equal(await authorize("admin"), true); - assert.equal(await authorize("viewer"), true); - assert.equal(await authorize("user"), false); + assert.equal(await authorize("viewer"), false); }); diff --git a/frontend/tests/identity-auth-integration.test.ts b/frontend/tests/identity-auth-integration.test.ts index 50af8fbd..1f5af954 100644 --- a/frontend/tests/identity-auth-integration.test.ts +++ b/frontend/tests/identity-auth-integration.test.ts @@ -24,7 +24,6 @@ const migrationsDirectory = fileURLToPath( new URL("../db/migrations", import.meta.url), ); const userHost = "staging.jyotisha.chat"; -const adminHost = "admin.staging.jyotisha.chat"; function request( host: string, @@ -54,14 +53,12 @@ const envKeys = [ "SELF_HOSTED_IDENTITY_ENABLED", "IDENTITY_DATABASE_URL", "AUTH_USER_ORIGIN", - "AUTH_ADMIN_ORIGIN", "BETTER_AUTH_USER_SECRET", - "BETTER_AUTH_ADMIN_SECRET", "RESEND_API_KEY", "RESEND_FROM_EMAIL", ] as const; -test("Better Auth supports user OTP/password flows and password-only admin login", async () => { +test("Better Auth supports shared user OTP/password sessions for admins", async () => { const fixture = startPostgresFixture(); const migration = spawnSync(process.execPath, [runnerPath], { encoding: "utf8", @@ -83,9 +80,7 @@ test("Better Auth supports user OTP/password flows and password-only admin login "identity-runtime-test-password", ), userOrigin: `https://${userHost}`, - adminOrigin: `https://${adminHost}`, 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 ", }; @@ -97,9 +92,7 @@ test("Better Auth supports user OTP/password flows and password-only admin login SELF_HOSTED_IDENTITY_ENABLED: "true", IDENTITY_DATABASE_URL: config.databaseUrl, AUTH_USER_ORIGIN: config.userOrigin, - AUTH_ADMIN_ORIGIN: config.adminOrigin, BETTER_AUTH_USER_SECRET: config.userSecret, - BETTER_AUTH_ADMIN_SECRET: config.adminSecret, RESEND_API_KEY: config.resendApiKey, RESEND_FROM_EMAIL: config.resendFrom, }); @@ -117,7 +110,6 @@ test("Better Auth supports user OTP/password flows and password-only admin login }); const handlers = createHostIsolatedAuthHandlers(config, { user: toNextJsHandler(services.user), - admin: toNextJsHandler(services.admin), }); async function otpSignIn(email: string): Promise { @@ -300,22 +292,13 @@ test("Better Auth supports user OTP/password flows and password-only admin login /^(?:__Secure-)?jyotisha-user\.session_token=/, ); - const nonAdminPasswordLogin = await handlers.POST( - request(adminHost, "/api/auth/sign-in/email", { - email: newEmail, - password: resetPassword, - }), - ); - assert.notEqual(nonAdminPasswordLogin.status, 200); - assert.equal(nonAdminPasswordLogin.headers.has("set-cookie"), false); - fixture.psqlAs( "identity_runtime", "identity-runtime-test-password", "update identity.users set role = 'user,admin' where email = 'new-user@example.com'", ); const adminPasswordLogin = await handlers.POST( - request(adminHost, "/api/auth/sign-in/email", { + request(userHost, "/api/auth/sign-in/email", { email: newEmail, password: resetPassword, }), @@ -323,30 +306,14 @@ test("Better Auth supports user OTP/password flows and password-only admin login assert.equal(adminPasswordLogin.status, 200); assert.match( sessionCookie(adminPasswordLogin), - /^(?:__Secure-)?jyotisha-admin\.session_token=/, + /^(?:__Secure-)?jyotisha-user\.session_token=/, ); - - const sentMessageCount = sender.messages.length; - const adminSend = await handlers.POST( - request(adminHost, "/api/auth/email-otp/send-verification-otp", { - email: newEmail, - type: "sign-in", - }), - ); - assert.notEqual(adminSend.status, 200); - assert.equal(sender.messages.length, sentMessageCount); - - const adminPasswordRoute = await setAccountPassword( - request( - adminHost, - "/api/account/password", - { newPassword: "admin-must-not-set-password" }, - sessionCookie(adminPasswordLogin), - ), - ); - assert.equal(adminPasswordRoute.status, 401); } finally { - const globalServices = identityGlobal.jyotishaIdentityAuth; + const globalServices = ( + globalThis as typeof globalThis & { + jyotishaIdentityAuth?: ReturnType; + } + ).jyotishaIdentityAuth; if (globalServices) { await globalServices.pool.end(); delete identityGlobal.jyotishaIdentityAuth; diff --git a/frontend/tests/identity-config.test.ts b/frontend/tests/identity-config.test.ts index 60f049c3..55267d31 100644 --- a/frontend/tests/identity-config.test.ts +++ b/frontend/tests/identity-config.test.ts @@ -1,154 +1,54 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - isSelfHostedIdentityEnabled, - readIdentityConfig, - readSelfHostedIdentityConfig, -} from "../src/modules/identity/config.ts"; +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", + IDENTITY_DATABASE_URL: "postgresql://identity_runtime:test-password@postgres:5432/jyotisha", AUTH_USER_ORIGIN: "https://staging.jyotisha.chat", - AUTH_ADMIN_ORIGIN: "https://admin.staging.jyotisha.chat", BETTER_AUTH_USER_SECRET: "user-secret-that-is-at-least-32-bytes-long", - BETTER_AUTH_ADMIN_SECRET: "admin-secret-that-is-at-least-32-bytes-long", RESEND_API_KEY: "re_test_key_that_must_not_be_printed", RESEND_FROM_EMAIL: "Jyotisha Staging ", }; -test("identity provider defaults to supabase without self-hosted settings", () => { +test("identity provider defaults to supabase", () => { 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", () => { +test("identity config accepts one self-hosted user origin and secret", () => { const config = readIdentityConfig(selfHostedEnvironment); - assert.equal(config.provider, "self-hosted"); - if (config.provider !== "self-hosted") { - assert.fail("expected self-hosted identity configuration"); - } + if (config.provider !== "self-hosted") assert.fail(); 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); + assert.equal(config.userSecret, selfHostedEnvironment.BETTER_AUTH_USER_SECRET); + assert.equal("adminOrigin" in config, false); + assert.equal("adminSecret" in config, false); }); -test("identity config rejects unknown providers", () => { - assert.throws( - () => readIdentityConfig({ AUTH_PROVIDER: "firebase" }), - /AUTH_PROVIDER must be supabase or self-hosted/, - ); -}); - -test("self-hosted provider requires its independent service flag", () => { - assert.throws( - () => - readIdentityConfig({ - ...selfHostedEnvironment, - SELF_HOSTED_IDENTITY_ENABLED: "false", - }), - /SELF_HOSTED_IDENTITY_ENABLED must be true/, - ); - assert.throws( - () => isSelfHostedIdentityEnabled({ SELF_HOSTED_IDENTITY_ENABLED: "yes" }), - /must be true or false/, - ); -}); - -test("self-hosted identity reports missing keys without leaking configured secrets", () => { - const secret = "this-secret-must-never-appear-in-an-error"; - - assert.throws( - () => - readIdentityConfig({ - ...selfHostedEnvironment, - BETTER_AUTH_USER_SECRET: secret, - RESEND_API_KEY: "", - }), - (error: unknown) => { - assert.ok(error instanceof Error); - assert.match(error.message, /RESEND_API_KEY is required/); - assert.doesNotMatch(error.message, new RegExp(secret)); - return true; - }, - ); -}); - -test("self-hosted identity validates database URL, origins, secrets, and sender", () => { - const invalidCases: Array<[string, Record, RegExp]> = [ - [ - "database URL", - { IDENTITY_DATABASE_URL: "https://database.invalid" }, - /IDENTITY_DATABASE_URL must be a PostgreSQL URL/, - ], - [ - "production HTTP origin", - { AUTH_USER_ORIGIN: "http://staging.jyotisha.chat" }, - /AUTH_USER_ORIGIN must use HTTPS/, - ], - [ - "origin path", - { AUTH_ADMIN_ORIGIN: "https://admin.staging.jyotisha.chat/login" }, - /AUTH_ADMIN_ORIGIN must be an origin without a path/, - ], - [ - "short secret", - { BETTER_AUTH_ADMIN_SECRET: "too-short" }, - /BETTER_AUTH_ADMIN_SECRET must be at least 32 characters/, - ], - [ - "shared secret", - { - BETTER_AUTH_ADMIN_SECRET: - selfHostedEnvironment.BETTER_AUTH_USER_SECRET, - }, - /user and admin secrets must be different/, - ], - [ - "shared origin", - { AUTH_ADMIN_ORIGIN: selfHostedEnvironment.AUTH_USER_ORIGIN }, - /user and admin origins must be different/, - ], - [ - "invalid sender", - { RESEND_FROM_EMAIL: "Jyotisha Staging" }, - /RESEND_FROM_EMAIL must contain a valid email address/, - ], - ]; - - for (const [name, override, expected] of invalidCases) { - assert.throws( - () => readIdentityConfig({ ...selfHostedEnvironment, ...override }), - expected, - name, - ); - } -}); - -test("localhost origins may use HTTP for local development", () => { - const config = readIdentityConfig({ +test("self-hosted identity ignores retired admin-surface variables", () => { + const config = readSelfHostedIdentityConfig({ ...selfHostedEnvironment, - AUTH_USER_ORIGIN: "http://localhost:3000", - AUTH_ADMIN_ORIGIN: "http://admin.localhost:3000", + AUTH_ADMIN_ORIGIN: "invalid-retired-value", + BETTER_AUTH_ADMIN_SECRET: "short", }); + assert.equal(config.userOrigin, selfHostedEnvironment.AUTH_USER_ORIGIN); +}); +test("self-hosted provider requires its enable flag", () => { + assert.throws(() => readIdentityConfig({ ...selfHostedEnvironment, SELF_HOSTED_IDENTITY_ENABLED: "false" }), /must be true/); +}); + +test("self-hosted identity validates active database, origin, secret, and sender", () => { + assert.throws(() => readIdentityConfig({ ...selfHostedEnvironment, IDENTITY_DATABASE_URL: "https://invalid" }), /PostgreSQL URL/); + assert.throws(() => readIdentityConfig({ ...selfHostedEnvironment, AUTH_USER_ORIGIN: "http://staging.jyotisha.chat" }), /must use HTTPS/); + assert.throws(() => readIdentityConfig({ ...selfHostedEnvironment, BETTER_AUTH_USER_SECRET: "short" }), /at least 32/); + assert.throws(() => readIdentityConfig({ ...selfHostedEnvironment, RESEND_FROM_EMAIL: "invalid" }), /valid email/); +}); + +test("localhost may use HTTP", () => { + const config = readIdentityConfig({ ...selfHostedEnvironment, AUTH_USER_ORIGIN: "http://localhost:3000" }); assert.equal(config.provider, "self-hosted"); }); diff --git a/frontend/tests/identity-host-routing.test.ts b/frontend/tests/identity-host-routing.test.ts index 3cca107f..3b5ae6ca 100644 --- a/frontend/tests/identity-host-routing.test.ts +++ b/frontend/tests/identity-host-routing.test.ts @@ -1,130 +1,47 @@ 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"; +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 ", }; -test("identity surface matching is exact, case-insensitive, and port-normalized", () => { +test("identity host accepts only the configured user origin", () => { 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)); + for (const host of [null, "", "admin.staging.jyotisha.chat", "staging.jyotisha.chat.evil.example", "staging.jyotisha.chat,evil.example"]) { + assert.equal(resolveIdentitySurface(host, config), null); } }); -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("auth route dispatches only to the user service", async () => { + let calls = 0; + const handlers = createHostIsolatedAuthHandlers(config, { user: { GET: async () => { calls += 1; return new Response("user"); }, POST: async () => { calls += 1; return new Response("user"); } } }); + const response = await handlers.GET(new Request("https://internal/api/auth/get-session", { headers: { host: "staging.jyotisha.chat" } })); + assert.equal(await response.text(), "user"); + assert.equal(calls, 1); }); -test("unknown hosts fail closed before an auth handler reads cookies", async () => { +test("unknown hosts remain fail-closed with 421", 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", - }, - }), - ); - + const handler = async () => { calls += 1; return new Response("unexpected"); }; + const handlers = createHostIsolatedAuthHandlers(config, { user: { GET: handler, POST: handler } }); + const response = await handlers.GET(new Request("https://internal/api/auth/get-session", { headers: { host: "unknown.example" } })); 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 () => { +test("main auth surface keeps Better Auth admin endpoints closed", 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" }, - }), - ); - + const handler = async () => { calls += 1; return new Response("unexpected"); }; + const handlers = createHostIsolatedAuthHandlers(config, { user: { 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); }); diff --git a/frontend/tests/identity-login-provider.test.ts b/frontend/tests/identity-login-provider.test.ts index 93ba111d..abbd21e1 100644 --- a/frontend/tests/identity-login-provider.test.ts +++ b/frontend/tests/identity-login-provider.test.ts @@ -4,7 +4,7 @@ import test from "node:test"; import { createSelfHostedAuthActions } from "../src/modules/identity/client.ts"; -test("login page uses password-only mode on the self-hosted admin surface", () => { +test("login page uses one self-hosted user surface", () => { const page = readFileSync( new URL("../src/app/login/page.tsx", import.meta.url), "utf8", @@ -13,17 +13,8 @@ test("login page uses password-only mode on the self-hosted admin surface", () = 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, - /passwordEnabled = provider === "self-hosted"/, - ); - assert.match(page, /passwordOnly = surface === "admin"/); - assert.match(page, /passwordEnabled=\{passwordEnabled\}/); - assert.match(page, /passwordOnly=\{passwordOnly\}/); - assert.doesNotMatch(page, /NEXT_PUBLIC_AUTH_PROVIDER/); + assert.match(page, /passwordEnabled=\{config\.provider === "self-hosted"\}/); + assert.doesNotMatch(page, /resolveIdentitySurface|passwordOnly|surface === "admin"|NEXT_PUBLIC_AUTH_PROVIDER/); }); test("self-hosted auth actions call Better Auth without browser token storage", async () => { diff --git a/frontend/tests/sidebar-contract.test.ts b/frontend/tests/sidebar-contract.test.ts index 22eccb16..9850382b 100644 --- a/frontend/tests/sidebar-contract.test.ts +++ b/frontend/tests/sidebar-contract.test.ts @@ -154,7 +154,7 @@ test("keeps app sidebar props as product data and callbacks", () => { assert.doesNotMatch(appSidebar, /supabase|fetch\(|\/api\//i); }); -test("uses the server-provided independent admin entry URL", () => { +test("uses the server-provided same-origin admin entry URL", () => { const appSidebar = readProjectFile("src/components/app-sidebar.tsx"); const page = readProjectFile("src/app/page.tsx"); diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 9986d41c..3c12f68e 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -422,16 +422,13 @@ test("public rectification rollout rewrites only rollout gates and recreates web "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=self-hosted", "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:${"i".repeat(40)}@postgres:5432/jyotisha`, `APP_DATABASE_URL=postgresql://app_runtime:${"a".repeat(40)}@postgres:5432/jyotisha`, `ADMIN_DATABASE_URL=postgresql://admin_runtime:${"d".repeat(40)}@postgres:5432/jyotisha`, `BETTER_AUTH_USER_SECRET=${"u".repeat(32)}`, - `BETTER_AUTH_ADMIN_SECRET=${"v".repeat(32)}`, "RESEND_API_KEY=re_test_key", "RESEND_FROM_EMAIL=test@example.com", "ADMIN_EMAILS=admin@example.com",