diff --git a/.agents/skills/release-note-writer/SKILL.md b/.agents/skills/release-note-writer/SKILL.md index 0a940b680..ac45d4468 100644 --- a/.agents/skills/release-note-writer/SKILL.md +++ b/.agents/skills/release-note-writer/SKILL.md @@ -52,7 +52,7 @@ Use `To developers` for external developer impact: - Plugin SDK, plugin manifest, gamelet, widget, extension, public package API, self-host deployment behavior, server-runtime, Docker image behavior, integration contracts. - Include deployment behavior changes that affect external operators. -- Do not include purely internal `apps/server` business logic unless users of AIRI Cloud or external operators need to act on it. +- Do not include purely internal `server/apps/api` business logic unless users of AIRI Cloud or external operators need to act on it. Use `To contributors` for internal project work: diff --git a/AGENTS.md b/AGENTS.md index de2ad245f..e09c3cbcb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,12 @@ Concise but detailed reference for contributors working across the `moeru-ai/air ## Structure & Responsibilities +- **Hosted backend** (`server/`) + - `server/apps/api`: Hono resource API and business domains. + - `server/apps/auth`: standalone Better Auth and OIDC service. + - `server/packages`: backend-private schema and Node infrastructure packages. + - `server/dev/caddy`: local-only Auth/API edge routing. + - `server/docker-compose.yaml`: complete local backend stack. - **Apps** - `apps/stage-web`: Web app; composables/stores in `src/composables`, `src/stores`; pages in `src/pages`; devtools in `src/pages/devtools`; router config via `vite.config.ts`. - `apps/stage-tamagotchi`: Electron app; renderer pages in `src/renderer/pages`; devtools in `src/renderer/pages/devtools`; settings layout at `src/renderer/layouts/settings.vue`; router config via `electron.vite.config.ts`. @@ -49,6 +55,7 @@ Concise but detailed reference for contributors working across the `moeru-ai/air - `packages/stage-shared`: Shared logic across stage-ui, stage-ui-three, stage-web, stage-tamagotchi. - `packages/ui`: Standardized primitives (inputs/textarea/buttons/layout) built on reka-ui. - `packages/i18n`: All translations. +- Hosted backend: `server/apps/api`, `server/apps/auth`, `server/packages`, and local tooling under `server/dev`. - Server channel: `packages/server-runtime`, `packages/server-sdk`, `packages/server-shared` (power `services/` and `plugins/`). - Legacy desktop: `crates/` (old Tauri; Electron is current). - Pages: `packages/stage-pages` (shared bases); `apps/stage-web/src/pages` and `apps/stage-tamagotchi/src/renderer/pages` for app-specific pages; devtools live in each app’s `.../pages/devtools`. diff --git a/apps/ui-server-auth/src/modules/auth-fetch.ts b/apps/ui-server-auth/src/modules/auth-fetch.ts index 9e29d3199..5567ed628 100644 --- a/apps/ui-server-auth/src/modules/auth-fetch.ts +++ b/apps/ui-server-auth/src/modules/auth-fetch.ts @@ -1,5 +1,5 @@ /** - * Shared HTTP plumbing for the ui-server-auth → server/apps/api auth surface. + * Shared HTTP plumbing for the ui-server-auth → server/apps/auth surface. * * Use when: * - Hitting any `/api/auth/...` endpoint from the UI (sign-in, sign-up, diff --git a/apps/ui-server-auth/src/pages/profile.vue b/apps/ui-server-auth/src/pages/profile.vue index 3169f0a3b..20745741f 100644 --- a/apps/ui-server-auth/src/pages/profile.vue +++ b/apps/ui-server-auth/src/pages/profile.vue @@ -67,7 +67,7 @@ const signOutError = shallowRef(null) // set / provider URL or a Gravatar fallback URL. We detect the fallback by // URL prefix so the server doesn't need to ship a redundant `imageSource` // flag — gravatar URLs are stable enough that prefix-matching is fine. -// See server/apps/api/src/routes/oidc/token-auth.ts for the server-side build. +// See server/apps/auth/src/routes.ts for the server-side build. const GRAVATAR_AVATAR_PREFIX = 'https://www.gravatar.com/avatar/' const avatarUrl = computed(() => user.value?.image ?? null) const usingGravatarFallback = computed( diff --git a/package.json b/package.json index f956985d7..2e1293f27 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "dev:ui": "pnpm -rF @proj-airi/stage-ui run story:dev", "dev:web": "pnpm -rF @proj-airi/stage-web run dev", "dev:web:https": "pnpm -rF @proj-airi/stage-web run dev:https", + "dev:server-auth": "pnpm -rF @proj-airi/ui-server-auth run dev", + "dev:backend": "docker compose -f server/docker-compose.yaml up --build", "dev:pocket:ios": "pnpm -rF @proj-airi/stage-pocket run dev:ios", "dev:pocket:android": "pnpm -rF @proj-airi/stage-pocket run dev:android", "dev:server": "pnpm -rF @proj-airi/server-runtime run dev", diff --git a/packages/stage-pages/src/pages/settings/account/account-settings-page.vue b/packages/stage-pages/src/pages/settings/account/account-settings-page.vue index 4a0b1cf1a..d2f8fc2fe 100644 --- a/packages/stage-pages/src/pages/settings/account/account-settings-page.vue +++ b/packages/stage-pages/src/pages/settings/account/account-settings-page.vue @@ -38,7 +38,8 @@ const userAvatar = computed(() => user.value?.image ?? null) // Gravatar fallback is decorated server-side onto `user.image`. We detect // the fallback by URL prefix instead of carrying a redundant `imageSource` // flag — Gravatar URL format is stable and prefix-matching keeps the API -// surface small. Keep this prefix aligned with the server-generated fallback. +// surface small. If the avatar source ever changes, both this constant +// and server/apps/auth/src/routes.ts must move together. const GRAVATAR_AVATAR_PREFIX = 'https://www.gravatar.com/avatar/' const usingGravatarFallback = computed( () => userAvatar.value?.startsWith(GRAVATAR_AVATAR_PREFIX) ?? false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 392e5d050..ff7b29408 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5334,12 +5334,6 @@ importers: server/apps/api: dependencies: - '@better-auth/drizzle-adapter': - specifier: 'catalog:' - version: 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.4.3))(jose@6.2.2)(kysely@0.29.4)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.29.4)(pg@8.20.0)(postgres@3.4.9)) - '@better-auth/oauth-provider': - specifier: 'catalog:' - version: 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.4.3))(jose@6.2.2)(kysely@0.29.4)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.1.21)(better-auth@1.6.25(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.29.4)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)))(better-call@1.1.8(zod@4.4.3)) '@dotenvx/dotenvx': specifier: 'catalog:' version: 1.61.1 @@ -5421,6 +5415,9 @@ importers: '@opentelemetry/semantic-conventions': specifier: 'catalog:' version: 1.40.0 + '@proj-airi/auth-shared': + specifier: workspace:* + version: link:../../packages/auth-shared '@proj-airi/drizzle-migration': specifier: workspace:* version: link:../../packages/drizzle-migration @@ -5430,12 +5427,6 @@ importers: '@proj-airi/server-sdk-shared': specifier: workspace:* version: link:../../../packages/server-sdk-shared - better-auth: - specifier: 'catalog:' - version: 1.6.25(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.29.4)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) - cac: - specifier: 'catalog:' - version: 7.0.0 drizzle-orm: specifier: 'catalog:' version: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.29.4)(pg@8.20.0)(postgres@3.4.9) @@ -5472,9 +5463,6 @@ importers: posthog-node: specifier: 'catalog:' version: 5.39.4(rxjs@7.8.2) - resend: - specifier: 'catalog:' - version: 6.12.2 stripe: specifier: 'catalog:' version: 22.0.2(@types/node@25.6.0) @@ -5491,9 +5479,6 @@ importers: specifier: 'catalog:' version: 4.4.3 devDependencies: - '@better-auth/cli': - specifier: 'catalog:' - version: 1.4.22(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.4.3))(drizzle-kit@0.31.10)(jose@6.2.2)(kysely@0.29.4)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.9)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) '@types/pg': specifier: 'catalog:' version: 8.20.0 @@ -5504,6 +5489,139 @@ importers: specifier: 'catalog:' version: 0.31.10 + server/apps/auth: + dependencies: + '@better-auth/drizzle-adapter': + specifier: 'catalog:' + version: 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.4.3))(jose@6.2.2)(kysely@0.29.4)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.29.4)(pg@8.20.0)(postgres@3.4.9)) + '@better-auth/oauth-provider': + specifier: 'catalog:' + version: 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.4.3))(jose@6.2.2)(kysely@0.29.4)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.1.21)(better-auth@1.6.25(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.29.4)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)))(better-call@1.1.8(zod@4.4.3)) + '@dotenvx/dotenvx': + specifier: 'catalog:' + version: 1.61.1 + '@guiiai/logg': + specifier: 'catalog:' + version: 1.2.11 + '@hono/node-server': + specifier: 'catalog:' + version: 1.19.14(hono@4.11.3) + '@moeru/std': + specifier: 'catalog:' + version: 0.1.0-beta.17 + '@opentelemetry/api': + specifier: 'catalog:' + version: 1.9.1 + '@opentelemetry/api-logs': + specifier: 'catalog:' + version: 0.215.0 + '@opentelemetry/exporter-logs-otlp-proto': + specifier: 'catalog:' + version: 0.215.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: 'catalog:' + version: 0.215.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: 'catalog:' + version: 0.215.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': + specifier: 'catalog:' + version: 0.215.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-ioredis': + specifier: 'catalog:' + version: 0.63.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pg': + specifier: 'catalog:' + version: 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-runtime-node': + specifier: 'catalog:' + version: 0.28.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-undici': + specifier: 'catalog:' + version: 0.25.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': + specifier: 'catalog:' + version: 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': + specifier: 'catalog:' + version: 0.215.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: 'catalog:' + version: 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: 'catalog:' + version: 0.215.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': + specifier: 'catalog:' + version: 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': + specifier: 'catalog:' + version: 1.40.0 + '@proj-airi/auth-shared': + specifier: workspace:* + version: link:../../packages/auth-shared + better-auth: + specifier: 'catalog:' + version: 1.6.25(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.29.4)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) + drizzle-orm: + specifier: 'catalog:' + version: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.29.4)(pg@8.20.0)(postgres@3.4.9) + hono: + specifier: 'catalog:' + version: 4.11.3 + hono-rate-limiter: + specifier: 'catalog:' + version: 0.5.3(hono@4.11.3)(unstorage@1.17.5(aws4fetch@1.0.20)(idb-keyval@6.2.2)(ioredis@5.10.1)) + injeca: + specifier: 'catalog:' + version: 0.2.0(@guiiai/logg@1.2.11)(error-stack-parser@2.1.4)(nanoid@6.0.1) + ioredis: + specifier: 'catalog:' + version: 5.10.1 + jose: + specifier: 'catalog:' + version: 6.2.2 + ofetch: + specifier: 'catalog:' + version: 1.5.1 + pg: + specifier: 'catalog:' + version: 8.20.0 + resend: + specifier: 'catalog:' + version: 6.12.2 + tsx: + specifier: 'catalog:' + version: 4.21.0 + valibot: + specifier: 'catalog:' + version: 1.4.2(typescript@5.9.3) + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@better-auth/cli': + specifier: 'catalog:' + version: 1.4.22(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.4.3))(drizzle-kit@0.31.10)(jose@6.2.2)(kysely@0.29.4)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.9)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) + '@electric-sql/pglite': + specifier: 'catalog:' + version: 0.4.4 + '@types/pg': + specifier: 'catalog:' + version: 8.20.0 + drizzle-kit: + specifier: 'catalog:' + version: 0.31.10 + typescript: + specifier: 'catalog:' + version: 5.9.3 + + server/packages/auth-shared: + dependencies: + drizzle-orm: + specifier: 'catalog:' + version: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.29.4)(pg@8.20.0)(postgres@3.4.9) + server/packages/drizzle-migration: devDependencies: '@proj-airi/unplugin-drizzle-orm-migrations': diff --git a/server/README.md b/server/README.md index fad4ad863..f85f3628c 100644 --- a/server/README.md +++ b/server/README.md @@ -1,29 +1,35 @@ -# AIRI server workspace +# AIRI Backend -Backend deployables and backend-only packages live under this directory. Keeping them in one workspace makes the deployment boundary explicit while the repository root remains the shared pnpm workspace. +Project AIRI's hosted backend source lives under this folder. Workspace package +names stay stable; the directory groups service source and database ownership +while production deployment configuration remains in `proj-airi/airi-railway`. -## Structure +## Layout -- `apps/api`: Hono HTTP and WebSocket API, including auth, billing, chat synchronization, model gateway routing, and observability. -- `packages/drizzle-migration`: compiled Drizzle migrations consumed by the API at startup. -- `docker-compose.yml`: local API, PostgreSQL, and Redis stack. +- `apps/api`: resource API, business domains, database migrations, and API runtime. +- `apps/auth`: standalone Better Auth and OIDC service. +- `packages/auth-shared`: Auth-owned database schema and principal contracts. +- `packages/drizzle-migration`: bundled migration history consumed by the API migration owner. +- `dev/caddy`: local-only public edge routing for the shared Auth/API origin. +- `docker-compose.yaml`: complete local API + Auth + PostgreSQL + Redis + Caddy stack. -Packages shared with browser, desktop, integrations, or plugins remain in the root `packages/` directory because they are not backend-only. - -## Usage +## Run locally From the repository root: ```sh -pnpm -F @proj-airi/api-server dev -pnpm -F @proj-airi/api-server typecheck -pnpm -F @proj-airi/api-server exec vitest run -pnpm -F @proj-airi/api-server build pnpm dev:backend ``` -Use the scoped package commands when PostgreSQL and Redis already exist. Use `pnpm dev:backend` to build and run the complete local Compose stack. +The command uses `server/docker-compose.yaml` and exposes only Caddy at +`http://localhost:6112`. -## Boundaries +## Not included -Use `server/apps/api` for API-owned routes, services, schemas, and runtime composition. Use `server/packages` only for packages that are private to backend deployables. Cross-runtime contracts and SDKs belong in the root `packages/` workspace. +Frontend applications remain under `apps/`. Cross-runtime server SDK and +protocol packages remain under `packages/` because Web, Electron, plugins, +and independent services consume them. + +Production Caddy routing, OpenTelemetry Collector configuration, observability +storage, and Grafana dashboards live in `proj-airi/airi-railway` so deployment +topology is not duplicated in the application repository. diff --git a/server/apps/api/CLAUDE.md b/server/apps/api/CLAUDE.md index 5b71f8e6f..36f261cad 100644 --- a/server/apps/api/CLAUDE.md +++ b/server/apps/api/CLAUDE.md @@ -4,37 +4,38 @@ Agent-facing guide for `server/apps/api`. ## Overview -Hono-based Node.js backend. Owns auth, billing, chat sync, LLM gateway forwarding, and observability. **Multi-instance deployed on Railway** — design all features assuming N>1 instances sharing the same Postgres and Redis. +Hono-based Node.js resource API. The sibling `server/apps/auth` workspace app owns Better Auth, OIDC, sessions, and account lifecycle; this package owns billing, chat sync, LLM gateway forwarding, and business observability. **Multi-instance deployed on Railway** — design all features assuming N>1 instances sharing the same Postgres and Redis. ## Deployment Model - Hosted on **Railway**, multiple instances behind a load balancer. -- Single CLI role: `api` (see `src/bin/run.ts`). No background polling loops, no fire-and-forget tasks — every write happens inside the request thread. +- Independent applications: `server/apps/api/src/main.ts` and `server/apps/auth/src/main.ts`. There is no runtime role CLI and neither app imports the other service graph. +- The API has no background polling loops or fire-and-forget tasks — every business write happens inside the request thread. - Stateless per-instance: no local state that matters across requests. - Cross-instance coordination via Redis Pub/Sub (WebSocket broadcast). DB-level idempotency (`(userId, requestId)` partial unique index on `flux_transaction`) covers retries. - Rate limiting is currently **in-memory** (not distributed) — keep this in mind when adding rate-sensitive features. ## Tech Stack -Hono, Better Auth (OIDC provider, RS256 JWT), Drizzle ORM, PostgreSQL, Redis, Stripe, OpenTelemetry, Valibot, injeca (DI), tsx. +Hono, Drizzle ORM, PostgreSQL, Redis, Stripe, OpenTelemetry, Valibot, injeca (DI), tsx. Better Auth lives only in `server/apps/auth`. ## Commands ```sh pnpm -F @proj-airi/api-server dev # dev with dotenvx (.env.local) pnpm -F @proj-airi/api-server typecheck -pnpm -F @proj-airi/api-server exec vitest run # all API tests +pnpm -F @proj-airi/api-server exec vitest run # all server tests pnpm exec vitest run server/apps/api/src/... # single test file pnpm -F @proj-airi/api-server db:generate # drizzle-kit generate pnpm -F @proj-airi/api-server db:push # drizzle-kit push -pnpm -F @proj-airi/api-server auth:generate # better-auth → src/schemas/accounts.ts +pnpm -F @proj-airi/auth-server auth:generate # better-auth → server/packages/auth-shared/src/schema.ts ``` -Local API, PostgreSQL, and Redis: `pnpm dev:backend` +Local observability is maintained in `proj-airi/airi-railway`; run its `otel/docker-compose.yaml` stack. ## Architecture Summary -**Entry & DI**: `src/app.ts` (`createApp()`) → logger, env, OTel, Postgres/Redis, DB migrations, services via `injeca`, routes/middleware. CLI entry `src/bin/run.ts`. +**Entry & DI**: `server/apps/api/src/main.ts` → `src/server.ts` → `src/app.ts`; `server/apps/auth/src/main.ts` → `src/server.ts`. The workspace apps have separate package manifests, env schemas, Dockerfiles, and composition roots. **Layering**: - **Routes** (`src/routes/`): thin — param validation (Valibot), auth guards, error mapping. No business logic here. @@ -51,5 +52,5 @@ Local API, PostgreSQL, and Redis: `pnpm dev:backend` - **No async billing pipeline**: debits and credits update balance + ledger in one transaction. The `(user_id, request_id)` partial unique index gives DB-level idempotency for retries; LLM `request log` rows are written best-effort right after the response is delivered. - **In-process LLM/TTS router**: `/api/v1/openai` is dispatched by `services/domain/llm-router` reading `LLM_ROUTER_CONFIG` (per-model upstream chain + envelope-encrypted keys). `chat/completions` walks LLM upstreams with key fallback; `audio/speech` delegates to a TTS adapter (`azure` / `dashscope-cosyvoice` / `volcengine`); `audio/voices` returns the adapter's compiled-in catalog. Server handles auth/billing/logging, not model execution. - **Redis is cache + pub/sub, not truth**: balance cache, app_settings read cache, WebSocket cross-instance pub/sub. Truth is always Postgres. -- **Auth**: Better Auth + OIDC. `sessionMiddleware` fills context but doesn't block; `authGuard` returns 401. +- **Auth boundary**: `server/apps/auth` owns Better Auth + OIDC. The API's `sessionMiddleware` validates Auth-issued JWTs and fills context but doesn't block; `authGuard` returns 401. - **Multi-instance safe**: all writes go through Postgres transactions; cross-instance messaging uses Redis Pub/Sub. No async work, no in-process singletons — admin flux grants happen synchronously inside the POST that triggered them. diff --git a/server/apps/api/Dockerfile b/server/apps/api/Dockerfile index 3e59f1203..cc35db2e3 100644 --- a/server/apps/api/Dockerfile +++ b/server/apps/api/Dockerfile @@ -9,6 +9,7 @@ RUN corepack enable COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./ COPY patches/ ./patches/ COPY server/apps/api server/apps/api +COPY server/packages/auth-shared server/packages/auth-shared COPY server/packages/drizzle-migration server/packages/drizzle-migration COPY packages/server-sdk-shared packages/server-sdk-shared diff --git a/server/apps/api/README.md b/server/apps/api/README.md index d477fdd63..2ab46218a 100644 --- a/server/apps/api/README.md +++ b/server/apps/api/README.md @@ -1,62 +1,48 @@ # `@proj-airi/api-server` -HTTP and WebSocket backend for AIRI. This app owns auth, billing, chat synchronization, gateway forwarding, and server-side observability export. +Project AIRI's resource API. Authentication is a separate workspace app at +`server/apps/auth`; this package does not instantiate Better Auth or expose +auth/OIDC routes. -## What It Does +## Responsibilities -- Serves the Hono-based API and WebSocket endpoints. -- Uses Postgres as the source of truth for users, billing, and durable state. -- Uses Redis for cache, KV, Pub/Sub, and Streams. -- Forwards GenAI requests to the configured upstream gateway and records billing from usage. -- Exports traces, metrics, and logs through OpenTelemetry. +- Hono business APIs and WebSocket endpoints. +- Characters, chats, providers, Flux, Stripe, model routing, and billing. +- PostgreSQL migration ownership for the currently shared database. +- Redis cache, configuration KV, and cross-instance Pub/Sub. +- Local verification of Auth-issued OIDC JWTs through public JWKS. -## How To Use It - -Install dependencies from the repo root and run scoped commands: +## Run locally ```sh +pnpm -F @proj-airi/api-server dev pnpm -F @proj-airi/api-server typecheck pnpm -F @proj-airi/api-server exec vitest run pnpm -F @proj-airi/api-server build ``` -To run the API together with local PostgreSQL and Redis, use: +Run the complete local backend from the repository root: ```sh pnpm dev:backend ``` -## `AUTH_UI_URL` +For source-level debugging, start `@proj-airi/api-server` and +`@proj-airi/auth-server` separately instead. -`apps/ui-server-auth` is deployed separately from the server image. The API server still owns the historical `/auth/*` entrypoints and redirects them to **`AUTH_UI_URL`**. +`server/docker-compose.yaml` exposes the local Caddy gateway at `http://localhost:6112` and keeps +the API and Auth container ports private. -Default: +## Service boundaries -`AUTH_UI_URL=https://accounts.airi.build/ui` - -Set this when previewing or deploying auth UI to a different Cloudflare URL. - -## `ADMIN_UI_URL` - -The admin UI is deployed from the standalone `proj-airi` repository. The API server still owns the historical `/admin/*` entrypoints and redirects them to **`ADMIN_UI_URL`**. - -Default: - -`ADMIN_UI_URL=https://admin.airi.build` - -Set this when previewing or deploying admin UI to a different Cloudflare URL. - -## `RATE_LIMIT_TRUSTED_PROXY` - -Keep this unset for local and self-hosted deployments. Set -`RATE_LIMIT_TRUSTED_PROXY=railway` when the API runs behind the trusted -Railway/Caddy boundary so anonymous auth requests are keyed by Railway's -canonical `X-Real-IP` instead of the gateway socket address. - -## `ADDITIONAL_TRUSTED_ORIGINS` (LAN / Capacitor dev) - -When the mobile dev server uses a non-localhost origin (for example `https://10.x.x.x:5273` from `cap copy ios` / `capacitor.config.json`), set **`ADDITIONAL_TRUSTED_ORIGINS`** in `server/apps/api/.env.local` to a comma-separated list of exact origins (parsed and normalized at startup). Example: - -`ADDITIONAL_TRUSTED_ORIGINS=https://10.0.0.129:5273,https://198.18.0.1:5273` - -Restart the API server after changing this variable. +- `AUTH_SERVER_URL` is the public issuer origin used for JWKS, issuer, and + audience validation. With Caddy routing, it remains `https://api.airi.build`. +- `/internal/auth/*` is reachable only on the deployment's trusted private + network. The public edge must reject `/internal/*` and the API service must + not have its own public ingress. +- `AUTH_SERVER_INTERNAL_URL` optionally sends JWKS fetches directly to Auth on + the private network while issuer and audience remain `AUTH_SERVER_URL`. +- Auth tables and principal types come from `@proj-airi/auth-shared`; no module + under `server/apps/auth` is imported. +- `ADMIN_UI_URL` controls the standalone admin UI redirect and defaults to + `https://admin.airi.build`. diff --git a/server/apps/api/drizzle.config.ts b/server/apps/api/drizzle.config.ts index 953e3c1fb..306e23fff 100644 --- a/server/apps/api/drizzle.config.ts +++ b/server/apps/api/drizzle.config.ts @@ -3,7 +3,7 @@ import { env } from 'node:process' import { defineConfig } from 'drizzle-kit' export default defineConfig({ - schema: './src/schemas/**/*.ts', + schema: ['./src/schemas/**/*.ts', '../../packages/auth-shared/src/schema.ts'], out: './drizzle', dialect: 'postgresql', dbCredentials: { diff --git a/server/apps/api/package.json b/server/apps/api/package.json index d46d69510..2c81d4cb9 100644 --- a/server/apps/api/package.json +++ b/server/apps/api/package.json @@ -8,18 +8,14 @@ }, "scripts": { "apply:env": "dotenvx run -f .env.local --overload --ignore=MISSING_ENV_FILE", - "auth:generate": "pnpm run apply:env -- better-auth generate --config src/scripts/auth.ts --output src/schemas/accounts.ts -y", - "dev": "pnpm run apply:env -- tsx --import ./instrumentation.ts --watch src/bin/run.ts api", - "start": "pnpm run apply:env -- tsx --import ./instrumentation.ts src/bin/run.ts api", - "server": "pnpm run apply:env -- tsx --import ./instrumentation.ts src/bin/run.ts", - "build": "tsc --noEmit", + "dev": "pnpm run apply:env -- tsx --import ./instrumentation.ts --watch src/main.ts", + "start": "pnpm run apply:env -- tsx --import ./instrumentation.ts src/main.ts", + "build": "tsc -b", "typecheck": "tsc --noEmit", "db:generate": "drizzle-kit generate", "db:push": "pnpm run apply:env -- drizzle-kit push" }, "dependencies": { - "@better-auth/drizzle-adapter": "catalog:", - "@better-auth/oauth-provider": "catalog:", "@dotenvx/dotenvx": "catalog:", "@electric-sql/pglite": "catalog:", "@guiiai/logg": "catalog:", @@ -47,11 +43,10 @@ "@opentelemetry/sdk-node": "catalog:", "@opentelemetry/sdk-trace-node": "catalog:", "@opentelemetry/semantic-conventions": "catalog:", + "@proj-airi/auth-shared": "workspace:*", "@proj-airi/drizzle-migration": "workspace:*", "@proj-airi/drizzle-orm-browser-migrator": "catalog:", "@proj-airi/server-sdk-shared": "workspace:*", - "better-auth": "catalog:", - "cac": "catalog:", "drizzle-orm": "catalog:", "drizzle-valibot": "catalog:", "es-toolkit": "catalog:", @@ -64,7 +59,6 @@ "ofetch": "catalog:", "pg": "catalog:", "posthog-node": "catalog:", - "resend": "catalog:", "stripe": "catalog:", "unspeech": "catalog:xsai", "valibot": "catalog:", @@ -72,7 +66,6 @@ "zod": "catalog:" }, "devDependencies": { - "@better-auth/cli": "catalog:", "@types/pg": "catalog:", "@types/ws": "catalog:", "drizzle-kit": "catalog:" diff --git a/server/apps/api/production/railway/Dockerfile b/server/apps/api/production/railway/Dockerfile index af41b05bb..fe75c93cd 100644 --- a/server/apps/api/production/railway/Dockerfile +++ b/server/apps/api/production/railway/Dockerfile @@ -9,6 +9,7 @@ RUN corepack enable COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./ COPY patches/ ./patches/ COPY server/apps/api server/apps/api +COPY server/packages/auth-shared server/packages/auth-shared COPY server/packages/drizzle-migration server/packages/drizzle-migration COPY packages/server-sdk-shared packages/server-sdk-shared diff --git a/server/apps/api/railway.toml b/server/apps/api/railway.toml index 8fe612334..8e183abc2 100644 --- a/server/apps/api/railway.toml +++ b/server/apps/api/railway.toml @@ -3,8 +3,7 @@ builder = "DOCKERFILE" dockerfilePath = "/server/apps/api/production/railway/Dockerfile" watchPatterns = [ "server/apps/api/**", - "server/packages/drizzle-migration/**", - "packages/**", + "server/packages/**", "pnpm-lock.yaml" ] diff --git a/server/apps/api/src/app.test.ts b/server/apps/api/src/app.test.ts index c16e1f641..1f68e146d 100644 --- a/server/apps/api/src/app.test.ts +++ b/server/apps/api/src/app.test.ts @@ -3,122 +3,70 @@ import { describe, expect, it, vi } from 'vitest' import { buildApp } from './app' function createTestDeps() { - const authServerMetadata = { - issuer: 'http://localhost:3000/api/auth', - authorization_endpoint: 'http://localhost:3000/api/auth/oauth2/authorize', - token_endpoint: 'http://localhost:3000/api/auth/oauth2/token', - } - - const openIdConfig = { - issuer: 'http://localhost:3000/api/auth', - jwks_uri: 'http://localhost:3000/api/auth/jwks', - authorization_endpoint: 'http://localhost:3000/api/auth/oauth2/authorize', - token_endpoint: 'http://localhost:3000/api/auth/oauth2/token', - } - - const auth = { - api: { - getSession: vi.fn(async () => null), - getOAuthServerConfig: vi.fn(async () => authServerMetadata), - getOpenIdConfig: vi.fn(async () => openIdConfig), - }, - handler: vi.fn(async () => new Response('not-found', { status: 404 })), - } as any - const redisSubscriber = { on: vi.fn(), subscribe: vi.fn(async () => 1), unsubscribe: vi.fn(async () => 0), } - const redis = { duplicate: vi.fn(() => redisSubscriber), publish: vi.fn(async () => 0), } - const deps = { - auth, - db: {} as any, - characterService: {} as any, - chatService: {} as any, - providerService: {} as any, - fluxService: {} as any, - fluxTransactionService: {} as any, - stripeService: {} as any, - billingService: {} as any, - adminFluxGrantsService: {} as any, - adminRouterConfigService: {} as any, - adminUsersService: {} as any, - ttsMeter: {} as any, - requestLogService: {} as any, - voicePackService: {} as any, - providerCatalogService: {} as any, + return { + db: { query: { user: { findFirst: vi.fn() } } } as never, + characterService: {} as never, + chatService: {} as never, + providerService: {} as never, + fluxService: {} as never, + fluxTransactionService: {} as never, + stripeService: {} as never, + billingService: {} as never, + adminFluxGrantsService: {} as never, + adminRouterConfigService: {} as never, + adminUsersService: {} as never, + ttsMeter: {} as never, + requestLogService: {} as never, + voicePackService: {} as never, + providerCatalogService: {} as never, productEventService: { track: vi.fn(async () => undefined), trackGeneration: vi.fn(async () => undefined), countDistinctUsersByFeature: vi.fn(async () => []), - }, - configKV: { - getOrThrow: vi.fn(async (key: string) => { - switch (key) { - case 'AUTH_RATE_LIMIT_MAX': - return 20 - case 'AUTH_RATE_LIMIT_WINDOW_SEC': - return 60 - default: - throw new Error(`Unexpected config key: ${key}`) - } - }), - } as any, - redis: redis as any, + } as never, + configKV: { getOrThrow: vi.fn() } as never, + redis: redis as never, env: { - API_SERVER_URL: 'http://localhost:3000', - } as any, + API_SERVER_URL: 'https://api.airi.build', + AUTH_SERVER_URL: 'https://api.airi.build', + } as never, otel: null, - userDeletionService: {} as any, + userDeletionService: { register: vi.fn(), softDeleteAll: vi.fn() }, llmRouter: { route: vi.fn(async () => new Response('{}', { status: 200 })), invalidateConfig: vi.fn(), - } as any, + } as never, envelopeCrypto: { encryptKey: vi.fn(), decryptKey: vi.fn(), - } as any, - } - - return { - deps, - auth, - authServerMetadata, - openIdConfig, - redis, + } as never, } } -describe('app well-known metadata routes', () => { - it('serves oauth authorization server metadata at the root well-known path', async () => { - const { deps, auth, authServerMetadata } = createTestDeps() - const { app } = await buildApp(deps) +describe('business API app', () => { + it('does not expose Better Auth or OIDC provider routes', async () => { + const { app } = await buildApp(createTestDeps()) - const res = await app.request('/.well-known/oauth-authorization-server/api/auth') - - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toContain('application/json') - expect(await res.json()).toEqual(authServerMetadata) - expect(auth.api.getOAuthServerConfig).toHaveBeenCalledTimes(1) - expect(auth.api.getOpenIdConfig).not.toHaveBeenCalled() + expect((await app.request('/api/auth/get-session')).status).toBe(404) + expect((await app.request('/api/auth/.well-known/openid-configuration')).status).toBe(404) + expect((await app.request('/.well-known/oauth-authorization-server/api/auth')).status).toBe(404) }) - it('serves openid configuration at the issuer-appended well-known path', async () => { - const { deps, auth, openIdConfig } = createTestDeps() - const { app } = await buildApp(deps) + it('identifies itself as the resource API', async () => { + const { app } = await buildApp(createTestDeps()) + const response = await app.request('/') - const res = await app.request('/api/auth/.well-known/openid-configuration') - - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toContain('application/json') - expect(await res.json()).toEqual(openIdConfig) - expect(auth.api.getOpenIdConfig).toHaveBeenCalledTimes(1) - expect(auth.api.getOAuthServerConfig).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ service: 'airi-api' }) }) }) diff --git a/server/apps/api/src/app.ts b/server/apps/api/src/app.ts index d944e5a29..089d469c3 100644 --- a/server/apps/api/src/app.ts +++ b/server/apps/api/src/app.ts @@ -1,9 +1,6 @@ -import type Redis from 'ioredis' - -import type { AuthInstance } from './libs/auth' import type { Database } from './libs/db' import type { Env } from './libs/env' -import type { OtelInstance } from './otel' +import type { ApiOtelInstance } from './otel' import type { StreamingTtsVoiceType } from './routes/audio-speech-ws/session' import type { ConfigKVService } from './services/adapters/config-kv' import type { AdminFluxGrantsService } from './services/domain/admin/flux-grants' @@ -28,10 +25,10 @@ import type { EnvelopeCrypto } from './utils/envelope-crypto' import process from 'node:process' +import Redis from 'ioredis' import Stripe from 'stripe' import { initLogger, LoggerFormat, LoggerLevel, setGlobalHookPostLog, useLogger } from '@guiiai/logg' -import { serve } from '@hono/node-server' import { createNodeWebSocket } from '@hono/node-ws' import { httpInstrumentationMiddleware } from '@hono/otel' import { Hono } from 'hono' @@ -40,11 +37,9 @@ import { cors } from 'hono/cors' import { logger as honoLogger } from 'hono/logger' import { createLoggLogger, injeca, lifecycle } from 'injeca' -import { createAuth, getTrustedClientSeedSummaries, seedTrustedClients } from './libs/auth' import { createDrizzle, migrateDatabase } from './libs/db' import { parsedEnv } from './libs/env' import { initializeExternalDependency } from './libs/external-dependency' -import { createRedis } from './libs/redis' import { resolveRequestAuth } from './libs/request-auth' import { createUnauthorizedWsEvents } from './libs/ws-auth' import { sessionMiddleware } from './middlewares/auth' @@ -62,17 +57,16 @@ import { createAdminUsersRoutes } from './routes/admin/users' import { createAdminVoicePackRoutes } from './routes/admin/voice-packs' import { createAudioSpeechWsHandlers } from './routes/audio-speech-ws' import { createAudioTranscriptionStreamHandler } from './routes/audio-transcription-stream/route' -import { createAuthRoutes } from './routes/auth' import { createCharacterRoutes } from './routes/characters' import { createChatWsHandlers } from './routes/chat-ws' import { createChatRoutes } from './routes/chats' import { createFluxRoutes } from './routes/flux' +import { createInternalAuthRoutes } from './routes/internal-auth' import { createV1Routes } from './routes/openai/v1' import { createProviderRoutes } from './routes/providers' import { createStripeRoutes } from './routes/stripe' import { createVoicePackRoutes } from './routes/voice-packs' import { createConfigKVService } from './services/adapters/config-kv' -import { createEmailService } from './services/adapters/email' import { createPosthogSink } from './services/adapters/posthog' import { createAdminFluxGrantsService } from './services/domain/admin/flux-grants' import { createAdminRouterConfigService } from './services/domain/admin/router-config' @@ -97,7 +91,6 @@ import { nanoid } from './utils/id' import { getTrustedOrigin } from './utils/origin' interface AppDeps { - auth: AuthInstance db: Database characterService: CharacterService chatService: ChatService @@ -117,7 +110,7 @@ interface AppDeps { envelopeCrypto: EnvelopeCrypto redis: Redis env: Env - otel: OtelInstance | null + otel: ApiOtelInstance | null userDeletionService: UserDeletionService llmRouter: LlmRouterService providerCatalogService: ProviderCatalogService @@ -126,16 +119,15 @@ interface AppDeps { export async function buildApp(deps: AppDeps) { const logger = useLogger('app').useGlobalConfig() const userMetricsRecorder = deps.otel - ? registerUserMetricsSnapshotGauges(deps.otel.auth) + ? registerUserMetricsSnapshotGauges(deps.otel.user) : createDiscardingUserMetricsSnapshotRecorder() const app = new Hono() .use('*', async (c, next) => { await next() - // NOTICE: All API responses should be non-cacheable. Auth responses can - // carry session state through redirects, and stale API payloads are not - // safe to serve from edge caches after user/account mutations. + // NOTICE: Stale API payloads are unsafe to serve from edge caches after + // user, billing, or configuration mutations. c.res.headers.set('Cache-Control', 'no-store, no-cache, private, max-age=0') c.res.headers.set('Pragma', 'no-cache') c.res.headers.set('Expires', '0') @@ -183,7 +175,7 @@ export async function buildApp(deps: AppDeps) { return createUnauthorizedWsEvents() const session = await resolveRequestAuth( - deps.auth, + deps.db, deps.env, new Headers({ Authorization: `Bearer ${token}` }), ) @@ -211,7 +203,7 @@ export async function buildApp(deps: AppDeps) { return createUnauthorizedWsEvents() const session = await resolveRequestAuth( - deps.auth, + deps.db, deps.env, new Headers({ Authorization: `Bearer ${token}` }), ) @@ -229,7 +221,7 @@ export async function buildApp(deps: AppDeps) { // the request body is a live microphone PCM stream rather than a bounded JSON // payload. Auth is resolved manually here for the same reason. app.post('/api/v1/audio/transcriptions/stream', createAudioTranscriptionStreamHandler({ - auth: deps.auth, + db: deps.db, env: deps.env, configKV: deps.configKV, envelopeCrypto: deps.envelopeCrypto, @@ -265,7 +257,7 @@ export async function buildApp(deps: AppDeps) { }) const builtApp = app - .use('*', sessionMiddleware(deps.auth, deps.env)) + .use('*', sessionMiddleware(deps.db, deps.env)) .use('*', bodyLimit({ maxSize: 1024 * 1024 })) .onError((err, c) => { if (err instanceof ApiError) { @@ -343,16 +335,9 @@ export async function buildApp(deps: AppDeps) { ui: 'https://airi.moeru.ai', })) - /** - * Auth routes: sign-in page, token auth helpers, electron callback - * relay, well-known metadata, and better-auth catch-all. - */ - .route('/', await createAuthRoutes({ - auth: deps.auth, - db: deps.db, - env: deps.env, - configKV: deps.configKV, - rateLimitMetrics: deps.otel?.rateLimit, + .route('/internal/auth', createInternalAuthRoutes({ + userDeletionService: deps.userDeletionService, + productEventService: deps.productEventService, })) /** @@ -410,8 +395,8 @@ export async function buildApp(deps: AppDeps) { /** * Admin per-user balance override (set balance, incl. 0 for testing). - * Account ban/unban live under the better-auth admin plugin at - * `/api/auth/admin/ban-user` / `/api/auth/admin/unban-user`. + * Account ban/unban live on the Auth service under the Better Auth + * admin plugin at `/api/auth/admin/ban-user` and `/api/auth/admin/unban-user`. */ .route('/api/admin/users', createAdminUsersRoutes(deps.adminUsersService)) @@ -561,7 +546,7 @@ export async function createApp() { 'Redis', logger, async (attempt) => { - const instance = createRedis(dependsOn.env.REDIS_URL) + const instance = new Redis(dependsOn.env.REDIS_URL, { lazyConnect: true }) try { await instance.connect() @@ -587,15 +572,6 @@ export async function createApp() { build: ({ dependsOn }) => createConfigKVService(dependsOn.redis), }) - const emailService = injeca.provide('services:email', { - dependsOn: { env: parsedEnv, otel }, - build: ({ dependsOn }) => createEmailService({ - apiKey: dependsOn.env.RESEND_API_KEY, - fromEmail: dependsOn.env.RESEND_FROM_EMAIL, - fromName: dependsOn.env.RESEND_FROM_NAME, - }, undefined, dependsOn.otel?.email), - }) - const posthogSink = injeca.provide('services:posthogSink', { dependsOn: { env: parsedEnv, lifecycle }, // POSTHOG_PROJECT_KEY defaults to the shared project key, so the falsy @@ -678,24 +654,6 @@ export async function createApp() { }, }) - const auth = injeca.provide('services:auth', { - dependsOn: { db, env: parsedEnv, otel, email: emailService, userDeletionService, productEventService }, - build: async ({ dependsOn }) => { - // Seed trusted OIDC clients into DB so FK constraints on oauth_access_token are satisfied - await seedTrustedClients(dependsOn.db, dependsOn.env) - const trustedClients = getTrustedClientSeedSummaries(dependsOn.env) - logger.withField('apiServerUrl', dependsOn.env.API_SERVER_URL).log('OIDC startup configuration') - for (const client of trustedClients) { - logger.withFields({ - clientId: client.clientId, - clientName: client.name, - redirectUris: client.redirectUris.join(', '), - }).log('OIDC trusted client ready') - } - return createAuth(dependsOn.db, dependsOn.env, dependsOn.email, dependsOn.otel?.auth, dependsOn.userDeletionService, dependsOn.productEventService) - }, - }) - const requestLogService = injeca.provide('services:requestLog', { dependsOn: { db }, build: ({ dependsOn }) => createRequestLogService(dependsOn.db), @@ -799,7 +757,6 @@ export async function createApp() { await injeca.start() const resolved = await injeca.resolve({ db, - auth, characterService, chatService, providerService, @@ -824,13 +781,14 @@ export async function createApp() { providerCatalogService, ttsConcurrencyLedger, }) + // User/account gauges are passive snapshots refreshed by the admin route; + // Auth owns authentication event counters and performs no periodic DB reads. if (resolved.otel) { registerTtsPoolGauge(resolved.otel.gateway.poolInflight, resolved.ttsConcurrencyLedger, resolved.otel.observability.metricReadErrors) registerWsOnlineUsersGauge(resolved.otel.engagement.wsUsersOnline, resolved.redis, resolved.otel.observability.metricReadErrors) } - const { app, injectWebSocket } = await buildApp({ - auth: resolved.auth, + const appDeps = { db: resolved.db, characterService: resolved.characterService, chatService: resolved.chatService, @@ -854,9 +812,11 @@ export async function createApp() { userDeletionService: resolved.userDeletionService, llmRouter: resolved.llmRouter, providerCatalogService: resolved.providerCatalogService, - }) + } - logger.withFields({ hostname: resolved.env.HOST, port: resolved.env.PORT }).log('Server started') + const { app, injectWebSocket } = await buildApp(appDeps) + + logger.withFields({ role: 'api', hostname: resolved.env.HOST, port: resolved.env.PORT }).log('Server started') return { app, @@ -865,21 +825,3 @@ export async function createApp() { hostname: resolved.env.HOST, } } - -function handleProcessError(error: unknown, type: string) { - useLogger().withError(error).error(type) -} - -export async function runApiServer(): Promise { - const { app: honoApp, injectWebSocket, port, hostname } = await createApp() - const server = serve({ fetch: honoApp.fetch, port, hostname }) - injectWebSocket(server) - - process.on('uncaughtException', error => handleProcessError(error, 'Uncaught exception')) - process.on('unhandledRejection', error => handleProcessError(error, 'Unhandled rejection')) - - await new Promise((resolve, reject) => { - server.once('close', () => resolve()) - server.once('error', error => reject(error)) - }) -} diff --git a/server/apps/api/src/bin/run.ts b/server/apps/api/src/bin/run.ts deleted file mode 100644 index 550db0106..000000000 --- a/server/apps/api/src/bin/run.ts +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env node - -import process from 'node:process' - -import { pathToFileURL } from 'node:url' - -import { cac } from 'cac' - -import { runApiServer } from '../app' -import { errorMessageFromUnknown } from '../utils/error-message' - -export function createServerCli() { - const cli = cac('server') - - cli - .usage('') - .command('api', 'Start the HTTP/WebSocket API process') - .action(() => runApiServer()) - - cli.help() - - return cli -} - -async function main(): Promise { - const cli = createServerCli() - cli.parse(process.argv, { run: false }) - - if (!cli.matchedCommand) { - cli.outputHelp() - process.exitCode = 1 - return - } - - await cli.runMatchedCommand() -} - -function isExecutedAsMainModule(): boolean { - const entryFile = process.argv[1] - if (!entryFile) { - return false - } - - return import.meta.url === pathToFileURL(entryFile).href -} - -if (isExecutedAsMainModule()) { - void main().catch((error: unknown) => { - process.stderr.write(`${errorMessageFromUnknown(error)}\n`) - process.exit(1) - }) -} diff --git a/server/apps/api/src/libs/env.ts b/server/apps/api/src/libs/env.ts index 61f3c616c..5ca0f9ddf 100644 --- a/server/apps/api/src/libs/env.ts +++ b/server/apps/api/src/libs/env.ts @@ -5,48 +5,18 @@ import { env, exit } from 'node:process' import { useLogger } from '@guiiai/logg' import { injeca } from 'injeca' -import { check, integer, maxValue, minValue, nonEmpty, object, optional, parse, picklist, pipe, string, transform } from 'valibot' +import { array, check, integer, maxValue, minValue, nonEmpty, object, optional, parse, pipe, string, transform, url } from 'valibot' -/** - * Parses `ADDITIONAL_TRUSTED_ORIGINS`: comma-separated absolute origins used for - * CORS (`/api/*`) and request-derived trusted bases (e.g. Stripe return URLs). - * Each segment is normalized via `URL.origin` so trailing slashes are stripped. - * - * Before: - * - `" https://10.0.0.129:5273/ , https://198.18.0.1:5273 "` - * - * After: - * - `["https://10.0.0.129:5273", "https://198.18.0.1:5273"]` - */ -export function parseAdditionalTrustedOriginsEnv(raw: string): string[] { - const trimmed = raw.trim() - if (!trimmed) - return [] - - const seen = new Set() - const out: string[] = [] - - for (const part of trimmed.split(',')) { - const entry = part.trim() - if (!entry) - continue - - let normalized: string - try { - normalized = new URL(entry).origin - } - catch { - throw new TypeError(`ADDITIONAL_TRUSTED_ORIGINS: invalid URL origin segment "${entry}"`) - } - - if (!seen.has(normalized)) { - seen.add(normalized) - out.push(normalized) - } - } - - return out -} +const AdditionalTrustedOriginsSchema = pipe( + string(), + transform(raw => raw.split(',').map(origin => origin.trim()).filter(Boolean)), + array(pipe( + string(), + url('ADDITIONAL_TRUSTED_ORIGINS entries must be valid URLs'), + transform(origin => new URL(origin).origin), + )), + transform(origins => [...new Set(origins)]), +) function optionalIntegerFromString(defaultValue: number, envKey: string, minimum: number) { return optional( @@ -79,16 +49,8 @@ const EnvSchema = object({ PORT: optionalIntegerFromString(3000, 'PORT', 1), API_SERVER_URL: optional(string(), 'http://localhost:3000'), - - // Trust Railway's canonical client-IP headers only when the application is - // deployed behind a private reverse-proxy boundary. Keep unset for direct or - // self-hosted deployments so callers cannot choose their own rate-limit key. - RATE_LIMIT_TRUSTED_PROXY: optional(picklist(['railway'])), - - // Standalone auth UI base URL. The server keeps `/auth/*` as the historical - // entrypoint and redirects those requests here after ui-server-auth moved out - // of the server image. - AUTH_UI_URL: optional(string(), 'https://accounts.airi.build/ui'), + AUTH_SERVER_URL: optional(string(), 'http://localhost:3000'), + AUTH_SERVER_INTERNAL_URL: optional(string()), // Standalone admin UI base URL. The server keeps `/admin/*` as the historical // entrypoint and redirects those requests here after the admin UI moved to @@ -105,68 +67,22 @@ const EnvSchema = object({ // Comma-separated exact origins (e.g. Capacitor dev server `https://10.x:5273`). // Prefer this over broad private-IP regex heuristics in production-like configs. ADDITIONAL_TRUSTED_ORIGINS: optional( - pipe( - string(), - transform(raw => parseAdditionalTrustedOriginsEnv(raw)), - ), + AdditionalTrustedOriginsSchema, '', ), DATABASE_URL: pipe(string(), nonEmpty('DATABASE_URL is required')), REDIS_URL: pipe(string(), nonEmpty('REDIS_URL is required')), - // Required: signs session cookies and encrypts JWKS private keys in DB. - // Must be stable across deploys/instances, otherwise every redeploy invalidates - // all existing sessions and forces users to re-login. - BETTER_AUTH_SECRET: pipe(string(), nonEmpty('BETTER_AUTH_SECRET is required')), - - AUTH_GOOGLE_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_ID is required')), - AUTH_GOOGLE_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_SECRET is required')), - AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')), - AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')), - AUTH_APPLE_CLIENT_ID: optional(string(), ''), - AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: optional( - pipe( - string(), - transform(raw => [...new Set( - raw - .split(',') - .map(bundleIdentifier => bundleIdentifier.trim()) - .filter(Boolean), - )]), - ), - '', - ), - AUTH_APPLE_TEAM_ID: optional(string(), ''), - AUTH_APPLE_KEY_ID: optional(string(), ''), - AUTH_APPLE_PRIVATE_KEY_PEM: optional( - pipe( - string(), - // Deployment dashboards commonly store multiline secrets with escaped - // newlines. jose's PKCS8 importer requires the original PEM layout. - transform(raw => raw.replaceAll(String.raw`\n`, '\n')), - ), - '', - ), - // Testing-only bearer token bypass. Keep unset in production. When set, // Authorization: Bearer $TEST_AUTH_TOKEN resolves to the virtual user below - // through resolveRequestAuth without creating a better-auth session row. + // through resolveRequestAuth without creating an Auth session row. TEST_AUTH_TOKEN: optional(string(), ''), TEST_AUTH_USER_ID: optional(pipe(string(), nonEmpty('TEST_AUTH_USER_ID must not be empty when set')), 'test-user'), TEST_AUTH_USER_EMAIL: optional(pipe(string(), nonEmpty('TEST_AUTH_USER_EMAIL must not be empty when set')), 'test@example.com'), TEST_AUTH_USER_NAME: optional(pipe(string(), nonEmpty('TEST_AUTH_USER_NAME must not be empty when set')), 'Test User'), TEST_AUTH_USER_ROLE: optional(string(), ''), - // Resend transactional email. RESEND_API_KEY required when emailAndPassword - // sign-up / forgot-password / change-email / magic-link is exercised. Service - // boots without it but those flows will throw at send-time. - RESEND_API_KEY: optional(string(), ''), - // From address must be a verified Resend sender (e.g. `noreply@your-domain`). - RESEND_FROM_EMAIL: optional(string(), 'noreply@airi.moeru.ai'), - // Optional friendly name; rendered as `Name ` per Resend's RFC 5322 display-name format. - RESEND_FROM_NAME: optional(string(), 'Project AIRI'), - STRIPE_SECRET_KEY: optional(string()), STRIPE_WEBHOOK_SECRET: optional(string()), diff --git a/server/apps/api/src/libs/external-dependency.ts b/server/apps/api/src/libs/external-dependency.ts index f11de609a..2166717dc 100644 --- a/server/apps/api/src/libs/external-dependency.ts +++ b/server/apps/api/src/libs/external-dependency.ts @@ -1,18 +1,14 @@ +import type { Logger } from '@guiiai/logg' + import { withRetry } from '@moeru/std' const EXTERNAL_DEPENDENCY_INIT_MAX_ATTEMPTS = 5 const EXTERNAL_DEPENDENCY_INIT_BASE_DELAY_MS = 5000 -interface ExternalDependencyLogger { - log: (message: string) => unknown - withError: (error: unknown) => { - warn: (message: string) => unknown - } -} - +/** Initializes an API dependency using the process startup retry policy. */ export async function initializeExternalDependency( dependencyName: string, - logger: ExternalDependencyLogger, + logger: Logger, initialize: (attempt: number) => Promise, ): Promise { let attempt = 0 diff --git a/server/apps/api/src/libs/gravatar.ts b/server/apps/api/src/libs/gravatar.ts deleted file mode 100644 index 69985e902..000000000 --- a/server/apps/api/src/libs/gravatar.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Gravatar fallback URL builder for the server side. - * - * Use when: - * - Decorating session/profile responses so every client (web, Electron, - * mobile) receives a usable avatar URL even if no provider supplied an - * `image` and the user never uploaded one. Computing on the server keeps - * the hashing implementation in one place and lets future swaps (e.g. - * hosting our own avatars or proxying through a CDN) happen without - * touching every client. - * - * Background: - * - Gravatar's modern API hashes the trimmed/lowercased email with SHA-256. - * Docs: https://docs.gravatar.com/api/avatars/hash/. - */ - -import { createHash } from 'node:crypto' - -const GRAVATAR_BASE_URL = 'https://www.gravatar.com/avatar/' - -/** - * Default-image keyword for Gravatar's `d` query parameter. - * - * `identicon` deterministically generates a geometric pattern from the email - * hash so users with no Gravatar still get a unique-looking avatar. Switch - * to `mp` (mystery person) when a neutral silhouette is preferred. - */ -const DEFAULT_FALLBACK = 'identicon' - -/** - * Default rendered size in pixels. Profile avatar slot is rendered at - * 96px logical, so 200px gives sharp output on retina displays. - */ -const DEFAULT_SIZE = 200 - -interface GravatarOptions { - /** - * Default image keyword to serve when the email has no Gravatar profile. - * - * @default 'identicon' - */ - fallback?: 'identicon' | 'monsterid' | 'wavatar' | 'retro' | 'robohash' | 'mp' | '404' - /** - * Output square size in pixels. - * - * @default 200 - */ - size?: number -} - -/** - * Build a Gravatar avatar URL from an email address. - * - * Use when: - * - Decorating an API response that exposes a user; falls back to a - * personalised placeholder when no real avatar is on file. - * - * Expects: - * - `email` is non-empty. Empty/whitespace emails return `null` so the - * caller can decide whether to omit the field entirely. - * - * Returns: - * - Full HTTPS Gravatar URL or `null` when input is unusable. - * - * Before: - * - "Hello@Example.COM " - * - * After: - * - "https://www.gravatar.com/avatar/973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b?d=identicon&s=200" - */ -export function buildGravatarUrl(email: string, options: GravatarOptions = {}): string | null { - const trimmed = email.trim().toLowerCase() - if (!trimmed) - return null - - const hash = createHash('sha256').update(trimmed).digest('hex') - - const url = new URL(hash, GRAVATAR_BASE_URL) - url.searchParams.set('d', options.fallback ?? DEFAULT_FALLBACK) - url.searchParams.set('s', String(options.size ?? DEFAULT_SIZE)) - return url.toString() -} diff --git a/server/apps/api/src/libs/redis.ts b/server/apps/api/src/libs/redis.ts deleted file mode 100644 index c545849c0..000000000 --- a/server/apps/api/src/libs/redis.ts +++ /dev/null @@ -1,5 +0,0 @@ -import Redis from 'ioredis' - -export function createRedis(url: string): Redis { - return new Redis(url, { lazyConnect: true }) -} diff --git a/server/apps/api/src/libs/request-auth.ts b/server/apps/api/src/libs/request-auth.ts index a06a68a9b..181aa1104 100644 --- a/server/apps/api/src/libs/request-auth.ts +++ b/server/apps/api/src/libs/request-auth.ts @@ -1,33 +1,33 @@ -import type auth from '../scripts/auth' -import type { AuthInstance } from './auth' -import type { Env } from './env' +import type { AuthSession } from '@proj-airi/auth-shared' + +import type { Database } from './db' import { Buffer } from 'node:buffer' import { timingSafeEqual } from 'node:crypto' +import { isUserBannedNow } from '@proj-airi/auth-shared' +import { eq } from 'drizzle-orm' import { createRemoteJWKSet, jwtVerify } from 'jose' -export interface RequestAuthSession { - user: typeof auth.$Infer.Session.user - session: typeof auth.$Infer.Session.session +import * as authSchema from '@proj-airi/auth-shared' + +interface RequestAuthEnv { + AUTH_SERVER_URL: string + AUTH_SERVER_INTERNAL_URL?: string + TEST_AUTH_TOKEN: string + TEST_AUTH_USER_ID: string + TEST_AUTH_USER_EMAIL: string + TEST_AUTH_USER_NAME: string + TEST_AUTH_USER_ROLE: string } -/** - * Whether a user is currently banned, honoring `banExpires`. - * - * The better-auth `admin` plugin auto-clears an expired ban only on the next - * login attempt (`session.create.before`); the stateless OIDC JWT hot path - * never creates a session, so we evaluate expiry here too — a `banned` row - * whose `banExpires` is in the past is treated as not banned. - */ -export function isUserBannedNow(user: { banned?: boolean | null, banExpires?: Date | string | null }): boolean { - if (!user.banned) - return false - if (user.banExpires == null) - return true - return new Date(user.banExpires).getTime() > Date.now() +interface TokenIssuerEnv { + AUTH_SERVER_URL: string + AUTH_SERVER_INTERNAL_URL?: string } +export type RequestAuthSession = AuthSession + function readBearerToken(headers: Headers): string | null { const authorization = headers.get('authorization') if (!authorization?.startsWith('Bearer ')) @@ -43,7 +43,7 @@ function timingSafeStringEqual(left: string, right: string): boolean { return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer) } -function resolveTestAuthToken(env: Env, accessToken: string): RequestAuthSession | null { +function resolveTestAuthToken(env: RequestAuthEnv, accessToken: string): AuthSession | null { if (!env.TEST_AUTH_TOKEN || !timingSafeStringEqual(accessToken, env.TEST_AUTH_TOKEN)) return null @@ -65,7 +65,7 @@ function resolveTestAuthToken(env: Env, accessToken: string): RequestAuthSession lastSeenAt: now, createdAt: now, updatedAt: now, - } as RequestAuthSession['user'], + } as AuthSession['user'], session: { id: `test-auth:${env.TEST_AUTH_USER_ID}`, token: accessToken, @@ -75,19 +75,21 @@ function resolveTestAuthToken(env: Env, accessToken: string): RequestAuthSession expiresAt, ipAddress: null, userAgent: null, - } as RequestAuthSession['session'], + } as AuthSession['session'], } } -let cachedJWKS: ReturnType | null = null +const cachedJWKS = new Map>() -function getJWKS(env: Env): ReturnType { - if (!cachedJWKS) { - cachedJWKS = createRemoteJWKSet( - new URL('/api/auth/jwks', env.API_SERVER_URL), - ) - } - return cachedJWKS +function getJWKS(env: TokenIssuerEnv): ReturnType { + const jwksUrl = new URL('/api/auth/jwks', env.AUTH_SERVER_INTERNAL_URL ?? env.AUTH_SERVER_URL).toString() + const cached = cachedJWKS.get(jwksUrl) + if (cached) + return cached + + const jwks = createRemoteJWKSet(new URL(jwksUrl)) + cachedJWKS.set(jwksUrl, jwks) + return jwks } /** @@ -96,32 +98,28 @@ function getJWKS(env: Env): ReturnType { * Still requires one findUserById call to build the full RequestAuthSession. */ async function resolveJWTAccessToken( - auth: AuthInstance, - env: Env, + db: Database, + env: TokenIssuerEnv, accessToken: string, -): Promise { +): Promise { try { const jwks = getJWKS(env) // NOTICE: better-auth's jwt() plugin sets issuer to the full baseURL // including the path prefix (e.g. "http://localhost:3000/api/auth"), // not just the server origin. const { payload } = await jwtVerify(accessToken, jwks, { - issuer: `${env.API_SERVER_URL}/api/auth`, - audience: env.API_SERVER_URL, + issuer: `${env.AUTH_SERVER_URL}/api/auth`, + audience: env.AUTH_SERVER_URL, }) if (!payload.sub) return null - const ctx = await auth.$context - // NOTICE: - // internalAdapter.findUserById is typed as better-auth's base User and omits - // the admin-plugin fields (banned/role/banReason/banExpires), but the query - // selects the full row so the runtime value carries them. Widen to the - // inferred session user so `banned` is visible to isUserBannedNow and the - // RequestAuthSession return type matches. - // Removal condition: better-auth's adapter return type includes plugin fields. - const user = await ctx.internalAdapter.findUserById(payload.sub) as RequestAuthSession['user'] | null + // The resource server deliberately reads only its authorization projection. + // It does not instantiate Better Auth or depend on its internal adapter. + const user = await db.query.user.findFirst({ + where: eq(authSchema.user.id, payload.sub), + }) if (!user) return null @@ -144,44 +142,17 @@ async function resolveJWTAccessToken( } } -/** - * Resolve a session from request headers WITHOUT applying the ban gate. - * - * Use when: - * - A caller needs the verified principal but will make its own ban decision, - * e.g. the OIDC `/oauth2/userinfo` guard that wants to 403 a banned subject - * distinctly from an invalid/expired token. - * - * Do NOT use this on request-serving paths to obtain `c.get('user')` — that is - * what {@link resolveRequestAuth} is for, and it applies the ban gate. Using - * this resolver there would silently let banned principals through. - */ -export async function resolveSessionIgnoringBan( - auth: AuthInstance, - env: Env, +export async function resolveRequestAuth( + db: Database, + env: RequestAuthEnv, headers: Headers, -): Promise { - const session = await auth.api.getSession({ headers }) - if (session?.user && session?.session) - return session - +): Promise { const accessToken = readBearerToken(headers) if (!accessToken) return null const testSession = resolveTestAuthToken(env, accessToken) - if (testSession) - return testSession - - return await resolveJWTAccessToken(auth, env, accessToken) -} - -export async function resolveRequestAuth( - auth: AuthInstance, - env: Env, - headers: Headers, -): Promise { - const resolved = await resolveSessionIgnoringBan(auth, env, headers) + const resolved = testSession ?? await resolveJWTAccessToken(db, env, accessToken) if (!resolved) return null diff --git a/server/apps/api/src/libs/tests/env.test.ts b/server/apps/api/src/libs/tests/env.test.ts index 1cf440e52..9a90f44e8 100644 --- a/server/apps/api/src/libs/tests/env.test.ts +++ b/server/apps/api/src/libs/tests/env.test.ts @@ -2,103 +2,34 @@ import { Buffer } from 'node:buffer' import { describe, expect, it } from 'vitest' -import { parseAdditionalTrustedOriginsEnv, parseEnv } from '../env' +import { parseEnv } from '../env' function baseEnv(): Record { return { DATABASE_URL: 'postgres://example', REDIS_URL: 'redis://example', - BETTER_AUTH_SECRET: 'test-secret-at-least-32-characters-long', - AUTH_GOOGLE_CLIENT_ID: 'google-client', - AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', - AUTH_GITHUB_CLIENT_ID: 'github-client', - AUTH_GITHUB_CLIENT_SECRET: 'github-secret', - AUTH_APPLE_CLIENT_ID: 'apple-service-id', - AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: 'ai.moeru.airi-pocket, ai.moeru.airi-pro, ai.moeru.airi-pocket', - AUTH_APPLE_TEAM_ID: 'apple-team-id', - AUTH_APPLE_KEY_ID: 'apple-key-id', - AUTH_APPLE_PRIVATE_KEY_PEM: 'line-one\\nline-two', // Required: a deterministic 32-byte base64 value so env parse succeeds. LLM_ROUTER_MASTER_KEY: Buffer.alloc(32, 0xAA).toString('base64'), } } -describe('parseAdditionalTrustedOriginsEnv', () => { - it('normalizes comma-separated origins and dedupes', () => { - expect(parseAdditionalTrustedOriginsEnv('')).toEqual([]) - expect(parseAdditionalTrustedOriginsEnv(' https://10.0.0.129:5273/ , https://198.18.0.1:5273 ')).toEqual([ - 'https://10.0.0.129:5273', - 'https://198.18.0.1:5273', - ]) - expect(parseAdditionalTrustedOriginsEnv('https://x.test:5273/,https://x.test:5273')).toEqual([ - 'https://x.test:5273', - ]) - }) - - it('throws on invalid segments', () => { - expect(() => parseAdditionalTrustedOriginsEnv('not-a-url')).toThrow(/invalid URL origin segment/) - }) -}) - describe('parseEnv', () => { - it('parses the required auth and infrastructure environment variables', () => { + it('parses the API environment without Identity-provider credentials', () => { const env = parseEnv(baseEnv()) expect(env.DATABASE_URL).toBe('postgres://example') expect(env.REDIS_URL).toBe('redis://example') - expect(env.AUTH_UI_URL).toBe('https://accounts.airi.build/ui') expect(env.ADMIN_UI_URL).toBe('https://admin.airi.build') expect(env.ADDITIONAL_TRUSTED_ORIGINS).toEqual([]) - expect(env.AUTH_APPLE_APP_BUNDLE_IDENTIFIERS).toEqual([ - 'ai.moeru.airi-pocket', - 'ai.moeru.airi-pro', - ]) - expect(env.RATE_LIMIT_TRUSTED_PROXY).toBeUndefined() - expect(env.AUTH_APPLE_PRIVATE_KEY_PEM).toBe('line-one\nline-two') - }) - - it('parses an explicit Railway rate-limit proxy boundary', () => { - const env = parseEnv({ - ...baseEnv(), - RATE_LIMIT_TRUSTED_PROXY: 'railway', - }) - - expect(env.RATE_LIMIT_TRUSTED_PROXY).toBe('railway') - }) - - it('allows Apple auth to remain disabled when no Apple credentials are configured', () => { - const input = baseEnv() - delete input.AUTH_APPLE_CLIENT_ID - delete input.AUTH_APPLE_APP_BUNDLE_IDENTIFIERS - delete input.AUTH_APPLE_TEAM_ID - delete input.AUTH_APPLE_KEY_ID - delete input.AUTH_APPLE_PRIVATE_KEY_PEM - - const env = parseEnv(input) - - expect(env.AUTH_APPLE_CLIENT_ID).toBe('') - expect(env.AUTH_APPLE_APP_BUNDLE_IDENTIFIERS).toEqual([]) - expect(env.AUTH_APPLE_TEAM_ID).toBe('') - expect(env.AUTH_APPLE_KEY_ID).toBe('') - expect(env.AUTH_APPLE_PRIVATE_KEY_PEM).toBe('') - }) - - it('leaves incomplete Apple credentials for provider setup to disable', () => { - const input = baseEnv() - delete input.AUTH_APPLE_PRIVATE_KEY_PEM - - const env = parseEnv(input) - - expect(env.AUTH_APPLE_CLIENT_ID).toBe('apple-service-id') - expect(env.AUTH_APPLE_TEAM_ID).toBe('apple-team-id') - expect(env.AUTH_APPLE_KEY_ID).toBe('apple-key-id') - expect(env.AUTH_APPLE_PRIVATE_KEY_PEM).toBe('') + expect('BETTER_AUTH_SECRET' in env).toBe(false) + expect('AUTH_GOOGLE_CLIENT_ID' in env).toBe(false) + expect('RESEND_API_KEY' in env).toBe(false) }) it('parses ADDITIONAL_TRUSTED_ORIGINS into a normalized origin list', () => { const env = parseEnv({ ...baseEnv(), - ADDITIONAL_TRUSTED_ORIGINS: 'https://10.0.0.129:5273/, https://198.18.0.1:5273', + ADDITIONAL_TRUSTED_ORIGINS: 'https://10.0.0.129:5273/, https://198.18.0.1:5273, https://10.0.0.129:5273', }) expect(env.ADDITIONAL_TRUSTED_ORIGINS).toEqual([ diff --git a/server/apps/api/src/libs/tests/request-auth.test.ts b/server/apps/api/src/libs/tests/request-auth.test.ts index dc1537939..29667e319 100644 --- a/server/apps/api/src/libs/tests/request-auth.test.ts +++ b/server/apps/api/src/libs/tests/request-auth.test.ts @@ -1,121 +1,88 @@ +import type { Database } from '../db' +import type { RequestAuthSession } from '../request-auth' + import { beforeEach, describe, expect, it, vi } from 'vitest' import { resolveRequestAuth } from '../request-auth' -// Mock jose module vi.mock('jose', () => ({ createRemoteJWKSet: vi.fn(() => 'mock-jwks'), jwtVerify: vi.fn(), })) -const { jwtVerify } = await import('jose') +const { createRemoteJWKSet, jwtVerify } = await import('jose') +const mockedCreateRemoteJWKSet = vi.mocked(createRemoteJWKSet) const mockedJwtVerify = vi.mocked(jwtVerify) const mockEnv = { - API_SERVER_URL: 'http://localhost:3000', + AUTH_SERVER_URL: 'https://api.airi.build', TEST_AUTH_TOKEN: '', TEST_AUTH_USER_ID: 'test-user', TEST_AUTH_USER_EMAIL: 'test@example.com', TEST_AUTH_USER_NAME: 'Test User', TEST_AUTH_USER_ROLE: '', -} as any +} as const + +function createUser(overrides: Partial = {}): RequestAuthSession['user'] { + const now = new Date() + return { + id: 'user-1', + email: 'user@example.com', + name: 'User', + emailVerified: true, + image: null, + role: null, + banned: false, + banReason: null, + banExpires: null, + createdAt: now, + updatedAt: now, + ...overrides, + } +} + +function createDb(user: RequestAuthSession['user'] | null): Database { + return { + query: { + user: { + findFirst: vi.fn(async () => user), + }, + }, + } as unknown as Database +} + +function mockValidJwt(subject = 'user-1') { + const iat = Math.floor(Date.now() / 1000) + const exp = iat + 3600 + mockedJwtVerify.mockResolvedValue({ + payload: { sub: subject, iat, exp, jti: 'jwt-token-id' }, + protectedHeader: { alg: 'RS256' }, + key: new Uint8Array(), + }) + return { iat, exp } +} describe('resolveRequestAuth', () => { beforeEach(() => { mockedJwtVerify.mockReset() + mockedCreateRemoteJWKSet.mockClear() }) - it('rejects a banned principal even when the session resolves (immediate revocation)', async () => { - // `user.banned` comes from the better-auth admin plugin and is loaded with - // the user row, so the hot-path gate is a field check (no extra query). - const authSession = { - user: { id: 'user-1', email: 'banned@example.com', name: 'User', emailVerified: true, image: null, banned: true, banExpires: null, createdAt: new Date(), updatedAt: new Date() }, - session: { id: 'session-1', userId: 'user-1', token: 'session-token', createdAt: new Date(), updatedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), ipAddress: null, userAgent: null }, - } - const auth = { api: { getSession: vi.fn().mockResolvedValue(authSession) } } - - const result = await resolveRequestAuth(auth as any, mockEnv, new Headers()) - - expect(result).toBeNull() - }) - - it('treats an expired ban (banExpires in the past) as not banned', async () => { - const authSession = { - user: { id: 'user-1', email: 'expired@example.com', name: 'User', emailVerified: true, image: null, banned: true, banExpires: new Date(Date.now() - 1000), createdAt: new Date(), updatedAt: new Date() }, - session: { id: 'session-1', userId: 'user-1', token: 'session-token', createdAt: new Date(), updatedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), ipAddress: null, userAgent: null }, - } - const auth = { api: { getSession: vi.fn().mockResolvedValue(authSession) } } - - const result = await resolveRequestAuth(auth as any, mockEnv, new Headers()) - - expect(result).toBe(authSession) - }) - - it('returns the better-auth session when it is already available', async () => { - const authSession = { - user: { id: 'user-1', email: 'user@example.com', name: 'User', emailVerified: true, image: null, createdAt: new Date(), updatedAt: new Date() }, - session: { id: 'session-1', userId: 'user-1', token: 'session-token', createdAt: new Date(), updatedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), ipAddress: null, userAgent: null }, - } - - const auth = { - api: { - getSession: vi.fn().mockResolvedValue(authSession), - }, - } + it('verifies access tokens against the public API issuer and audience', async () => { + const { iat, exp } = mockValidJwt() + const user = createUser() const result = await resolveRequestAuth( - auth as any, - mockEnv, - new Headers({ Authorization: 'Bearer ignored' }), - ) - - expect(result).toBe(authSession) - expect(mockedJwtVerify).not.toHaveBeenCalled() - }) - - it('verifies JWT and returns user session when no better-auth session exists', async () => { - const iat = Math.floor(Date.now() / 1000) - const exp = iat + 3600 - const user = { - id: 'user-1', - email: 'user@example.com', - name: 'User', - emailVerified: true, - image: null, - createdAt: new Date(), - updatedAt: new Date(), - } - - mockedJwtVerify.mockResolvedValue({ - payload: { - sub: 'user-1', - iss: 'http://localhost:3000/api/auth', - aud: ['http://localhost:3000', 'http://localhost:3000/api/auth/oauth2/userinfo'], - iat, - exp, - jti: 'jwt-token-id', - }, - protectedHeader: { alg: 'RS256' }, - key: {} as any, - } as any) - - const auth = { - api: { - getSession: vi.fn().mockResolvedValue(null), - }, - $context: Promise.resolve({ - internalAdapter: { - findUserById: vi.fn().mockResolvedValue(user), - }, - }), - } - - const result = await resolveRequestAuth( - auth as any, + createDb(user), mockEnv, new Headers({ Authorization: 'Bearer eyJhbGciOiJSUzI1NiJ9.test.sig' }), ) + expect(mockedCreateRemoteJWKSet).toHaveBeenCalledWith(new URL('https://api.airi.build/api/auth/jwks')) + expect(mockedJwtVerify).toHaveBeenCalledWith('eyJhbGciOiJSUzI1NiJ9.test.sig', 'mock-jwks', { + issuer: 'https://api.airi.build/api/auth', + audience: 'https://api.airi.build', + }) expect(result).toEqual({ user, session: { @@ -131,33 +98,49 @@ describe('resolveRequestAuth', () => { }) }) - it('returns null when JWT verification fails', async () => { - mockedJwtVerify.mockRejectedValue(new Error('invalid signature')) + it('fetches JWKS privately while preserving the public issuer contract', async () => { + mockValidJwt() - const auth = { - api: { - getSession: vi.fn().mockResolvedValue(null), + await resolveRequestAuth( + createDb(createUser()), + { + ...mockEnv, + AUTH_SERVER_INTERNAL_URL: 'http://auth:3000', }, - } - - const result = await resolveRequestAuth( - auth as any, - mockEnv, - new Headers({ Authorization: 'Bearer invalid-jwt' }), + new Headers({ Authorization: 'Bearer jwt' }), ) + expect(mockedCreateRemoteJWKSet).toHaveBeenCalledWith(new URL('http://auth:3000/api/auth/jwks')) + expect(mockedJwtVerify).toHaveBeenCalledWith('jwt', 'mock-jwks', { + issuer: 'https://api.airi.build/api/auth', + audience: 'https://api.airi.build', + }) + }) + + it('rejects a banned principal after signature verification', async () => { + mockValidJwt() + const result = await resolveRequestAuth( + createDb(createUser({ banned: true, banExpires: null })), + mockEnv, + new Headers({ Authorization: 'Bearer jwt' }), + ) expect(result).toBeNull() }) - it('returns the configured test user when bearer token matches TEST_AUTH_TOKEN', async () => { - const auth = { - api: { - getSession: vi.fn().mockResolvedValue(null), - }, - } - + it('accepts a principal whose temporary ban has expired', async () => { + mockValidJwt() + const user = createUser({ banned: true, banExpires: new Date(Date.now() - 1000) }) const result = await resolveRequestAuth( - auth as any, + createDb(user), + mockEnv, + new Headers({ Authorization: 'Bearer jwt' }), + ) + expect(result?.user).toEqual(user) + }) + + it('returns the configured test principal without querying JWKS', async () => { + const result = await resolveRequestAuth( + createDb(null), { ...mockEnv, TEST_AUTH_TOKEN: 'test-secret', @@ -171,75 +154,29 @@ describe('resolveRequestAuth', () => { expect(result?.user.id).toBe('test-user-1') expect(result?.user.email).toBe('test@example.com') - expect(result?.user.name).toBe('Local Test User') expect(result?.user.role).toBe('admin') - expect(result?.session.userId).toBe('test-user-1') - expect(result?.session.token).toBe('test-secret') expect(mockedJwtVerify).not.toHaveBeenCalled() }) - it('falls through to JWT verification when TEST_AUTH_TOKEN does not match', async () => { - mockedJwtVerify.mockRejectedValue(new Error('invalid signature')) + it('returns null for missing, invalid, or subjectless bearer tokens', async () => { + expect(await resolveRequestAuth(createDb(null), mockEnv, new Headers())).toBeNull() - const auth = { - api: { - getSession: vi.fn().mockResolvedValue(null), - }, - } - - const result = await resolveRequestAuth( - auth as any, - { - ...mockEnv, - TEST_AUTH_TOKEN: 'test-secret', - }, - new Headers({ Authorization: 'Bearer different-secret' }), - ) - - expect(result).toBeNull() - expect(mockedJwtVerify).toHaveBeenCalled() - }) - - it('returns null when no Authorization header is present', async () => { - const auth = { - api: { - getSession: vi.fn().mockResolvedValue(null), - }, - } - - const result = await resolveRequestAuth( - auth as any, + mockedJwtVerify.mockRejectedValueOnce(new Error('invalid signature')) + expect(await resolveRequestAuth( + createDb(null), mockEnv, - new Headers(), - ) + new Headers({ Authorization: 'Bearer invalid' }), + )).toBeNull() - expect(result).toBeNull() - }) - - it('returns null when JWT has no sub claim', async () => { - mockedJwtVerify.mockResolvedValue({ - payload: { - iss: 'http://localhost:3000', - aud: 'http://localhost:3000', - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600, - }, + mockedJwtVerify.mockResolvedValueOnce({ + payload: { exp: Math.floor(Date.now() / 1000) + 3600 }, protectedHeader: { alg: 'RS256' }, - key: {} as any, - } as any) - - const auth = { - api: { - getSession: vi.fn().mockResolvedValue(null), - }, - } - - const result = await resolveRequestAuth( - auth as any, + key: new Uint8Array(), + }) + expect(await resolveRequestAuth( + createDb(null), mockEnv, - new Headers({ Authorization: 'Bearer eyJhbGciOiJSUzI1NiJ9.nosub.sig' }), - ) - - expect(result).toBeNull() + new Headers({ Authorization: 'Bearer subjectless' }), + )).toBeNull() }) }) diff --git a/server/apps/api/src/main.ts b/server/apps/api/src/main.ts new file mode 100644 index 000000000..b07aa1804 --- /dev/null +++ b/server/apps/api/src/main.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +import process from 'node:process' + +import { errorMessageFrom } from '@moeru/std' + +import { runApiServer } from './server' + +/** + * Starts only the AIRI resource API dependency graph. + * + * Call stack: + * + * main + * -> {@link runApiServer} + * -> createApp + * -> buildApp + */ +async function main(): Promise { + await runApiServer() +} + +void main().catch((error: unknown) => { + process.stderr.write(`${errorMessageFrom(error) ?? 'Unknown API server error'}\n`) + process.exit(1) +}) diff --git a/server/apps/api/src/middlewares/auth.ts b/server/apps/api/src/middlewares/auth.ts index 7caee0711..416353350 100644 --- a/server/apps/api/src/middlewares/auth.ts +++ b/server/apps/api/src/middlewares/auth.ts @@ -1,42 +1,30 @@ import type { MiddlewareHandler } from 'hono' -import type { createAuth } from '../libs/auth' +import type { Database } from '../libs/db' import type { Env } from '../libs/env' import type { HonoEnv } from '../types/hono' import { resolveRequestAuth } from '../libs/request-auth' import { createUnauthorizedError } from '../utils/error' -type AuthInstance = ReturnType - /** * Session middleware injects the user and session into the Hono context. * It does not block unauthorized requests. */ -export function sessionMiddleware(auth: AuthInstance, env: Env): MiddlewareHandler { +export function sessionMiddleware(db: Database, env: Env): MiddlewareHandler { return async (c, next) => { - // NOTICE: auth routes handle session lookup inside better-auth itself, - // and `/auth/*` only redirects to the standalone ui-server-auth deployment. - // Running the global session middleware on `/api/auth/*`, `/auth/*`, - // and the auth discovery endpoints duplicates the same session - // read and slows the OIDC login path (`authorize` → `token` → - // `get-session`) noticeably. - // - // `/auth/` and `/api/auth/` are distinct prefixes — `/api/auth/...` - // starts with `/api` and won't be matched by the `/auth/` startsWith. + // Admin UI routes are public redirects. Authorization is enforced by the + // API endpoints they call, so avoid unnecessary token work here. if ( - c.req.path.startsWith('/auth/') - || c.req.path.startsWith('/admin/') + c.req.path.startsWith('/admin/') || c.req.path === '/admin' - || c.req.path.startsWith('/api/auth/') - || c.req.path === '/.well-known/oauth-authorization-server/api/auth' ) { c.set('user', null) c.set('session', null) return await next() } - const session = await resolveRequestAuth(auth, env, c.req.raw.headers) + const session = await resolveRequestAuth(db, env, c.req.raw.headers) if (!session) { c.set('user', null) diff --git a/server/apps/api/src/routes/admin/index.ts b/server/apps/api/src/routes/admin/index.ts index 1a3bfa015..796c7d8a2 100644 --- a/server/apps/api/src/routes/admin/index.ts +++ b/server/apps/api/src/routes/admin/index.ts @@ -6,13 +6,13 @@ import type { ConfigKVService } from '../../services/adapters/config-kv' import type { BillingService } from '../../services/domain/billing/billing-service' import type { HonoEnv } from '../../types/hono' +import { session as sessionTable, user as userTable } from '@proj-airi/auth-shared' import { and, asc, count, countDistinct, desc, eq, gt, ilike, isNull, or, sql } from 'drizzle-orm' import { Hono } from 'hono' import { integer, maxLength, maxValue, minValue, nonEmpty, number, object, optional, pipe, safeParse, string } from 'valibot' import { adminGuard } from '../../middlewares/admin-guard' import { authGuard } from '../../middlewares/auth' -import { session as sessionTable, user as userTable } from '../../schemas/accounts' import { userFlux } from '../../schemas/flux' import { fluxTransaction } from '../../schemas/flux-transaction' import { llmRequestLog } from '../../schemas/llm-request-log' diff --git a/server/apps/api/src/routes/audio-transcription-stream/route.ts b/server/apps/api/src/routes/audio-transcription-stream/route.ts index d4ea55953..0bbd248e9 100644 --- a/server/apps/api/src/routes/audio-transcription-stream/route.ts +++ b/server/apps/api/src/routes/audio-transcription-stream/route.ts @@ -1,6 +1,6 @@ import type { Context } from 'hono' -import type { AuthInstance } from '../../libs/auth' +import type { Database } from '../../libs/db' import type { Env } from '../../libs/env' import type { ConfigKVService } from '../../services/adapters/config-kv' import type { RouterConfig } from '../../services/domain/llm-router/types' @@ -117,7 +117,7 @@ export async function resolveOfficialAliyunNlsCredentialsFromConfig(input: { * - An SSE response that mirrors `@xsai/stream-transcription` delta events. */ export function createAudioTranscriptionStreamHandler(input: { - auth: AuthInstance + db: Database env: Env configKV: ConfigKVService envelopeCrypto: EnvelopeCrypto @@ -125,7 +125,7 @@ export function createAudioTranscriptionStreamHandler(input: { }) { return async function handleAudioTranscriptionStream(c: Context) { const session = await resolveRequestAuth( - input.auth, + input.db, input.env, c.req.raw.headers, ) diff --git a/server/apps/api/src/routes/auth/email-identifier.ts b/server/apps/api/src/routes/auth/email-identifier.ts deleted file mode 100644 index 3c23b19f4..000000000 --- a/server/apps/api/src/routes/auth/email-identifier.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { Database } from '../../libs/db' - -import { and, eq } from 'drizzle-orm' -import { email, nonEmpty, object, pipe, safeParse, string, transform } from 'valibot' - -import { account, user } from '../../schemas/accounts' -import { createBadRequestError } from '../../utils/error' - -const CheckEmailIdentifierBodySchema = object({ - email: pipe( - string(), - transform(value => value.trim().toLowerCase()), - nonEmpty('email is required'), - email('email must be a valid email address'), - ), -}) - -export interface CheckEmailIdentifierDeps { - /** Database used to inspect user and credential-account rows. */ - db: Database -} - -export interface CheckEmailIdentifierResult { - /** Whether a user row exists for the normalized email. */ - exists: boolean - /** Whether the matching user can sign in with email and password. */ - hasPassword: boolean -} - -/** - * Checks whether an email belongs to an existing credential-capable account. - * - * Use when: - * - The auth UI needs to choose between password sign-in, account creation, - * or social-provider guidance. - * - * Expects: - * - Raw body shape from `/api/auth/check-email`. - * - * Returns: - * - Existence and credential-account flags for the normalized email. - */ -export async function checkEmailIdentifier( - deps: CheckEmailIdentifierDeps, - body: { email?: unknown } | null, -): Promise { - const parsed = safeParse(CheckEmailIdentifierBodySchema, body) - if (!parsed.success) - throw createBadRequestError('Invalid email', 'INVALID_EMAIL') - - const [matched] = await deps.db - .select({ id: user.id }) - .from(user) - .where(eq(user.email, parsed.output.email)) - .limit(1) - - if (!matched) - return { exists: false, hasPassword: false } - - const [credential] = await deps.db - .select({ id: account.id }) - .from(account) - .where(and( - eq(account.userId, matched.id), - eq(account.providerId, 'credential'), - )) - .limit(1) - - return { exists: true, hasPassword: !!credential } -} diff --git a/server/apps/api/src/routes/auth/index.ts b/server/apps/api/src/routes/auth/index.ts deleted file mode 100644 index 0c2c2ea3d..000000000 --- a/server/apps/api/src/routes/auth/index.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { AuthInstance } from '../../libs/auth' -import type { Database } from '../../libs/db' -import type { Env } from '../../libs/env' -import type { RateLimitMetrics } from '../../otel' -import type { ConfigKVService } from '../../services/adapters/config-kv' -import type { HonoEnv } from '../../types/hono' - -import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from '@better-auth/oauth-provider' -import { Hono } from 'hono' - -import { ensureDynamicFirstPartyRedirectUri } from '../../libs/auth' -import { isUserBannedNow, resolveSessionIgnoringBan } from '../../libs/request-auth' -import { rateLimiter } from '../../middlewares/rate-limit' -import { createForbiddenError } from '../../utils/error' -import { checkEmailIdentifier } from './email-identifier' -import { createElectronCallbackRelay } from './oidc/electron-callback' -import { createOIDCTokenAuthRoute } from './oidc/token-auth' -import { createAuthUiRoutes } from './ui-routes' - -export interface AuthRoutesDeps { - auth: AuthInstance - db: Database - env: Env - configKV: ConfigKVService - rateLimitMetrics?: RateLimitMetrics | null -} - -/** - * All auth-related routes: sign-in page, rate-limited better-auth - * helper routes, electron callback relay, catch-all, and - * well-known metadata endpoints. - * - * Mounted at the root level because routes span multiple prefixes - * (`/auth/*`, `/api/auth/*`, `/.well-known/*`). - */ -export async function createAuthRoutes(deps: AuthRoutesDeps) { - async function handleAuthRequest(request: Request): Promise { - const response = await deps.auth.handler(request) - - if (!(response instanceof Response)) - throw new TypeError('Expected auth handler to return a Response') - - return response - } - - return new Hono() - .route('/', createAuthUiRoutes({ env: deps.env })) - /** - * Auth routes are handled by the auth instance directly, - * Powered by better-auth. - * Rate limited by IP: 20 requests per minute. - */ - .use('/api/auth/*', rateLimiter({ - max: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_MAX'), - windowSec: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_WINDOW_SEC'), - // Proxy trust is a deployment boundary, not a property of the public - // API URL. Custom domains and private gateways must opt in explicitly. - trustedProxy: deps.env.RATE_LIMIT_TRUSTED_PROXY, - metrics: deps.rateLimitMetrics, - routeLabel: 'auth.api', - })) - .use('/api/auth/oauth2/authorize', async (c, next) => { - await ensureDynamicFirstPartyRedirectUri(deps.db, c.req.raw, deps.env.ADDITIONAL_TRUSTED_ORIGINS) - await next() - }) - // NOTICE: - // `/api/auth/*` bypasses sessionMiddleware (and thus the ban gate in - // resolveRequestAuth), and oauthProvider's /oauth2/userinfo validates the - // bearer JWT by signature only — so a banned user's still-valid access - // token (<=1h TTL) could otherwise read its own profile claims after a ban. - // This guard re-applies the ban check on that one endpoint. We resolve the - // subject ignoring the ban, then 403 if banned, so an invalid/expired token - // still falls through to better-auth's own 401 rather than being masked. - // (/oauth2/introspect needs confidential client credentials, which no - // first-party AIRI client has, so it has no reachable banned-caller path.) - .use('/api/auth/oauth2/userinfo', async (c, next) => { - const resolved = await resolveSessionIgnoringBan(deps.auth, deps.env, c.req.raw.headers) - if (resolved && isUserBannedNow(resolved.user)) - throw createForbiddenError('This account has been banned') - await next() - }) - .route('/api/auth', createOIDCTokenAuthRoute(deps)) - /** - * Electron OIDC callback relay: serves an HTML page that forwards the - * authorization code to the Electron loopback server via JS fetch(). - * This avoids navigating the browser to http://127.0.0.1:{port}. - */ - .route('/api/auth/oidc/electron-callback', createElectronCallbackRelay(deps.env)) - /** - * OAuth 2.1 Authorization Server metadata must live at the root-level - * well-known path with the issuer path inserted for non-root issuers. - */ - .on('GET', '/.well-known/oauth-authorization-server/api/auth', async (c) => { - return oauthProviderAuthServerMetadata(deps.auth)(c.req.raw) - }) - /** - * OpenID Connect discovery metadata uses path appending for issuers with - * paths, so `/api/auth` serves its own `/.well-known/openid-configuration`. - */ - .on('GET', '/api/auth/.well-known/openid-configuration', async (c) => { - return oauthProviderOpenIdConfigMetadata(deps.auth)(c.req.raw) - }) - /** - * Email-first identifier check. - * - * Powers the unified sign-in/up UI: the user types an email, the UI calls - * this to decide whether to render a password input (existing user with - * a credential account) or the new-account form (or steer them to a - * social provider when only social accounts exist). - * - * Returns: - * - `exists`: a `user` row matches the email (case-insensitive). - * - `hasPassword`: that user has an account row with `providerId='credential'`, - * i.e. can sign in via email + password (vs. social-only). - * - * Account-enumeration tradeoff: this confirms whether an email is - * registered, mirroring the standard set by Google/Linear/Notion. We - * accept the disclosure since the existing rate limiter applied to - * `/api/auth/*` (`AUTH_RATE_LIMIT_MAX` per IP per window) already throttles - * enumeration attempts. - */ - .on('POST', '/api/auth/check-email', async (c) => { - const body = await c.req.json().catch(() => null) as { email?: unknown } | null - return c.json(await checkEmailIdentifier({ db: deps.db }, body)) - }) - .on(['POST', 'GET'], '/api/auth/*', async (c) => { - return handleAuthRequest(c.req.raw) - }) -} diff --git a/server/apps/api/src/routes/auth/oidc/electron-callback.ts b/server/apps/api/src/routes/auth/oidc/electron-callback.ts deleted file mode 100644 index 1efc0a17a..000000000 --- a/server/apps/api/src/routes/auth/oidc/electron-callback.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Env } from '../../../libs/env' -import type { HonoEnv } from '../../../types/hono' - -import { Hono } from 'hono' - -import { buildAuthUiUrl } from '../../../utils/auth-ui' - -/** - * Redirects the Electron OIDC callback to the standalone auth UI relay page. - * - * Use when: - * - The API origin remains the registered Electron redirect URI, but the relay - * UI bundle is deployed separately from the server image. - * - * Expects: - * The loopback port is encoded in the `state` parameter as a prefix: - * `{port}:{originalState}`. The relay page extracts the port, reconstructs - * the original state, and forwards both `code` and `state` to the loopback. - * - * Returns: - * - A redirect preserving the OIDC callback query string. - */ -export function createElectronCallbackRelay(env: Env) { - return new Hono() - .get('/', (c) => { - const request = new URL(c.req.url) - return c.redirect(buildAuthUiUrl(env.AUTH_UI_URL, '/api/auth/oidc/electron-callback', request.search)) - }) -} diff --git a/server/apps/api/src/routes/auth/oidc/token-auth.ts b/server/apps/api/src/routes/auth/oidc/token-auth.ts deleted file mode 100644 index 94e2a88bc..000000000 --- a/server/apps/api/src/routes/auth/oidc/token-auth.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { AuthInstance } from '../../../libs/auth' -import type { Env } from '../../../libs/env' -import type { HonoEnv } from '../../../types/hono' - -import { Hono } from 'hono' - -import { buildGravatarUrl } from '../../../libs/gravatar' -import { resolveRequestAuth } from '../../../libs/request-auth' - -export interface OIDCTokenAuthRouteDeps { - auth: AuthInstance - env: Env -} - -export function createOIDCTokenAuthRoute(deps: OIDCTokenAuthRouteDeps) { - return new Hono() - .on(['GET', 'POST'], '/get-session', async (c) => { - const session = await resolveRequestAuth(deps.auth, deps.env, c.req.raw.headers) - if (!session) - return c.json(null) - - // NOTICE: - // Avatar fallback to Gravatar happens here so every client (web, - // Electron, mobile, future SSR) renders the same picture without - // re-implementing SHA-256 hashing or Gravatar URL conventions. The - // DB only stores user-set / provider-set images; the fallback is - // computed at response time so a future swap (DiceBear, self-hosted - // proxy) is a one-line change here. - // - // We intentionally do NOT carry an `imageSource` flag — the URL - // itself is the signal: anything starting with - // `https://www.gravatar.com/avatar/` is the fallback, anything else - // is manual / provider-set. Skipping the flag keeps the API surface - // small and the server free of redundant state. If we ever change - // the fallback provider, both this file and the client-side prefix - // check must be updated together. - // Removal condition: avatar storage moves off-band (e.g. CDN) and - // `user.image` becomes the canonical URL for every user. - const image = session.user.image || buildGravatarUrl(session.user.email) - - return c.json({ ...session, user: { ...session.user, image } }) - }) - .post('/sign-out', async (c) => { - // NOTICE: JWT access tokens are self-contained and expire naturally. - // Refresh token revocation is handled by oauthProvider's /oauth2/token endpoint. - // This endpoint exists for client compatibility — it acknowledges the signout intent. - return c.json({ success: true }) - }) - .get('/list-sessions', async (c) => { - const session = await resolveRequestAuth(deps.auth, deps.env, c.req.raw.headers) - return c.json(session ? [session.session] : []) - }) -} diff --git a/server/apps/api/src/routes/auth/ui-routes.ts b/server/apps/api/src/routes/auth/ui-routes.ts deleted file mode 100644 index eedd6d68e..000000000 --- a/server/apps/api/src/routes/auth/ui-routes.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Env } from '../../libs/env' -import type { HonoEnv } from '../../types/hono' - -import { Hono } from 'hono' - -import { buildAuthUiRedirectUrl, SERVER_AUTH_UI_BASE_PATH } from '../../utils/auth-ui' - -export interface AuthUiRoutesDeps { - /** Server environment carrying the standalone auth UI URL. */ - env: Env -} - -/** - * Creates routes that redirect historical server auth UI URLs to the - * standalone auth UI deployment. - * - * Use when: - * - Mounting auth pages before `/api/auth/*` catch-all routes. - * - * Expects: - * - `env.AUTH_UI_URL` points to the public standalone auth UI base. - * - * Returns: - * - Root-mounted redirects for `/auth/*`. - */ -export function createAuthUiRoutes(deps: AuthUiRoutesDeps) { - return new Hono() - .get(SERVER_AUTH_UI_BASE_PATH, c => c.redirect(buildAuthUiRedirectUrl(deps.env.AUTH_UI_URL, c.req.url, deps.env.API_SERVER_URL))) - .get(`${SERVER_AUTH_UI_BASE_PATH}/*`, c => c.redirect(buildAuthUiRedirectUrl(deps.env.AUTH_UI_URL, c.req.url, deps.env.API_SERVER_URL))) -} diff --git a/server/apps/api/src/routes/internal-auth.test.ts b/server/apps/api/src/routes/internal-auth.test.ts new file mode 100644 index 000000000..4a3bae4dd --- /dev/null +++ b/server/apps/api/src/routes/internal-auth.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createInternalAuthRoutes } from './internal-auth' + +describe('internal auth routes', () => { + it('rejects an invalid deletion contract before calling business services', async () => { + const userDeletionService = { register: vi.fn(), softDeleteAll: vi.fn() } + const productEventService = { track: vi.fn() } + const app = createInternalAuthRoutes({ userDeletionService, productEventService }) + + const response = await app.request('/user-deletion', { method: 'POST', body: '{}' }) + + expect(response.status).toBe(400) + expect(userDeletionService.softDeleteAll).not.toHaveBeenCalled() + }) + + it('delegates private cleanup to the API-owned deletion workflow', async () => { + const userDeletionService = { register: vi.fn(), softDeleteAll: vi.fn(async () => undefined) } + const productEventService = { track: vi.fn() } + const app = createInternalAuthRoutes({ userDeletionService, productEventService }) + + const response = await app.request('/user-deletion', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ userId: 'user-1', reason: 'user-requested' }), + }) + + expect(response.status).toBe(200) + expect(userDeletionService.softDeleteAll).toHaveBeenCalledWith({ + userId: 'user-1', + reason: 'user-requested', + }) + }) + + it('records private auth lifecycle events in the API-owned event service', async () => { + const userDeletionService = { register: vi.fn(), softDeleteAll: vi.fn() } + const productEventService = { track: vi.fn(async () => undefined) } + const app = createInternalAuthRoutes({ + userDeletionService, + productEventService, + }) + + const response = await app.request('/events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userId: 'user-1', + action: 'user_signed_up', + source: 'better-auth.user.create', + }), + }) + + expect(response.status).toBe(200) + expect(productEventService.track).toHaveBeenCalledWith({ + userId: 'user-1', + feature: 'auth', + action: 'user_signed_up', + status: 'succeeded', + source: 'better-auth.user.create', + }) + }) +}) diff --git a/server/apps/api/src/routes/internal-auth.ts b/server/apps/api/src/routes/internal-auth.ts new file mode 100644 index 000000000..401568405 --- /dev/null +++ b/server/apps/api/src/routes/internal-auth.ts @@ -0,0 +1,55 @@ +import type { ProductEventService } from '../services/domain/product-events' +import type { UserDeletionExecutor, UserDeletionReason } from '../services/domain/user-deletion' +import type { HonoEnv } from '../types/hono' + +import { Hono } from 'hono' +import { nonEmpty, object, picklist, pipe, safeParse, string, trim } from 'valibot' + +const UserDeletionRequestSchema = object({ + userId: pipe(string(), trim(), nonEmpty()), + reason: picklist(['user-requested', 'admin', 'compliance']), +}) + +const AuthEventRequestSchema = object({ + userId: pipe(string(), trim(), nonEmpty()), + action: picklist(['user_signed_up', 'session_started']), + source: picklist(['better-auth.user.create', 'better-auth.session.create']), +}) + +/** + * Internal boundary called by the Auth service before credentials are + * hard-deleted. Deployment must keep this route on the private service network + * and block `/internal/*` at the public edge. + */ +export function createInternalAuthRoutes(input: { + userDeletionService: UserDeletionExecutor + productEventService: Pick +}) { + return new Hono() + .post('/user-deletion', async (c) => { + const parsed = safeParse(UserDeletionRequestSchema, await c.req.json().catch(() => null)) + if (!parsed.success) + return c.json({ error: 'BAD_REQUEST', message: 'Invalid user deletion request' }, 400) + + const request = parsed.output + await input.userDeletionService.softDeleteAll({ + userId: request.userId, + reason: request.reason as UserDeletionReason, + }) + return c.json({ success: true }) + }) + .post('/events', async (c) => { + const parsed = safeParse(AuthEventRequestSchema, await c.req.json().catch(() => null)) + if (!parsed.success) + return c.json({ error: 'BAD_REQUEST', message: 'Invalid auth event' }, 400) + + await input.productEventService.track({ + userId: parsed.output.userId, + feature: 'auth', + action: parsed.output.action, + status: 'succeeded', + source: parsed.output.source, + }) + return c.json({ success: true }) + }) +} diff --git a/server/apps/api/src/schemas/characters.ts b/server/apps/api/src/schemas/characters.ts index 92e47fc4d..11f6ebeb9 100644 --- a/server/apps/api/src/schemas/characters.ts +++ b/server/apps/api/src/schemas/characters.ts @@ -3,11 +3,11 @@ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' import type { AvatarModelConfig } from '../types/character-avatar-model' import type { CharacterCapabilityConfig } from '../types/character-capability' +import { user } from '@proj-airi/auth-shared' import { relations } from 'drizzle-orm' import { integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core' import { nanoid } from '../utils/id' -import { user } from './accounts' import { characterBookmarks, characterLikes } from './user-character' export const character = pgTable( diff --git a/server/apps/api/src/schemas/index.ts b/server/apps/api/src/schemas/index.ts index ad4136b6e..71419e4a3 100644 --- a/server/apps/api/src/schemas/index.ts +++ b/server/apps/api/src/schemas/index.ts @@ -1,4 +1,3 @@ -export * from './accounts' export * from './characters' export * from './chats' export * from './flux' @@ -10,3 +9,4 @@ export * from './providers' export * from './stripe' export * from './user-character' export * from './voice-packs' +export * from '@proj-airi/auth-shared' diff --git a/server/apps/api/src/schemas/providers.ts b/server/apps/api/src/schemas/providers.ts index 0b8d41333..e40022a3b 100644 --- a/server/apps/api/src/schemas/providers.ts +++ b/server/apps/api/src/schemas/providers.ts @@ -1,10 +1,10 @@ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' +import { user } from '@proj-airi/auth-shared' import { relations } from 'drizzle-orm' import { boolean, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core' import { nanoid } from '../utils/id' -import { user } from './accounts' // NOTICE: bare ownerId is intentional — no FK to user.id. better-auth hard-deletes // the user row; a cascade would wipe these soft-delete archive rows. diff --git a/server/apps/api/src/schemas/stripe.ts b/server/apps/api/src/schemas/stripe.ts index 273ad0973..b3975cd7f 100644 --- a/server/apps/api/src/schemas/stripe.ts +++ b/server/apps/api/src/schemas/stripe.ts @@ -1,10 +1,10 @@ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' +import { user } from '@proj-airi/auth-shared' import { relations } from 'drizzle-orm' import { boolean, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' import { nanoid } from '../utils/id' -import { user } from './accounts' // NOTICE: bare userId is intentional — no FK to user.id. better-auth hard-deletes // the user row; a cascade would wipe these soft-delete archive rows kept for diff --git a/server/apps/api/src/schemas/user-character.ts b/server/apps/api/src/schemas/user-character.ts index dd98347ac..83f74dd7d 100644 --- a/server/apps/api/src/schemas/user-character.ts +++ b/server/apps/api/src/schemas/user-character.ts @@ -1,9 +1,9 @@ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' +import { user } from '@proj-airi/auth-shared' import { pgTable, primaryKey, text, timestamp } from 'drizzle-orm/pg-core' import { relations } from 'drizzle-orm/relations' -import { user } from './accounts' import { character } from './characters' // NOTICE: bare userId is intentional — no FK to user.id. better-auth hard-deletes diff --git a/server/apps/api/src/scripts/otel/smoke.ts b/server/apps/api/src/scripts/otel/smoke.ts index c26d43c86..77e8fb915 100644 --- a/server/apps/api/src/scripts/otel/smoke.ts +++ b/server/apps/api/src/scripts/otel/smoke.ts @@ -34,11 +34,6 @@ metrics.setGlobalMeterProvider(provider) // flush via the in-memory reader. env.DATABASE_URL ??= 'postgres://test' env.REDIS_URL ??= 'redis://test' -env.BETTER_AUTH_SECRET ??= 'test' -env.AUTH_GOOGLE_CLIENT_ID ??= 'test' -env.AUTH_GOOGLE_CLIENT_SECRET ??= 'test' -env.AUTH_GITHUB_CLIENT_ID ??= 'test' -env.AUTH_GITHUB_CLIENT_SECRET ??= 'test' // 32 deterministic bytes is enough to satisfy env validation; the smoke // script never actually hits the router. env.LLM_ROUTER_MASTER_KEY ??= Buffer.alloc(32, 0xAA).toString('base64') diff --git a/server/apps/api/src/server.ts b/server/apps/api/src/server.ts new file mode 100644 index 000000000..92e6ca46b --- /dev/null +++ b/server/apps/api/src/server.ts @@ -0,0 +1,34 @@ +import process from 'node:process' + +import { useLogger } from '@guiiai/logg' +import { serve } from '@hono/node-server' + +import { createApp } from './app' + +function handleProcessError(error: unknown, type: string) { + useLogger().withError(error).error(type) +} + +/** + * Starts the dedicated resource API HTTP/WebSocket process. + * + * Call stack: + * + * runApiServer + * -> {@link createApp} + * -> buildApp + * -> business HTTP and WebSocket routes + */ +export async function runApiServer(): Promise { + const { app, injectWebSocket, port, hostname } = await createApp() + const server = serve({ fetch: app.fetch, port, hostname }) + injectWebSocket(server) + + process.on('uncaughtException', error => handleProcessError(error, 'Uncaught exception')) + process.on('unhandledRejection', error => handleProcessError(error, 'Unhandled rejection')) + + await new Promise((resolve, reject) => { + server.once('close', () => resolve()) + server.once('error', error => reject(error)) + }) +} diff --git a/server/apps/api/src/services/domain/admin/flux-grants/index.ts b/server/apps/api/src/services/domain/admin/flux-grants/index.ts index 4b4730bea..f4d629ce5 100644 --- a/server/apps/api/src/services/domain/admin/flux-grants/index.ts +++ b/server/apps/api/src/services/domain/admin/flux-grants/index.ts @@ -5,7 +5,8 @@ import { useLogger } from '@guiiai/logg' import { errorMessageFrom } from '@moeru/std' import { inArray } from 'drizzle-orm' -import * as accountsSchema from '../../../../schemas/accounts' +import * as accountsSchema from '@proj-airi/auth-shared' + import * as fluxSchema from '../../../../schemas/flux' const logger = useLogger('admin-flux-grants').useGlobalConfig() diff --git a/server/apps/api/src/services/domain/user-deletion/index.ts b/server/apps/api/src/services/domain/user-deletion/index.ts index 4a367092e..56fcbde10 100644 --- a/server/apps/api/src/services/domain/user-deletion/index.ts +++ b/server/apps/api/src/services/domain/user-deletion/index.ts @@ -2,7 +2,7 @@ import type { UserDeletionHandler, UserDeletionReason, UserDeletionService } fro import { useLogger } from '@guiiai/logg' -export type { UserDeletionContext, UserDeletionHandler, UserDeletionReason, UserDeletionService } from './types' +export type { UserDeletionContext, UserDeletionExecutor, UserDeletionHandler, UserDeletionReason, UserDeletionService } from './types' /** * Build an empty deletion-service registry. @@ -19,9 +19,11 @@ export type { UserDeletionContext, UserDeletionHandler, UserDeletionReason, User * Call stack: * * better-auth `/delete-user/callback` - * -> `user.deleteUser.beforeDelete` (libs/auth.ts) - * -> {@link UserDeletionService.softDeleteAll} - * -> handler.softDelete (per registered module) + * -> `user.deleteUser.beforeDelete` (`server/apps/auth/src/auth.ts`) + * -> Auth `RemoteUserDeletionService` + * -> `POST /internal/auth/user-deletion` + * -> {@link UserDeletionService.softDeleteAll} + * -> handler.softDelete (per registered module) * * Failure model: a thrown error from any handler aborts before * `internalAdapter.deleteUser`, leaving the user row intact. The next retry diff --git a/server/apps/api/src/services/domain/user-deletion/types.ts b/server/apps/api/src/services/domain/user-deletion/types.ts index 080c30b30..9b64f96df 100644 --- a/server/apps/api/src/services/domain/user-deletion/types.ts +++ b/server/apps/api/src/services/domain/user-deletion/types.ts @@ -68,22 +68,26 @@ export interface UserDeletionHandler { * Coordinator for account deletion across business modules. * * Use when: - * - Wiring better-auth's `user.deleteUser.beforeDelete` hook in `libs/auth.ts`. + * - Serving the authenticated internal request emitted by Auth server's + * `user.deleteUser.beforeDelete` hook. * - Implementing an admin-triggered deletion path (future). * * Expects: * - All handlers are registered at app-composition time before the first * request hits `beforeDelete`. Late registration is allowed but discouraged. */ -export interface UserDeletionService { - /** - * Register a handler. Throws if `handler.name` is already registered — - * names must be unique so logs and metrics can attribute work cleanly. - */ - register: (handler: UserDeletionHandler) => void +export interface UserDeletionExecutor { /** * Run every registered handler in priority order. Returns when all * handlers complete, or throws the first handler error and stops. */ softDeleteAll: (input: { userId: string, reason: UserDeletionReason }) => Promise } + +export interface UserDeletionService extends UserDeletionExecutor { + /** + * Register a handler. Throws if `handler.name` is already registered — + * names must be unique so logs and metrics can attribute work cleanly. + */ + register: (handler: UserDeletionHandler) => void +} diff --git a/server/apps/api/src/services/domain/users/resolve-user.ts b/server/apps/api/src/services/domain/users/resolve-user.ts index 701747896..ac5911efc 100644 --- a/server/apps/api/src/services/domain/users/resolve-user.ts +++ b/server/apps/api/src/services/domain/users/resolve-user.ts @@ -2,9 +2,9 @@ import type { Database } from '../../../libs/db' import { eq } from 'drizzle-orm' -import { createNotFoundError } from '../../../utils/error' +import * as accountsSchema from '@proj-airi/auth-shared' -import * as accountsSchema from '../../../schemas/accounts' +import { createNotFoundError } from '../../../utils/error' export interface UserSelector { /** Select by user id. Exactly one of `userId` / `email` must be set. */ diff --git a/server/apps/api/src/types/hono.ts b/server/apps/api/src/types/hono.ts index cc20e9dc8..6d02e7370 100644 --- a/server/apps/api/src/types/hono.ts +++ b/server/apps/api/src/types/hono.ts @@ -1,8 +1,8 @@ -import type auth from '../scripts/auth' +import type { RequestAuthSession } from '../libs/request-auth' export interface HonoEnv { Variables: { - user: typeof auth.$Infer.Session.user | null - session: typeof auth.$Infer.Session.session | null + user: RequestAuthSession['user'] | null + session: RequestAuthSession['session'] | null } } diff --git a/server/apps/api/src/utils/auth-ui.ts b/server/apps/api/src/utils/auth-ui.ts deleted file mode 100644 index 344e07296..000000000 --- a/server/apps/api/src/utils/auth-ui.ts +++ /dev/null @@ -1,97 +0,0 @@ -export const SERVER_AUTH_UI_BASE_PATH = '/auth' -export const AUTH_UI_API_SERVER_URL_QUERY_PARAM = 'api_server_url' -export const DEFAULT_AUTH_UI_URL = 'https://accounts.airi.build/ui' -export const SERVER_DEV_API_SERVER_URL = 'https://airi-server-dev.up.railway.app' -export const SERVER_DEV_AUTH_UI_URL = 'https://server-dev.airi-server-auth.pages.dev/ui' - -/** - * Builds an absolute URL inside the externally hosted auth UI. - * - * Use when: - * - Redirecting server-owned auth UI entrypoints to the standalone - * `apps/ui-server-auth` deployment. - * - Preserving query parameters from OIDC, verification, or reset flows. - * - * Expects: - * - `authUiUrl` is the public auth UI base, usually ending in `/ui`. - * - `path` is the route path within the auth UI router. - * - * Returns: - * - An absolute URL with the auth UI base path, normalized path, and search. - */ -export function buildAuthUiUrl(authUiUrl: string, path: string, search = ''): string { - const target = new URL(authUiUrl) - const basePath = target.pathname.replace(/\/+$/, '') - const routePath = path.startsWith('/') ? path : `/${path}` - - target.pathname = `${basePath}${routePath}` - target.search = search - target.hash = '' - - return target.toString() -} - -/** - * Resolves the standalone auth UI base for the active server environment. - * - * Use when: - * - The server redirects historical `/auth/*` entrypoints to the standalone UI. - * - The server-dev Railway deployment needs the matching Cloudflare Pages - * branch without changing the production auth domain. - * - * Expects: - * - `authUiUrl` is the configured auth UI base URL. - * - `apiServerUrl` is the configured API server URL. - * - * Returns: - * - The configured auth UI URL, except for the server-dev default pairing where - * the matching Pages branch URL is returned. - */ -export function resolveAuthUiUrl(authUiUrl: string, apiServerUrl: string): string { - try { - const authUi = new URL(authUiUrl) - const defaultAuthUi = new URL(DEFAULT_AUTH_UI_URL) - const apiServer = new URL(apiServerUrl) - const authUiBase = `${authUi.origin}${authUi.pathname.replace(/\/+$/, '')}` - const defaultAuthUiBase = `${defaultAuthUi.origin}${defaultAuthUi.pathname.replace(/\/+$/, '')}` - - if (authUiBase === defaultAuthUiBase && apiServer.origin === SERVER_DEV_API_SERVER_URL) { - return SERVER_DEV_AUTH_UI_URL - } - } - catch { - return authUiUrl - } - - return authUiUrl -} - -/** - * Maps a server `/auth/*` request to the standalone auth UI. - * - * Use when: - * - The server keeps owning the historical `/auth/*` entrypoint but no longer - * packages the auth UI bundle. - * - * Expects: - * - `requestUrl` is the incoming server URL. - * - `authUiUrl` points to the standalone auth UI base path. - * - * Returns: - * - The external auth UI URL preserving route suffix and query string. - */ -export function buildAuthUiRedirectUrl(authUiUrl: string, requestUrl: string, apiServerUrl?: string): string { - const request = new URL(requestUrl) - const suffix = request.pathname === SERVER_AUTH_UI_BASE_PATH - ? '/' - : request.pathname.slice(SERVER_AUTH_UI_BASE_PATH.length) || '/' - const resolvedAuthUiUrl = apiServerUrl ? resolveAuthUiUrl(authUiUrl, apiServerUrl) : authUiUrl - - const target = new URL(buildAuthUiUrl(resolvedAuthUiUrl, suffix, request.search)) - if (apiServerUrl) { - const apiServer = new URL(apiServerUrl) - target.searchParams.set(AUTH_UI_API_SERVER_URL_QUERY_PARAM, apiServer.origin) - } - - return target.toString() -} diff --git a/server/apps/api/src/utils/origin.ts b/server/apps/api/src/utils/origin.ts index ed0d0933f..070c34ac0 100644 --- a/server/apps/api/src/utils/origin.ts +++ b/server/apps/api/src/utils/origin.ts @@ -1,5 +1,3 @@ -import type { Env } from '../libs/env' - function getOriginFromUrl(url: string): string | undefined { try { return new URL(url).origin @@ -19,15 +17,6 @@ const TRUSTED_EXACT_ORIGINS = [ 'https://server-dev.airi-server-admin.pages.dev', // Server-dev standalone admin UI ] -// NOTICE: -// Better Auth accepts non-http(s) origins by prefix (`url.startsWith(pattern)`), -// so native deep-link schemes must not be copied from TRUSTED_EXACT_ORIGINS -// into auth callback validation. Browser auth callbacks only need web origins. -const TRUSTED_AUTH_CALLBACK_ORIGINS = TRUSTED_EXACT_ORIGINS.filter((origin) => { - const protocol = new URL(origin).protocol - return protocol === 'http:' || protocol === 'https:' -}) - // NOTICE: // Private LAN / CGNAT-style dev hosts (e.g. https://10.x:5273 from cap-vite) are NOT matched // by regex here — list them explicitly via env `ADDITIONAL_TRUSTED_ORIGINS` (see env.ts). @@ -51,7 +40,7 @@ const TRUSTED_ORIGIN_PATTERNS = [ * * Expects: * - `origin` is the raw `Origin` header value or `new URL(referer).origin`. - * - `additionalTrustedOrigins` entries are normalized origins (see {@link parseAdditionalTrustedOriginsEnv}). + * - `additionalTrustedOrigins` entries are normalized by the environment schema. * * Returns: * - The same origin string when trusted, or `''` when not trusted. @@ -120,63 +109,3 @@ export function resolveCheckoutRedirectBase( ): string { return resolveTrustedRequestOrigin(request, additionalTrustedOrigins) ?? webAppFallbackUrl } - -// NOTICE: -// Better Auth's callbackURL validation walks `trustedOrigins`. Static entries -// support `*` wildcards via the framework's wildcardMatch (see -// node_modules/better-auth/dist/auth/trusted-origins.mjs). Loopback origins -// across any port are allowed so dev (Vite at :5173/:5174/:4173, electron -// loopback OAuth at :random_port) and prod (where these addresses are -// unreachable) share the same config. The pattern is intentionally broad — -// loopback is unreachable from the public internet, so any origin that -// resolves to localhost is by definition the same machine the user is on. -// -// Removal condition: when dev serves UI from the same origin as the API -// (e.g. via vite proxy or static mount), drop these entries. -const ALWAYS_TRUSTED_AUTH_ORIGINS = [ - 'http://localhost:*', - 'http://127.0.0.1:*', -] - -/** - * Builds the origin list passed to Better Auth `trustedOrigins` (and related flows). - * - * Expects: - * - `env.API_SERVER_URL` and parsed `env.ADDITIONAL_TRUSTED_ORIGINS`. - * - Optional `request` so the caller's Origin/Referer can be merged when known. - * - * Returns: - * - De-duplicated origins in insertion order (API URL, env extras, localhost wildcards, then request-derived). - */ -export function getAuthTrustedOrigins( - env: Pick, - request?: Request, -): string[] { - const origins = new Set() - const apiServerOrigin = getOriginFromUrl(env.API_SERVER_URL) - if (apiServerOrigin) { - origins.add(apiServerOrigin) - } - - for (const origin of TRUSTED_AUTH_CALLBACK_ORIGINS) { - origins.add(origin) - } - origins.add('https://appleid.apple.com') - - for (const origin of env.ADDITIONAL_TRUSTED_ORIGINS) { - origins.add(origin) - } - - for (const origin of ALWAYS_TRUSTED_AUTH_ORIGINS) { - origins.add(origin) - } - - if (request) { - const requestOrigin = resolveTrustedRequestOrigin(request, env.ADDITIONAL_TRUSTED_ORIGINS) - if (requestOrigin) { - origins.add(requestOrigin) - } - } - - return [...origins] -} diff --git a/server/apps/api/src/utils/tests/origin.test.ts b/server/apps/api/src/utils/tests/origin.test.ts index c1fb432a9..4cae619c1 100644 --- a/server/apps/api/src/utils/tests/origin.test.ts +++ b/server/apps/api/src/utils/tests/origin.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { getAuthTrustedOrigins, getTrustedOrigin, resolveCheckoutRedirectBase, resolveTrustedRequestOrigin } from '../origin' +import { getTrustedOrigin, resolveCheckoutRedirectBase, resolveTrustedRequestOrigin } from '../origin' describe('origin utils', () => { it('allows localhost origins', () => { @@ -55,30 +55,6 @@ describe('origin utils', () => { expect(resolveTrustedRequestOrigin(request)).toBe('http://localhost:5173') }) - it('collects api and request origins for auth', () => { - const request = new Request('http://localhost/api/auth/sign-in/social', { - headers: { - origin: 'http://localhost:5173', - }, - }) - - expect(getAuthTrustedOrigins({ - API_SERVER_URL: 'https://api.airi.moeru.ai', - ADDITIONAL_TRUSTED_ORIGINS: [], - }, request)).toEqual([ - 'https://api.airi.moeru.ai', - 'https://airi.moeru.ai', - 'https://accounts.airi.build', - 'https://server-dev.airi-server-auth.pages.dev', - 'https://admin.airi.build', - 'https://server-dev.airi-server-admin.pages.dev', - 'https://appleid.apple.com', - 'http://localhost:*', - 'http://127.0.0.1:*', - 'http://localhost:5173', - ]) - }) - describe('resolveCheckoutRedirectBase', () => { const fallback = 'https://airi.moeru.ai' @@ -121,55 +97,4 @@ describe('origin utils', () => { expect(resolveCheckoutRedirectBase(request, [], fallback)).toBe(fallback) }) }) - - it('includes ADDITIONAL_TRUSTED_ORIGINS in Better Auth trustedOrigins list', () => { - expect(getAuthTrustedOrigins({ - API_SERVER_URL: 'https://api.airi.moeru.ai', - ADDITIONAL_TRUSTED_ORIGINS: ['https://10.0.0.129:5273'], - })).toEqual([ - 'https://api.airi.moeru.ai', - 'https://airi.moeru.ai', - 'https://accounts.airi.build', - 'https://server-dev.airi-server-auth.pages.dev', - 'https://admin.airi.build', - 'https://server-dev.airi-server-admin.pages.dev', - 'https://appleid.apple.com', - 'https://10.0.0.129:5273', - 'http://localhost:*', - 'http://127.0.0.1:*', - ]) - }) - - it('does not include native deep-link schemes in Better Auth trustedOrigins', () => { - expect(getTrustedOrigin('capacitor://localhost')).toBe('capacitor://localhost') - expect(getTrustedOrigin('ai.moeru.airi-pocket://links')).toBe('ai.moeru.airi-pocket://links') - - const authOrigins = getAuthTrustedOrigins({ - API_SERVER_URL: 'https://api.airi.build', - ADDITIONAL_TRUSTED_ORIGINS: [], - }) - - expect(authOrigins).not.toContain('capacitor://localhost') - expect(authOrigins).not.toContain('ai.moeru.airi-pocket://links') - }) - - // ROOT CAUSE: - // - // Email verification links carry a callbackURL query parameter that Better - // Auth validates only against its trustedOrigins list. The standalone auth - // UI sends callbackURL=https://accounts.airi.build/ui/verify-email?verified=true, - // but getAuthTrustedOrigins previously listed only API_SERVER_URL, - // additional env origins, and localhost wildcards. Clicking the email from - // a normal inbox has no usable Origin/Referer header, so request-derived - // trust could not add the auth UI origin and Better Auth returned - // INVALID_CALLBACK_URL. - // - // Before patch: auth UI callback -> not in trustedOrigins -> 403. - // After patch: built-in first-party exact origins are always present. - it('includes built-in first-party origins for email verification callbacks without request headers', () => { - expect(getAuthTrustedOrigins({ - API_SERVER_URL: 'https://api.airi.build', - ADDITIONAL_TRUSTED_ORIGINS: [], - })).toContain('https://accounts.airi.build') - }) }) diff --git a/server/apps/auth/Dockerfile b/server/apps/auth/Dockerfile new file mode 100644 index 000000000..c6a9e5e2b --- /dev/null +++ b/server/apps/auth/Dockerfile @@ -0,0 +1,22 @@ +FROM node:24-alpine + +WORKDIR /app + +RUN addgroup -S airi && adduser -S airi -G airi + +RUN corepack enable + +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./ +COPY patches/ ./patches/ +COPY server/apps/auth server/apps/auth +COPY server/packages/auth-shared server/packages/auth-shared + +RUN pnpm install --frozen-lockfile --ignore-scripts --filter @proj-airi/auth-server... + +RUN pnpm -F @proj-airi/auth-server run build + +EXPOSE 3000 + +USER airi + +CMD ["pnpm", "-F", "@proj-airi/auth-server", "start"] diff --git a/server/apps/auth/README.md b/server/apps/auth/README.md new file mode 100644 index 000000000..c60744222 --- /dev/null +++ b/server/apps/auth/README.md @@ -0,0 +1,51 @@ +# AIRI Auth Server + +Standalone authentication and identity application for Project AIRI. + +## Responsibilities + +- Better Auth session, social login, magic-link, password, and OIDC flows. +- `/api/auth/*`, `/auth/*`, and authentication discovery endpoints. +- Auth-owned Redis configuration, transactional email, and auth telemetry. +- Calling the resource API over the deployment's private network before deleting business data. + +## Code layout + +The runtime is intentionally flat. Its main boundaries are: + +- `auth.ts`: Better Auth configuration and identity lifecycle hooks. +- `routes.ts`: the complete public Auth HTTP surface and request authentication. +- `server.ts`: dependency composition, health checks, and process lifecycle. +- `resource-api.ts`: the single private Auth-to-resource-API boundary. +- `rate-limit.ts` and `otel.ts`: cross-route operational policies. +- `email.ts` and `oidc-jwt-bearer.ts`: substantial external integration modules. + +Small shared contracts stay beside those boundaries (`db.ts`, `env.ts`, +`error.ts`, and `origin.ts`). Tests are collected under `src/tests`; Better +Auth schema-generation wiring is isolated under `src/tooling`. + +## Run locally + +```bash +pnpm -F @proj-airi/auth-server dev +``` + +The service reads `.env.local` from this directory. `PUBLIC_URL` is the public issuer origin presented through Caddy; `RESOURCE_SERVER_URL` is the private resource API used for internal calls. + +To run PostgreSQL, Redis, the resource API, and Auth together from the repository root: + +```bash +pnpm dev:backend +``` + +`server/docker-compose.yaml` exposes only the local Caddy gateway on `http://localhost:6112`; API and Auth +stay on its private network. The internal `/internal/*` boundary has no +application token, and Caddy rejects that path at the public edge. + +## Do not use it for + +- Product APIs, billing, model routing, chat, or WebSocket business state. +- Importing modules from `server/apps/api`. +- Running the shared database migration history during normal process startup. + +Auth tables and principal contracts live in `@proj-airi/auth-shared`. The existing `@proj-airi/drizzle-migration` build remains the migration owner while both applications share one PostgreSQL database. diff --git a/server/apps/auth/instrumentation.ts b/server/apps/auth/instrumentation.ts new file mode 100644 index 000000000..e877ce787 --- /dev/null +++ b/server/apps/auth/instrumentation.ts @@ -0,0 +1,103 @@ +import process, { env } from 'node:process' + +/** + * Auth-server OpenTelemetry preload. + * + * Loaded before application modules so PostgreSQL, Redis, HTTP, and fetch + * instrumentation can patch their runtimes before the auth composition root + * imports those clients. + */ +import { randomUUID } from 'node:crypto' + +import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api' +import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-proto' +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http' +import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis' +import { PgInstrumentation } from '@opentelemetry/instrumentation-pg' +import { RuntimeNodeInstrumentation } from '@opentelemetry/instrumentation-runtime-node' +import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici' +import { resourceFromAttributes } from '@opentelemetry/resources' +import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs' +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' +import { NodeSDK } from '@opentelemetry/sdk-node' +import { BatchSpanProcessor, ParentBasedSampler, TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-node' +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions' + +if (!env.OTEL_SEMCONV_STABILITY_OPT_IN) + env.OTEL_SEMCONV_STABILITY_OPT_IN = 'http' + +const otlpEndpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT +if (!otlpEndpoint) { + console.info('[otel-preload] Auth OpenTelemetry disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)') +} +else { + if (env.OTEL_DEBUG === 'true') + diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG) + + const headers: Record = {} + for (const pair of (env.OTEL_EXPORTER_OTLP_HEADERS ?? '').split(',')) { + const separator = pair.indexOf('=') + if (separator > 0) + headers[pair.slice(0, separator).trim()] = pair.slice(separator + 1).trim() + } + + const samplingRatioRaw = Number(env.OTEL_TRACES_SAMPLING_RATIO ?? '1') + const samplingRatio = Number.isFinite(samplingRatioRaw) && samplingRatioRaw >= 0 && samplingRatioRaw <= 1 + ? samplingRatioRaw + : 1 + const instanceId = env.RAILWAY_REPLICA_ID || env.SERVER_INSTANCE_ID || randomUUID() + const resource = resourceFromAttributes({ + [ATTR_SERVICE_NAME]: env.OTEL_SERVICE_NAME || 'auth-server', + [ATTR_SERVICE_VERSION]: env.npm_package_version || '0.0.0', + 'service.namespace': env.OTEL_SERVICE_NAMESPACE || 'airi', + 'service.instance.id': instanceId, + 'deployment.environment': env.NODE_ENV || 'development', + }) + + const sdk = new NodeSDK({ + resource, + sampler: new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(samplingRatio) }), + spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({ + url: `${otlpEndpoint}/v1/traces`, + headers, + }))], + metricReaders: [new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ url: `${otlpEndpoint}/v1/metrics`, headers }), + exportIntervalMillis: 15_000, + exportTimeoutMillis: 10_000, + })], + logRecordProcessors: [new BatchLogRecordProcessor(new OTLPLogExporter({ + url: `${otlpEndpoint}/v1/logs`, + headers, + }))], + instrumentations: [ + new HttpInstrumentation({ disableIncomingRequestInstrumentation: true }), + new PgInstrumentation({ enhancedDatabaseReporting: true }), + new IORedisInstrumentation(), + new RuntimeNodeInstrumentation(), + new UndiciInstrumentation(), + ], + }) + + sdk.start() + console.info(`[otel-preload] Auth OpenTelemetry initialized — OTLP: ${otlpEndpoint}, sampling ratio: ${samplingRatio}`) + + let shuttingDown = false + const shutdown = async () => { + if (shuttingDown) + return + shuttingDown = true + try { + await sdk.shutdown() + console.info('[otel-preload] Auth OpenTelemetry shut down successfully') + } + catch (error) { + console.error('[otel-preload] Failed to shut down Auth OpenTelemetry:', error) + } + } + + process.once('SIGTERM', () => void shutdown().finally(() => process.exit(0))) + process.once('SIGINT', () => void shutdown().finally(() => process.exit(0))) +} diff --git a/server/apps/auth/package.json b/server/apps/auth/package.json new file mode 100644 index 000000000..875c136e7 --- /dev/null +++ b/server/apps/auth/package.json @@ -0,0 +1,59 @@ +{ + "name": "@proj-airi/auth-server", + "type": "module", + "version": "0.11.3", + "private": true, + "scripts": { + "apply:env": "dotenvx run -f .env.local --overload --ignore=MISSING_ENV_FILE", + "auth:generate": "pnpm run apply:env -- better-auth generate --config src/tooling/auth-config.ts --output ../../packages/auth-shared/src/schema.ts -y", + "dev": "pnpm run apply:env -- tsx --import ./instrumentation.ts --watch src/main.ts", + "start": "pnpm run apply:env -- tsx --import ./instrumentation.ts src/main.ts", + "build": "tsc -b", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@better-auth/drizzle-adapter": "catalog:", + "@better-auth/oauth-provider": "catalog:", + "@dotenvx/dotenvx": "catalog:", + "@guiiai/logg": "catalog:", + "@hono/node-server": "catalog:", + "@moeru/std": "catalog:", + "@opentelemetry/api": "catalog:", + "@opentelemetry/api-logs": "catalog:", + "@opentelemetry/exporter-logs-otlp-proto": "catalog:", + "@opentelemetry/exporter-metrics-otlp-proto": "catalog:", + "@opentelemetry/exporter-trace-otlp-proto": "catalog:", + "@opentelemetry/instrumentation-http": "catalog:", + "@opentelemetry/instrumentation-ioredis": "catalog:", + "@opentelemetry/instrumentation-pg": "catalog:", + "@opentelemetry/instrumentation-runtime-node": "catalog:", + "@opentelemetry/instrumentation-undici": "catalog:", + "@opentelemetry/resources": "catalog:", + "@opentelemetry/sdk-logs": "catalog:", + "@opentelemetry/sdk-metrics": "catalog:", + "@opentelemetry/sdk-node": "catalog:", + "@opentelemetry/sdk-trace-node": "catalog:", + "@opentelemetry/semantic-conventions": "catalog:", + "@proj-airi/auth-shared": "workspace:*", + "better-auth": "catalog:", + "drizzle-orm": "catalog:", + "hono": "catalog:", + "hono-rate-limiter": "catalog:", + "injeca": "catalog:", + "ioredis": "catalog:", + "jose": "catalog:", + "ofetch": "catalog:", + "pg": "catalog:", + "resend": "catalog:", + "tsx": "catalog:", + "valibot": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@better-auth/cli": "catalog:", + "@electric-sql/pglite": "catalog:", + "@types/pg": "catalog:", + "drizzle-kit": "catalog:", + "typescript": "catalog:" + } +} diff --git a/server/apps/api/src/libs/auth.ts b/server/apps/auth/src/auth.ts similarity index 91% rename from server/apps/api/src/libs/auth.ts rename to server/apps/auth/src/auth.ts index d9ef19be9..e6ffa0940 100644 --- a/server/apps/api/src/libs/auth.ts +++ b/server/apps/auth/src/auth.ts @@ -1,11 +1,12 @@ +import type { AuthSession } from '@proj-airi/auth-shared' +import type { BetterAuthOptions } from 'better-auth' import type { AppleProfile } from 'better-auth/social-providers' -import type { AuthMetrics } from '../otel' -import type { EmailService } from '../services/adapters/email' -import type { ProductEventService } from '../services/domain/product-events' -import type { UserDeletionService } from '../services/domain/user-deletion' -import type { Database } from './db' -import type { Env } from './env' +import type { AuthDatabase } from './db' +import type { EmailService } from './email' +import type { AuthEnv } from './env' +import type { AuthMetrics } from './otel' +import type { ResourceApi } from './resource-api' import { Buffer } from 'node:buffer' @@ -19,12 +20,12 @@ import { admin, bearer, jwt, magicLink } from 'better-auth/plugins' import { eq } from 'drizzle-orm' import { importPKCS8, SignJWT } from 'jose' -import { ApiError } from '../utils/error' -import { getAuthTrustedOrigins, getTrustedOrigin } from '../utils/origin' -import { oidcJwtBearer } from './auth-plugins/oidc-jwt-bearer' -import { steam } from './auth-plugins/steam' +import * as authSchema from '@proj-airi/auth-shared' -import * as authSchema from '../schemas/accounts' +import { ApiError } from './error' +import { oidcJwtBearer } from './oidc-jwt-bearer' +import { getAuthTrustedOrigins, getTrustedOrigin } from './origin' +import { steam } from './steam' const logger = useLogger('auth').useGlobalConfig() @@ -76,22 +77,22 @@ const DEFAULT_WEB_REDIRECT_URIS = [ /** * Build redirect URIs for the web OIDC client. - * Includes the default set plus any derived from API_SERVER_URL for + * Includes the default set plus any derived from PUBLIC_URL for * colocated dev/preview deployments. */ -function buildWebRedirectUris(env: Env): string[] { +function buildWebRedirectUris(env: AuthEnv): string[] { const uris = new Set(DEFAULT_WEB_REDIRECT_URIS) - // If API_SERVER_URL has a different origin (e.g. a dev branch deployment), + // If PUBLIC_URL has a different origin (e.g. a dev branch deployment), // add its /auth/callback so OIDC redirect validation passes. try { - const apiOrigin = new URL(env.API_SERVER_URL).origin + const apiOrigin = new URL(env.PUBLIC_URL).origin const derived = `${apiOrigin}/auth/callback` if (!uris.has(derived)) uris.add(derived) } catch { - // Invalid API_SERVER_URL — skip + // Invalid PUBLIC_URL — skip } return [...uris] @@ -110,7 +111,7 @@ function buildWebRedirectUris(env: Env): string[] { * empty optional configuration. */ function createAppleProviderConfig( - env: Pick, + env: Pick, ) { if (!env.AUTH_APPLE_CLIENT_ID || !env.AUTH_APPLE_TEAM_ID @@ -196,7 +197,7 @@ function buildTrustedElectronRedirectUri(request: Request, redirectUri: string): /** * Build the list of first-party OIDC clients to seed into the database. */ -function buildTrustedClientSeeds(env: Env): TrustedClientSeed[] { +function buildTrustedClientSeeds(env: AuthEnv): TrustedClientSeed[] { const clients: TrustedClientSeed[] = [] clients.push({ clientId: OIDC_CLIENT_ID_WEB, @@ -222,7 +223,7 @@ function buildTrustedClientSeeds(env: Env): TrustedClientSeed[] { type: 'native', public: true, redirectUris: [ - `${env.API_SERVER_URL}/api/auth/oidc/electron-callback`, + `${env.PUBLIC_URL}/api/auth/oidc/electron-callback`, ], scopes: [...OIDC_SCOPES], grantTypes: [...OIDC_GRANT_TYPES], @@ -256,7 +257,7 @@ function buildTrustedClientSeeds(env: Env): TrustedClientSeed[] { return clients } -export function getTrustedClientSeedSummaries(env: Env): TrustedClientSeedSummary[] { +export function getTrustedClientSeedSummaries(env: AuthEnv): TrustedClientSeedSummary[] { return buildTrustedClientSeeds(env).map(seed => ({ clientId: seed.clientId, name: seed.name, @@ -269,7 +270,7 @@ export function getTrustedOIDCClientIds(): string[] { } export async function ensureDynamicFirstPartyRedirectUri( - db: Database, + db: AuthDatabase, request: Request, additionalTrustedOrigins: readonly string[], ): Promise { @@ -338,7 +339,7 @@ async function hashClientSecret(secret: string): Promise { * Secrets are hashed before storage to match oauthProvider's default * `storeClientSecret: "hashed"` mode. */ -export async function seedTrustedClients(db: Database, env: Env): Promise { +export async function seedTrustedClients(db: AuthDatabase, env: AuthEnv): Promise { const seeds = buildTrustedClientSeeds(env) if (seeds.length === 0) return @@ -407,32 +408,42 @@ function requireEmailService(email: EmailService | undefined): EmailService { /** * NOTICE: - * `userDeletionService` is optional for the same reason `email` is — the + * `resourceApi` is optional for the same reason `email` is — the * `auth:generate` schema introspection path constructs `createAuth` without * a real DI graph and never exercises `user.deleteUser`. The runtime path - * always supplies it from `app.ts`, and the `beforeDelete` callback throws + * always supplies it from `server.ts`, and the `beforeDelete` callback throws * if it's missing so silent no-ops are impossible. */ -function requireUserDeletionService(service: UserDeletionService | undefined): UserDeletionService { - if (!service) { +function requireResourceApi(resourceApi: ResourceApi | undefined): ResourceApi { + if (!resourceApi) { throw new ApiError( 503, 'user-deletion/service_not_configured', - 'User deletion service not available in this server context.', + 'Resource API not available in this server context.', ) } - return service + return resourceApi +} + +/** Better Auth surface consumed outside this implementation module. */ +export interface AuthInstance { + handler: (request: Request) => Promise + api: { + getSession: (input: { headers: Headers }) => Promise + getOAuthServerConfig: () => Promise + getOpenIdConfig: () => Promise + } + options: BetterAuthOptions } export function createAuth( - db: Database, - env: Env, + db: AuthDatabase, + env: AuthEnv, email?: EmailService, metrics?: AuthMetrics | null, - userDeletionService?: UserDeletionService, - productEventService?: ProductEventService, -) { - return betterAuth({ + resourceApi?: ResourceApi, +): AuthInstance { + const auth = betterAuth({ secret: env.BETTER_AUTH_SECRET, database: drizzleAdapter(db, { @@ -474,12 +485,12 @@ export function createAuth( // into a real better-auth session so `sessionMiddleware` and every // downstream `/api/auth/*` endpoint accept them. Must run after // bearer() so we don't intercept HMAC session tokens that bearer() - // already handles. See libs/auth-plugins/oidc-jwt-bearer.ts for the + // already handles. See oidc-jwt-bearer.ts for the // architectural mismatch this paves over. oidcJwtBearer(env), // Steam's web login is OpenID 2.0, not OAuth2/OIDC, so it can't be a - // `socialProviders` entry — see libs/auth-plugins/steam.ts for why this - // needs to be its own plugin. + // `socialProviders` entry — see steam.ts for why this needs to be its + // own plugin. steam(), magicLink({ // NOTICE: better-auth's magic-link callback receives a server-side @@ -499,7 +510,7 @@ export function createAuth( loginPage: '/auth/sign-in', consentPage: '/oauth/authorize', scopes: [...OIDC_SCOPES], - validAudiences: [env.API_SERVER_URL], + validAudiences: [env.PUBLIC_URL], accessTokenExpiresIn: 3600, // NOTICE: do not enable cachedTrustedClients here. // The oauth-provider plugin caches the full oauth_client row in-process, @@ -593,7 +604,7 @@ export function createAuth( }) }, async beforeDelete(user) { - await requireUserDeletionService(userDeletionService).softDeleteAll({ + await requireResourceApi(resourceApi).softDeleteUserData({ userId: user.id, reason: 'user-requested', }) @@ -624,19 +635,19 @@ export function createAuth( // current AIRI flow the cost is negligible: cookie-based /get-session is // only used by ui-server-auth pages, and /oauth2/authorize is rare. // Bearer-token sessions (the hot path for stage-web/electron/pocket) bypass - // this entirely via libs/request-auth.ts. + // this entirely in the HTTP route boundary. // // Removal condition: oauth-provider's end-session itself clears session // cookies upstream, OR cookieCache TTL is reduced to a window short // enough that "session no longer exists" is not user-visible. }, - baseURL: env.API_SERVER_URL, + baseURL: env.PUBLIC_URL, trustedOrigins: request => getAuthTrustedOrigins(env, request), advanced: { // Caddy reconstructs this header from Cloudflare's client address before - // forwarding to the private API service. Better Auth otherwise defaults + // forwarding to the private Auth service. Better Auth otherwise defaults // to X-Forwarded-For, which contains the proxy chain and can collapse // unrelated clients into a shared rate-limit bucket. ipAddress: { @@ -759,11 +770,9 @@ export function createAuth( create: { after: async (user) => { metrics?.userRegistered.add(1) - void productEventService?.track({ + void resourceApi?.trackAuthEvent({ userId: user.id, - feature: 'auth', action: 'user_signed_up', - status: 'succeeded', source: 'better-auth.user.create', }) }, @@ -803,11 +812,9 @@ export function createAuth( .set({ lastSeenAt: new Date() }) .where(eq(authSchema.user.id, session.userId)) .catch(err => logger.withError(err).withFields({ userId: session.userId }).warn('Failed to update user lastSeenAt; continuing session create')) - void productEventService?.track({ + void resourceApi?.trackAuthEvent({ userId: session.userId, - feature: 'auth', action: 'session_started', - status: 'succeeded', source: 'better-auth.session.create', }) }, @@ -815,6 +822,9 @@ export function createAuth( }, }, }) -} -export type AuthInstance = ReturnType + // The concrete Better Auth type expands every plugin endpoint into a very + // large inferred declaration. Export the stable surface this application + // actually consumes while returning the complete runtime object unchanged. + return auth as AuthInstance +} diff --git a/server/apps/auth/src/db.ts b/server/apps/auth/src/db.ts new file mode 100644 index 000000000..c74cb1fd3 --- /dev/null +++ b/server/apps/auth/src/db.ts @@ -0,0 +1,39 @@ +import type { AuthEnv } from './env' + +import pg from 'pg' + +import { useLogger } from '@guiiai/logg' +import { drizzle } from 'drizzle-orm/node-postgres' + +import * as authSchema from '@proj-airi/auth-shared' + +const logger = useLogger('db') + +/** Database projection visible to the Auth runtime. */ +export type AuthDatabase = ReturnType['db'] + +type AuthDrizzleEnv = Pick + +/** + * Creates the auth service's database projection. Business tables are not + * visible through this handle; cross-service work uses the internal HTTP port. + */ +export function createAuthDrizzle(env: AuthDrizzleEnv) { + // pg must remain a static import so the instrumentation preload can patch it + // before the Auth application modules are evaluated. + const pool = new pg.Pool({ + connectionString: env.DATABASE_URL, + max: env.DB_POOL_MAX, + idleTimeoutMillis: env.DB_POOL_IDLE_TIMEOUT_MS, + connectionTimeoutMillis: env.DB_POOL_CONNECTION_TIMEOUT_MS, + keepAlive: true, + keepAliveInitialDelayMillis: env.DB_POOL_KEEPALIVE_INITIAL_DELAY_MS, + }) + + pool.on('error', (error) => { + logger.withError(error).error('Unexpected pool error on idle client') + }) + + const db = drizzle(pool, { schema: authSchema }) + return { db, pool } +} diff --git a/server/apps/api/src/services/adapters/email.ts b/server/apps/auth/src/email.ts similarity index 98% rename from server/apps/api/src/services/adapters/email.ts rename to server/apps/auth/src/email.ts index c6a9bb5a9..4fd5d712b 100644 --- a/server/apps/api/src/services/adapters/email.ts +++ b/server/apps/auth/src/email.ts @@ -1,12 +1,12 @@ import type { Logger } from '@guiiai/logg' -import type { EmailMetrics } from '../../otel' +import type { EmailMetrics } from './otel' import { useLogger } from '@guiiai/logg' import { errorMessageFrom } from '@moeru/std' import { Resend } from 'resend' -import { ApiError } from '../../utils/error' +import { ApiError } from './error' /** * Outbound email payload accepted by {@link EmailService.send}. @@ -87,7 +87,7 @@ function formatFrom(config: EmailConfig): string { * Construct the email service. * * Use when: - * - DI assembly in `server/apps/api/src/app.ts`. + * - DI assembly in `server/apps/auth/src/server.ts`. * * Expects: * - `RESEND_API_KEY` is set in env. When empty, `send` throws an `ApiError` diff --git a/server/apps/auth/src/env.ts b/server/apps/auth/src/env.ts new file mode 100644 index 000000000..8ace139c5 --- /dev/null +++ b/server/apps/auth/src/env.ts @@ -0,0 +1,94 @@ +import type { InferOutput } from 'valibot' + +import { exit } from 'node:process' + +import { useLogger } from '@guiiai/logg' +import { array, integer, minValue, nonEmpty, object, optional, parse, picklist, pipe, string, transform, url } from 'valibot' + +function optionalIntegerFromString(defaultValue: number, envKey: string, minimum: number) { + return optional( + pipe( + string(), + nonEmpty(`${envKey} must not be empty`), + transform(input => Number(input)), + integer(`${envKey} must be an integer`), + minValue(minimum, `${envKey} must be at least ${minimum}`), + ), + String(defaultValue), + ) +} + +const AdditionalTrustedOriginsSchema = pipe( + string(), + transform(raw => raw.split(',').map(origin => origin.trim()).filter(Boolean)), + array(pipe( + string(), + url('ADDITIONAL_TRUSTED_ORIGINS entries must be valid URLs'), + transform(origin => new URL(origin).origin), + )), + transform(origins => [...new Set(origins)]), +) + +const AuthEnvSchema = object({ + HOST: optional(string(), '0.0.0.0'), + PORT: optionalIntegerFromString(3000, 'PORT', 1), + PUBLIC_URL: optional(string(), 'http://localhost:3000'), + RESOURCE_SERVER_URL: optional(string(), 'http://localhost:3001'), + RATE_LIMIT_TRUSTED_PROXY: optional(picklist(['railway'])), + AUTH_UI_URL: optional(string(), 'https://accounts.airi.build/ui'), + ADDITIONAL_TRUSTED_ORIGINS: optional(AdditionalTrustedOriginsSchema, ''), + DATABASE_URL: pipe(string(), nonEmpty('DATABASE_URL is required')), + REDIS_URL: pipe(string(), nonEmpty('REDIS_URL is required')), + BETTER_AUTH_SECRET: pipe(string(), nonEmpty('BETTER_AUTH_SECRET is required')), + AUTH_GOOGLE_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_ID is required')), + AUTH_GOOGLE_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_SECRET is required')), + AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')), + AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')), + AUTH_APPLE_CLIENT_ID: optional(string(), ''), + AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: optional( + pipe( + string(), + transform(raw => [...new Set( + raw + .split(',') + .map(bundleIdentifier => bundleIdentifier.trim()) + .filter(Boolean), + )]), + ), + '', + ), + AUTH_APPLE_TEAM_ID: optional(string(), ''), + AUTH_APPLE_KEY_ID: optional(string(), ''), + AUTH_APPLE_PRIVATE_KEY_PEM: optional( + pipe( + string(), + // Deployment dashboards commonly store multiline secrets with escaped + // newlines. jose's PKCS8 importer requires the original PEM layout. + transform(raw => raw.replaceAll(String.raw`\n`, '\n')), + ), + '', + ), + RESEND_API_KEY: optional(string(), ''), + RESEND_FROM_EMAIL: optional(string(), 'noreply@airi.moeru.ai'), + RESEND_FROM_NAME: optional(string(), 'Project AIRI'), + DB_POOL_MAX: optionalIntegerFromString(20, 'DB_POOL_MAX', 1), + DB_POOL_IDLE_TIMEOUT_MS: optionalIntegerFromString(30000, 'DB_POOL_IDLE_TIMEOUT_MS', 1), + DB_POOL_CONNECTION_TIMEOUT_MS: optionalIntegerFromString(5000, 'DB_POOL_CONNECTION_TIMEOUT_MS', 1), + DB_POOL_KEEPALIVE_INITIAL_DELAY_MS: optionalIntegerFromString(10000, 'DB_POOL_KEEPALIVE_INITIAL_DELAY_MS', 1), + OTEL_SERVICE_NAME: optional(string(), 'auth-server'), + OTEL_EXPORTER_OTLP_ENDPOINT: optional(string()), +}) + +/** Environment owned exclusively by the standalone Auth process. */ +export type AuthEnv = InferOutput + +/** Parses only Auth-owned configuration; business-only secrets are ignored. */ +export function parseAuthEnv(inputEnv: Record | NodeJS.ProcessEnv): AuthEnv { + try { + return parse(AuthEnvSchema, inputEnv) + } + catch (err) { + useLogger().withError(err).error('Invalid auth environment variables') + exit(1) + } +} diff --git a/server/apps/auth/src/error.ts b/server/apps/auth/src/error.ts new file mode 100644 index 000000000..703189408 --- /dev/null +++ b/server/apps/auth/src/error.ts @@ -0,0 +1,60 @@ +import type { ContentfulStatusCode } from 'hono/utils/http-status' + +export class ApiError extends Error { + constructor( + public readonly statusCode: ContentfulStatusCode, + public readonly errorCode: string, + message: string, + public readonly details?: unknown, + ) { + super(message) + this.name = 'ApiError' + } +} + +/** + * Creates an internal server error (500) + */ +export function createInternalError(message = 'Internal Server Error', details?: unknown) { + return new ApiError(500, 'INTERNAL_SERVER_ERROR', message, details) +} + +/** + * Creates a bad request error (400) + */ +export function createBadRequestError(message: string, errorCode = 'BAD_REQUEST', details?: unknown) { + return new ApiError(400, errorCode, message, details) +} + +/** + * Creates a forbidden error (403) + */ +export function createForbiddenError(message = 'Forbidden', details?: unknown) { + return new ApiError(403, 'FORBIDDEN', message, details) +} + +/** + * Creates a service unavailable error (503) + */ +export function createServiceUnavailableError(message = 'Service Unavailable', errorCode = 'SERVICE_UNAVAILABLE', details?: unknown) { + return new ApiError(503, errorCode, message, details) +} + +/** + * Creates a bad gateway error (502). + * + * Use when: + * - An upstream provider (LLM, TTS, third-party API) returned a fallback- + * triggering response (401 / 402 / 403 / 5xx) and the gateway has exhausted + * every retry/fallback path. The client must see a gateway-side error code, + * not the upstream's status, because the client did nothing wrong. + * + * Expects: + * - `details` is sanitized — never include raw upstream response bodies or + * headers (they can leak provider-internal info like subscription IDs, + * region identifiers, or rate-limit metadata). Use shape + * `{ triedKeys?: number, triedUpstreams?: number, lastStatusCode?: number }`. + */ +export function createBadGatewayError(message = 'Bad Gateway', details?: unknown) { + return new ApiError(502, 'BAD_GATEWAY', message, details) +} diff --git a/server/apps/auth/src/main.ts b/server/apps/auth/src/main.ts new file mode 100644 index 000000000..da0feec87 --- /dev/null +++ b/server/apps/auth/src/main.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +import process from 'node:process' + +import { errorMessageFrom } from '@moeru/std' + +import { runAuthServer } from './server' + +/** + * Starts only the AIRI Auth dependency graph. + * + * Call stack: + * + * main + * -> {@link runAuthServer} + * -> createAuthServer + * -> buildAuthApp + */ +async function main(): Promise { + await runAuthServer() +} + +void main().catch((error: unknown) => { + process.stderr.write(`${errorMessageFrom(error) ?? 'Unknown auth server error'}\n`) + process.exit(1) +}) diff --git a/server/apps/api/src/libs/auth-plugins/oidc-jwt-bearer.ts b/server/apps/auth/src/oidc-jwt-bearer.ts similarity index 94% rename from server/apps/api/src/libs/auth-plugins/oidc-jwt-bearer.ts rename to server/apps/auth/src/oidc-jwt-bearer.ts index d94612954..d2110cb50 100644 --- a/server/apps/api/src/libs/auth-plugins/oidc-jwt-bearer.ts +++ b/server/apps/auth/src/oidc-jwt-bearer.ts @@ -1,7 +1,7 @@ import type { BetterAuthPlugin } from 'better-auth' import type { JSONWebKeySet } from 'jose' -import type { Env } from '../env' +import type { AuthEnv } from './env' import { createHmac } from 'node:crypto' @@ -22,11 +22,9 @@ const JwtBearerTokenSchema = pipe( * plugin understands. * * Use when: - * - The same Hono app hosts both the OIDC IdP (oauthProvider) and the - * resource server (`/api/v1/*`, `/api/auth/*`). Stage-web / Electron / - * Pocket clients carry an OIDC JWT for everything; without this plugin - * their `Authorization: Bearer ` is silently rejected by every - * `/api/auth/*` endpoint that needs `c.context.session`. + * - Auth endpoints still need to accept access tokens minted by this + * service (for example profile and account-management requests). The + * separate resource API validates the same tokens from Auth's JWKS. * * Why a plugin (vs. per-route shims): * - The `before` hook fires before `sessionMiddleware`, so a single @@ -70,7 +68,7 @@ const JwtBearerTokenSchema = pipe( * Removal condition: better-auth ships a first-party way to verify * externally-signed JWTs against a JWKS for its own session resolution. */ -export function oidcJwtBearer(env: Env): BetterAuthPlugin { +export function oidcJwtBearer(env: AuthEnv): BetterAuthPlugin { // Bridge session lifetime. Long enough to span an OAuth round-trip // (link-social → provider → callback) on slow networks; short enough // that an unused row TTL-prunes quickly. @@ -174,7 +172,7 @@ export function oidcJwtBearer(env: Env): BetterAuthPlugin { * recipe at node_modules/better-call/dist/crypto.mjs L27-32. * * Why inline (not import from better-call): better-call is a transitive - * via better-auth, not a direct dep of server/apps/api. Inlining a 3-line + * via better-auth, not a direct dependency of the resource API. Inlining a 3-line * helper avoids polluting package.json with what is, semantically, an * internal of better-auth's bearer flow. */ @@ -233,8 +231,8 @@ export function oidcJwtBearer(env: Env): BetterAuthPlugin { let userId: string try { const { payload } = await jwtVerify(token, keySet, { - issuer: `${env.API_SERVER_URL}/api/auth`, - audience: env.API_SERVER_URL, + issuer: `${env.PUBLIC_URL}/api/auth`, + audience: env.PUBLIC_URL, }) if (typeof payload.sub !== 'string') return diff --git a/server/apps/auth/src/origin.ts b/server/apps/auth/src/origin.ts new file mode 100644 index 000000000..36ee82f31 --- /dev/null +++ b/server/apps/auth/src/origin.ts @@ -0,0 +1,157 @@ +function getOriginFromUrl(url: string): string | undefined { + try { + return new URL(url).origin + } + catch { + return undefined + } +} + +const TRUSTED_EXACT_ORIGINS = [ + 'https://airi.moeru.ai', // Production web app + 'capacitor://localhost', // Capacitor mobile (iOS) + 'ai.moeru.airi-pocket://links', // Android deep link + 'https://accounts.airi.build', // Standalone auth UI + 'https://server-dev.airi-server-auth.pages.dev', // Server-dev standalone auth UI + 'https://admin.airi.build', // Standalone admin UI + 'https://server-dev.airi-server-admin.pages.dev', // Server-dev standalone admin UI +] + +// NOTICE: +// Better Auth accepts non-http(s) origins by prefix (`url.startsWith(pattern)`), +// so native deep-link schemes must not be copied from TRUSTED_EXACT_ORIGINS +// into auth callback validation. Browser auth callbacks only need web origins. +const TRUSTED_AUTH_CALLBACK_ORIGINS = TRUSTED_EXACT_ORIGINS.filter((origin) => { + const protocol = new URL(origin).protocol + return protocol === 'http:' || protocol === 'https:' +}) + +// NOTICE: +// Private LAN / CGNAT-style dev hosts (e.g. https://10.x:5273 from cap-vite) are NOT matched +// by regex here — list them explicitly via env `ADDITIONAL_TRUSTED_ORIGINS` (see env.ts). +const TRUSTED_ORIGIN_PATTERNS = [ + // Localhost dev (any port) + /^http:\/\/localhost(:\d+)?$/, + // Loopback interface for Electron OIDC callbacks (RFC 8252 S7.3) + /^http:\/\/127\.0\.0\.1(:\d+)?$/, + // Vite + mkcert (https://localhost:5273, etc.) + /^https:\/\/localhost(:\d+)?$/, + /^https:\/\/127\.0\.0\.1(:\d+)?$/, + // Cloudflare Workers subdomains + /^https:\/\/.*\.kwaa\.workers\.dev$/, +] + +/** + * Returns `origin` when it matches built-in trust rules or `additionalTrustedOrigins`. + * + * Use when: + * - Auth CORS allowlists and callback validation need the same trust policy. + * + * Expects: + * - `origin` is the raw `Origin` header value or `new URL(referer).origin`. + * - `additionalTrustedOrigins` entries are normalized by the environment schema. + * + * Returns: + * - The same origin string when trusted, or `''` when not trusted. + */ +export function getTrustedOrigin(origin: string, additionalTrustedOrigins: readonly string[] = []): string { + if (!origin) + return origin + if (TRUSTED_EXACT_ORIGINS.includes(origin)) + return origin + if (additionalTrustedOrigins.includes(origin)) + return origin + if (TRUSTED_ORIGIN_PATTERNS.some(pattern => pattern.test(origin))) + return origin + return '' +} + +/** + * Resolves a trusted browser origin from `Referer` (preferred) or `Origin`. + * + * Expects: + * - Same trust inputs as {@link getTrustedOrigin}. + * + * Returns: + * - The trusted origin string, or `undefined` when neither header yields a trusted origin. + */ +export function resolveTrustedRequestOrigin( + request: Request, + additionalTrustedOrigins: readonly string[] = [], +): string | undefined { + const refererOrigin = getOriginFromUrl(request.headers.get('referer') ?? '') + if (refererOrigin) { + const trustedRefererOrigin = getTrustedOrigin(refererOrigin, additionalTrustedOrigins) + if (trustedRefererOrigin) { + return trustedRefererOrigin + } + } + + const requestOrigin = request.headers.get('origin') ?? '' + const trustedRequestOrigin = getTrustedOrigin(requestOrigin, additionalTrustedOrigins) + if (trustedRequestOrigin) { + return trustedRequestOrigin + } + + return undefined +} + +// NOTICE: +// Better Auth's callbackURL validation walks `trustedOrigins`. Static entries +// support `*` wildcards via the framework's wildcardMatch (see +// node_modules/better-auth/dist/auth/trusted-origins.mjs). Loopback origins +// across any port are allowed so dev (Vite at :5173/:5174/:4173, electron +// loopback OAuth at :random_port) and prod (where these addresses are +// unreachable) share the same config. The pattern is intentionally broad — +// loopback is unreachable from the public internet, so any origin that +// resolves to localhost is by definition the same machine the user is on. +// +// Removal condition: when dev serves UI from the same origin as the API +// (e.g. via vite proxy or static mount), drop these entries. +const ALWAYS_TRUSTED_AUTH_ORIGINS = [ + 'http://localhost:*', + 'http://127.0.0.1:*', +] + +/** + * Builds the origin list passed to Better Auth `trustedOrigins` (and related flows). + * + * Expects: + * - Public Auth server URL and parsed `env.ADDITIONAL_TRUSTED_ORIGINS`. + * - Optional `request` so the caller's Origin/Referer can be merged when known. + * + * Returns: + * - De-duplicated origins in insertion order (Auth URL, env extras, localhost wildcards, then request-derived). + */ +export function getAuthTrustedOrigins( + env: { PUBLIC_URL: string, ADDITIONAL_TRUSTED_ORIGINS: readonly string[] }, + request?: Request, +): string[] { + const origins = new Set() + const authServerOrigin = getOriginFromUrl(env.PUBLIC_URL) + if (authServerOrigin) { + origins.add(authServerOrigin) + } + + for (const origin of TRUSTED_AUTH_CALLBACK_ORIGINS) { + origins.add(origin) + } + origins.add('https://appleid.apple.com') + + for (const origin of env.ADDITIONAL_TRUSTED_ORIGINS) { + origins.add(origin) + } + + for (const origin of ALWAYS_TRUSTED_AUTH_ORIGINS) { + origins.add(origin) + } + + if (request) { + const requestOrigin = resolveTrustedRequestOrigin(request, env.ADDITIONAL_TRUSTED_ORIGINS) + if (requestOrigin) { + origins.add(requestOrigin) + } + } + + return [...origins] +} diff --git a/server/apps/auth/src/otel.ts b/server/apps/auth/src/otel.ts new file mode 100644 index 000000000..a559759be --- /dev/null +++ b/server/apps/auth/src/otel.ts @@ -0,0 +1,93 @@ +import type { Counter, Histogram } from '@opentelemetry/api' + +import { useLogger } from '@guiiai/logg' +import { metrics, trace } from '@opentelemetry/api' +import { logs, SeverityNumber } from '@opentelemetry/api-logs' + +const logger = useLogger('otel') + +export interface AuthMetrics { + attempts: Counter + failures: Counter + userRegistered: Counter + userLogin: Counter +} + +export interface EmailMetrics { + send: Counter + failures: Counter + duration: Histogram +} + +export interface RateLimitMetrics { + blocked: Counter +} + +export interface AuthOtelInstance { + auth: AuthMetrics + email: EmailMetrics + rateLimit: RateLimitMetrics +} + +/** Builds the metric handles owned by the standalone auth process. */ +export function initAuthOtel(env: { OTEL_EXPORTER_OTLP_ENDPOINT?: string, OTEL_SERVICE_NAME: string }): AuthOtelInstance | null { + if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) { + logger.log('OpenTelemetry disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)') + return null + } + + const meter = metrics.getMeter(env.OTEL_SERVICE_NAME) + const auth: AuthMetrics = { + attempts: meter.createCounter('auth.attempts', { description: 'Number of authentication attempts' }), + failures: meter.createCounter('auth.failures', { description: 'Number of failed authentication attempts' }), + userRegistered: meter.createCounter('user.registered', { description: 'Number of new user registrations' }), + userLogin: meter.createCounter('user.login', { description: 'Number of user sign-ins' }), + } + const email: EmailMetrics = { + send: meter.createCounter('airi.email.send', { description: 'Transactional emails accepted by Resend' }), + failures: meter.createCounter('airi.email.failures', { description: 'Transactional email send failures' }), + duration: meter.createHistogram('airi.email.duration', { description: 'Email provider call duration', unit: 's' }), + } + const rateLimit: RateLimitMetrics = { + blocked: meter.createCounter('airi.rate_limit.blocked', { description: 'Requests blocked by the auth rate limiter' }), + } + for (const counter of [ + auth.attempts, + auth.failures, + auth.userRegistered, + auth.userLogin, + email.send, + email.failures, + rateLimit.blocked, + ]) counter.add(0) + + return { auth, email, rateLimit } +} + +const severityMap: Record = { + debug: SeverityNumber.DEBUG, + verbose: SeverityNumber.TRACE, + log: SeverityNumber.INFO, + info: SeverityNumber.INFO, + warn: SeverityNumber.WARN, + error: SeverityNumber.ERROR, +} + +/** Emits a log record through the auth process's global OTel provider. */ +export function emitOtelLog( + level: string, + context: string, + message: string, + attributes?: Record, +): void { + const spanContext = trace.getActiveSpan()?.spanContext() + logs.getLogger(context).emit({ + severityNumber: severityMap[level.toLowerCase()] ?? SeverityNumber.INFO, + severityText: level.toUpperCase(), + body: message, + attributes: { + ...attributes, + ...(spanContext && { trace_id: spanContext.traceId, span_id: spanContext.spanId }), + }, + }) +} diff --git a/server/apps/auth/src/rate-limit.ts b/server/apps/auth/src/rate-limit.ts new file mode 100644 index 000000000..f67c458eb --- /dev/null +++ b/server/apps/auth/src/rate-limit.ts @@ -0,0 +1,142 @@ +import type { Context } from 'hono' +import type Redis from 'ioredis' + +import type { RateLimitMetrics } from './otel' +import type { HonoEnv } from './routes' + +import { isIP } from 'node:net' + +import { getConnInfo } from '@hono/node-server/conninfo' +import { errorMessageFrom } from '@moeru/std' +import { rateLimiter as createRateLimiter } from 'hono-rate-limiter' +import { number, parse } from 'valibot' + +import { createServiceUnavailableError } from './error' + +export interface AuthRateLimitConfig { + max: number + windowSec: number +} + +export function createAuthConfigService(redis: Redis) { + async function readNumber(key: 'AUTH_RATE_LIMIT_MAX' | 'AUTH_RATE_LIMIT_WINDOW_SEC', defaultValue: number): Promise { + const raw = await redis.get(`config:${key}`) + if (raw === null) + return defaultValue + + try { + return parse(number(), JSON.parse(raw)) + } + catch (error) { + throw createServiceUnavailableError('Auth configuration is invalid', 'CONFIG_INVALID', { + key, + message: errorMessageFrom(error) ?? 'Unknown config parse error', + }) + } + } + + return { + async getRateLimit(): Promise { + const [max, windowSec] = await Promise.all([ + readNumber('AUTH_RATE_LIMIT_MAX', 20), + readNumber('AUTH_RATE_LIMIT_WINDOW_SEC', 60), + ]) + return { max, windowSec } + }, + } +} + +export type AuthConfigService = ReturnType + +interface RateLimitOptions { + /** Max requests allowed within the window */ + max: number + /** Window size in seconds */ + windowSec: number + /** Key generator: extracts a unique identifier from the request */ + keyGenerator?: (c: Context) => string + /** + * Reverse proxy whose client-address header is safe to use. The caller must + * select this only when the deployment guarantees that the named proxy owns + * and overwrites that header before the request reaches the application. + */ + trustedProxy?: 'railway' + /** + * Optional metrics handle. When provided, blocked requests increment + * `airi_rate_limit_blocked_total{route, key_type, limit}`. + * `key_type` reflects whether the limiter keyed off authenticated user id + * or remote IP — important for distinguishing logged-in abuse from + * anonymous scraping. + */ + metrics?: RateLimitMetrics | null + /** + * Stable label for the route this limiter guards (e.g. `auth.api`, + * `openai.completions`, `stripe.checkout`). Avoids high-cardinality URL + * paths in metric labels. + */ + routeLabel?: string +} + +/** + * Rate limiter middleware powered by hono-rate-limiter. + * Uses in-memory store by default (single-instance). + */ +export function rateLimiter(opts: RateLimitOptions) { + const keyGen = opts.keyGenerator + ?? ((c) => { + const userId = c.get('user')?.id + if (userId) + return userId + + const trustedProxyAddress = getTrustedProxyClientAddress(c, opts.trustedProxy) + if (trustedProxyAddress) + return trustedProxyAddress + + // `app.request()` and fetch-style deployments have no Node incoming + // socket. Keep those requests in a shared bucket rather than trusting a + // client-controlled forwarding header. + try { + const info = getConnInfo(c) + return info.remote?.address ?? 'anonymous' + } + catch { + return 'anonymous' + } + }) + + return createRateLimiter({ + windowMs: opts.windowSec * 1000, + limit: opts.max, + // NOTICE: keep `draft-6` so the middleware emits the widely supported + // `RateLimit-*` header set. `draft-7`/`draft-8` switch to newer combined + // header formats that are easier to break in existing clients and proxies. + standardHeaders: 'draft-6', + keyGenerator: keyGen, + handler: (c) => { + // Record before producing the 429 response so the time series captures + // every block, even when the response shape later changes. + const keyType = c.get('user')?.id ? 'user' : 'ip' + opts.metrics?.blocked.add(1, { + route: opts.routeLabel ?? 'unknown', + key_type: keyType, + limit: String(opts.max), + }) + return c.json({ error: 'TOO_MANY_REQUESTS', message: 'Too many requests' }, 429) + }, + }) +} + +/** + * Uses Railway's canonical client address only after the deployment explicitly + * opts into that trust boundary. Proxy transport details do not affect it. + */ +function getTrustedProxyClientAddress(c: Context, trustedProxy: RateLimitOptions['trustedProxy']): string | undefined { + if (trustedProxy !== 'railway') + return undefined + + const clientAddress = c.req.header('x-real-ip')?.trim() + if (!clientAddress || isIP(clientAddress) === 0) + return undefined + + return clientAddress +} diff --git a/server/apps/auth/src/resource-api.ts b/server/apps/auth/src/resource-api.ts new file mode 100644 index 000000000..ee30d4123 --- /dev/null +++ b/server/apps/auth/src/resource-api.ts @@ -0,0 +1,61 @@ +import { useLogger } from '@guiiai/logg' + +import { createBadGatewayError } from './error' + +export type UserDeletionReason = 'user-requested' | 'admin' | 'compliance' + +export interface AuthEventInput { + userId: string + action: 'user_signed_up' | 'session_started' + source: 'better-auth.user.create' | 'better-auth.session.create' +} + +/** + * Business operations owned by the resource API that Auth must coordinate. + * Calls use the deployment's private service URL; the public edge must not + * expose `/internal/*`. + */ +export interface ResourceApi { + softDeleteUserData: (input: { userId: string, reason: UserDeletionReason }) => Promise + trackAuthEvent: (input: AuthEventInput) => Promise +} + +/** Creates the single private HTTP boundary from Auth to the resource API. */ +export function createResourceApi( + resourceServerUrl: string, + fetchRequest: typeof fetch = fetch, +): ResourceApi { + const logger = useLogger('resource-api').useGlobalConfig() + + return { + async softDeleteUserData(input) { + const response = await fetchRequest(new URL('/internal/auth/user-deletion', resourceServerUrl), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) + + if (!response.ok) { + throw createBadGatewayError('Business account cleanup failed', { + statusCode: response.status, + }) + } + }, + + async trackAuthEvent(input) { + try { + const response = await fetchRequest(new URL('/internal/auth/events', resourceServerUrl), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) + if (!response.ok) + logger.withFields({ statusCode: response.status, action: input.action }).warn('Resource API rejected auth event') + } + catch (error) { + // Analytics must never make signup or login unavailable. + logger.withError(error).withFields({ action: input.action }).warn('Failed to forward auth event') + } + }, + } +} diff --git a/server/apps/auth/src/routes.ts b/server/apps/auth/src/routes.ts new file mode 100644 index 000000000..79aea9e57 --- /dev/null +++ b/server/apps/auth/src/routes.ts @@ -0,0 +1,347 @@ +import type { AuthSession } from '@proj-airi/auth-shared' + +import type { AuthInstance } from './auth' +import type { AuthDatabase } from './db' +import type { AuthEnv } from './env' +import type { RateLimitMetrics } from './otel' +import type { AuthConfigService } from './rate-limit' + +import { createHash } from 'node:crypto' + +import { account, isUserBannedNow, user } from '@proj-airi/auth-shared' +import { and, eq } from 'drizzle-orm' +import { Hono } from 'hono' +import { createRemoteJWKSet, jwtVerify } from 'jose' +import { email, nonEmpty, object, pipe, safeParse, string, transform } from 'valibot' + +import { ensureDynamicFirstPartyRedirectUri } from './auth' +import { createBadRequestError, createForbiddenError } from './error' +import { rateLimiter } from './rate-limit' + +export interface HonoEnv { + Variables: { + user: AuthSession['user'] | null + session: AuthSession['session'] | null + } +} + +export const SERVER_AUTH_UI_BASE_PATH = '/auth' +export const AUTH_UI_PUBLIC_URL_QUERY_PARAM = 'api_server_url' +export const DEFAULT_AUTH_UI_URL = 'https://accounts.airi.build/ui' +export const SERVER_DEV_PUBLIC_URL = 'https://airi-server-dev.up.railway.app' +export const SERVER_DEV_AUTH_UI_URL = 'https://server-dev.airi-server-auth.pages.dev/ui' + +/** Builds a route URL below the configured standalone Auth UI base. */ +export function buildAuthUiUrl(authUiUrl: string, path: string, search = ''): string { + const target = new URL(authUiUrl) + const basePath = target.pathname.replace(/\/+$/, '') + const routePath = path.startsWith('/') ? path : `/${path}` + target.pathname = `${basePath}${routePath}` + target.search = search + target.hash = '' + return target.toString() +} + +/** Resolves environment-specific Auth UI hosting without changing other URLs. */ +export function resolveAuthUiUrl(authUiUrl: string, apiServerUrl: string): string { + try { + const authUi = new URL(authUiUrl) + const defaultAuthUi = new URL(DEFAULT_AUTH_UI_URL) + const apiServer = new URL(apiServerUrl) + const authUiBase = `${authUi.origin}${authUi.pathname.replace(/\/+$/, '')}` + const defaultAuthUiBase = `${defaultAuthUi.origin}${defaultAuthUi.pathname.replace(/\/+$/, '')}` + if (authUiBase === defaultAuthUiBase && apiServer.origin === SERVER_DEV_PUBLIC_URL) + return SERVER_DEV_AUTH_UI_URL + } + catch { + return authUiUrl + } + return authUiUrl +} + +/** Maps a public `/auth/*` request to its standalone Auth UI URL. */ +export function buildAuthUiRedirectUrl(authUiUrl: string, requestUrl: string, apiServerUrl?: string): string { + const request = new URL(requestUrl) + const suffix = request.pathname === SERVER_AUTH_UI_BASE_PATH + ? '/' + : request.pathname.slice(SERVER_AUTH_UI_BASE_PATH.length) || '/' + const resolvedAuthUiUrl = apiServerUrl ? resolveAuthUiUrl(authUiUrl, apiServerUrl) : authUiUrl + const target = new URL(buildAuthUiUrl(resolvedAuthUiUrl, suffix, request.search)) + if (apiServerUrl) + target.searchParams.set(AUTH_UI_PUBLIC_URL_QUERY_PARAM, new URL(apiServerUrl).origin) + return target.toString() +} + +const remoteJwksByUrl = new Map>() + +function readBearerToken(headers: Headers): string | null { + const authorization = headers.get('authorization') + if (!authorization?.startsWith('Bearer ')) + return null + + const token = authorization.slice(7).trim() + return token.length > 0 ? token : null +} + +function getRemoteJwks(publicUrl: string): ReturnType { + const jwksUrl = new URL('/api/auth/jwks', publicUrl).toString() + const cached = remoteJwksByUrl.get(jwksUrl) + if (cached) + return cached + + const jwks = createRemoteJWKSet(new URL(jwksUrl)) + remoteJwksByUrl.set(jwksUrl, jwks) + return jwks +} + +async function resolveJwtAccessToken( + db: AuthDatabase, + env: Pick, + accessToken: string, +): Promise { + try { + const { payload } = await jwtVerify(accessToken, getRemoteJwks(env.PUBLIC_URL), { + issuer: `${env.PUBLIC_URL}/api/auth`, + audience: env.PUBLIC_URL, + }) + if (!payload.sub) + return null + + const resolvedUser = await db.query.user.findFirst({ + where: eq(user.id, payload.sub), + }) + if (!resolvedUser) + return null + + const issuedAt = payload.iat ? new Date(payload.iat * 1000) : new Date() + return { + user: resolvedUser, + session: { + id: payload.jti ?? payload.sub, + token: accessToken, + userId: payload.sub, + createdAt: issuedAt, + updatedAt: issuedAt, + expiresAt: payload.exp ? new Date(payload.exp * 1000) : new Date(), + ipAddress: null, + userAgent: null, + }, + } + } + catch { + return null + } +} + +async function resolveSessionIgnoringBan( + auth: AuthInstance, + db: AuthDatabase, + env: Pick, + headers: Headers, +): Promise { + const session = await auth.api.getSession({ headers }) + if (session?.user && session?.session) + return session + + const accessToken = readBearerToken(headers) + if (!accessToken) + return null + + return await resolveJwtAccessToken(db, env, accessToken) +} + +async function resolveAuthRequest( + auth: AuthInstance, + db: AuthDatabase, + env: Pick, + headers: Headers, +): Promise { + const resolved = await resolveSessionIgnoringBan(auth, db, env, headers) + if (!resolved || isUserBannedNow(resolved.user)) + return null + + return resolved +} + +function buildGravatarUrl(emailAddress: string): string | null { + const normalized = emailAddress.trim().toLowerCase() + if (!normalized) + return null + + const url = new URL(createHash('sha256').update(normalized).digest('hex'), 'https://www.gravatar.com/avatar/') + url.searchParams.set('d', 'identicon') + url.searchParams.set('s', '200') + return url.toString() +} + +const CheckEmailIdentifierBodySchema = object({ + email: pipe( + string(), + transform(value => value.trim().toLowerCase()), + nonEmpty('email is required'), + email('email must be a valid email address'), + ), +}) + +async function checkEmailIdentifier(db: AuthDatabase, body: { email?: unknown } | null) { + const parsed = safeParse(CheckEmailIdentifierBodySchema, body) + if (!parsed.success) + throw createBadRequestError('Invalid email', 'INVALID_EMAIL') + + const [matched] = await db.select({ id: user.id }).from(user).where(eq(user.email, parsed.output.email)).limit(1) + if (!matched) + return { exists: false, hasPassword: false } + + const [credential] = await db + .select({ id: account.id }) + .from(account) + .where(and(eq(account.userId, matched.id), eq(account.providerId, 'credential'))) + .limit(1) + return { exists: true, hasPassword: !!credential } +} + +function createAuthUiRoutes(env: AuthEnv) { + return new Hono() + .get(SERVER_AUTH_UI_BASE_PATH, c => c.redirect(buildAuthUiRedirectUrl(env.AUTH_UI_URL, c.req.url, env.PUBLIC_URL))) + .get(`${SERVER_AUTH_UI_BASE_PATH}/*`, c => c.redirect(buildAuthUiRedirectUrl(env.AUTH_UI_URL, c.req.url, env.PUBLIC_URL))) +} + +function createElectronCallbackRelay(env: AuthEnv) { + return new Hono().get('/', (c) => { + const request = new URL(c.req.url) + return c.redirect(buildAuthUiUrl(env.AUTH_UI_URL, '/api/auth/oidc/electron-callback', request.search)) + }) +} + +function createOIDCTokenAuthRoute(deps: Pick) { + return new Hono() + .on(['GET', 'POST'], '/get-session', async (c) => { + const session = await resolveAuthRequest(deps.auth, deps.db, deps.env, c.req.raw.headers) + if (!session) + return c.json(null) + const image = session.user.image || buildGravatarUrl(session.user.email) + return c.json({ ...session, user: { ...session.user, image } }) + }) + .post('/sign-out', c => c.json({ success: true })) + .get('/list-sessions', async (c) => { + const session = await resolveAuthRequest(deps.auth, deps.db, deps.env, c.req.raw.headers) + return c.json(session ? [session.session] : []) + }) +} + +export interface AuthRoutesDeps { + auth: AuthInstance + db: AuthDatabase + env: AuthEnv + authConfig: AuthConfigService + rateLimitMetrics?: RateLimitMetrics | null +} + +/** + * All auth-related routes: sign-in page, rate-limited better-auth + * helper routes, electron callback relay, catch-all, and + * well-known metadata endpoints. + * + * Mounted at the root level because routes span multiple prefixes + * (`/auth/*`, `/api/auth/*`, `/.well-known/*`). + */ +export async function createAuthRoutes(deps: AuthRoutesDeps) { + const rateLimitConfig = await deps.authConfig.getRateLimit() + + async function handleAuthRequest(request: Request): Promise { + const response = await deps.auth.handler(request) + + if (!(response instanceof Response)) + throw new TypeError('Expected auth handler to return a Response') + + return response + } + + return new Hono() + .route('/', createAuthUiRoutes(deps.env)) + /** + * Auth routes are handled by the auth instance directly, + * Powered by better-auth. + * Rate limited by the Auth-owned runtime configuration. + */ + .use('/api/auth/*', rateLimiter({ + max: rateLimitConfig.max, + windowSec: rateLimitConfig.windowSec, + // Proxy trust is a deployment boundary, not a property of the public + // API URL. Custom domains and private gateways must opt in explicitly. + trustedProxy: deps.env.RATE_LIMIT_TRUSTED_PROXY, + metrics: deps.rateLimitMetrics, + routeLabel: 'auth.api', + })) + .use('/api/auth/oauth2/authorize', async (c, next) => { + await ensureDynamicFirstPartyRedirectUri(deps.db, c.req.raw, deps.env.ADDITIONAL_TRUSTED_ORIGINS) + await next() + }) + // NOTICE: + // `/api/auth/*` bypasses sessionMiddleware (and thus the ban gate in + // resolveRequestAuth), and oauthProvider's /oauth2/userinfo validates the + // bearer JWT by signature only — so a banned user's still-valid access + // token (<=1h TTL) could otherwise read its own profile claims after a ban. + // This guard re-applies the ban check on that one endpoint. We resolve the + // subject ignoring the ban, then 403 if banned, so an invalid/expired token + // still falls through to better-auth's own 401 rather than being masked. + // (/oauth2/introspect needs confidential client credentials, which no + // first-party AIRI client has, so it has no reachable banned-caller path.) + .use('/api/auth/oauth2/userinfo', async (c, next) => { + const resolved = await resolveSessionIgnoringBan(deps.auth, deps.db, deps.env, c.req.raw.headers) + if (resolved && isUserBannedNow(resolved.user)) + throw createForbiddenError('This account has been banned') + await next() + }) + .route('/api/auth', createOIDCTokenAuthRoute(deps)) + /** + * Electron OIDC callback relay: serves an HTML page that forwards the + * authorization code to the Electron loopback server via JS fetch(). + * This avoids navigating the browser to http://127.0.0.1:{port}. + */ + .route('/api/auth/oidc/electron-callback', createElectronCallbackRelay(deps.env)) + /** + * OAuth 2.1 Authorization Server metadata must live at the root-level + * well-known path with the issuer path inserted for non-root issuers. + */ + .on('GET', '/.well-known/oauth-authorization-server/api/auth', async (c) => { + return c.json(await deps.auth.api.getOAuthServerConfig(), 200, { + 'Cache-Control': 'public, max-age=15, stale-while-revalidate=15, stale-if-error=86400', + }) + }) + /** + * OpenID Connect discovery metadata uses path appending for issuers with + * paths, so `/api/auth` serves its own `/.well-known/openid-configuration`. + */ + .on('GET', '/api/auth/.well-known/openid-configuration', async (c) => { + return c.json(await deps.auth.api.getOpenIdConfig(), 200, { + 'Cache-Control': 'public, max-age=15, stale-while-revalidate=15, stale-if-error=86400', + }) + }) + /** + * Email-first identifier check. + * + * Powers the unified sign-in/up UI: the user types an email, the UI calls + * this to decide whether to render a password input (existing user with + * a credential account) or the new-account form (or steer them to a + * social provider when only social accounts exist). + * + * Returns: + * - `exists`: a `user` row matches the email (case-insensitive). + * - `hasPassword`: that user has an account row with `providerId='credential'`, + * i.e. can sign in via email + password (vs. social-only). + * + * Account-enumeration tradeoff: this confirms whether an email is + * registered, mirroring the standard set by Google/Linear/Notion. We + * accept the disclosure since the existing rate limiter applied to + * `/api/auth/*` (`AUTH_RATE_LIMIT_MAX` per IP per window) already throttles + * enumeration attempts. + */ + .on('POST', '/api/auth/check-email', async (c) => { + const body = await c.req.json().catch(() => null) as { email?: unknown } | null + return c.json(await checkEmailIdentifier(deps.db, body)) + }) + .on(['POST', 'GET'], '/api/auth/*', async (c) => { + return handleAuthRequest(c.req.raw) + }) +} diff --git a/server/apps/auth/src/server.ts b/server/apps/auth/src/server.ts new file mode 100644 index 000000000..ab427ab77 --- /dev/null +++ b/server/apps/auth/src/server.ts @@ -0,0 +1,291 @@ +import type { Logger } from '@guiiai/logg' + +import type { AuthInstance } from './auth' +import type { AuthDatabase } from './db' +import type { AuthEnv } from './env' +import type { RateLimitMetrics } from './otel' +import type { AuthConfigService } from './rate-limit' +import type { HonoEnv } from './routes' + +import process from 'node:process' + +import Redis from 'ioredis' + +import { initLogger, LoggerFormat, LoggerLevel, setGlobalHookPostLog, useLogger } from '@guiiai/logg' +import { serve } from '@hono/node-server' +import { withRetry } from '@moeru/std' +import { Hono } from 'hono' +import { bodyLimit } from 'hono/body-limit' +import { cors } from 'hono/cors' +import { logger as honoLogger } from 'hono/logger' +import { createContainer, createLoggLogger, lifecycle, provide, resolve, start, stop } from 'injeca' + +import { createAuth, getTrustedClientSeedSummaries, seedTrustedClients } from './auth' +import { createAuthDrizzle } from './db' +import { createEmailService } from './email' +import { parseAuthEnv } from './env' +import { ApiError, createInternalError } from './error' +import { getTrustedOrigin } from './origin' +import { emitOtelLog, initAuthOtel } from './otel' +import { createAuthConfigService } from './rate-limit' +import { createResourceApi } from './resource-api' +import { createAuthRoutes } from './routes' + +const EXTERNAL_DEPENDENCY_INIT_MAX_ATTEMPTS = 5 +const EXTERNAL_DEPENDENCY_INIT_BASE_DELAY_MS = 5000 + +/** Initializes an Auth dependency using the process startup retry policy. */ +async function initializeExternalDependency( + dependencyName: string, + logger: Logger, + initialize: (attempt: number) => Promise, +): Promise { + let attempt = 0 + + return await withRetry( + async () => { + attempt += 1 + return await initialize(attempt) + }, + { + retry: EXTERNAL_DEPENDENCY_INIT_MAX_ATTEMPTS - 1, + retryDelay: EXTERNAL_DEPENDENCY_INIT_BASE_DELAY_MS, + retryDelayFactor: 2, + retryDelayMax: EXTERNAL_DEPENDENCY_INIT_BASE_DELAY_MS * 2 ** (EXTERNAL_DEPENDENCY_INIT_MAX_ATTEMPTS - 1), + onError: (error) => { + logger.withError(error).warn(`${dependencyName} initialization failed on attempt ${attempt}/${EXTERNAL_DEPENDENCY_INIT_MAX_ATTEMPTS}`) + }, + }, + )() +} + +export interface AuthAppDeps { + auth: AuthInstance + db: AuthDatabase + redis: Redis + env: AuthEnv + authConfig: AuthConfigService + rateLimitMetrics?: RateLimitMetrics | null +} + +/** Builds the standalone Auth HTTP surface without constructing its runtime dependencies. */ +export async function buildAuthApp(deps: AuthAppDeps) { + const logger = useLogger('auth-app').useGlobalConfig() + + const app = new Hono() + .use('*', async (c, next) => { + await next() + c.res.headers.set('Cache-Control', 'no-store, no-cache, private, max-age=0') + c.res.headers.set('Pragma', 'no-cache') + c.res.headers.set('Expires', '0') + }) + .use( + '/api/*', + cors({ + origin: origin => getTrustedOrigin(origin, deps.env.ADDITIONAL_TRUSTED_ORIGINS), + credentials: true, + }), + ) + .use(honoLogger()) + .use('*', bodyLimit({ maxSize: 1024 * 1024 })) + .onError((err, c) => { + if (err instanceof ApiError) { + const logFields = { details: err.details, cause: (err as { cause?: unknown }).cause } + if (err.statusCode >= 500) + logger.withError(err).withFields(logFields).error('Auth API error occurred') + else if (err.statusCode !== 401) + logger.withError(err).withFields(logFields).warn('Auth API error occurred') + + return c.json({ + error: err.errorCode, + message: err.message, + details: err.details, + }, err.statusCode) + } + + logger.withError(err).error('Unhandled auth error') + const internalError = createInternalError() + return c.json({ + error: internalError.errorCode, + message: internalError.message, + }, internalError.statusCode) + }) + .get('/livez', c => c.json({ status: 'live' })) + .get('/readyz', async (c) => { + const [dbResult, redisResult] = await Promise.allSettled([ + // Auth does not run migrations. Probe an owned table so readiness + // stays false until the migration owner has installed the auth schema. + deps.db.execute('SELECT 1 FROM "user" LIMIT 1'), + deps.redis.ping(), + ]) + const dbReady = dbResult.status === 'fulfilled' + const redisReady = redisResult.status === 'fulfilled' + const ready = dbReady && redisReady + + return c.json({ + status: ready ? 'ready' : 'not_ready', + checks: { db: dbReady ? 'ok' : 'fail', redis: redisReady ? 'ok' : 'fail' }, + }, ready ? 200 : 503) + }) + .get('/', c => c.json({ + service: 'airi-auth', + issuer: `${deps.env.PUBLIC_URL}/api/auth`, + accounts: deps.env.AUTH_UI_URL, + })) + .route('/', await createAuthRoutes({ + auth: deps.auth, + db: deps.db, + env: deps.env, + authConfig: deps.authConfig, + rateLimitMetrics: deps.rateLimitMetrics, + })) + + return { app } +} + +export type AuthAppType = Awaited>['app'] + +/** + * Builds the standalone auth runtime with its own dependency container. + * Only authentication infrastructure is registered here; business services + * remain owned by the resource API process. + */ +export async function createAuthServer() { + initLogger(LoggerLevel.Debug, LoggerFormat.Pretty) + const logger = useLogger('auth-server').useGlobalConfig() + const container = createContainer({ logger: createLoggLogger(useLogger('injeca').useGlobalConfig()) }) + + setGlobalHookPostLog((log) => { + emitOtelLog(log.level, log.context, log.message, log.fields as Record) + }) + + const env = provide(container, 'env', () => parseAuthEnv(process.env)) + const otel = provide(container, 'libs:otel', { + dependsOn: { env }, + build: ({ dependsOn }) => initAuthOtel(dependsOn.env), + }) + const db = provide(container, 'datastore:db', { + dependsOn: { env, lifecycle }, + build: async ({ dependsOn }) => { + const connection = await initializeExternalDependency('Database', logger, async (attempt) => { + const candidate = createAuthDrizzle(dependsOn.env) + try { + await candidate.db.execute('SELECT 1') + logger.log(`Connected to database on attempt ${attempt}`) + // The drizzle-migration build owns the shared database history. + // Auth startup only checks connectivity and never races migrations. + return candidate + } + catch (error) { + await candidate.pool.end() + throw error + } + }) + dependsOn.lifecycle.appHooks.onStop(() => connection.pool.end()) + return connection.db + }, + }) + const redis = provide(container, 'datastore:redis', { + dependsOn: { env, lifecycle }, + build: async ({ dependsOn }) => { + const instance = await initializeExternalDependency('Redis', logger, async (attempt) => { + const candidate = new Redis(dependsOn.env.REDIS_URL, { lazyConnect: true }) + try { + await candidate.connect() + logger.log(`Connected to Redis on attempt ${attempt}`) + return candidate + } + catch (error) { + candidate.disconnect() + throw error + } + }) + dependsOn.lifecycle.appHooks.onStop(async () => { + await instance.quit() + }) + return instance + }, + }) + const authConfig = provide(container, 'services:authConfig', { + dependsOn: { redis }, + build: ({ dependsOn }) => createAuthConfigService(dependsOn.redis), + }) + const email = provide(container, 'services:email', { + dependsOn: { env, otel }, + build: ({ dependsOn }) => createEmailService({ + apiKey: dependsOn.env.RESEND_API_KEY, + fromEmail: dependsOn.env.RESEND_FROM_EMAIL, + fromName: dependsOn.env.RESEND_FROM_NAME, + }, undefined, dependsOn.otel?.email), + }) + const resourceApi = provide(container, 'services:resourceApi', { + dependsOn: { env }, + build: ({ dependsOn }) => createResourceApi(dependsOn.env.RESOURCE_SERVER_URL), + }) + const auth = provide(container, 'services:auth', { + dependsOn: { db, env, email, otel, resourceApi }, + build: async ({ dependsOn }) => { + await seedTrustedClients(dependsOn.db, dependsOn.env) + for (const client of getTrustedClientSeedSummaries(dependsOn.env)) { + logger.withFields({ + clientId: client.clientId, + clientName: client.name, + redirectUris: client.redirectUris.join(', '), + }).log('OIDC trusted client ready') + } + return createAuth( + dependsOn.db, + dependsOn.env, + dependsOn.email, + dependsOn.otel?.auth, + dependsOn.resourceApi, + ) + }, + }) + + await start(container) + const dependencies = await resolve(container, { auth, authConfig, db, redis, env, otel }) + + const { app } = await buildAuthApp({ + auth: dependencies.auth, + db: dependencies.db, + redis: dependencies.redis, + env: dependencies.env, + authConfig: dependencies.authConfig, + rateLimitMetrics: dependencies.otel?.rateLimit, + }) + + return { + app, + hostname: dependencies.env.HOST, + port: dependencies.env.PORT, + stop: () => stop(container), + } +} + +function handleProcessError(error: unknown, type: string) { + useLogger().withError(error).error(type) +} + +/** + * Starts the dedicated Auth HTTP process and owns its shutdown lifecycle. + * + * Call stack: + * + * runAuthServer + * -> {@link createAuthServer} + * -> {@link buildAuthApp} + * -> Better Auth / OIDC routes + */ +export async function runAuthServer(): Promise { + const runtime = await createAuthServer() + const server = serve({ fetch: runtime.app.fetch, port: runtime.port, hostname: runtime.hostname }) + + process.on('uncaughtException', error => handleProcessError(error, 'Uncaught exception')) + process.on('unhandledRejection', error => handleProcessError(error, 'Unhandled rejection')) + + await new Promise((resolvePromise, reject) => { + server.once('close', () => resolvePromise()) + server.once('error', error => reject(error)) + }).finally(runtime.stop) +} diff --git a/server/apps/api/src/libs/auth-plugins/steam.ts b/server/apps/auth/src/steam.ts similarity index 100% rename from server/apps/api/src/libs/auth-plugins/steam.ts rename to server/apps/auth/src/steam.ts diff --git a/server/apps/auth/src/tests/app.test.ts b/server/apps/auth/src/tests/app.test.ts new file mode 100644 index 000000000..e1779c9a1 --- /dev/null +++ b/server/apps/auth/src/tests/app.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' + +import { buildAuthApp } from '../server' + +function createTestDeps() { + return { + auth: { + api: { + getSession: vi.fn(async () => null), + getOAuthServerConfig: vi.fn(async () => ({ issuer: 'https://api.airi.build/api/auth' })), + getOpenIdConfig: vi.fn(async () => ({ issuer: 'https://api.airi.build/api/auth' })), + }, + handler: vi.fn(async () => new Response('auth-handler')), + } as any, + db: { + execute: vi.fn(async () => []), + } as any, + redis: { + ping: vi.fn(async () => 'PONG'), + } as any, + env: { + PUBLIC_URL: 'https://api.airi.build', + AUTH_UI_URL: 'https://accounts.airi.build/ui', + ADDITIONAL_TRUSTED_ORIGINS: [], + } as any, + authConfig: { + getRateLimit: vi.fn(async () => ({ max: 20, windowSec: 60 })), + } as any, + rateLimitMetrics: null, + } +} + +describe('standalone auth app', () => { + it('identifies its public issuer at the root', async () => { + const { app } = await buildAuthApp(createTestDeps()) + const response = await app.request('/') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + service: 'airi-auth', + issuer: 'https://api.airi.build/api/auth', + accounts: 'https://accounts.airi.build/ui', + }) + }) + + it('serves auth without exposing business API routes', async () => { + const deps = createTestDeps() + const { app } = await buildAuthApp(deps) + + expect((await app.request('/api/auth/custom-route')).status).toBe(200) + expect((await app.request('/api/v1/characters')).status).toBe(404) + expect(deps.auth.handler).toHaveBeenCalledTimes(1) + }) + + it('checks only the infrastructure needed by the auth surface', async () => { + const deps = createTestDeps() + const { app } = await buildAuthApp(deps) + const response = await app.request('/readyz') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + status: 'ready', + checks: { db: 'ok', redis: 'ok' }, + }) + expect(deps.db.execute).toHaveBeenCalledWith('SELECT 1 FROM "user" LIMIT 1') + expect(deps.redis.ping).toHaveBeenCalledTimes(1) + }) +}) diff --git a/server/apps/api/src/libs/tests/auth.test.ts b/server/apps/auth/src/tests/auth.test.ts similarity index 84% rename from server/apps/api/src/libs/tests/auth.test.ts rename to server/apps/auth/src/tests/auth.test.ts index 61954103a..59a4940e0 100644 --- a/server/apps/api/src/libs/tests/auth.test.ts +++ b/server/apps/auth/src/tests/auth.test.ts @@ -1,5 +1,5 @@ -import type { Database } from '../db' -import type { Env } from '../env' +import type { AuthDatabase } from '../db' +import type { AuthEnv } from '../env' import { generateKeyPairSync } from 'node:crypto' @@ -41,8 +41,8 @@ describe('createAuth', () => { const applePublicKey = publicKey.export({ type: 'spki', format: 'pem' }).toString() it('allows signed-in users to link OAuth accounts that use a different email', () => { - const auth = createAuth({} as unknown as Database, { - API_SERVER_URL: 'http://localhost:3000', + const auth = createAuth({} as unknown as AuthDatabase, { + PUBLIC_URL: 'http://localhost:3000', AUTH_GOOGLE_CLIENT_ID: 'google-client', AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', AUTH_GITHUB_CLIENT_ID: 'github-client', @@ -54,14 +54,14 @@ describe('createAuth', () => { AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey, BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret', ADDITIONAL_TRUSTED_ORIGINS: [], - } as unknown as Env) + } as unknown as AuthEnv) expect(auth.options.account?.accountLinking?.allowDifferentEmails).toBe(true) }) it('asks social providers to show the account picker during OAuth authorization', () => { - const auth = createAuth({} as unknown as Database, { - API_SERVER_URL: 'http://localhost:3000', + const auth = createAuth({} as unknown as AuthDatabase, { + PUBLIC_URL: 'http://localhost:3000', AUTH_GOOGLE_CLIENT_ID: 'google-client', AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', AUTH_GITHUB_CLIENT_ID: 'github-client', @@ -73,15 +73,19 @@ describe('createAuth', () => { AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey, BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret', ADDITIONAL_TRUSTED_ORIGINS: [], - } as unknown as Env) + } as unknown as AuthEnv) - expect(auth.options.socialProviders?.google?.prompt).toBe('select_account') - expect(auth.options.socialProviders?.github?.prompt).toBe('select_account') + const google = auth.options.socialProviders?.google + const github = auth.options.socialProviders?.github + if (!google || typeof google === 'function' || !github || typeof github === 'function') + throw new TypeError('Expected synchronous Google and GitHub provider configuration') + expect(google.prompt).toBe('select_account') + expect(github.prompt).toBe('select_account') }) it('does not register Apple when its optional credentials are absent', () => { - const auth = createAuth({} as unknown as Database, { - API_SERVER_URL: 'http://localhost:3000', + const auth = createAuth({} as unknown as AuthDatabase, { + PUBLIC_URL: 'http://localhost:3000', AUTH_GOOGLE_CLIENT_ID: 'google-client', AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', AUTH_GITHUB_CLIENT_ID: 'github-client', @@ -93,14 +97,14 @@ describe('createAuth', () => { AUTH_APPLE_PRIVATE_KEY_PEM: '', BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret', ADDITIONAL_TRUSTED_ORIGINS: [], - } as unknown as Env) + } as unknown as AuthEnv) expect(auth.options.socialProviders?.apple).toBeUndefined() }) it('does not register Apple when its optional credentials are incomplete', () => { - const auth = createAuth({} as unknown as Database, { - API_SERVER_URL: 'http://localhost:3000', + const auth = createAuth({} as unknown as AuthDatabase, { + PUBLIC_URL: 'http://localhost:3000', AUTH_GOOGLE_CLIENT_ID: 'google-client', AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', AUTH_GITHUB_CLIENT_ID: 'github-client', @@ -112,14 +116,14 @@ describe('createAuth', () => { AUTH_APPLE_PRIVATE_KEY_PEM: '', BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret', ADDITIONAL_TRUSTED_ORIGINS: [], - } as unknown as Env) + } as unknown as AuthEnv) expect(auth.options.socialProviders?.apple).toBeUndefined() }) it('configures Apple for web OAuth and native ID-token sign-in', async () => { - const auth = createAuth({} as unknown as Database, { - API_SERVER_URL: 'http://localhost:3000', + const auth = createAuth({} as unknown as AuthDatabase, { + PUBLIC_URL: 'http://localhost:3000', AUTH_GOOGLE_CLIENT_ID: 'google-client', AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', AUTH_GITHUB_CLIENT_ID: 'github-client', @@ -131,7 +135,7 @@ describe('createAuth', () => { AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey, BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret', ADDITIONAL_TRUSTED_ORIGINS: [], - } as unknown as Env) + } as unknown as AuthEnv) const appleProvider = auth.options.socialProviders?.apple expect(typeof appleProvider).toBe('function') @@ -139,6 +143,8 @@ describe('createAuth', () => { throw new TypeError('Expected Apple provider to use async configuration') const config = await appleProvider() + if (!config.clientSecret) + throw new TypeError('Expected Apple client-secret JWT') const header = decodeProtectedHeader(config.clientSecret) const claims = decodeJwt(config.clientSecret) const verificationKey = await importSPKI(applePublicKey, 'ES256') @@ -180,37 +186,26 @@ describe('createAuth', () => { email: 'relay@privaterelay.appleid.com', }) - const context = await auth.$context - const resolvedProvider = context.socialProviders.find(provider => provider.id === 'apple') - if (!resolvedProvider) - throw new TypeError('Expected Better Auth to resolve the Apple provider') - - expect(resolvedProvider.options && 'audience' in resolvedProvider.options - ? resolvedProvider.options.audience - : undefined).toEqual([ - 'apple-service-id', - 'ai.moeru.airi-pocket', - 'ai.moeru.airi-pro', - ]) - - const authorizationURL = await resolvedProvider.createAuthorizationURL({ - state: 'apple-oauth-state', - codeVerifier: 'unused-by-apple', - redirectURI: 'https://api.airi.build/api/auth/callback/apple', - }) - expect(authorizationURL.origin).toBe('https://appleid.apple.com') - expect(authorizationURL.pathname).toBe('/auth/authorize') - expect(authorizationURL.searchParams.get('client_id')).toBe('apple-service-id') - expect(authorizationURL.searchParams.get('redirect_uri')).toBe('https://api.airi.build/api/auth/callback/apple') - expect(authorizationURL.searchParams.get('scope')).toBe('email name') - expect(authorizationURL.searchParams.get('response_mode')).toBe('form_post') - const trustedOrigins = auth.options.trustedOrigins expect(typeof trustedOrigins).toBe('function') if (typeof trustedOrigins !== 'function') throw new TypeError('Expected request-aware trusted origins') expect(await trustedOrigins(new Request('http://localhost:3000/api/auth/sign-in/social'))).toContain('https://appleid.apple.com') }) + + it('uses the Caddy public API origin as the Better Auth base URL', () => { + const auth = createAuth({} as unknown as AuthDatabase, { + PUBLIC_URL: 'https://api.airi.build', + AUTH_GOOGLE_CLIENT_ID: 'google-client', + AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', + AUTH_GITHUB_CLIENT_ID: 'github-client', + AUTH_GITHUB_CLIENT_SECRET: 'github-secret', + BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret', + ADDITIONAL_TRUSTED_ORIGINS: [], + } as unknown as AuthEnv) + + expect(auth.options.baseURL).toBe('https://api.airi.build') + }) }) describe('seedTrustedClients', () => { @@ -218,7 +213,7 @@ describe('seedTrustedClients', () => { const { db, values, capturedValues } = createMockDb([[], [], []]) await seedTrustedClients(db as any, { - API_SERVER_URL: 'http://localhost:3000', + PUBLIC_URL: 'http://localhost:3000', } as any) expect(values).toHaveBeenCalledTimes(3) @@ -231,7 +226,7 @@ describe('seedTrustedClients', () => { expect(webClient.clientId).toBe('airi-stage-web') expect(webClient.clientSecret).toBeNull() expect(webClient.public).toBe(true) - // Includes default URIs + derived from API_SERVER_URL (localhost:3000) + // Includes default URIs + derived from PUBLIC_URL (localhost:3000) expect(webClient.redirectUris).toEqual([ 'https://airi.moeru.ai/auth/callback', 'http://localhost:5173/auth/callback', @@ -288,7 +283,7 @@ describe('seedTrustedClients', () => { (db as any).update = vi.fn(() => ({ set })) await seedTrustedClients(db as any, { - API_SERVER_URL: 'http://localhost:3000', + PUBLIC_URL: 'http://localhost:3000', } as any) expect(values).toHaveBeenCalledTimes(2) @@ -297,6 +292,18 @@ describe('seedTrustedClients', () => { expect(setCalls[0].tokenEndpointAuthMethod).toBe('none') expect(setCalls[0].clientSecret).toBeNull() }) + + it('registers the Electron callback on the public API origin', async () => { + const { db, capturedValues } = createMockDb([[], [], []]) + + await seedTrustedClients(db as any, { + PUBLIC_URL: 'https://api.airi.build', + } as any) + + expect(capturedValues[1].redirectUris).toEqual([ + 'https://api.airi.build/api/auth/oidc/electron-callback', + ]) + }) }) describe('ensureDynamicFirstPartyRedirectUri', () => { diff --git a/server/apps/auth/src/tests/env.test.ts b/server/apps/auth/src/tests/env.test.ts new file mode 100644 index 000000000..c3e07f512 --- /dev/null +++ b/server/apps/auth/src/tests/env.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' + +import { parseAuthEnv } from '../env' + +function baseAuthEnv(): Record { + return { + DATABASE_URL: 'postgres://identity', + REDIS_URL: 'redis://identity', + PUBLIC_URL: 'https://api.airi.build', + RESOURCE_SERVER_URL: 'https://resource.internal', + BETTER_AUTH_SECRET: 'identity-secret-at-least-32-characters', + AUTH_GOOGLE_CLIENT_ID: 'google-client', + AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', + AUTH_GITHUB_CLIENT_ID: 'github-client', + AUTH_GITHUB_CLIENT_SECRET: 'github-secret', + } +} + +describe('parseAuthEnv', () => { + it('parses auth configuration without business-only LLM or Stripe secrets', () => { + const env = parseAuthEnv(baseAuthEnv()) + + expect(env.PUBLIC_URL).toBe('https://api.airi.build') + expect(env.RESOURCE_SERVER_URL).toBe('https://resource.internal') + expect(env.BETTER_AUTH_SECRET).toBe('identity-secret-at-least-32-characters') + expect('LLM_ROUTER_MASTER_KEY' in env).toBe(false) + expect('STRIPE_SECRET_KEY' in env).toBe(false) + expect('TEST_AUTH_TOKEN' in env).toBe(false) + }) + + it('normalizes Apple audiences and escaped private-key newlines', () => { + const env = parseAuthEnv({ + ...baseAuthEnv(), + AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: 'ai.moeru.airi-pocket, ai.moeru.airi-pro, ai.moeru.airi-pocket', + AUTH_APPLE_TEAM_ID: 'apple-team-id', + AUTH_APPLE_KEY_ID: 'apple-key-id', + AUTH_APPLE_PRIVATE_KEY_PEM: 'line-one\\nline-two', + }) + + expect(env.AUTH_APPLE_APP_BUNDLE_IDENTIFIERS).toEqual([ + 'ai.moeru.airi-pocket', + 'ai.moeru.airi-pro', + ]) + expect(env.AUTH_APPLE_PRIVATE_KEY_PEM).toBe('line-one\nline-two') + }) + + it('keeps optional Apple authentication disabled by default', () => { + const env = parseAuthEnv(baseAuthEnv()) + + expect(env.AUTH_APPLE_CLIENT_ID).toBe('') + expect(env.AUTH_APPLE_APP_BUNDLE_IDENTIFIERS).toEqual([]) + expect(env.AUTH_APPLE_TEAM_ID).toBe('') + expect(env.AUTH_APPLE_KEY_ID).toBe('') + expect(env.AUTH_APPLE_PRIVATE_KEY_PEM).toBe('') + }) + + it('parses the explicit Railway trusted-proxy boundary', () => { + const env = parseAuthEnv({ + ...baseAuthEnv(), + RATE_LIMIT_TRUSTED_PROXY: 'railway', + }) + + expect(env.RATE_LIMIT_TRUSTED_PROXY).toBe('railway') + }) + + it('normalizes trusted origins and parses database pool settings', () => { + const env = parseAuthEnv({ + ...baseAuthEnv(), + ADDITIONAL_TRUSTED_ORIGINS: 'https://desktop.test/, https://desktop.test, https://web.test:5273/', + DB_POOL_MAX: '8', + }) + + expect(env.ADDITIONAL_TRUSTED_ORIGINS).toEqual([ + 'https://desktop.test', + 'https://web.test:5273', + ]) + expect(env.DB_POOL_MAX).toBe(8) + expect(env.DB_POOL_IDLE_TIMEOUT_MS).toBe(30000) + }) +}) diff --git a/server/apps/auth/src/tests/origin.test.ts b/server/apps/auth/src/tests/origin.test.ts new file mode 100644 index 000000000..e9ef67471 --- /dev/null +++ b/server/apps/auth/src/tests/origin.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' + +import { getAuthTrustedOrigins, getTrustedOrigin } from '../origin' + +describe('auth origin policy', () => { + it('collects public, first-party, loopback, and request origins', () => { + const request = new Request('http://localhost/api/auth/sign-in/social', { + headers: { origin: 'http://localhost:5173' }, + }) + + expect(getAuthTrustedOrigins({ + PUBLIC_URL: 'https://api.airi.moeru.ai', + ADDITIONAL_TRUSTED_ORIGINS: [], + }, request)).toEqual([ + 'https://api.airi.moeru.ai', + 'https://airi.moeru.ai', + 'https://accounts.airi.build', + 'https://server-dev.airi-server-auth.pages.dev', + 'https://admin.airi.build', + 'https://server-dev.airi-server-admin.pages.dev', + 'https://appleid.apple.com', + 'http://localhost:*', + 'http://127.0.0.1:*', + 'http://localhost:5173', + ]) + }) + + it('includes explicit development origins without trusting native callback schemes', () => { + const origins = getAuthTrustedOrigins({ + PUBLIC_URL: 'https://api.airi.build', + ADDITIONAL_TRUSTED_ORIGINS: ['https://10.0.0.129:5273'], + }) + + expect(origins).toContain('https://10.0.0.129:5273') + expect(getTrustedOrigin('capacitor://localhost')).toBe('capacitor://localhost') + expect(origins).not.toContain('capacitor://localhost') + expect(origins).not.toContain('ai.moeru.airi-pocket://links') + }) + + it('always trusts the first-party auth UI for email callbacks', () => { + expect(getAuthTrustedOrigins({ + PUBLIC_URL: 'https://api.airi.build', + ADDITIONAL_TRUSTED_ORIGINS: [], + })).toContain('https://accounts.airi.build') + }) +}) diff --git a/server/apps/api/src/routes/auth/auth-rate-limit.test.ts b/server/apps/auth/src/tests/rate-limit.test.ts similarity index 64% rename from server/apps/api/src/routes/auth/auth-rate-limit.test.ts rename to server/apps/auth/src/tests/rate-limit.test.ts index 982386796..02baf7a75 100644 --- a/server/apps/api/src/routes/auth/auth-rate-limit.test.ts +++ b/server/apps/auth/src/tests/rate-limit.test.ts @@ -1,24 +1,44 @@ -import type { ConfigKVService } from '../../services/adapters/config-kv' -import type { HonoEnv } from '../../types/hono' +import type Redis from 'ioredis' + +import type { AuthConfigService } from '../rate-limit' +import type { HonoEnv } from '../routes' import { serve } from '@hono/node-server' import { Hono } from 'hono' import { describe, expect, it, vi } from 'vitest' -import { createAuthRoutes } from '.' - -function createConfigKV(): ConfigKVService { - const values: Record = { - AUTH_RATE_LIMIT_MAX: 1, - AUTH_RATE_LIMIT_WINDOW_SEC: 60, - } +import { createAuthConfigService } from '../rate-limit' +import { createAuthRoutes } from '../routes' +function createRedis(values: Record): Redis { return { - get: vi.fn(async (key: string) => values[key]), - getOrThrow: vi.fn(async (key: string) => values[key]), - getOptional: vi.fn(async (key: string) => values[key] ?? null), - set: vi.fn(), - } as any + get: vi.fn(async (key: string) => values[key] ?? null), + } as unknown as Redis +} + +describe('auth rate-limit config', () => { + it('uses defaults when Redis keys are absent', async () => { + expect(await createAuthConfigService(createRedis({})).getRateLimit()).toEqual({ max: 20, windowSec: 60 }) + }) + + it('reads rate-limit values from the shared ConfigKV namespace', async () => { + const service = createAuthConfigService(createRedis({ + 'config:AUTH_RATE_LIMIT_MAX': '40', + 'config:AUTH_RATE_LIMIT_WINDOW_SEC': '120', + })) + expect(await service.getRateLimit()).toEqual({ max: 40, windowSec: 120 }) + }) + + it('rejects malformed stored values', async () => { + const service = createAuthConfigService(createRedis({ 'config:AUTH_RATE_LIMIT_MAX': '"forty"' })) + await expect(service.getRateLimit()).rejects.toMatchObject({ errorCode: 'CONFIG_INVALID' }) + }) +}) + +function createAuthConfig(): AuthConfigService { + return { + getRateLimit: vi.fn(async () => ({ max: 1, windowSec: 60 })), + } } async function createApp(trustedProxy?: 'railway') { @@ -29,12 +49,12 @@ async function createApp(trustedProxy?: 'railway') { } as any, db: {} as any, env: { - API_SERVER_URL: 'https://api.airi.build', + PUBLIC_URL: 'https://api.airi.build', AUTH_UI_URL: 'https://accounts.airi.build/ui', ADDITIONAL_TRUSTED_ORIGINS: [], RATE_LIMIT_TRUSTED_PROXY: trustedProxy, } as any, - configKV: createConfigKV(), + authConfig: createAuthConfig(), rateLimitMetrics: null, }) @@ -82,7 +102,7 @@ describe('auth API rate limiting behind Railway', () => { }) it('uses the forwarded client IP over an IPv6 gateway socket', async () => { - // ROOT CAUSE: proxy trust was inferred from API_SERVER_URL, so moving the + // ROOT CAUSE: proxy trust was inferred from PUBLIC_URL, so moving the // public custom domain to Caddy first disabled X-Real-IP. The replacement // then allowed only IPv4 proxy sockets, while Railway connected Caddy to // ts-api over private IPv6, so callers still shared the Caddy socket bucket. diff --git a/server/apps/auth/src/tests/resource-api.test.ts b/server/apps/auth/src/tests/resource-api.test.ts new file mode 100644 index 000000000..fb92a7e6e --- /dev/null +++ b/server/apps/auth/src/tests/resource-api.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createResourceApi } from '../resource-api' + +describe('resource API', () => { + it('forwards business-data deletion over the private HTTP boundary', async () => { + const fetchRequest = vi.fn(async () => new Response(null, { status: 204 })) + const resourceApi = createResourceApi('https://resource.internal', fetchRequest) + + await resourceApi.softDeleteUserData({ userId: 'user-1', reason: 'user-requested' }) + + expect(fetchRequest).toHaveBeenCalledTimes(1) + const [url, init] = fetchRequest.mock.calls[0] + expect(url.toString()).toBe('https://resource.internal/internal/auth/user-deletion') + expect(init?.method).toBe('POST') + expect(init?.headers).toEqual({ 'Content-Type': 'application/json' }) + expect(init?.body).toBe('{"userId":"user-1","reason":"user-requested"}') + }) + + it('fails account deletion when the resource API rejects cleanup', async () => { + const resourceApi = createResourceApi( + 'https://resource.internal', + vi.fn(async () => new Response(null, { status: 503 })), + ) + + await expect( + resourceApi.softDeleteUserData({ userId: 'user-1', reason: 'user-requested' }), + ).rejects.toMatchObject({ statusCode: 502 }) + }) + + it('forwards auth events without adding an application credential', async () => { + const fetchRequest = vi.fn(async () => new Response(null, { status: 204 })) + const resourceApi = createResourceApi('https://resource.internal', fetchRequest) + + await resourceApi.trackAuthEvent({ + userId: 'user-1', + action: 'user_signed_up', + source: 'better-auth.user.create', + }) + + const [url, init] = fetchRequest.mock.calls[0] + expect(url.toString()).toBe('https://resource.internal/internal/auth/events') + expect(init?.headers).toEqual({ 'Content-Type': 'application/json' }) + expect(init?.body).toBe('{"userId":"user-1","action":"user_signed_up","source":"better-auth.user.create"}') + }) +}) diff --git a/server/apps/api/src/utils/tests/auth-ui.test.ts b/server/apps/auth/src/tests/routes-ui.test.ts similarity index 98% rename from server/apps/api/src/utils/tests/auth-ui.test.ts rename to server/apps/auth/src/tests/routes-ui.test.ts index bf597b27f..aba82040a 100644 --- a/server/apps/api/src/utils/tests/auth-ui.test.ts +++ b/server/apps/auth/src/tests/routes-ui.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { buildAuthUiRedirectUrl, buildAuthUiUrl, resolveAuthUiUrl } from '../auth-ui' +import { buildAuthUiRedirectUrl, buildAuthUiUrl, resolveAuthUiUrl } from '../routes' describe('auth UI URL helpers', () => { it('builds auth UI URLs under the configured auth base path', () => { diff --git a/server/apps/api/src/routes/auth/oidc-userinfo-ban.test.ts b/server/apps/auth/src/tests/routes-userinfo.test.ts similarity index 86% rename from server/apps/api/src/routes/auth/oidc-userinfo-ban.test.ts rename to server/apps/auth/src/tests/routes-userinfo.test.ts index aaf02a267..4e31966bc 100644 --- a/server/apps/api/src/routes/auth/oidc-userinfo-ban.test.ts +++ b/server/apps/auth/src/tests/routes-userinfo.test.ts @@ -1,26 +1,21 @@ -import type { AuthRoutesDeps } from '.' -import type { ConfigKVService } from '../../services/adapters/config-kv' -import type { HonoEnv } from '../../types/hono' +import type { AuthConfigService } from '../rate-limit' +import type { AuthRoutesDeps, HonoEnv } from '../routes' import { Hono } from 'hono' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createAuthRoutes } from '.' -import { ApiError } from '../../utils/error' +import { ApiError } from '../error' +import { createAuthRoutes } from '../routes' // The /oauth2/userinfo guard composes resolveSessionIgnoringBan (cookie path // mocked via auth.api.getSession) with isUserBannedNow(user.banned). The ban // flag lives on the user row (better-auth admin plugin), so we drive it via the // mocked session — no DB query happens on this path. -function createConfigKV(): ConfigKVService { - const values: Record = { AUTH_RATE_LIMIT_MAX: 100, AUTH_RATE_LIMIT_WINDOW_SEC: 60 } +function createAuthConfig(): AuthConfigService { return { - get: vi.fn(async (k: string) => values[k]), - getOrThrow: vi.fn(async (k: string) => values[k]), - getOptional: vi.fn(async (k: string) => values[k] ?? null), - set: vi.fn(), - } as any + getRateLimit: vi.fn(async () => ({ max: 100, windowSec: 60 })), + } } interface SessionUser { id: string, email: string, banned: boolean, banExpires: Date | null } @@ -42,11 +37,11 @@ async function buildRoutes(currentUser: SessionUser) { } as any, db: {} as any, // userinfo path never queries the DB env: { - API_SERVER_URL: 'http://localhost:3000', + PUBLIC_URL: 'http://localhost:3000', AUTH_UI_URL: 'https://accounts.airi.build/ui', ADDITIONAL_TRUSTED_ORIGINS: [], } as any, - configKV: createConfigKV(), + authConfig: createAuthConfig(), rateLimitMetrics: null, } diff --git a/server/apps/api/src/libs/auth-plugins/steam.test.ts b/server/apps/auth/src/tests/steam.test.ts similarity index 98% rename from server/apps/api/src/libs/auth-plugins/steam.test.ts rename to server/apps/auth/src/tests/steam.test.ts index 52dfbc34d..1a878693c 100644 --- a/server/apps/api/src/libs/auth-plugins/steam.test.ts +++ b/server/apps/auth/src/tests/steam.test.ts @@ -2,10 +2,10 @@ import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { mockDB } from '../mock-db' -import { steam } from './steam' +import * as schema from '@proj-airi/auth-shared' -import * as schema from '../../schemas' +import { steam } from '../steam' +import { createTestDatabase } from './mock-db' /** Test fixture: arbitrary valid-format SteamID64 used in fake OpenID callbacks. */ const STEAM_ID = '76561198012345678' @@ -58,7 +58,7 @@ function buildCallbackQuery(state: string, steamId = STEAM_ID): string { } async function createTestAuth() { - const db = await mockDB(schema) + const db = await createTestDatabase() return betterAuth({ database: drizzleAdapter(db, { provider: 'pg', schema }), secret: 'test-secret', diff --git a/server/apps/api/src/scripts/auth.ts b/server/apps/auth/src/tooling/auth-config.ts similarity index 56% rename from server/apps/api/src/scripts/auth.ts rename to server/apps/auth/src/tooling/auth-config.ts index 4f8e136ed..26bb4bdbc 100644 --- a/server/apps/api/src/scripts/auth.ts +++ b/server/apps/auth/src/tooling/auth-config.ts @@ -1,13 +1,13 @@ import process from 'node:process' -import { createAuth } from '../libs/auth' -import { createDrizzle } from '../libs/db' -import { parseEnv } from '../libs/env' +import { createAuth } from '../auth' +import { createAuthDrizzle } from '../db' +import { parseAuthEnv } from '../env' -const env = parseEnv(process.env) +const env = parseAuthEnv(process.env) // NOTICE: // `better-auth generate` only introspects the auth instance's schema — it never // fires the email callbacks. Pass no EmailService; createAuth's email-aware // callbacks throw if invoked, but introspection never reaches them. -export default createAuth(createDrizzle(env).db, env) +export default createAuth(createAuthDrizzle(env).db, env) diff --git a/server/apps/auth/tsconfig.json b/server/apps/auth/tsconfig.json new file mode 100644 index 000000000..bc4c2e358 --- /dev/null +++ b/server/apps/auth/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "composite": true, + "target": "ESNext", + "lib": [ + "ESNext" + ], + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "types": [ + "vitest", + "node" + ], + "strict": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "instrumentation.ts" + ] +} diff --git a/server/apps/auth/vitest.config.ts b/server/apps/auth/vitest.config.ts new file mode 100644 index 000000000..f973f04fe --- /dev/null +++ b/server/apps/auth/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + fileParallelism: false, + globals: true, + hookTimeout: 60_000, + maxWorkers: 1, + }, +}) diff --git a/server/dev/caddy/Caddyfile b/server/dev/caddy/Caddyfile new file mode 100644 index 000000000..2f3f1a317 --- /dev/null +++ b/server/dev/caddy/Caddyfile @@ -0,0 +1,11 @@ +:3000 { + route { + @internal path /internal /internal/* + respond @internal 404 + + @auth path /api/auth /api/auth/* /auth /auth/* /.well-known/oauth-authorization-server/api/auth + reverse_proxy @auth auth:3000 + + reverse_proxy api:3000 + } +} diff --git a/server/docker-compose.yaml b/server/docker-compose.yaml new file mode 100644 index 000000000..b25df8206 --- /dev/null +++ b/server/docker-compose.yaml @@ -0,0 +1,98 @@ +name: proj-airi-backend + +services: + db: + image: ghcr.io/tensorchord/vchord-postgres:pg18-v1.0.0 + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: example-PAssw0rd-xHjDYR.b7N + ports: + - '127.0.0.1:5435:5432' + volumes: + - ./apps/api/sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + - db_data:/var/lib/postgresql + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB'] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + ports: + - '127.0.0.1:6379:6379' + volumes: + - redis_data:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 5s + retries: 10 + + api: + build: + context: .. + dockerfile: server/apps/api/Dockerfile + command: ['pnpm', '-F', '@proj-airi/api-server', 'start'] + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + env_file: + - path: ./apps/api/.env + required: false + - path: ./apps/api/.env.local + required: false + environment: + API_SERVER_URL: http://localhost:6112 + AUTH_SERVER_INTERNAL_URL: http://auth:3000 + AUTH_SERVER_URL: http://localhost:6112 + DATABASE_URL: postgres://postgres:example-PAssw0rd-xHjDYR.b7N@db:5432/postgres + REDIS_URL: redis://redis:6379 + healthcheck: + test: ['CMD-SHELL', 'wget -qO- http://localhost:3000/readyz >/dev/null'] + interval: 10s + timeout: 5s + retries: 10 + + auth: + build: + context: .. + dockerfile: server/apps/auth/Dockerfile + command: ['pnpm', '-F', '@proj-airi/auth-server', 'start'] + depends_on: + api: + condition: service_healthy + env_file: + - path: ./apps/api/.env + required: false + - path: ./apps/api/.env.local + required: false + environment: + DATABASE_URL: postgres://postgres:example-PAssw0rd-xHjDYR.b7N@db:5432/postgres + PUBLIC_URL: http://localhost:6112 + REDIS_URL: redis://redis:6379 + RESOURCE_SERVER_URL: http://api:3000 + healthcheck: + test: ['CMD-SHELL', 'wget -qO- http://localhost:3000/readyz >/dev/null'] + interval: 10s + timeout: 5s + retries: 10 + + caddy: + image: caddy:2-alpine + depends_on: + api: + condition: service_healthy + auth: + condition: service_healthy + ports: + - '127.0.0.1:6112:3000' + volumes: + - ./dev/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + +volumes: + db_data: + redis_data: diff --git a/server/docker-compose.yml b/server/docker-compose.yml deleted file mode 100644 index b62cb62cd..000000000 --- a/server/docker-compose.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: proj-airi-api - -services: - db: - image: ghcr.io/tensorchord/vchord-postgres:pg18-v1.0.0 - environment: - - POSTGRES_DB=postgres - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=example-PAssw0rd-xHjDYR.b7N - ports: - - '5435:5432' - volumes: - - ./apps/api/sql/init.sql:/docker-entrypoint-initdb.d/init.sql - - db_data:/var/lib/postgresql - healthcheck: - test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB'] - interval: 5s - timeout: 5s - retries: 10 - - redis: - image: redis:7-alpine - ports: - - '6379:6379' - volumes: - - redis_data:/data - healthcheck: - test: ['CMD', 'redis-cli', 'ping'] - interval: 5s - timeout: 5s - retries: 10 - - api: - build: - context: .. - dockerfile: server/apps/api/Dockerfile - command: ['pnpm', '-F', '@proj-airi/api-server', 'run', 'server', 'api'] - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - env_file: - - path: ./apps/api/.env - required: false - - path: ./apps/api/.env.local - required: false - environment: - DATABASE_URL: postgresql://postgres:example-PAssw0rd-xHjDYR.b7N@db:5432/postgres - REDIS_URL: redis://redis:6379 - ports: - - '6112:3000' - healthcheck: - test: ['CMD', 'node', '-e', "fetch('http://localhost:3000/livez').then(response => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"] - interval: 10s - timeout: 5s - retries: 5 - -volumes: - db_data: - driver: local - redis_data: - driver: local diff --git a/server/packages/auth-shared/README.md b/server/packages/auth-shared/README.md new file mode 100644 index 000000000..d5b1b733f --- /dev/null +++ b/server/packages/auth-shared/README.md @@ -0,0 +1,17 @@ +# `@proj-airi/auth-shared` + +Neutral authentication contracts shared by the resource API and the standalone auth service. + +## Use it for + +- The Better Auth-owned PostgreSQL schema. +- The authenticated principal/session shape exchanged inside server code. +- Authorization policy that must remain identical in both processes, such as ban expiry handling. + +## Do not use it for + +- Better Auth runtime construction or HTTP routes. +- Service environment parsing, database pools, Redis, email, or telemetry. +- Importing either `server/apps/api` or `server/apps/auth`. + +Keeping this package free of runtime composition lets both applications depend on the same protocol without depending on each other. diff --git a/server/packages/auth-shared/package.json b/server/packages/auth-shared/package.json new file mode 100644 index 000000000..0f9484033 --- /dev/null +++ b/server/packages/auth-shared/package.json @@ -0,0 +1,14 @@ +{ + "name": "@proj-airi/auth-shared", + "type": "module", + "version": "0.11.3", + "private": true, + "description": "Shared authentication schema and principal contracts for AIRI services", + "exports": "./src/index.ts", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "drizzle-orm": "catalog:" + } +} diff --git a/server/packages/auth-shared/src/index.ts b/server/packages/auth-shared/src/index.ts new file mode 100644 index 000000000..7ca541291 --- /dev/null +++ b/server/packages/auth-shared/src/index.ts @@ -0,0 +1,2 @@ +export * from './schema' +export * from './session' diff --git a/server/apps/api/src/schemas/accounts.ts b/server/packages/auth-shared/src/schema.ts similarity index 100% rename from server/apps/api/src/schemas/accounts.ts rename to server/packages/auth-shared/src/schema.ts diff --git a/server/packages/auth-shared/src/session.ts b/server/packages/auth-shared/src/session.ts new file mode 100644 index 000000000..765a48581 --- /dev/null +++ b/server/packages/auth-shared/src/session.ts @@ -0,0 +1,40 @@ +/** Authenticated principal exposed to AIRI resource handlers. */ +export interface AuthSession { + user: { + id: string + name: string + email: string + emailVerified: boolean + image?: string | null + role?: string | null + banned?: boolean | null + banReason?: string | null + banExpires?: Date | null + lastSeenAt?: Date | null + createdAt: Date + updatedAt: Date + } + session: { + id: string + token: string + userId: string + expiresAt: Date + createdAt: Date + updatedAt: Date + ipAddress?: string | null + userAgent?: string | null + impersonatedBy?: string | null + } +} + +/** + * Evaluates Better Auth's persisted ban fields without requiring its runtime. + * Expired temporary bans are treated as inactive on stateless JWT paths. + */ +export function isUserBannedNow(user: { banned?: boolean | null, banExpires?: Date | string | null }): boolean { + if (!user.banned) + return false + if (user.banExpires == null) + return true + return new Date(user.banExpires).getTime() > Date.now() +} diff --git a/server/packages/auth-shared/tsconfig.json b/server/packages/auth-shared/tsconfig.json new file mode 100644 index 000000000..989acd051 --- /dev/null +++ b/server/packages/auth-shared/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "lib": [ + "ESNext" + ], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts index ace835de9..0547c9922 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { projects: [ + 'server/apps/auth', 'server/apps/api', 'apps/ui-server-auth', 'apps/stage-tamagotchi',