refactor(server): split independent auth service (#2202)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
RainbowBird
2026-08-12 23:33:58 +08:00
co-authored by autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
parent a9906bf13b
commit 22b5249c64
91 changed files with 2877 additions and 1658 deletions
+1 -1
View File
@@ -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:
+7
View File
@@ -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 apps `.../pages/devtools`.
@@ -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,
+1 -1
View File
@@ -67,7 +67,7 @@ const signOutError = shallowRef<string | null>(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(
+2
View File
@@ -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",
@@ -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,
+136 -18
View File
@@ -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':
+22 -16
View File
@@ -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.
+9 -8
View File
@@ -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.
+1
View File
@@ -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
+28 -42
View File
@@ -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`.
+1 -1
View File
@@ -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: {
+4 -11
View File
@@ -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:"
@@ -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
+1 -2
View File
@@ -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"
]
+36 -88
View File
@@ -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' })
})
})
+24 -82
View File
@@ -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<HonoEnv>()
.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<void> {
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<void>((resolve, reject) => {
server.once('close', () => resolve())
server.once('error', error => reject(error))
})
}
-52
View File
@@ -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('<role>')
.command('api', 'Start the HTTP/WebSocket API process')
.action(() => runApiServer())
cli.help()
return cli
}
async function main(): Promise<void> {
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)
})
}
+15 -99
View File
@@ -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<string>()
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 <email>` 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()),
@@ -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<T>(
dependencyName: string,
logger: ExternalDependencyLogger,
logger: Logger,
initialize: (attempt: number) => Promise<T>,
): Promise<T> {
let attempt = 0
-82
View File
@@ -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()
}
-5
View File
@@ -1,5 +0,0 @@
import Redis from 'ioredis'
export function createRedis(url: string): Redis {
return new Redis(url, { lazyConnect: true })
}
+48 -77
View File
@@ -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<typeof createRemoteJWKSet> | null = null
const cachedJWKS = new Map<string, ReturnType<typeof createRemoteJWKSet>>()
function getJWKS(env: Env): ReturnType<typeof createRemoteJWKSet> {
if (!cachedJWKS) {
cachedJWKS = createRemoteJWKSet(
new URL('/api/auth/jwks', env.API_SERVER_URL),
)
}
return cachedJWKS
function getJWKS(env: TokenIssuerEnv): ReturnType<typeof createRemoteJWKSet> {
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<typeof createRemoteJWKSet> {
* Still requires one findUserById call to build the full RequestAuthSession.
*/
async function resolveJWTAccessToken(
auth: AuthInstance,
env: Env,
db: Database,
env: TokenIssuerEnv,
accessToken: string,
): Promise<RequestAuthSession | null> {
): Promise<AuthSession | null> {
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<RequestAuthSession | null> {
const session = await auth.api.getSession({ headers })
if (session?.user && session?.session)
return session
): Promise<AuthSession | null> {
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<RequestAuthSession | null> {
const resolved = await resolveSessionIgnoringBan(auth, env, headers)
const resolved = testSession ?? await resolveJWTAccessToken(db, env, accessToken)
if (!resolved)
return null
+6 -75
View File
@@ -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<string, string> {
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([
@@ -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']> = {}): 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()
})
})
+26
View File
@@ -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<void> {
await runApiServer()
}
void main().catch((error: unknown) => {
process.stderr.write(`${errorMessageFrom(error) ?? 'Unknown API server error'}\n`)
process.exit(1)
})
+6 -18
View File
@@ -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<typeof createAuth>
/**
* 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<HonoEnv> {
export function sessionMiddleware(db: Database, env: Env): MiddlewareHandler<HonoEnv> {
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)
+1 -1
View File
@@ -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'
@@ -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,
)
@@ -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<CheckEmailIdentifierResult> {
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 }
}
-129
View File
@@ -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<Response> {
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<HonoEnv>()
.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)
})
}
@@ -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<HonoEnv>()
.get('/', (c) => {
const request = new URL(c.req.url)
return c.redirect(buildAuthUiUrl(env.AUTH_UI_URL, '/api/auth/oidc/electron-callback', request.search))
})
}
@@ -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<HonoEnv>()
.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] : [])
})
}
@@ -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<HonoEnv>()
.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)))
}
@@ -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',
})
})
})
@@ -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<ProductEventService, 'track'>
}) {
return new Hono<HonoEnv>()
.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 })
})
}
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
@@ -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
@@ -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')
+34
View File
@@ -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<void> {
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<void>((resolve, reject) => {
server.once('close', () => resolve())
server.once('error', error => reject(error))
})
}
@@ -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()
@@ -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
@@ -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<void>
}
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
}
@@ -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. */
+3 -3
View File
@@ -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
}
}
-97
View File
@@ -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()
}
+1 -72
View File
@@ -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<Env, 'API_SERVER_URL' | 'ADDITIONAL_TRUSTED_ORIGINS'>,
request?: Request,
): string[] {
const origins = new Set<string>()
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]
}
+1 -76
View File
@@ -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')
})
})
+22
View File
@@ -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"]
+51
View File
@@ -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.
+103
View File
@@ -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<string, string> = {}
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)))
}
+59
View File
@@ -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:"
}
}
@@ -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, 'AUTH_APPLE_CLIENT_ID' | 'AUTH_APPLE_APP_BUNDLE_IDENTIFIERS' | 'AUTH_APPLE_TEAM_ID' | 'AUTH_APPLE_KEY_ID' | 'AUTH_APPLE_PRIVATE_KEY_PEM'>,
env: Pick<AuthEnv, 'AUTH_APPLE_CLIENT_ID' | 'AUTH_APPLE_APP_BUNDLE_IDENTIFIERS' | 'AUTH_APPLE_TEAM_ID' | 'AUTH_APPLE_KEY_ID' | 'AUTH_APPLE_PRIVATE_KEY_PEM'>,
) {
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<void> {
@@ -338,7 +339,7 @@ async function hashClientSecret(secret: string): Promise<string> {
* Secrets are hashed before storage to match oauthProvider's default
* `storeClientSecret: "hashed"` mode.
*/
export async function seedTrustedClients(db: Database, env: Env): Promise<void> {
export async function seedTrustedClients(db: AuthDatabase, env: AuthEnv): Promise<void> {
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<Response>
api: {
getSession: (input: { headers: Headers }) => Promise<AuthSession | null>
getOAuthServerConfig: () => Promise<unknown>
getOpenIdConfig: () => Promise<unknown>
}
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<typeof createAuth>
// 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
}
+39
View File
@@ -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<typeof createAuthDrizzle>['db']
type AuthDrizzleEnv = Pick<AuthEnv, 'DATABASE_URL' | 'DB_POOL_MAX' | 'DB_POOL_IDLE_TIMEOUT_MS' | 'DB_POOL_CONNECTION_TIMEOUT_MS' | 'DB_POOL_KEEPALIVE_INITIAL_DELAY_MS'>
/**
* 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 }
}
@@ -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`
+94
View File
@@ -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<typeof AuthEnvSchema>
/** Parses only Auth-owned configuration; business-only secrets are ignored. */
export function parseAuthEnv(inputEnv: Record<string, string> | NodeJS.ProcessEnv): AuthEnv {
try {
return parse(AuthEnvSchema, inputEnv)
}
catch (err) {
useLogger().withError(err).error('Invalid auth environment variables')
exit(1)
}
}
+60
View File
@@ -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)
}
+26
View File
@@ -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<void> {
await runAuthServer()
}
void main().catch((error: unknown) => {
process.stderr.write(`${errorMessageFrom(error) ?? 'Unknown auth server error'}\n`)
process.exit(1)
})
@@ -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 <jwt>` 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
+157
View File
@@ -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<string>()
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]
}
+93
View File
@@ -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<string, SeverityNumber> = {
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<string, string | number | boolean>,
): 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 }),
},
})
}
+142
View File
@@ -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<number> {
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<AuthRateLimitConfig> {
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<typeof createAuthConfigService>
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<HonoEnv>) => 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<HonoEnv>({
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<HonoEnv>, 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
}
+61
View File
@@ -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<void>
trackAuthEvent: (input: AuthEventInput) => Promise<void>
}
/** 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')
}
},
}
}
+347
View File
@@ -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<string, ReturnType<typeof createRemoteJWKSet>>()
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<typeof createRemoteJWKSet> {
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<AuthEnv, 'PUBLIC_URL'>,
accessToken: string,
): Promise<AuthSession | null> {
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<AuthEnv, 'PUBLIC_URL'>,
headers: Headers,
): Promise<AuthSession | null> {
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<AuthEnv, 'PUBLIC_URL'>,
headers: Headers,
): Promise<AuthSession | null> {
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<HonoEnv>()
.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<HonoEnv>().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<AuthRoutesDeps, 'auth' | 'db' | 'env'>) {
return new Hono<HonoEnv>()
.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<Response> {
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<HonoEnv>()
.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)
})
}
+291
View File
@@ -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<T>(
dependencyName: string,
logger: Logger,
initialize: (attempt: number) => Promise<T>,
): Promise<T> {
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<HonoEnv>()
.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<ReturnType<typeof buildAuthApp>>['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<string, string | number | boolean>)
})
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<void> {
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<void>((resolvePromise, reject) => {
server.once('close', () => resolvePromise())
server.once('error', error => reject(error))
}).finally(runtime.stop)
}
+68
View File
@@ -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)
})
})
@@ -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', () => {
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import { parseAuthEnv } from '../env'
function baseAuthEnv(): Record<string, string> {
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)
})
})
+46
View File
@@ -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')
})
})
@@ -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<string, number> = {
AUTH_RATE_LIMIT_MAX: 1,
AUTH_RATE_LIMIT_WINDOW_SEC: 60,
}
import { createAuthConfigService } from '../rate-limit'
import { createAuthRoutes } from '../routes'
function createRedis(values: Record<string, string | null>): 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.
@@ -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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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"}')
})
})
@@ -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', () => {
@@ -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<string, number> = { 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,
}
@@ -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',
@@ -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)
+23
View File
@@ -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"
]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
fileParallelism: false,
globals: true,
hookTimeout: 60_000,
maxWorkers: 1,
},
})
+11
View File
@@ -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
}
}
+98
View File
@@ -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:
-63
View File
@@ -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
+17
View File
@@ -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.
+14
View File
@@ -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:"
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './schema'
export * from './session'
@@ -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()
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"lib": [
"ESNext"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts"
]
}
+1
View File
@@ -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',