import type { OpenAPIHono } from "@hono/zod-openapi"; import type { MigrationRunner } from "@suaveplan/api-migrations"; import type { OpenAPIRegistry } from "@suaveplan/api-openapi"; import { definePluginRoutes } from "@suaveplan/api-openapi"; import { definePlugin, type PluginContext, type PluginDescriptor, } from "@suaveplan/api-plugin"; import { applyBetterAuthSchema, BETTER_AUTH_SURREAL_SCHEMA, } from "@suaveplan/better-auth-surrealdb"; import type { SurrealAdapter } from "@suaveplan/surrealdb"; import type { Auth } from "better-auth"; import { betterAuth as betterAuthFactory } from "better-auth"; import { Hono } from "hono"; import { AUTH_CODES, AuthError } from "../error-codes/error-codes.js"; import { sessionMiddleware } from "../middleware/session.js"; import { buildAuthRoutes } from "../routes/build-routes.js"; import { AuthPluginOptionsSchema } from "../schemas/options.js"; import { createAuthService } from "../services/auth-service.js"; import "../services/registry-augmentation.js"; import type { AuthPluginConfig, AuthPluginOptions } from "../types/types.js"; /** * Default Better-Auth construction factory. Wraps the live * `betterAuth` import so the indirection is named. */ const DEFAULT_BETTER_AUTH_FACTORY = (opts: Record): Auth => betterAuthFactory(opts); /** * Better-Auth construction factory. Module-level mutable binding so * unit tests can substitute a stub via {@link __setBetterAuthFactory} * without resorting to module-mocking. Production callers do not need * to touch this — the default value is the live `betterAuth` import. */ let resolveBetterAuth: (opts: Record) => Auth = DEFAULT_BETTER_AUTH_FACTORY; /** * Test-only — swap the `betterAuth(...)` factory used by `onInit` so * unit tests can exercise success / failure paths without standing up * a real Better-Auth instance (which requires a live database + valid * secret). Pass `undefined` to restore the default. Not exported from * the package barrel. */ export function __setBetterAuthFactory( factory: ((opts: Record) => Auth) | undefined ): void { resolveBetterAuth = factory ?? DEFAULT_BETTER_AUTH_FACTORY; } /** * Internal state owned by the plugin instance across the 4-phase * lifecycle. Captured as a closure variable rather than on * `PluginContext` because the registry's per-hook context is rebuilt * each call. */ interface AuthPluginState { auth: Auth | null; routePrefix: string; } /** * Factory — produces a typed `PluginDescriptor` for the auth plugin. * * Zod-validates the supplied options at the boundary so invalid input * fails fast with `AuthError(AUTH_INVALID_INPUT)` carrying the * `ZodError` as `cause`. The returned descriptor's `onInit` executes * the canonical 6-step lifecycle: * * 1. Resolve `app` + `openAPIRegistry` + (optionally) `migrationRunner` * / `db` from the host `ServiceRegistry`. * 2. Apply the bundled SurrealQL schema via the resolved adapter (or * skip when `applySchemaOnInit: false`). * 3. Construct the Better-Auth instance via `betterAuth(opts)`. * 4. Register `authService` in the `ServiceRegistry`. * 5. Register the 9 OpenAPI routes via `definePluginRoutes` (mounted at * `routePrefix`). * 6. Install the global session-resolver middleware on `ctx.app`. */ export function createAuthPlugin( options: AuthPluginOptions ): PluginDescriptor { const parsed = AuthPluginOptionsSchema.safeParse(options); if (!parsed.success) { throw new AuthError( AUTH_CODES.INVALID_INPUT, "createAuthPlugin received invalid options", { cause: parsed.error, context: { statusCode: 500 } } ); } const resolved = parsed.data as AuthPluginConfig; const state: AuthPluginState = { auth: null, routePrefix: resolved.routePrefix, }; return definePlugin({ name: "auth", version: "0.1.0", description: "Better-Auth-first authentication plugin for @suaveplan/api. Mounts 9 OpenAPI routes, registers authService, installs session-resolver middleware.", defaults: resolved, tags: ["auth", "security"], onInit: async (ctx) => runOnInit(ctx, resolved, state), onClose: async () => { state.auth = null; }, }); } async function runOnInit( ctx: PluginContext, config: AuthPluginConfig, state: AuthPluginState ): Promise { // (1) Resolve required services from the host ServiceRegistry. const app = ctx.getService("app"); if (app === undefined) { throw new AuthError( AUTH_CODES.SERVICE_NOT_AVAILABLE, "auth plugin requires the `app` service in ServiceRegistry", { context: { missing: ["app"], statusCode: 500 } } ); } const openAPIRegistry = ctx.getService("openAPIRegistry"); const migrationRunner = ctx.getService("migrationRunner"); const db = ctx.getService("db"); const logger = config.logger ?? ctx.logger; // (2) Apply the bundled SurrealQL schema (when enabled). if (config.applySchemaOnInit) { await applySchema({ migrationRunner, db }); } // (3) Construct the Better-Auth instance. let auth: Auth; try { auth = resolveBetterAuth(config.betterAuth); } catch (err) { throw new AuthError( AUTH_CODES.BETTER_AUTH_INIT_FAILED, "betterAuth(options) failed", { cause: err, context: { statusCode: 500 } } ); } state.auth = auth; // (4) Register the authService slot. ctx.registerService("authService", createAuthService({ auth })); // (5) Register the 9 OpenAPI routes mounted under routePrefix. registerRoutes({ app, openAPIRegistry, auth, routePrefix: config.routePrefix, logger, }); // (6) Install the global session middleware. const middleware = sessionMiddleware({ auth, ...(config.principalMapping !== undefined ? { principalMapping: config.principalMapping } : {}), logger, }); app.use("*", middleware); } async function applySchema(args: { migrationRunner: MigrationRunner | undefined; db: SurrealAdapter | undefined; }): Promise { const { migrationRunner, db } = args; if (migrationRunner !== undefined) { try { await migrationRunner.run([ { name: "0000-better-auth-schema", path: "", order: 0, content: BETTER_AUTH_SURREAL_SCHEMA, }, ]); return; } catch (err) { throw new AuthError( AUTH_CODES.SCHEMA_APPLY_FAILED, "migrationRunner.run(BETTER_AUTH_SURREAL_SCHEMA) failed", { cause: err, context: { statusCode: 500 } } ); } } if (db !== undefined) { try { await applyBetterAuthSchema(db); return; } catch (err) { throw new AuthError( AUTH_CODES.SCHEMA_APPLY_FAILED, "applyBetterAuthSchema(db) failed", { cause: err, context: { statusCode: 500 } } ); } } throw new AuthError( AUTH_CODES.SERVICE_NOT_AVAILABLE, "applySchemaOnInit requires either a migrationRunner or db service", { context: { missing: ["migrationRunner", "db"], statusCode: 500, }, } ); } function registerRoutes(args: { app: OpenAPIHono; openAPIRegistry: OpenAPIRegistry | undefined; auth: Auth; routePrefix: string; logger: { warn: (msg: string, ctx?: Readonly>) => void; }; }): void { const { app, openAPIRegistry, auth, routePrefix, logger } = args; const sub = new Hono() as OpenAPIHono; const entries = buildAuthRoutes(auth); const catchAllMount = routePrefix === "/" ? "/*" : `${routePrefix}/*`; if (openAPIRegistry !== undefined) { const existing = openAPIRegistry .getRoutes() .filter((r) => r.path.startsWith(routePrefix)); if (existing.length > 0) { throw new AuthError( AUTH_CODES.ROUTE_PREFIX_COLLISION, `Route prefix '${routePrefix}' collides with previously registered routes`, { context: { prefix: routePrefix, existing: existing.map((r) => r.path), statusCode: 500, }, } ); } const prefixedEntries = entries.map((entry) => ({ ...entry, route: { ...entry.route, path: `${routePrefix === "/" ? "" : routePrefix}${entry.route.path}`, }, })); definePluginRoutes(prefixedEntries, { app, registry: openAPIRegistry, pluginName: "auth", }); // Mount Better-Auth's catch-all AFTER the OpenAPI routes so // specific paths win; the catch-all covers paths the OpenAPI // doc does not advertise (e.g. `/sign-up/email`). app.all(catchAllMount, async (c) => auth.handler(c.req.raw)); return; } logger.warn( "auth plugin: openAPIRegistry not registered — routes mount on Hono only" ); for (const entry of entries) { const method = entry.route.method as | "get" | "post" | "put" | "patch" | "delete"; sub[method]( entry.route.path, async (c) => (await entry.handler(c as never)) as Response ); } app.route(routePrefix === "/" ? "/" : routePrefix, sub); // Mount Better-Auth's catch-all AFTER the sub-router so specific // mounted routes win; the catch-all covers everything else. app.all(catchAllMount, async (c) => auth.handler(c.req.raw)); }