Initial Commit
This commit is contained in:
290
packages/api-stack/api/api-plugin-auth/src/plugin/plugin.ts
Normal file
290
packages/api-stack/api/api-plugin-auth/src/plugin/plugin.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user