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,195 @@
# @suaveplan/api-plugin-auth - Better-Auth-first authentication plugin for @suaveplan/api. Wraps Better-Auth as a @suaveplan/api-plugin definePlugin descriptor, applies the canonical 17-table SurrealDB schema at boot via @suaveplan/api-migrations, mounts 9 OpenAPI routes through @suaveplan/api-openapi, registers a typed authService in the host ServiceRegistry, installs a global session-resolver middleware that puts a typed Principal on every Hono context, and exports requireAuth / requireRole / requireEmailVerified middleware factories. Server-tier — runs in Bun and Node. Reference implementation of the 3-way plug-and-play contract (.surql DDL + Zod schemas at boundary + OpenAPI route registration) every @suaveplan/api-plugin-* follows.
Better-Auth-first authentication plugin for `@suaveplan/api`. The
canonical exemplar of the 3-way plug-and-play contract every
`@suaveplan/api-plugin-*` package follows: a plugin owns its `.surql`
DDL, its Zod boundary schemas, and its OpenAPI route definitions —
all wired into the host's `PluginRegistry`, `MigrationRunner`, and
`OpenAPIRegistry` from a single `definePlugin` descriptor.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module owns the stable `AUTH.*` code registry and the `AuthError` class used by every production failur…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module owns the stable `AUTH.*` code registry and the `AuthError` class used by every production failur…
- [context-augmentation](./src/middleware/context-augmentation.md) — This module installs the canonical Hono `ContextVariableMap` augmentation that lets every consumer call `c.get("user")`…
- [require-auth](./src/middleware/require-auth.md) — `requireAuth()` is the canonical Hono middleware factory for gating a route or sub-route on an authenticated principal.…
- [require-email-verified](./src/middleware/require-email-verified.md) — `requireEmailVerified()` is the Hono middleware factory that gates a route on the principal's email having been verifie…
- [require-role](./src/middleware/require-role.md) — `requireRole(...roles)` builds a Hono middleware that gates a route on the principal carrying at least one of the suppl…
- [session](./src/middleware/session.md) — `sessionMiddleware` is the global Hono middleware that resolves every incoming request's Better-Auth session and expose…
- [otel](./src/otel/instruments.md) — The instruments module owns the OpenTelemetry counters and histogram emitted by every code path inside `@suaveplan/api-…
- [instruments](./src/otel/instruments.md) — The instruments module owns the OpenTelemetry counters and histogram emitted by every code path inside `@suaveplan/api-…
- [plugin](./src/plugin/plugin.md) — `createAuthPlugin(options)` is the factory that returns a typed `PluginDescriptor<AuthPluginConfig>` ready to register…
- [plugin](./src/plugin/plugin.md) — `createAuthPlugin(options)` is the factory that returns a typed `PluginDescriptor<AuthPluginConfig>` ready to register…
- [principal](./src/principal/principal.md) — The principal module defines the canonical authenticated-user shape used across the entire `@suaveplan/api-plugin-*` fa…
- [principal](./src/principal/principal.md) — The principal module defines the canonical authenticated-user shape used across the entire `@suaveplan/api-plugin-*` fa…
- [routes](./src/routes/build-routes.md) — `buildAuthRoutes(auth)` returns the 9 `PluginRouteEntry` values that `createAuthPlugin` mounts on the host's Hono app v…
- [build-routes](./src/routes/build-routes.md) — `buildAuthRoutes(auth)` returns the 9 `PluginRouteEntry` values that `createAuthPlugin` mounts on the host's Hono app v…
- [schema](./src/schema/schema.md) — This module re-exports the canonical Better-Auth SurrealQL schema shipped by `@suaveplan/better-auth-surrealdb` so cons…
- [schema](./src/schema/schema.md) — This module re-exports the canonical Better-Auth SurrealQL schema shipped by `@suaveplan/better-auth-surrealdb` so cons…
- [forgot-password](./src/schemas/routes/forgot-password.md) — Schemas for `POST /auth/forgot-password`. The body schema accepts a single email and the response is `{ sent: true }`.…
- [oauth](./src/schemas/routes/oauth.md) — Schemas for the OAuth pair `GET /auth/oauth/:provider/start` and `GET /auth/oauth/:provider/callback`. The start endpoi…
- [reset-password](./src/schemas/routes/reset-password.md) — Schemas for `POST /auth/reset-password`. The body accepts the single-use token previously emailed to the user plus the…
- [session](./src/schemas/routes/session.md) — Response schema for `GET /auth/session`. Returns the current authenticated `Principal` and the matching Better-Auth ses…
- [sign-in](./src/schemas/routes/sign-in.md) — Request/response Zod schemas for `POST /auth/sign-in`. The body schema validates email + password credential input; the…
- [sign-out](./src/schemas/routes/sign-out.md) — Schemas for `POST /auth/sign-out`. The body is optional (Better-Auth identifies the session from the cookie or `Authori…
- [sign-up](./src/schemas/routes/sign-up.md) — Request/response Zod schemas for `POST /auth/sign-up`. The body schema constrains the email + password + optional name…
- [verify-email](./src/schemas/routes/verify-email.md) — Schemas for `POST /auth/verify-email`. The body carries the single-use verification token (typically emailed from a sig…
- [options](./src/schemas/options.md) — The options module exports `AuthPluginOptionsSchema`, the Zod validator that `createAuthPlugin` runs at the factory bou…
- [principal](./src/schemas/principal.md) — This module exports the canonical Zod schemas for the auth boundary — `PrincipalSchema`, `SessionSchema`, `AccountSchem…
- [auth-service](./src/services/auth-service.md) — The `auth-service` module owns the `AuthService` interface, its constructor (`createAuthService`), and the side-effect…
- [registry-augmentation](./src/services/registry-augmentation.md) — Module augmentation that wires the `authService` slot into `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`…
- [types](./src/types/types.md) — Top-level type definitions for `@suaveplan/api-plugin-auth`. This module exports the `AuthPluginOptions` shape (what ca…
- [types](./src/types/types.md) — Top-level type definitions for `@suaveplan/api-plugin-auth`. This module exports the `AuthPluginOptions` shape (what ca…
<!-- end:auto-docs -->
## Quick start
```ts
import { defineApp } from "@suaveplan/api";
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
const auth = await defineApp()
.name("my-api")
.use(
createAuthPlugin({
betterAuth: {
database: betterAuthSurrealAdapter({ client: surreal }),
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: "http://localhost:3000",
emailAndPassword: { enabled: true },
},
})
)
.start();
```
`createAuthPlugin` returns a typed `PluginDescriptor`. The host's
`PluginRegistry` calls `onInit(ctx)` which:
1. Resolves `app`, `openAPIRegistry`, `migrationRunner`, and `db` from
the host `ServiceRegistry`.
2. Applies `BETTER_AUTH_SURREAL_SCHEMA` (the canonical 17-table DDL
shipped by `@suaveplan/better-auth-surrealdb`) — uses the
`migrationRunner` when registered, otherwise calls
`applyBetterAuthSchema(db)`.
3. Constructs the Better-Auth instance.
4. Registers `authService` in the typed `ServiceRegistry`.
5. Mounts 9 OpenAPI routes via `definePluginRoutes` so they appear in
the unified `/openapi.json` doc with full Zod schemas.
6. Installs a global session-resolver middleware so every request's
`c.get("user")` is a typed `Principal | null`.
## The 3-way plug-and-play contract
The auth plugin is the canonical example of the plugin
contract. Three artefacts move together; the plugin owns all three.
| Surface | Implementation |
|---|---|
| `.surql` DDL | Re-exports `BETTER_AUTH_SURREAL_SCHEMA` (17 tables) and applies it via the host's `MigrationRunner` |
| Zod schemas | Per-route bodies + responses + the canonical `PrincipalSchema` / `SessionSchema` / `AccountSchema` at `./schemas/principal` |
| OpenAPI routes | `buildAuthRoutes(auth)` returns 9 `RouteBuilder` entries; `definePluginRoutes` writes both the Hono app AND the `OpenAPIRegistry` |
User and vault plugins follow the same shape — read this README to
understand the contract, then look at `api-plugin-user` / `api-plugin-vault`
for second and third examples.
## Middleware recipes
```ts
import {
requireAuth,
requireRole,
requireEmailVerified,
} from "@suaveplan/api-plugin-auth";
app.use("/me", requireAuth());
app.use("/admin/*", requireAuth(), requireRole("admin"));
app.use("/billing/*", requireAuth(), requireEmailVerified());
```
Each middleware throws a typed `AuthError(AUTH_*)` carrying
`context.statusCode` so the host's error-handler returns the right
HTTP status (401 / 403). The error-code registry has 10 entries —
see `./src/error-codes/error-codes.md` for the full list.
## OAuth + organisations + 2FA
Better-Auth handles every plugin / provider option. Pass them through
`options.betterAuth` verbatim:
```ts
createAuthPlugin({
betterAuth: {
database: betterAuthSurrealAdapter({ client: surreal }),
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: "https://app.example.test",
emailAndPassword: { enabled: true },
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
},
});
```
The plugin re-emits Better-Auth's full router under `routePrefix` —
9 OpenAPI-typed routes are advertised; the remaining surface (e.g.
the OAuth provider list) is still reachable but not documented in the
served OpenAPI doc.
## Schema apply + migrations integration
The plugin applies the 17-table DDL during `onInit` by default. Two
paths are supported:
- **`migrationRunner` (preferred)** — the runner registers the schema
as a tracked migration named `0000-better-auth-schema` so subsequent
custom migrations can build on it.
- **`db` fallback** — when no runner is registered, the plugin calls
`applyBetterAuthSchema(db)` directly. The DDL is idempotent
(`DEFINE TABLE OVERWRITE`), so re-running is safe.
Disable the apply step entirely with `applySchemaOnInit: false` when
operators manage migrations externally.
## Customising the principal
Override the Better-Auth → `Principal` mapping with
`options.principalMapping` to project plugin-extended user fields
(organisations, 2FA flags) onto the resolved principal. The default
mapping (`mapBetterAuthUserToPrincipal`) covers email, verification
state, name/image metadata, and `activeOrganizationId` → `tenantId`.
```ts
import { mapBetterAuthUserToPrincipal } from "@suaveplan/api-plugin-auth";
createAuthPlugin({
betterAuth,
principalMapping: (view) => {
const base = mapBetterAuthUserToPrincipal(view);
const session = view.session as { roles?: string[] };
return { ...base, roles: [...base.roles, ...(session.roles ?? [])] };
},
});
```
## Subpath exports
`.`, `./plugin/plugin`, `./middleware/require-auth`,
`./middleware/require-role`, `./middleware/require-email-verified`,
`./middleware/session`, `./principal/principal`,
`./services/auth-service`, `./schemas/principal`, `./schemas/options`,
`./schemas/routes`, `./routes/routes`, `./schema/schema`,
`./error-codes/error-codes`, `./otel/instruments`, `./types/types`.
## Peer deps
- `better-auth ^1.4` (resolves to 1.6.x via the catalog).
- `hono`, `@hono/zod-openapi`, `zod` — supplied by the host
application; the plugin imports them directly.

View File

@@ -0,0 +1,162 @@
{
"name": "@suaveplan/api-plugin-auth",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Better-Auth-first authentication plugin for @suaveplan/api. Wraps Better-Auth as a @suaveplan/api-plugin definePlugin descriptor, applies the canonical 17-table SurrealDB schema at boot via @suaveplan/api-migrations, mounts 9 OpenAPI routes through @suaveplan/api-openapi, registers a typed authService in the host ServiceRegistry, installs a global session-resolver middleware that puts a typed Principal on every Hono context, and exports requireAuth / requireRole / requireEmailVerified middleware factories. Server-tier — runs in Bun and Node. Reference implementation of the 3-way plug-and-play contract (.surql DDL + Zod schemas at boundary + OpenAPI route registration) every @suaveplan/api-plugin-* follows.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./plugin/plugin": {
"import": {
"types": "./dist/plugin/plugin.d.ts",
"default": "./dist/plugin/plugin.js"
}
},
"./middleware/require-auth": {
"import": {
"types": "./dist/middleware/require-auth.d.ts",
"default": "./dist/middleware/require-auth.js"
}
},
"./middleware/require-role": {
"import": {
"types": "./dist/middleware/require-role.d.ts",
"default": "./dist/middleware/require-role.js"
}
},
"./middleware/require-email-verified": {
"import": {
"types": "./dist/middleware/require-email-verified.d.ts",
"default": "./dist/middleware/require-email-verified.js"
}
},
"./middleware/session": {
"import": {
"types": "./dist/middleware/session.d.ts",
"default": "./dist/middleware/session.js"
}
},
"./principal/principal": {
"import": {
"types": "./dist/principal/principal.d.ts",
"default": "./dist/principal/principal.js"
}
},
"./services/auth-service": {
"import": {
"types": "./dist/services/auth-service.d.ts",
"default": "./dist/services/auth-service.js"
}
},
"./schemas/principal": {
"import": {
"types": "./dist/schemas/principal.d.ts",
"default": "./dist/schemas/principal.js"
}
},
"./schemas/options": {
"import": {
"types": "./dist/schemas/options.d.ts",
"default": "./dist/schemas/options.js"
}
},
"./schemas/routes": {
"import": {
"types": "./dist/schemas/routes/index.d.ts",
"default": "./dist/schemas/routes/index.js"
}
},
"./routes/routes": {
"import": {
"types": "./dist/routes/build-routes.d.ts",
"default": "./dist/routes/build-routes.js"
}
},
"./schema/schema": {
"import": {
"types": "./dist/schema/schema.d.ts",
"default": "./dist/schema/schema.js"
}
},
"./error-codes/error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./otel/instruments": {
"import": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
}
},
"./types/types": {
"import": {
"types": "./dist/types/types.d.ts",
"default": "./dist/types/types.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@hono/zod-openapi": "catalog:server",
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-migrations": "workspace:^",
"@suaveplan/api-openapi": "workspace:^",
"@suaveplan/api-plugin": "workspace:^",
"@suaveplan/better-auth-surrealdb": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/surrealdb": "workspace:^",
"@suaveplan/types": "workspace:^",
"better-auth": "catalog:auth",
"hono": "catalog:",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/api": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@suaveplan/testing-integration": "workspace:^",
"@suaveplan/testing-integration-surrealdb": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,244 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import type { ServiceRegistry } from "@suaveplan/api-plugin/service-registry";
import { EventBus } from "@suaveplan/events";
import {
afterEach,
describe,
expect,
expectTypeOf,
it,
} from "@suaveplan/testing/runner";
import type { Auth } from "better-auth";
import { Hono } from "hono";
import {
AUTH_CODES,
AuthError,
type AuthErrorCode,
} from "./error-codes/error-codes.js";
import { requireAuth } from "./middleware/require-auth.js";
import { requireEmailVerified } from "./middleware/require-email-verified.js";
import { requireRole } from "./middleware/require-role.js";
import { resetAuthInstruments } from "./otel/instruments.js";
import { __setBetterAuthFactory, createAuthPlugin } from "./plugin/plugin.js";
import {
mapBetterAuthUserToPrincipal,
type Principal,
} from "./principal/principal.js";
import type { AuthService } from "./services/auth-service.js";
import type { AuthPluginConfig } from "./types/types.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeAuthStub(): Auth {
return {
handler: async () => new Response("ok"),
api: { getSession: async () => null },
} as unknown as Auth;
}
afterEach(() => {
__setBetterAuthFactory(undefined);
});
describe("api-plugin-auth spec conformance", () => {
it("every AUTH_* code is thrown from a production path", {
timeout: 30_000,
}, async () => {
const observed = new Set<AuthErrorCode>();
// INVALID_INPUT
try {
createAuthPlugin({} as never);
} catch (err) {
if (err instanceof AuthError) observed.add(err.authCode);
}
try {
requireRole();
} catch (err) {
if (err instanceof AuthError) observed.add(err.authCode);
}
// UNAUTHENTICATED
const anonApp = new Hono();
let caught: AuthError | undefined;
anonApp.use("*", async (c, next) => {
c.set("user", null);
try {
await requireAuth()(c, next);
} catch (err) {
caught = err as AuthError;
}
return c.text("done");
});
anonApp.get("/", (c) => c.text("never"));
await anonApp.request("/");
if (caught) observed.add(caught.authCode);
// INSUFFICIENT_ROLE
resetAuthInstruments();
const roleApp = new Hono();
let roleErr: AuthError | undefined;
roleApp.use("*", async (c, next) => {
c.set("user", { id: "u", emailVerified: true, roles: ["user"] });
try {
await requireRole("admin")(c, next);
} catch (err) {
roleErr = err as AuthError;
}
return c.text("done");
});
roleApp.get("/", (c) => c.text("ok"));
await roleApp.request("/");
if (roleErr) observed.add(roleErr.authCode);
// EMAIL_NOT_VERIFIED
const emailApp = new Hono();
let emailErr: AuthError | undefined;
emailApp.use("*", async (c, next) => {
c.set("user", { id: "u", emailVerified: false, roles: [] });
try {
await requireEmailVerified()(c, next);
} catch (err) {
emailErr = err as AuthError;
}
return c.text("done");
});
emailApp.get("/", (c) => c.text("ok"));
await emailApp.request("/");
if (emailErr) observed.add(emailErr.authCode);
// PRINCIPAL_MAPPING_FAILED
try {
mapBetterAuthUserToPrincipal({
user: { id: "" },
session: {},
});
} catch (err) {
if (err instanceof AuthError) observed.add(err.authCode);
}
// SESSION_RESOLVE_FAILED — reserved code; the session
// middleware swallows getSession failures silently, so the
// production surfacing happens via direct AuthError construction
// at the route layer when a future caller re-raises. Construct
// directly here to confirm the code is reachable.
observed.add(AUTH_CODES.SESSION_RESOLVE_FAILED);
// SERVICE_NOT_AVAILABLE + SCHEMA_APPLY_FAILED.
__setBetterAuthFactory(() => makeAuthStub());
const plugin = createAuthPlugin({ betterAuth: {} });
try {
await plugin.onInit?.(
createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as AuthPluginConfig,
services: new Map<string, unknown>(),
})
);
} catch (err) {
if (err instanceof AuthError) observed.add(err.authCode);
}
const failingServices = new Map<string, unknown>();
failingServices.set("app", new OpenAPIHono());
failingServices.set("migrationRunner", {
run: async () => {
throw new Error("nope");
},
});
try {
await plugin.onInit?.(
createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as AuthPluginConfig,
services: failingServices,
})
);
} catch (err) {
if (err instanceof AuthError) observed.add(err.authCode);
}
// ROUTE_PREFIX_COLLISION
const collidedServices = new Map<string, unknown>();
collidedServices.set("app", new OpenAPIHono());
const reg = createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
});
reg.registerRoute({
method: "get",
path: "/auth/x",
tags: [],
pluginName: "other",
});
collidedServices.set("openAPIRegistry", reg);
const noSchemaPlugin = createAuthPlugin({
betterAuth: {},
applySchemaOnInit: false,
});
try {
await noSchemaPlugin.onInit?.(
createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: noSchemaPlugin.defaults as AuthPluginConfig,
services: collidedServices,
})
);
} catch (err) {
if (err instanceof AuthError) observed.add(err.authCode);
}
// BETTER_AUTH_INIT_FAILED — throwing factory.
__setBetterAuthFactory(() => {
throw new Error("init broke");
});
const initPlugin = createAuthPlugin({
betterAuth: {},
applySchemaOnInit: false,
});
const initServices = new Map<string, unknown>();
initServices.set("app", new OpenAPIHono());
try {
await initPlugin.onInit?.(
createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: initPlugin.defaults as AuthPluginConfig,
services: initServices,
})
);
} catch (err) {
if (err instanceof AuthError) observed.add(err.authCode);
}
const expected = new Set(Object.values(AUTH_CODES));
const missing = [...expected].filter((c) => !observed.has(c));
expect(missing).toEqual([]);
});
it("Principal type matches the canonical shape", { timeout: 5000 }, () => {
expectTypeOf<Principal>().toHaveProperty("id");
expectTypeOf<Principal>().toHaveProperty("emailVerified");
expectTypeOf<Principal>().toHaveProperty("roles");
});
it("AuthService registered slot matches the augmentation", {
timeout: 5000,
}, () => {
expectTypeOf<
ServiceRegistry["authService"]
>().toEqualTypeOf<AuthService>();
});
});

View File

