147 lines
12 KiB
Markdown
147 lines
12 KiB
Markdown
# 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/<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).
|