15 KiB
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>withname === "auth"and all 4 lifecycle hooks defined
Scenario: invalid options raise AUTH_INVALID_INPUT
- WHEN
createAuthPlugin({} as never)is called (missingbetterAuth) - THEN
AuthError(AUTH_INVALID_INPUT)is thrown withcause 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
migrationRunneris inServiceRegistryand 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 isAUTH_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
OpenAPIRegistryis inServiceRegistryand the plugin initializes - THEN the resulting
registry.getRoutesByPlugin("auth")array contains every route frombuildAuthRoutesAND 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 withcontext.prefixset 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")returnsnullAND 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 aPrincipalwithid,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()— readsc.get("user"); null → throwsAuthError(AUTH_UNAUTHENTICATED)(statusCode 401). Returns the typedPrincipalto downstream handlers.requireRole(...roles: string[])— depends onrequireAuthupstream. Readsc.get("user")?.roles; no intersection withroles→ throwsAuthError(AUTH_INSUFFICIENT_ROLE)(403) withcontext.required: roles.requireEmailVerified()— depends onrequireAuthupstream.c.get("user")?.emailVerified === false→ throwsAuthError(AUTH_EMAIL_NOT_VERIFIED)(403).
Scenario: requireAuth on anonymous request raises 401
- WHEN
requireAuth()runs on a request withc.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 withprincipal.roles === ["owner"] - THEN the middleware passes (no throw)
Scenario: requireEmailVerified rejects when emailVerified is false
- WHEN
requireEmailVerified()runs on a request whose principal hasemailVerified: 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)callsctx.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
RouteConfighas arequest.body.content["application/json"].schemaORrequest.queryreferencing the matching Zod schema from./schemas/routes, AND the successresponses[200]schema is non-empty
Scenario: registered routes appear in OpenAPI doc
- WHEN the plugin initializes against a real OpenAPIRegistry + Hono app and
/openapi.jsonis 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/principaland callsPrincipalSchema.parse(payload) - THEN the parse succeeds for valid principals and produces a typed
Principalvalue
Scenario: every routes-schemas export has a sibling .md
- WHEN
tests/dep-graph.test.tsenumerates./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_totalANDauth.session_resolve_duration_mshave 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.tsconstructs a realPluginRegistrywithcreateAuthPlugin({ betterAuth }), realMigrationRunneragainst SurrealDB testcontainer, realOpenAPIRegistry, then drives a sign-up → sign-in → GET /session → sign-out roundtrip - THEN every step succeeds, the schema is applied (idempotent on second
start()), andGET /openapi.jsonreturns 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"