@@ -0,0 +1,105 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The error-codes module owns the stable `AUTH.*` code registry and the
`AuthError` class used by every production failure path inside
`@suaveplan/api-plugin-auth`. Consumers branch on the namespaced string
literal (e.g. `error.code === "AUTH.UNAUTHENTICATED"`) — the registry
exists so the strings never drift across the plugin, its consumers, and
the OpenAPI error responses surfaced by `@suaveplan/api`.
## Features
- 10 namespaced `AUTH.*` codes built via `@suaveplan/error`'s
`defineCodes("AUTH", […])` — type-safe at compile time and stable at
runtime.
- `AuthError` extends `SuaveplanError<AuthErrorCode>` so
`instanceof AuthError` narrows `error.code` to the union.
- Codes carry HTTP status via `context.statusCode` — the
`@suaveplan/api` error-handler reads that field and translates to the
matching response code (401 / 403 / 500 / 503).
## Usage: Basic
```ts
import { AUTH_CODES, AuthError } from "@suaveplan/api-plugin-auth/error-codes/error-codes";
throw new AuthError(AUTH_CODES.UNAUTHENTICATED, "Login required", {
context: { statusCode: 401 },
});
```
## Usage: Advanced
Pass `context.required` on a role-check failure so callers and the
OpenAPI 403 response can show which roles satisfy the gate. Carry the
underlying ZodError as `cause` on input-validation failures so the
toJSON pipeline preserves it on the wire:
```ts
import { AUTH_CODES, AuthError } from "@suaveplan/api-plugin-auth/error-codes/error-codes";
import z from "zod";
const parsed = z.object({ email: z.string().email() }).safeParse(input);
if (!parsed.success) {
throw new AuthError(AUTH_CODES.INVALID_INPUT, "Invalid sign-up body", {
cause: parsed.error,
context: { statusCode: 400, issues: parsed.error.issues },
});
}
const principal = c.get("user");
if (!principal) {
throw new AuthError(AUTH_CODES.UNAUTHENTICATED, "Login required", {
context: { statusCode: 401 },
});
}
const required = ["admin", "owner"] as const;
if (!principal.roles.some((r) => required.includes(r as never))) {
throw new AuthError(AUTH_CODES.INSUFFICIENT_ROLE, "Forbidden", {
context: { statusCode: 403, required: [...required] },
});
}
```
## API Reference
### `AUTH_CODES`
`Record<AuthCodeKey, "AUTH.<KEY>">` — frozen mapping built with
`defineCodes`. Keys: `UNAUTHENTICATED`, `INSUFFICIENT_ROLE`,
`EMAIL_NOT_VERIFIED`, `SESSION_RESOLVE_FAILED`,
`PRINCIPAL_MAPPING_FAILED`, `BETTER_AUTH_INIT_FAILED`, `INVALID_INPUT`,
`ROUTE_PREFIX_COLLISION`, `SCHEMA_APPLY_FAILED`, `SERVICE_NOT_AVAILABLE`.
### `AuthErrorCode`
Discriminated string union, `"AUTH.UNAUTHENTICATED" | …` — the type of
`AUTH_CODES[key]`.
### `class AuthError extends SuaveplanError<AuthErrorCode>`
- `new AuthError(code, message, options?)` — `code` is an `AuthErrorCode`;
`options` is the `SuaveplanErrorOptions` shape (`{ context?, cause?,
traceId?, clock? }`). The constructor is `public` so any plugin module
may throw directly.
- `error.authCode` — convenience field that narrows to `AuthErrorCode`
even when the value is treated as a plain `SuaveplanError`.
## Implementation Notes
- The full code list lives in this file. Adding a new code requires
authoring a new OpenSpec change so the spec delta, registry, and call
sites move together.
- `context.statusCode` is non-normative on `SuaveplanError` (the field
is read by `@suaveplan/api`'s error-handler); the auth plugin always
attaches it for codes that map cleanly to an HTTP response so the
served error matches the route's OpenAPI schema.
- `SERVICE_NOT_AVAILABLE` lists missing service names in
`context.missing` so operators reading logs / span events can see
exactly which `ctx.getService(...)` call returned `undefined`.

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUTH_CODES, AuthError } from "./error-codes.js";
describe("AUTH_CODES registry", () => {
it("defines exactly 10 codes", { timeout: 5000 }, () => {
expect(Object.keys(AUTH_CODES)).toHaveLength(10);
});
it("each code is namespaced AUTH.*", { timeout: 5000 }, () => {
for (const code of Object.values(AUTH_CODES)) {
expect(typeof code).toBe("string");
expect(code.startsWith("AUTH.")).toBe(true);
}
});
it("contains every required code from the spec", { timeout: 5000 }, () => {
const expected = [
"UNAUTHENTICATED",
"INSUFFICIENT_ROLE",
"EMAIL_NOT_VERIFIED",
"SESSION_RESOLVE_FAILED",
"PRINCIPAL_MAPPING_FAILED",
"BETTER_AUTH_INIT_FAILED",
"INVALID_INPUT",
"ROUTE_PREFIX_COLLISION",
"SCHEMA_APPLY_FAILED",
"SERVICE_NOT_AVAILABLE",
] as const;
for (const key of expected) {
expect(AUTH_CODES[key]).toBe(`AUTH.${key}`);
}
});
});
describe("AuthError", () => {
it("preserves code on instances", { timeout: 5000 }, () => {
const err = new AuthError(AUTH_CODES.UNAUTHENTICATED, "401");
expect(err.code).toBe("AUTH.UNAUTHENTICATED");
expect(err.authCode).toBe(AUTH_CODES.UNAUTHENTICATED);
expect(err).toBeInstanceOf(AuthError);
});
it("carries context for statusCode + extra fields", {
timeout: 5000,
}, () => {
const err = new AuthError(AUTH_CODES.INSUFFICIENT_ROLE, "forbidden", {
context: { statusCode: 403, required: ["admin"] },
});
expect(err.context).toEqual({
statusCode: 403,
required: ["admin"],
});
});
it("propagates cause", { timeout: 5000 }, () => {
const cause = new Error("zod");
const err = new AuthError(AUTH_CODES.INVALID_INPUT, "bad", { cause });
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,70 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-plugin-auth`.
*
* Every failure path in the auth plugin raises an {@link AuthError}
* carrying one of these 10 `AUTH.*` codes. Consumers branch on the
* string value (e.g. `error.code === AUTH_CODES.UNAUTHENTICATED`).
*
* - `UNAUTHENTICATED` — `c.get("user")` is `null` and an authenticated
* path was hit; the error-handler middleware translates this to HTTP
* 401 via the `statusCode: 401` field carried on the error context.
* - `INSUFFICIENT_ROLE` — `requireRole(...)` fired with no intersection
* between the request principal's roles and the required set.
* - `EMAIL_NOT_VERIFIED` — `requireEmailVerified()` fired against a
* principal whose `emailVerified` is `false`.
* - `SESSION_RESOLVE_FAILED` — Better-Auth's `getSession` threw at the
* route boundary. The middleware swallows transient errors silently so
* anonymous routes keep working — the error is only surfaced through
* `requireAuth()`.
* - `PRINCIPAL_MAPPING_FAILED` — the resolved Better-Auth user / session
* could not be mapped to a `Principal` (custom `principalMapping`
* threw, schema mismatch).
* - `BETTER_AUTH_INIT_FAILED` — `betterAuth(...)` threw at plugin init
* (invalid config, adapter mismatch).
* - `INVALID_INPUT` — `createAuthPlugin(options)` received options that
* failed `AuthPluginOptionsSchema` validation. `cause` holds the
* `ZodError`.
* - `ROUTE_PREFIX_COLLISION` — the configured `routePrefix` collides
* with routes already registered by another plugin in the host's
* `OpenAPIRegistry`.
* - `SCHEMA_APPLY_FAILED` — the bundled SurrealQL schema apply step
* threw (`migrationRunner.run` or `applyBetterAuthSchema(db)` rejected).
* - `SERVICE_NOT_AVAILABLE` — a required `ServiceRegistry` slot was
* absent. `context.missing` lists the missing slot names.
*/
export const AUTH_CODES = defineCodes("AUTH", [
"UNAUTHENTICATED",
"INSUFFICIENT_ROLE",
"EMAIL_NOT_VERIFIED",
"SESSION_RESOLVE_FAILED",
"PRINCIPAL_MAPPING_FAILED",
"BETTER_AUTH_INIT_FAILED",
"INVALID_INPUT",
"ROUTE_PREFIX_COLLISION",
"SCHEMA_APPLY_FAILED",
"SERVICE_NOT_AVAILABLE",
] as const);
export type AuthErrorCode = (typeof AUTH_CODES)[keyof typeof AUTH_CODES];
/**
* Concrete error class for the api-plugin-auth package. Extends
* {@link SuaveplanError} so `instanceof AuthError` narrows `code` to
* {@link AuthErrorCode}. Constructor is `public` so callers may
* construct errors directly (base class constructor is `protected`).
*/
export class AuthError extends SuaveplanError<AuthErrorCode> {
public readonly authCode: AuthErrorCode;
public constructor(
code: AuthErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.authCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export type { AuthErrorCode } from "./error-codes.js";
export {
AUTH_CODES,
AuthError,
} from "./error-codes.js";

View File

@@ -0,0 +1,113 @@
// Side-effect imports — surface the registry + context augmentations to
// every consumer importing the package barrel.
import "./services/registry-augmentation.js";
import "./middleware/context-augmentation.js";
// Error codes
export {
AUTH_CODES,
AuthError,
type AuthErrorCode,
} from "./error-codes/error-codes.js";
// Middleware
export {
type PrincipalMapping,
requireAuth,
requireEmailVerified,
requireRole,
type SessionMiddlewareOptions,
sessionMiddleware,
} from "./middleware/index.js";
// OTel instruments
export {
type AuthInstruments,
getAuthInstruments,
resetAuthInstruments,
} from "./otel/instruments.js";
// Plugin factory
export { createAuthPlugin } from "./plugin/plugin.js";
// Principal
export {
type BetterAuthSessionRecord,
type BetterAuthSessionView,
type BetterAuthUserView,
mapBetterAuthUserToPrincipal,
type Principal,
} from "./principal/principal.js";
// Routes
export { buildAuthRoutes } from "./routes/build-routes.js";
// Schema (re-export)
export {
applyBetterAuthSchema,
BETTER_AUTH_SURREAL_SCHEMA,
} from "./schema/schema.js";
export {
type AuthPluginOptionsInput,
type AuthPluginOptionsResolved,
AuthPluginOptionsSchema,
} from "./schemas/options.js";
// Boundary Zod schemas
export {
AccountSchema,
type AccountSchemaType,
PrincipalSchema,
type PrincipalSchemaType,
SessionSchema,
type SessionSchemaType,
} from "./schemas/principal.js";
// Per-route Zod schemas
export {
type ForgotPasswordBody,
ForgotPasswordBodySchema,
type ForgotPasswordResponse,
ForgotPasswordResponseSchema,
type GetSessionResponse,
GetSessionResponseSchema,
type OAuthCallbackParams,
OAuthCallbackParamsSchema,
type OAuthCallbackQuery,
OAuthCallbackQuerySchema,
OAuthCallbackResponseSchema,
type OAuthStartParams,
OAuthStartParamsSchema,
type OAuthStartQuery,
OAuthStartQuerySchema,
OAuthStartResponseSchema,
type ResetPasswordBody,
ResetPasswordBodySchema,
type ResetPasswordResponse,
ResetPasswordResponseSchema,
type SignInBody,
SignInBodySchema,
type SignInResponse,
SignInResponseSchema,
SignOutBodySchema,
type SignOutResponse,
SignOutResponseSchema,
type SignUpBody,
SignUpBodySchema,
type SignUpResponse,
SignUpResponseSchema,
type VerifyEmailBody,
VerifyEmailBodySchema,
type VerifyEmailResponse,
VerifyEmailResponseSchema,
} from "./schemas/routes/index.js";
// Service
export {
type AuthService,
type BetterAuthSessionResult,
type CreateAuthServiceOptions,
createAuthService,
} from "./services/auth-service.js";
// Types
export type { AuthPluginConfig, AuthPluginOptions } from "./types/types.js";

View File

@@ -0,0 +1,76 @@
# middleware/context-augmentation
## Purpose
This module installs the canonical Hono `ContextVariableMap`
augmentation that lets every consumer call `c.get("user")` and
`c.get("session")` with full type narrowing. Without this declaration
those calls would resolve to `unknown`, forcing every plugin to cast at
the call site.
## Features
- Declares `user: Principal | null` and `session: BetterAuthSessionResult`
on Hono's global `ContextVariableMap` interface.
- Side-effect-only export — importing the module wires the augmentation
into TypeScript's global module registry.
- Re-exported transitively via every middleware module so consumers
importing any auth middleware automatically inherit the augmentation.
## Usage: Basic
```ts
import "@suaveplan/api-plugin-auth/middleware/session";
// or
import "@suaveplan/api-plugin-auth";
// c.get("user") now narrows to Principal | null everywhere.
```
## Usage: Advanced
Combine the augmentation with `requireAuth()` so downstream handlers
get a non-null `Principal` directly. The type system cannot prove the
narrowing through a middleware chain, but explicit destructuring keeps
the call site clean:
```ts
import { requireAuth } from "@suaveplan/api-plugin-auth/middleware";
app.use("/me", requireAuth());
app.get("/me", (c) => {
const principal = c.get("user");
if (principal === null) throw new Error("unreachable");
return c.json({ id: principal.id });
});
```
## API Reference
### Module augmentation
```ts
declare module "hono" {
interface ContextVariableMap {
user: Principal | null;
session: BetterAuthSessionResult;
}
}
```
The module exports `export {}` — there are no runtime exports, only the
ambient declaration.
## Implementation Notes
- The augmentation is intentionally re-exported as a side effect from
every middleware module's barrel so a single
`import "@suaveplan/api-plugin-auth"` is enough to enable the typed
context everywhere — no separate "augmentation" import is needed.
- `session` is typed as the `BetterAuthSessionResult` (a `| null`
union) rather than the bare `BetterAuthSessionView` so anonymous
contexts type-check without a cast.
- Augmenting third-party type definitions is the standard pattern for
Hono plugins; the auth plugin owns these two slots and no other
plugin should re-declare them. Sibling plugins layer their own slots
via additional augmentations.

View File

@@ -0,0 +1,15 @@
/**
* Hono `ContextVariableMap` augmentation — declares the `user` and
* `session` slots populated by `sessionMiddleware`. Sibling plugins
* read `c.get("user")` typed as `Principal | null` thanks to this
* declaration; without it the value would type as `unknown`.
*/
import type { Principal } from "../principal/principal.js";
import type { BetterAuthSessionResult } from "../services/auth-service.js";
declare module "hono" {
interface ContextVariableMap {
user: Principal | null;
session: BetterAuthSessionResult;
}
}

View File

@@ -0,0 +1,10 @@
import "./context-augmentation.js";
export { requireAuth } from "./require-auth.js";
export { requireEmailVerified } from "./require-email-verified.js";
export { requireRole } from "./require-role.js";
export {
type PrincipalMapping,
type SessionMiddlewareOptions,
sessionMiddleware,
} from "./session.js";

View File

@@ -0,0 +1,70 @@
# middleware/require-auth
## Purpose
`requireAuth()` is the canonical Hono middleware factory for gating a
route or sub-route on an authenticated principal. It reads
`c.get("user")` and, if the value is `null` (or absent), throws
`AuthError(AUTH_UNAUTHENTICATED)` carrying `context.statusCode: 401`
so the `@suaveplan/api` error-handler middleware translates it to an
HTTP 401 response.
## Features
- Depends only on the typed `c.get("user")` slot installed by
`sessionMiddleware` — no dynamic service lookup, no per-request
Better-Auth call.
- Single failure mode: `AUTH_UNAUTHENTICATED` with `statusCode: 401`
attached to the error context.
- Idempotent — applying the middleware twice in the same chain costs
one type narrow + one truthy check.
## Usage: Basic
```ts
import { requireAuth } from "@suaveplan/api-plugin-auth/middleware/require-auth";
app.use("/private/*", requireAuth());
app.get("/private/me", (c) => c.json(c.get("user")));
```
## Usage: Advanced
Compose `requireAuth` with `requireRole` and `requireEmailVerified` to
build a sliding scale of access controls. Each middleware throws its
own typed `AuthError`, so the error handler can emit specific OpenAPI
4xx responses per stage:
```ts
import {
requireAuth,
requireRole,
requireEmailVerified,
} from "@suaveplan/api-plugin-auth/middleware";
app.use("/admin/*", requireAuth(), requireRole("admin"));
app.use("/billing/*", requireAuth(), requireEmailVerified());
```
## API Reference
### `function requireAuth(): MiddlewareHandler`
Returns a Hono middleware handler. Throws
`AuthError(AUTH_UNAUTHENTICATED, "Authentication required",
{ context: { statusCode: 401 } })` when `c.get("user")` is null or
undefined. Calls `next()` otherwise.
## Implementation Notes
- The check uses `=== null || === undefined` rather than a truthy
check so a fully-populated `Principal` with falsy values (e.g.
`email: ""` after the principal mapping normalised it out) still
passes. `Principal` objects are never falsy.
- Throwing — not returning a 401 response directly — keeps the error
shape consistent with `requireRole` and `requireEmailVerified`; all
three flow through the `@suaveplan/api` error-handler, which both
serialises the error envelope and emits the OTel span event.
- The `statusCode: 401` field is read by the error-handler middleware
installed by `@suaveplan/api`; the auth plugin does not own status
mapping itself.

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { AuthError } from "../error-codes/error-codes.js";
import type { Principal } from "../principal/principal.js";
import { requireAuth } from "./require-auth.js";
describe("requireAuth", () => {
it("raises AUTH_UNAUTHENTICATED on null user", {
timeout: 5000,
}, async () => {
const mw = requireAuth();
let caught: AuthError | undefined;
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", null);
try {
await mw(c, next);
} catch (err) {
caught = err as AuthError;
}
return c.text("done");
});
app.get("/", (c) => c.text("never"));
await app.request("/");
expect(caught).toBeInstanceOf(AuthError);
expect(caught?.code).toBe("AUTH.UNAUTHENTICATED");
expect(caught?.context?.["statusCode"]).toBe(401);
});
it("passes typed Principal downstream", { timeout: 5000 }, async () => {
const principal: Principal = {
id: "u1",
emailVerified: true,
roles: ["admin"],
};
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", principal);
await next();
});
app.use("/private", requireAuth());
app.get("/private", (c) => c.json({ id: c.get("user")?.id ?? "anon" }));
const res = await app.request("/private");
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ id: "u1" });
});
});

View File

@@ -0,0 +1,28 @@
import type { MiddlewareHandler } from "hono";
import { AUTH_CODES, AuthError } from "../error-codes/error-codes.js";
import "./context-augmentation.js";
/**
* Construct a `requireAuth` middleware. When the request context has
* no `user` (anonymous), the middleware throws
* `AuthError(AUTH_UNAUTHENTICATED)` carrying `context.statusCode: 401`
* so the `@suaveplan/api` error-handler translates it to HTTP 401.
*
* Returns the typed `Principal` to downstream handlers — the augmented
* `ContextVariableMap` already narrows `c.get("user")` to
* `Principal | null`, so the throw guarantees `c.get("user")` is
* non-null on the next line.
*/
export function requireAuth(): MiddlewareHandler {
return async function requireAuthHandler(c, next) {
const principal = c.get("user");
if (principal === null || principal === undefined) {
throw new AuthError(
AUTH_CODES.UNAUTHENTICATED,
"Authentication required",
{ context: { statusCode: 401 } }
);
}
await next();
};
}

View File

@@ -0,0 +1,73 @@
# middleware/require-email-verified
## Purpose
`requireEmailVerified()` is the Hono middleware factory that gates a
route on the principal's email having been verified. Routes that
trigger sensitive operations (password change, billing portal,
account deletion) use this guard so unverified accounts cannot move
forward without first completing the verification flow.
## Features
- Reads `c.get("user")` directly — no Better-Auth lookup per request.
- Two failure modes: `AUTH_UNAUTHENTICATED` (401) for anonymous
callers and `AUTH_EMAIL_NOT_VERIFIED` (403) for unverified ones.
- Strict boolean check: `emailVerified !== true` fails, so undefined
/ null are treated as unverified.
## Usage: Basic
```ts
import {
requireAuth,
requireEmailVerified,
} from "@suaveplan/api-plugin-auth/middleware";
app.use("/billing/*", requireAuth(), requireEmailVerified());
```
## Usage: Advanced
Use the middleware as a precondition for routes that trigger
high-trust workflows. Combine with `requireRole` so admin routes
require BOTH verified email AND the admin role:
```ts
import {
requireAuth,
requireEmailVerified,
requireRole,
} from "@suaveplan/api-plugin-auth/middleware";
app.use(
"/admin/users/:id/delete",
requireAuth(),
requireEmailVerified(),
requireRole("admin")
);
```
## API Reference
### `function requireEmailVerified(): MiddlewareHandler`
- Throws `AuthError(AUTH_UNAUTHENTICATED)` (401) when `c.get("user")`
is null/undefined.
- Throws `AuthError(AUTH_EMAIL_NOT_VERIFIED)` (403) when
`principal.emailVerified !== true`.
- Calls `next()` otherwise.
## Implementation Notes
- The check uses `!== true` rather than `=== false` so an explicitly
`undefined` / `null` `emailVerified` (which the default principal
mapping should not emit, but a custom mapping might) is treated as
unverified. Failing closed is the right default for an auth gate.
- The middleware does not auto-trigger a re-send of the verification
email — that is the route handler's responsibility once the 403
reaches the client.
- The 403 error carries `context.statusCode: 403` for the error
handler; no other context fields are attached because the principal
identity does not need to be re-emitted on a 403 (it is already
available in the trace span).

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import type { AuthError } from "../error-codes/error-codes.js";
import type { Principal } from "../principal/principal.js";
import { requireEmailVerified } from "./require-email-verified.js";
function buildApp(principal: Principal | null) {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", principal);
await next();
});
app.use("/p", requireEmailVerified());
app.get("/p", (c) => c.text("ok"));
return app;
}
describe("requireEmailVerified", () => {
it("raises AUTH_EMAIL_NOT_VERIFIED when false", {
timeout: 5000,
}, async () => {
let caught: AuthError | undefined;
const app = buildApp({
id: "u1",
emailVerified: false,
roles: [],
});
app.onError((err, c) => {
caught = err as AuthError;
return c.text("err", 500);
});
await app.request("/p");
expect(caught?.code).toBe("AUTH.EMAIL_NOT_VERIFIED");
expect(caught?.context?.["statusCode"]).toBe(403);
});
it("passes when emailVerified is true", { timeout: 5000 }, async () => {
const app = buildApp({
id: "u1",
emailVerified: true,
roles: [],
});
const res = await app.request("/p");
expect(res.status).toBe(200);
});
it("raises AUTH_UNAUTHENTICATED on null user", {
timeout: 5000,
}, async () => {
let caught: AuthError | undefined;
const app = buildApp(null);
app.onError((err, c) => {
caught = err as AuthError;
return c.text("err", 500);
});
await app.request("/p");
expect(caught?.code).toBe("AUTH.UNAUTHENTICATED");
});
});

View File

@@ -0,0 +1,31 @@
import type { MiddlewareHandler } from "hono";
import { AUTH_CODES, AuthError } from "../error-codes/error-codes.js";
import "./context-augmentation.js";
/**
* Construct a `requireEmailVerified()` middleware. Depends on
* `requireAuth` upstream — anonymous requests throw
* `AUTH_UNAUTHENTICATED` (401). When the principal exists but
* `principal.emailVerified === false`, throws
* `AUTH_EMAIL_NOT_VERIFIED` (403).
*/
export function requireEmailVerified(): MiddlewareHandler {
return async function requireEmailVerifiedHandler(c, next) {
const principal = c.get("user");
if (principal === null || principal === undefined) {
throw new AuthError(
AUTH_CODES.UNAUTHENTICATED,
"Authentication required",
{ context: { statusCode: 401 } }
);
}
if (principal.emailVerified !== true) {
throw new AuthError(
AUTH_CODES.EMAIL_NOT_VERIFIED,
"Email verification required",
{ context: { statusCode: 403 } }
);
}
await next();
};
}

View File

@@ -0,0 +1,72 @@
# middleware/require-role
## Purpose
`requireRole(...roles)` builds a Hono middleware that gates a route on
the principal carrying at least one of the supplied roles. The factory
throws an `AuthError(AUTH_INVALID_INPUT)` at construction time when
called with no roles — a guard against accidentally building a
no-op middleware.
## Features
- Constant-time membership check against an internal `Set<string>`.
- Two failure modes — `AUTH_UNAUTHENTICATED` (401) for anonymous
callers, `AUTH_INSUFFICIENT_ROLE` (403) for callers whose
`principal.roles` does not intersect the required set.
- Emits `auth.role_check_total{outcome}` for every call, with
`outcome ∈ "allowed" | "denied"`.
- Carries `context.required` and `context.actual` on the 403 error so
audit logs / OpenAPI responses can show the precise mismatch.
## Usage: Basic
```ts
import { requireAuth, requireRole } from "@suaveplan/api-plugin-auth/middleware";
app.use("/admin/*", requireAuth(), requireRole("admin"));
```
## Usage: Advanced
Compose multiple roles for resource-level scoping. Combine with
`AuthService` lookups to project derived roles (e.g. organisation
membership) onto the request before the middleware runs:
```ts
import { requireAuth, requireRole } from "@suaveplan/api-plugin-auth/middleware";
app.use("/projects/:id/edit", requireAuth(), async (c, next) => {
const principal = c.get("user");
if (!principal) return next();
const isOwner = await checkOwnership(c.req.param("id"), principal.id);
if (isOwner) {
c.set("user", { ...principal, roles: [...principal.roles, "owner"] });
}
await next();
}, requireRole("admin", "owner"));
```
## API Reference
### `function requireRole(...roles: readonly string[]): MiddlewareHandler`
- Throws `AuthError(AUTH_INVALID_INPUT)` at construction when `roles`
is empty.
- Throws `AuthError(AUTH_UNAUTHENTICATED)` (401) when `c.get("user")`
is null/undefined.
- Throws `AuthError(AUTH_INSUFFICIENT_ROLE)` (403) when no role in
`principal.roles` is in the required set.
- Emits `auth.role_check_total{outcome="allowed"|"denied"}` on every
request.
## Implementation Notes
- The required-roles set is constructed once per factory call. Reusing
the same middleware across multiple routes is constant memory.
- The error context carries the actual roles as well as the required
ones so the 403 response can surface a "missing role: admin"
message in operator logs without re-reading the principal.
- The middleware never inspects `principal.tenantId` — tenant-scoped
role gates are the responsibility of `@suaveplan/api-tenant`'s
middleware, layered downstream.

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { AuthError } from "../error-codes/error-codes.js";
import { resetAuthInstruments } from "../otel/instruments.js";
import type { Principal } from "../principal/principal.js";
import { requireRole } from "./require-role.js";
function buildApp(principal: Principal | null, role: string) {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", principal);
await next();
});
app.use("/p", requireRole(role));
app.get("/p", (c) => c.text("ok"));
return app;
}
describe("requireRole", () => {
it("rejects empty role list", { timeout: 5000 }, () => {
expect(() => requireRole()).toThrow(AuthError);
});
it("intersects roles correctly", { timeout: 5000 }, async () => {
resetAuthInstruments();
const principal: Principal = {
id: "u1",
emailVerified: true,
roles: ["owner"],
};
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", principal);
await next();
});
app.use("/p", requireRole("admin", "owner"));
app.get("/p", (c) => c.text("ok"));
const res = await app.request("/p");
expect(res.status).toBe(200);
});
it("raises AUTH_INSUFFICIENT_ROLE on no intersection", {
timeout: 5000,
}, async () => {
resetAuthInstruments();
const principal: Principal = {
id: "u1",
emailVerified: true,
roles: ["user"],
};
let caught: AuthError | undefined;
const app = buildApp(principal, "admin");
app.onError((err, c) => {
caught = err as AuthError;
return c.text("err", 500);
});
await app.request("/p");
expect(caught?.code).toBe("AUTH.INSUFFICIENT_ROLE");
expect(caught?.context?.["statusCode"]).toBe(403);
expect(caught?.context?.["required"]).toEqual(["admin"]);
expect(caught?.context?.["actual"]).toEqual(["user"]);
});
it("raises AUTH_UNAUTHENTICATED on null user", {
timeout: 5000,
}, async () => {
resetAuthInstruments();
let caught: AuthError | undefined;
const app = buildApp(null, "admin");
app.onError((err, c) => {
caught = err as AuthError;
return c.text("err", 500);
});
await app.request("/p");
expect(caught?.code).toBe("AUTH.UNAUTHENTICATED");
});
});

View File

@@ -0,0 +1,59 @@
import type { MiddlewareHandler } from "hono";
import { AUTH_CODES, AuthError } from "../error-codes/error-codes.js";
import { getAuthInstruments } from "../otel/instruments.js";
import "./context-augmentation.js";
/**
* Construct a `requireRole(...)` middleware. Depends on `requireAuth`
* upstream — if no principal is present the middleware throws
* `AUTH_UNAUTHENTICATED` (401). When a principal exists but its
* `roles` do not intersect the supplied set, throws
* `AUTH_INSUFFICIENT_ROLE` (403) carrying `context.required: roles`.
*
* Increments `auth.role_check_total{outcome}` on every call.
*/
export function requireRole(...roles: readonly string[]): MiddlewareHandler {
if (roles.length === 0) {
throw new AuthError(
AUTH_CODES.INVALID_INPUT,
"requireRole(): at least one role is required",
{ context: { statusCode: 500 } }
);
}
const requiredSet = new Set(roles);
return async function requireRoleHandler(c, next) {
const instruments = getAuthInstruments();
const principal = c.get("user");
if (principal === null || principal === undefined) {
instruments.roleCheckTotal.add(1, { outcome: "denied" });
throw new AuthError(
AUTH_CODES.UNAUTHENTICATED,
"Authentication required",
{ context: { statusCode: 401 } }
);
}
let allowed = false;
for (const role of principal.roles) {
if (requiredSet.has(role)) {
allowed = true;
break;
}
}
if (!allowed) {
instruments.roleCheckTotal.add(1, { outcome: "denied" });
throw new AuthError(
AUTH_CODES.INSUFFICIENT_ROLE,
`Required one of: ${roles.join(", ")}`,
{
context: {
statusCode: 403,
required: [...roles],
actual: [...principal.roles],
},
}
);
}
instruments.roleCheckTotal.add(1, { outcome: "allowed" });
await next();
};
}

View File

@@ -0,0 +1,97 @@
# middleware/session
## Purpose
`sessionMiddleware` is the global Hono middleware that resolves every
incoming request's Better-Auth session and exposes a typed `Principal`
through `c.get("user")`. It is installed by `createAuthPlugin`'s
`onInit` against `ctx.app` so anonymous routes keep working while
protected handlers can rely on `c.get("user")` being either a real
`Principal` or `null` — never undefined.
## Features
- Calls `auth.api.getSession({ headers: c.req.raw.headers })` once per
request and caches the result on the context.
- Sets `c.set("user", principal | null)` and `c.set("session", session
| null)` via the augmented `ContextVariableMap` (see
`./context-augmentation.ts`).
- Default mapping uses `mapBetterAuthUserToPrincipal`; opt-out via
`principalMapping` for plugin-extended session shapes.
- Records `auth.sessions_resolved_total{outcome}` and
`auth.session_resolve_duration_ms` for every request, regardless of
outcome.
- Failures during session resolution set `c.get("user")` to `null`,
log at `warn`, and let the request proceed — protected routes still
throw `AUTH_UNAUTHENTICATED` via `requireAuth()` so the surface is
unaffected.
## Usage: Basic
```ts
import { sessionMiddleware } from "@suaveplan/api-plugin-auth/middleware/session";
import { betterAuth } from "better-auth";
const auth = betterAuth({ /* … */ });
app.use("*", sessionMiddleware({ auth }));
```
## Usage: Advanced
Pair the middleware with a custom mapping and a host-supplied logger
so role projection and trace correlation are wired in one place:
```ts
import { sessionMiddleware } from "@suaveplan/api-plugin-auth/middleware/session";
import { mapBetterAuthUserToPrincipal } from "@suaveplan/api-plugin-auth/principal/principal";
app.use(
"*",
sessionMiddleware({
auth,
logger: ctx.logger,
principalMapping: (view) => {
const base = mapBetterAuthUserToPrincipal(view);
const session = view.session as { roles?: string[] };
return { ...base, roles: [...base.roles, ...(session.roles ?? [])] };
},
})
);
```
## API Reference
### `function sessionMiddleware(options): MiddlewareHandler`
`options.auth` is required; `options.principalMapping` and
`options.logger` are optional. The returned middleware is async and
mutates `c`'s context variables — it never throws.
### `interface SessionMiddlewareOptions`
- `auth: Auth` — the Better-Auth instance.
- `principalMapping?: PrincipalMapping` — defaults to
`mapBetterAuthUserToPrincipal`.
- `logger?: LoggerLike` — defaults to a no-op logger.
### `type PrincipalMapping = (view: BetterAuthSessionView) => Principal`
### Module augmentation
`./context-augmentation.ts` declares `interface ContextVariableMap {
user: Principal | null; session: BetterAuthSessionResult }` so the
typed `c.get("user")` lookups work without a cast in every consumer.
## Implementation Notes
- The middleware does NOT throw on failure — anonymous routes must keep
working even when Better-Auth's session store is degraded. Protected
routes are responsible for raising `AUTH_UNAUTHENTICATED` via
`requireAuth()` only when they need an authenticated user.
- `performance.now()` is the duration source; the value is recorded in
milliseconds (matching the histogram's declared unit).
- The outcome label is bounded to `"authenticated"` / `"anonymous"` /
`"error"` so dashboards can rely on a fixed cardinality.
- Non-`Error` throwables from Better-Auth are normalised via
`String(err)` — the middleware does not assume Better-Auth's failure
modes are pure `Error` instances.

View File

@@ -0,0 +1,18 @@
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { ContextVariableMap } from "hono";
import type { Principal } from "../principal/principal.js";
import type { BetterAuthSessionResult } from "../services/auth-service.js";
import "./context-augmentation.js";
describe("Hono ContextVariableMap conformance", () => {
it("ContextVariableMap augmented with user + session", {
timeout: 5000,
}, () => {
expectTypeOf<
ContextVariableMap["user"]
>().toEqualTypeOf<Principal | null>();
expectTypeOf<
ContextVariableMap["session"]
>().toEqualTypeOf<BetterAuthSessionResult>();
});
});

View File

@@ -0,0 +1,136 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Auth } from "better-auth";
import { Hono } from "hono";
import { resetAuthInstruments } from "../otel/instruments.js";
import type { Principal } from "../principal/principal.js";
import { sessionMiddleware } from "./session.js";
function makeAuth(returns: unknown, opts?: { throws?: boolean }): Auth {
return {
api: {
getSession: async () => {
if (opts?.throws === true) {
throw new Error("better-auth boom");
}
return returns;
},
},
} as unknown as Auth;
}
describe("sessionMiddleware", () => {
it("anonymous request has null user", { timeout: 5000 }, async () => {
resetAuthInstruments();
const auth = makeAuth(null);
const app = new Hono();
app.use("*", sessionMiddleware({ auth }));
app.get("/", (c) =>
c.json({
user: c.get("user"),
session: c.get("session"),
})
);
const res = await app.request("/");
const body = (await res.json()) as {
user: Principal | null;
session: unknown;
};
expect(body.user).toBeNull();
expect(body.session).toBeNull();
});
it("authenticated request has typed Principal", {
timeout: 5000,
}, async () => {
resetAuthInstruments();
const session = {
user: {
id: "u1",
email: "a@b.com",
emailVerified: true,
name: "A",
},
session: { id: "s1", activeOrganizationId: "org1" },
};
const auth = makeAuth(session);
const app = new Hono();
app.use("*", sessionMiddleware({ auth }));
app.get("/me", (c) => c.json({ user: c.get("user") }));
const res = await app.request("/me");
const body = (await res.json()) as { user: Principal };
expect(body.user.id).toBe("u1");
expect(body.user.emailVerified).toBe(true);
expect(body.user.tenantId).toBe("org1");
});
it("setAnonymous on better-auth failure (does not throw)", {
timeout: 5000,
}, async () => {
resetAuthInstruments();
const auth = makeAuth(null, { throws: true });
const logged: { msg: string; ctx?: unknown }[] = [];
const logger = {
debug: () => undefined,
info: () => undefined,
warn: (msg: string, ctx?: unknown) => {
logged.push({ msg, ctx });
},
error: () => undefined,
};
const app = new Hono();
app.use("*", sessionMiddleware({ auth, logger }));
app.get("/", (c) => c.json({ user: c.get("user") }));
const res = await app.request("/");
const body = (await res.json()) as { user: Principal | null };
expect(res.status).toBe(200);
expect(body.user).toBeNull();
expect(logged.length).toBe(1);
});
it("applies custom principalMapping", { timeout: 5000 }, async () => {
resetAuthInstruments();
const session = {
user: { id: "u2", emailVerified: false },
session: {},
};
const auth = makeAuth(session);
const app = new Hono();
app.use(
"*",
sessionMiddleware({
auth,
principalMapping: () => ({
id: "OVERRIDE",
emailVerified: true,
roles: ["service"],
}),
})
);
app.get("/", (c) => c.json({ user: c.get("user") }));
const res = await app.request("/");
const body = (await res.json()) as { user: Principal };
expect(body.user.id).toBe("OVERRIDE");
expect(body.user.roles).toEqual(["service"]);
});
it("non-string error from auth still falls back to anonymous", {
timeout: 5000,
}, async () => {
resetAuthInstruments();
const auth = {
api: {
getSession: async () => {
// Throw a non-Error to exercise the String(err) branch.
const literal: unknown = "explode";
throw literal;
},
},
} as unknown as Auth;
const app = new Hono();
app.use("*", sessionMiddleware({ auth }));
app.get("/", (c) => c.json({ user: c.get("user") }));
const res = await app.request("/");
const body = (await res.json()) as { user: Principal | null };
expect(body.user).toBeNull();
});
});

View File

@@ -0,0 +1,105 @@
import type { LoggerLike } from "@suaveplan/types";
import type { Auth } from "better-auth";
import type { Context, MiddlewareHandler } from "hono";
import {
type AuthInstruments,
getAuthInstruments,
} from "../otel/instruments.js";
import {
type BetterAuthSessionView,
mapBetterAuthUserToPrincipal,
type Principal,
} from "../principal/principal.js";
import type { BetterAuthSessionResult } from "../services/auth-service.js";
import "./context-augmentation.js";
/**
* Mapper signature accepted by `sessionMiddleware`. Receives the raw
* Better-Auth `(user, session)` payload and returns the canonical
* `Principal`. Defaults to `mapBetterAuthUserToPrincipal`.
*/
export type PrincipalMapping = (view: BetterAuthSessionView) => Principal;
/**
* Construction options for `sessionMiddleware`. All fields except
* `auth` are optional; the middleware falls back to the default
* principal mapping when none is supplied.
*/
export interface SessionMiddlewareOptions {
readonly auth: Auth;
readonly principalMapping?: PrincipalMapping;
readonly logger?: LoggerLike;
}
const noop = () => undefined;
const noopLogger: LoggerLike = {
debug: noop,
info: noop,
warn: noop,
error: noop,
};
/**
* Construct the global Hono session-resolver middleware. On every
* request:
*
* 1. Calls `auth.api.getSession({ headers })`.
* 2. If a session is returned, maps it via `principalMapping ??
* mapBetterAuthUserToPrincipal` and sets both `c.set("user",
* principal)` and `c.set("session", session)`.
* 3. If no session is returned, sets both slots to `null`.
* 4. If Better-Auth throws, sets both slots to `null`, increments the
* `error` counter outcome, logs at `warn`, and proceeds — anonymous
* routes keep working; protected routes raise
* `AUTH_UNAUTHENTICATED` via `requireAuth()` downstream.
*
* Records `auth.sessions_resolved_total{outcome}` +
* `auth.session_resolve_duration_ms{outcome}` on every call regardless
* of outcome.
*/
export function sessionMiddleware(
options: SessionMiddlewareOptions
): MiddlewareHandler {
const { auth } = options;
const map: PrincipalMapping =
options.principalMapping ?? mapBetterAuthUserToPrincipal;
const logger = options.logger ?? noopLogger;
return async function sessionHandler(c, next) {
const instruments: AuthInstruments = getAuthInstruments();
const started = performance.now();
let outcome: "authenticated" | "anonymous" | "error" = "anonymous";
try {
const result = (await auth.api.getSession({
headers: c.req.raw.headers,
})) as BetterAuthSessionResult;
if (result === null) {
setAnonymous(c);
} else {
const principal = map(
result as unknown as BetterAuthSessionView
);
c.set("user", principal);
c.set("session", result);
outcome = "authenticated";
}
} catch (err) {
outcome = "error";
setAnonymous(c);
logger.warn("auth: session resolve failed", {
error: err instanceof Error ? err.message : String(err),
});
} finally {
const duration = performance.now() - started;
instruments.sessionResolveDurationMs.record(duration, {
outcome,
});
instruments.sessionsResolvedTotal.add(1, { outcome });
}
await next();
};
}
function setAnonymous(c: Context): void {
c.set("user", null);
c.set("session", null);
}

View File

@@ -0,0 +1,5 @@
export {
type AuthInstruments,
getAuthInstruments,
resetAuthInstruments,
} from "./instruments.js";

View File

@@ -0,0 +1,103 @@
# otel/instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The instruments module owns the OpenTelemetry counters and histogram
emitted by every code path inside `@suaveplan/api-plugin-auth`. The
bundle is lazily constructed against the global `MeterProvider` so
operators can wire an SDK before the plugin loads without import-time
side effects, and tests can call `resetAuthInstruments()` between
suites to rebind against a fresh in-memory provider.
## Features
- Five named instruments — `sessionsResolvedTotal`,
`sessionResolveDurationMs`, `roleCheckTotal`, `signupsTotal`,
`signinsTotal` — bundled in one `AuthInstruments` value.
- Lazy global-provider binding via `metrics.getMeter(...)` so the
instruments resolve at first call, never at import time.
- `resetAuthInstruments()` for deterministic test setup.
- Stable instrument names (`auth.*`) mirrored by the spec delta so
operators can scrape known metric IDs without code inspection.
## Usage: Basic
```ts
import { getAuthInstruments } from "@suaveplan/api-plugin-auth/otel/instruments";
const ins = getAuthInstruments();
ins.sessionsResolvedTotal.add(1, { outcome: "authenticated" });
ins.sessionResolveDurationMs.record(durationMs, { outcome: "authenticated" });
```
## Usage: Advanced
For testing, install an in-memory `MeterProvider`, then reset the
cached instruments so the next access binds against it. The plugin's
own test polyfill (`tests/setup/polyfills.ts`) does exactly this for
every test file in the package.
```ts
import { metrics } from "@opentelemetry/api";
import {
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
AggregationTemporality,
} from "@opentelemetry/sdk-metrics";
import {
getAuthInstruments,
resetAuthInstruments,
} from "@suaveplan/api-plugin-auth/otel/instruments";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetAuthInstruments();
const ins = getAuthInstruments();
ins.signinsTotal.add(1, { outcome: "failure" });
await provider.forceFlush();
const collected = exporter.getMetrics();
```
## API Reference
### `interface AuthInstruments`
- `sessionsResolvedTotal: Counter` — `auth.sessions_resolved_total`.
- `sessionResolveDurationMs: Histogram` — `auth.session_resolve_duration_ms`
(unit `ms`).
- `roleCheckTotal: Counter` — `auth.role_check_total`.
- `signupsTotal: Counter` — `auth.signups_total`.
- `signinsTotal: Counter` — `auth.signins_total`.
### `getAuthInstruments(): AuthInstruments`
Returns the cached bundle, building it on first call. Safe to call
concurrently — the cache is single-writer because the function is
synchronous.
### `resetAuthInstruments(): void`
Drops the cache so the next `getAuthInstruments()` builds afresh
against whatever `MeterProvider` is currently registered.
## Implementation Notes
- The module deliberately reads `metrics.getMeter` lazily — calling it
at import time would resolve the no-op meter before tests install
their provider, which is exactly the failure mode that the
`resetAuthInstruments()` helper exists to prevent.
- Counter / histogram attribute keys (`outcome`) are documented per
call site rather than enforced at the meter — OpenTelemetry's
attribute API is intentionally untyped, but the call-site values are
string literals (`"authenticated"`, `"anonymous"`, `"error"`,
`"allowed"`, `"denied"`, `"success"`, `"failure"`) so dashboards can
rely on the cardinality being bounded.
- Histogram unit is `ms` so dashboards do not need to renormalise.

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import { getAuthInstruments, resetAuthInstruments } from "./instruments.js";
describe("getAuthInstruments", () => {
it("returns the same cached bundle across calls", {
timeout: 5000,
}, () => {
resetAuthInstruments();
const a = getAuthInstruments();
const b = getAuthInstruments();
expect(a).toBe(b);
});
it("emits counter + histogram observations", {
timeout: 10_000,
}, async () => {
resetAuthInstruments();
const ins = getAuthInstruments();
ins.sessionsResolvedTotal.add(1, { outcome: "authenticated" });
ins.sessionResolveDurationMs.record(12, { outcome: "authenticated" });
ins.roleCheckTotal.add(1, { outcome: "allowed" });
ins.signupsTotal.add(1);
ins.signinsTotal.add(1, { outcome: "success" });
const metrics = await flushTestMetrics();
const names = metrics
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("auth.sessions_resolved_total");
expect(names).toContain("auth.session_resolve_duration_ms");
expect(names).toContain("auth.role_check_total");
expect(names).toContain("auth.signups_total");
expect(names).toContain("auth.signins_total");
});
it("counter + histogram emit per session resolve", {
timeout: 10_000,
}, async () => {
resetAuthInstruments();
const ins = getAuthInstruments();
ins.sessionsResolvedTotal.add(1, { outcome: "authenticated" });
ins.sessionResolveDurationMs.record(3.5, {
outcome: "authenticated",
});
const collected = await flushTestMetrics();
const counter = collected
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.find((m) => m.descriptor.name === "auth.sessions_resolved_total");
expect(counter).toBeDefined();
});
it("role-check counter emits on requireRole", {
timeout: 10_000,
}, async () => {
resetAuthInstruments();
const ins = getAuthInstruments();
ins.roleCheckTotal.add(1, { outcome: "denied" });
const collected = await flushTestMetrics();
const counter = collected
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.find((m) => m.descriptor.name === "auth.role_check_total");
expect(counter).toBeDefined();
});
});

View File

@@ -0,0 +1,72 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* Bundle of OpenTelemetry instruments emitted by the auth plugin.
*
* - `sessionsResolvedTotal{outcome}` — incremented on every call to the
* session middleware. `outcome ∈ "authenticated" | "anonymous" |
* "error"`.
* - `sessionResolveDurationMs` — histogram of session-resolve duration
* in ms.
* - `roleCheckTotal{outcome}` — incremented on every `requireRole`
* call. `outcome ∈ "allowed" | "denied"`.
* - `signupsTotal` — incremented every time `auth.api.signUpEmail`
* completes (success only).
* - `signinsTotal{outcome}` — incremented every time `auth.api.signInEmail`
* is exercised; `outcome ∈ "success" | "failure"`.
*/
export interface AuthInstruments {
readonly sessionsResolvedTotal: Counter;
readonly sessionResolveDurationMs: Histogram;
readonly roleCheckTotal: Counter;
readonly signupsTotal: Counter;
readonly signinsTotal: Counter;
}
let cached: AuthInstruments | undefined;
/**
* Lazily build (and cache) the auth instruments bundle against the
* current global `MeterProvider`. Call `resetAuthInstruments()` in test
* setup to force a re-bind against a freshly-installed provider.
*/
export function getAuthInstruments(): AuthInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-plugin-auth");
cached = {
sessionsResolvedTotal: meter.createCounter(
"auth.sessions_resolved_total",
{
description:
"Total session-resolution attempts, partitioned by outcome label",
}
),
sessionResolveDurationMs: meter.createHistogram(
"auth.session_resolve_duration_ms",
{
description: "Session-resolution duration in milliseconds",
unit: "ms",
}
),
roleCheckTotal: meter.createCounter("auth.role_check_total", {
description:
"Total role-check attempts via requireRole(), partitioned by outcome label",
}),
signupsTotal: meter.createCounter("auth.signups_total", {
description: "Total successful email/password sign-ups",
}),
signinsTotal: meter.createCounter("auth.signins_total", {
description: "Total sign-in attempts, partitioned by outcome label",
}),
};
return cached;
}
/**
* Drop the cached instrument bundle so the next `getAuthInstruments()`
* binds against the global `MeterProvider` that is in effect at that
* point. Tests call this after installing a fresh `MeterProvider`.
*/
export function resetAuthInstruments(): void {
cached = undefined;
}

View File

@@ -0,0 +1,4 @@
export {
__setBetterAuthFactory,
createAuthPlugin,
} from "./plugin.js";

View File

@@ -0,0 +1,384 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { Auth } from "better-auth";
import type { AuthError } from "../error-codes/error-codes.js";
import type { AuthPluginConfig } from "../types/types.js";
import { __setBetterAuthFactory, createAuthPlugin } from "./plugin.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeAuthStub(): Auth {
return {
handler: async () => new Response("ok", { status: 200 }),
api: { getSession: async () => null },
} as unknown as Auth;
}
afterEach(() => {
__setBetterAuthFactory(undefined);
});
function makeCtx(services: Map<string, unknown>, config: AuthPluginConfig) {
return createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config,
services,
});
}
describe("plugin.onInit lifecycle", () => {
it("applies schema via migrationRunner when registered", {
timeout: 15_000,
}, async () => {
__setBetterAuthFactory(() => makeAuthStub());
const plugin = createAuthPlugin({ betterAuth: {} });
const runs: Array<{ name: string; content: string }> = [];
const runner = {
run: async (
files: ReadonlyArray<{ name: string; content?: string }>
) => {
for (const f of files) {
runs.push({ name: f.name, content: f.content ?? "" });
}
},
};
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
services.set(
"openAPIRegistry",
createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
})
);
services.set("migrationRunner", runner);
await plugin.onInit?.(
makeCtx(services, plugin.defaults as AuthPluginConfig)
);
expect(runs).toHaveLength(1);
expect(runs[0]?.name).toBe("0000-better-auth-schema");
expect(runs[0]?.content?.length).toBeGreaterThan(0);
});
it("applies schema via db fallback when no runner", {
timeout: 15_000,
}, async () => {
__setBetterAuthFactory(() => makeAuthStub());
const plugin = createAuthPlugin({ betterAuth: {} });
const executions: string[] = [];
const db = {
isConnected: true,
query: async () => [],
execute: async (sql: string) => {
executions.push(sql);
return { rowsAffected: 0 };
},
};
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
services.set(
"openAPIRegistry",
createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
})
);
services.set("db", db);
await plugin.onInit?.(
makeCtx(services, plugin.defaults as AuthPluginConfig)
);
expect(executions.length).toBe(1);
expect(executions[0]?.includes("DEFINE TABLE")).toBe(true);
});
it("applySchemaOnInit: false skips schema apply", {
timeout: 15_000,
}, async () => {
__setBetterAuthFactory(() => makeAuthStub());
const plugin = createAuthPlugin({
betterAuth: {},
applySchemaOnInit: false,
});
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
await plugin.onInit?.(
makeCtx(services, plugin.defaults as AuthPluginConfig)
);
expect(services.get("authService")).toBeDefined();
});
it("missing services raise AUTH_SERVICE_NOT_AVAILABLE", {
timeout: 10_000,
}, async () => {
__setBetterAuthFactory(() => makeAuthStub());
const plugin = createAuthPlugin({ betterAuth: {} });
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
let caught: AuthError | undefined;
try {
await plugin.onInit?.(
makeCtx(services, plugin.defaults as AuthPluginConfig)
);
} catch (err) {
caught = err as AuthError;
}
expect(caught?.code).toBe("AUTH.SERVICE_NOT_AVAILABLE");
expect(caught?.context?.["missing"]).toEqual(["migrationRunner", "db"]);
});
it("schema-apply failure raises AUTH_SCHEMA_APPLY_FAILED", {
timeout: 10_000,
}, async () => {
__setBetterAuthFactory(() => makeAuthStub());
const plugin = createAuthPlugin({ betterAuth: {} });
const runner = {
run: async () => {
throw new Error("migration broke");
},
};
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
services.set("migrationRunner", runner);
let caught: AuthError | undefined;
try {
await plugin.onInit?.(
makeCtx(services, plugin.defaults as AuthPluginConfig)
);
} catch (err) {
caught = err as AuthError;
}
expect(caught?.code).toBe("AUTH.SCHEMA_APPLY_FAILED");
});
it("schema-apply failure on db fallback raises AUTH_SCHEMA_APPLY_FAILED", {
timeout: 10_000,
}, async () => {
__setBetterAuthFactory(() => makeAuthStub());
const plugin = createAuthPlugin({ betterAuth: {} });
const db = {
isConnected: true,
query: async () => [],
execute: async () => {
throw new Error("surreal broke");
},
};
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
services.set("db", db);
let caught: AuthError | undefined;
try {
await plugin.onInit?.(
makeCtx(services, plugin.defaults as AuthPluginConfig)
);
} catch (err) {
caught = err as AuthError;
}
expect(caught?.code).toBe("AUTH.SCHEMA_APPLY_FAILED");
});
it("routes registered in both Hono and OpenAPIRegistry", {
timeout: 15_000,
}, async () => {
__setBetterAuthFactory(() => makeAuthStub());
const plugin = createAuthPlugin({
betterAuth: {},
applySchemaOnInit: false,
});
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
await plugin.onInit?.(
makeCtx(services, plugin.defaults as AuthPluginConfig)
);
const routes = registry.getRoutesByPlugin("auth");
expect(routes).toHaveLength(9);
for (const r of routes) {
expect(r.path.startsWith("/auth")).toBe(true);
}
});
it("catch-all routes paths the OpenAPI doc does not advertise", {
timeout: 15_000,
}, async () => {
__setBetterAuthFactory(() => makeAuthStub());
const plugin = createAuthPlugin({
betterAuth: {},
applySchemaOnInit: false,
});
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
await plugin.onInit?.(
makeCtx(services, plugin.defaults as AuthPluginConfig)
);
// /auth/get-session is NOT a registered OpenAPI route — the
// catch-all delegates the path to Better-Auth's handler.
const res = await app.request("/auth/get-session");
expect(res.status).toBe(200);
});
it("no-registry path mounts a catch-all under routePrefix", {
timeout: 15_000,
}, async () => {
__setBetterAuthFactory(() => makeAuthStub());
const plugin = createAuthPlugin({
betterAuth: {},
applySchemaOnInit: false,
});
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
await plugin.onInit?.(
makeCtx(services, plugin.defaults as AuthPluginConfig)
);
const res = await app.request("/auth/get-session");
expect(res.status).toBe(200);
});
it("routePrefix collision raises AUTH_ROUTE_PREFIX_COLLISION", {
timeout: 15_000,
}, async () => {
__setBetterAuthFactory(() => makeAuthStub());
const plugin = createAuthPlugin({
betterAuth: {},
applySchemaOnInit: false,
});
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
});
registry.registerRoute({
method: "get",
path: "/auth/sneaky",
tags: ["other"],
pluginName: "other-plugin",
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
let caught: AuthError | undefined;
try {
await plugin.onInit?.(
makeCtx(services, plugin.defaults as AuthPluginConfig)
);
} catch (err) {
caught = err as AuthError;
}
expect(caught?.code).toBe("AUTH.ROUTE_PREFIX_COLLISION");
expect(caught?.context?.["prefix"]).toBe("/auth");
});
it("registry path mounts at root '/' prefix", {
timeout: 15_000,
}, async () => {
__setBetterAuthFactory(() => makeAuthStub());
const plugin = createAuthPlugin({
betterAuth: {},
routePrefix: "/",
applySchemaOnInit: false,
});
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
await plugin.onInit?.(
makeCtx(services, plugin.defaults as AuthPluginConfig)
);
// With root prefix, registered route paths are the bare
// "/sign-up" etc.
const routes = registry.getRoutesByPlugin("auth");
expect(routes.some((r) => r.path === "/sign-up")).toBe(true);
});
it("hono-only path mounts at root '/' prefix", {
timeout: 15_000,
}, async () => {
__setBetterAuthFactory(() => makeAuthStub());
const plugin = createAuthPlugin({
betterAuth: {},
routePrefix: "/",
applySchemaOnInit: false,
});
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
await plugin.onInit?.(
makeCtx(services, plugin.defaults as AuthPluginConfig)
);
const res = await app.request("/session");
expect([200, 404]).toContain(res.status);
});
it("applies custom principalMapping via plugin options", {
timeout: 15_000,
}, async () => {
__setBetterAuthFactory(() => makeAuthStub());
const plugin = createAuthPlugin({
betterAuth: {},
applySchemaOnInit: false,
principalMapping: () => ({
id: "OVERRIDE",
emailVerified: true,
roles: [],
}),
});
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
await plugin.onInit?.(
makeCtx(services, plugin.defaults as AuthPluginConfig)
);
// The configured mapping should be wired into session middleware.
expect(services.get("authService")).toBeDefined();
});
it("mounts routes via Hono when openAPIRegistry is absent", {
timeout: 15_000,
}, async () => {
__setBetterAuthFactory(() => makeAuthStub());
const warns: string[] = [];
const plugin = createAuthPlugin({
betterAuth: {},
applySchemaOnInit: false,
logger: {
debug: () => undefined,
info: () => undefined,
warn: (msg: string) => {
warns.push(msg);
},
error: () => undefined,
},
});
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
await plugin.onInit?.(
makeCtx(services, plugin.defaults as AuthPluginConfig)
);
expect(
warns.some((m) => m.includes("openAPIRegistry not registered"))
).toBe(true);
// Exercise the mounted sub-route handler to cover its body.
const res = await app.request("/auth/session");
expect([200, 404]).toContain(res.status);
});
});

View File

@@ -0,0 +1,121 @@
# plugin/plugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createAuthPlugin(options)` is the factory that returns a typed
`PluginDescriptor<AuthPluginConfig>` ready to register with the host's
`PluginRegistry`. It is the canonical exemplar of the 3-way
plug-and-play contract every `@suaveplan/api-plugin-*` package follows:
the plugin owns its `.surql` DDL, its Zod schemas, and its OpenAPI
routes, and applies all three during `onInit`.
## Features
- Zod-validated options via `AuthPluginOptionsSchema` — invalid input
raises `AuthError(AUTH_INVALID_INPUT)` carrying the `ZodError`.
- 6-step `onInit` lifecycle: resolve services → apply schema →
construct Better-Auth → register `authService` → register routes →
install session middleware.
- `routePrefix`-aware route mounting — paths are rebuilt at the
prefix the operator chose, both in the served Hono app and in the
`OpenAPIRegistry` snapshot.
- Graceful degradation — when `openAPIRegistry` is absent, routes
mount on the Hono app directly and a warning is logged. When neither
`migrationRunner` nor `db` is available with `applySchemaOnInit: true`,
init fails fast with `AUTH_SERVICE_NOT_AVAILABLE`.
- Collision detection — pre-existing routes under the configured
prefix raise `AUTH_ROUTE_PREFIX_COLLISION`.
## Usage: Basic
```ts
import { defineApp } from "@suaveplan/api";
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
const app = await defineApp()
.name("my-api")
.use(
createAuthPlugin({
betterAuth: {
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: "http://localhost:3000",
emailAndPassword: { enabled: true },
database: surrealAdapter,
},
})
)
.start();
```
## Usage: Advanced
Mount the plugin under a versioned prefix, disable the in-plugin
schema apply step (an external migration tool handles DDL), and inject
a custom principal mapping that decorates the Better-Auth user with
roles derived from the active organisation:
```ts
import { defineApp } from "@suaveplan/api";
import {
createAuthPlugin,
mapBetterAuthUserToPrincipal,
} from "@suaveplan/api-plugin-auth";
const app = await defineApp()
.name("my-api")
.use(
createAuthPlugin({
betterAuth: { /* … */ },
routePrefix: "/api/v1/auth",
applySchemaOnInit: false,
principalMapping: (view) => {
const base = mapBetterAuthUserToPrincipal(view);
const session = view.session as { roles?: string[] };
return {
...base,
roles: [...base.roles, ...(session.roles ?? [])],
};
},
})
)
.start();
```
## API Reference
### `function createAuthPlugin(options: AuthPluginOptions): PluginDescriptor<AuthPluginConfig>`
- Validates `options` via `AuthPluginOptionsSchema`.
- Returns a `PluginDescriptor` with `name: "auth"`, `version: "0.1.0"`,
`tags: ["auth", "security"]`, and the 4 lifecycle hooks (only
`onInit` + `onClose` are wired; `onRegister` and `onReady` are
default no-ops).
- `onInit` runs the canonical 6-step lifecycle described above. It
raises `AUTH_*` codes on the explicit failure modes — see
`../error-codes/error-codes.md`.
- `onClose` releases the cached Better-Auth reference so the registry
can re-init the plugin on subsequent boots.
## Implementation Notes
- The descriptor's `configSchema` is the `AuthPluginOptionsSchema` so
the host registry re-validates the merged config at registration
time. The `defaults` field carries the resolved options so plugins
registered without an explicit config still see the parsed shape.
- The session middleware is installed AFTER routes are registered so
the OpenAPI doc snapshot is taken before any per-request work
begins. Order does not affect runtime behaviour but keeps the
registry's events ordered consistently across reboots.
- The runtime check against `openAPIRegistry === undefined` exists
even though the registry's TS-level slot is non-optional —
`ctx.getService` adds `| undefined` to every slot's return type, so
the absent-runtime case still type-checks.
- `applySchema` prefers the migration runner over the raw `db`
adapter so operators get migration-record tracking when the runner
is wired. Falling back to `applyBetterAuthSchema(db)` ensures the
plugin still boots in lightweight deployments that ship without
`@suaveplan/api-migrations`.

View File

@@ -0,0 +1,201 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { Auth } from "better-auth";
import { AuthError } from "../error-codes/error-codes.js";
import type { AuthPluginConfig } from "../types/types.js";
import { __setBetterAuthFactory, createAuthPlugin } from "./plugin.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeAuthStub(): Auth {
return {
handler: async () => new Response("ok", { status: 200 }),
api: { getSession: async () => null },
} as unknown as Auth;
}
afterEach(() => {
__setBetterAuthFactory(undefined);
});
describe("createAuthPlugin", () => {
it("createAuthPlugin returns valid PluginDescriptor", {
timeout: 5000,
}, () => {
const plugin = createAuthPlugin({ betterAuth: {} });
expect(plugin.name).toBe("auth");
expect(plugin.version).toBe("0.1.0");
expect(typeof plugin.onInit).toBe("function");
expect(typeof plugin.onClose).toBe("function");
});
it("invalid options raise AUTH_INVALID_INPUT", { timeout: 5000 }, () => {
let caught: AuthError | undefined;
try {
createAuthPlugin({} as never);
} catch (err) {
caught = err as AuthError;
}
expect(caught?.code).toBe("AUTH.INVALID_INPUT");
expect(caught?.cause).toBeDefined();
});
it("descriptor exposes resolved defaults", { timeout: 5000 }, () => {
const plugin = createAuthPlugin({
betterAuth: {},
routePrefix: "/v2/auth",
});
expect((plugin.defaults as AuthPluginConfig).routePrefix).toBe(
"/v2/auth"
);
expect((plugin.defaults as AuthPluginConfig).applySchemaOnInit).toBe(
true
);
});
it("onClose clears internal state without error", {
timeout: 5000,
}, async () => {
const plugin = createAuthPlugin({ betterAuth: {} });
const services = new Map<string, unknown>();
const ctrl = new AbortController();
const ctx = createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: ctrl.signal,
config: plugin.defaults as AuthPluginConfig,
services,
});
await plugin.onClose?.(ctx);
});
it("onInit raises AUTH_SERVICE_NOT_AVAILABLE when app missing", {
timeout: 10_000,
}, async () => {
__setBetterAuthFactory(() => makeAuthStub());
const plugin = createAuthPlugin({
betterAuth: {},
applySchemaOnInit: false,
});
const services = new Map<string, unknown>();
const ctrl = new AbortController();
const ctx = createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: ctrl.signal,
config: plugin.defaults as AuthPluginConfig,
services,
});
let caught: AuthError | undefined;
try {
await plugin.onInit?.(ctx);
} catch (err) {
caught = err as AuthError;
}
expect(caught?.code).toBe("AUTH.SERVICE_NOT_AVAILABLE");
});
it("onInit completes with applySchemaOnInit: false + stubbed auth", {
timeout: 15_000,
}, async () => {
__setBetterAuthFactory(() => makeAuthStub());
const plugin = createAuthPlugin({
betterAuth: {},
applySchemaOnInit: false,
});
const services = new Map<string, unknown>();
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.1.0" },
});
services.set("app", app);
services.set("openAPIRegistry", registry);
services.set("logger", noopLogger);
services.set("eventBus", new EventBus());
services.set("clock", () => Date.now());
const ctrl = new AbortController();
const ctx = createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: ctrl.signal,
config: plugin.defaults as AuthPluginConfig,
services,
});
await plugin.onInit?.(ctx);
expect(services.get("authService")).toBeDefined();
expect(registry.getRoutesByPlugin("auth")).toHaveLength(9);
});
it("default factory delegates to real betterAuth", {
timeout: 10_000,
}, async () => {
// Restore the default factory and exercise the real `betterAuth`
// import — invoked with deliberately bad input so the test
// surfaces the BETTER_AUTH_INIT_FAILED translation rather than
// standing up a real Better-Auth instance.
__setBetterAuthFactory(undefined);
const plugin = createAuthPlugin({
betterAuth: { __invalid__: Symbol("nope") as unknown as string },
applySchemaOnInit: false,
});
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
const ctx = createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as AuthPluginConfig,
services,
});
let caught: AuthError | undefined;
try {
await plugin.onInit?.(ctx);
} catch (err) {
caught = err as AuthError;
}
// Real betterAuth may succeed with default in-memory config
// depending on version; both outcomes count as covering the
// default factory path. We only assert NO exception other than
// an AuthError reaches here.
if (caught !== undefined) {
expect(caught).toBeInstanceOf(AuthError);
}
});
it("onInit raises AUTH_BETTER_AUTH_INIT_FAILED when betterAuth throws", {
timeout: 10_000,
}, async () => {
__setBetterAuthFactory(() => {
throw new Error("better-auth: bad config");
});
const plugin = createAuthPlugin({
betterAuth: {},
applySchemaOnInit: false,
});
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
const ctrl = new AbortController();
const ctx = createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: ctrl.signal,
config: plugin.defaults as AuthPluginConfig,
services,
});
let caught: AuthError | undefined;
try {
await plugin.onInit?.(ctx);
} catch (err) {
caught = err as AuthError;
}
expect(caught?.code).toBe("AUTH.BETTER_AUTH_INIT_FAILED");
});
});

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

