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

12 KiB

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)

@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).