first commit
This commit is contained in:
195
docs/api-stack/api/api-plugin-auth/README.md
Normal file
195
docs/api-stack/api/api-plugin-auth/README.md
Normal 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.
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
97
docs/api-stack/api/api-plugin-auth/src/middleware/session.md
Normal file
97
docs/api-stack/api/api-plugin-auth/src/middleware/session.md
Normal 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.
|
||||
103
docs/api-stack/api/api-plugin-auth/src/otel/instruments.md
Normal file
103
docs/api-stack/api/api-plugin-auth/src/otel/instruments.md
Normal 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.
|
||||
121
docs/api-stack/api/api-plugin-auth/src/plugin/plugin.md
Normal file
121
docs/api-stack/api/api-plugin-auth/src/plugin/plugin.md
Normal 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`.
|
||||
108
docs/api-stack/api/api-plugin-auth/src/principal/principal.md
Normal file
108
docs/api-stack/api/api-plugin-auth/src/principal/principal.md
Normal 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".
|
||||
@@ -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.
|
||||
74
docs/api-stack/api/api-plugin-auth/src/schema/schema.md
Normal file
74
docs/api-stack/api/api-plugin-auth/src/schema/schema.md
Normal 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.
|
||||
103
docs/api-stack/api/api-plugin-auth/src/schemas/options.md
Normal file
103
docs/api-stack/api/api-plugin-auth/src/schemas/options.md
Normal 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.
|
||||
90
docs/api-stack/api/api-plugin-auth/src/schemas/principal.md
Normal file
90
docs/api-stack/api/api-plugin-auth/src/schemas/principal.md
Normal 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.
|
||||
@@ -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.
|
||||
106
docs/api-stack/api/api-plugin-auth/src/schemas/routes/oauth.md
Normal file
106
docs/api-stack/api/api-plugin-auth/src/schemas/routes/oauth.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
106
docs/api-stack/api/api-plugin-auth/src/services/auth-service.md
Normal file
106
docs/api-stack/api/api-plugin-auth/src/services/auth-service.md
Normal 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.
|
||||
@@ -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.
|
||||
85
docs/api-stack/api/api-plugin-auth/src/types/types.md
Normal file
85
docs/api-stack/api/api-plugin-auth/src/types/types.md
Normal 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.
|
||||
Reference in New Issue
Block a user