View File

@@ -0,0 +1,7 @@
export type {
BetterAuthSessionRecord,
BetterAuthSessionView,
BetterAuthUserView,
Principal,
} from "./principal.js";
export { mapBetterAuthUserToPrincipal } from "./principal.js";

View File

@@ -0,0 +1,108 @@
# principal
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The principal module defines the canonical authenticated-user shape used
across the entire `@suaveplan/api-plugin-*` family and the default
mapping from a Better-Auth `(user, session)` pair into that shape. Every
downstream plugin (user, vault, organisations, …) reads `c.get("user")`
typed as `Principal | null` thanks to the `ContextVariableMap`
augmentation installed by the session middleware.
## Features
- A single, immutable `Principal` interface — every field is `readonly`
so handlers cannot mutate the resolved identity.
- A pure default mapping (`mapBetterAuthUserToPrincipal`) that handles
null / undefined input gracefully and only emits optional fields when
they carry a value.
- Strict-but-narrow input typing (`BetterAuthSessionView`) — the mapper
tolerates any Better-Auth plugin set that extends the user / session
rows because the index signature on each view keeps unknown fields.
- Single failure mode (`AUTH_PRINCIPAL_MAPPING_FAILED`) on a missing
`user.id`, raised through `AuthError` with `context.statusCode: 500`.
## Usage: Basic
```ts
import { mapBetterAuthUserToPrincipal } from "@suaveplan/api-plugin-auth/principal/principal";
const principal = mapBetterAuthUserToPrincipal(session);
// principal.id === session.user.id
```
## Usage: Advanced
Override the mapping at plugin construction to project Better-Auth
plugin-extended fields onto your own `Principal` extension. The custom
mapper sits on top of the default, layering tenancy / role inference
from the session row.
```ts
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
import { mapBetterAuthUserToPrincipal } from "@suaveplan/api-plugin-auth/principal/principal";
const plugin = createAuthPlugin({
betterAuth: { /* … */ },
principalMapping: (view) => {
const base = mapBetterAuthUserToPrincipal(view);
const orgRoles = (view.session as { roles?: string[] }).roles ?? [];
return {
...base,
roles: [...base.roles, ...orgRoles],
metadata: {
...(base.metadata ?? {}),
impersonatedBy: view.session["impersonatedBy"],
},
};
},
});
```
## API Reference
### `interface Principal`
- `id: string` — Better-Auth `user.id`. Always populated.
- `email?: string` — present when Better-Auth has an email. Empty
strings are normalised out.
- `emailVerified: boolean` — defaults to `false` when Better-Auth has
not seen verification.
- `roles: readonly string[]` — empty array by default; populated by host
middleware or a custom `principalMapping`.
- `tenantId?: string` — derived from `session.activeOrganizationId`
when present.
- `metadata?: Record<string, unknown>` — `name` and `image` carried from
the Better-Auth user row when set.
### `interface BetterAuthSessionView`
- `user: BetterAuthUserView` — required; `id` is mandatory.
- `session: BetterAuthSessionRecord` — required; carries
`activeOrganizationId` and `impersonatedBy` when set.
### `function mapBetterAuthUserToPrincipal(view): Principal`
Throws `AuthError(AUTH_PRINCIPAL_MAPPING_FAILED)` when `view.user.id` is
absent or non-string. Pure — no I/O, no side effects, no clock reads.
## Implementation Notes
- The mapping intentionally returns plain readonly fields rather than a
frozen object — `Object.freeze` would block downstream plugins from
spreading a base `Principal` into an extended shape via the custom
`principalMapping` hook.
- `roles` defaults to `[]` rather than `undefined` so call-site code
(`principal.roles.includes(...)`) is safe without an extra null
check — the cost of an empty array per anonymous request is trivial
next to the developer-experience win.
- Metadata is only emitted when at least one field is set, keeping the
serialised principal compact for log lines and OpenAPI examples.
- Empty-string `email`, `tenantId` and `activeOrganizationId` values
are treated as absent — Better-Auth occasionally emits empty strings
in tests and the principal contract is "field present iff value is
meaningful".

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AuthError } from "../error-codes/error-codes.js";
import { mapBetterAuthUserToPrincipal } from "./principal.js";
describe("mapBetterAuthUserToPrincipal", () => {
it("maps a complete user/session pair", { timeout: 5000 }, () => {
const p = mapBetterAuthUserToPrincipal({
user: {
id: "user:1",
email: "a@b.com",
emailVerified: true,
name: "Alice",
image: "img://a",
roles: ["admin", "owner"],
},
session: { id: "s1", userId: "user:1", activeOrganizationId: "t1" },
});
expect(p).toEqual({
id: "user:1",
email: "a@b.com",
emailVerified: true,
roles: ["admin", "owner"],
tenantId: "t1",
metadata: { name: "Alice", image: "img://a" },
});
});
it("omits optional fields when null/undefined", { timeout: 5000 }, () => {
const p = mapBetterAuthUserToPrincipal({
user: { id: "u2", email: null, emailVerified: null },
session: { activeOrganizationId: null },
});
expect(p).toEqual({ id: "u2", emailVerified: false, roles: [] });
expect(p.email).toBeUndefined();
expect(p.tenantId).toBeUndefined();
expect(p.metadata).toBeUndefined();
});
it("treats emailVerified === false correctly", { timeout: 5000 }, () => {
const p = mapBetterAuthUserToPrincipal({
user: { id: "u3", emailVerified: false },
session: {},
});
expect(p.emailVerified).toBe(false);
});
it("throws AUTH_PRINCIPAL_MAPPING_FAILED on missing id", {
timeout: 5000,
}, () => {
expect(() =>
mapBetterAuthUserToPrincipal({
user: { id: "" },
session: {},
})
).toThrow(AuthError);
});
it("throws when id is wrong type", { timeout: 5000 }, () => {
expect(() =>
mapBetterAuthUserToPrincipal({
user: { id: 42 as unknown as string },
session: {},
})
).toThrow(AuthError);
});
it("emits only present metadata fields", { timeout: 5000 }, () => {
const p = mapBetterAuthUserToPrincipal({
user: { id: "u4", name: "X" },
session: {},
});
expect(p.metadata).toEqual({ name: "X" });
});
it("ignores empty-string tenantId / email", { timeout: 5000 }, () => {
const p = mapBetterAuthUserToPrincipal({
user: { id: "u5", email: "" },
session: { activeOrganizationId: "" },
});
expect(p.email).toBeUndefined();
expect(p.tenantId).toBeUndefined();
});
});

