Files
suaveplan/openspec/archive/2026-05-18-add-api-plugin-auth/specs/api-plugin-auth/spec.md
2026-05-27 23:29:53 +01:00

15 KiB

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"