# 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/.ts ← Zod schemas at boundary (login body, register body, etc.) schemas/index.ts ← named re-exports for ./schemas subpath routes/.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` — 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 }`. 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; getSession(req: Request): Promise }`. - 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` 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).