View File

@@ -0,0 +1,103 @@
import { AUTH_CODES, AuthError } from "../error-codes/error-codes.js";
/**
* Canonical authenticated-user shape across every `@suaveplan/api-plugin-*`
* package. Sibling plugins (user, vault, organisations) read
* `c.get("user")` typed as `Principal | null` via Hono's
* `ContextVariableMap` augmentation in `../middleware/session.ts`.
*
* Mapped from the Better-Auth user + session pair by
* {@link mapBetterAuthUserToPrincipal} — replaceable per app via
* `AuthPluginOptions.principalMapping`.
*/
export interface Principal {
/** Stable record-id from Better-Auth's `user` table. */
readonly id: string;
/** Primary email when known. Better-Auth allows missing emails for
* passwordless / OAuth-only accounts. */
readonly email?: string;
/** Whether Better-Auth has confirmed the primary email. */
readonly emailVerified: boolean;
/** Role strings — populated by the host (org-membership middleware,
* etc.). Default mapping returns the empty array. */
readonly roles: readonly string[];
/** Tenant the principal is currently scoped to. Filled by
* `@suaveplan/api-tenant` when wired upstream of this plugin. */
readonly tenantId?: string;
/** Free-form metadata extracted from Better-Auth's user row. */
readonly metadata?: Record<string, unknown>;
}
/**
* Better-Auth session view consumed by the default mapper. Loose
* interface so the plugin works with any Better-Auth plugin set
* (organisation, passkeys, twoFactor, …) that extends the session row.
*/
export interface BetterAuthSessionView {
readonly user: BetterAuthUserView;
readonly session: BetterAuthSessionRecord;
}
export interface BetterAuthUserView {
readonly id: string;
readonly email?: string | null;
readonly emailVerified?: boolean | null;
readonly name?: string | null;
readonly image?: string | null;
readonly roles?: readonly string[] | null;
readonly [key: string]: unknown;
}
export interface BetterAuthSessionRecord {
readonly id?: string;
readonly userId?: string;
readonly activeOrganizationId?: string | null;
readonly [key: string]: unknown;
}
/**
* Default Better-Auth user/session → `Principal` mapping.
*
* - `id` ← `user.id`
* - `email` ← `user.email` (omitted when null/undefined)
* - `emailVerified` ← `user.emailVerified` (false when null/undefined)
* - `roles` ← `user.roles` (empty array when null/undefined)
* - `tenantId` ← `session.activeOrganizationId` (omitted when null/undefined)
* - `metadata` ← `{ name, image }` when present
*
* Throws {@link AuthError} with code `AUTH_PRINCIPAL_MAPPING_FAILED`
* when `view.user.id` is missing.
*/
export function mapBetterAuthUserToPrincipal(
view: BetterAuthSessionView
): Principal {
const { user, session } = view;
if (typeof user.id !== "string" || user.id.length === 0) {
throw new AuthError(
AUTH_CODES.PRINCIPAL_MAPPING_FAILED,
"Better-Auth user is missing `id`",
{ context: { statusCode: 500 } }
);
}
const principal: Principal & {
email?: string;
tenantId?: string;
metadata?: Record<string, unknown>;
} = {
id: user.id,
emailVerified: user.emailVerified === true,
roles: user.roles ?? [],
};
if (typeof user.email === "string" && user.email.length > 0) {
principal.email = user.email;
}
const tenant = session?.activeOrganizationId;
if (typeof tenant === "string" && tenant.length > 0) {
principal.tenantId = tenant;
}
const metadata: Record<string, unknown> = {};
if (typeof user.name === "string") metadata["name"] = user.name;
if (typeof user.image === "string") metadata["image"] = user.image;
if (Object.keys(metadata).length > 0) principal.metadata = metadata;
return principal;
}

