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,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.