Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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));
}