first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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.