291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
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<string, unknown>): 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<string, unknown>) => 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<string, unknown>) => 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<AuthPluginConfig> {
|
|
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<AuthPluginConfig>({
|
|
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<AuthPluginConfig>,
|
|
config: AuthPluginConfig,
|
|
state: AuthPluginState
|
|
): Promise<void> {
|
|
// (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<void> {
|
|
const { migrationRunner, db } = args;
|
|
if (migrationRunner !== undefined) {
|
|
try {
|
|
await migrationRunner.run([
|
|
{
|
|
name: "0000-better-auth-schema",
|
|
path: "<inline>",
|
|
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<Record<string, unknown>>) => 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));
|
|
}
|