196 lines
15 KiB
Markdown
196 lines
15 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec Delta: api-plugin-auth (add-api-plugin-auth)
|
|
|
|
**Package**: `@suaveplan/api-plugin-auth`
|
|
**Status**: ACCEPTED
|
|
|
|
## ADDED Requirements
|
|
|
|
### Requirement: createAuthPlugin returns a Zod-validated PluginDescriptor
|
|
|
|
The package SHALL export `createAuthPlugin(options): PluginDescriptor<AuthPluginConfig>` that validates `options` against `AuthPluginOptionsSchema` (Zod) at the factory boundary. Invalid input SHALL raise `AuthError(AUTH_INVALID_INPUT)` carrying the `ZodError` as `cause`. The returned descriptor SHALL declare `name: "auth"`, the resolved `configSchema`, and the 4-phase lifecycle hooks (`onRegister`, `onInit`, `onReady`, `onClose`).
|
|
|
|
#### Scenario: factory produces a valid descriptor
|
|
|
|
- **WHEN** `createAuthPlugin({ betterAuth: { /* valid */ } })` is called
|
|
- **THEN** the returned descriptor satisfies `PluginDescriptor<AuthPluginConfig>` with `name === "auth"` and all 4 lifecycle hooks defined
|
|
|
|
#### Scenario: invalid options raise AUTH_INVALID_INPUT
|
|
|
|
- **WHEN** `createAuthPlugin({} as never)` is called (missing `betterAuth`)
|
|
- **THEN** `AuthError(AUTH_INVALID_INPUT)` is thrown with `cause instanceof ZodError`
|
|
|
|
**Verified by:** `src/plugin/plugin.test.ts::"createAuthPlugin returns valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise AUTH_INVALID_INPUT"`
|
|
|
|
---
|
|
|
|
### Requirement: onInit applies the bundled SurrealQL schema (plug-and-play point 1)
|
|
|
|
The descriptor's `onInit(ctx)` hook SHALL resolve `migrationRunner` (preferred) or `db` (fallback) from `ctx`'s `ServiceRegistry`. When `options.applySchemaOnInit === true` (default), it SHALL run `BETTER_AUTH_SURREAL_SCHEMA` (re-exported from `@suaveplan/better-auth-surrealdb/schema`) against the resolved adapter before constructing the Better-Auth instance. When neither service is registered AND `applySchemaOnInit === true`, `onInit` SHALL throw `AuthError(AUTH_SERVICE_NOT_AVAILABLE)` listing the missing service names. When `applySchemaOnInit === false`, the schema apply step SHALL be skipped (operator manages migrations externally).
|
|
|
|
#### Scenario: schema applied via migration runner when registered
|
|
|
|
- **WHEN** `migrationRunner` is in `ServiceRegistry` and the plugin initializes
|
|
- **THEN** `migrationRunner.run([{ name: "better-auth", content: BETTER_AUTH_SURREAL_SCHEMA, order: 0 }])` is awaited successfully AND Better-Auth construction follows
|
|
|
|
#### Scenario: schema applied via db fallback when no runner
|
|
|
|
- **WHEN** only `db` (SurrealAdapter) is registered and the plugin initializes
|
|
- **THEN** `applyBetterAuthSchema(db)` is awaited successfully before Better-Auth construction
|
|
|
|
#### Scenario: applySchemaOnInit: false skips the step
|
|
|
|
- **WHEN** `createAuthPlugin({ betterAuth, applySchemaOnInit: false })` initializes with NEITHER service registered
|
|
- **THEN** init completes without raising (Better-Auth construction still requires `db`; if that's absent the error is `AUTH_SERVICE_NOT_AVAILABLE`)
|
|
|
|
**Verified by:** `src/plugin/on-init.test.ts::"applies schema via migrationRunner when registered"` AND `src/plugin/on-init.test.ts::"applies schema via db fallback when no runner"` AND `src/plugin/on-init.test.ts::"applySchemaOnInit: false skips schema apply"` AND `src/plugin/on-init.test.ts::"missing services raise AUTH_SERVICE_NOT_AVAILABLE"`
|
|
|
|
---
|
|
|
|
### Requirement: onInit registers routes via definePluginRoutes (plug-and-play point 3)
|
|
|
|
After schema apply and Better-Auth construction, `onInit` SHALL resolve `openAPIRegistry` from `ServiceRegistry` and call `definePluginRoutes(buildAuthRoutes(auth), { app: ctx.app, registry: openAPIRegistry, pluginName: "auth" })` so the host's unified OpenAPI 3.1 document at `/openapi.json` includes every auth route with Zod-typed request/response schemas. Routes mount at `options.routePrefix` (default `/auth`). When `openAPIRegistry` is absent, `onInit` SHALL log a warning and mount routes via Hono directly (OpenAPI doc will not include them, but the runtime surface still works).
|
|
|
|
#### Scenario: routes registered in both Hono and OpenAPIRegistry
|
|
|
|
- **WHEN** an `OpenAPIRegistry` is in `ServiceRegistry` and the plugin initializes
|
|
- **THEN** the resulting `registry.getRoutesByPlugin("auth")` array contains every route from `buildAuthRoutes` AND every route is also reachable via the Hono app
|
|
|
|
#### Scenario: routePrefix collision raises AUTH_ROUTE_PREFIX_COLLISION
|
|
|
|
- **WHEN** another plugin has already registered routes under the same prefix and the plugin initializes
|
|
- **THEN** `AuthError(AUTH_ROUTE_PREFIX_COLLISION)` is thrown with `context.prefix` set to the offending value
|
|
|
|
**Verified by:** `src/plugin/on-init.test.ts::"routes registered in both Hono and OpenAPIRegistry"` AND `src/plugin/on-init.test.ts::"routePrefix collision raises AUTH_ROUTE_PREFIX_COLLISION"` AND `tests/integration/openapi-doc.test.ts::"served /openapi.json includes auth routes with Zod-typed schemas"`
|
|
|
|
---
|
|
|
|
### Requirement: Session middleware sets typed c.get("user") on every request
|
|
|
|
`onInit` SHALL register a Hono middleware (via `ctx.app.use("*", ...)`) that on every request: (a) calls `auth.api.getSession(c.req.raw)`, (b) maps the result via `options.principalMapping ?? mapBetterAuthUserToPrincipal` to a `Principal`, (c) calls `c.set("user", principal)` and `c.set("session", session)`. Failures during session resolution SHALL set `c.set("user", null)` AND raise `AuthError(AUTH_SESSION_RESOLVE_FAILED)` only at the route level via `requireAuth` (silent at the middleware level — so anonymous routes still work). The package SHALL augment Hono's `ContextVariableMap` with `user: Principal | null` and `session: Session | null`.
|
|
|
|
#### Scenario: anonymous request has c.get("user") === null
|
|
|
|
- **WHEN** a request with no auth cookie/header reaches a handler
|
|
- **THEN** `c.get("user")` returns `null` AND the handler runs (no global 401)
|
|
|
|
#### Scenario: authenticated request has typed Principal
|
|
|
|
- **WHEN** a request with a valid Better-Auth cookie reaches a handler
|
|
- **THEN** `c.get("user")` returns a `Principal` with `id`, `email`, `emailVerified`, `roles`
|
|
|
|
**Verified by:** `src/middleware/session.test.ts::"anonymous request has null user"` AND `src/middleware/session.test.ts::"authenticated request has typed Principal"` AND `src/middleware/session.spec-conformance.test.ts::"ContextVariableMap augmented with user + session"`
|
|
|
|
---
|
|
|
|
### Requirement: requireAuth / requireRole / requireEmailVerified middleware
|
|
|
|
The package SHALL export three middleware factories:
|
|
|
|
- `requireAuth()` — reads `c.get("user")`; null → throws `AuthError(AUTH_UNAUTHENTICATED)` (statusCode 401). Returns the typed `Principal` to downstream handlers.
|
|
- `requireRole(...roles: string[])` — depends on `requireAuth` upstream. Reads `c.get("user")?.roles`; no intersection with `roles` → throws `AuthError(AUTH_INSUFFICIENT_ROLE)` (403) with `context.required: roles`.
|
|
- `requireEmailVerified()` — depends on `requireAuth` upstream. `c.get("user")?.emailVerified === false` → throws `AuthError(AUTH_EMAIL_NOT_VERIFIED)` (403).
|
|
|
|
#### Scenario: requireAuth on anonymous request raises 401
|
|
|
|
- **WHEN** `requireAuth()` runs on a request with `c.get("user") === null`
|
|
- **THEN** the middleware throws `AuthError(AUTH_UNAUTHENTICATED)` which the error-handler middleware translates to HTTP 401
|
|
|
|
#### Scenario: requireRole succeeds when intersection non-empty
|
|
|
|
- **WHEN** `requireRole("admin", "owner")` runs on a request with `principal.roles === ["owner"]`
|
|
- **THEN** the middleware passes (no throw)
|
|
|
|
#### Scenario: requireEmailVerified rejects when emailVerified is false
|
|
|
|
- **WHEN** `requireEmailVerified()` runs on a request whose principal has `emailVerified: false`
|
|
- **THEN** the middleware throws `AuthError(AUTH_EMAIL_NOT_VERIFIED)`
|
|
|
|
**Verified by:** `src/middleware/require-auth.test.ts::"raises AUTH_UNAUTHENTICATED on null user"` AND `src/middleware/require-auth.test.ts::"passes typed Principal downstream"` AND `src/middleware/require-role.test.ts::"intersects roles correctly"` AND `src/middleware/require-role.test.ts::"raises AUTH_INSUFFICIENT_ROLE on no intersection"` AND `src/middleware/require-email-verified.test.ts::"raises AUTH_EMAIL_NOT_VERIFIED when false"`
|
|
|
|
---
|
|
|
|
### Requirement: authService registered in ServiceRegistry with typed slot
|
|
|
|
`onInit` SHALL call `ctx.registerService("authService", { getCurrentPrincipal, getBetterAuthInstance, getSession })`. The package SHALL augment `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` with `authService: AuthService` so downstream plugins can call `ctx.getService("authService")` with full type inference.
|
|
|
|
#### Scenario: downstream plugin reads authService with type safety
|
|
|
|
- **WHEN** a sibling plugin's `onInit(ctx)` calls `ctx.getService("authService")`
|
|
- **THEN** the return type is `AuthService | undefined` (no cast) AND the runtime value exposes the 3 methods
|
|
|
|
**Verified by:** `src/services/auth-service.spec-conformance.test.ts::"ServiceRegistry slot typed as AuthService"` AND `src/services/auth-service.test.ts::"getCurrentPrincipal returns c.get('user')"` AND `src/services/auth-service.test.ts::"getSession proxies to Better-Auth"`
|
|
|
|
---
|
|
|
|
### Requirement: 9 OpenAPI routes built with RouteBuilder + Zod end-to-end (plug-and-play points 2 + 3)
|
|
|
|
The package SHALL export `buildAuthRoutes(auth: BetterAuthInstance): PluginRouteEntry[]` returning route entries for: `POST /sign-up`, `POST /sign-in`, `POST /sign-out`, `GET /session`, `POST /forgot-password`, `POST /reset-password`, `POST /verify-email`, `GET /oauth/:provider/start`, `GET /oauth/:provider/callback`. Each entry SHALL use `RouteBuilder` from `@suaveplan/api-openapi` (preserving Zod type inference through `.body/.query/.params/.response`). Body and query schemas SHALL be exported at `./schemas/routes` so downstream callers consume the same Zod types.
|
|
|
|
#### Scenario: every route has typed body/query/response
|
|
|
|
- **WHEN** `buildAuthRoutes(stubAuth)` returns its 9 entries and each is `.build()`-inspected
|
|
- **THEN** each `RouteConfig` has a `request.body.content["application/json"].schema` OR `request.query` referencing the matching Zod schema from `./schemas/routes`, AND the success `responses[200]` schema is non-empty
|
|
|
|
#### Scenario: registered routes appear in OpenAPI doc
|
|
|
|
- **WHEN** the plugin initializes against a real OpenAPIRegistry + Hono app and `/openapi.json` is GET'd
|
|
- **THEN** the response includes paths `/auth/sign-up`, `/auth/sign-in`, `/auth/session`, …, each with the expected method and schema references
|
|
|
|
**Verified by:** `src/routes/build-routes.test.ts::"every route has typed body/query/response"` AND `src/routes/build-routes.spec-conformance.test.ts::"expectTypeOf RouteBuilder preserves Zod inference end-to-end"` AND `tests/integration/openapi-doc.test.ts::"served /openapi.json includes auth routes with schemas"`
|
|
|
|
---
|
|
|
|
### Requirement: Zod schemas exported at ./schemas/* for cross-plugin consumption (plug-and-play point 2)
|
|
|
|
The package SHALL export Zod schemas at dedicated subpaths so sibling plugins consume the same boundary types: `./schemas/principal` (PrincipalSchema, SessionSchema, AccountSchema), `./schemas/options` (AuthPluginOptionsSchema), `./schemas/routes` (per-route request/response schemas). Every schema MUST have a corresponding TypeScript type via `z.infer` exported alongside.
|
|
|
|
#### Scenario: sibling plugin imports PrincipalSchema from ./schemas/principal
|
|
|
|
- **WHEN** a downstream plugin imports `@suaveplan/api-plugin-auth/schemas/principal` and calls `PrincipalSchema.parse(payload)`
|
|
- **THEN** the parse succeeds for valid principals and produces a typed `Principal` value
|
|
|
|
#### Scenario: every routes-schemas export has a sibling .md
|
|
|
|
- **WHEN** `tests/dep-graph.test.ts` enumerates `./schemas/routes`-tree files
|
|
- **THEN** every `<name>.ts` (excluding index.ts/test files) has a sibling `<name>.md`
|
|
|
|
**Verified by:** `src/schemas/principal.test.ts::"PrincipalSchema parses valid principal"` AND `src/schemas/options.test.ts::"AuthPluginOptionsSchema rejects missing betterAuth"` AND `tests/dep-graph.test.ts::"every schemas/* .ts has a sibling .md"`
|
|
|
|
---
|
|
|
|
### Requirement: Every AUTH_* code is thrown from a production path
|
|
|
|
`AUTH_CODES` SHALL define exactly 10 codes (`AUTH_UNAUTHENTICATED`, `AUTH_INSUFFICIENT_ROLE`, `AUTH_EMAIL_NOT_VERIFIED`, `AUTH_SESSION_RESOLVE_FAILED`, `AUTH_PRINCIPAL_MAPPING_FAILED`, `AUTH_BETTER_AUTH_INIT_FAILED`, `AUTH_INVALID_INPUT`, `AUTH_ROUTE_PREFIX_COLLISION`, `AUTH_SCHEMA_APPLY_FAILED`, `AUTH_SERVICE_NOT_AVAILABLE`). Every code SHALL be raised by ≥1 production call site.
|
|
|
|
**Verified by:** `src/api-plugin-auth.spec-conformance.test.ts::"every AUTH_* code is thrown from a production path"`
|
|
|
|
---
|
|
|
|
### Requirement: OpenTelemetry instruments emit per operation
|
|
|
|
The package SHALL register counters `auth.sessions_resolved_total{outcome}`, `auth.role_check_total{outcome}`, `auth.signups_total`, `auth.signins_total{outcome}` and histogram `auth.session_resolve_duration_ms`. Every middleware + route emits both regardless of success/failure.
|
|
|
|
#### Scenario: counter + histogram emit per session resolve
|
|
|
|
- **WHEN** the session middleware runs against a real in-memory `MeterProvider`
|
|
- **THEN** both `auth.sessions_resolved_total` AND `auth.session_resolve_duration_ms` have observations
|
|
|
|
**Verified by:** `src/otel/instruments.test.ts::"counter + histogram emit per session resolve"` AND `src/otel/instruments.test.ts::"role-check counter emits on requireRole"`
|
|
|
|
---
|
|
|
|
### Requirement: Server-tier package shape, full 3-way plug-and-play
|
|
|
|
The package SHALL run in Bun + Node. `package.json` SHALL declare subpath exports per feature module (15 subpaths covering `./plugin/plugin`, `./middleware/*`, `./principal/principal`, `./services/auth-service`, `./schemas/principal`, `./schemas/options`, `./schemas/routes`, `./routes/routes`, `./schema/schema`, `./error-codes/error-codes`, `./otel/instruments`, `./types/types`). Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. No `export *` in feature barrels. 100/100/100/100 coverage. Integration test SHALL exercise: (a) plugin registered in a real `PluginRegistry`, (b) `.surql` schema applied via real `@suaveplan/api-migrations` against testcontainer, (c) routes registered in real `OpenAPIRegistry`, (d) full sign-up → sign-in → /session → sign-out flow.
|
|
|
|
#### Scenario: full integration boot
|
|
|
|
- **WHEN** `tests/integration/full-boot.test.ts` constructs a real `PluginRegistry` with `createAuthPlugin({ betterAuth })`, real `MigrationRunner` against SurrealDB testcontainer, real `OpenAPIRegistry`, then drives a sign-up → sign-in → GET /session → sign-out roundtrip
|
|
- **THEN** every step succeeds, the schema is applied (idempotent on second `start()`), and `GET /openapi.json` returns a doc containing the 9 auth routes
|
|
|
|
**Verified by:** `tests/integration/full-boot.test.ts::"full sign-up/sign-in/session/sign-out roundtrip against real SurrealDB"` AND `tests/integration/full-boot.test.ts::"schema apply is idempotent across restarts"` AND `tests/integration/openapi-doc.test.ts::"served doc lists all 9 auth routes"` AND `tests/dep-graph.test.ts::"every subpath export resolves"` AND `tests/dep-graph.test.ts::"every impl .ts has a sibling .md"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|