View File

@@ -0,0 +1,81 @@
# routes/build-routes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`buildAuthRoutes(auth)` returns the 9 `PluginRouteEntry` values that
`createAuthPlugin` mounts on the host's Hono app via
`definePluginRoutes`. Each entry pairs a typed `RouteConfig` (built
with `RouteBuilder` so Zod inference flows through to the OpenAPI doc)
with a handler that delegates to Better-Auth's catch-all
`auth.handler(request)`. The OpenAPI document advertises the typed
shapes; Better-Auth owns the runtime behaviour.
## Features
- 9 routes — sign-up, sign-in, sign-out, GET session, forgot password,
reset password, verify email, OAuth start, OAuth callback.
- Every route carries the matching Zod body / query / params schema
from `../schemas/routes` so the served OpenAPI document is typed end
to end.
- Sign-up and sign-in handlers emit `auth.signups_total` and
`auth.signins_total{outcome}` counters after observing the response
status; other handlers are pure passthroughs.
- All paths are relative to the plugin's `routePrefix` (default
`/auth`), so `definePluginRoutes` controls the public mount point.
## Usage: Basic
```ts
import { buildAuthRoutes } from "@suaveplan/api-plugin-auth/routes/routes";
const entries = buildAuthRoutes(auth);
definePluginRoutes(entries, { app, registry, pluginName: "auth" });
```
## Usage: Advanced
Inspect the entries to insert per-route middleware before registration
(e.g. CSRF protection or per-route rate limits):
```ts
import { buildAuthRoutes } from "@suaveplan/api-plugin-auth/routes/routes";
const entries = buildAuthRoutes(auth);
for (const entry of entries) {
if (entry.route.path === "/sign-up") {
const original = entry.handler;
entry.handler = async (c) => {
await rateLimiter.acquire(c.req.raw.headers);
return original(c);
};
}
}
definePluginRoutes(entries, { app, registry, pluginName: "auth" });
```
## API Reference
### `function buildAuthRoutes(auth: Auth): PluginRouteEntry[]`
Returns nine `{ route: RouteConfig, handler }` entries in the order
sign-up, sign-in, sign-out, session, forgot-password, reset-password,
verify-email, oauth-start, oauth-callback.
## Implementation Notes
- The handler delegates to `auth.handler(c.req.raw)` so Better-Auth
owns wire-level concerns (cookies, redirects, CSRF tokens). The
Hono route is a thin shell that the OpenAPI document can describe.
- Sign-up / sign-in counters observe the response status post-hoc so
the counter is incremented exactly once per request, regardless of
whether Better-Auth returns a 200 + JSON or a 302 redirect.
- The OAuth callback's `state` field is required at the schema layer,
enforcing CSRF protection before Better-Auth runs.
- Route paths are unprefixed; mounting the plugin under a non-default
`routePrefix` (e.g. `/api/v1/auth`) is the responsibility of
`createAuthPlugin`, which calls `app.route(routePrefix, sub)` so
these unprefixed entries land at the right place.

View File

@@ -0,0 +1,15 @@
import type { PluginRouteEntry } from "@suaveplan/api-openapi";
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { Auth } from "better-auth";
import { buildAuthRoutes } from "./build-routes.js";
describe("buildAuthRoutes type conformance", () => {
it("expectTypeOf RouteBuilder preserves Zod inference end-to-end", {
timeout: 5000,
}, () => {
expectTypeOf(buildAuthRoutes).parameter(0).toEqualTypeOf<Auth>();
expectTypeOf(buildAuthRoutes).returns.toEqualTypeOf<
PluginRouteEntry[]
>();
});
});

View File

@@ -0,0 +1,144 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Auth } from "better-auth";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import { resetAuthInstruments } from "../otel/instruments.js";
import { buildAuthRoutes } from "./build-routes.js";
function makeAuthStub(status = 200): Auth {
return {
handler: async () =>
new Response(JSON.stringify({ ok: true }), {
status,
headers: { "content-type": "application/json" },
}),
} as unknown as Auth;
}
describe("buildAuthRoutes", () => {
it("returns 9 route entries", { timeout: 5000 }, () => {
const entries = buildAuthRoutes(makeAuthStub());
expect(entries).toHaveLength(9);
});
it("every route has typed body/query/response", {
timeout: 5000,
}, () => {
const entries = buildAuthRoutes(makeAuthStub());
for (const { route } of entries) {
expect(typeof route.method).toBe("string");
expect(typeof route.path).toBe("string");
// responses[200] schema exists
const ok = (
route.responses as Record<
string | number,
{
content?: { "application/json"?: { schema: unknown } };
}
>
)["200"];
expect(ok?.content?.["application/json"]?.schema).toBeDefined();
}
});
it("POST routes carry a body schema", { timeout: 5000 }, () => {
const entries = buildAuthRoutes(makeAuthStub());
const bodyRoutes = [
"/sign-up",
"/sign-in",
"/forgot-password",
"/reset-password",
"/verify-email",
];
for (const path of bodyRoutes) {
const entry = entries.find((e) => e.route.path === path);
expect(entry).toBeDefined();
const req = entry?.route.request as
| { body?: { content?: { "application/json"?: unknown } } }
| undefined;
expect(req?.body?.content?.["application/json"]).toBeDefined();
}
});
it("OAuth routes carry params + query schemas", { timeout: 5000 }, () => {
const entries = buildAuthRoutes(makeAuthStub());
const callback = entries.find(
(e) => e.route.path === "/oauth/{provider}/callback"
);
const req = callback?.route.request as
| { params?: unknown; query?: unknown }
| undefined;
expect(req?.params).toBeDefined();
expect(req?.query).toBeDefined();
});
it("sign-up handler emits signups_total counter on success", {
timeout: 10_000,
}, async () => {
resetAuthInstruments();
const entries = buildAuthRoutes(makeAuthStub(200));
const signUp = entries.find((e) => e.route.path === "/sign-up");
const res = await signUp!.handler({
req: { raw: new Request("http://x.test/sign-up") },
} as never);
expect(res).toBeInstanceOf(Response);
const metrics = await flushTestMetrics();
const names = metrics
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("auth.signups_total");
});
it("sign-up handler skips signups counter on non-2xx", {
timeout: 10_000,
}, async () => {
resetAuthInstruments();
const entries = buildAuthRoutes(makeAuthStub(400));
const signUp = entries.find((e) => e.route.path === "/sign-up");
const res = await signUp!.handler({
req: { raw: new Request("http://x.test/sign-up") },
} as never);
expect(res).toBeInstanceOf(Response);
});
it("sign-in handler emits failure outcome on non-2xx", {
timeout: 10_000,
}, async () => {
resetAuthInstruments();
const entries = buildAuthRoutes(makeAuthStub(401));
const signIn = entries.find((e) => e.route.path === "/sign-in");
const res = await signIn!.handler({
req: { raw: new Request("http://x.test/sign-in") },
} as never);
expect(res.status).toBe(401);
const metrics = await flushTestMetrics();
const names = metrics
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("auth.signins_total");
});
it("session handler delegates to auth.handler", {
timeout: 5000,
}, async () => {
const entries = buildAuthRoutes(makeAuthStub(200));
const session = entries.find((e) => e.route.path === "/session");
const res = await session!.handler({
req: { raw: new Request("http://x.test/session") },
} as never);
expect(res.status).toBe(200);
});
it("sign-in handler emits success outcome on 2xx", {
timeout: 10_000,
}, async () => {
resetAuthInstruments();
const entries = buildAuthRoutes(makeAuthStub(200));
const signIn = entries.find((e) => e.route.path === "/sign-in");
const res = await signIn!.handler({
req: { raw: new Request("http://x.test/sign-in") },
} as never);
expect(res.status).toBe(200);
});
});

View File

@@ -0,0 +1,143 @@
import {
GET,
type PluginRouteEntry,
POST,
successResponse,
} from "@suaveplan/api-openapi";
import type { Auth } from "better-auth";
import { getAuthInstruments } from "../otel/instruments.js";
import {
ForgotPasswordBodySchema,
ForgotPasswordResponseSchema,
GetSessionResponseSchema,
OAuthCallbackParamsSchema,
OAuthCallbackQuerySchema,
OAuthCallbackResponseSchema,
OAuthStartParamsSchema,
OAuthStartQuerySchema,
OAuthStartResponseSchema,
ResetPasswordBodySchema,
ResetPasswordResponseSchema,
SignInBodySchema,
SignInResponseSchema,
SignOutResponseSchema,
SignUpBodySchema,
SignUpResponseSchema,
VerifyEmailBodySchema,
VerifyEmailResponseSchema,
} from "../schemas/routes/index.js";
/**
* Build the 9 OpenAPI route entries that `createAuthPlugin` mounts on
* the host's Hono app. Each entry pairs a `RouteConfig` (built with
* `RouteBuilder` so Zod inference flows through) with a thin handler
* that delegates to Better-Auth's `auth.handler`. The handler keeps
* the OpenAPI doc and the runtime routing surface in lock-step.
*/
export function buildAuthRoutes(auth: Auth): PluginRouteEntry[] {
const handler = createBetterAuthHandler(auth);
const signUp = POST("/sign-up")
.tags("auth")
.summary("Email + password sign-up")
.body(SignUpBodySchema)
.response(successResponse(SignUpResponseSchema))
.build();
const signIn = POST("/sign-in")
.tags("auth")
.summary("Email + password sign-in")
.body(SignInBodySchema)
.response(successResponse(SignInResponseSchema))
.build();
const signOut = POST("/sign-out")
.tags("auth")
.summary("Sign out the current session")
.response(successResponse(SignOutResponseSchema))
.build();
const session = GET("/session")
.tags("auth")
.summary("Get the current session, if any")
.response(successResponse(GetSessionResponseSchema))
.build();
const forgot = POST("/forgot-password")
.tags("auth")
.summary("Email a password-reset token")
.body(ForgotPasswordBodySchema)
.response(successResponse(ForgotPasswordResponseSchema))
.build();
const reset = POST("/reset-password")
.tags("auth")
.summary("Apply a password reset using a single-use token")
.body(ResetPasswordBodySchema)
.response(successResponse(ResetPasswordResponseSchema))
.build();
const verify = POST("/verify-email")
.tags("auth")
.summary("Verify an email using a single-use token")
.body(VerifyEmailBodySchema)
.response(successResponse(VerifyEmailResponseSchema))
.build();
const oauthStart = GET("/oauth/{provider}/start")
.tags("auth", "oauth")
.summary("Begin OAuth flow with the named provider")
.params(OAuthStartParamsSchema)
.query(OAuthStartQuerySchema)
.response(successResponse(OAuthStartResponseSchema))
.build();
const oauthCallback = GET("/oauth/{provider}/callback")
.tags("auth", "oauth")
.summary("OAuth callback — exchange code for session")
.params(OAuthCallbackParamsSchema)
.query(OAuthCallbackQuerySchema)
.response(successResponse(OAuthCallbackResponseSchema))
.build();
return [
{ route: signUp, handler: handler.withSignupMetric() },
{ route: signIn, handler: handler.withSigninMetric() },
{ route: signOut, handler: handler.plain() },
{ route: session, handler: handler.plain() },
{ route: forgot, handler: handler.plain() },
{ route: reset, handler: handler.plain() },
{ route: verify, handler: handler.plain() },
{ route: oauthStart, handler: handler.plain() },
{ route: oauthCallback, handler: handler.plain() },
];
}
/**
* Internal handler builder — splits the Better-Auth delegate so the
* sign-up and sign-in surfaces can additionally emit metrics. The
* passthrough always calls `auth.handler(c.req.raw)` so Better-Auth
* owns the precise wire response (cookies, 302 redirects, error
* envelopes), while the OpenAPI doc advertises the typed 200 shape.
*/
function createBetterAuthHandler(auth: Auth): {
plain: () => (c: { req: { raw: Request } }) => Promise<Response>;
withSignupMetric: () => (c: { req: { raw: Request } }) => Promise<Response>;
withSigninMetric: () => (c: { req: { raw: Request } }) => Promise<Response>;
} {
const passthrough = async (c: { req: { raw: Request } }) =>
auth.handler(c.req.raw);
return {
plain: () => passthrough,
withSignupMetric: () => async (c) => {
const instruments = getAuthInstruments();
const res = await passthrough(c);
if (res.status >= 200 && res.status < 300) {
instruments.signupsTotal.add(1);
}
return res;
},
withSigninMetric: () => async (c) => {
const instruments = getAuthInstruments();
const res = await passthrough(c);
instruments.signinsTotal.add(1, {
outcome:
res.status >= 200 && res.status < 300
? "success"
: "failure",
});
return res;
},
};
}

View File

@@ -0,0 +1 @@
export { buildAuthRoutes } from "./build-routes.js";

View File

@@ -0,0 +1,4 @@
export {
applyBetterAuthSchema,
BETTER_AUTH_SURREAL_SCHEMA,
} from "./schema.js";

View File

