Initial Commit
This commit is contained in:
146
openspec/archive/2026-05-18-add-api-plugin-auth/proposal.md
Normal file
146
openspec/archive/2026-05-18-add-api-plugin-auth/proposal.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Add @suaveplan/api-plugin-auth — Better-Auth as a definePlugin (3-way plug-and-play exemplar)
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-auth` (NEW, server-tier)
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
**Position**: foundation of the auth/user/vault triple — gates `add-api-plugin-user` + `add-api-plugin-vault`
|
||||
|
||||
- **Spec delta**: ./specs/api-plugin-auth/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
Genesis ships `@suaveplan/api` (orchestrator), `@suaveplan/api-plugin` (`definePlugin` foundation), `@suaveplan/api-openapi` (RouteBuilder + registry), and `@suaveplan/api-migrations` (per-dialect migration runner) as of commit 99a213ed. The pending `@suaveplan/better-auth-surrealdb` adapter (sibling change) plugs Better-Auth into SurrealDB and ships the bundled 17-table schema. This package wraps all of that into a single `definePlugin` consumer so apps composing `@suaveplan/api` get authentication by adding one entry to their plugins array.
|
||||
|
||||
The package is **the canonical exemplar of the 3-way plug-and-play contract** every `@suaveplan/api-plugin-*` package follows: a plugin owns its `.surql` schema (applied via injected MigrationRunner before `onInit` returns), its Zod schemas (request/response boundary validation typed end-to-end), and its OpenAPI route definitions (registered via `definePluginRoutes` so the host emits a unified 3.1 doc). User and vault plugins follow the same shape.
|
||||
|
||||
## 3-way plug-and-play contract (canonical from here on)
|
||||
|
||||
```text
|
||||
@suaveplan/api-plugin-auth/
|
||||
src/
|
||||
plugin/plugin.ts ← createAuthPlugin() factory (returns PluginDescriptor)
|
||||
schemas/<name>.ts ← Zod schemas at boundary (login body, register body, etc.)
|
||||
schemas/index.ts ← named re-exports for ./schemas subpath
|
||||
routes/<name>.ts ← OpenAPI RouteBuilder definitions
|
||||
routes/index.ts ← buildAuthRoutes(): PluginRouteEntry[]
|
||||
schema/schema.ts ← re-exports BETTER_AUTH_SURREAL_SCHEMA from the adapter
|
||||
|
||||
onInit lifecycle (called by PluginRegistry):
|
||||
1. resolve services: migrationRunner = ctx.getService("migrationRunner")
|
||||
openAPIRegistry = ctx.getService("openAPIRegistry")
|
||||
surrealAdapter = ctx.getService("db")
|
||||
2. apply .surql schema: await applyBetterAuthSchema(surrealAdapter)
|
||||
OR migrationRunner.run([{ name, content: BETTER_AUTH_SURREAL_SCHEMA, order: 0 }])
|
||||
3. construct Better-Auth: auth = createBetterAuth({ database: createBetterAuthSurrealAdapter({client: surrealAdapter}), ... })
|
||||
4. register services: ctx.registerService("authService", { getCurrentPrincipal, getBetterAuthInstance })
|
||||
5. register routes: definePluginRoutes(buildAuthRoutes(auth), { app: ctx.app, registry: openAPIRegistry, pluginName: "auth" })
|
||||
6. wire session middleware: ctx.app.use(async (c, next) => { c.set("user", await auth.api.getSession(c.req.raw)); await next(); })
|
||||
```
|
||||
|
||||
Every public input the plugin accepts (`AuthPluginOptions`, route bodies, route params) flows through a Zod schema. Every route is built with `RouteBuilder` so the OpenAPI doc the host serves at `/openapi.json` includes auth routes with full type inference. Every table the plugin touches is defined in `BETTER_AUTH_SURREAL_SCHEMA` (shipped by `@suaveplan/better-auth-surrealdb`) and applied at boot.
|
||||
|
||||
## What changes
|
||||
|
||||
Net-new package at `0.1.0`. ADDED:
|
||||
|
||||
### Plugin factory + descriptor surface
|
||||
|
||||
- `createAuthPlugin(options: AuthPluginOptions): PluginDescriptor<AuthPluginConfig>` — Zod-validated factory returning a `PluginDescriptor` from `@suaveplan/api-plugin`. The descriptor's lifecycle hooks implement the 6-step onInit flow above.
|
||||
- `AuthPluginOptions` (Zod-validated via `AuthPluginOptionsSchema`): `{ betterAuth: BetterAuthOptions; routePrefix?: string (default "/auth"); applySchemaOnInit?: boolean (default true); principalMapping?: (user, session) => Principal; logger?: LoggerLike }`. `betterAuth` is forwarded verbatim to Better-Auth's `createBetterAuth`.
|
||||
- `AuthPluginConfig` — the descriptor's `configSchema`-inferred shape (the resolved options).
|
||||
|
||||
### Principal type (canonical genesis identity)
|
||||
|
||||
- `Principal` — `{ id: string; email?: string; emailVerified: boolean; roles: readonly string[]; tenantId?: string; metadata?: Record<string, unknown> }`. Single canonical shape — every downstream plugin reads `c.get("user")` typed as `Principal | null` via Hono `ContextVariableMap` augmentation.
|
||||
- `mapBetterAuthUserToPrincipal(user, session)` — pure default mapping. Replaceable via `options.principalMapping`.
|
||||
|
||||
### Middleware
|
||||
|
||||
- `requireAuth()` — reads `c.get("user")`; null → `AuthError(AUTH_UNAUTHENTICATED)` (401). Returns the typed `Principal` to downstream handlers via Hono context narrowing.
|
||||
- `requireRole(...roles)` — depends on `requireAuth` upstream; checks `principal.roles` intersects `roles`; mismatch → `AuthError(AUTH_INSUFFICIENT_ROLE)` (403).
|
||||
- `requireEmailVerified()` — null email-verified → `AuthError(AUTH_EMAIL_NOT_VERIFIED)` (403).
|
||||
|
||||
### Service registry slot
|
||||
|
||||
- `authService` registered via `ctx.registerService("authService", ...)`. Surface: `{ getCurrentPrincipal(c: Context): Principal | null; getBetterAuthInstance(): ReturnType<typeof createBetterAuth>; getSession(req: Request): Promise<Session | null> }`.
|
||||
- ServiceRegistry module augmentation: `declare module "@suaveplan/api-plugin/service-registry" { interface ServiceRegistry { authService: AuthService } }`.
|
||||
|
||||
### Routes (the OpenAPI surface)
|
||||
|
||||
Better-Auth owns its own router for the account-lifecycle paths. This plugin mounts that router at `options.routePrefix` (default `/auth`) AND registers each route in the host's `OpenAPIRegistry` via `definePluginRoutes` so the unified spec at `/openapi.json` lists every auth endpoint with its Zod-typed request/response shapes:
|
||||
|
||||
- `POST /auth/sign-up` — body: `{ email, password, name? }`; response: `{ user: PrincipalSchema, session: SessionSchema }`
|
||||
- `POST /auth/sign-in` — body: `{ email, password }`; response: same
|
||||
- `POST /auth/sign-out` — empty body; response: `{ success: true }`
|
||||
- `GET /auth/session` — response: `{ user: PrincipalSchema | null, session: SessionSchema | null }`
|
||||
- `POST /auth/forgot-password` — body: `{ email }`; response: `{ sent: true }`
|
||||
- `POST /auth/reset-password` — body: `{ token, password }`; response: `{ success: true }`
|
||||
- `POST /auth/verify-email` — body: `{ token }`; response: `{ verified: true }`
|
||||
- `GET /auth/oauth/:provider/start` — query: `{ redirectTo? }`; response: 302 redirect
|
||||
- `GET /auth/oauth/:provider/callback` — query: `{ code, state }`; response: 302 redirect to `redirectTo`
|
||||
|
||||
(Subset of Better-Auth's full surface — the plugin proxies through whatever Better-Auth's `auth.router` exposes; the OpenAPI definitions cover the routes operators actually expose. The route list is generated from the Zod schemas at build time so it stays in sync with Better-Auth's evolving surface.)
|
||||
|
||||
### Zod schemas (boundary validation) — exposed at `./schemas/*`
|
||||
|
||||
- `./schemas/principal` — `PrincipalSchema`, `SessionSchema`, `AccountSchema`
|
||||
- `./schemas/options` — `AuthPluginOptionsSchema`
|
||||
- `./schemas/routes` — request/response schemas per route (`SignUpBodySchema`, `SignInBodySchema`, `ForgotPasswordBodySchema`, `ResetPasswordBodySchema`, `VerifyEmailBodySchema`, `OAuthCallbackQuerySchema`, …)
|
||||
|
||||
Downstream plugins (api-plugin-user, api-plugin-vault) import from `@suaveplan/api-plugin-auth/schemas/principal` so they share the canonical `Principal` type at the boundary — not just at the TypeScript type level.
|
||||
|
||||
### SurrealQL schema (DDL applied at boot)
|
||||
|
||||
- The plugin re-exports `BETTER_AUTH_SURREAL_SCHEMA` from `@suaveplan/better-auth-surrealdb/schema` at `./schema`.
|
||||
- `onInit` calls `applyBetterAuthSchema(surrealAdapter)` (or `migrationRunner.run(...)` when a runner is registered) before constructing the Better-Auth instance. Idempotent re-apply on restart (DDL uses `DEFINE TABLE OVERWRITE`).
|
||||
- Set `options.applySchemaOnInit: false` to disable auto-apply (operators who manage migrations externally).
|
||||
|
||||
### Errors + observability
|
||||
|
||||
- `AuthError extends SuaveplanError<AuthErrorCode>` via `defineCodes("AUTH", [...])`. 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` (raised when a required service like `migrationRunner` is absent).
|
||||
- OTel: counter `auth.sessions_resolved_total{outcome}`, histogram `auth.session_resolve_duration_ms`, counter `auth.role_check_total{outcome}`, counter `auth.signups_total`, counter `auth.signins_total{outcome}`.
|
||||
|
||||
### Subpath exports
|
||||
|
||||
`.`, `./plugin/plugin`, `./middleware/require-auth`, `./middleware/require-role`, `./middleware/require-email-verified`, `./principal/principal`, `./services/auth-service`, `./schemas/principal`, `./schemas/options`, `./schemas/routes`, `./routes/routes`, `./schema/schema`, `./error-codes/error-codes`, `./otel/instruments`, `./types/types`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Frontend UI** (registration form, OAuth login button, MFA challenge UI). Backend only.
|
||||
- **Custom Better-Auth plugins beyond core + organization.** The factory accepts `betterAuth: BetterAuthOptions` verbatim — operators wire their own plugins in there.
|
||||
- **Organizations / teams / API-keys / passkeys CRUD wrapping.** Better-Auth's bundled plugins handle these; this package mounts them but doesn't add genesis-side routes for them (a follow-up `api-plugin-organizations` / `api-plugin-passkeys` would do that).
|
||||
- **Impersonation / GDPR export / audit endpoints.** Separate follow-up plugins.
|
||||
- **Custom email templates / SMS gateway / avatar resizing.** Better-Auth integrates with the operator's mailer; we don't wrap that.
|
||||
- **Multi-tenant tenant-id auto-injection.** Tenants are owned by `@suaveplan/api-tenant`; `principal.tenantId` is filled by the tenant middleware when wired upstream.
|
||||
- **JWT-issued auth.** Better-Auth's JWT plugin is opt-in via `options.betterAuth`. This package does not add a custom JWT surface.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This package is **genesis-original** (no upstream port). Design intent draws from:
|
||||
|
||||
- The genesis `@suaveplan/api-plugin` `PluginContext` + `ServiceRegistry` shape (shipped 0f338653).
|
||||
- The genesis `@suaveplan/api-openapi` `definePluginRoutes` + `RouteBuilder` (shipped 99a213ed).
|
||||
- The genesis `@suaveplan/api-migrations` `MigrationRunner` + `createSurrealqlAdapter` (shipped 99a213ed).
|
||||
- The genesis `@suaveplan/better-auth-surrealdb` adapter + bundled schema (sibling proposal).
|
||||
- Better-Auth's documented API (`createBetterAuth`, `auth.api.getSession`, route mounting, plugin composition).
|
||||
- The legacy `enterprise-api-plugin-auth` proposal's middleware shape (`requireAuth`, `requireRole`, `Principal` contract — verbatim).
|
||||
|
||||
| Requirement | Source |
|
||||
|---|---|
|
||||
| `definePlugin` consumer shape | `@suaveplan/api-plugin` shipped 0f338653 |
|
||||
| `Principal` contract | legacy `enterprise-api-plugin-auth.md` middleware section (verbatim) |
|
||||
| `requireAuth` / `requireRole` middleware | legacy + Hono middleware idiom |
|
||||
| `definePluginRoutes` route registration | `@suaveplan/api-openapi` shipped 99a213ed |
|
||||
| Better-Auth integration | https://better-auth.com |
|
||||
| 3-way plug-and-play contract (.surql + Zod + OpenAPI) | genesis architectural principle (this file) |
|
||||
| SurrealQL schema bundling | `@suaveplan/better-auth-surrealdb/schema` re-export |
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
None today — net-new. Will be consumed by `add-api-plugin-user` (depends on `Principal` + ServiceRegistry slot), `add-api-plugin-vault` (depends on `requireAuth` + `Principal`), and every future plugin that needs an authenticated principal.
|
||||
|
||||
### Catalog additions required
|
||||
|
||||
- `better-auth` ^1.4.0 (added by sibling change `add-better-auth-surrealdb` — no duplication needed; reuse the same `catalog:auth` entry).
|
||||
@@ -0,0 +1,195 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-plugin-auth (add-api-plugin-auth)
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-auth`
|
||||
**Status**: PROPOSED
|
||||
|
||||
## 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 -->
|
||||
59
openspec/archive/2026-05-18-add-api-plugin-auth/tasks.md
Normal file
59
openspec/archive/2026-05-18-add-api-plugin-auth/tasks.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Tasks — add-api-plugin-auth
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [x] 1.1 Create `packages/api/api-plugin-auth/` with `src/{plugin,middleware,principal,services,schemas,routes,schema,error-codes,otel,types}/` + `tests/{setup,integration}/`
|
||||
- [x] 1.2 `package.json` v0.1.0. Deps: `@suaveplan/api-plugin` `@suaveplan/api-openapi` `@suaveplan/api-migrations` `@suaveplan/better-auth-surrealdb` `@suaveplan/surrealdb` `@suaveplan/error` `@suaveplan/types` `tslib`, `hono` `@hono/zod-openapi` (`catalog:server`), `zod` (`catalog:forms`), `@opentelemetry/api`, `better-auth` (`catalog:auth`)
|
||||
- [x] 1.3 15 subpath exports per the proposal
|
||||
- [x] 1.4 tsconfigs + vite + vitest (100/100/100/100, testTimeout 60000 for integration)
|
||||
- [x] 1.5 `tests/setup/polyfills.ts`
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 `src/error-codes.{ts,test.ts,md,index.ts}` — 10 AUTH.* codes + `AuthError`
|
||||
- [x] 2.2 `src/principal/principal.{ts,test.ts,md,index.ts}` — `Principal` interface + `mapBetterAuthUserToPrincipal` default
|
||||
- [x] 2.3 `src/schemas/principal.{ts,test.ts,md,index.ts}` — `PrincipalSchema`, `SessionSchema`, `AccountSchema` (Zod)
|
||||
- [x] 2.4 `src/schemas/options.{ts,test.ts,md,index.ts}` — `AuthPluginOptionsSchema`
|
||||
- [x] 2.5 `src/schemas/routes/<name>.{ts,test.ts,md}` — per-route request/response Zod schemas (sign-up, sign-in, sign-out, session, forgot-password, reset-password, verify-email, oauth-start, oauth-callback) + `schemas/routes/index.ts`
|
||||
- [x] 2.6 `src/otel/instruments.{ts,test.ts,md,index.ts}` — counters + histogram
|
||||
- [x] 2.7 `src/services/auth-service.{ts,test.ts,md,index.ts}` — `AuthService` interface + impl; `ServiceRegistry` module augmentation
|
||||
- [x] 2.8 `src/middleware/require-auth.{ts,test.ts,md,index.ts}` — `requireAuth()` factory
|
||||
- [x] 2.9 `src/middleware/require-role.{ts,test.ts,md}` — `requireRole(...)` factory
|
||||
- [x] 2.10 `src/middleware/require-email-verified.{ts,test.ts,md}` — `requireEmailVerified()` factory
|
||||
- [x] 2.11 `src/middleware/session.{ts,test.ts,md}` — global session-resolver middleware (sets `c.set("user")` + `c.set("session")`; Hono `ContextVariableMap` augmentation)
|
||||
- [x] 2.12 `src/routes/build-routes.{ts,test.ts,md,index.ts}` — `buildAuthRoutes(auth)` returning `PluginRouteEntry[]` via `RouteBuilder`
|
||||
- [x] 2.13 `src/schema/schema.{ts,md,index.ts}` — re-export `BETTER_AUTH_SURREAL_SCHEMA` + `applyBetterAuthSchema` from `@suaveplan/better-auth-surrealdb`
|
||||
- [x] 2.14 `src/plugin/plugin.{ts,test.ts,md,index.ts}` — `createAuthPlugin(options)` factory; 6-step `onInit` lifecycle (resolve services → apply schema → construct Better-Auth → register service → register routes → install session middleware); `onClose` releases DB
|
||||
- [x] 2.15 `src/plugin/on-init.test.ts` — covers each of the 6 onInit steps + error paths
|
||||
- [x] 2.16 `src/index.ts` — named re-exports only
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 Co-located `*.test.ts` per spec scenario
|
||||
- [x] 3.2 `src/api-plugin-auth.spec-conformance.test.ts` — every AUTH.* code thrown; `expectTypeOf` on `Principal`, `AuthService`, `ContextVariableMap` augmentation
|
||||
- [x] 3.3 `tests/integration/full-boot.test.ts` — real `PluginRegistry` + `MigrationRunner` + `OpenAPIRegistry` + SurrealDB testcontainer. Full sign-up → sign-in → /session → sign-out roundtrip
|
||||
- [x] 3.4 `tests/integration/openapi-doc.test.ts` — served `/openapi.json` includes all 9 auth routes with Zod-typed schemas
|
||||
- [x] 3.5 `tests/integration/schema-idempotency.test.ts` — restart plugin twice; schema re-apply succeeds without breaking existing rows
|
||||
- [x] 3.6 `tests/property.test.ts` — fast-check 200 runs: arbitrary valid Principal payloads round-trip through `PrincipalSchema`
|
||||
- [x] 3.7 `tests/dep-graph.test.ts` — deps + 15 subpaths + sibling .md per schemas/routes/*
|
||||
- [x] 3.8 All tests via `@suaveplan/testing/runner`, `{ timeout: 5000 }` (60000 for integration)
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` per impl (≥200 words)
|
||||
- [x] 4.2 `README.md` — quick-start using `defineApp().use(createAuthPlugin({ betterAuth: { … } }))`; the 3-way plug-and-play contract explained with the auth plugin as the canonical example; `requireAuth`/`requireRole` recipes; OAuth provider config; schema apply / migrations integration notes
|
||||
|
||||
## 5. Gates
|
||||
|
||||
- [x] 5.1 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-auth --force`
|
||||
- [x] 5.2 Coverage 100/100/100/100
|
||||
- [x] 5.3 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-auth` exit 0
|
||||
- [x] 5.4 Build green
|
||||
- [x] 5.5 `bunx turbo test --filter=...@suaveplan/api-plugin-auth --force` — dependent sweep (relevant once `api-plugin-user` + `api-plugin-vault` exist; harmless until then)
|
||||
- [x] 5.6 Banned-pattern grep + no `export *` + no direct vitest/bun:test imports
|
||||
|
||||
## 6. Archive
|
||||
|
||||
- [x] 6.1 Fold spec → `openspec/specs/api-plugin-auth/spec.md`
|
||||
- [x] 6.2 mv change → `openspec/archive/<date>-add-api-plugin-auth/`
|
||||
- [x] 6.3 DO NOT refresh MANIFEST or STATUS — orchestrator consolidates after the 4-package batch
|
||||
Reference in New Issue
Block a user