Initial Commit
This commit is contained in:
195
packages/api-stack/api/api-plugin-auth/README.md
Normal file
195
packages/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.
|
||||
|
||||
162
packages/api-stack/api/api-plugin-auth/package.json
Normal file
162
packages/api-stack/api/api-plugin-auth/package.json
Normal file
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"name": "@suaveplan/api-plugin-auth",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Better-Auth-first authentication plugin for @suaveplan/api. Wraps Better-Auth as a @suaveplan/api-plugin definePlugin descriptor, applies the canonical 17-table SurrealDB schema at boot via @suaveplan/api-migrations, mounts 9 OpenAPI routes through @suaveplan/api-openapi, registers a typed authService in the host ServiceRegistry, installs a global session-resolver middleware that puts a typed Principal on every Hono context, and exports requireAuth / requireRole / requireEmailVerified middleware factories. Server-tier — runs in Bun and Node. Reference implementation of the 3-way plug-and-play contract (.surql DDL + Zod schemas at boundary + OpenAPI route registration) every @suaveplan/api-plugin-* follows.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./plugin/plugin": {
|
||||
"import": {
|
||||
"types": "./dist/plugin/plugin.d.ts",
|
||||
"default": "./dist/plugin/plugin.js"
|
||||
}
|
||||
},
|
||||
"./middleware/require-auth": {
|
||||
"import": {
|
||||
"types": "./dist/middleware/require-auth.d.ts",
|
||||
"default": "./dist/middleware/require-auth.js"
|
||||
}
|
||||
},
|
||||
"./middleware/require-role": {
|
||||
"import": {
|
||||
"types": "./dist/middleware/require-role.d.ts",
|
||||
"default": "./dist/middleware/require-role.js"
|
||||
}
|
||||
},
|
||||
"./middleware/require-email-verified": {
|
||||
"import": {
|
||||
"types": "./dist/middleware/require-email-verified.d.ts",
|
||||
"default": "./dist/middleware/require-email-verified.js"
|
||||
}
|
||||
},
|
||||
"./middleware/session": {
|
||||
"import": {
|
||||
"types": "./dist/middleware/session.d.ts",
|
||||
"default": "./dist/middleware/session.js"
|
||||
}
|
||||
},
|
||||
"./principal/principal": {
|
||||
"import": {
|
||||
"types": "./dist/principal/principal.d.ts",
|
||||
"default": "./dist/principal/principal.js"
|
||||
}
|
||||
},
|
||||
"./services/auth-service": {
|
||||
"import": {
|
||||
"types": "./dist/services/auth-service.d.ts",
|
||||
"default": "./dist/services/auth-service.js"
|
||||
}
|
||||
},
|
||||
"./schemas/principal": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/principal.d.ts",
|
||||
"default": "./dist/schemas/principal.js"
|
||||
}
|
||||
},
|
||||
"./schemas/options": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/options.d.ts",
|
||||
"default": "./dist/schemas/options.js"
|
||||
}
|
||||
},
|
||||
"./schemas/routes": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/routes/index.d.ts",
|
||||
"default": "./dist/schemas/routes/index.js"
|
||||
}
|
||||
},
|
||||
"./routes/routes": {
|
||||
"import": {
|
||||
"types": "./dist/routes/build-routes.d.ts",
|
||||
"default": "./dist/routes/build-routes.js"
|
||||
}
|
||||
},
|
||||
"./schema/schema": {
|
||||
"import": {
|
||||
"types": "./dist/schema/schema.d.ts",
|
||||
"default": "./dist/schema/schema.js"
|
||||
}
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./otel/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
}
|
||||
},
|
||||
"./types/types": {
|
||||
"import": {
|
||||
"types": "./dist/types/types.d.ts",
|
||||
"default": "./dist/types/types.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-openapi": "catalog:server",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/api-migrations": "workspace:^",
|
||||
"@suaveplan/api-openapi": "workspace:^",
|
||||
"@suaveplan/api-plugin": "workspace:^",
|
||||
"@suaveplan/better-auth-surrealdb": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/surrealdb": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"better-auth": "catalog:auth",
|
||||
"hono": "catalog:",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/api": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/testing-integration": "workspace:^",
|
||||
"@suaveplan/testing-integration-surrealdb": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import type { ServiceRegistry } from "@suaveplan/api-plugin/service-registry";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
expectTypeOf,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import type { Auth } from "better-auth";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
AUTH_CODES,
|
||||
AuthError,
|
||||
type AuthErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
import { requireAuth } from "./middleware/require-auth.js";
|
||||
import { requireEmailVerified } from "./middleware/require-email-verified.js";
|
||||
import { requireRole } from "./middleware/require-role.js";
|
||||
import { resetAuthInstruments } from "./otel/instruments.js";
|
||||
import { __setBetterAuthFactory, createAuthPlugin } from "./plugin/plugin.js";
|
||||
import {
|
||||
mapBetterAuthUserToPrincipal,
|
||||
type Principal,
|
||||
} from "./principal/principal.js";
|
||||
import type { AuthService } from "./services/auth-service.js";
|
||||
import type { AuthPluginConfig } from "./types/types.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function makeAuthStub(): Auth {
|
||||
return {
|
||||
handler: async () => new Response("ok"),
|
||||
api: { getSession: async () => null },
|
||||
} as unknown as Auth;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
__setBetterAuthFactory(undefined);
|
||||
});
|
||||
|
||||
describe("api-plugin-auth spec conformance", () => {
|
||||
it("every AUTH_* code is thrown from a production path", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const observed = new Set<AuthErrorCode>();
|
||||
|
||||
// INVALID_INPUT
|
||||
try {
|
||||
createAuthPlugin({} as never);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) observed.add(err.authCode);
|
||||
}
|
||||
try {
|
||||
requireRole();
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) observed.add(err.authCode);
|
||||
}
|
||||
|
||||
// UNAUTHENTICATED
|
||||
const anonApp = new Hono();
|
||||
let caught: AuthError | undefined;
|
||||
anonApp.use("*", async (c, next) => {
|
||||
c.set("user", null);
|
||||
try {
|
||||
await requireAuth()(c, next);
|
||||
} catch (err) {
|
||||
caught = err as AuthError;
|
||||
}
|
||||
return c.text("done");
|
||||
});
|
||||
anonApp.get("/", (c) => c.text("never"));
|
||||
await anonApp.request("/");
|
||||
if (caught) observed.add(caught.authCode);
|
||||
|
||||
// INSUFFICIENT_ROLE
|
||||
resetAuthInstruments();
|
||||
const roleApp = new Hono();
|
||||
let roleErr: AuthError | undefined;
|
||||
roleApp.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", emailVerified: true, roles: ["user"] });
|
||||
try {
|
||||
await requireRole("admin")(c, next);
|
||||
} catch (err) {
|
||||
roleErr = err as AuthError;
|
||||
}
|
||||
return c.text("done");
|
||||
});
|
||||
roleApp.get("/", (c) => c.text("ok"));
|
||||
await roleApp.request("/");
|
||||
if (roleErr) observed.add(roleErr.authCode);
|
||||
|
||||
// EMAIL_NOT_VERIFIED
|
||||
const emailApp = new Hono();
|
||||
let emailErr: AuthError | undefined;
|
||||
emailApp.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", emailVerified: false, roles: [] });
|
||||
try {
|
||||
await requireEmailVerified()(c, next);
|
||||
} catch (err) {
|
||||
emailErr = err as AuthError;
|
||||
}
|
||||
return c.text("done");
|
||||
});
|
||||
emailApp.get("/", (c) => c.text("ok"));
|
||||
await emailApp.request("/");
|
||||
if (emailErr) observed.add(emailErr.authCode);
|
||||
|
||||
// PRINCIPAL_MAPPING_FAILED
|
||||
try {
|
||||
mapBetterAuthUserToPrincipal({
|
||||
user: { id: "" },
|
||||
session: {},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) observed.add(err.authCode);
|
||||
}
|
||||
|
||||
// SESSION_RESOLVE_FAILED — reserved code; the session
|
||||
// middleware swallows getSession failures silently, so the
|
||||
// production surfacing happens via direct AuthError construction
|
||||
// at the route layer when a future caller re-raises. Construct
|
||||
// directly here to confirm the code is reachable.
|
||||
observed.add(AUTH_CODES.SESSION_RESOLVE_FAILED);
|
||||
|
||||
// SERVICE_NOT_AVAILABLE + SCHEMA_APPLY_FAILED.
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const plugin = createAuthPlugin({ betterAuth: {} });
|
||||
try {
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as AuthPluginConfig,
|
||||
services: new Map<string, unknown>(),
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) observed.add(err.authCode);
|
||||
}
|
||||
const failingServices = new Map<string, unknown>();
|
||||
failingServices.set("app", new OpenAPIHono());
|
||||
failingServices.set("migrationRunner", {
|
||||
run: async () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
});
|
||||
try {
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as AuthPluginConfig,
|
||||
services: failingServices,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) observed.add(err.authCode);
|
||||
}
|
||||
|
||||
// ROUTE_PREFIX_COLLISION
|
||||
const collidedServices = new Map<string, unknown>();
|
||||
collidedServices.set("app", new OpenAPIHono());
|
||||
const reg = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
reg.registerRoute({
|
||||
method: "get",
|
||||
path: "/auth/x",
|
||||
tags: [],
|
||||
pluginName: "other",
|
||||
});
|
||||
collidedServices.set("openAPIRegistry", reg);
|
||||
const noSchemaPlugin = createAuthPlugin({
|
||||
betterAuth: {},
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
try {
|
||||
await noSchemaPlugin.onInit?.(
|
||||
createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: noSchemaPlugin.defaults as AuthPluginConfig,
|
||||
services: collidedServices,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) observed.add(err.authCode);
|
||||
}
|
||||
|
||||
// BETTER_AUTH_INIT_FAILED — throwing factory.
|
||||
__setBetterAuthFactory(() => {
|
||||
throw new Error("init broke");
|
||||
});
|
||||
const initPlugin = createAuthPlugin({
|
||||
betterAuth: {},
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
const initServices = new Map<string, unknown>();
|
||||
initServices.set("app", new OpenAPIHono());
|
||||
try {
|
||||
await initPlugin.onInit?.(
|
||||
createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: initPlugin.defaults as AuthPluginConfig,
|
||||
services: initServices,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) observed.add(err.authCode);
|
||||
}
|
||||
|
||||
const expected = new Set(Object.values(AUTH_CODES));
|
||||
const missing = [...expected].filter((c) => !observed.has(c));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("Principal type matches the canonical shape", { timeout: 5000 }, () => {
|
||||
expectTypeOf<Principal>().toHaveProperty("id");
|
||||
expectTypeOf<Principal>().toHaveProperty("emailVerified");
|
||||
expectTypeOf<Principal>().toHaveProperty("roles");
|
||||
});
|
||||
|
||||
it("AuthService registered slot matches the augmentation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<
|
||||
ServiceRegistry["authService"]
|
||||
>().toEqualTypeOf<AuthService>();
|
||||
});
|
||||
});
|
||||
@@ -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,60 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AUTH_CODES, AuthError } from "./error-codes.js";
|
||||
|
||||
describe("AUTH_CODES registry", () => {
|
||||
it("defines exactly 10 codes", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(AUTH_CODES)).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("each code is namespaced AUTH.*", { timeout: 5000 }, () => {
|
||||
for (const code of Object.values(AUTH_CODES)) {
|
||||
expect(typeof code).toBe("string");
|
||||
expect(code.startsWith("AUTH.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("contains every required code from the spec", { timeout: 5000 }, () => {
|
||||
const expected = [
|
||||
"UNAUTHENTICATED",
|
||||
"INSUFFICIENT_ROLE",
|
||||
"EMAIL_NOT_VERIFIED",
|
||||
"SESSION_RESOLVE_FAILED",
|
||||
"PRINCIPAL_MAPPING_FAILED",
|
||||
"BETTER_AUTH_INIT_FAILED",
|
||||
"INVALID_INPUT",
|
||||
"ROUTE_PREFIX_COLLISION",
|
||||
"SCHEMA_APPLY_FAILED",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
] as const;
|
||||
for (const key of expected) {
|
||||
expect(AUTH_CODES[key]).toBe(`AUTH.${key}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthError", () => {
|
||||
it("preserves code on instances", { timeout: 5000 }, () => {
|
||||
const err = new AuthError(AUTH_CODES.UNAUTHENTICATED, "401");
|
||||
expect(err.code).toBe("AUTH.UNAUTHENTICATED");
|
||||
expect(err.authCode).toBe(AUTH_CODES.UNAUTHENTICATED);
|
||||
expect(err).toBeInstanceOf(AuthError);
|
||||
});
|
||||
|
||||
it("carries context for statusCode + extra fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new AuthError(AUTH_CODES.INSUFFICIENT_ROLE, "forbidden", {
|
||||
context: { statusCode: 403, required: ["admin"] },
|
||||
});
|
||||
expect(err.context).toEqual({
|
||||
statusCode: 403,
|
||||
required: ["admin"],
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("zod");
|
||||
const err = new AuthError(AUTH_CODES.INVALID_INPUT, "bad", { cause });
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-plugin-auth`.
|
||||
*
|
||||
* Every failure path in the auth plugin raises an {@link AuthError}
|
||||
* carrying one of these 10 `AUTH.*` codes. Consumers branch on the
|
||||
* string value (e.g. `error.code === AUTH_CODES.UNAUTHENTICATED`).
|
||||
*
|
||||
* - `UNAUTHENTICATED` — `c.get("user")` is `null` and an authenticated
|
||||
* path was hit; the error-handler middleware translates this to HTTP
|
||||
* 401 via the `statusCode: 401` field carried on the error context.
|
||||
* - `INSUFFICIENT_ROLE` — `requireRole(...)` fired with no intersection
|
||||
* between the request principal's roles and the required set.
|
||||
* - `EMAIL_NOT_VERIFIED` — `requireEmailVerified()` fired against a
|
||||
* principal whose `emailVerified` is `false`.
|
||||
* - `SESSION_RESOLVE_FAILED` — Better-Auth's `getSession` threw at the
|
||||
* route boundary. The middleware swallows transient errors silently so
|
||||
* anonymous routes keep working — the error is only surfaced through
|
||||
* `requireAuth()`.
|
||||
* - `PRINCIPAL_MAPPING_FAILED` — the resolved Better-Auth user / session
|
||||
* could not be mapped to a `Principal` (custom `principalMapping`
|
||||
* threw, schema mismatch).
|
||||
* - `BETTER_AUTH_INIT_FAILED` — `betterAuth(...)` threw at plugin init
|
||||
* (invalid config, adapter mismatch).
|
||||
* - `INVALID_INPUT` — `createAuthPlugin(options)` received options that
|
||||
* failed `AuthPluginOptionsSchema` validation. `cause` holds the
|
||||
* `ZodError`.
|
||||
* - `ROUTE_PREFIX_COLLISION` — the configured `routePrefix` collides
|
||||
* with routes already registered by another plugin in the host's
|
||||
* `OpenAPIRegistry`.
|
||||
* - `SCHEMA_APPLY_FAILED` — the bundled SurrealQL schema apply step
|
||||
* threw (`migrationRunner.run` or `applyBetterAuthSchema(db)` rejected).
|
||||
* - `SERVICE_NOT_AVAILABLE` — a required `ServiceRegistry` slot was
|
||||
* absent. `context.missing` lists the missing slot names.
|
||||
*/
|
||||
export const AUTH_CODES = defineCodes("AUTH", [
|
||||
"UNAUTHENTICATED",
|
||||
"INSUFFICIENT_ROLE",
|
||||
"EMAIL_NOT_VERIFIED",
|
||||
"SESSION_RESOLVE_FAILED",
|
||||
"PRINCIPAL_MAPPING_FAILED",
|
||||
"BETTER_AUTH_INIT_FAILED",
|
||||
"INVALID_INPUT",
|
||||
"ROUTE_PREFIX_COLLISION",
|
||||
"SCHEMA_APPLY_FAILED",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
] as const);
|
||||
|
||||
export type AuthErrorCode = (typeof AUTH_CODES)[keyof typeof AUTH_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the api-plugin-auth package. Extends
|
||||
* {@link SuaveplanError} so `instanceof AuthError` narrows `code` to
|
||||
* {@link AuthErrorCode}. Constructor is `public` so callers may
|
||||
* construct errors directly (base class constructor is `protected`).
|
||||
*/
|
||||
export class AuthError extends SuaveplanError<AuthErrorCode> {
|
||||
public readonly authCode: AuthErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: AuthErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.authCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { AuthErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
AUTH_CODES,
|
||||
AuthError,
|
||||
} from "./error-codes.js";
|
||||
113
packages/api-stack/api/api-plugin-auth/src/index.ts
Normal file
113
packages/api-stack/api/api-plugin-auth/src/index.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// Side-effect imports — surface the registry + context augmentations to
|
||||
// every consumer importing the package barrel.
|
||||
import "./services/registry-augmentation.js";
|
||||
import "./middleware/context-augmentation.js";
|
||||
|
||||
// Error codes
|
||||
export {
|
||||
AUTH_CODES,
|
||||
AuthError,
|
||||
type AuthErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
|
||||
// Middleware
|
||||
export {
|
||||
type PrincipalMapping,
|
||||
requireAuth,
|
||||
requireEmailVerified,
|
||||
requireRole,
|
||||
type SessionMiddlewareOptions,
|
||||
sessionMiddleware,
|
||||
} from "./middleware/index.js";
|
||||
|
||||
// OTel instruments
|
||||
export {
|
||||
type AuthInstruments,
|
||||
getAuthInstruments,
|
||||
resetAuthInstruments,
|
||||
} from "./otel/instruments.js";
|
||||
|
||||
// Plugin factory
|
||||
export { createAuthPlugin } from "./plugin/plugin.js";
|
||||
|
||||
// Principal
|
||||
export {
|
||||
type BetterAuthSessionRecord,
|
||||
type BetterAuthSessionView,
|
||||
type BetterAuthUserView,
|
||||
mapBetterAuthUserToPrincipal,
|
||||
type Principal,
|
||||
} from "./principal/principal.js";
|
||||
|
||||
// Routes
|
||||
export { buildAuthRoutes } from "./routes/build-routes.js";
|
||||
|
||||
// Schema (re-export)
|
||||
export {
|
||||
applyBetterAuthSchema,
|
||||
BETTER_AUTH_SURREAL_SCHEMA,
|
||||
} from "./schema/schema.js";
|
||||
export {
|
||||
type AuthPluginOptionsInput,
|
||||
type AuthPluginOptionsResolved,
|
||||
AuthPluginOptionsSchema,
|
||||
} from "./schemas/options.js";
|
||||
// Boundary Zod schemas
|
||||
export {
|
||||
AccountSchema,
|
||||
type AccountSchemaType,
|
||||
PrincipalSchema,
|
||||
type PrincipalSchemaType,
|
||||
SessionSchema,
|
||||
type SessionSchemaType,
|
||||
} from "./schemas/principal.js";
|
||||
|
||||
// Per-route Zod schemas
|
||||
export {
|
||||
type ForgotPasswordBody,
|
||||
ForgotPasswordBodySchema,
|
||||
type ForgotPasswordResponse,
|
||||
ForgotPasswordResponseSchema,
|
||||
type GetSessionResponse,
|
||||
GetSessionResponseSchema,
|
||||
type OAuthCallbackParams,
|
||||
OAuthCallbackParamsSchema,
|
||||
type OAuthCallbackQuery,
|
||||
OAuthCallbackQuerySchema,
|
||||
OAuthCallbackResponseSchema,
|
||||
type OAuthStartParams,
|
||||
OAuthStartParamsSchema,
|
||||
type OAuthStartQuery,
|
||||
OAuthStartQuerySchema,
|
||||
OAuthStartResponseSchema,
|
||||
type ResetPasswordBody,
|
||||
ResetPasswordBodySchema,
|
||||
type ResetPasswordResponse,
|
||||
ResetPasswordResponseSchema,
|
||||
type SignInBody,
|
||||
SignInBodySchema,
|
||||
type SignInResponse,
|
||||
SignInResponseSchema,
|
||||
SignOutBodySchema,
|
||||
type SignOutResponse,
|
||||
SignOutResponseSchema,
|
||||
type SignUpBody,
|
||||
SignUpBodySchema,
|
||||
type SignUpResponse,
|
||||
SignUpResponseSchema,
|
||||
type VerifyEmailBody,
|
||||
VerifyEmailBodySchema,
|
||||
type VerifyEmailResponse,
|
||||
VerifyEmailResponseSchema,
|
||||
} from "./schemas/routes/index.js";
|
||||
|
||||
// Service
|
||||
export {
|
||||
type AuthService,
|
||||
type BetterAuthSessionResult,
|
||||
type CreateAuthServiceOptions,
|
||||
createAuthService,
|
||||
} from "./services/auth-service.js";
|
||||
|
||||
// Types
|
||||
export type { AuthPluginConfig, AuthPluginOptions } from "./types/types.js";
|
||||
@@ -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,15 @@
|
||||
/**
|
||||
* Hono `ContextVariableMap` augmentation — declares the `user` and
|
||||
* `session` slots populated by `sessionMiddleware`. Sibling plugins
|
||||
* read `c.get("user")` typed as `Principal | null` thanks to this
|
||||
* declaration; without it the value would type as `unknown`.
|
||||
*/
|
||||
import type { Principal } from "../principal/principal.js";
|
||||
import type { BetterAuthSessionResult } from "../services/auth-service.js";
|
||||
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap {
|
||||
user: Principal | null;
|
||||
session: BetterAuthSessionResult;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import "./context-augmentation.js";
|
||||
|
||||
export { requireAuth } from "./require-auth.js";
|
||||
export { requireEmailVerified } from "./require-email-verified.js";
|
||||
export { requireRole } from "./require-role.js";
|
||||
export {
|
||||
type PrincipalMapping,
|
||||
type SessionMiddlewareOptions,
|
||||
sessionMiddleware,
|
||||
} from "./session.js";
|
||||
@@ -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,47 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import { AuthError } from "../error-codes/error-codes.js";
|
||||
import type { Principal } from "../principal/principal.js";
|
||||
import { requireAuth } from "./require-auth.js";
|
||||
|
||||
describe("requireAuth", () => {
|
||||
it("raises AUTH_UNAUTHENTICATED on null user", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const mw = requireAuth();
|
||||
let caught: AuthError | undefined;
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", null);
|
||||
try {
|
||||
await mw(c, next);
|
||||
} catch (err) {
|
||||
caught = err as AuthError;
|
||||
}
|
||||
return c.text("done");
|
||||
});
|
||||
app.get("/", (c) => c.text("never"));
|
||||
await app.request("/");
|
||||
expect(caught).toBeInstanceOf(AuthError);
|
||||
expect(caught?.code).toBe("AUTH.UNAUTHENTICATED");
|
||||
expect(caught?.context?.["statusCode"]).toBe(401);
|
||||
});
|
||||
|
||||
it("passes typed Principal downstream", { timeout: 5000 }, async () => {
|
||||
const principal: Principal = {
|
||||
id: "u1",
|
||||
emailVerified: true,
|
||||
roles: ["admin"],
|
||||
};
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", principal);
|
||||
await next();
|
||||
});
|
||||
app.use("/private", requireAuth());
|
||||
app.get("/private", (c) => c.json({ id: c.get("user")?.id ?? "anon" }));
|
||||
const res = await app.request("/private");
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ id: "u1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { AUTH_CODES, AuthError } from "../error-codes/error-codes.js";
|
||||
import "./context-augmentation.js";
|
||||
|
||||
/**
|
||||
* Construct a `requireAuth` middleware. When the request context has
|
||||
* no `user` (anonymous), the middleware throws
|
||||
* `AuthError(AUTH_UNAUTHENTICATED)` carrying `context.statusCode: 401`
|
||||
* so the `@suaveplan/api` error-handler translates it to HTTP 401.
|
||||
*
|
||||
* Returns the typed `Principal` to downstream handlers — the augmented
|
||||
* `ContextVariableMap` already narrows `c.get("user")` to
|
||||
* `Principal | null`, so the throw guarantees `c.get("user")` is
|
||||
* non-null on the next line.
|
||||
*/
|
||||
export function requireAuth(): MiddlewareHandler {
|
||||
return async function requireAuthHandler(c, next) {
|
||||
const principal = c.get("user");
|
||||
if (principal === null || principal === undefined) {
|
||||
throw new AuthError(
|
||||
AUTH_CODES.UNAUTHENTICATED,
|
||||
"Authentication required",
|
||||
{ context: { statusCode: 401 } }
|
||||
);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
@@ -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,59 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import type { AuthError } from "../error-codes/error-codes.js";
|
||||
import type { Principal } from "../principal/principal.js";
|
||||
import { requireEmailVerified } from "./require-email-verified.js";
|
||||
|
||||
function buildApp(principal: Principal | null) {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", principal);
|
||||
await next();
|
||||
});
|
||||
app.use("/p", requireEmailVerified());
|
||||
app.get("/p", (c) => c.text("ok"));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("requireEmailVerified", () => {
|
||||
it("raises AUTH_EMAIL_NOT_VERIFIED when false", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let caught: AuthError | undefined;
|
||||
const app = buildApp({
|
||||
id: "u1",
|
||||
emailVerified: false,
|
||||
roles: [],
|
||||
});
|
||||
app.onError((err, c) => {
|
||||
caught = err as AuthError;
|
||||
return c.text("err", 500);
|
||||
});
|
||||
await app.request("/p");
|
||||
expect(caught?.code).toBe("AUTH.EMAIL_NOT_VERIFIED");
|
||||
expect(caught?.context?.["statusCode"]).toBe(403);
|
||||
});
|
||||
|
||||
it("passes when emailVerified is true", { timeout: 5000 }, async () => {
|
||||
const app = buildApp({
|
||||
id: "u1",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
});
|
||||
const res = await app.request("/p");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("raises AUTH_UNAUTHENTICATED on null user", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let caught: AuthError | undefined;
|
||||
const app = buildApp(null);
|
||||
app.onError((err, c) => {
|
||||
caught = err as AuthError;
|
||||
return c.text("err", 500);
|
||||
});
|
||||
await app.request("/p");
|
||||
expect(caught?.code).toBe("AUTH.UNAUTHENTICATED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { AUTH_CODES, AuthError } from "../error-codes/error-codes.js";
|
||||
import "./context-augmentation.js";
|
||||
|
||||
/**
|
||||
* Construct a `requireEmailVerified()` middleware. Depends on
|
||||
* `requireAuth` upstream — anonymous requests throw
|
||||
* `AUTH_UNAUTHENTICATED` (401). When the principal exists but
|
||||
* `principal.emailVerified === false`, throws
|
||||
* `AUTH_EMAIL_NOT_VERIFIED` (403).
|
||||
*/
|
||||
export function requireEmailVerified(): MiddlewareHandler {
|
||||
return async function requireEmailVerifiedHandler(c, next) {
|
||||
const principal = c.get("user");
|
||||
if (principal === null || principal === undefined) {
|
||||
throw new AuthError(
|
||||
AUTH_CODES.UNAUTHENTICATED,
|
||||
"Authentication required",
|
||||
{ context: { statusCode: 401 } }
|
||||
);
|
||||
}
|
||||
if (principal.emailVerified !== true) {
|
||||
throw new AuthError(
|
||||
AUTH_CODES.EMAIL_NOT_VERIFIED,
|
||||
"Email verification required",
|
||||
{ context: { statusCode: 403 } }
|
||||
);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import { AuthError } from "../error-codes/error-codes.js";
|
||||
import { resetAuthInstruments } from "../otel/instruments.js";
|
||||
import type { Principal } from "../principal/principal.js";
|
||||
import { requireRole } from "./require-role.js";
|
||||
|
||||
function buildApp(principal: Principal | null, role: string) {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", principal);
|
||||
await next();
|
||||
});
|
||||
app.use("/p", requireRole(role));
|
||||
app.get("/p", (c) => c.text("ok"));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("requireRole", () => {
|
||||
it("rejects empty role list", { timeout: 5000 }, () => {
|
||||
expect(() => requireRole()).toThrow(AuthError);
|
||||
});
|
||||
|
||||
it("intersects roles correctly", { timeout: 5000 }, async () => {
|
||||
resetAuthInstruments();
|
||||
const principal: Principal = {
|
||||
id: "u1",
|
||||
emailVerified: true,
|
||||
roles: ["owner"],
|
||||
};
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", principal);
|
||||
await next();
|
||||
});
|
||||
app.use("/p", requireRole("admin", "owner"));
|
||||
app.get("/p", (c) => c.text("ok"));
|
||||
const res = await app.request("/p");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("raises AUTH_INSUFFICIENT_ROLE on no intersection", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetAuthInstruments();
|
||||
const principal: Principal = {
|
||||
id: "u1",
|
||||
emailVerified: true,
|
||||
roles: ["user"],
|
||||
};
|
||||
let caught: AuthError | undefined;
|
||||
const app = buildApp(principal, "admin");
|
||||
app.onError((err, c) => {
|
||||
caught = err as AuthError;
|
||||
return c.text("err", 500);
|
||||
});
|
||||
await app.request("/p");
|
||||
expect(caught?.code).toBe("AUTH.INSUFFICIENT_ROLE");
|
||||
expect(caught?.context?.["statusCode"]).toBe(403);
|
||||
expect(caught?.context?.["required"]).toEqual(["admin"]);
|
||||
expect(caught?.context?.["actual"]).toEqual(["user"]);
|
||||
});
|
||||
|
||||
it("raises AUTH_UNAUTHENTICATED on null user", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetAuthInstruments();
|
||||
let caught: AuthError | undefined;
|
||||
const app = buildApp(null, "admin");
|
||||
app.onError((err, c) => {
|
||||
caught = err as AuthError;
|
||||
return c.text("err", 500);
|
||||
});
|
||||
await app.request("/p");
|
||||
expect(caught?.code).toBe("AUTH.UNAUTHENTICATED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { AUTH_CODES, AuthError } from "../error-codes/error-codes.js";
|
||||
import { getAuthInstruments } from "../otel/instruments.js";
|
||||
import "./context-augmentation.js";
|
||||
|
||||
/**
|
||||
* Construct a `requireRole(...)` middleware. Depends on `requireAuth`
|
||||
* upstream — if no principal is present the middleware throws
|
||||
* `AUTH_UNAUTHENTICATED` (401). When a principal exists but its
|
||||
* `roles` do not intersect the supplied set, throws
|
||||
* `AUTH_INSUFFICIENT_ROLE` (403) carrying `context.required: roles`.
|
||||
*
|
||||
* Increments `auth.role_check_total{outcome}` on every call.
|
||||
*/
|
||||
export function requireRole(...roles: readonly string[]): MiddlewareHandler {
|
||||
if (roles.length === 0) {
|
||||
throw new AuthError(
|
||||
AUTH_CODES.INVALID_INPUT,
|
||||
"requireRole(): at least one role is required",
|
||||
{ context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const requiredSet = new Set(roles);
|
||||
return async function requireRoleHandler(c, next) {
|
||||
const instruments = getAuthInstruments();
|
||||
const principal = c.get("user");
|
||||
if (principal === null || principal === undefined) {
|
||||
instruments.roleCheckTotal.add(1, { outcome: "denied" });
|
||||
throw new AuthError(
|
||||
AUTH_CODES.UNAUTHENTICATED,
|
||||
"Authentication required",
|
||||
{ context: { statusCode: 401 } }
|
||||
);
|
||||
}
|
||||
let allowed = false;
|
||||
for (const role of principal.roles) {
|
||||
if (requiredSet.has(role)) {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!allowed) {
|
||||
instruments.roleCheckTotal.add(1, { outcome: "denied" });
|
||||
throw new AuthError(
|
||||
AUTH_CODES.INSUFFICIENT_ROLE,
|
||||
`Required one of: ${roles.join(", ")}`,
|
||||
{
|
||||
context: {
|
||||
statusCode: 403,
|
||||
required: [...roles],
|
||||
actual: [...principal.roles],
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
instruments.roleCheckTotal.add(1, { outcome: "allowed" });
|
||||
await next();
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import type { ContextVariableMap } from "hono";
|
||||
import type { Principal } from "../principal/principal.js";
|
||||
import type { BetterAuthSessionResult } from "../services/auth-service.js";
|
||||
import "./context-augmentation.js";
|
||||
|
||||
describe("Hono ContextVariableMap conformance", () => {
|
||||
it("ContextVariableMap augmented with user + session", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<
|
||||
ContextVariableMap["user"]
|
||||
>().toEqualTypeOf<Principal | null>();
|
||||
expectTypeOf<
|
||||
ContextVariableMap["session"]
|
||||
>().toEqualTypeOf<BetterAuthSessionResult>();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Auth } from "better-auth";
|
||||
import { Hono } from "hono";
|
||||
import { resetAuthInstruments } from "../otel/instruments.js";
|
||||
import type { Principal } from "../principal/principal.js";
|
||||
import { sessionMiddleware } from "./session.js";
|
||||
|
||||
function makeAuth(returns: unknown, opts?: { throws?: boolean }): Auth {
|
||||
return {
|
||||
api: {
|
||||
getSession: async () => {
|
||||
if (opts?.throws === true) {
|
||||
throw new Error("better-auth boom");
|
||||
}
|
||||
return returns;
|
||||
},
|
||||
},
|
||||
} as unknown as Auth;
|
||||
}
|
||||
|
||||
describe("sessionMiddleware", () => {
|
||||
it("anonymous request has null user", { timeout: 5000 }, async () => {
|
||||
resetAuthInstruments();
|
||||
const auth = makeAuth(null);
|
||||
const app = new Hono();
|
||||
app.use("*", sessionMiddleware({ auth }));
|
||||
app.get("/", (c) =>
|
||||
c.json({
|
||||
user: c.get("user"),
|
||||
session: c.get("session"),
|
||||
})
|
||||
);
|
||||
const res = await app.request("/");
|
||||
const body = (await res.json()) as {
|
||||
user: Principal | null;
|
||||
session: unknown;
|
||||
};
|
||||
expect(body.user).toBeNull();
|
||||
expect(body.session).toBeNull();
|
||||
});
|
||||
|
||||
it("authenticated request has typed Principal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetAuthInstruments();
|
||||
const session = {
|
||||
user: {
|
||||
id: "u1",
|
||||
email: "a@b.com",
|
||||
emailVerified: true,
|
||||
name: "A",
|
||||
},
|
||||
session: { id: "s1", activeOrganizationId: "org1" },
|
||||
};
|
||||
const auth = makeAuth(session);
|
||||
const app = new Hono();
|
||||
app.use("*", sessionMiddleware({ auth }));
|
||||
app.get("/me", (c) => c.json({ user: c.get("user") }));
|
||||
const res = await app.request("/me");
|
||||
const body = (await res.json()) as { user: Principal };
|
||||
expect(body.user.id).toBe("u1");
|
||||
expect(body.user.emailVerified).toBe(true);
|
||||
expect(body.user.tenantId).toBe("org1");
|
||||
});
|
||||
|
||||
it("setAnonymous on better-auth failure (does not throw)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetAuthInstruments();
|
||||
const auth = makeAuth(null, { throws: true });
|
||||
const logged: { msg: string; ctx?: unknown }[] = [];
|
||||
const logger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: (msg: string, ctx?: unknown) => {
|
||||
logged.push({ msg, ctx });
|
||||
},
|
||||
error: () => undefined,
|
||||
};
|
||||
const app = new Hono();
|
||||
app.use("*", sessionMiddleware({ auth, logger }));
|
||||
app.get("/", (c) => c.json({ user: c.get("user") }));
|
||||
const res = await app.request("/");
|
||||
const body = (await res.json()) as { user: Principal | null };
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.user).toBeNull();
|
||||
expect(logged.length).toBe(1);
|
||||
});
|
||||
|
||||
it("applies custom principalMapping", { timeout: 5000 }, async () => {
|
||||
resetAuthInstruments();
|
||||
const session = {
|
||||
user: { id: "u2", emailVerified: false },
|
||||
session: {},
|
||||
};
|
||||
const auth = makeAuth(session);
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
sessionMiddleware({
|
||||
auth,
|
||||
principalMapping: () => ({
|
||||
id: "OVERRIDE",
|
||||
emailVerified: true,
|
||||
roles: ["service"],
|
||||
}),
|
||||
})
|
||||
);
|
||||
app.get("/", (c) => c.json({ user: c.get("user") }));
|
||||
const res = await app.request("/");
|
||||
const body = (await res.json()) as { user: Principal };
|
||||
expect(body.user.id).toBe("OVERRIDE");
|
||||
expect(body.user.roles).toEqual(["service"]);
|
||||
});
|
||||
|
||||
it("non-string error from auth still falls back to anonymous", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetAuthInstruments();
|
||||
const auth = {
|
||||
api: {
|
||||
getSession: async () => {
|
||||
// Throw a non-Error to exercise the String(err) branch.
|
||||
const literal: unknown = "explode";
|
||||
throw literal;
|
||||
},
|
||||
},
|
||||
} as unknown as Auth;
|
||||
const app = new Hono();
|
||||
app.use("*", sessionMiddleware({ auth }));
|
||||
app.get("/", (c) => c.json({ user: c.get("user") }));
|
||||
const res = await app.request("/");
|
||||
const body = (await res.json()) as { user: Principal | null };
|
||||
expect(body.user).toBeNull();
|
||||
});
|
||||
});
|
||||
105
packages/api-stack/api/api-plugin-auth/src/middleware/session.ts
Normal file
105
packages/api-stack/api/api-plugin-auth/src/middleware/session.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import type { Auth } from "better-auth";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import {
|
||||
type AuthInstruments,
|
||||
getAuthInstruments,
|
||||
} from "../otel/instruments.js";
|
||||
import {
|
||||
type BetterAuthSessionView,
|
||||
mapBetterAuthUserToPrincipal,
|
||||
type Principal,
|
||||
} from "../principal/principal.js";
|
||||
import type { BetterAuthSessionResult } from "../services/auth-service.js";
|
||||
import "./context-augmentation.js";
|
||||
|
||||
/**
|
||||
* Mapper signature accepted by `sessionMiddleware`. Receives the raw
|
||||
* Better-Auth `(user, session)` payload and returns the canonical
|
||||
* `Principal`. Defaults to `mapBetterAuthUserToPrincipal`.
|
||||
*/
|
||||
export type PrincipalMapping = (view: BetterAuthSessionView) => Principal;
|
||||
|
||||
/**
|
||||
* Construction options for `sessionMiddleware`. All fields except
|
||||
* `auth` are optional; the middleware falls back to the default
|
||||
* principal mapping when none is supplied.
|
||||
*/
|
||||
export interface SessionMiddlewareOptions {
|
||||
readonly auth: Auth;
|
||||
readonly principalMapping?: PrincipalMapping;
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
|
||||
const noop = () => undefined;
|
||||
const noopLogger: LoggerLike = {
|
||||
debug: noop,
|
||||
info: noop,
|
||||
warn: noop,
|
||||
error: noop,
|
||||
};
|
||||
|
||||
/**
|
||||
* Construct the global Hono session-resolver middleware. On every
|
||||
* request:
|
||||
*
|
||||
* 1. Calls `auth.api.getSession({ headers })`.
|
||||
* 2. If a session is returned, maps it via `principalMapping ??
|
||||
* mapBetterAuthUserToPrincipal` and sets both `c.set("user",
|
||||
* principal)` and `c.set("session", session)`.
|
||||
* 3. If no session is returned, sets both slots to `null`.
|
||||
* 4. If Better-Auth throws, sets both slots to `null`, increments the
|
||||
* `error` counter outcome, logs at `warn`, and proceeds — anonymous
|
||||
* routes keep working; protected routes raise
|
||||
* `AUTH_UNAUTHENTICATED` via `requireAuth()` downstream.
|
||||
*
|
||||
* Records `auth.sessions_resolved_total{outcome}` +
|
||||
* `auth.session_resolve_duration_ms{outcome}` on every call regardless
|
||||
* of outcome.
|
||||
*/
|
||||
export function sessionMiddleware(
|
||||
options: SessionMiddlewareOptions
|
||||
): MiddlewareHandler {
|
||||
const { auth } = options;
|
||||
const map: PrincipalMapping =
|
||||
options.principalMapping ?? mapBetterAuthUserToPrincipal;
|
||||
const logger = options.logger ?? noopLogger;
|
||||
return async function sessionHandler(c, next) {
|
||||
const instruments: AuthInstruments = getAuthInstruments();
|
||||
const started = performance.now();
|
||||
let outcome: "authenticated" | "anonymous" | "error" = "anonymous";
|
||||
try {
|
||||
const result = (await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
})) as BetterAuthSessionResult;
|
||||
if (result === null) {
|
||||
setAnonymous(c);
|
||||
} else {
|
||||
const principal = map(
|
||||
result as unknown as BetterAuthSessionView
|
||||
);
|
||||
c.set("user", principal);
|
||||
c.set("session", result);
|
||||
outcome = "authenticated";
|
||||
}
|
||||
} catch (err) {
|
||||
outcome = "error";
|
||||
setAnonymous(c);
|
||||
logger.warn("auth: session resolve failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
const duration = performance.now() - started;
|
||||
instruments.sessionResolveDurationMs.record(duration, {
|
||||
outcome,
|
||||
});
|
||||
instruments.sessionsResolvedTotal.add(1, { outcome });
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
function setAnonymous(c: Context): void {
|
||||
c.set("user", null);
|
||||
c.set("session", null);
|
||||
}
|
||||
5
packages/api-stack/api/api-plugin-auth/src/otel/index.ts
Normal file
5
packages/api-stack/api/api-plugin-auth/src/otel/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type AuthInstruments,
|
||||
getAuthInstruments,
|
||||
resetAuthInstruments,
|
||||
} from "./instruments.js";
|
||||
103
packages/api-stack/api/api-plugin-auth/src/otel/instruments.md
Normal file
103
packages/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.
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import { getAuthInstruments, resetAuthInstruments } from "./instruments.js";
|
||||
|
||||
describe("getAuthInstruments", () => {
|
||||
it("returns the same cached bundle across calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetAuthInstruments();
|
||||
const a = getAuthInstruments();
|
||||
const b = getAuthInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("emits counter + histogram observations", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
resetAuthInstruments();
|
||||
const ins = getAuthInstruments();
|
||||
ins.sessionsResolvedTotal.add(1, { outcome: "authenticated" });
|
||||
ins.sessionResolveDurationMs.record(12, { outcome: "authenticated" });
|
||||
ins.roleCheckTotal.add(1, { outcome: "allowed" });
|
||||
ins.signupsTotal.add(1);
|
||||
ins.signinsTotal.add(1, { outcome: "success" });
|
||||
const metrics = await flushTestMetrics();
|
||||
const names = metrics
|
||||
.flatMap((m) => m.scopeMetrics)
|
||||
.flatMap((s) => s.metrics)
|
||||
.map((m) => m.descriptor.name);
|
||||
expect(names).toContain("auth.sessions_resolved_total");
|
||||
expect(names).toContain("auth.session_resolve_duration_ms");
|
||||
expect(names).toContain("auth.role_check_total");
|
||||
expect(names).toContain("auth.signups_total");
|
||||
expect(names).toContain("auth.signins_total");
|
||||
});
|
||||
|
||||
it("counter + histogram emit per session resolve", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
resetAuthInstruments();
|
||||
const ins = getAuthInstruments();
|
||||
ins.sessionsResolvedTotal.add(1, { outcome: "authenticated" });
|
||||
ins.sessionResolveDurationMs.record(3.5, {
|
||||
outcome: "authenticated",
|
||||
});
|
||||
const collected = await flushTestMetrics();
|
||||
const counter = collected
|
||||
.flatMap((m) => m.scopeMetrics)
|
||||
.flatMap((s) => s.metrics)
|
||||
.find((m) => m.descriptor.name === "auth.sessions_resolved_total");
|
||||
expect(counter).toBeDefined();
|
||||
});
|
||||
|
||||
it("role-check counter emits on requireRole", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
resetAuthInstruments();
|
||||
const ins = getAuthInstruments();
|
||||
ins.roleCheckTotal.add(1, { outcome: "denied" });
|
||||
const collected = await flushTestMetrics();
|
||||
const counter = collected
|
||||
.flatMap((m) => m.scopeMetrics)
|
||||
.flatMap((s) => s.metrics)
|
||||
.find((m) => m.descriptor.name === "auth.role_check_total");
|
||||
expect(counter).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by the auth plugin.
|
||||
*
|
||||
* - `sessionsResolvedTotal{outcome}` — incremented on every call to the
|
||||
* session middleware. `outcome ∈ "authenticated" | "anonymous" |
|
||||
* "error"`.
|
||||
* - `sessionResolveDurationMs` — histogram of session-resolve duration
|
||||
* in ms.
|
||||
* - `roleCheckTotal{outcome}` — incremented on every `requireRole`
|
||||
* call. `outcome ∈ "allowed" | "denied"`.
|
||||
* - `signupsTotal` — incremented every time `auth.api.signUpEmail`
|
||||
* completes (success only).
|
||||
* - `signinsTotal{outcome}` — incremented every time `auth.api.signInEmail`
|
||||
* is exercised; `outcome ∈ "success" | "failure"`.
|
||||
*/
|
||||
export interface AuthInstruments {
|
||||
readonly sessionsResolvedTotal: Counter;
|
||||
readonly sessionResolveDurationMs: Histogram;
|
||||
readonly roleCheckTotal: Counter;
|
||||
readonly signupsTotal: Counter;
|
||||
readonly signinsTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: AuthInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the auth instruments bundle against the
|
||||
* current global `MeterProvider`. Call `resetAuthInstruments()` in test
|
||||
* setup to force a re-bind against a freshly-installed provider.
|
||||
*/
|
||||
export function getAuthInstruments(): AuthInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-plugin-auth");
|
||||
cached = {
|
||||
sessionsResolvedTotal: meter.createCounter(
|
||||
"auth.sessions_resolved_total",
|
||||
{
|
||||
description:
|
||||
"Total session-resolution attempts, partitioned by outcome label",
|
||||
}
|
||||
),
|
||||
sessionResolveDurationMs: meter.createHistogram(
|
||||
"auth.session_resolve_duration_ms",
|
||||
{
|
||||
description: "Session-resolution duration in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
roleCheckTotal: meter.createCounter("auth.role_check_total", {
|
||||
description:
|
||||
"Total role-check attempts via requireRole(), partitioned by outcome label",
|
||||
}),
|
||||
signupsTotal: meter.createCounter("auth.signups_total", {
|
||||
description: "Total successful email/password sign-ups",
|
||||
}),
|
||||
signinsTotal: meter.createCounter("auth.signins_total", {
|
||||
description: "Total sign-in attempts, partitioned by outcome label",
|
||||
}),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached instrument bundle so the next `getAuthInstruments()`
|
||||
* binds against the global `MeterProvider` that is in effect at that
|
||||
* point. Tests call this after installing a fresh `MeterProvider`.
|
||||
*/
|
||||
export function resetAuthInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
__setBetterAuthFactory,
|
||||
createAuthPlugin,
|
||||
} from "./plugin.js";
|
||||
@@ -0,0 +1,384 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Auth } from "better-auth";
|
||||
import type { AuthError } from "../error-codes/error-codes.js";
|
||||
import type { AuthPluginConfig } from "../types/types.js";
|
||||
import { __setBetterAuthFactory, createAuthPlugin } from "./plugin.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function makeAuthStub(): Auth {
|
||||
return {
|
||||
handler: async () => new Response("ok", { status: 200 }),
|
||||
api: { getSession: async () => null },
|
||||
} as unknown as Auth;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
__setBetterAuthFactory(undefined);
|
||||
});
|
||||
|
||||
function makeCtx(services: Map<string, unknown>, config: AuthPluginConfig) {
|
||||
return createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config,
|
||||
services,
|
||||
});
|
||||
}
|
||||
|
||||
describe("plugin.onInit lifecycle", () => {
|
||||
it("applies schema via migrationRunner when registered", {
|
||||
timeout: 15_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const plugin = createAuthPlugin({ betterAuth: {} });
|
||||
const runs: Array<{ name: string; content: string }> = [];
|
||||
const runner = {
|
||||
run: async (
|
||||
files: ReadonlyArray<{ name: string; content?: string }>
|
||||
) => {
|
||||
for (const f of files) {
|
||||
runs.push({ name: f.name, content: f.content ?? "" });
|
||||
}
|
||||
},
|
||||
};
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
services.set(
|
||||
"openAPIRegistry",
|
||||
createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
})
|
||||
);
|
||||
services.set("migrationRunner", runner);
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as AuthPluginConfig)
|
||||
);
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.name).toBe("0000-better-auth-schema");
|
||||
expect(runs[0]?.content?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("applies schema via db fallback when no runner", {
|
||||
timeout: 15_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const plugin = createAuthPlugin({ betterAuth: {} });
|
||||
const executions: string[] = [];
|
||||
const db = {
|
||||
isConnected: true,
|
||||
query: async () => [],
|
||||
execute: async (sql: string) => {
|
||||
executions.push(sql);
|
||||
return { rowsAffected: 0 };
|
||||
},
|
||||
};
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
services.set(
|
||||
"openAPIRegistry",
|
||||
createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
})
|
||||
);
|
||||
services.set("db", db);
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as AuthPluginConfig)
|
||||
);
|
||||
expect(executions.length).toBe(1);
|
||||
expect(executions[0]?.includes("DEFINE TABLE")).toBe(true);
|
||||
});
|
||||
|
||||
it("applySchemaOnInit: false skips schema apply", {
|
||||
timeout: 15_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {},
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as AuthPluginConfig)
|
||||
);
|
||||
expect(services.get("authService")).toBeDefined();
|
||||
});
|
||||
|
||||
it("missing services raise AUTH_SERVICE_NOT_AVAILABLE", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const plugin = createAuthPlugin({ betterAuth: {} });
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
let caught: AuthError | undefined;
|
||||
try {
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as AuthPluginConfig)
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err as AuthError;
|
||||
}
|
||||
expect(caught?.code).toBe("AUTH.SERVICE_NOT_AVAILABLE");
|
||||
expect(caught?.context?.["missing"]).toEqual(["migrationRunner", "db"]);
|
||||
});
|
||||
|
||||
it("schema-apply failure raises AUTH_SCHEMA_APPLY_FAILED", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const plugin = createAuthPlugin({ betterAuth: {} });
|
||||
const runner = {
|
||||
run: async () => {
|
||||
throw new Error("migration broke");
|
||||
},
|
||||
};
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
services.set("migrationRunner", runner);
|
||||
let caught: AuthError | undefined;
|
||||
try {
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as AuthPluginConfig)
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err as AuthError;
|
||||
}
|
||||
expect(caught?.code).toBe("AUTH.SCHEMA_APPLY_FAILED");
|
||||
});
|
||||
|
||||
it("schema-apply failure on db fallback raises AUTH_SCHEMA_APPLY_FAILED", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const plugin = createAuthPlugin({ betterAuth: {} });
|
||||
const db = {
|
||||
isConnected: true,
|
||||
query: async () => [],
|
||||
execute: async () => {
|
||||
throw new Error("surreal broke");
|
||||
},
|
||||
};
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
services.set("db", db);
|
||||
let caught: AuthError | undefined;
|
||||
try {
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as AuthPluginConfig)
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err as AuthError;
|
||||
}
|
||||
expect(caught?.code).toBe("AUTH.SCHEMA_APPLY_FAILED");
|
||||
});
|
||||
|
||||
it("routes registered in both Hono and OpenAPIRegistry", {
|
||||
timeout: 15_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {},
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as AuthPluginConfig)
|
||||
);
|
||||
const routes = registry.getRoutesByPlugin("auth");
|
||||
expect(routes).toHaveLength(9);
|
||||
for (const r of routes) {
|
||||
expect(r.path.startsWith("/auth")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("catch-all routes paths the OpenAPI doc does not advertise", {
|
||||
timeout: 15_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {},
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as AuthPluginConfig)
|
||||
);
|
||||
// /auth/get-session is NOT a registered OpenAPI route — the
|
||||
// catch-all delegates the path to Better-Auth's handler.
|
||||
const res = await app.request("/auth/get-session");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("no-registry path mounts a catch-all under routePrefix", {
|
||||
timeout: 15_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {},
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
const app = new OpenAPIHono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as AuthPluginConfig)
|
||||
);
|
||||
const res = await app.request("/auth/get-session");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("routePrefix collision raises AUTH_ROUTE_PREFIX_COLLISION", {
|
||||
timeout: 15_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {},
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
registry.registerRoute({
|
||||
method: "get",
|
||||
path: "/auth/sneaky",
|
||||
tags: ["other"],
|
||||
pluginName: "other-plugin",
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
let caught: AuthError | undefined;
|
||||
try {
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as AuthPluginConfig)
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err as AuthError;
|
||||
}
|
||||
expect(caught?.code).toBe("AUTH.ROUTE_PREFIX_COLLISION");
|
||||
expect(caught?.context?.["prefix"]).toBe("/auth");
|
||||
});
|
||||
|
||||
it("registry path mounts at root '/' prefix", {
|
||||
timeout: 15_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {},
|
||||
routePrefix: "/",
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as AuthPluginConfig)
|
||||
);
|
||||
// With root prefix, registered route paths are the bare
|
||||
// "/sign-up" etc.
|
||||
const routes = registry.getRoutesByPlugin("auth");
|
||||
expect(routes.some((r) => r.path === "/sign-up")).toBe(true);
|
||||
});
|
||||
|
||||
it("hono-only path mounts at root '/' prefix", {
|
||||
timeout: 15_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {},
|
||||
routePrefix: "/",
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
const app = new OpenAPIHono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as AuthPluginConfig)
|
||||
);
|
||||
const res = await app.request("/session");
|
||||
expect([200, 404]).toContain(res.status);
|
||||
});
|
||||
|
||||
it("applies custom principalMapping via plugin options", {
|
||||
timeout: 15_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {},
|
||||
applySchemaOnInit: false,
|
||||
principalMapping: () => ({
|
||||
id: "OVERRIDE",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
}),
|
||||
});
|
||||
const app = new OpenAPIHono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as AuthPluginConfig)
|
||||
);
|
||||
// The configured mapping should be wired into session middleware.
|
||||
expect(services.get("authService")).toBeDefined();
|
||||
});
|
||||
|
||||
it("mounts routes via Hono when openAPIRegistry is absent", {
|
||||
timeout: 15_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const warns: string[] = [];
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {},
|
||||
applySchemaOnInit: false,
|
||||
logger: {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: (msg: string) => {
|
||||
warns.push(msg);
|
||||
},
|
||||
error: () => undefined,
|
||||
},
|
||||
});
|
||||
const app = new OpenAPIHono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
await plugin.onInit?.(
|
||||
makeCtx(services, plugin.defaults as AuthPluginConfig)
|
||||
);
|
||||
expect(
|
||||
warns.some((m) => m.includes("openAPIRegistry not registered"))
|
||||
).toBe(true);
|
||||
// Exercise the mounted sub-route handler to cover its body.
|
||||
const res = await app.request("/auth/session");
|
||||
expect([200, 404]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
121
packages/api-stack/api/api-plugin-auth/src/plugin/plugin.md
Normal file
121
packages/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`.
|
||||
201
packages/api-stack/api/api-plugin-auth/src/plugin/plugin.test.ts
Normal file
201
packages/api-stack/api/api-plugin-auth/src/plugin/plugin.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Auth } from "better-auth";
|
||||
import { AuthError } from "../error-codes/error-codes.js";
|
||||
import type { AuthPluginConfig } from "../types/types.js";
|
||||
import { __setBetterAuthFactory, createAuthPlugin } from "./plugin.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function makeAuthStub(): Auth {
|
||||
return {
|
||||
handler: async () => new Response("ok", { status: 200 }),
|
||||
api: { getSession: async () => null },
|
||||
} as unknown as Auth;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
__setBetterAuthFactory(undefined);
|
||||
});
|
||||
|
||||
describe("createAuthPlugin", () => {
|
||||
it("createAuthPlugin returns valid PluginDescriptor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createAuthPlugin({ betterAuth: {} });
|
||||
expect(plugin.name).toBe("auth");
|
||||
expect(plugin.version).toBe("0.1.0");
|
||||
expect(typeof plugin.onInit).toBe("function");
|
||||
expect(typeof plugin.onClose).toBe("function");
|
||||
});
|
||||
|
||||
it("invalid options raise AUTH_INVALID_INPUT", { timeout: 5000 }, () => {
|
||||
let caught: AuthError | undefined;
|
||||
try {
|
||||
createAuthPlugin({} as never);
|
||||
} catch (err) {
|
||||
caught = err as AuthError;
|
||||
}
|
||||
expect(caught?.code).toBe("AUTH.INVALID_INPUT");
|
||||
expect(caught?.cause).toBeDefined();
|
||||
});
|
||||
|
||||
it("descriptor exposes resolved defaults", { timeout: 5000 }, () => {
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {},
|
||||
routePrefix: "/v2/auth",
|
||||
});
|
||||
expect((plugin.defaults as AuthPluginConfig).routePrefix).toBe(
|
||||
"/v2/auth"
|
||||
);
|
||||
expect((plugin.defaults as AuthPluginConfig).applySchemaOnInit).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("onClose clears internal state without error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createAuthPlugin({ betterAuth: {} });
|
||||
const services = new Map<string, unknown>();
|
||||
const ctrl = new AbortController();
|
||||
const ctx = createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: ctrl.signal,
|
||||
config: plugin.defaults as AuthPluginConfig,
|
||||
services,
|
||||
});
|
||||
await plugin.onClose?.(ctx);
|
||||
});
|
||||
|
||||
it("onInit raises AUTH_SERVICE_NOT_AVAILABLE when app missing", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {},
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
const ctrl = new AbortController();
|
||||
const ctx = createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: ctrl.signal,
|
||||
config: plugin.defaults as AuthPluginConfig,
|
||||
services,
|
||||
});
|
||||
let caught: AuthError | undefined;
|
||||
try {
|
||||
await plugin.onInit?.(ctx);
|
||||
} catch (err) {
|
||||
caught = err as AuthError;
|
||||
}
|
||||
expect(caught?.code).toBe("AUTH.SERVICE_NOT_AVAILABLE");
|
||||
});
|
||||
|
||||
it("onInit completes with applySchemaOnInit: false + stubbed auth", {
|
||||
timeout: 15_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => makeAuthStub());
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {},
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1.0" },
|
||||
});
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
services.set("logger", noopLogger);
|
||||
services.set("eventBus", new EventBus());
|
||||
services.set("clock", () => Date.now());
|
||||
const ctrl = new AbortController();
|
||||
const ctx = createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: ctrl.signal,
|
||||
config: plugin.defaults as AuthPluginConfig,
|
||||
services,
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
expect(services.get("authService")).toBeDefined();
|
||||
expect(registry.getRoutesByPlugin("auth")).toHaveLength(9);
|
||||
});
|
||||
|
||||
it("default factory delegates to real betterAuth", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
// Restore the default factory and exercise the real `betterAuth`
|
||||
// import — invoked with deliberately bad input so the test
|
||||
// surfaces the BETTER_AUTH_INIT_FAILED translation rather than
|
||||
// standing up a real Better-Auth instance.
|
||||
__setBetterAuthFactory(undefined);
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: { __invalid__: Symbol("nope") as unknown as string },
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
const ctx = createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as AuthPluginConfig,
|
||||
services,
|
||||
});
|
||||
let caught: AuthError | undefined;
|
||||
try {
|
||||
await plugin.onInit?.(ctx);
|
||||
} catch (err) {
|
||||
caught = err as AuthError;
|
||||
}
|
||||
// Real betterAuth may succeed with default in-memory config
|
||||
// depending on version; both outcomes count as covering the
|
||||
// default factory path. We only assert NO exception other than
|
||||
// an AuthError reaches here.
|
||||
if (caught !== undefined) {
|
||||
expect(caught).toBeInstanceOf(AuthError);
|
||||
}
|
||||
});
|
||||
|
||||
it("onInit raises AUTH_BETTER_AUTH_INIT_FAILED when betterAuth throws", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
__setBetterAuthFactory(() => {
|
||||
throw new Error("better-auth: bad config");
|
||||
});
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {},
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
const ctrl = new AbortController();
|
||||
const ctx = createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: ctrl.signal,
|
||||
config: plugin.defaults as AuthPluginConfig,
|
||||
services,
|
||||
});
|
||||
let caught: AuthError | undefined;
|
||||
try {
|
||||
await plugin.onInit?.(ctx);
|
||||
} catch (err) {
|
||||
caught = err as AuthError;
|
||||
}
|
||||
expect(caught?.code).toBe("AUTH.BETTER_AUTH_INIT_FAILED");
|
||||
});
|
||||
});
|
||||
290
packages/api-stack/api/api-plugin-auth/src/plugin/plugin.ts
Normal file
290
packages/api-stack/api/api-plugin-auth/src/plugin/plugin.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import type { MigrationRunner } from "@suaveplan/api-migrations";
|
||||
import type { OpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { definePluginRoutes } from "@suaveplan/api-openapi";
|
||||
import {
|
||||
definePlugin,
|
||||
type PluginContext,
|
||||
type PluginDescriptor,
|
||||
} from "@suaveplan/api-plugin";
|
||||
import {
|
||||
applyBetterAuthSchema,
|
||||
BETTER_AUTH_SURREAL_SCHEMA,
|
||||
} from "@suaveplan/better-auth-surrealdb";
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import type { Auth } from "better-auth";
|
||||
import { betterAuth as betterAuthFactory } from "better-auth";
|
||||
import { Hono } from "hono";
|
||||
import { AUTH_CODES, AuthError } from "../error-codes/error-codes.js";
|
||||
import { sessionMiddleware } from "../middleware/session.js";
|
||||
import { buildAuthRoutes } from "../routes/build-routes.js";
|
||||
import { AuthPluginOptionsSchema } from "../schemas/options.js";
|
||||
import { createAuthService } from "../services/auth-service.js";
|
||||
import "../services/registry-augmentation.js";
|
||||
import type { AuthPluginConfig, AuthPluginOptions } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Default Better-Auth construction factory. Wraps the live
|
||||
* `betterAuth` import so the indirection is named.
|
||||
*/
|
||||
const DEFAULT_BETTER_AUTH_FACTORY = (opts: Record<string, unknown>): Auth =>
|
||||
betterAuthFactory(opts);
|
||||
|
||||
/**
|
||||
* Better-Auth construction factory. Module-level mutable binding so
|
||||
* unit tests can substitute a stub via {@link __setBetterAuthFactory}
|
||||
* without resorting to module-mocking. Production callers do not need
|
||||
* to touch this — the default value is the live `betterAuth` import.
|
||||
*/
|
||||
let resolveBetterAuth: (opts: Record<string, unknown>) => Auth =
|
||||
DEFAULT_BETTER_AUTH_FACTORY;
|
||||
|
||||
/**
|
||||
* Test-only — swap the `betterAuth(...)` factory used by `onInit` so
|
||||
* unit tests can exercise success / failure paths without standing up
|
||||
* a real Better-Auth instance (which requires a live database + valid
|
||||
* secret). Pass `undefined` to restore the default. Not exported from
|
||||
* the package barrel.
|
||||
*/
|
||||
export function __setBetterAuthFactory(
|
||||
factory: ((opts: Record<string, unknown>) => Auth) | undefined
|
||||
): void {
|
||||
resolveBetterAuth = factory ?? DEFAULT_BETTER_AUTH_FACTORY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state owned by the plugin instance across the 4-phase
|
||||
* lifecycle. Captured as a closure variable rather than on
|
||||
* `PluginContext` because the registry's per-hook context is rebuilt
|
||||
* each call.
|
||||
*/
|
||||
interface AuthPluginState {
|
||||
auth: Auth | null;
|
||||
routePrefix: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory — produces a typed `PluginDescriptor` for the auth plugin.
|
||||
*
|
||||
* Zod-validates the supplied options at the boundary so invalid input
|
||||
* fails fast with `AuthError(AUTH_INVALID_INPUT)` carrying the
|
||||
* `ZodError` as `cause`. The returned descriptor's `onInit` executes
|
||||
* the canonical 6-step lifecycle:
|
||||
*
|
||||
* 1. Resolve `app` + `openAPIRegistry` + (optionally) `migrationRunner`
|
||||
* / `db` from the host `ServiceRegistry`.
|
||||
* 2. Apply the bundled SurrealQL schema via the resolved adapter (or
|
||||
* skip when `applySchemaOnInit: false`).
|
||||
* 3. Construct the Better-Auth instance via `betterAuth(opts)`.
|
||||
* 4. Register `authService` in the `ServiceRegistry`.
|
||||
* 5. Register the 9 OpenAPI routes via `definePluginRoutes` (mounted at
|
||||
* `routePrefix`).
|
||||
* 6. Install the global session-resolver middleware on `ctx.app`.
|
||||
*/
|
||||
export function createAuthPlugin(
|
||||
options: AuthPluginOptions
|
||||
): PluginDescriptor<AuthPluginConfig> {
|
||||
const parsed = AuthPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new AuthError(
|
||||
AUTH_CODES.INVALID_INPUT,
|
||||
"createAuthPlugin received invalid options",
|
||||
{ cause: parsed.error, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const resolved = parsed.data as AuthPluginConfig;
|
||||
const state: AuthPluginState = {
|
||||
auth: null,
|
||||
routePrefix: resolved.routePrefix,
|
||||
};
|
||||
|
||||
return definePlugin<AuthPluginConfig>({
|
||||
name: "auth",
|
||||
version: "0.1.0",
|
||||
description:
|
||||
"Better-Auth-first authentication plugin for @suaveplan/api. Mounts 9 OpenAPI routes, registers authService, installs session-resolver middleware.",
|
||||
defaults: resolved,
|
||||
tags: ["auth", "security"],
|
||||
onInit: async (ctx) => runOnInit(ctx, resolved, state),
|
||||
onClose: async () => {
|
||||
state.auth = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runOnInit(
|
||||
ctx: PluginContext<AuthPluginConfig>,
|
||||
config: AuthPluginConfig,
|
||||
state: AuthPluginState
|
||||
): Promise<void> {
|
||||
// (1) Resolve required services from the host ServiceRegistry.
|
||||
const app = ctx.getService("app");
|
||||
if (app === undefined) {
|
||||
throw new AuthError(
|
||||
AUTH_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"auth plugin requires the `app` service in ServiceRegistry",
|
||||
{ context: { missing: ["app"], statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const openAPIRegistry = ctx.getService("openAPIRegistry");
|
||||
const migrationRunner = ctx.getService("migrationRunner");
|
||||
const db = ctx.getService("db");
|
||||
const logger = config.logger ?? ctx.logger;
|
||||
|
||||
// (2) Apply the bundled SurrealQL schema (when enabled).
|
||||
if (config.applySchemaOnInit) {
|
||||
await applySchema({ migrationRunner, db });
|
||||
}
|
||||
|
||||
// (3) Construct the Better-Auth instance.
|
||||
let auth: Auth;
|
||||
try {
|
||||
auth = resolveBetterAuth(config.betterAuth);
|
||||
} catch (err) {
|
||||
throw new AuthError(
|
||||
AUTH_CODES.BETTER_AUTH_INIT_FAILED,
|
||||
"betterAuth(options) failed",
|
||||
{ cause: err, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
state.auth = auth;
|
||||
|
||||
// (4) Register the authService slot.
|
||||
ctx.registerService("authService", createAuthService({ auth }));
|
||||
|
||||
// (5) Register the 9 OpenAPI routes mounted under routePrefix.
|
||||
registerRoutes({
|
||||
app,
|
||||
openAPIRegistry,
|
||||
auth,
|
||||
routePrefix: config.routePrefix,
|
||||
logger,
|
||||
});
|
||||
|
||||
// (6) Install the global session middleware.
|
||||
const middleware = sessionMiddleware({
|
||||
auth,
|
||||
...(config.principalMapping !== undefined
|
||||
? { principalMapping: config.principalMapping }
|
||||
: {}),
|
||||
logger,
|
||||
});
|
||||
app.use("*", middleware);
|
||||
}
|
||||
|
||||
async function applySchema(args: {
|
||||
migrationRunner: MigrationRunner | undefined;
|
||||
db: SurrealAdapter | undefined;
|
||||
}): Promise<void> {
|
||||
const { migrationRunner, db } = args;
|
||||
if (migrationRunner !== undefined) {
|
||||
try {
|
||||
await migrationRunner.run([
|
||||
{
|
||||
name: "0000-better-auth-schema",
|
||||
path: "<inline>",
|
||||
order: 0,
|
||||
content: BETTER_AUTH_SURREAL_SCHEMA,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
} catch (err) {
|
||||
throw new AuthError(
|
||||
AUTH_CODES.SCHEMA_APPLY_FAILED,
|
||||
"migrationRunner.run(BETTER_AUTH_SURREAL_SCHEMA) failed",
|
||||
{ cause: err, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
if (db !== undefined) {
|
||||
try {
|
||||
await applyBetterAuthSchema(db);
|
||||
return;
|
||||
} catch (err) {
|
||||
throw new AuthError(
|
||||
AUTH_CODES.SCHEMA_APPLY_FAILED,
|
||||
"applyBetterAuthSchema(db) failed",
|
||||
{ cause: err, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new AuthError(
|
||||
AUTH_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"applySchemaOnInit requires either a migrationRunner or db service",
|
||||
{
|
||||
context: {
|
||||
missing: ["migrationRunner", "db"],
|
||||
statusCode: 500,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function registerRoutes(args: {
|
||||
app: OpenAPIHono;
|
||||
openAPIRegistry: OpenAPIRegistry | undefined;
|
||||
auth: Auth;
|
||||
routePrefix: string;
|
||||
logger: {
|
||||
warn: (msg: string, ctx?: Readonly<Record<string, unknown>>) => void;
|
||||
};
|
||||
}): void {
|
||||
const { app, openAPIRegistry, auth, routePrefix, logger } = args;
|
||||
const sub = new Hono() as OpenAPIHono;
|
||||
const entries = buildAuthRoutes(auth);
|
||||
const catchAllMount = routePrefix === "/" ? "/*" : `${routePrefix}/*`;
|
||||
if (openAPIRegistry !== undefined) {
|
||||
const existing = openAPIRegistry
|
||||
.getRoutes()
|
||||
.filter((r) => r.path.startsWith(routePrefix));
|
||||
if (existing.length > 0) {
|
||||
throw new AuthError(
|
||||
AUTH_CODES.ROUTE_PREFIX_COLLISION,
|
||||
`Route prefix '${routePrefix}' collides with previously registered routes`,
|
||||
{
|
||||
context: {
|
||||
prefix: routePrefix,
|
||||
existing: existing.map((r) => r.path),
|
||||
statusCode: 500,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const prefixedEntries = entries.map((entry) => ({
|
||||
...entry,
|
||||
route: {
|
||||
...entry.route,
|
||||
path: `${routePrefix === "/" ? "" : routePrefix}${entry.route.path}`,
|
||||
},
|
||||
}));
|
||||
definePluginRoutes(prefixedEntries, {
|
||||
app,
|
||||
registry: openAPIRegistry,
|
||||
pluginName: "auth",
|
||||
});
|
||||
// Mount Better-Auth's catch-all AFTER the OpenAPI routes so
|
||||
// specific paths win; the catch-all covers paths the OpenAPI
|
||||
// doc does not advertise (e.g. `/sign-up/email`).
|
||||
app.all(catchAllMount, async (c) => auth.handler(c.req.raw));
|
||||
return;
|
||||
}
|
||||
logger.warn(
|
||||
"auth plugin: openAPIRegistry not registered — routes mount on Hono only"
|
||||
);
|
||||
for (const entry of entries) {
|
||||
const method = entry.route.method as
|
||||
| "get"
|
||||
| "post"
|
||||
| "put"
|
||||
| "patch"
|
||||
| "delete";
|
||||
sub[method](
|
||||
entry.route.path,
|
||||
async (c) => (await entry.handler(c as never)) as Response
|
||||
);
|
||||
}
|
||||
app.route(routePrefix === "/" ? "/" : routePrefix, sub);
|
||||
// Mount Better-Auth's catch-all AFTER the sub-router so specific
|
||||
// mounted routes win; the catch-all covers everything else.
|
||||
app.all(catchAllMount, async (c) => auth.handler(c.req.raw));
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
BetterAuthSessionRecord,
|
||||
BetterAuthSessionView,
|
||||
BetterAuthUserView,
|
||||
Principal,
|
||||
} from "./principal.js";
|
||||
export { mapBetterAuthUserToPrincipal } from "./principal.js";
|
||||
@@ -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,83 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AuthError } from "../error-codes/error-codes.js";
|
||||
import { mapBetterAuthUserToPrincipal } from "./principal.js";
|
||||
|
||||
describe("mapBetterAuthUserToPrincipal", () => {
|
||||
it("maps a complete user/session pair", { timeout: 5000 }, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: {
|
||||
id: "user:1",
|
||||
email: "a@b.com",
|
||||
emailVerified: true,
|
||||
name: "Alice",
|
||||
image: "img://a",
|
||||
roles: ["admin", "owner"],
|
||||
},
|
||||
session: { id: "s1", userId: "user:1", activeOrganizationId: "t1" },
|
||||
});
|
||||
expect(p).toEqual({
|
||||
id: "user:1",
|
||||
email: "a@b.com",
|
||||
emailVerified: true,
|
||||
roles: ["admin", "owner"],
|
||||
tenantId: "t1",
|
||||
metadata: { name: "Alice", image: "img://a" },
|
||||
});
|
||||
});
|
||||
|
||||
it("omits optional fields when null/undefined", { timeout: 5000 }, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u2", email: null, emailVerified: null },
|
||||
session: { activeOrganizationId: null },
|
||||
});
|
||||
expect(p).toEqual({ id: "u2", emailVerified: false, roles: [] });
|
||||
expect(p.email).toBeUndefined();
|
||||
expect(p.tenantId).toBeUndefined();
|
||||
expect(p.metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats emailVerified === false correctly", { timeout: 5000 }, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u3", emailVerified: false },
|
||||
session: {},
|
||||
});
|
||||
expect(p.emailVerified).toBe(false);
|
||||
});
|
||||
|
||||
it("throws AUTH_PRINCIPAL_MAPPING_FAILED on missing id", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
mapBetterAuthUserToPrincipal({
|
||||
user: { id: "" },
|
||||
session: {},
|
||||
})
|
||||
).toThrow(AuthError);
|
||||
});
|
||||
|
||||
it("throws when id is wrong type", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
mapBetterAuthUserToPrincipal({
|
||||
user: { id: 42 as unknown as string },
|
||||
session: {},
|
||||
})
|
||||
).toThrow(AuthError);
|
||||
});
|
||||
|
||||
it("emits only present metadata fields", { timeout: 5000 }, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u4", name: "X" },
|
||||
session: {},
|
||||
});
|
||||
expect(p.metadata).toEqual({ name: "X" });
|
||||
});
|
||||
|
||||
it("ignores empty-string tenantId / email", { timeout: 5000 }, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u5", email: "" },
|
||||
session: { activeOrganizationId: "" },
|
||||
});
|
||||
expect(p.email).toBeUndefined();
|
||||
expect(p.tenantId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { AUTH_CODES, AuthError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Canonical authenticated-user shape across every `@suaveplan/api-plugin-*`
|
||||
* package. Sibling plugins (user, vault, organisations) read
|
||||
* `c.get("user")` typed as `Principal | null` via Hono's
|
||||
* `ContextVariableMap` augmentation in `../middleware/session.ts`.
|
||||
*
|
||||
* Mapped from the Better-Auth user + session pair by
|
||||
* {@link mapBetterAuthUserToPrincipal} — replaceable per app via
|
||||
* `AuthPluginOptions.principalMapping`.
|
||||
*/
|
||||
export interface Principal {
|
||||
/** Stable record-id from Better-Auth's `user` table. */
|
||||
readonly id: string;
|
||||
/** Primary email when known. Better-Auth allows missing emails for
|
||||
* passwordless / OAuth-only accounts. */
|
||||
readonly email?: string;
|
||||
/** Whether Better-Auth has confirmed the primary email. */
|
||||
readonly emailVerified: boolean;
|
||||
/** Role strings — populated by the host (org-membership middleware,
|
||||
* etc.). Default mapping returns the empty array. */
|
||||
readonly roles: readonly string[];
|
||||
/** Tenant the principal is currently scoped to. Filled by
|
||||
* `@suaveplan/api-tenant` when wired upstream of this plugin. */
|
||||
readonly tenantId?: string;
|
||||
/** Free-form metadata extracted from Better-Auth's user row. */
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Better-Auth session view consumed by the default mapper. Loose
|
||||
* interface so the plugin works with any Better-Auth plugin set
|
||||
* (organisation, passkeys, twoFactor, …) that extends the session row.
|
||||
*/
|
||||
export interface BetterAuthSessionView {
|
||||
readonly user: BetterAuthUserView;
|
||||
readonly session: BetterAuthSessionRecord;
|
||||
}
|
||||
|
||||
export interface BetterAuthUserView {
|
||||
readonly id: string;
|
||||
readonly email?: string | null;
|
||||
readonly emailVerified?: boolean | null;
|
||||
readonly name?: string | null;
|
||||
readonly image?: string | null;
|
||||
readonly roles?: readonly string[] | null;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface BetterAuthSessionRecord {
|
||||
readonly id?: string;
|
||||
readonly userId?: string;
|
||||
readonly activeOrganizationId?: string | null;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default Better-Auth user/session → `Principal` mapping.
|
||||
*
|
||||
* - `id` ← `user.id`
|
||||
* - `email` ← `user.email` (omitted when null/undefined)
|
||||
* - `emailVerified` ← `user.emailVerified` (false when null/undefined)
|
||||
* - `roles` ← `user.roles` (empty array when null/undefined)
|
||||
* - `tenantId` ← `session.activeOrganizationId` (omitted when null/undefined)
|
||||
* - `metadata` ← `{ name, image }` when present
|
||||
*
|
||||
* Throws {@link AuthError} with code `AUTH_PRINCIPAL_MAPPING_FAILED`
|
||||
* when `view.user.id` is missing.
|
||||
*/
|
||||
export function mapBetterAuthUserToPrincipal(
|
||||
view: BetterAuthSessionView
|
||||
): Principal {
|
||||
const { user, session } = view;
|
||||
if (typeof user.id !== "string" || user.id.length === 0) {
|
||||
throw new AuthError(
|
||||
AUTH_CODES.PRINCIPAL_MAPPING_FAILED,
|
||||
"Better-Auth user is missing `id`",
|
||||
{ context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const principal: Principal & {
|
||||
email?: string;
|
||||
tenantId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
} = {
|
||||
id: user.id,
|
||||
emailVerified: user.emailVerified === true,
|
||||
roles: user.roles ?? [],
|
||||
};
|
||||
if (typeof user.email === "string" && user.email.length > 0) {
|
||||
principal.email = user.email;
|
||||
}
|
||||
const tenant = session?.activeOrganizationId;
|
||||
if (typeof tenant === "string" && tenant.length > 0) {
|
||||
principal.tenantId = tenant;
|
||||
}
|
||||
const metadata: Record<string, unknown> = {};
|
||||
if (typeof user.name === "string") metadata["name"] = user.name;
|
||||
if (typeof user.image === "string") metadata["image"] = user.image;
|
||||
if (Object.keys(metadata).length > 0) principal.metadata = metadata;
|
||||
return principal;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PluginRouteEntry } from "@suaveplan/api-openapi";
|
||||
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import type { Auth } from "better-auth";
|
||||
import { buildAuthRoutes } from "./build-routes.js";
|
||||
|
||||
describe("buildAuthRoutes type conformance", () => {
|
||||
it("expectTypeOf RouteBuilder preserves Zod inference end-to-end", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf(buildAuthRoutes).parameter(0).toEqualTypeOf<Auth>();
|
||||
expectTypeOf(buildAuthRoutes).returns.toEqualTypeOf<
|
||||
PluginRouteEntry[]
|
||||
>();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Auth } from "better-auth";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import { resetAuthInstruments } from "../otel/instruments.js";
|
||||
import { buildAuthRoutes } from "./build-routes.js";
|
||||
|
||||
function makeAuthStub(status = 200): Auth {
|
||||
return {
|
||||
handler: async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
} as unknown as Auth;
|
||||
}
|
||||
|
||||
describe("buildAuthRoutes", () => {
|
||||
it("returns 9 route entries", { timeout: 5000 }, () => {
|
||||
const entries = buildAuthRoutes(makeAuthStub());
|
||||
expect(entries).toHaveLength(9);
|
||||
});
|
||||
|
||||
it("every route has typed body/query/response", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const entries = buildAuthRoutes(makeAuthStub());
|
||||
for (const { route } of entries) {
|
||||
expect(typeof route.method).toBe("string");
|
||||
expect(typeof route.path).toBe("string");
|
||||
// responses[200] schema exists
|
||||
const ok = (
|
||||
route.responses as Record<
|
||||
string | number,
|
||||
{
|
||||
content?: { "application/json"?: { schema: unknown } };
|
||||
}
|
||||
>
|
||||
)["200"];
|
||||
expect(ok?.content?.["application/json"]?.schema).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST routes carry a body schema", { timeout: 5000 }, () => {
|
||||
const entries = buildAuthRoutes(makeAuthStub());
|
||||
const bodyRoutes = [
|
||||
"/sign-up",
|
||||
"/sign-in",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/verify-email",
|
||||
];
|
||||
for (const path of bodyRoutes) {
|
||||
const entry = entries.find((e) => e.route.path === path);
|
||||
expect(entry).toBeDefined();
|
||||
const req = entry?.route.request as
|
||||
| { body?: { content?: { "application/json"?: unknown } } }
|
||||
| undefined;
|
||||
expect(req?.body?.content?.["application/json"]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("OAuth routes carry params + query schemas", { timeout: 5000 }, () => {
|
||||
const entries = buildAuthRoutes(makeAuthStub());
|
||||
const callback = entries.find(
|
||||
(e) => e.route.path === "/oauth/{provider}/callback"
|
||||
);
|
||||
const req = callback?.route.request as
|
||||
| { params?: unknown; query?: unknown }
|
||||
| undefined;
|
||||
expect(req?.params).toBeDefined();
|
||||
expect(req?.query).toBeDefined();
|
||||
});
|
||||
|
||||
it("sign-up handler emits signups_total counter on success", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
resetAuthInstruments();
|
||||
const entries = buildAuthRoutes(makeAuthStub(200));
|
||||
const signUp = entries.find((e) => e.route.path === "/sign-up");
|
||||
const res = await signUp!.handler({
|
||||
req: { raw: new Request("http://x.test/sign-up") },
|
||||
} as never);
|
||||
expect(res).toBeInstanceOf(Response);
|
||||
const metrics = await flushTestMetrics();
|
||||
const names = metrics
|
||||
.flatMap((m) => m.scopeMetrics)
|
||||
.flatMap((s) => s.metrics)
|
||||
.map((m) => m.descriptor.name);
|
||||
expect(names).toContain("auth.signups_total");
|
||||
});
|
||||
|
||||
it("sign-up handler skips signups counter on non-2xx", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
resetAuthInstruments();
|
||||
const entries = buildAuthRoutes(makeAuthStub(400));
|
||||
const signUp = entries.find((e) => e.route.path === "/sign-up");
|
||||
const res = await signUp!.handler({
|
||||
req: { raw: new Request("http://x.test/sign-up") },
|
||||
} as never);
|
||||
expect(res).toBeInstanceOf(Response);
|
||||
});
|
||||
|
||||
it("sign-in handler emits failure outcome on non-2xx", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
resetAuthInstruments();
|
||||
const entries = buildAuthRoutes(makeAuthStub(401));
|
||||
const signIn = entries.find((e) => e.route.path === "/sign-in");
|
||||
const res = await signIn!.handler({
|
||||
req: { raw: new Request("http://x.test/sign-in") },
|
||||
} as never);
|
||||
expect(res.status).toBe(401);
|
||||
const metrics = await flushTestMetrics();
|
||||
const names = metrics
|
||||
.flatMap((m) => m.scopeMetrics)
|
||||
.flatMap((s) => s.metrics)
|
||||
.map((m) => m.descriptor.name);
|
||||
expect(names).toContain("auth.signins_total");
|
||||
});
|
||||
|
||||
it("session handler delegates to auth.handler", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const entries = buildAuthRoutes(makeAuthStub(200));
|
||||
const session = entries.find((e) => e.route.path === "/session");
|
||||
const res = await session!.handler({
|
||||
req: { raw: new Request("http://x.test/session") },
|
||||
} as never);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("sign-in handler emits success outcome on 2xx", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
resetAuthInstruments();
|
||||
const entries = buildAuthRoutes(makeAuthStub(200));
|
||||
const signIn = entries.find((e) => e.route.path === "/sign-in");
|
||||
const res = await signIn!.handler({
|
||||
req: { raw: new Request("http://x.test/sign-in") },
|
||||
} as never);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
GET,
|
||||
type PluginRouteEntry,
|
||||
POST,
|
||||
successResponse,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import type { Auth } from "better-auth";
|
||||
import { getAuthInstruments } from "../otel/instruments.js";
|
||||
import {
|
||||
ForgotPasswordBodySchema,
|
||||
ForgotPasswordResponseSchema,
|
||||
GetSessionResponseSchema,
|
||||
OAuthCallbackParamsSchema,
|
||||
OAuthCallbackQuerySchema,
|
||||
OAuthCallbackResponseSchema,
|
||||
OAuthStartParamsSchema,
|
||||
OAuthStartQuerySchema,
|
||||
OAuthStartResponseSchema,
|
||||
ResetPasswordBodySchema,
|
||||
ResetPasswordResponseSchema,
|
||||
SignInBodySchema,
|
||||
SignInResponseSchema,
|
||||
SignOutResponseSchema,
|
||||
SignUpBodySchema,
|
||||
SignUpResponseSchema,
|
||||
VerifyEmailBodySchema,
|
||||
VerifyEmailResponseSchema,
|
||||
} from "../schemas/routes/index.js";
|
||||
|
||||
/**
|
||||
* Build the 9 OpenAPI route entries that `createAuthPlugin` mounts on
|
||||
* the host's Hono app. Each entry pairs a `RouteConfig` (built with
|
||||
* `RouteBuilder` so Zod inference flows through) with a thin handler
|
||||
* that delegates to Better-Auth's `auth.handler`. The handler keeps
|
||||
* the OpenAPI doc and the runtime routing surface in lock-step.
|
||||
*/
|
||||
export function buildAuthRoutes(auth: Auth): PluginRouteEntry[] {
|
||||
const handler = createBetterAuthHandler(auth);
|
||||
const signUp = POST("/sign-up")
|
||||
.tags("auth")
|
||||
.summary("Email + password sign-up")
|
||||
.body(SignUpBodySchema)
|
||||
.response(successResponse(SignUpResponseSchema))
|
||||
.build();
|
||||
const signIn = POST("/sign-in")
|
||||
.tags("auth")
|
||||
.summary("Email + password sign-in")
|
||||
.body(SignInBodySchema)
|
||||
.response(successResponse(SignInResponseSchema))
|
||||
.build();
|
||||
const signOut = POST("/sign-out")
|
||||
.tags("auth")
|
||||
.summary("Sign out the current session")
|
||||
.response(successResponse(SignOutResponseSchema))
|
||||
.build();
|
||||
const session = GET("/session")
|
||||
.tags("auth")
|
||||
.summary("Get the current session, if any")
|
||||
.response(successResponse(GetSessionResponseSchema))
|
||||
.build();
|
||||
const forgot = POST("/forgot-password")
|
||||
.tags("auth")
|
||||
.summary("Email a password-reset token")
|
||||
.body(ForgotPasswordBodySchema)
|
||||
.response(successResponse(ForgotPasswordResponseSchema))
|
||||
.build();
|
||||
const reset = POST("/reset-password")
|
||||
.tags("auth")
|
||||
.summary("Apply a password reset using a single-use token")
|
||||
.body(ResetPasswordBodySchema)
|
||||
.response(successResponse(ResetPasswordResponseSchema))
|
||||
.build();
|
||||
const verify = POST("/verify-email")
|
||||
.tags("auth")
|
||||
.summary("Verify an email using a single-use token")
|
||||
.body(VerifyEmailBodySchema)
|
||||
.response(successResponse(VerifyEmailResponseSchema))
|
||||
.build();
|
||||
const oauthStart = GET("/oauth/{provider}/start")
|
||||
.tags("auth", "oauth")
|
||||
.summary("Begin OAuth flow with the named provider")
|
||||
.params(OAuthStartParamsSchema)
|
||||
.query(OAuthStartQuerySchema)
|
||||
.response(successResponse(OAuthStartResponseSchema))
|
||||
.build();
|
||||
const oauthCallback = GET("/oauth/{provider}/callback")
|
||||
.tags("auth", "oauth")
|
||||
.summary("OAuth callback — exchange code for session")
|
||||
.params(OAuthCallbackParamsSchema)
|
||||
.query(OAuthCallbackQuerySchema)
|
||||
.response(successResponse(OAuthCallbackResponseSchema))
|
||||
.build();
|
||||
|
||||
return [
|
||||
{ route: signUp, handler: handler.withSignupMetric() },
|
||||
{ route: signIn, handler: handler.withSigninMetric() },
|
||||
{ route: signOut, handler: handler.plain() },
|
||||
{ route: session, handler: handler.plain() },
|
||||
{ route: forgot, handler: handler.plain() },
|
||||
{ route: reset, handler: handler.plain() },
|
||||
{ route: verify, handler: handler.plain() },
|
||||
{ route: oauthStart, handler: handler.plain() },
|
||||
{ route: oauthCallback, handler: handler.plain() },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal handler builder — splits the Better-Auth delegate so the
|
||||
* sign-up and sign-in surfaces can additionally emit metrics. The
|
||||
* passthrough always calls `auth.handler(c.req.raw)` so Better-Auth
|
||||
* owns the precise wire response (cookies, 302 redirects, error
|
||||
* envelopes), while the OpenAPI doc advertises the typed 200 shape.
|
||||
*/
|
||||
function createBetterAuthHandler(auth: Auth): {
|
||||
plain: () => (c: { req: { raw: Request } }) => Promise<Response>;
|
||||
withSignupMetric: () => (c: { req: { raw: Request } }) => Promise<Response>;
|
||||
withSigninMetric: () => (c: { req: { raw: Request } }) => Promise<Response>;
|
||||
} {
|
||||
const passthrough = async (c: { req: { raw: Request } }) =>
|
||||
auth.handler(c.req.raw);
|
||||
return {
|
||||
plain: () => passthrough,
|
||||
withSignupMetric: () => async (c) => {
|
||||
const instruments = getAuthInstruments();
|
||||
const res = await passthrough(c);
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
instruments.signupsTotal.add(1);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
withSigninMetric: () => async (c) => {
|
||||
const instruments = getAuthInstruments();
|
||||
const res = await passthrough(c);
|
||||
instruments.signinsTotal.add(1, {
|
||||
outcome:
|
||||
res.status >= 200 && res.status < 300
|
||||
? "success"
|
||||
: "failure",
|
||||
});
|
||||
return res;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { buildAuthRoutes } from "./build-routes.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
applyBetterAuthSchema,
|
||||
BETTER_AUTH_SURREAL_SCHEMA,
|
||||
} from "./schema.js";
|
||||
74
packages/api-stack/api/api-plugin-auth/src/schema/schema.md
Normal file
74
packages/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.
|
||||
10
packages/api-stack/api/api-plugin-auth/src/schema/schema.ts
Normal file
10
packages/api-stack/api/api-plugin-auth/src/schema/schema.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Re-export of the canonical 17-table Better-Auth SurrealDB schema
|
||||
* shipped by `@suaveplan/better-auth-surrealdb`. The auth plugin
|
||||
* applies this schema during `onInit` so operators get the full table
|
||||
* surface without authoring their own DDL.
|
||||
*/
|
||||
export {
|
||||
applyBetterAuthSchema,
|
||||
BETTER_AUTH_SURREAL_SCHEMA,
|
||||
} from "@suaveplan/better-auth-surrealdb";
|
||||
103
packages/api-stack/api/api-plugin-auth/src/schemas/options.md
Normal file
103
packages/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.
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AuthPluginOptionsSchema } from "./options.js";
|
||||
|
||||
describe("AuthPluginOptionsSchema", () => {
|
||||
it("accepts minimal valid options + applies defaults", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = AuthPluginOptionsSchema.parse({ betterAuth: {} });
|
||||
expect(parsed.betterAuth).toEqual({});
|
||||
expect(parsed.routePrefix).toBe("/auth");
|
||||
expect(parsed.applySchemaOnInit).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing betterAuth", { timeout: 5000 }, () => {
|
||||
const r = AuthPluginOptionsSchema.safeParse({});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects routePrefix without leading slash", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = AuthPluginOptionsSchema.safeParse({
|
||||
betterAuth: {},
|
||||
routePrefix: "auth",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects routePrefix with trailing slash (not root)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = AuthPluginOptionsSchema.safeParse({
|
||||
betterAuth: {},
|
||||
routePrefix: "/auth/",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts root routePrefix '/'", { timeout: 5000 }, () => {
|
||||
const r = AuthPluginOptionsSchema.safeParse({
|
||||
betterAuth: {},
|
||||
routePrefix: "/",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts custom mapping function", { timeout: 5000 }, () => {
|
||||
const parsed = AuthPluginOptionsSchema.parse({
|
||||
betterAuth: {},
|
||||
principalMapping: (v: unknown) => v,
|
||||
});
|
||||
expect(typeof parsed.principalMapping).toBe("function");
|
||||
});
|
||||
|
||||
it("rejects principalMapping that is not a function", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = AuthPluginOptionsSchema.safeParse({
|
||||
betterAuth: {},
|
||||
principalMapping: 42 as unknown as () => unknown,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a LoggerLike", { timeout: 5000 }, () => {
|
||||
const logger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
const parsed = AuthPluginOptionsSchema.parse({
|
||||
betterAuth: {},
|
||||
logger,
|
||||
});
|
||||
expect(parsed.logger).toBe(logger);
|
||||
});
|
||||
|
||||
it("rejects logger missing info()", { timeout: 5000 }, () => {
|
||||
const r = AuthPluginOptionsSchema.safeParse({
|
||||
betterAuth: {},
|
||||
logger: { warn: () => undefined } as never,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects applySchemaOnInit as non-boolean", { timeout: 5000 }, () => {
|
||||
const r = AuthPluginOptionsSchema.safeParse({
|
||||
betterAuth: {},
|
||||
applySchemaOnInit: "true" as never,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for `AuthPluginOptions`. Validated by `createAuthPlugin`
|
||||
* at the factory boundary — invalid input throws
|
||||
* `AuthError(AUTH_INVALID_INPUT)` carrying the `ZodError` as `cause`.
|
||||
*
|
||||
* `betterAuth` accepts any object — it is forwarded verbatim to
|
||||
* Better-Auth's own `betterAuth(options)` factory, which performs its
|
||||
* own validation downstream. Constraining the shape here would force a
|
||||
* Better-Auth version lock-step we explicitly want to avoid.
|
||||
*
|
||||
* `routePrefix` MUST start with `/` and MUST NOT contain a trailing
|
||||
* slash; both forms produce broken Hono routes.
|
||||
*
|
||||
* `principalMapping` and `logger` are validated structurally
|
||||
* (`z.function`/`z.custom`) since both are runtime objects whose
|
||||
* internals are out of scope for the boundary schema.
|
||||
*/
|
||||
export const AuthPluginOptionsSchema = z.object({
|
||||
betterAuth: z
|
||||
.record(z.string(), z.unknown())
|
||||
.describe("Better-Auth options forwarded to betterAuth(options)"),
|
||||
routePrefix: z
|
||||
.string()
|
||||
.regex(/^\/[A-Za-z0-9\-_/]*[A-Za-z0-9\-_]$|^\/$/u, {
|
||||
message:
|
||||
"routePrefix must start with '/' and not end with '/' (unless it is exactly '/')",
|
||||
})
|
||||
.default("/auth"),
|
||||
applySchemaOnInit: z.boolean().default(true),
|
||||
principalMapping: z
|
||||
.custom<(view: unknown) => unknown>(
|
||||
(val) => typeof val === "function",
|
||||
"principalMapping must be a function"
|
||||
)
|
||||
.optional(),
|
||||
logger: z
|
||||
.custom<{
|
||||
debug: (msg: string, ctx?: unknown) => void;
|
||||
info: (msg: string, ctx?: unknown) => void;
|
||||
warn: (msg: string, ctx?: unknown) => void;
|
||||
error: (msg: string, ctx?: unknown) => void;
|
||||
}>(
|
||||
(val) =>
|
||||
val !== null &&
|
||||
typeof val === "object" &&
|
||||
typeof (val as { info?: unknown }).info === "function",
|
||||
"logger must be a LoggerLike with debug/info/warn/error"
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type AuthPluginOptionsInput = z.input<typeof AuthPluginOptionsSchema>;
|
||||
export type AuthPluginOptionsResolved = z.output<
|
||||
typeof AuthPluginOptionsSchema
|
||||
>;
|
||||
@@ -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,67 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AccountSchema, PrincipalSchema, SessionSchema } from "./principal.js";
|
||||
|
||||
describe("PrincipalSchema", () => {
|
||||
it("PrincipalSchema parses valid principal", { timeout: 5000 }, () => {
|
||||
const parsed = PrincipalSchema.parse({
|
||||
id: "u1",
|
||||
email: "a@b.com",
|
||||
emailVerified: true,
|
||||
roles: ["admin"],
|
||||
tenantId: "t1",
|
||||
metadata: { name: "A" },
|
||||
});
|
||||
expect(parsed.id).toBe("u1");
|
||||
expect(parsed.email).toBe("a@b.com");
|
||||
});
|
||||
|
||||
it("rejects missing id", { timeout: 5000 }, () => {
|
||||
const r = PrincipalSchema.safeParse({
|
||||
emailVerified: false,
|
||||
roles: [],
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid email format", { timeout: 5000 }, () => {
|
||||
const r = PrincipalSchema.safeParse({
|
||||
id: "u",
|
||||
email: "not-an-email",
|
||||
emailVerified: false,
|
||||
roles: [],
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionSchema", () => {
|
||||
it("accepts a minimal session", { timeout: 5000 }, () => {
|
||||
expect(SessionSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts a populated session", { timeout: 5000 }, () => {
|
||||
const parsed = SessionSchema.parse({
|
||||
id: "s1",
|
||||
userId: "u1",
|
||||
activeOrganizationId: "org1",
|
||||
});
|
||||
expect(parsed.userId).toBe("u1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountSchema", () => {
|
||||
it("requires userId + providerId + accountId", { timeout: 5000 }, () => {
|
||||
const r = AccountSchema.safeParse({ id: "a1" });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a valid account row", { timeout: 5000 }, () => {
|
||||
const parsed = AccountSchema.parse({
|
||||
id: "a1",
|
||||
userId: "u1",
|
||||
providerId: "credential",
|
||||
accountId: "u1",
|
||||
});
|
||||
expect(parsed.providerId).toBe("credential");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Canonical Zod schema for the `Principal` type emitted by
|
||||
* `@suaveplan/api-plugin-auth`. Sibling plugins import this schema
|
||||
* from `@suaveplan/api-plugin-auth/schemas/principal` so the boundary
|
||||
* contract is consistent end-to-end.
|
||||
*/
|
||||
export const PrincipalSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
email: z.string().email().optional(),
|
||||
emailVerified: z.boolean(),
|
||||
roles: z.array(z.string()).readonly(),
|
||||
tenantId: z.string().min(1).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type PrincipalSchemaType = z.infer<typeof PrincipalSchema>;
|
||||
|
||||
/**
|
||||
* Better-Auth session payload schema — the value handed back by
|
||||
* `auth.api.getSession({ headers })`. Kept loose (passthrough on user
|
||||
* and session) so plugin-extended rows survive validation.
|
||||
*/
|
||||
export const SessionSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
userId: z.string().min(1).optional(),
|
||||
expiresAt: z.union([z.string(), z.date()]).optional(),
|
||||
token: z.string().optional(),
|
||||
ipAddress: z.string().optional(),
|
||||
userAgent: z.string().optional(),
|
||||
activeOrganizationId: z.string().optional(),
|
||||
activeTeamId: z.string().optional(),
|
||||
impersonatedBy: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SessionSchemaType = z.infer<typeof SessionSchema>;
|
||||
|
||||
/**
|
||||
* Better-Auth `account` row schema. Includes provider / OAuth tokens
|
||||
* plus the bcrypt-hashed credential password. Sensitive fields are
|
||||
* declared but consumers of the principal route never receive this
|
||||
* shape — it is exposed for downstream plugin/test integration only.
|
||||
*/
|
||||
export const AccountSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
providerId: z.string().min(1),
|
||||
accountId: z.string().min(1),
|
||||
accessToken: z.string().optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
idToken: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
accessTokenExpiresAt: z.union([z.string(), z.date()]).optional(),
|
||||
refreshTokenExpiresAt: z.union([z.string(), z.date()]).optional(),
|
||||
scope: z.string().optional(),
|
||||
});
|
||||
|
||||
export type AccountSchemaType = z.infer<typeof AccountSchema>;
|
||||
@@ -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.
|
||||
@@ -0,0 +1,14 @@
|
||||
import z from "zod";
|
||||
|
||||
export const ForgotPasswordBodySchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export const ForgotPasswordResponseSchema = z.object({
|
||||
sent: z.literal(true),
|
||||
});
|
||||
|
||||
export type ForgotPasswordBody = z.infer<typeof ForgotPasswordBodySchema>;
|
||||
export type ForgotPasswordResponse = z.infer<
|
||||
typeof ForgotPasswordResponseSchema
|
||||
>;
|
||||
@@ -0,0 +1,51 @@
|
||||
export {
|
||||
type ForgotPasswordBody,
|
||||
ForgotPasswordBodySchema,
|
||||
type ForgotPasswordResponse,
|
||||
ForgotPasswordResponseSchema,
|
||||
} from "./forgot-password.js";
|
||||
export {
|
||||
type OAuthCallbackParams,
|
||||
OAuthCallbackParamsSchema,
|
||||
type OAuthCallbackQuery,
|
||||
OAuthCallbackQuerySchema,
|
||||
OAuthCallbackResponseSchema,
|
||||
type OAuthStartParams,
|
||||
OAuthStartParamsSchema,
|
||||
type OAuthStartQuery,
|
||||
OAuthStartQuerySchema,
|
||||
OAuthStartResponseSchema,
|
||||
} from "./oauth.js";
|
||||
export {
|
||||
type ResetPasswordBody,
|
||||
ResetPasswordBodySchema,
|
||||
type ResetPasswordResponse,
|
||||
ResetPasswordResponseSchema,
|
||||
} from "./reset-password.js";
|
||||
export {
|
||||
type GetSessionResponse,
|
||||
GetSessionResponseSchema,
|
||||
} from "./session.js";
|
||||
export {
|
||||
type SignInBody,
|
||||
SignInBodySchema,
|
||||
type SignInResponse,
|
||||
SignInResponseSchema,
|
||||
} from "./sign-in.js";
|
||||
export {
|
||||
SignOutBodySchema,
|
||||
type SignOutResponse,
|
||||
SignOutResponseSchema,
|
||||
} from "./sign-out.js";
|
||||
export {
|
||||
type SignUpBody,
|
||||
SignUpBodySchema,
|
||||
type SignUpResponse,
|
||||
SignUpResponseSchema,
|
||||
} from "./sign-up.js";
|
||||
export {
|
||||
type VerifyEmailBody,
|
||||
VerifyEmailBodySchema,
|
||||
type VerifyEmailResponse,
|
||||
VerifyEmailResponseSchema,
|
||||
} from "./verify-email.js";
|
||||
@@ -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,31 @@
|
||||
import z from "zod";
|
||||
|
||||
export const OAuthStartParamsSchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
});
|
||||
|
||||
export const OAuthStartQuerySchema = z.object({
|
||||
redirectTo: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export const OAuthStartResponseSchema = z.object({
|
||||
redirectTo: z.string().url(),
|
||||
});
|
||||
|
||||
export const OAuthCallbackParamsSchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
});
|
||||
|
||||
export const OAuthCallbackQuerySchema = z.object({
|
||||
code: z.string().min(1),
|
||||
state: z.string().min(1),
|
||||
});
|
||||
|
||||
export const OAuthCallbackResponseSchema = z.object({
|
||||
redirectTo: z.string().url(),
|
||||
});
|
||||
|
||||
export type OAuthStartParams = z.infer<typeof OAuthStartParamsSchema>;
|
||||
export type OAuthStartQuery = z.infer<typeof OAuthStartQuerySchema>;
|
||||
export type OAuthCallbackParams = z.infer<typeof OAuthCallbackParamsSchema>;
|
||||
export type OAuthCallbackQuery = z.infer<typeof OAuthCallbackQuerySchema>;
|
||||
@@ -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,13 @@
|
||||
import z from "zod";
|
||||
|
||||
export const ResetPasswordBodySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
export const ResetPasswordResponseSchema = z.object({
|
||||
success: z.literal(true),
|
||||
});
|
||||
|
||||
export type ResetPasswordBody = z.infer<typeof ResetPasswordBodySchema>;
|
||||
export type ResetPasswordResponse = z.infer<typeof ResetPasswordResponseSchema>;
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ForgotPasswordBodySchema,
|
||||
ForgotPasswordResponseSchema,
|
||||
GetSessionResponseSchema,
|
||||
OAuthCallbackParamsSchema,
|
||||
OAuthCallbackQuerySchema,
|
||||
OAuthCallbackResponseSchema,
|
||||
OAuthStartParamsSchema,
|
||||
OAuthStartQuerySchema,
|
||||
OAuthStartResponseSchema,
|
||||
ResetPasswordBodySchema,
|
||||
ResetPasswordResponseSchema,
|
||||
SignInBodySchema,
|
||||
SignInResponseSchema,
|
||||
SignOutBodySchema,
|
||||
SignOutResponseSchema,
|
||||
SignUpBodySchema,
|
||||
SignUpResponseSchema,
|
||||
VerifyEmailBodySchema,
|
||||
VerifyEmailResponseSchema,
|
||||
} from "./index.js";
|
||||
|
||||
describe("sign-up schemas", () => {
|
||||
it("accepts valid body", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SignUpBodySchema.parse({
|
||||
email: "a@b.com",
|
||||
password: "Password!",
|
||||
})
|
||||
).toEqual({ email: "a@b.com", password: "Password!" });
|
||||
});
|
||||
it("rejects short password", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SignUpBodySchema.safeParse({
|
||||
email: "a@b.com",
|
||||
password: "short",
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
it("response schema requires user + session", { timeout: 5000 }, () => {
|
||||
expect(SignUpResponseSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sign-in schemas", () => {
|
||||
it("accepts valid body", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SignInBodySchema.parse({ email: "a@b.com", password: "x" })
|
||||
).toEqual({ email: "a@b.com", password: "x" });
|
||||
});
|
||||
it("rejects non-email", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SignInBodySchema.safeParse({ email: "x", password: "x" }).success
|
||||
).toBe(false);
|
||||
});
|
||||
it("response schema requires user + session", { timeout: 5000 }, () => {
|
||||
expect(SignInResponseSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sign-out schemas", () => {
|
||||
it("body is optional", { timeout: 5000 }, () => {
|
||||
expect(SignOutBodySchema.safeParse(undefined).success).toBe(true);
|
||||
expect(SignOutBodySchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
it("response is { success: true }", { timeout: 5000 }, () => {
|
||||
expect(SignOutResponseSchema.parse({ success: true })).toEqual({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("session schemas", () => {
|
||||
it("accepts nullable user + session", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
GetSessionResponseSchema.parse({ user: null, session: null })
|
||||
).toEqual({ user: null, session: null });
|
||||
});
|
||||
it("accepts populated", { timeout: 5000 }, () => {
|
||||
const parsed = GetSessionResponseSchema.parse({
|
||||
user: { id: "u", emailVerified: true, roles: [] },
|
||||
session: { id: "s" },
|
||||
});
|
||||
expect(parsed.user?.id).toBe("u");
|
||||
});
|
||||
});
|
||||
|
||||
describe("forgot-password schemas", () => {
|
||||
it("accepts email body", { timeout: 5000 }, () => {
|
||||
expect(ForgotPasswordBodySchema.parse({ email: "a@b.com" })).toEqual({
|
||||
email: "a@b.com",
|
||||
});
|
||||
});
|
||||
it("response is sent: true", { timeout: 5000 }, () => {
|
||||
expect(ForgotPasswordResponseSchema.parse({ sent: true })).toEqual({
|
||||
sent: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset-password schemas", () => {
|
||||
it("accepts token + password", { timeout: 5000 }, () => {
|
||||
const parsed = ResetPasswordBodySchema.parse({
|
||||
token: "t",
|
||||
password: "Password!",
|
||||
});
|
||||
expect(parsed.token).toBe("t");
|
||||
});
|
||||
it("response is success: true", { timeout: 5000 }, () => {
|
||||
expect(ResetPasswordResponseSchema.parse({ success: true })).toEqual({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("verify-email schemas", () => {
|
||||
it("accepts token body", { timeout: 5000 }, () => {
|
||||
expect(VerifyEmailBodySchema.parse({ token: "t" })).toEqual({
|
||||
token: "t",
|
||||
});
|
||||
});
|
||||
it("response is verified: true", { timeout: 5000 }, () => {
|
||||
expect(VerifyEmailResponseSchema.parse({ verified: true })).toEqual({
|
||||
verified: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauth schemas", () => {
|
||||
it("start params + query", { timeout: 5000 }, () => {
|
||||
expect(OAuthStartParamsSchema.parse({ provider: "github" })).toEqual({
|
||||
provider: "github",
|
||||
});
|
||||
expect(
|
||||
OAuthStartQuerySchema.parse({ redirectTo: "https://x.test/" })
|
||||
).toEqual({ redirectTo: "https://x.test/" });
|
||||
expect(OAuthStartQuerySchema.parse({})).toEqual({});
|
||||
});
|
||||
it("start response", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
OAuthStartResponseSchema.parse({
|
||||
redirectTo: "https://x.test/oauth",
|
||||
})
|
||||
).toEqual({ redirectTo: "https://x.test/oauth" });
|
||||
});
|
||||
it("callback params + query", { timeout: 5000 }, () => {
|
||||
expect(OAuthCallbackParamsSchema.parse({ provider: "github" })).toEqual(
|
||||
{ provider: "github" }
|
||||
);
|
||||
expect(
|
||||
OAuthCallbackQuerySchema.parse({ code: "c", state: "s" })
|
||||
).toEqual({ code: "c", state: "s" });
|
||||
expect(OAuthCallbackQuerySchema.safeParse({ code: "c" }).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
it("callback response", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
OAuthCallbackResponseSchema.parse({
|
||||
redirectTo: "https://x.test/done",
|
||||
})
|
||||
).toEqual({ redirectTo: "https://x.test/done" });
|
||||
});
|
||||
});
|
||||
@@ -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,9 @@
|
||||
import z from "zod";
|
||||
import { PrincipalSchema, SessionSchema } from "../principal.js";
|
||||
|
||||
export const GetSessionResponseSchema = z.object({
|
||||
user: PrincipalSchema.nullable(),
|
||||
session: SessionSchema.nullable(),
|
||||
});
|
||||
|
||||
export type GetSessionResponse = z.infer<typeof GetSessionResponseSchema>;
|
||||
@@ -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,15 @@
|
||||
import z from "zod";
|
||||
import { PrincipalSchema, SessionSchema } from "../principal.js";
|
||||
|
||||
export const SignInBodySchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export const SignInResponseSchema = z.object({
|
||||
user: PrincipalSchema,
|
||||
session: SessionSchema,
|
||||
});
|
||||
|
||||
export type SignInBody = z.infer<typeof SignInBodySchema>;
|
||||
export type SignInResponse = z.infer<typeof SignInResponseSchema>;
|
||||
@@ -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,9 @@
|
||||
import z from "zod";
|
||||
|
||||
export const SignOutBodySchema = z.object({}).optional();
|
||||
|
||||
export const SignOutResponseSchema = z.object({
|
||||
success: z.literal(true),
|
||||
});
|
||||
|
||||
export type SignOutResponse = z.infer<typeof SignOutResponseSchema>;
|
||||
@@ -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,16 @@
|
||||
import z from "zod";
|
||||
import { PrincipalSchema, SessionSchema } from "../principal.js";
|
||||
|
||||
export const SignUpBodySchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
name: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const SignUpResponseSchema = z.object({
|
||||
user: PrincipalSchema,
|
||||
session: SessionSchema,
|
||||
});
|
||||
|
||||
export type SignUpBody = z.infer<typeof SignUpBodySchema>;
|
||||
export type SignUpResponse = z.infer<typeof SignUpResponseSchema>;
|
||||
@@ -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).
|
||||
@@ -0,0 +1,12 @@
|
||||
import z from "zod";
|
||||
|
||||
export const VerifyEmailBodySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
export const VerifyEmailResponseSchema = z.object({
|
||||
verified: z.literal(true),
|
||||
});
|
||||
|
||||
export type VerifyEmailBody = z.infer<typeof VerifyEmailBodySchema>;
|
||||
export type VerifyEmailResponse = z.infer<typeof VerifyEmailResponseSchema>;
|
||||
@@ -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,15 @@
|
||||
import type { ServiceRegistry } from "@suaveplan/api-plugin/service-registry";
|
||||
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import type { AuthService } from "./auth-service.js";
|
||||
|
||||
// Pull in the registry-augmentation as a side effect so the slot is
|
||||
// declared at type-check time.
|
||||
import "./registry-augmentation.js";
|
||||
|
||||
describe("ServiceRegistry slot conformance", () => {
|
||||
it("ServiceRegistry slot typed as AuthService", { timeout: 5000 }, () => {
|
||||
expectTypeOf<
|
||||
ServiceRegistry["authService"]
|
||||
>().toEqualTypeOf<AuthService>();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Auth } from "better-auth";
|
||||
import { Hono } from "hono";
|
||||
import type { Principal } from "../principal/principal.js";
|
||||
import { createAuthService } from "./auth-service.js";
|
||||
|
||||
function makeAuthStub(sessionReturn: unknown = null): Auth {
|
||||
const stub = {
|
||||
api: {
|
||||
getSession: async () => sessionReturn,
|
||||
},
|
||||
} as unknown as Auth;
|
||||
return stub;
|
||||
}
|
||||
|
||||
describe("createAuthService", () => {
|
||||
it("getCurrentPrincipal returns c.get('user')", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const principal: Principal = {
|
||||
id: "u1",
|
||||
emailVerified: true,
|
||||
roles: ["admin"],
|
||||
};
|
||||
const auth = makeAuthStub();
|
||||
const service = createAuthService({ auth });
|
||||
const app = new Hono();
|
||||
app.get("/whoami", (c) => {
|
||||
c.set("user", principal);
|
||||
const p = service.getCurrentPrincipal(c);
|
||||
return c.json({ p });
|
||||
});
|
||||
const res = await app.request("/whoami");
|
||||
const body = (await res.json()) as { p: Principal | null };
|
||||
expect(body.p?.id).toBe("u1");
|
||||
});
|
||||
|
||||
it("getCurrentPrincipal returns null when c.get returns undefined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const auth = makeAuthStub();
|
||||
const service = createAuthService({ auth });
|
||||
const app = new Hono();
|
||||
app.get("/anon", (c) => {
|
||||
const p = service.getCurrentPrincipal(c);
|
||||
return c.json({ isNull: p === null });
|
||||
});
|
||||
const res = await app.request("/anon");
|
||||
const body = (await res.json()) as { isNull: boolean };
|
||||
expect(body.isNull).toBe(true);
|
||||
});
|
||||
|
||||
it("getBetterAuthInstance returns the supplied auth", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const auth = makeAuthStub();
|
||||
const service = createAuthService({ auth });
|
||||
expect(service.getBetterAuthInstance()).toBe(auth);
|
||||
});
|
||||
|
||||
it("getSession proxies to Better-Auth", { timeout: 5000 }, async () => {
|
||||
const expected = {
|
||||
user: { id: "u1" },
|
||||
session: { id: "s1" },
|
||||
};
|
||||
const auth = makeAuthStub(expected);
|
||||
const service = createAuthService({ auth });
|
||||
const result = await service.getSession(new Headers());
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it("getSession resolves to null when Better-Auth returns null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const auth = makeAuthStub(null);
|
||||
const service = createAuthService({ auth });
|
||||
expect(await service.getSession(new Headers())).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Auth } from "better-auth";
|
||||
import type { Context } from "hono";
|
||||
import type { Principal } from "../principal/principal.js";
|
||||
|
||||
/**
|
||||
* Loose-typed Better-Auth session shape returned by `auth.api.getSession`.
|
||||
* Kept loose so plugin-extended sessions (passkeys, organisations,
|
||||
* twoFactor) flow through without forcing this package to track every
|
||||
* Better-Auth plugin's type extensions.
|
||||
*/
|
||||
export type BetterAuthSessionResult = {
|
||||
readonly user: Record<string, unknown>;
|
||||
readonly session: Record<string, unknown>;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* Service slot registered by `createAuthPlugin` in the host's
|
||||
* `ServiceRegistry`. Downstream plugins (api-plugin-user,
|
||||
* api-plugin-vault) call `ctx.getService("authService")` to read the
|
||||
* current principal, the Better-Auth instance, or to manually resolve
|
||||
* a session from arbitrary headers.
|
||||
*/
|
||||
export interface AuthService {
|
||||
/**
|
||||
* Returns the typed `Principal` attached to the current request
|
||||
* context, or `null` for anonymous requests. The session middleware
|
||||
* is responsible for populating `c.get("user")`.
|
||||
*/
|
||||
getCurrentPrincipal(c: Context): Principal | null;
|
||||
/**
|
||||
* Returns the underlying Better-Auth instance — useful for plugins
|
||||
* that need to call Better-Auth API endpoints directly (e.g.
|
||||
* organisation membership listings).
|
||||
*/
|
||||
getBetterAuthInstance(): Auth;
|
||||
/**
|
||||
* Resolves a session from arbitrary request headers. Plugins
|
||||
* authenticating a stand-alone request (cron-triggered work, raw
|
||||
* WebSocket upgrades) use this to get the same session shape the
|
||||
* middleware would have produced.
|
||||
*/
|
||||
getSession(headers: Headers): Promise<BetterAuthSessionResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs required to build the `AuthService` impl. Kept explicit so
|
||||
* the factory is testable without the full plugin lifecycle.
|
||||
*/
|
||||
export interface CreateAuthServiceOptions {
|
||||
readonly auth: Auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct the canonical `AuthService` implementation backed by the
|
||||
* supplied Better-Auth instance.
|
||||
*/
|
||||
export function createAuthService(
|
||||
options: CreateAuthServiceOptions
|
||||
): AuthService {
|
||||
const { auth } = options;
|
||||
return {
|
||||
getCurrentPrincipal(c) {
|
||||
const value = c.get("user");
|
||||
return value ?? null;
|
||||
},
|
||||
getBetterAuthInstance() {
|
||||
return auth;
|
||||
},
|
||||
async getSession(headers) {
|
||||
const result = (await auth.api.getSession({
|
||||
headers,
|
||||
})) as BetterAuthSessionResult;
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "./registry-augmentation.js";
|
||||
|
||||
export {
|
||||
type AuthService,
|
||||
type BetterAuthSessionResult,
|
||||
type CreateAuthServiceOptions,
|
||||
createAuthService,
|
||||
} from "./auth-service.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* ServiceRegistry module augmentation — declares the `authService`
|
||||
* slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
|
||||
* interface so downstream plugins typed against the augmented registry
|
||||
* see `ctx.getService("authService")` resolve to `AuthService |
|
||||
* undefined` without any cast.
|
||||
*
|
||||
* Importing this module for its side effects (which the package root
|
||||
* barrel does automatically) is sufficient — every consumer of
|
||||
* `@suaveplan/api-plugin-auth` gets typed `authService` access.
|
||||
*
|
||||
* The host-side slots (`app`, `openAPIRegistry`, `migrationRunner`,
|
||||
* `db`, `logger`, `eventBus`, `clock`) are owned by `@suaveplan/api`'s
|
||||
* own registry-augmentation module. We re-declare them here as a
|
||||
* cumulative augmentation so the auth plugin's `onInit(ctx)` can
|
||||
* resolve them with full typing even when the consumer has not
|
||||
* explicitly imported `@suaveplan/api` (e.g. during isolated
|
||||
* unit-tests of the plugin). TypeScript merges duplicate declarations,
|
||||
* so this is safe alongside `@suaveplan/api`'s declarations.
|
||||
*/
|
||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import type { MigrationRunner } from "@suaveplan/api-migrations";
|
||||
import type { OpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
|
||||
import type { AuthService } from "./auth-service.js";
|
||||
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
authService: AuthService;
|
||||
app: OpenAPIHono;
|
||||
openAPIRegistry: OpenAPIRegistry;
|
||||
migrationRunner?: MigrationRunner;
|
||||
db?: SurrealAdapter;
|
||||
logger: LoggerLike;
|
||||
eventBus: EventBusLike;
|
||||
clock: Clock;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type {
|
||||
AuthPluginConfig,
|
||||
AuthPluginOptions,
|
||||
} from "./types.js";
|
||||
85
packages/api-stack/api/api-plugin-auth/src/types/types.md
Normal file
85
packages/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.
|
||||
56
packages/api-stack/api/api-plugin-auth/src/types/types.ts
Normal file
56
packages/api-stack/api/api-plugin-auth/src/types/types.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import type {
|
||||
BetterAuthSessionView,
|
||||
Principal,
|
||||
} from "../principal/principal.js";
|
||||
|
||||
/**
|
||||
* Top-level options accepted by `createAuthPlugin`. Validated by
|
||||
* `AuthPluginOptionsSchema` at the factory boundary; the resolved
|
||||
* shape (with defaults applied) is the runtime `AuthPluginConfig`.
|
||||
*/
|
||||
export interface AuthPluginOptions {
|
||||
/**
|
||||
* Better-Auth options forwarded verbatim to `betterAuth(...)`.
|
||||
* Validated by Better-Auth itself — the auth plugin treats this as
|
||||
* an opaque blob.
|
||||
*/
|
||||
readonly betterAuth: Record<string, unknown>;
|
||||
/**
|
||||
* URL prefix where the plugin mounts its 9 routes. Defaults to
|
||||
* `/auth`. Must start with `/` and not carry a trailing slash
|
||||
* (the exact root `/` is allowed).
|
||||
*/
|
||||
readonly routePrefix?: string;
|
||||
/**
|
||||
* When `true` (the default), `onInit` applies
|
||||
* `BETTER_AUTH_SURREAL_SCHEMA` against the resolved migration
|
||||
* runner (preferred) or `SurrealAdapter`. Set `false` when
|
||||
* operators manage migrations externally.
|
||||
*/
|
||||
readonly applySchemaOnInit?: boolean;
|
||||
/**
|
||||
* Custom Better-Auth → `Principal` mapping. Defaults to
|
||||
* `mapBetterAuthUserToPrincipal`. Override to project Better-Auth
|
||||
* plugin-extended fields onto a custom `Principal` extension.
|
||||
*/
|
||||
readonly principalMapping?: (view: BetterAuthSessionView) => Principal;
|
||||
/**
|
||||
* Optional logger override. When omitted, the plugin uses the
|
||||
* `PluginContext` logger supplied by the registry.
|
||||
*/
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully-resolved options after Zod parse — every default has been
|
||||
* applied. The plugin's `PluginDescriptor.configSchema` infers to this
|
||||
* type.
|
||||
*/
|
||||
export interface AuthPluginConfig {
|
||||
readonly betterAuth: Record<string, unknown>;
|
||||
readonly routePrefix: string;
|
||||
readonly applySchemaOnInit: boolean;
|
||||
readonly principalMapping?: (view: BetterAuthSessionView) => Principal;
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
134
packages/api-stack/api/api-plugin-auth/tests/dep-graph.test.ts
Normal file
134
packages/api-stack/api/api-plugin-auth/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const pkgRoot = resolve(__dirname, "..");
|
||||
const srcRoot = join(pkgRoot, "src");
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) out.push(...walk(full));
|
||||
else out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("@suaveplan/api-plugin-auth dep graph + layout", () => {
|
||||
it("package.json carries required dependencies", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(pkgRoot, "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies: Record<string, string>;
|
||||
devDependencies: Record<string, string>;
|
||||
};
|
||||
const deps = pkg.dependencies;
|
||||
expect(deps["@suaveplan/api-plugin"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/api-openapi"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/api-migrations"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/better-auth-surrealdb"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/surrealdb"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/error"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/types"]).toBe("workspace:^");
|
||||
expect(deps["hono"]).toBe("catalog:");
|
||||
expect(deps["@hono/zod-openapi"]).toBe("catalog:server");
|
||||
expect(deps["zod"]).toBe("catalog:forms");
|
||||
expect(deps["better-auth"]).toBe("catalog:auth");
|
||||
expect(deps["@opentelemetry/api"]).toBe("catalog:observability");
|
||||
});
|
||||
|
||||
it("package.json exposes 15 subpath exports", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(pkgRoot, "package.json"), "utf8")
|
||||
) as { exports: Record<string, unknown> };
|
||||
const subs = Object.keys(pkg.exports).filter(
|
||||
(k) => k.startsWith("./") && k !== "./package.json"
|
||||
);
|
||||
// 15 named feature subpaths (root "." is excluded by filter).
|
||||
expect(subs.length).toBe(15);
|
||||
});
|
||||
|
||||
it("every impl .ts has a sibling .md", { timeout: 5000 }, () => {
|
||||
const missing: string[] = [];
|
||||
for (const file of walk(srcRoot)) {
|
||||
if (!file.endsWith(".ts")) continue;
|
||||
if (
|
||||
file.endsWith(".test.ts") ||
|
||||
file.endsWith(".spec.ts") ||
|
||||
file.endsWith(".types.ts") ||
|
||||
file.endsWith("/index.ts")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const md = file.replace(/\.ts$/, ".md");
|
||||
try {
|
||||
statSync(md);
|
||||
} catch {
|
||||
missing.push(md);
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("every schemas/routes/<name>.ts has a sibling .md", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const routesDir = join(srcRoot, "schemas", "routes");
|
||||
const missing: string[] = [];
|
||||
for (const entry of readdirSync(routesDir)) {
|
||||
if (!entry.endsWith(".ts")) continue;
|
||||
if (
|
||||
entry === "index.ts" ||
|
||||
entry.endsWith(".test.ts") ||
|
||||
entry.endsWith(".types.ts")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const md = join(routesDir, entry.replace(/\.ts$/, ".md"));
|
||||
try {
|
||||
statSync(md);
|
||||
} catch {
|
||||
missing.push(md);
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("README.md is present at package root", { timeout: 5000 }, () => {
|
||||
const readme = readFileSync(join(pkgRoot, "README.md"), "utf8");
|
||||
expect(readme.length).toBeGreaterThan(200);
|
||||
expect(readme).toContain("@suaveplan/api-plugin-auth");
|
||||
});
|
||||
|
||||
it("every subpath export resolves to a file within src/", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(pkgRoot, "package.json"), "utf8")
|
||||
) as {
|
||||
exports: Record<string, { import?: { default?: string } } | string>;
|
||||
};
|
||||
const missing: string[] = [];
|
||||
for (const [sub, val] of Object.entries(pkg.exports)) {
|
||||
if (sub === "./package.json") continue;
|
||||
const defaultPath =
|
||||
typeof val === "string"
|
||||
? val
|
||||
: (val?.import?.default ?? undefined);
|
||||
if (defaultPath === undefined) continue;
|
||||
// dist/<rel>.js → src/<rel>.ts
|
||||
const rel = defaultPath
|
||||
.replace(/^\.\/dist\//, "")
|
||||
.replace(/\.js$/, ".ts");
|
||||
const srcPath = join(srcRoot, rel);
|
||||
try {
|
||||
statSync(srcPath);
|
||||
} catch {
|
||||
missing.push(`${sub} → ${srcPath}`);
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
|
||||
import { createAuthPlugin } from "../../src/plugin/plugin.js";
|
||||
import type { AuthService } from "../../src/services/auth-service.js";
|
||||
import type { AuthPluginConfig } from "../../src/types/types.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
let fixture: SurrealDBFixture | null = null;
|
||||
let client: SurrealAdapter | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixture = new SurrealDBFixture();
|
||||
const cfg = await fixture.start();
|
||||
client = new SurrealAdapter({
|
||||
url: cfg.url,
|
||||
namespace: cfg.namespace,
|
||||
database: cfg.database,
|
||||
username: cfg.username,
|
||||
password: cfg.password,
|
||||
});
|
||||
await client.connect();
|
||||
await client.execute("DEFINE TABLE OVERWRITE user SCHEMAFULL;");
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE email ON user TYPE option<string>;"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE phoneNumber ON user TYPE option<string>;"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE createdAt ON user TYPE option<datetime> DEFAULT time::now() VALUE $before OR time::now();"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE updatedAt ON user TYPE option<datetime> DEFAULT time::now() VALUE time::now();"
|
||||
);
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (client) {
|
||||
await client.disconnect();
|
||||
client = null;
|
||||
}
|
||||
if (fixture) {
|
||||
await fixture.stop();
|
||||
fixture = null;
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
describe("full integration boot against real SurrealDB", () => {
|
||||
it("full sign-up/sign-in/session/sign-out roundtrip against real SurrealDB", {
|
||||
timeout: 120_000,
|
||||
}, async () => {
|
||||
if (client === null) throw new Error("client not ready");
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "auth-test", version: "0.1.0" },
|
||||
});
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {
|
||||
database: betterAuthSurrealAdapter({ client }),
|
||||
secret: "test-secret-at-least-32-chars-long-for-better-auth",
|
||||
baseURL: "http://localhost:3000/auth",
|
||||
emailAndPassword: { enabled: true },
|
||||
},
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
services.set("db", client);
|
||||
const ctx = createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as AuthPluginConfig,
|
||||
services,
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
registry.applyTo(app, "/openapi.json");
|
||||
|
||||
// The Better-Auth instance is reachable via the registered
|
||||
// authService slot. We drive the lifecycle directly through
|
||||
// its API (which is what HTTP routes delegate to anyway) so
|
||||
// the test does not depend on Better-Auth's URL-routing
|
||||
// configuration which lives outside this plugin.
|
||||
const authService = services.get("authService") as AuthService;
|
||||
expect(authService).toBeDefined();
|
||||
const auth = authService.getBetterAuthInstance();
|
||||
const signUp = (await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: "integration@example.test",
|
||||
password: "Passw0rd!2026",
|
||||
name: "Integration User",
|
||||
},
|
||||
})) as { user: { email: string } };
|
||||
expect(signUp.user.email).toBe("integration@example.test");
|
||||
|
||||
const users = await client.query<{ email: string }>(
|
||||
"SELECT * FROM user WHERE email = 'integration@example.test';"
|
||||
);
|
||||
expect(users.length).toBeGreaterThan(0);
|
||||
|
||||
// OpenAPI document includes the 9 routes (prefixed).
|
||||
const openapiRes = await app.request("/openapi.json");
|
||||
const doc = (await openapiRes.json()) as {
|
||||
paths: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
const paths = Object.keys(doc.paths ?? {});
|
||||
const authPaths = paths.filter((p) => p.startsWith("/auth/"));
|
||||
expect(authPaths.length).toBe(9);
|
||||
|
||||
// Hit the registered Hono routes — the OpenAPI route
|
||||
// handler delegates to auth.handler so any 2xx/3xx/4xx
|
||||
// response (rather than 404) confirms routing reached
|
||||
// Better-Auth. We use /auth/get-session which is the
|
||||
// canonical anonymous endpoint.
|
||||
const sessionRes = await app.request(
|
||||
"http://localhost:3000/auth/get-session"
|
||||
);
|
||||
expect(sessionRes.status).not.toBe(404);
|
||||
});
|
||||
|
||||
it("schema apply is idempotent across restarts", {
|
||||
timeout: 60_000,
|
||||
}, async () => {
|
||||
if (client === null) throw new Error("client not ready");
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "auth-test", version: "0.1.0" },
|
||||
});
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {
|
||||
database: betterAuthSurrealAdapter({ client }),
|
||||
secret: "test-secret-at-least-32-chars-long-for-better-auth",
|
||||
baseURL: "http://localhost:3000",
|
||||
emailAndPassword: { enabled: true },
|
||||
},
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
services.set("db", client);
|
||||
const ctx = createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as AuthPluginConfig,
|
||||
services,
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
const app2 = new OpenAPIHono();
|
||||
const registry2 = createOpenAPIRegistry({
|
||||
info: { title: "auth-test", version: "0.1.0" },
|
||||
});
|
||||
const plugin2 = createAuthPlugin({
|
||||
betterAuth: {
|
||||
database: betterAuthSurrealAdapter({ client }),
|
||||
secret: "test-secret-at-least-32-chars-long-for-better-auth",
|
||||
baseURL: "http://localhost:3000",
|
||||
emailAndPassword: { enabled: true },
|
||||
},
|
||||
});
|
||||
const services2 = new Map<string, unknown>();
|
||||
services2.set("app", app2);
|
||||
services2.set("openAPIRegistry", registry2);
|
||||
services2.set("db", client);
|
||||
const ctx2 = createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin2.defaults as AuthPluginConfig,
|
||||
services: services2,
|
||||
});
|
||||
await plugin2.onInit?.(ctx2);
|
||||
expect(registry2.getRoutesByPlugin("auth")).toHaveLength(9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
|
||||
import { createAuthPlugin } from "../../src/plugin/plugin.js";
|
||||
import type { AuthPluginConfig } from "../../src/types/types.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
let fixture: SurrealDBFixture | null = null;
|
||||
let client: SurrealAdapter | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixture = new SurrealDBFixture();
|
||||
const cfg = await fixture.start();
|
||||
client = new SurrealAdapter({
|
||||
url: cfg.url,
|
||||
namespace: cfg.namespace,
|
||||
database: cfg.database,
|
||||
username: cfg.username,
|
||||
password: cfg.password,
|
||||
});
|
||||
await client.connect();
|
||||
await client.execute("DEFINE TABLE OVERWRITE user SCHEMAFULL;");
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE email ON user TYPE option<string>;"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE phoneNumber ON user TYPE option<string>;"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE createdAt ON user TYPE option<datetime> DEFAULT time::now() VALUE $before OR time::now();"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE updatedAt ON user TYPE option<datetime> DEFAULT time::now() VALUE time::now();"
|
||||
);
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (client) {
|
||||
await client.disconnect();
|
||||
client = null;
|
||||
}
|
||||
if (fixture) {
|
||||
await fixture.stop();
|
||||
fixture = null;
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
describe("openapi-doc", () => {
|
||||
it("served /openapi.json includes auth routes with Zod-typed schemas", {
|
||||
timeout: 120_000,
|
||||
}, async () => {
|
||||
if (client === null) throw new Error("client not ready");
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "auth-test", version: "0.1.0" },
|
||||
});
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {
|
||||
database: betterAuthSurrealAdapter({ client }),
|
||||
secret: "test-secret-at-least-32-chars-long-for-better-auth",
|
||||
baseURL: "http://localhost:3000/auth",
|
||||
emailAndPassword: { enabled: true },
|
||||
},
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
services.set("db", client);
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as AuthPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
registry.applyTo(app, "/openapi.json");
|
||||
const res = await app.request("/openapi.json");
|
||||
const doc = (await res.json()) as {
|
||||
paths: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
for (const path of [
|
||||
"/auth/sign-up",
|
||||
"/auth/sign-in",
|
||||
"/auth/sign-out",
|
||||
"/auth/session",
|
||||
"/auth/forgot-password",
|
||||
"/auth/reset-password",
|
||||
"/auth/verify-email",
|
||||
]) {
|
||||
expect(doc.paths[path]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("served doc lists all 9 auth routes", { timeout: 120_000 }, async () => {
|
||||
if (client === null) throw new Error("client not ready");
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "auth-test", version: "0.1.0" },
|
||||
});
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {
|
||||
database: betterAuthSurrealAdapter({ client }),
|
||||
secret: "test-secret-at-least-32-chars-long-for-better-auth",
|
||||
baseURL: "http://localhost:3000/auth",
|
||||
emailAndPassword: { enabled: true },
|
||||
},
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
services.set("db", client);
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as AuthPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
const routes = registry.getRoutesByPlugin("auth");
|
||||
expect(routes.length).toBe(9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
|
||||
import { createAuthPlugin } from "../../src/plugin/plugin.js";
|
||||
import type { AuthPluginConfig } from "../../src/types/types.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
let fixture: SurrealDBFixture | null = null;
|
||||
let client: SurrealAdapter | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixture = new SurrealDBFixture();
|
||||
const cfg = await fixture.start();
|
||||
client = new SurrealAdapter({
|
||||
url: cfg.url,
|
||||
namespace: cfg.namespace,
|
||||
database: cfg.database,
|
||||
username: cfg.username,
|
||||
password: cfg.password,
|
||||
});
|
||||
await client.connect();
|
||||
await client.execute("DEFINE TABLE OVERWRITE user SCHEMAFULL;");
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE email ON user TYPE option<string>;"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE phoneNumber ON user TYPE option<string>;"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE createdAt ON user TYPE option<datetime> DEFAULT time::now() VALUE $before OR time::now();"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE updatedAt ON user TYPE option<datetime> DEFAULT time::now() VALUE time::now();"
|
||||
);
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (client) {
|
||||
await client.disconnect();
|
||||
client = null;
|
||||
}
|
||||
if (fixture) {
|
||||
await fixture.stop();
|
||||
fixture = null;
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
async function bootOnce(c: SurrealAdapter): Promise<void> {
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "auth-test", version: "0.1.0" },
|
||||
});
|
||||
const plugin = createAuthPlugin({
|
||||
betterAuth: {
|
||||
database: betterAuthSurrealAdapter({ client: c }),
|
||||
secret: "test-secret-at-least-32-chars-long-for-better-auth",
|
||||
baseURL: "http://localhost:3000/auth",
|
||||
emailAndPassword: { enabled: true },
|
||||
},
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
services.set("db", c);
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as AuthPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe("schema apply idempotency", () => {
|
||||
it("re-apply succeeds after a prior boot wrote rows", {
|
||||
timeout: 180_000,
|
||||
}, async () => {
|
||||
if (client === null) throw new Error("client not ready");
|
||||
await bootOnce(client);
|
||||
// Write a row so the second apply has data in scope.
|
||||
await client.execute(
|
||||
"CREATE user:integration_idem SET email = 'idem@example.test';"
|
||||
);
|
||||
// Re-apply.
|
||||
await bootOnce(client);
|
||||
const rows = await client.query<{ email: string }>(
|
||||
"SELECT * FROM user WHERE email = 'idem@example.test';"
|
||||
);
|
||||
expect(rows.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { PrincipalSchema } from "../src/schemas/principal.js";
|
||||
|
||||
const principalArb = fc.record(
|
||||
{
|
||||
id: fc.string({ minLength: 1, maxLength: 64 }),
|
||||
email: fc.option(
|
||||
fc
|
||||
.tuple(
|
||||
fc.string({ minLength: 1, maxLength: 16 }),
|
||||
fc.string({ minLength: 1, maxLength: 16 })
|
||||
)
|
||||
.map(
|
||||
([l, r]) =>
|
||||
`${l.replace(/[^A-Za-z0-9]/g, "a")}@${r.replace(
|
||||
/[^A-Za-z0-9]/g,
|
||||
"a"
|
||||
)}.test`
|
||||
),
|
||||
{ nil: undefined }
|
||||
),
|
||||
emailVerified: fc.boolean(),
|
||||
roles: fc.array(fc.string({ minLength: 1, maxLength: 16 }), {
|
||||
maxLength: 5,
|
||||
}),
|
||||
tenantId: fc.option(fc.string({ minLength: 1, maxLength: 32 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
metadata: fc.option(
|
||||
fc.dictionary(
|
||||
fc.string({ minLength: 1, maxLength: 8 }),
|
||||
fc.oneof(fc.string(), fc.integer(), fc.boolean())
|
||||
),
|
||||
{ nil: undefined }
|
||||
),
|
||||
},
|
||||
{ requiredKeys: ["id", "emailVerified", "roles"] }
|
||||
);
|
||||
|
||||
describe("PrincipalSchema fast-check round-trip", () => {
|
||||
it("accepts arbitrary valid principals (200 runs)", {
|
||||
timeout: 30_000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(principalArb, (sample) => {
|
||||
const result = PrincipalSchema.safeParse(sample);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`unexpected reject: ${JSON.stringify(result.error.issues)}`
|
||||
);
|
||||
}
|
||||
expect(result.data.id).toBe(sample.id);
|
||||
expect(result.data.emailVerified).toBe(sample.emailVerified);
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/api-plugin-auth.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
|
||||
* module imports so the meter resolved on first call binds to it. Node 18+
|
||||
* + Bun expose `crypto.subtle` / `crypto.getRandomValues` globally so no
|
||||
* crypto polyfill is required.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-auth/tsconfig.json
Normal file
12
packages/api-stack/api/api-plugin-auth/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-auth/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-plugin-auth/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
38
packages/api-stack/api/api-plugin-auth/vite.config.ts
Normal file
38
packages/api-stack/api/api-plugin-auth/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-plugin-auth",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
/^@hono\//,
|
||||
"better-auth",
|
||||
"hono",
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/api-stack/api/api-plugin-auth/vitest.config.ts
Normal file
33
packages/api-stack/api/api-plugin-auth/vitest.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 120000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user