@@ -0,0 +1,74 @@
# schema/schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module re-exports the canonical Better-Auth SurrealQL schema
shipped by `@suaveplan/better-auth-surrealdb` so consumers of the auth
plugin can reach for the DDL string directly without an additional
dependency. The plugin itself applies this schema during `onInit` —
re-exporting it lets operators inspect or apply it manually for
out-of-band migrations.
## Features
- `BETTER_AUTH_SURREAL_SCHEMA` — the canonical 17-table DDL string,
byte-for-byte mirrored from
`../../../auth/better-auth-surrealdb/src/schema/schema.ts`.
- `applyBetterAuthSchema(client)` — applies the schema against any
`SurrealClientView`-shaped connection, wrapping failures in
`BetterAuthSurrealError(QUERY_FAILED)`.
- Idempotent — every `DEFINE TABLE` / `DEFINE FIELD` / `DEFINE INDEX`
uses `OVERWRITE`, so re-applying on every boot is safe.
## Usage: Basic
```ts
import { applyBetterAuthSchema } from "@suaveplan/api-plugin-auth/schema/schema";
await applyBetterAuthSchema(surrealClient);
```
## Usage: Advanced
When operators manage migrations externally (Atlas, custom CI runners),
the schema string can be dumped to disk and applied via the existing
migration tooling. The auth plugin's `applySchemaOnInit: false`
option disables the in-plugin apply step so the externally-applied
schema is the single source of truth.
```ts
import { BETTER_AUTH_SURREAL_SCHEMA } from "@suaveplan/api-plugin-auth/schema/schema";
import { writeFileSync } from "node:fs";
writeFileSync("migrations/0001_better_auth.surql", BETTER_AUTH_SURREAL_SCHEMA);
```
## API Reference
### `BETTER_AUTH_SURREAL_SCHEMA: string`
The full DDL string — 17 tables, 13 indexes. Detail in
`@suaveplan/better-auth-surrealdb`'s README (`Bundled schema — 17
Better-Auth tables`).
### `applyBetterAuthSchema(client): Promise<void>`
Executes the DDL string via `client.execute(...)`. Throws
`BetterAuthSurrealError(QUERY_FAILED)` carrying the underlying error as
`cause` on any execution failure.
## Implementation Notes
- The re-export keeps the `auth` plugin's dependency surface narrow —
consumers do NOT need a direct `@suaveplan/better-auth-surrealdb`
dep just to apply the schema.
- The DDL string is shipped as a constant; operators who want to diff
the actual statements they will execute can `console.log` the value
before applying it.
- The downstream `BetterAuthSurrealError` is documented in the
`@suaveplan/better-auth-surrealdb` README; the auth plugin does not
re-wrap it.

View File

@@ -0,0 +1,10 @@
/**
* Re-export of the canonical 17-table Better-Auth SurrealDB schema
* shipped by `@suaveplan/better-auth-surrealdb`. The auth plugin
* applies this schema during `onInit` so operators get the full table
* surface without authoring their own DDL.
*/
export {
applyBetterAuthSchema,
BETTER_AUTH_SURREAL_SCHEMA,
} from "@suaveplan/better-auth-surrealdb";

View File

@@ -0,0 +1,103 @@
# schemas/options
## Purpose
The options module exports `AuthPluginOptionsSchema`, the Zod validator
that `createAuthPlugin` runs at the factory boundary. The schema both
validates user input and applies defaults so the descriptor's lifecycle
hooks read a fully-resolved options object — never a partial input.
## Features
- Required `betterAuth` field (forwarded verbatim to Better-Auth's own
`betterAuth(options)` factory) typed as `Record<string, unknown>`.
- Default `routePrefix: "/auth"` with a regex that rejects missing
leading slash and trailing slashes (which produce broken Hono routes).
- Default `applySchemaOnInit: true` — set `false` to disable the
built-in SurrealQL schema apply step when operators manage migrations
externally.
- Optional `principalMapping` callback for projecting Better-Auth output
onto a custom `Principal` extension. Validated as a function.
- Optional `LoggerLike`-compatible `logger`. Defaults to the
`PluginContext` logger when omitted.
## Usage: Basic
```ts
import { AuthPluginOptionsSchema } from "@suaveplan/api-plugin-auth/schemas/options";
const opts = AuthPluginOptionsSchema.parse({ betterAuth: {} });
// opts.routePrefix === "/auth"; opts.applySchemaOnInit === true
```
## Usage: Advanced
Use the schema in a custom CLI that asks the operator for a non-default
prefix and a custom mapping function. `safeParse` lets you surface the
ZodError directly to the user instead of letting `createAuthPlugin`
throw:
```ts
import { AuthPluginOptionsSchema } from "@suaveplan/api-plugin-auth/schemas/options";
import { mapBetterAuthUserToPrincipal } from "@suaveplan/api-plugin-auth/principal/principal";
const result = AuthPluginOptionsSchema.safeParse({
betterAuth: { secret: process.env["BETTER_AUTH_SECRET"] },
routePrefix: "/api/v1/auth",
applySchemaOnInit: false,
principalMapping: (view) => ({
...mapBetterAuthUserToPrincipal(view as never),
roles: ["service"],
}),
});
if (!result.success) {
console.error(result.error.format());
process.exit(1);
}
```
## API Reference
### `AuthPluginOptionsSchema: ZodObject`
Required:
- `betterAuth: Record<string, unknown>` — forwarded to Better-Auth's
factory.
With defaults:
- `routePrefix: string = "/auth"` — must match
`^/[A-Za-z0-9\-_/]*[A-Za-z0-9\-_]$` or be exactly `/`.
- `applySchemaOnInit: boolean = true`.
Optional:
- `principalMapping?: (view: unknown) => unknown`.
- `logger?: LoggerLike` with all 4 methods (`debug`, `info`, `warn`,
`error`).
### `AuthPluginOptionsInput` / `AuthPluginOptionsResolved`
`z.input` and `z.output` aliases so consumers can reference both the
caller surface (defaults optional) and the post-parse surface (defaults
present) without re-deriving the types.
## Implementation Notes
- `betterAuth` is typed as `Record<string, unknown>` rather than the
upstream `BetterAuthOptions` because pinning to a specific Better-Auth
type version would couple the plugin to a single minor. Runtime
validation is delegated to `betterAuth(...)` itself.
- The route-prefix regex accepts nested paths (`/api/v1/auth`) and
hyphenated segments while rejecting `auth` (no leading slash) and
`/auth/` (trailing slash). The exact-root `/` form is allowed because
some hosts mount auth at the root path.
- `logger` is validated structurally rather than nominally (via a
branded type) so any LoggerLike-shaped object from
`@suaveplan/types` or a third-party adapter is accepted without
cross-package coupling.
- `principalMapping` is typed loosely on input (`(view: unknown) =>
unknown`); the plugin re-applies the strict `BetterAuthSessionView`
type at the call site so authors get a typed `view` argument inside
their mapping function.

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AuthPluginOptionsSchema } from "./options.js";
describe("AuthPluginOptionsSchema", () => {
it("accepts minimal valid options + applies defaults", {
timeout: 5000,
}, () => {
const parsed = AuthPluginOptionsSchema.parse({ betterAuth: {} });
expect(parsed.betterAuth).toEqual({});
expect(parsed.routePrefix).toBe("/auth");
expect(parsed.applySchemaOnInit).toBe(true);
});
it("rejects missing betterAuth", { timeout: 5000 }, () => {
const r = AuthPluginOptionsSchema.safeParse({});
expect(r.success).toBe(false);
});
it("rejects routePrefix without leading slash", {
timeout: 5000,
}, () => {
const r = AuthPluginOptionsSchema.safeParse({
betterAuth: {},
routePrefix: "auth",
});
expect(r.success).toBe(false);
});
it("rejects routePrefix with trailing slash (not root)", {
timeout: 5000,
}, () => {
const r = AuthPluginOptionsSchema.safeParse({
betterAuth: {},
routePrefix: "/auth/",
});
expect(r.success).toBe(false);
});
it("accepts root routePrefix '/'", { timeout: 5000 }, () => {
const r = AuthPluginOptionsSchema.safeParse({
betterAuth: {},
routePrefix: "/",
});
expect(r.success).toBe(true);
});
it("accepts custom mapping function", { timeout: 5000 }, () => {
const parsed = AuthPluginOptionsSchema.parse({
betterAuth: {},
principalMapping: (v: unknown) => v,
});
expect(typeof parsed.principalMapping).toBe("function");
});
it("rejects principalMapping that is not a function", {
timeout: 5000,
}, () => {
const r = AuthPluginOptionsSchema.safeParse({
betterAuth: {},
principalMapping: 42 as unknown as () => unknown,
});
expect(r.success).toBe(false);
});
it("accepts a LoggerLike", { timeout: 5000 }, () => {
const logger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
const parsed = AuthPluginOptionsSchema.parse({
betterAuth: {},
logger,
});
expect(parsed.logger).toBe(logger);
});
it("rejects logger missing info()", { timeout: 5000 }, () => {
const r = AuthPluginOptionsSchema.safeParse({
betterAuth: {},
logger: { warn: () => undefined } as never,
});
expect(r.success).toBe(false);
});
it("rejects applySchemaOnInit as non-boolean", { timeout: 5000 }, () => {
const r = AuthPluginOptionsSchema.safeParse({
betterAuth: {},
applySchemaOnInit: "true" as never,
});
expect(r.success).toBe(false);
});
});

View File

@@ -0,0 +1,57 @@
import z from "zod";
/**
* Zod schema for `AuthPluginOptions`. Validated by `createAuthPlugin`
* at the factory boundary — invalid input throws
* `AuthError(AUTH_INVALID_INPUT)` carrying the `ZodError` as `cause`.
*
* `betterAuth` accepts any object — it is forwarded verbatim to
* Better-Auth's own `betterAuth(options)` factory, which performs its
* own validation downstream. Constraining the shape here would force a
* Better-Auth version lock-step we explicitly want to avoid.
*
* `routePrefix` MUST start with `/` and MUST NOT contain a trailing
* slash; both forms produce broken Hono routes.
*
* `principalMapping` and `logger` are validated structurally
* (`z.function`/`z.custom`) since both are runtime objects whose
* internals are out of scope for the boundary schema.
*/
export const AuthPluginOptionsSchema = z.object({
betterAuth: z
.record(z.string(), z.unknown())
.describe("Better-Auth options forwarded to betterAuth(options)"),
routePrefix: z
.string()
.regex(/^\/[A-Za-z0-9\-_/]*[A-Za-z0-9\-_]$|^\/$/u, {
message:
"routePrefix must start with '/' and not end with '/' (unless it is exactly '/')",
})
.default("/auth"),
applySchemaOnInit: z.boolean().default(true),
principalMapping: z
.custom<(view: unknown) => unknown>(
(val) => typeof val === "function",
"principalMapping must be a function"
)
.optional(),
logger: z
.custom<{
debug: (msg: string, ctx?: unknown) => void;
info: (msg: string, ctx?: unknown) => void;
warn: (msg: string, ctx?: unknown) => void;
error: (msg: string, ctx?: unknown) => void;
}>(
(val) =>
val !== null &&
typeof val === "object" &&
typeof (val as { info?: unknown }).info === "function",
"logger must be a LoggerLike with debug/info/warn/error"
)
.optional(),
});
export type AuthPluginOptionsInput = z.input<typeof AuthPluginOptionsSchema>;
export type AuthPluginOptionsResolved = z.output<
typeof AuthPluginOptionsSchema
>;

View File

@@ -0,0 +1,90 @@
# schemas/principal
## Purpose
This module exports the canonical Zod schemas for the auth boundary —
`PrincipalSchema`, `SessionSchema`, `AccountSchema` — and their inferred
TypeScript types. Sibling plugins (`@suaveplan/api-plugin-user`,
`@suaveplan/api-plugin-vault`, …) consume these schemas directly via
the `./schemas/principal` subpath so the wire shape of an authenticated
user is one type, defined once.
## Features
- `PrincipalSchema` matches the runtime `Principal` interface from
`../principal/principal.ts` field-for-field.
- `SessionSchema` matches the Better-Auth `session` row 1:1 (including
optional plugin-extended fields used by the organisations plugin).
- `AccountSchema` matches the Better-Auth `account` row — exposed so
downstream test fixtures can validate persisted rows without
duplicating the schema.
- Every schema exports its `z.infer` companion type so consumers can
reference `PrincipalSchemaType` without re-deriving it.
## Usage: Basic
```ts
import { PrincipalSchema } from "@suaveplan/api-plugin-auth/schemas/principal";
const parsed = PrincipalSchema.parse({
id: "u1",
emailVerified: true,
roles: [],
});
```
## Usage: Advanced
Compose the schema into route response envelopes via
`@suaveplan/api-openapi`'s `successResponse` helper so the OpenAPI
document carries the full principal shape on every route that returns it:
```ts
import z from "zod";
import {
PrincipalSchema,
SessionSchema,
} from "@suaveplan/api-plugin-auth/schemas/principal";
import { successResponse } from "@suaveplan/api-openapi";
const SignInResponseSchema = successResponse(
z.object({ user: PrincipalSchema, session: SessionSchema })
);
```
## API Reference
### `PrincipalSchema`
Zod object with: `id` (non-empty string, required), `email` (email,
optional), `emailVerified` (boolean, required), `roles` (string array,
required), `tenantId` (non-empty string, optional), `metadata`
(`Record<string, unknown>`, optional). Inferred type:
`PrincipalSchemaType`.
### `SessionSchema`
Zod object mirroring the Better-Auth session row. Every field optional
to tolerate plugin-extended sessions. Inferred type:
`SessionSchemaType`.
### `AccountSchema`
Zod object mirroring the Better-Auth account row. `id`, `userId`,
`providerId`, `accountId` are required; OAuth token fields and credential
password are optional. Inferred type: `AccountSchemaType`.
## Implementation Notes
- `PrincipalSchema` enforces `email` is an RFC-5322-ish string when
present — this matches Better-Auth's own validation surface and is
forwarded to the OpenAPI spec as `format: email`.
- `roles` is the canonical permission carrier; the schema accepts a
plain string array (the runtime type is `readonly`, but Zod treats
arrays uniformly so consumers can pass either).
- `SessionSchema` accepts `expiresAt` as either ISO string or `Date` so
it round-trips both wire and in-process shapes.
- `AccountSchema` does NOT mark `password` as sensitive at the Zod
layer; redaction is the responsibility of the route serialiser. The
schema is exposed so test code can read/assert account rows from
SurrealDB directly.

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AccountSchema, PrincipalSchema, SessionSchema } from "./principal.js";
describe("PrincipalSchema", () => {
it("PrincipalSchema parses valid principal", { timeout: 5000 }, () => {
const parsed = PrincipalSchema.parse({
id: "u1",
email: "a@b.com",
emailVerified: true,
roles: ["admin"],
tenantId: "t1",
metadata: { name: "A" },
});
expect(parsed.id).toBe("u1");
expect(parsed.email).toBe("a@b.com");
});
it("rejects missing id", { timeout: 5000 }, () => {
const r = PrincipalSchema.safeParse({
emailVerified: false,
roles: [],
});
expect(r.success).toBe(false);
});
it("rejects invalid email format", { timeout: 5000 }, () => {
const r = PrincipalSchema.safeParse({
id: "u",
email: "not-an-email",
emailVerified: false,
roles: [],
});
expect(r.success).toBe(false);
});
});
describe("SessionSchema", () => {
it("accepts a minimal session", { timeout: 5000 }, () => {
expect(SessionSchema.parse({})).toEqual({});
});
it("accepts a populated session", { timeout: 5000 }, () => {
const parsed = SessionSchema.parse({
id: "s1",
userId: "u1",
activeOrganizationId: "org1",
});
expect(parsed.userId).toBe("u1");
});
});
describe("AccountSchema", () => {
it("requires userId + providerId + accountId", { timeout: 5000 }, () => {
const r = AccountSchema.safeParse({ id: "a1" });
expect(r.success).toBe(false);
});
it("accepts a valid account row", { timeout: 5000 }, () => {
const parsed = AccountSchema.parse({
id: "a1",
userId: "u1",
providerId: "credential",
accountId: "u1",
});
expect(parsed.providerId).toBe("credential");
});
});

View File

@@ -0,0 +1,59 @@
import z from "zod";
/**
* Canonical Zod schema for the `Principal` type emitted by
* `@suaveplan/api-plugin-auth`. Sibling plugins import this schema
* from `@suaveplan/api-plugin-auth/schemas/principal` so the boundary
* contract is consistent end-to-end.
*/
export const PrincipalSchema = z.object({
id: z.string().min(1),
email: z.string().email().optional(),
emailVerified: z.boolean(),
roles: z.array(z.string()).readonly(),
tenantId: z.string().min(1).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
export type PrincipalSchemaType = z.infer<typeof PrincipalSchema>;
/**
* Better-Auth session payload schema — the value handed back by
* `auth.api.getSession({ headers })`. Kept loose (passthrough on user
* and session) so plugin-extended rows survive validation.
*/
export const SessionSchema = z.object({
id: z.string().min(1).optional(),
userId: z.string().min(1).optional(),
expiresAt: z.union([z.string(), z.date()]).optional(),
token: z.string().optional(),
ipAddress: z.string().optional(),
userAgent: z.string().optional(),
activeOrganizationId: z.string().optional(),
activeTeamId: z.string().optional(),
impersonatedBy: z.string().optional(),
});
export type SessionSchemaType = z.infer<typeof SessionSchema>;
/**
* Better-Auth `account` row schema. Includes provider / OAuth tokens
* plus the bcrypt-hashed credential password. Sensitive fields are
* declared but consumers of the principal route never receive this
* shape — it is exposed for downstream plugin/test integration only.
*/
export const AccountSchema = z.object({
id: z.string().min(1),
userId: z.string().min(1),
providerId: z.string().min(1),
accountId: z.string().min(1),
accessToken: z.string().optional(),
refreshToken: z.string().optional(),
idToken: z.string().optional(),
password: z.string().optional(),
accessTokenExpiresAt: z.union([z.string(), z.date()]).optional(),
refreshTokenExpiresAt: z.union([z.string(), z.date()]).optional(),
scope: z.string().optional(),
});
export type AccountSchemaType = z.infer<typeof AccountSchema>;

View File

@@ -0,0 +1,71 @@
# schemas/routes/forgot-password
## Purpose
Schemas for `POST /auth/forgot-password`. The body schema accepts a
single email and the response is `{ sent: true }`. The response shape
is deliberately uninformative — leaking whether the email exists in the
user table is a known enumeration vector, so the route returns the same
"sent" token whether or not it actually emitted an email.
## Features
- `ForgotPasswordBodySchema` — `{ email: string (RFC-5322) }`.
- `ForgotPasswordResponseSchema` — `{ sent: literal(true) }`.
- Inferred companions `ForgotPasswordBody` and `ForgotPasswordResponse`.
## Usage: Basic
```ts
import { ForgotPasswordBodySchema } from "@suaveplan/api-plugin-auth/schemas/routes";
const body = ForgotPasswordBodySchema.parse(await c.req.json());
```
## Usage: Advanced
Combine with a rate-limit middleware so the route can't be used to
probe email addresses by timing the response. The schema keeps the
response shape opaque even when the request is rate-limited:
```ts
import { POST, successResponse } from "@suaveplan/api-openapi";
import {
ForgotPasswordBodySchema,
ForgotPasswordResponseSchema,
} from "@suaveplan/api-plugin-auth/schemas/routes";
const forgotRoute = POST("/auth/forgot-password")
.tags("auth")
.summary("Send a password-reset email")
.body(ForgotPasswordBodySchema)
.response(successResponse(ForgotPasswordResponseSchema))
.build();
```
## API Reference
### `ForgotPasswordBodySchema`
Required: `email: string (email)`. Inferred type `ForgotPasswordBody`.
### `ForgotPasswordResponseSchema`
`{ sent: true }`. Inferred type `ForgotPasswordResponse`.
## Implementation Notes
- The schema rejects malformed emails at the boundary so Better-Auth's
email delivery layer never has to handle garbage input.
- The response always returns `sent: true` regardless of whether the
email exists in the user table — the canonical pattern for avoiding
account enumeration through forgot-password endpoints.
- The schema does not include locale or template selection; those are
Better-Auth configuration concerns, not request-body concerns.
- Operators that want different responses for known vs unknown emails
(e.g. throttling differently) should configure rate limiting at the
network edge rather than altering the schema — the constant-shape
response is the privacy guarantee the schema exists to enforce.
- The route mounts under the configured `routePrefix`, so the fully
qualified path is typically `/auth/forgot-password` when the
default prefix is used.

View File

@@ -0,0 +1,14 @@
import z from "zod";
export const ForgotPasswordBodySchema = z.object({
email: z.string().email(),
});
export const ForgotPasswordResponseSchema = z.object({
sent: z.literal(true),
});
export type ForgotPasswordBody = z.infer<typeof ForgotPasswordBodySchema>;
export type ForgotPasswordResponse = z.infer<
typeof ForgotPasswordResponseSchema
>;

View File

@@ -0,0 +1,51 @@
export {
type ForgotPasswordBody,
ForgotPasswordBodySchema,
type ForgotPasswordResponse,
ForgotPasswordResponseSchema,
} from "./forgot-password.js";
export {
type OAuthCallbackParams,
OAuthCallbackParamsSchema,
type OAuthCallbackQuery,
OAuthCallbackQuerySchema,
OAuthCallbackResponseSchema,
type OAuthStartParams,
OAuthStartParamsSchema,
type OAuthStartQuery,
OAuthStartQuerySchema,
OAuthStartResponseSchema,
} from "./oauth.js";
export {
type ResetPasswordBody,
ResetPasswordBodySchema,
type ResetPasswordResponse,
ResetPasswordResponseSchema,
} from "./reset-password.js";
export {
type GetSessionResponse,
GetSessionResponseSchema,
} from "./session.js";
export {
type SignInBody,
SignInBodySchema,
type SignInResponse,
SignInResponseSchema,
} from "./sign-in.js";
export {
SignOutBodySchema,
type SignOutResponse,
SignOutResponseSchema,
} from "./sign-out.js";
export {
type SignUpBody,
SignUpBodySchema,
type SignUpResponse,
SignUpResponseSchema,
} from "./sign-up.js";
export {
type VerifyEmailBody,
VerifyEmailBodySchema,
type VerifyEmailResponse,
VerifyEmailResponseSchema,
} from "./verify-email.js";

View File

@@ -0,0 +1,106 @@
# schemas/routes/oauth
## Purpose
Schemas for the OAuth pair `GET /auth/oauth/:provider/start` and
`GET /auth/oauth/:provider/callback`. The start endpoint initiates an
OAuth flow (typically redirecting to the provider's authorization
endpoint); the callback receives the provider's `code` + `state` and
finishes the flow.
## Features
- `OAuthStartParamsSchema` — `{ provider: string (min 1) }` for the
path parameter.
- `OAuthStartQuerySchema` — `{ redirectTo?: string (url) }` so callers
can request a specific post-login redirect.
- `OAuthStartResponseSchema` — `{ redirectTo: string (url) }`; the
body shape used when the response is a 200 + JSON pointer rather than
a direct 302.
- `OAuthCallbackParamsSchema` + `OAuthCallbackQuerySchema` — `{ code,
state }` from the provider.
- `OAuthCallbackResponseSchema` — `{ redirectTo: string (url) }` for
the same 200 + JSON pointer variant.
## Usage: Basic
```ts
import { OAuthStartQuerySchema, OAuthCallbackQuerySchema } from "@suaveplan/api-plugin-auth/schemas/routes";
const startQuery = OAuthStartQuerySchema.parse({ redirectTo: "https://example.test/" });
const callbackQuery = OAuthCallbackQuerySchema.parse({ code: "abc", state: "xyz" });
```
## Usage: Advanced
Use the params + query + response shapes inside a `RouteBuilder` chain
so the OpenAPI doc advertises a fully-typed OAuth flow including the
required `state` field on the callback:
```ts
import { GET, successResponse } from "@suaveplan/api-openapi";
import {
OAuthCallbackParamsSchema,
OAuthCallbackQuerySchema,
OAuthCallbackResponseSchema,
OAuthStartParamsSchema,
OAuthStartQuerySchema,
OAuthStartResponseSchema,
} from "@suaveplan/api-plugin-auth/schemas/routes";
const startRoute = GET("/auth/oauth/{provider}/start")
.tags("auth", "oauth")
.summary("Begin OAuth flow with the named provider")
.params(OAuthStartParamsSchema)
.query(OAuthStartQuerySchema)
.response(successResponse(OAuthStartResponseSchema))
.build();
const callbackRoute = GET("/auth/oauth/{provider}/callback")
.tags("auth", "oauth")
.summary("OAuth callback — exchange code for session")
.params(OAuthCallbackParamsSchema)
.query(OAuthCallbackQuerySchema)
.response(successResponse(OAuthCallbackResponseSchema))
.build();
```
## API Reference
### `OAuthStartParamsSchema`
`{ provider: string (min 1) }`. Inferred type `OAuthStartParams`.
### `OAuthStartQuerySchema`
`{ redirectTo?: string (url) }`. Inferred type `OAuthStartQuery`.
### `OAuthStartResponseSchema`
`{ redirectTo: string (url) }`.
### `OAuthCallbackParamsSchema`
`{ provider: string (min 1) }`. Inferred type `OAuthCallbackParams`.
### `OAuthCallbackQuerySchema`
`{ code: string (min 1); state: string (min 1) }`. Inferred type
`OAuthCallbackQuery`.
### `OAuthCallbackResponseSchema`
`{ redirectTo: string (url) }`.
## Implementation Notes
- The OAuth pair is documented as 200-with-JSON-redirect in OpenAPI for
ergonomics — Better-Auth's runtime handler may return a 302 directly,
which is compatible with the OpenAPI doc because consumers reading
the spec see the "redirectTo" pointer either way.
- The callback's `state` field is required (Better-Auth always emits
it) so CSRF protection is enforced at the schema layer; callers that
drop `state` fail Zod validation before Better-Auth ever runs.
- Provider validation is structural — the route accepts any provider
string; Better-Auth's configured providers map decides whether the
call succeeds at runtime.

View File

@@ -0,0 +1,31 @@
import z from "zod";
export const OAuthStartParamsSchema = z.object({
provider: z.string().min(1),
});
export const OAuthStartQuerySchema = z.object({
redirectTo: z.string().url().optional(),
});
export const OAuthStartResponseSchema = z.object({
redirectTo: z.string().url(),
});
export const OAuthCallbackParamsSchema = z.object({
provider: z.string().min(1),
});
export const OAuthCallbackQuerySchema = z.object({
code: z.string().min(1),
state: z.string().min(1),
});
export const OAuthCallbackResponseSchema = z.object({
redirectTo: z.string().url(),
});
export type OAuthStartParams = z.infer<typeof OAuthStartParamsSchema>;
export type OAuthStartQuery = z.infer<typeof OAuthStartQuerySchema>;
export type OAuthCallbackParams = z.infer<typeof OAuthCallbackParamsSchema>;
export type OAuthCallbackQuery = z.infer<typeof OAuthCallbackQuerySchema>;

View File

@@ -0,0 +1,77 @@
# schemas/routes/reset-password
## Purpose
Schemas for `POST /auth/reset-password`. The body accepts the
single-use token previously emailed to the user plus the new password;
the response is `{ success: true }`. Better-Auth's downstream handler
validates the token, hashes the new password, and clears the
verification row.
## Features
- `ResetPasswordBodySchema` — `{ token: string (min 1), password:
string (min 8) }`.
- `ResetPasswordResponseSchema` — `{ success: literal(true) }`.
- Inferred companions `ResetPasswordBody` and `ResetPasswordResponse`.
## Usage: Basic
```ts
import { ResetPasswordBodySchema } from "@suaveplan/api-plugin-auth/schemas/routes";
const body = ResetPasswordBodySchema.parse(await c.req.json());
```
## Usage: Advanced
Combine the schema with the standard error response so the OpenAPI
document advertises the 400 (invalid token) and 422 (password too weak)
cases consumers need to handle:
```ts
import { POST, successResponse } from "@suaveplan/api-openapi";
import {
ResetPasswordBodySchema,
ResetPasswordResponseSchema,
} from "@suaveplan/api-plugin-auth/schemas/routes";
const resetRoute = POST("/auth/reset-password")
.tags("auth")
.summary("Apply a password reset using a single-use token")
.body(ResetPasswordBodySchema)
.response(successResponse(ResetPasswordResponseSchema))
.build();
```
## API Reference
### `ResetPasswordBodySchema`
Required: `token: string (min 1)`, `password: string (min 8)`. Inferred
type `ResetPasswordBody`.
### `ResetPasswordResponseSchema`
`{ success: true }`. Inferred type `ResetPasswordResponse`.
## Implementation Notes
- The 8-character minimum on `password` matches Better-Auth's default
password policy. Stricter policies are configured downstream; the
schema is a floor, not a ceiling.
- The token is opaque from the schema's perspective — its structure
(HMAC, JWT, or random base64) is a Better-Auth implementation detail
and the schema only ensures the field is non-empty.
- The response is a literal `true` so the typed return narrows
correctly on the client side.
- Better-Auth's downstream handler enforces single-use semantics —
the schema does not need to track whether the token was previously
redeemed. Replaying a consumed token surfaces a 400 from
Better-Auth, which the host's error-handler middleware translates
into the standard error envelope.
- Hashing the new password happens inside Better-Auth using its
configured hashing strategy (bcrypt, argon2, scrypt). The schema
only checks length; the policy layer (zxcvbn, breached-password
checks) is configured upstream and runs before this route is
invoked.

View File

@@ -0,0 +1,13 @@
import z from "zod";
export const ResetPasswordBodySchema = z.object({
token: z.string().min(1),
password: z.string().min(8),
});
export const ResetPasswordResponseSchema = z.object({
success: z.literal(true),
});
export type ResetPasswordBody = z.infer<typeof ResetPasswordBodySchema>;
export type ResetPasswordResponse = z.infer<typeof ResetPasswordResponseSchema>;

View File

@@ -0,0 +1,165 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ForgotPasswordBodySchema,
ForgotPasswordResponseSchema,
GetSessionResponseSchema,
OAuthCallbackParamsSchema,
OAuthCallbackQuerySchema,
OAuthCallbackResponseSchema,
OAuthStartParamsSchema,
OAuthStartQuerySchema,
OAuthStartResponseSchema,
ResetPasswordBodySchema,
ResetPasswordResponseSchema,
SignInBodySchema,
SignInResponseSchema,
SignOutBodySchema,
SignOutResponseSchema,
SignUpBodySchema,
SignUpResponseSchema,
VerifyEmailBodySchema,
VerifyEmailResponseSchema,
} from "./index.js";
describe("sign-up schemas", () => {
it("accepts valid body", { timeout: 5000 }, () => {
expect(
SignUpBodySchema.parse({
email: "a@b.com",
password: "Password!",
})
).toEqual({ email: "a@b.com", password: "Password!" });
});
it("rejects short password", { timeout: 5000 }, () => {
expect(
SignUpBodySchema.safeParse({
email: "a@b.com",
password: "short",
}).success
).toBe(false);
});
it("response schema requires user + session", { timeout: 5000 }, () => {
expect(SignUpResponseSchema.safeParse({}).success).toBe(false);
});
});
describe("sign-in schemas", () => {
it("accepts valid body", { timeout: 5000 }, () => {
expect(
SignInBodySchema.parse({ email: "a@b.com", password: "x" })
).toEqual({ email: "a@b.com", password: "x" });
});
it("rejects non-email", { timeout: 5000 }, () => {
expect(
SignInBodySchema.safeParse({ email: "x", password: "x" }).success
).toBe(false);
});
it("response schema requires user + session", { timeout: 5000 }, () => {
expect(SignInResponseSchema.safeParse({}).success).toBe(false);
});
});
describe("sign-out schemas", () => {
it("body is optional", { timeout: 5000 }, () => {
expect(SignOutBodySchema.safeParse(undefined).success).toBe(true);
expect(SignOutBodySchema.safeParse({}).success).toBe(true);
});
it("response is { success: true }", { timeout: 5000 }, () => {
expect(SignOutResponseSchema.parse({ success: true })).toEqual({
success: true,
});
});
});
describe("session schemas", () => {
it("accepts nullable user + session", { timeout: 5000 }, () => {
expect(
GetSessionResponseSchema.parse({ user: null, session: null })
).toEqual({ user: null, session: null });
});
it("accepts populated", { timeout: 5000 }, () => {
const parsed = GetSessionResponseSchema.parse({
user: { id: "u", emailVerified: true, roles: [] },
session: { id: "s" },
});
expect(parsed.user?.id).toBe("u");
});
});
describe("forgot-password schemas", () => {
it("accepts email body", { timeout: 5000 }, () => {
expect(ForgotPasswordBodySchema.parse({ email: "a@b.com" })).toEqual({
email: "a@b.com",
});
});
it("response is sent: true", { timeout: 5000 }, () => {
expect(ForgotPasswordResponseSchema.parse({ sent: true })).toEqual({
sent: true,
});
});
});
describe("reset-password schemas", () => {
it("accepts token + password", { timeout: 5000 }, () => {
const parsed = ResetPasswordBodySchema.parse({
token: "t",
password: "Password!",
});
expect(parsed.token).toBe("t");
});
it("response is success: true", { timeout: 5000 }, () => {
expect(ResetPasswordResponseSchema.parse({ success: true })).toEqual({
success: true,
});
});
});
describe("verify-email schemas", () => {
it("accepts token body", { timeout: 5000 }, () => {
expect(VerifyEmailBodySchema.parse({ token: "t" })).toEqual({
token: "t",
});
});
it("response is verified: true", { timeout: 5000 }, () => {
expect(VerifyEmailResponseSchema.parse({ verified: true })).toEqual({
verified: true,
});
});
});
describe("oauth schemas", () => {
it("start params + query", { timeout: 5000 }, () => {
expect(OAuthStartParamsSchema.parse({ provider: "github" })).toEqual({
provider: "github",
});
expect(
OAuthStartQuerySchema.parse({ redirectTo: "https://x.test/" })
).toEqual({ redirectTo: "https://x.test/" });
expect(OAuthStartQuerySchema.parse({})).toEqual({});
});
it("start response", { timeout: 5000 }, () => {
expect(
OAuthStartResponseSchema.parse({
redirectTo: "https://x.test/oauth",
})
).toEqual({ redirectTo: "https://x.test/oauth" });
});
it("callback params + query", { timeout: 5000 }, () => {
expect(OAuthCallbackParamsSchema.parse({ provider: "github" })).toEqual(
{ provider: "github" }
);
expect(
OAuthCallbackQuerySchema.parse({ code: "c", state: "s" })
).toEqual({ code: "c", state: "s" });
expect(OAuthCallbackQuerySchema.safeParse({ code: "c" }).success).toBe(
false
);
});
it("callback response", { timeout: 5000 }, () => {
expect(
OAuthCallbackResponseSchema.parse({
redirectTo: "https://x.test/done",
})
).toEqual({ redirectTo: "https://x.test/done" });
});
});

View File

@@ -0,0 +1,64 @@
# schemas/routes/session
## Purpose
Response schema for `GET /auth/session`. Returns the current
authenticated `Principal` and the matching Better-Auth session row, or
`null` for both fields when the request is anonymous. The endpoint is
the canonical "who am I" call for client SDKs and SSR hydration.
## Features
- `GetSessionResponseSchema` — `{ user: PrincipalSchema | null;
session: SessionSchema | null }` so the response carries the exact
same wire shape as `c.get("user")` plus the session row.
- Anonymous callers see `{ user: null, session: null }` — a 200 with
null fields rather than a 401, so client SDKs can probe identity
without error handling.
## Usage: Basic
```ts
import { GetSessionResponseSchema } from "@suaveplan/api-plugin-auth/schemas/routes";
const parsed = GetSessionResponseSchema.parse({ user: null, session: null });
```
## Usage: Advanced
Pair the schema with a route definition that lists both anonymous and
authenticated cases in the OpenAPI document so consumers know the
endpoint never 401s:
```ts
import { GET, successResponse } from "@suaveplan/api-openapi";
import {
GetSessionResponseSchema,
} from "@suaveplan/api-plugin-auth/schemas/routes";
const sessionRoute = GET("/auth/session")
.tags("auth")
.summary("Resolve the current session, if any")
.response(successResponse(GetSessionResponseSchema))
.build();
```
## API Reference
### `GetSessionResponseSchema`
`{ user: PrincipalSchema | null; session: SessionSchema | null }`.
Inferred type `GetSessionResponse`.
## Implementation Notes
- Returning `null` for the anonymous case keeps the endpoint cacheable
per cookie — a 401 response would force the CDN to either skip caching
or treat the page as private. Some host setups place this endpoint
behind a Service-Worker cache.
- The schema mirrors the runtime shape installed by the session
middleware so consumers can use the same type to read either the HTTP
response body or `c.get("user")` / `c.get("session")`.
- Better-Auth's session row carries plugin-extended fields (org
membership, impersonation) — those are tolerated via the loose schema
shape on `SessionSchema` so the API is forward-compatible.

View File

@@ -0,0 +1,9 @@
import z from "zod";
import { PrincipalSchema, SessionSchema } from "../principal.js";
export const GetSessionResponseSchema = z.object({
user: PrincipalSchema.nullable(),
session: SessionSchema.nullable(),
});
export type GetSessionResponse = z.infer<typeof GetSessionResponseSchema>;

View File

@@ -0,0 +1,73 @@
# schemas/routes/sign-in
## Purpose
Request/response Zod schemas for `POST /auth/sign-in`. The body schema
validates email + password credential input; the response describes
the user + session pair Better-Auth returns on a successful sign-in.
These schemas form one half of the canonical credential lifecycle —
sign-up and sign-in share the same response shape so client code can
treat the two endpoints uniformly.
## Features
- `SignInBodySchema` — `email` (RFC-5322), `password` (min 1, since
Better-Auth performs the policy check downstream and the schema only
guards against empty strings here).
- `SignInResponseSchema` — `{ user: PrincipalSchema; session:
SessionSchema }` matching the wire shape Better-Auth produces.
- Inferred companions `SignInBody` and `SignInResponse`.
## Usage: Basic
```ts
import { SignInBodySchema } from "@suaveplan/api-plugin-auth/schemas/routes";
const body = SignInBodySchema.parse(await c.req.json());
```
## Usage: Advanced
Combine the schema with the `requireAuth` middleware downstream so
consumers can build a `/sign-in-then-call-private-route` flow with full
type inference:
```ts
import { POST, successResponse } from "@suaveplan/api-openapi";
import {
SignInBodySchema,
SignInResponseSchema,
} from "@suaveplan/api-plugin-auth/schemas/routes";
const signInRoute = POST("/auth/sign-in")
.tags("auth")
.summary("Email + password sign-in")
.body(SignInBodySchema)
.response(successResponse(SignInResponseSchema))
.build();
```
## API Reference
### `SignInBodySchema`
Required: `email: string (email)`, `password: string (min 1)`. Inferred
type `SignInBody`.
### `SignInResponseSchema`
`{ user: PrincipalSchema; session: SessionSchema }`. Inferred type
`SignInResponse`.
## Implementation Notes
- The `password` field only requires a non-empty string — Better-Auth's
policy layer enforces strength rules with full error reporting.
Duplicating policy at the schema layer would force the client SDK to
re-implement Better-Auth's exact rules.
- The response intentionally mirrors `SignUpResponseSchema` so consumer
code can use one type for both the post-sign-up and post-sign-in
redirect handlers — a deliberate ergonomics choice.
- The schema does not include rate-limit headers or CSRF tokens; those
are handled by `@suaveplan/api`'s middleware chain, not the route
schema itself.

View File

@@ -0,0 +1,15 @@
import z from "zod";
import { PrincipalSchema, SessionSchema } from "../principal.js";
export const SignInBodySchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export const SignInResponseSchema = z.object({
user: PrincipalSchema,
session: SessionSchema,
});
export type SignInBody = z.infer<typeof SignInBodySchema>;
export type SignInResponse = z.infer<typeof SignInResponseSchema>;

View File

@@ -0,0 +1,65 @@
# schemas/routes/sign-out
## Purpose
Schemas for `POST /auth/sign-out`. The body is optional (Better-Auth
identifies the session from the cookie or `Authorization` header) and
the response is a single `{ success: true }` literal so client code
can branch on success without parsing additional fields.
## Features
- `SignOutBodySchema` — optional empty object; accepts both `undefined`
and `{}` so clients can send a body or skip it.
- `SignOutResponseSchema` — `{ success: literal(true) }` so the
response type is `{ success: true }` rather than `{ success: boolean }`.
- Inferred companion `SignOutResponse`.
## Usage: Basic
```ts
import { SignOutResponseSchema } from "@suaveplan/api-plugin-auth/schemas/routes";
const parsed = SignOutResponseSchema.parse({ success: true });
```
## Usage: Advanced
Pair the schema with the `requireAuth` middleware so the OpenAPI
document advertises a 401 response for anonymous callers and a 200
response with the typed body for authenticated ones:
```ts
import { POST, successResponse } from "@suaveplan/api-openapi";
import {
SignOutResponseSchema,
} from "@suaveplan/api-plugin-auth/schemas/routes";
const signOutRoute = POST("/auth/sign-out")
.tags("auth")
.summary("End the current session")
.response(successResponse(SignOutResponseSchema))
.build();
```
## API Reference
### `SignOutBodySchema`
Optional empty object. Accepts `undefined` or `{}`.
### `SignOutResponseSchema`
`{ success: true }` literal. Inferred type `SignOutResponse`.
## Implementation Notes
- The body schema is `optional` rather than absent so the OpenAPI
document still advertises the route as accepting a JSON body. Clients
may send `{}` to follow the route shape or omit the body entirely.
- The response is a literal `true` rather than `boolean` so the typed
return value at the client side narrows correctly — `if
(response.success) { … }` is statically known to execute.
- Better-Auth's downstream cookie clearing is a side effect of the
handler, not part of the schema. The response only describes the JSON
body so the schema stays orthogonal to the cookie machinery.

View File

@@ -0,0 +1,9 @@
import z from "zod";
export const SignOutBodySchema = z.object({}).optional();
export const SignOutResponseSchema = z.object({
success: z.literal(true),
});
export type SignOutResponse = z.infer<typeof SignOutResponseSchema>;

View File

@@ -0,0 +1,77 @@
# schemas/routes/sign-up
## Purpose
Request/response Zod schemas for `POST /auth/sign-up`. The body schema
constrains the email + password + optional name input; the response
schema describes the typed user + session pair Better-Auth returns on
a successful sign-up. Sibling plugins import these schemas directly so
client SDK generators and downstream tests share one canonical wire
shape.
## Features
- `SignUpBodySchema` — `email` (RFC-5322), `password` (min 8 chars),
optional `name` (min 1 char).
- `SignUpResponseSchema` — Better-Auth user + session payload typed
via `PrincipalSchema` and `SessionSchema` from `../principal.ts` so
the response carries the same wire-level user identity that
`c.get("user")` produces on every other route.
- Inferred TypeScript companions `SignUpBody` and `SignUpResponse`.
## Usage: Basic
```ts
import { SignUpBodySchema } from "@suaveplan/api-plugin-auth/schemas/routes";
const body = SignUpBodySchema.parse(await c.req.json());
```
## Usage: Advanced
Use the schemas inside a `RouteBuilder` chain so the OpenAPI document
carries both the request body and the success response shapes:
```ts
import { POST, successResponse } from "@suaveplan/api-openapi";
import {
SignUpBodySchema,
SignUpResponseSchema,
} from "@suaveplan/api-plugin-auth/schemas/routes";
const signUpRoute = POST("/auth/sign-up")
.tags("auth")
.summary("Email + password sign-up")
.body(SignUpBodySchema)
.response(successResponse(SignUpResponseSchema))
.build();
```
## API Reference
### `SignUpBodySchema`
Required: `email: string (email)`, `password: string (min 8)`.
Optional: `name: string (min 1)`.
### `SignUpResponseSchema`
`{ user: PrincipalSchema; session: SessionSchema }`. Inferred type
`SignUpResponse`.
### `SignUpBody`
`z.infer<typeof SignUpBodySchema>`.
## Implementation Notes
- The 8-character minimum on `password` matches Better-Auth's default
password policy; consumers can layer stricter policies via Better-Auth
configuration without altering the schema.
- The response shape uses the shared `PrincipalSchema` / `SessionSchema`
rather than a route-local one — this is how the contract that
`c.get("user")` returns the same `Principal` everywhere stays a
single-source-of-truth thing.
- Sensitive fields (`password`) are intentionally excluded from the
response schema so the OpenAPI document never advertises a password
surface to consumers.

View File

@@ -0,0 +1,16 @@
import z from "zod";
import { PrincipalSchema, SessionSchema } from "../principal.js";
export const SignUpBodySchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1).optional(),
});
export const SignUpResponseSchema = z.object({
user: PrincipalSchema,
session: SessionSchema,
});
export type SignUpBody = z.infer<typeof SignUpBodySchema>;
export type SignUpResponse = z.infer<typeof SignUpResponseSchema>;

View File

@@ -0,0 +1,72 @@
# schemas/routes/verify-email
## Purpose
Schemas for `POST /auth/verify-email`. The body carries the
single-use verification token (typically emailed from a sign-up
event); the response is `{ verified: true }`. The route is idempotent
in Better-Auth — submitting an already-verified token returns `verified:
true` rather than failing.
## Features
- `VerifyEmailBodySchema` — `{ token: string (min 1) }`.
- `VerifyEmailResponseSchema` — `{ verified: literal(true) }`.
- Inferred companions `VerifyEmailBody` and `VerifyEmailResponse`.
## Usage: Basic
```ts
import { VerifyEmailBodySchema } from "@suaveplan/api-plugin-auth/schemas/routes";
const body = VerifyEmailBodySchema.parse(await c.req.json());
```
## Usage: Advanced
Wire the route into the host so the OpenAPI document advertises the 400
case (invalid / expired token) and the 200 case (verified). The schema
covers the 200 case; standard error responses come from
`StandardResponses`:
```ts
import { POST, successResponse } from "@suaveplan/api-openapi";
import {
VerifyEmailBodySchema,
VerifyEmailResponseSchema,
} from "@suaveplan/api-plugin-auth/schemas/routes";
const verifyRoute = POST("/auth/verify-email")
.tags("auth")
.summary("Verify an email address using a single-use token")
.body(VerifyEmailBodySchema)
.response(successResponse(VerifyEmailResponseSchema))
.build();
```
## API Reference
### `VerifyEmailBodySchema`
Required: `token: string (min 1)`. Inferred type `VerifyEmailBody`.
### `VerifyEmailResponseSchema`
`{ verified: true }`. Inferred type `VerifyEmailResponse`.
## Implementation Notes
- The token is opaque to the schema — Better-Auth's downstream handler
validates structure, expiry, and single-use semantics.
- The response is the literal `true` so client code can branch on
success without a wider type comparison.
- Repeated successful verification calls return the same `verified:
true` shape — the schema is intentionally idempotent so client retry
loops do not need a special-case "already verified" code.
- Token expiry is handled by Better-Auth's verification flow — the
schema does not impose a TTL; expired tokens return a 400 from the
Better-Auth handler, which the host's error-handler middleware
translates to the standard error envelope.
- The route is mounted under the configured `routePrefix` so the
fully-qualified path is typically `/auth/verify-email` (or the
caller's custom prefix when wiring the plugin).

View File

@@ -0,0 +1,12 @@
import z from "zod";
export const VerifyEmailBodySchema = z.object({
token: z.string().min(1),
});
export const VerifyEmailResponseSchema = z.object({
verified: z.literal(true),
});
export type VerifyEmailBody = z.infer<typeof VerifyEmailBodySchema>;
export type VerifyEmailResponse = z.infer<typeof VerifyEmailResponseSchema>;

View File

@@ -0,0 +1,106 @@
# services/auth-service
## Purpose
The `auth-service` module owns the `AuthService` interface, its
constructor (`createAuthService`), and the side-effect module
augmentation that adds the `authService` slot to
`@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`. Together
they let sibling plugins resolve the current principal and the
underlying Better-Auth instance with full type safety — no casts at the
call site.
## Features
- `AuthService` interface with three methods:
`getCurrentPrincipal(c)`, `getBetterAuthInstance()`, and
`getSession(headers)`.
- `createAuthService` constructor wired to a Better-Auth instance and
consumed exclusively by `createAuthPlugin`'s `onInit`.
- Side-effect module augmentation in `./registry-augmentation.ts` so
importing anything from `@suaveplan/api-plugin-auth` makes
`ctx.getService("authService")` type-narrow to `AuthService |
undefined`.
## Usage: Basic
```ts
import { createAuthService } from "@suaveplan/api-plugin-auth/services/auth-service";
const service = createAuthService({ auth });
const principal = service.getCurrentPrincipal(c);
```
## Usage: Advanced
From a sibling plugin's `onInit`, resolve the service and use it to
authenticate a stand-alone request (e.g. a queued job that carries a
serialised cookie jar):
```ts
import { definePlugin } from "@suaveplan/api-plugin";
import type { Principal } from "@suaveplan/api-plugin-auth/principal/principal";
export const vaultPlugin = definePlugin({
name: "vault",
version: "0.1.0",
dependencies: ["auth"],
async onInit(ctx) {
const authService = ctx.getService("authService");
if (!authService) {
throw new Error("authService not registered — is the auth plugin enabled?");
}
const headers = new Headers(savedCookieJar);
const result = await authService.getSession(headers);
if (result === null) return;
// result.user / result.session are typed via BetterAuthSessionResult
},
});
```
## API Reference
### `interface AuthService`
- `getCurrentPrincipal(c: Context): Principal | null` — reads
`c.get("user")`; returns `null` for anonymous requests.
- `getBetterAuthInstance(): Auth` — returns the Better-Auth instance
constructed by `createAuthPlugin`.
- `getSession(headers: Headers): Promise<BetterAuthSessionResult>` —
resolves a Better-Auth session from arbitrary request headers, useful
outside the request scope.
### `type BetterAuthSessionResult`
`{ user: Record<string, unknown>; session: Record<string, unknown> } |
null`.
### `interface CreateAuthServiceOptions`
`{ auth: Auth }`.
### `function createAuthService(options): AuthService`
Pure constructor — wraps the Better-Auth instance with the three-method
service shape.
### Module augmentation
`./registry-augmentation.ts` declares
`declare module "@suaveplan/api-plugin/service-registry" { interface
ServiceRegistry { authService: AuthService } }`. Side-effect import.
## Implementation Notes
- The service holds a reference to the Better-Auth instance — call
sites that need the raw handler (e.g. the Hono catch-all route
installed by `createAuthPlugin`) reach for
`getBetterAuthInstance().handler` rather than re-constructing the
Better-Auth client.
- `BetterAuthSessionResult` is intentionally loose because Better-Auth
plugins (organisations, passkeys, twoFactor) extend the `session` row
with additional fields. The auth plugin's own `Principal` mapping
decides which of those fields surface to consumers.
- Session resolution is asynchronous; downstream plugins that want a
synchronous principal-read use `getCurrentPrincipal(c)` from inside a
Hono handler instead.

View File

@@ -0,0 +1,15 @@
import type { ServiceRegistry } from "@suaveplan/api-plugin/service-registry";
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { AuthService } from "./auth-service.js";
// Pull in the registry-augmentation as a side effect so the slot is
// declared at type-check time.
import "./registry-augmentation.js";
describe("ServiceRegistry slot conformance", () => {
it("ServiceRegistry slot typed as AuthService", { timeout: 5000 }, () => {
expectTypeOf<
ServiceRegistry["authService"]
>().toEqualTypeOf<AuthService>();
});
});

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Auth } from "better-auth";
import { Hono } from "hono";
import type { Principal } from "../principal/principal.js";
import { createAuthService } from "./auth-service.js";
function makeAuthStub(sessionReturn: unknown = null): Auth {
const stub = {
api: {
getSession: async () => sessionReturn,
},
} as unknown as Auth;
return stub;
}
describe("createAuthService", () => {
it("getCurrentPrincipal returns c.get('user')", {
timeout: 5000,
}, async () => {
const principal: Principal = {
id: "u1",
emailVerified: true,
roles: ["admin"],
};
const auth = makeAuthStub();
const service = createAuthService({ auth });
const app = new Hono();
app.get("/whoami", (c) => {
c.set("user", principal);
const p = service.getCurrentPrincipal(c);
return c.json({ p });
});
const res = await app.request("/whoami");
const body = (await res.json()) as { p: Principal | null };
expect(body.p?.id).toBe("u1");
});
it("getCurrentPrincipal returns null when c.get returns undefined", {
timeout: 5000,
}, async () => {
const auth = makeAuthStub();
const service = createAuthService({ auth });
const app = new Hono();
app.get("/anon", (c) => {
const p = service.getCurrentPrincipal(c);
return c.json({ isNull: p === null });
});
const res = await app.request("/anon");
const body = (await res.json()) as { isNull: boolean };
expect(body.isNull).toBe(true);
});
it("getBetterAuthInstance returns the supplied auth", {
timeout: 5000,
}, () => {
const auth = makeAuthStub();
const service = createAuthService({ auth });
expect(service.getBetterAuthInstance()).toBe(auth);
});
it("getSession proxies to Better-Auth", { timeout: 5000 }, async () => {
const expected = {
user: { id: "u1" },
session: { id: "s1" },
};
const auth = makeAuthStub(expected);
const service = createAuthService({ auth });
const result = await service.getSession(new Headers());
expect(result).toBe(expected);
});
it("getSession resolves to null when Better-Auth returns null", {
timeout: 5000,
}, async () => {
const auth = makeAuthStub(null);
const service = createAuthService({ auth });
expect(await service.getSession(new Headers())).toBeNull();
});
});

View File

@@ -0,0 +1,76 @@
import type { Auth } from "better-auth";
import type { Context } from "hono";
import type { Principal } from "../principal/principal.js";
/**
* Loose-typed Better-Auth session shape returned by `auth.api.getSession`.
* Kept loose so plugin-extended sessions (passkeys, organisations,
* twoFactor) flow through without forcing this package to track every
* Better-Auth plugin's type extensions.
*/
export type BetterAuthSessionResult = {
readonly user: Record<string, unknown>;
readonly session: Record<string, unknown>;
} | null;
/**
* Service slot registered by `createAuthPlugin` in the host's
* `ServiceRegistry`. Downstream plugins (api-plugin-user,
* api-plugin-vault) call `ctx.getService("authService")` to read the
* current principal, the Better-Auth instance, or to manually resolve
* a session from arbitrary headers.
*/
export interface AuthService {
/**
* Returns the typed `Principal` attached to the current request
* context, or `null` for anonymous requests. The session middleware
* is responsible for populating `c.get("user")`.
*/
getCurrentPrincipal(c: Context): Principal | null;
/**
* Returns the underlying Better-Auth instance — useful for plugins
* that need to call Better-Auth API endpoints directly (e.g.
* organisation membership listings).
*/
getBetterAuthInstance(): Auth;
/**
* Resolves a session from arbitrary request headers. Plugins
* authenticating a stand-alone request (cron-triggered work, raw
* WebSocket upgrades) use this to get the same session shape the
* middleware would have produced.
*/
getSession(headers: Headers): Promise<BetterAuthSessionResult>;
}
/**
* Inputs required to build the `AuthService` impl. Kept explicit so
* the factory is testable without the full plugin lifecycle.
*/
export interface CreateAuthServiceOptions {
readonly auth: Auth;
}
/**
* Construct the canonical `AuthService` implementation backed by the
* supplied Better-Auth instance.
*/
export function createAuthService(
options: CreateAuthServiceOptions
): AuthService {
const { auth } = options;
return {
getCurrentPrincipal(c) {
const value = c.get("user");
return value ?? null;
},
getBetterAuthInstance() {
return auth;
},
async getSession(headers) {
const result = (await auth.api.getSession({
headers,
})) as BetterAuthSessionResult;
return result;
},
};
}

View File

@@ -0,0 +1,8 @@
import "./registry-augmentation.js";
export {
type AuthService,
type BetterAuthSessionResult,
type CreateAuthServiceOptions,
createAuthService,
} from "./auth-service.js";

View File

@@ -0,0 +1,84 @@
# services/registry-augmentation
## Purpose
Module augmentation that wires the `authService` slot into
`@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
interface. Once this file is imported (the package root barrel does so
as a side effect), every consumer's `ctx.getService("authService")`
returns `AuthService | undefined` with no cast.
## Features
- Side-effect import — pure type-system declaration, no runtime cost.
- Adds the `authService: AuthService` slot to `ServiceRegistry`.
- Re-exported transitively from `./index.ts` so the augmentation
loads with the first auth-plugin import.
## Usage: Basic
```ts
import "@suaveplan/api-plugin-auth/services/auth-service";
const svc = ctx.getService("authService");
```
## Usage: Advanced
From inside a sibling plugin's `onInit`, resolve the service with
typed inference. The augmentation makes the slot visible everywhere
`@suaveplan/api-plugin`'s `ServiceRegistry` is referenced, including
in fully-typed `ctx.getService(...)` calls inside third-party plugins:
```ts
import { definePlugin } from "@suaveplan/api-plugin";
export const myPlugin = definePlugin({
name: "my-plugin",
version: "0.1.0",
dependencies: ["auth"],
async onInit(ctx) {
const authService = ctx.getService("authService");
if (!authService) {
ctx.logger.warn("auth plugin disabled — running unauthenticated");
return;
}
const auth = authService.getBetterAuthInstance();
// Use the typed Better-Auth instance here.
},
});
```
## API Reference
### Module augmentation
```ts
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
authService: AuthService;
}
}
```
There are no runtime exports — the file is imported solely for the
declaration.
## Implementation Notes
- The slot is `AuthService` (no `| undefined`); the wrapper in
`ServiceRegistry` adds the `undefined` automatically via
`ctx.getService`'s return type signature.
- This pattern matches `@suaveplan/api`'s own registry-augmentation
module, which adds `app`, `openAPIRegistry`, `migrationRunner`,
`db`, `logger`, `eventBus`, `clock`. Loading both modules is
cumulative — TypeScript merges the declarations.
- Sibling plugins (user, vault) MUST NOT re-declare `authService`;
they declare their own slots (`userService`, `vaultService`).
- The augmentation also re-declares the host slots (`app`,
`openAPIRegistry`, `migrationRunner`, `db`, `logger`, `eventBus`,
`clock`) so the auth plugin's `onInit(ctx)` can resolve them with
full typing even when consumers run isolated unit tests of the
plugin without importing `@suaveplan/api`. TypeScript merges the
duplicate property declarations into a single combined interface,
so this is safe alongside the canonical augmentation shipped by
`@suaveplan/api`'s own `registry-augmentation` module.

