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 aPluginDescriptorfrom@suaveplan/api-plugin. The descriptor's lifecycle hooks implement the 6-step onInit flow above.AuthPluginOptions(Zod-validated viaAuthPluginOptionsSchema):{ betterAuth: BetterAuthOptions; routePrefix?: string (default "/auth"); applySchemaOnInit?: boolean (default true); principalMapping?: (user, session) => Principal; logger?: LoggerLike }.betterAuthis forwarded verbatim to Better-Auth'screateBetterAuth.AuthPluginConfig— the descriptor'sconfigSchema-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 readsc.get("user")typed asPrincipal | nullvia HonoContextVariableMapaugmentation.mapBetterAuthUserToPrincipal(user, session)— pure default mapping. Replaceable viaoptions.principalMapping.
Middleware
requireAuth()— readsc.get("user"); null →AuthError(AUTH_UNAUTHENTICATED)(401). Returns the typedPrincipalto downstream handlers via Hono context narrowing.requireRole(...roles)— depends onrequireAuthupstream; checksprincipal.rolesintersectsroles; mismatch →AuthError(AUTH_INSUFFICIENT_ROLE)(403).requireEmailVerified()— null email-verified →AuthError(AUTH_EMAIL_NOT_VERIFIED)(403).
Service registry slot
authServiceregistered viactx.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: samePOST /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 redirectGET /auth/oauth/:provider/callback— query:{ code, state }; response: 302 redirect toredirectTo
(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_SCHEMAfrom@suaveplan/better-auth-surrealdb/schemaat./schema. onInitcallsapplyBetterAuthSchema(surrealAdapter)(ormigrationRunner.run(...)when a runner is registered) before constructing the Better-Auth instance. Idempotent re-apply on restart (DDL usesDEFINE TABLE OVERWRITE).- Set
options.applySchemaOnInit: falseto disable auto-apply (operators who manage migrations externally).
Errors + observability
AuthError extends SuaveplanError<AuthErrorCode>viadefineCodes("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 likemigrationRunneris absent).- OTel: counter
auth.sessions_resolved_total{outcome}, histogramauth.session_resolve_duration_ms, counterauth.role_check_total{outcome}, counterauth.signups_total, counterauth.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: BetterAuthOptionsverbatim — 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-passkeyswould 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.tenantIdis 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-pluginPluginContext+ServiceRegistryshape (shipped 0f338653). - The genesis
@suaveplan/api-openapidefinePluginRoutes+RouteBuilder(shipped 99a213ed). - The genesis
@suaveplan/api-migrationsMigrationRunner+createSurrealqlAdapter(shipped 99a213ed). - The genesis
@suaveplan/better-auth-surrealdbadapter + bundled schema (sibling proposal). - Better-Auth's documented API (
createBetterAuth,auth.api.getSession, route mounting, plugin composition). - The legacy
enterprise-api-plugin-authproposal's middleware shape (requireAuth,requireRole,Principalcontract — 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 changeadd-better-auth-surrealdb— no duplication needed; reuse the samecatalog:authentry).