mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
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:
co-authored by
autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
parent
a9906bf13b
commit
22b5249c64
@@ -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:
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ Concise but detailed reference for contributors working across the `moeru-ai/air
|
||||
|
||||
## Structure & Responsibilities
|
||||
|
||||
- **Hosted backend** (`server/`)
|
||||
- `server/apps/api`: Hono resource API and business domains.
|
||||
- `server/apps/auth`: standalone Better Auth and OIDC service.
|
||||
- `server/packages`: backend-private schema and Node infrastructure packages.
|
||||
- `server/dev/caddy`: local-only Auth/API edge routing.
|
||||
- `server/docker-compose.yaml`: complete local backend stack.
|
||||
- **Apps**
|
||||
- `apps/stage-web`: Web app; composables/stores in `src/composables`, `src/stores`; pages in `src/pages`; devtools in `src/pages/devtools`; router config via `vite.config.ts`.
|
||||
- `apps/stage-tamagotchi`: Electron app; renderer pages in `src/renderer/pages`; devtools in `src/renderer/pages/devtools`; settings layout at `src/renderer/layouts/settings.vue`; router config via `electron.vite.config.ts`.
|
||||
@@ -49,6 +55,7 @@ Concise but detailed reference for contributors working across the `moeru-ai/air
|
||||
- `packages/stage-shared`: Shared logic across stage-ui, stage-ui-three, stage-web, stage-tamagotchi.
|
||||
- `packages/ui`: Standardized primitives (inputs/textarea/buttons/layout) built on reka-ui.
|
||||
- `packages/i18n`: All translations.
|
||||
- Hosted backend: `server/apps/api`, `server/apps/auth`, `server/packages`, and local tooling under `server/dev`.
|
||||
- Server channel: `packages/server-runtime`, `packages/server-sdk`, `packages/server-shared` (power `services/` and `plugins/`).
|
||||
- Legacy desktop: `crates/` (old Tauri; Electron is current).
|
||||
- Pages: `packages/stage-pages` (shared bases); `apps/stage-web/src/pages` and `apps/stage-tamagotchi/src/renderer/pages` for app-specific pages; devtools live in each app’s `.../pages/devtools`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+136
-18
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import Redis from 'ioredis'
|
||||
|
||||
export function createRedis(url: string): Redis {
|
||||
return new Redis(url, { lazyConnect: true })
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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,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,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,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')
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
+8
-10
@@ -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
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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 }),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
+54
-47
@@ -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', () => {
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
+37
-17
@@ -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
-1
@@ -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', () => {
|
||||
+9
-14
@@ -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,
|
||||
}
|
||||
|
||||
+4
-4
@@ -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)
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
fileParallelism: false,
|
||||
globals: true,
|
||||
hookTimeout: 60_000,
|
||||
maxWorkers: 1,
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ESNext"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { defineConfig } from 'vitest/config'
|
||||
export default defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
'server/apps/auth',
|
||||
'server/apps/api',
|
||||
'apps/ui-server-auth',
|
||||
'apps/stage-tamagotchi',
|
||||
|
||||
Reference in New Issue
Block a user