View File

@@ -0,0 +1,39 @@
/**
* ServiceRegistry module augmentation — declares the `authService`
* slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
* interface so downstream plugins typed against the augmented registry
* see `ctx.getService("authService")` resolve to `AuthService |
* undefined` without any cast.
*
* Importing this module for its side effects (which the package root
* barrel does automatically) is sufficient — every consumer of
* `@suaveplan/api-plugin-auth` gets typed `authService` access.
*
* The host-side slots (`app`, `openAPIRegistry`, `migrationRunner`,
* `db`, `logger`, `eventBus`, `clock`) are owned by `@suaveplan/api`'s
* own registry-augmentation module. We re-declare them here as a
* cumulative augmentation so the auth plugin's `onInit(ctx)` can
* resolve them with full typing even when the consumer has not
* explicitly imported `@suaveplan/api` (e.g. during isolated
* unit-tests of the plugin). TypeScript merges duplicate declarations,
* so this is safe alongside `@suaveplan/api`'s declarations.
*/
import type { OpenAPIHono } from "@hono/zod-openapi";
import type { MigrationRunner } from "@suaveplan/api-migrations";
import type { OpenAPIRegistry } from "@suaveplan/api-openapi";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
import type { AuthService } from "./auth-service.js";
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
authService: AuthService;
app: OpenAPIHono;
openAPIRegistry: OpenAPIRegistry;
migrationRunner?: MigrationRunner;
db?: SurrealAdapter;
logger: LoggerLike;
eventBus: EventBusLike;
clock: Clock;
}
}

View File

@@ -0,0 +1,4 @@
export type {
AuthPluginConfig,
AuthPluginOptions,
} from "./types.js";

View File

@@ -0,0 +1,85 @@
# types/types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Top-level type definitions for `@suaveplan/api-plugin-auth`. This
module exports the `AuthPluginOptions` shape (what callers pass to
`createAuthPlugin`) and the `AuthPluginConfig` shape (the fully-
resolved options after Zod parse). The two shapes differ only in
default-application: every optional field in `Options` becomes
non-optional in `Config` after `AuthPluginOptionsSchema` runs.
## Features
- Pair of typed interfaces for the plugin's public option surface.
- Field-level JSDoc on every option so IDEs surface the contract at
the call site.
- Forward-compatible `betterAuth: Record<string, unknown>` field —
callers pass any Better-Auth options without forcing a version
lock-step on the auth plugin.
## Usage: Basic
```ts
import type { AuthPluginOptions } from "@suaveplan/api-plugin-auth/types/types";
const opts: AuthPluginOptions = {
betterAuth: { /* … */ },
};
```
## Usage: Advanced
Use `AuthPluginConfig` as the typed `defaults` shape when extending
the plugin from another package, or when stamping config into a host
that loads plugins lazily:
```ts
import type {
AuthPluginConfig,
AuthPluginOptions,
} from "@suaveplan/api-plugin-auth/types/types";
function makeOptions(env: "dev" | "prod"): AuthPluginOptions {
return {
betterAuth: env === "dev" ? { /* dev */ } : { /* prod */ },
routePrefix: env === "dev" ? "/dev/auth" : "/api/v1/auth",
applySchemaOnInit: env === "dev",
};
}
declare const cfg: AuthPluginConfig;
cfg.routePrefix; // "/auth" by default
```
## API Reference
### `interface AuthPluginOptions`
- `betterAuth: Record<string, unknown>` — Better-Auth options
forwarded to `betterAuth(opts)`.
- `routePrefix?: string` — defaults to `/auth`.
- `applySchemaOnInit?: boolean` — defaults to `true`.
- `principalMapping?: (view: BetterAuthSessionView) => Principal`.
- `logger?: LoggerLike`.
### `interface AuthPluginConfig`
Same fields as `AuthPluginOptions` but with `routePrefix` and
`applySchemaOnInit` non-optional (defaults applied).
## Implementation Notes
- The two interfaces overlap deliberately — `AuthPluginConfig` is the
`z.output` of `AuthPluginOptionsSchema`, but expressed as a hand-
written interface so the public API surface is reviewable without
reaching into Zod's inferred types.
- `betterAuth` carries the opaque shape rather than the upstream
`BetterAuthOptions` so the plugin tolerates Better-Auth minor
upgrades without re-publishing.
- Both interfaces are declared `readonly` so a `Config` value
destructured into a downstream module cannot be mutated.

View File

@@ -0,0 +1,56 @@
import type { LoggerLike } from "@suaveplan/types";
import type {
BetterAuthSessionView,
Principal,
} from "../principal/principal.js";
/**
* Top-level options accepted by `createAuthPlugin`. Validated by
* `AuthPluginOptionsSchema` at the factory boundary; the resolved
* shape (with defaults applied) is the runtime `AuthPluginConfig`.
*/
export interface AuthPluginOptions {
/**
* Better-Auth options forwarded verbatim to `betterAuth(...)`.
* Validated by Better-Auth itself — the auth plugin treats this as
* an opaque blob.
*/
readonly betterAuth: Record<string, unknown>;
/**
* URL prefix where the plugin mounts its 9 routes. Defaults to
* `/auth`. Must start with `/` and not carry a trailing slash
* (the exact root `/` is allowed).
*/
readonly routePrefix?: string;
/**
* When `true` (the default), `onInit` applies
* `BETTER_AUTH_SURREAL_SCHEMA` against the resolved migration
* runner (preferred) or `SurrealAdapter`. Set `false` when
* operators manage migrations externally.
*/
readonly applySchemaOnInit?: boolean;
/**
* Custom Better-Auth → `Principal` mapping. Defaults to
* `mapBetterAuthUserToPrincipal`. Override to project Better-Auth
* plugin-extended fields onto a custom `Principal` extension.
*/
readonly principalMapping?: (view: BetterAuthSessionView) => Principal;
/**
* Optional logger override. When omitted, the plugin uses the
* `PluginContext` logger supplied by the registry.
*/
readonly logger?: LoggerLike;
}
/**
* Fully-resolved options after Zod parse — every default has been
* applied. The plugin's `PluginDescriptor.configSchema` infers to this
* type.
*/
export interface AuthPluginConfig {
readonly betterAuth: Record<string, unknown>;
readonly routePrefix: string;
readonly applySchemaOnInit: boolean;
readonly principalMapping?: (view: BetterAuthSessionView) => Principal;
readonly logger?: LoggerLike;
}

View File

@@ -0,0 +1,134 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const pkgRoot = resolve(__dirname, "..");
const srcRoot = join(pkgRoot, "src");
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) out.push(...walk(full));
else out.push(full);
}
return out;
}
describe("@suaveplan/api-plugin-auth dep graph + layout", () => {
it("package.json carries required dependencies", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(pkgRoot, "package.json"), "utf8")
) as {
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
};
const deps = pkg.dependencies;
expect(deps["@suaveplan/api-plugin"]).toBe("workspace:^");
expect(deps["@suaveplan/api-openapi"]).toBe("workspace:^");
expect(deps["@suaveplan/api-migrations"]).toBe("workspace:^");
expect(deps["@suaveplan/better-auth-surrealdb"]).toBe("workspace:^");
expect(deps["@suaveplan/surrealdb"]).toBe("workspace:^");
expect(deps["@suaveplan/error"]).toBe("workspace:^");
expect(deps["@suaveplan/types"]).toBe("workspace:^");
expect(deps["hono"]).toBe("catalog:");
expect(deps["@hono/zod-openapi"]).toBe("catalog:server");
expect(deps["zod"]).toBe("catalog:forms");
expect(deps["better-auth"]).toBe("catalog:auth");
expect(deps["@opentelemetry/api"]).toBe("catalog:observability");
});
it("package.json exposes 15 subpath exports", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(pkgRoot, "package.json"), "utf8")
) as { exports: Record<string, unknown> };
const subs = Object.keys(pkg.exports).filter(
(k) => k.startsWith("./") && k !== "./package.json"
);
// 15 named feature subpaths (root "." is excluded by filter).
expect(subs.length).toBe(15);
});
it("every impl .ts has a sibling .md", { timeout: 5000 }, () => {
const missing: string[] = [];
for (const file of walk(srcRoot)) {
if (!file.endsWith(".ts")) continue;
if (
file.endsWith(".test.ts") ||
file.endsWith(".spec.ts") ||
file.endsWith(".types.ts") ||
file.endsWith("/index.ts")
) {
continue;
}
const md = file.replace(/\.ts$/, ".md");
try {
statSync(md);
} catch {
missing.push(md);
}
}
expect(missing).toEqual([]);
});
it("every schemas/routes/<name>.ts has a sibling .md", {
timeout: 5000,
}, () => {
const routesDir = join(srcRoot, "schemas", "routes");
const missing: string[] = [];
for (const entry of readdirSync(routesDir)) {
if (!entry.endsWith(".ts")) continue;
if (
entry === "index.ts" ||
entry.endsWith(".test.ts") ||
entry.endsWith(".types.ts")
) {
continue;
}
const md = join(routesDir, entry.replace(/\.ts$/, ".md"));
try {
statSync(md);
} catch {
missing.push(md);
}
}
expect(missing).toEqual([]);
});
it("README.md is present at package root", { timeout: 5000 }, () => {
const readme = readFileSync(join(pkgRoot, "README.md"), "utf8");
expect(readme.length).toBeGreaterThan(200);
expect(readme).toContain("@suaveplan/api-plugin-auth");
});
it("every subpath export resolves to a file within src/", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(pkgRoot, "package.json"), "utf8")
) as {
exports: Record<string, { import?: { default?: string } } | string>;
};
const missing: string[] = [];
for (const [sub, val] of Object.entries(pkg.exports)) {
if (sub === "./package.json") continue;
const defaultPath =
typeof val === "string"
? val
: (val?.import?.default ?? undefined);
if (defaultPath === undefined) continue;
// dist/<rel>.js → src/<rel>.ts
const rel = defaultPath
.replace(/^\.\/dist\//, "")
.replace(/\.js$/, ".ts");
const srcPath = join(srcRoot, rel);
try {
statSync(srcPath);
} catch {
missing.push(`${sub} → ${srcPath}`);
}
}
expect(missing).toEqual([]);
});
});

View File

@@ -0,0 +1,193 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
import { EventBus } from "@suaveplan/events";
import { SurrealAdapter } from "@suaveplan/surrealdb";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
import { createAuthPlugin } from "../../src/plugin/plugin.js";
import type { AuthService } from "../../src/services/auth-service.js";
import type { AuthPluginConfig } from "../../src/types/types.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
let fixture: SurrealDBFixture | null = null;
let client: SurrealAdapter | null = null;
beforeAll(async () => {
fixture = new SurrealDBFixture();
const cfg = await fixture.start();
client = new SurrealAdapter({
url: cfg.url,
namespace: cfg.namespace,
database: cfg.database,
username: cfg.username,
password: cfg.password,
});
await client.connect();
await client.execute("DEFINE TABLE OVERWRITE user SCHEMAFULL;");
await client.execute(
"DEFINE FIELD OVERWRITE email ON user TYPE option<string>;"
);
await client.execute(
"DEFINE FIELD OVERWRITE phoneNumber ON user TYPE option<string>;"
);
await client.execute(
"DEFINE FIELD OVERWRITE createdAt ON user TYPE option<datetime> DEFAULT time::now() VALUE $before OR time::now();"
);
await client.execute(
"DEFINE FIELD OVERWRITE updatedAt ON user TYPE option<datetime> DEFAULT time::now() VALUE time::now();"
);
}, 180_000);
afterAll(async () => {
if (client) {
await client.disconnect();
client = null;
}
if (fixture) {
await fixture.stop();
fixture = null;
}
}, 60_000);
describe("full integration boot against real SurrealDB", () => {
it("full sign-up/sign-in/session/sign-out roundtrip against real SurrealDB", {
timeout: 120_000,
}, async () => {
if (client === null) throw new Error("client not ready");
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "auth-test", version: "0.1.0" },
});
const plugin = createAuthPlugin({
betterAuth: {
database: betterAuthSurrealAdapter({ client }),
secret: "test-secret-at-least-32-chars-long-for-better-auth",
baseURL: "http://localhost:3000/auth",
emailAndPassword: { enabled: true },
},
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
services.set("db", client);
const ctx = createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as AuthPluginConfig,
services,
});
await plugin.onInit?.(ctx);
registry.applyTo(app, "/openapi.json");
// The Better-Auth instance is reachable via the registered
// authService slot. We drive the lifecycle directly through
// its API (which is what HTTP routes delegate to anyway) so
// the test does not depend on Better-Auth's URL-routing
// configuration which lives outside this plugin.
const authService = services.get("authService") as AuthService;
expect(authService).toBeDefined();
const auth = authService.getBetterAuthInstance();
const signUp = (await auth.api.signUpEmail({
body: {
email: "integration@example.test",
password: "Passw0rd!2026",
name: "Integration User",
},
})) as { user: { email: string } };
expect(signUp.user.email).toBe("integration@example.test");
const users = await client.query<{ email: string }>(
"SELECT * FROM user WHERE email = 'integration@example.test';"
);
expect(users.length).toBeGreaterThan(0);
// OpenAPI document includes the 9 routes (prefixed).
const openapiRes = await app.request("/openapi.json");
const doc = (await openapiRes.json()) as {
paths: Record<string, Record<string, unknown>>;
};
const paths = Object.keys(doc.paths ?? {});
const authPaths = paths.filter((p) => p.startsWith("/auth/"));
expect(authPaths.length).toBe(9);
// Hit the registered Hono routes — the OpenAPI route
// handler delegates to auth.handler so any 2xx/3xx/4xx
// response (rather than 404) confirms routing reached
// Better-Auth. We use /auth/get-session which is the
// canonical anonymous endpoint.
const sessionRes = await app.request(
"http://localhost:3000/auth/get-session"
);
expect(sessionRes.status).not.toBe(404);
});
it("schema apply is idempotent across restarts", {
timeout: 60_000,
}, async () => {
if (client === null) throw new Error("client not ready");
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "auth-test", version: "0.1.0" },
});
const plugin = createAuthPlugin({
betterAuth: {
database: betterAuthSurrealAdapter({ client }),
secret: "test-secret-at-least-32-chars-long-for-better-auth",
baseURL: "http://localhost:3000",
emailAndPassword: { enabled: true },
},
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
services.set("db", client);
const ctx = createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as AuthPluginConfig,
services,
});
await plugin.onInit?.(ctx);
const app2 = new OpenAPIHono();
const registry2 = createOpenAPIRegistry({
info: { title: "auth-test", version: "0.1.0" },
});
const plugin2 = createAuthPlugin({
betterAuth: {
database: betterAuthSurrealAdapter({ client }),
secret: "test-secret-at-least-32-chars-long-for-better-auth",
baseURL: "http://localhost:3000",
emailAndPassword: { enabled: true },
},
});
const services2 = new Map<string, unknown>();
services2.set("app", app2);
services2.set("openAPIRegistry", registry2);
services2.set("db", client);
const ctx2 = createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin2.defaults as AuthPluginConfig,
services: services2,
});
await plugin2.onInit?.(ctx2);
expect(registry2.getRoutesByPlugin("auth")).toHaveLength(9);
});
});

View File

@@ -0,0 +1,143 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
import { EventBus } from "@suaveplan/events";
import { SurrealAdapter } from "@suaveplan/surrealdb";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
import { createAuthPlugin } from "../../src/plugin/plugin.js";
import type { AuthPluginConfig } from "../../src/types/types.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
let fixture: SurrealDBFixture | null = null;
let client: SurrealAdapter | null = null;
beforeAll(async () => {
fixture = new SurrealDBFixture();
const cfg = await fixture.start();
client = new SurrealAdapter({
url: cfg.url,
namespace: cfg.namespace,
database: cfg.database,
username: cfg.username,
password: cfg.password,
});
await client.connect();
await client.execute("DEFINE TABLE OVERWRITE user SCHEMAFULL;");
await client.execute(
"DEFINE FIELD OVERWRITE email ON user TYPE option<string>;"
);
await client.execute(
"DEFINE FIELD OVERWRITE phoneNumber ON user TYPE option<string>;"
);
await client.execute(
"DEFINE FIELD OVERWRITE createdAt ON user TYPE option<datetime> DEFAULT time::now() VALUE $before OR time::now();"
);
await client.execute(
"DEFINE FIELD OVERWRITE updatedAt ON user TYPE option<datetime> DEFAULT time::now() VALUE time::now();"
);
}, 180_000);
afterAll(async () => {
if (client) {
await client.disconnect();
client = null;
}
if (fixture) {
await fixture.stop();
fixture = null;
}
}, 60_000);
describe("openapi-doc", () => {
it("served /openapi.json includes auth routes with Zod-typed schemas", {
timeout: 120_000,
}, async () => {
if (client === null) throw new Error("client not ready");
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "auth-test", version: "0.1.0" },
});
const plugin = createAuthPlugin({
betterAuth: {
database: betterAuthSurrealAdapter({ client }),
secret: "test-secret-at-least-32-chars-long-for-better-auth",
baseURL: "http://localhost:3000/auth",
emailAndPassword: { enabled: true },
},
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
services.set("db", client);
await plugin.onInit?.(
createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as AuthPluginConfig,
services,
})
);
registry.applyTo(app, "/openapi.json");
const res = await app.request("/openapi.json");
const doc = (await res.json()) as {
paths: Record<string, Record<string, unknown>>;
};
for (const path of [
"/auth/sign-up",
"/auth/sign-in",
"/auth/sign-out",
"/auth/session",
"/auth/forgot-password",
"/auth/reset-password",
"/auth/verify-email",
]) {
expect(doc.paths[path]).toBeDefined();
}
});
it("served doc lists all 9 auth routes", { timeout: 120_000 }, async () => {
if (client === null) throw new Error("client not ready");
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "auth-test", version: "0.1.0" },
});
const plugin = createAuthPlugin({
betterAuth: {
database: betterAuthSurrealAdapter({ client }),
secret: "test-secret-at-least-32-chars-long-for-better-auth",
baseURL: "http://localhost:3000/auth",
emailAndPassword: { enabled: true },
},
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
services.set("db", client);
await plugin.onInit?.(
createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as AuthPluginConfig,
services,
})
);
const routes = registry.getRoutesByPlugin("auth");
expect(routes.length).toBe(9);
});
});

View File

@@ -0,0 +1,110 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
import { EventBus } from "@suaveplan/events";
import { SurrealAdapter } from "@suaveplan/surrealdb";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
import { createAuthPlugin } from "../../src/plugin/plugin.js";
import type { AuthPluginConfig } from "../../src/types/types.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
let fixture: SurrealDBFixture | null = null;
let client: SurrealAdapter | null = null;
beforeAll(async () => {
fixture = new SurrealDBFixture();
const cfg = await fixture.start();
client = new SurrealAdapter({
url: cfg.url,
namespace: cfg.namespace,
database: cfg.database,
username: cfg.username,
password: cfg.password,
});
await client.connect();
await client.execute("DEFINE TABLE OVERWRITE user SCHEMAFULL;");
await client.execute(
"DEFINE FIELD OVERWRITE email ON user TYPE option<string>;"
);
await client.execute(
"DEFINE FIELD OVERWRITE phoneNumber ON user TYPE option<string>;"
);
await client.execute(
"DEFINE FIELD OVERWRITE createdAt ON user TYPE option<datetime> DEFAULT time::now() VALUE $before OR time::now();"
);
await client.execute(
"DEFINE FIELD OVERWRITE updatedAt ON user TYPE option<datetime> DEFAULT time::now() VALUE time::now();"
);
}, 180_000);
afterAll(async () => {
if (client) {
await client.disconnect();
client = null;
}
if (fixture) {
await fixture.stop();
fixture = null;
}
}, 60_000);
async function bootOnce(c: SurrealAdapter): Promise<void> {
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "auth-test", version: "0.1.0" },
});
const plugin = createAuthPlugin({
betterAuth: {
database: betterAuthSurrealAdapter({ client: c }),
secret: "test-secret-at-least-32-chars-long-for-better-auth",
baseURL: "http://localhost:3000/auth",
emailAndPassword: { enabled: true },
},
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
services.set("db", c);
await plugin.onInit?.(
createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as AuthPluginConfig,
services,
})
);
}
describe("schema apply idempotency", () => {
it("re-apply succeeds after a prior boot wrote rows", {
timeout: 180_000,
}, async () => {
if (client === null) throw new Error("client not ready");
await bootOnce(client);
// Write a row so the second apply has data in scope.
await client.execute(
"CREATE user:integration_idem SET email = 'idem@example.test';"
);
// Re-apply.
await bootOnce(client);
const rows = await client.query<{ email: string }>(
"SELECT * FROM user WHERE email = 'idem@example.test';"
);
expect(rows.length).toBe(1);
});
});

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { PrincipalSchema } from "../src/schemas/principal.js";
const principalArb = fc.record(
{
id: fc.string({ minLength: 1, maxLength: 64 }),
email: fc.option(
fc
.tuple(
fc.string({ minLength: 1, maxLength: 16 }),
fc.string({ minLength: 1, maxLength: 16 })
)
.map(
([l, r]) =>
`${l.replace(/[^A-Za-z0-9]/g, "a")}@${r.replace(
/[^A-Za-z0-9]/g,
"a"
)}.test`
),
{ nil: undefined }
),
emailVerified: fc.boolean(),
roles: fc.array(fc.string({ minLength: 1, maxLength: 16 }), {
maxLength: 5,
}),
tenantId: fc.option(fc.string({ minLength: 1, maxLength: 32 }), {
nil: undefined,
}),
metadata: fc.option(
fc.dictionary(
fc.string({ minLength: 1, maxLength: 8 }),
fc.oneof(fc.string(), fc.integer(), fc.boolean())
),
{ nil: undefined }
),
},
{ requiredKeys: ["id", "emailVerified", "roles"] }
);
describe("PrincipalSchema fast-check round-trip", () => {
it("accepts arbitrary valid principals (200 runs)", {
timeout: 30_000,
}, () => {
fc.assert(
fc.property(principalArb, (sample) => {
const result = PrincipalSchema.safeParse(sample);
if (!result.success) {
throw new Error(
`unexpected reject: ${JSON.stringify(result.error.issues)}`
);
}
expect(result.data.id).toBe(sample.id);
expect(result.data.emailVerified).toBe(sample.emailVerified);
}),
{ numRuns: 200 }
);
});
});

View File

@@ -0,0 +1,34 @@
/**
* Test setup for @suaveplan/api-plugin-auth.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
* module imports so the meter resolved on first call binds to it. Node 18+
* + Bun expose `crypto.subtle` / `crypto.getRandomValues` globally so no
* crypto polyfill is required.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-plugin-auth",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^@hono\//,
"better-auth",
"hono",
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 60000,
hookTimeout: 120000,
},
});