first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,203 @@
# @suaveplan/api-plugin-user - User-profile CRUD plugin for @suaveplan/api. Mounts /users/me GET/PATCH/DELETE on top of @suaveplan/api-plugin-auth, registers a typed userService in the host ServiceRegistry, extends Better-Auth's user table with operator-configurable profile fields via a bundled .surql schema, and routes are described with Zod-typed RouteBuilder so the served /openapi.json is end-to-end typed. Server-tier — runs in Bun and Node. Second instance of the 3-way plug-and-play contract.
User-profile CRUD plugin for `@suaveplan/api`. Mounts the canonical
`/users/me` GET/PATCH/DELETE surface on top of
`@suaveplan/api-plugin-auth`, registers a typed `userService` in the
host `ServiceRegistry`, extends Better-Auth's `user` table with
operator-configurable profile columns via a bundled `.surql` schema,
and serves Zod-typed routes through `definePluginRoutes` so the
unified `/openapi.json` document is end-to-end typed.
Second instance of the 3-way plug-and-play contract pioneered by
`@suaveplan/api-plugin-auth`.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module defines the stable registry of programmatic failure codes raised by `@suaveplan/api-plugin-user`…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module defines the stable registry of programmatic failure codes raised by `@suaveplan/api-plugin-user`…
- [otel](./src/otel/instruments.md) — The instruments module owns the three OpenTelemetry counters the user plugin emits — `user.profile_reads_total`, `user.…
- [instruments](./src/otel/instruments.md) — The instruments module owns the three OpenTelemetry counters the user plugin emits — `user.profile_reads_total`, `user.…
- [plugin](./src/plugin/plugin.md) — The plugin module exports `createUserPlugin`, the public factory for the user plugin's `PluginDescriptor`. The factory…
- [plugin](./src/plugin/plugin.md) — The plugin module exports `createUserPlugin`, the public factory for the user plugin's `PluginDescriptor`. The factory…
- [build-routes](./src/routes/build-routes.md) — `buildUserRoutes` returns the three `PluginRouteEntry` values that `createUserPlugin` mounts on the host's Hono app via…
- [delete-me](./src/routes/delete-me.md) — `buildDeleteMeRoute` returns the `PluginRouteEntry` for `DELETE /me`. The route is gated by the plugin's `allowSelfDele…
- [get-me](./src/routes/get-me.md) — `buildGetMeRoute` returns the `PluginRouteEntry` for `GET /me`. The handler resolves the current Principal from `c.get(…
- [patch-me](./src/routes/patch-me.md) — `buildPatchMeRoute` returns the `PluginRouteEntry` for `PATCH /me`. The handler reads the principal from `c.get("user")…
- [schema](./src/schema/schema.md) — The schema module owns the SurrealQL DDL the user plugin ships — a small extension to Better-Auth's `user` table that a…
- [schema](./src/schema/schema.md) — The schema module owns the SurrealQL DDL the user plugin ships — a small extension to Better-Auth's `user` table that a…
- [options](./src/schemas/options.md) — `UserPluginOptionsSchema` is the Zod boundary contract for the operator-supplied options accepted by `createUserPlugin`…
- [user-profile](./src/schemas/user-profile.md) — `user-profile` is the boundary Zod surface for the user plugin: the response shape for `GET /users/me`, the response sh…
- [registry-augmentation](./src/services/registry-augmentation.md) — This module is a TypeScript `declare module` augmentation. It adds the `userService` slot to the canonical `ServiceRegi…
- [user-service](./src/services/user-service.md) — The user-service module owns the `UserService` interface — the `ServiceRegistry`-resolved API downstream `@suaveplan/ap…
- [types](./src/types/types.md) — This module declares the three TypeScript types that flow across the public surface of `@suaveplan/api-plugin-user`: `U…
- [types](./src/types/types.md) — This module declares the three TypeScript types that flow across the public surface of `@suaveplan/api-plugin-user`: `U…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-plugin-user
```
The package depends on `@suaveplan/api-plugin-auth` — auth must be
mounted first for the user plugin to resolve the `authService`
service slot and use its `requireAuth()` middleware.
## Quick start
```ts
import { defineApp } from "@suaveplan/api";
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
import { createUserPlugin } from "@suaveplan/api-plugin-user";
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
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 },
user: { deleteUser: { enabled: true } },
},
})
)
.use(createUserPlugin({}))
.start();
```
`createUserPlugin` returns a typed `PluginDescriptor` with
`dependencies: ["auth"]` so the host's `PluginRegistry` topo-sorts the
plugin after `api-plugin-auth`. The `onInit` lifecycle:
1. Resolves `app`, `authService`, `db`, and (optional)
`openAPIRegistry` / `migrationRunner` from the host
`ServiceRegistry`.
2. Applies `USER_PROFILE_SCHEMA` — `DEFINE FIELD OVERWRITE` statements
that extend the Better-Auth `user` table with the configured
profile columns. Uses the migration runner when registered,
otherwise falls back to `db.execute`.
3. Builds a per-instance strict-mode `UpdateUserProfileBodySchema`
from the resolved `profileFields`.
4. Constructs the canonical `UserService` and registers it as
`userService` in the typed `ServiceRegistry`.
5. Installs `requireAuth()` from the auth plugin on the route prefix
so every `/users/me` handler runs under an authenticated context.
6. Registers the three routes (`GET`, `PATCH`, `DELETE` of `/me`) via
`definePluginRoutes`. They appear in the unified `/openapi.json`
doc with their Zod schemas.
## The 3-way plug-and-play contract
| Surface | Implementation |
|---|---|
| `.surql` DDL | `USER_PROFILE_SCHEMA` — operator-configurable `DEFINE FIELD OVERWRITE` statements extending the Better-Auth `user` table |
| Zod schemas | `UserProfileSchema` extends `PrincipalSchema` from `api-plugin-auth`; `UpdateUserProfileBodySchema` is strict-mode partial-pick from the profile fields |
| OpenAPI routes | `buildUserRoutes` returns 3 `RouteBuilder` entries; `definePluginRoutes` writes both the Hono app AND the `OpenAPIRegistry` |
## Configuration
```ts
createUserPlugin({
routePrefix: "/users", // default
allowSelfDelete: true, // default
profileFields: [ // default — operators add custom columns
"displayName",
"avatarUrl",
"locale",
"timezone",
],
applySchemaOnInit: true, // default — set false to manage DDL externally
});
```
### Custom profile fields
```ts
createUserPlugin({
profileFields: ["displayName", "avatarUrl", "bio", "locale"],
});
```
The plugin renders one `DEFINE FIELD OVERWRITE <field> ON user TYPE
option<string>` statement per name. Each name MUST match
`[A-Za-z_][A-Za-z0-9_]*` so the schema-apply step can interpolate it
directly into DDL without quoting — `UserPluginOptionsSchema` gates
this at the factory boundary.
### Disabled self-delete
```ts
createUserPlugin({ allowSelfDelete: false });
```
The OpenAPI document still advertises `DELETE /users/me`, but the
handler raises `UserError(USER_SELF_DELETE_DISABLED)` (HTTP 403) so
operators can detect the disabled state programmatically.
## Consuming `userService` from downstream plugins
```ts
import type { UserService } from "@suaveplan/api-plugin-user";
const userService = ctx.getService("userService");
if (!userService) {
throw new Error("user plugin not mounted");
}
const profile = await userService.getById("u1");
```
ServiceRegistry module augmentation (imported automatically by the
package barrel) makes `ctx.getService("userService")` resolve to
`UserService | undefined` with full inference — no cast required.
## Error codes
The plugin owns seven `USER.*` codes — every code is reachable from a
production call site:
| Code | When |
|---|---|
| `USER_NOT_FOUND` | `getById` / `requireById` / `getCurrent` could not resolve the row (also raised when `requireAuth()` is bypassed and the handler runs anonymously) |
| `USER_INVALID_INPUT` | `createUserPlugin(options)` failed Zod validation; or PATCH body failed `UpdateUserProfileBodySchema` validation |
| `USER_SELF_DELETE_DISABLED` | `DELETE /users/me` hit while `allowSelfDelete: false` |
| `USER_PROFILE_UPDATE_FAILED` | `updateProfile` / Better-Auth `internalAdapter.deleteUser` failed at the database layer |
| `USER_SERVICE_NOT_AVAILABLE` | Missing required slot in `ServiceRegistry` (`app`, `authService`, `db`) |
| `USER_SCHEMA_APPLY_FAILED` | `migrationRunner.run` / `db.execute` rejected the bundled DDL |
| `USER_ROUTE_PREFIX_COLLISION` | `routePrefix/me` collides with previously-registered routes in the `OpenAPIRegistry` |
Each error carries `context.statusCode` so the host's error-handler
middleware maps to the right HTTP status without per-route
boilerplate. Full reference in `src/error-codes/error-codes.md`.
## Modules
- [`error-codes/error-codes`](./src/error-codes/error-codes.md) — error registry
- [`otel/instruments`](./src/otel/instruments.md) — counters
- [`plugin/plugin`](./src/plugin/plugin.md) — factory
- [`routes/build-routes`](./src/routes/build-routes.md) — route entries
- [`routes/get-me`](./src/routes/get-me.md) — `GET /users/me`
- [`routes/patch-me`](./src/routes/patch-me.md) — `PATCH /users/me`
- [`routes/delete-me`](./src/routes/delete-me.md) — `DELETE /users/me`
- [`schema/schema`](./src/schema/schema.md) — DDL renderer + apply helper
- [`schemas/options`](./src/schemas/options.md) — `UserPluginOptionsSchema`
- [`schemas/user-profile`](./src/schemas/user-profile.md) — boundary Zod schemas
- [`services/user-service`](./src/services/user-service.md) — `UserService` impl
- [`services/registry-augmentation`](./src/services/registry-augmentation.md) — typed `userService` slot
- [`types/types`](./src/types/types.md) — public types
## OpenTelemetry
The plugin emits three counters:
- `user.profile_reads_total` — every successful `GET /users/me`
- `user.profile_updates_total{outcome}` — every `PATCH /users/me`
- `user.self_deletes_total` — every successful `DELETE /users/me`
Counters bind lazily to the global `MeterProvider`; tests
re-bind by calling `resetUserInstruments()` after swapping the
provider.

View File

@@ -0,0 +1,87 @@
# error-codes/error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The error-codes module defines the stable registry of programmatic
failure codes raised by `@suaveplan/api-plugin-user`. Every code lives
behind a `UserError` class extending the canonical `SuaveplanError`
base, so callers can `instanceof UserError` to narrow `error.code` to
the union of seven `USER.*` codes — never a free-form string. The
registry is the contract: adding, removing, or renaming a code is a
breaking change for consumers asserting on the value.
## Features
- Seven stable codes covering every plugin failure path: lookup misses,
invalid factory options, disabled self-delete, profile-update
failures, missing services, schema-apply failures, and route-prefix
collisions.
- `defineCodes("USER", [...])` registers the codes globally so the
unified error registry can resolve them by string.
- `UserError` carries `userCode` alongside the canonical `code` field
so downstream code that does not import the base class can still
branch on the discriminant.
- Each thrown instance carries `context.statusCode` so the host's
error-handler middleware maps the failure to the right HTTP status
without per-route boilerplate.
## Usage: Basic
```ts
import { USER_CODES, UserError } from "@suaveplan/api-plugin-user/error-codes/error-codes";
if (!row) {
throw new UserError(USER_CODES.NOT_FOUND, "user not found", {
context: { statusCode: 404, userId: id },
});
}
```
## Usage: Advanced
Compose the registry with the `@suaveplan/error` `cause` chain so the
original database error survives translation to HTTP without losing
its stack:
```ts
try {
await db.execute(sql);
} catch (cause) {
throw new UserError(USER_CODES.PROFILE_UPDATE_FAILED, "patch failed", {
cause,
context: { statusCode: 500, userId, fields: Object.keys(patch) },
});
}
```
## API Reference
### `USER_CODES`
`Readonly<Record<"NOT_FOUND" | "INVALID_INPUT" | "SELF_DELETE_DISABLED" | "PROFILE_UPDATE_FAILED" | "SERVICE_NOT_AVAILABLE" | "SCHEMA_APPLY_FAILED" | "ROUTE_PREFIX_COLLISION", string>>`.
Each value is the fully-qualified `"USER.<NAME>"` string used as the
discriminant.
### `UserError`
`class UserError extends SuaveplanError<UserErrorCode>` — public
constructor `(code, message, options?)` so callers can construct the
error directly. `userCode` mirrors `code` for ergonomic narrowing.
### `UserErrorCode`
Union of all seven literal code values.
## Implementation Notes
- `defineCodes` performs duplicate-detection at module-load time; a
duplicate name throws synchronously so a stale build fails loudly.
- The seven codes are intentionally cohesive — admin-side listings,
avatar uploads, and GDPR export are scoped to follow-up plugins with
their own registries.
- `statusCode` is always carried on `context`, not as a top-level
field, so the error registry remains transport-agnostic.

View File

@@ -0,0 +1,76 @@
# otel/instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The instruments module owns the three OpenTelemetry counters the user
plugin emits — `user.profile_reads_total`,
`user.profile_updates_total{outcome}`, and `user.self_deletes_total`.
The counters are lazily bound to the global `MeterProvider` on first
call and cached for the lifetime of the process, with a
`resetUserInstruments()` helper so test setup can install a fresh
provider and force the next call to rebind.
## Features
- Three counters with stable names — they appear verbatim in every
exporter (Prometheus, OTLP, in-memory test reader).
- Lazy binding — the meter is resolved on first call, so test setup
that installs a `MeterProvider` BEFORE importing the user plugin
is guaranteed to see its counters bound against that provider.
- `outcome` attribute on `profile_updates_total` so a Grafana panel
can split success vs. error without an additional counter.
- `resetUserInstruments()` clears the cache for tests that swap the
global `MeterProvider` mid-suite.
## Usage: Basic
```ts
import { getUserInstruments } from "@suaveplan/api-plugin-user/otel/instruments";
const instruments = getUserInstruments();
instruments.profileReadsTotal.add(1);
```
## Usage: Advanced
Tests install a real in-memory MeterProvider in `polyfills.ts` BEFORE
any plugin source imports run, then `resetUserInstruments()` after
`metrics.setGlobalMeterProvider(...)`:
```ts
import { metrics } from "@opentelemetry/api";
import { InMemoryMetricExporter, MeterProvider } from "@opentelemetry/sdk-metrics";
import { resetUserInstruments } from "@suaveplan/api-plugin-user/otel/instruments";
const provider = new MeterProvider({ readers: [/* … */] });
metrics.setGlobalMeterProvider(provider);
resetUserInstruments();
```
## API Reference
### `getUserInstruments(): UserInstruments`
Returns the cached bundle, building it on first call.
### `resetUserInstruments(): void`
Clears the module cache. Idempotent.
### `UserInstruments`
`{ profileReadsTotal, profileUpdatesTotal, selfDeletesTotal }` — each
value is an OpenTelemetry `Counter`.
## Implementation Notes
- The meter name is the package name — every exporter that records the
meter scope will surface it as `@suaveplan/api-plugin-user`.
- The counters are intentionally narrow — histograms / observables
belong in a follow-up if and when observability needs them.
- The cache invalidation pattern is mirrored from the sibling auth
plugin so test setup is uniform across `api-plugin-*` packages.

View File

@@ -0,0 +1,87 @@
# plugin/plugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The plugin module exports `createUserPlugin`, the public factory for
the user plugin's `PluginDescriptor`. The factory validates the
operator-supplied options against `UserPluginOptionsSchema`,
constructs the resolved `UserPluginConfig`, and returns a descriptor
that — when the host's `PluginRegistry` invokes `onInit(ctx)` —
applies the bundled schema, registers the `userService` slot in the
ServiceRegistry, installs `requireAuth()` on the route prefix, and
mounts the three `/users/me` routes via `definePluginRoutes`.
## Features
- Zod-validated factory input — invalid options throw
`UserError(USER_INVALID_INPUT)` carrying the `ZodError`.
- Declares `dependencies: ["auth"]` so the registry topo-sorts after
`api-plugin-auth` automatically.
- Resolves the `app`, `authService`, and `db` services from the
host's `ServiceRegistry`; raises `USER_SERVICE_NOT_AVAILABLE`
listing missing slot names when any is absent.
- Applies `USER_PROFILE_SCHEMA` via the `migrationRunner` when
registered (so the migration is tracked) and falls back to a direct
`db.execute` otherwise. Bypassed entirely when
`applySchemaOnInit: false`.
- Installs the auth plugin's `requireAuth()` middleware on the route
prefix so every `/users/me` handler runs under an authenticated
context.
- Detects route-prefix collisions in the OpenAPI registry and raises
`USER_ROUTE_PREFIX_COLLISION` rather than silently double-mounting.
## Usage: Basic
```ts
import { defineApp } from "@suaveplan/api";
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
import { createUserPlugin } from "@suaveplan/api-plugin-user";
await defineApp()
.use(createAuthPlugin({ betterAuth: { /* … */ } }))
.use(createUserPlugin({}))
.start();
```
## Usage: Advanced
Custom profile fields and the disabled self-delete path:
```ts
const userPlugin = createUserPlugin({
profileFields: ["displayName", "avatarUrl", "bio", "locale"],
allowSelfDelete: false,
applySchemaOnInit: true,
});
```
The resolved descriptor exposes `defaults` so a test harness can
inspect the post-Zod config without booting the registry.
## API Reference
### `createUserPlugin(options?: UserPluginOptions): PluginDescriptor<UserPluginConfig>`
Returns the typed descriptor consumed by the host's
`PluginRegistry`. `options` is fully optional — every field has a
default in `UserPluginOptionsSchema`.
## Implementation Notes
- The `onInit` closure captures the resolved config — the registry
rebuilds the per-hook `PluginContext` each call, so capturing the
config in the closure keeps the lifecycle stateless from the
registry's perspective.
- `requireAuth()` is installed via `app.use(prefix/*, ...)` so every
route handler sees a non-null Principal; the handlers also
defensively re-check the principal in case the operator changes the
middleware ordering.
- The route-prefix collision check compares the `/me` suffix
specifically — registering siblings under the same base path (e.g.
a future `/users/admin`) does NOT trigger the collision because
the prefix-aware audit narrows to the exact route paths the plugin
registers.

View File

@@ -0,0 +1,80 @@
# routes/build-routes
## Purpose
`buildUserRoutes` returns the three `PluginRouteEntry` values that
`createUserPlugin` 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 an async handler that delegates to `UserService` and
emits the appropriate OTel counter.
## Features
- Three routes — `GET /me`, `PATCH /me`, `DELETE /me`.
- Strict-mode body schema for `PATCH /me` is per-instance, minted
from the resolved `profileFields`, so the OpenAPI doc carries the
exact writable surface configured for the running plugin.
- `DELETE /me` invokes Better-Auth's internal-adapter cascade so the
user row and every session/account associated with it disappear in
a single transaction.
- Every handler defensively re-checks the principal so the routes are
self-contained even when `requireAuth()` is bypassed at the host
level (an operator misconfiguration that should still produce a
401 rather than a 500).
## Usage: Basic
```ts
import { buildUserRoutes } from "@suaveplan/api-plugin-user/routes/routes";
import { definePluginRoutes } from "@suaveplan/api-openapi";
const entries = buildUserRoutes({
userService,
authService,
allowSelfDelete: true,
updateBodySchema: bodySchema,
});
definePluginRoutes(entries, { app, registry, pluginName: "user" });
```
## Usage: Advanced
Operators that need to disable self-delete construct the entries with
`allowSelfDelete: false`; the route is still registered (the OpenAPI
doc still advertises it) but the handler raises
`USER_SELF_DELETE_DISABLED` so callers receive a 403 with the
canonical error envelope.
```ts
const entries = buildUserRoutes({
userService,
authService,
allowSelfDelete: false,
updateBodySchema: bodySchema,
});
```
## API Reference
### `BuildUserRoutesOptions`
`{ userService, authService, allowSelfDelete, updateBodySchema }` —
all four are required.
### `buildUserRoutes(options): readonly PluginRouteEntry[]`
Returns the three-entry array in stable order: get, patch, delete.
## Implementation Notes
- The route paths are mounted relative to the plugin's `routePrefix`
(`/users` by default) — the plugin prepends the prefix before
calling `definePluginRoutes` so every entry's `route.path` becomes
`/users/me`.
- The order of entries is stable so the dependent-sweep tests can
index into the returned array without ambiguity.
- The body-schema injection is what makes the contract end-to-end:
the same schema flows into the OpenAPI doc, the validation in the
handler, and the per-instance constraint surface.

View File

@@ -0,0 +1,68 @@
# routes/delete-me
## Purpose
`buildDeleteMeRoute` returns the `PluginRouteEntry` for `DELETE /me`.
The route is gated by the plugin's `allowSelfDelete` option — when
disabled, the handler raises `USER_SELF_DELETE_DISABLED` (403) so
the OpenAPI doc still advertises the path but the runtime refuses
the call. When enabled, the handler resolves Better-Auth's internal
adapter via `auth.$context` and invokes `deleteUser(id)` — the
adapter cascades the deletion across the `account` and `session`
tables so a subsequent `requireAuth()`-gated request from the same
session returns 401.
## Features
- `allowSelfDelete: false` produces a 403 with the canonical error
envelope; the OpenAPI doc still describes the route so consumers
can detect the disabled state programmatically.
- `auth.$context` is the canonical entry point into Better-Auth's
internal adapter; using it lets the plugin avoid any HTTP-layer
re-routing.
- `user.self_deletes_total` increments only on success so a
dashboard cannot mistake a disabled-state 403 for a real delete.
## Usage: Basic
```ts
const entry = buildDeleteMeRoute({ authService, allowSelfDelete: true });
```
## Usage: Advanced
The route can be mounted on a non-default path by destructuring the
returned entry:
```ts
const { route, handler } = buildDeleteMeRoute({
authService,
allowSelfDelete: false,
});
app.openapi({ ...route, path: "/account/delete" }, handler);
```
## API Reference
### `DeleteMeResponseSchema`
`z.object({ deleted: z.literal(true) })`.
### `DeleteMeResponse`
`z.infer<typeof DeleteMeResponseSchema>` — `{ deleted: true }`.
### `buildDeleteMeRoute({ authService, allowSelfDelete }): PluginRouteEntry`
`authService` MUST be the `@suaveplan/api-plugin-auth` service slot;
`allowSelfDelete` mirrors the plugin's resolved config.
## Implementation Notes
- The handler awaits `auth.$context` once per call — Better-Auth
caches the context internally so the round-trip is cheap.
- Failures from the internal adapter wrap into
`USER_PROFILE_UPDATE_FAILED` so the registry stays cohesive
(PROFILE_UPDATE = "any write to the user row failed").
- The 401 raise on missing principals mirrors the other route
handlers so the failure mode is identical end-to-end.

View File

@@ -0,0 +1,57 @@
# routes/get-me
## Purpose
`buildGetMeRoute` returns the `PluginRouteEntry` for `GET /me`. The
handler resolves the current Principal from `c.get("user")` (set by
the auth plugin's session middleware), invokes
`userService.getCurrent(c)` to merge the Principal with the
profile-row columns, and responds with the canonical
`{ success: true, data: UserProfile }` envelope. Successful responses
emit the `user.profile_reads_total` counter.
## Features
- Single-route builder so the OpenAPI doc registration is co-located
with the handler.
- Defensive 401 raise when the principal is missing — protects the
route when `requireAuth()` is bypassed at the host layer.
- Counter emission only on success — failures are recorded by the
host's error-handler middleware via `error.code`.
## Usage: Basic
```ts
import { buildGetMeRoute } from "@suaveplan/api-plugin-user/routes/routes";
const entry = buildGetMeRoute(userService);
```
## Usage: Advanced
Composing the handler outside the build-routes loop lets operators
mount a custom variant on a different path:
```ts
const { route, handler } = buildGetMeRoute(userService);
app.openapi({ ...route, path: "/profile" }, handler);
```
## API Reference
### `buildGetMeRoute(userService: UserService): PluginRouteEntry`
Returns `{ route, handler }`. `route` is a `RouteConfig` built from
`RouteBuilder`; `handler` is an async `(c: Context) => Promise<Response>`.
## Implementation Notes
- The handler reads `userService.getCurrent` rather than `getById` so
the Principal projection (which carries roles + tenantId from the
session) survives the round-trip.
- The 401 raise carries `statusCode: 401` so the host's
error-handler middleware emits HTTP 401 with the canonical error
envelope.
- The counter increment happens AFTER the `c.json` response is
prepared but is not blocking — OpenTelemetry counters are
fire-and-forget.

View File

@@ -0,0 +1,58 @@
# routes/patch-me
## Purpose
`buildPatchMeRoute` returns the `PluginRouteEntry` for
`PATCH /me`. The handler reads the principal from
`c.get("user")`, parses the body against the per-instance,
strict-mode update schema, and delegates the write to
`userService.updateProfile`. Both the success and failure paths
emit the `user.profile_updates_total{outcome}` counter so a
dashboard can split success/error without an additional metric.
## Features
- Per-instance Zod body schema — strict-mode rejects unknown fields
before the database is touched.
- Outcome label on the counter so observability covers both halves
of the request lifecycle.
- Single failure path for missing principals (401), invalid bodies
(400), and database failures (500), each carrying the matching
`statusCode` on the error context so the host error-handler picks
the right HTTP status.
## Usage: Basic
```ts
const entry = buildPatchMeRoute(userService, bodySchema);
```
## Usage: Advanced
Operators that want to validate the body more strictly compose a
custom schema before constructing the route:
```ts
const Strict = bodySchema.extend({
locale: bodySchema.shape.locale.refine((s) => s.length <= 5),
});
const entry = buildPatchMeRoute(userService, Strict);
```
## API Reference
### `buildPatchMeRoute(userService, bodySchema): PluginRouteEntry`
`bodySchema` is the strict-mode object schema minted from the
operator's resolved `profileFields`. `userService` is the resolved
service slot from the host's `ServiceRegistry`.
## Implementation Notes
- `c.req.json()` is `.catch(() => ({}))`-guarded — a malformed
payload becomes the empty object which the strict schema then
rejects with a clear "missing required key" or "unknown key"
diagnostic, not a generic parse error.
- The outcome counter increments BEFORE the throw in the failure
path so a panicked exception still produces observable metrics.
- The handler does NOT mutate the principal — it only reads the id.

View File

@@ -0,0 +1,96 @@
# schema/schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The schema module owns the SurrealQL DDL the user plugin ships — a
small extension to Better-Auth's `user` table that adds optional
profile columns operators configure via `UserPluginOptions.profileFields`.
Every statement uses `DEFINE FIELD OVERWRITE` so re-applying the
schema across plugin restarts is idempotent — the field definitions
are replaced, existing rows are untouched.
## Features
- `DEFAULT_USER_PROFILE_FIELDS` — the canonical four-field set the
plugin ships out of the box.
- `renderUserProfileSchema(fields)` — pure function that returns the
DDL string. Validates each field against the SurrealQL identifier
grammar so the output cannot drift into a parser error.
- `USER_PROFILE_SCHEMA` — the rendered DDL for the canonical
default field set; the most common operator path.
- `applyUserProfileSchema({ profileFields, migrationRunner?, db? })`
— chooses the migration runner when present (so the apply is
recorded in the migration tracking table) and falls back to an
inline `db.execute`. Failures wrap into
`UserError(USER_SCHEMA_APPLY_FAILED)`.
## Usage: Basic
```ts
import {
USER_PROFILE_SCHEMA,
applyUserProfileSchema,
} from "@suaveplan/api-plugin-user/schema/schema";
await applyUserProfileSchema({
profileFields: ["displayName", "avatarUrl", "locale", "timezone"],
db: surrealAdapter,
});
```
## Usage: Advanced
Operators who manage migrations externally render the DDL once and
write it to disk for their own tooling to apply:
```ts
import { renderUserProfileSchema } from "@suaveplan/api-plugin-user/schema/schema";
import { writeFileSync } from "node:fs";
const ddl = renderUserProfileSchema(["displayName", "avatarUrl", "bio"]);
writeFileSync("migrations/0001_user_profile.surql", ddl);
```
The plugin's `applySchemaOnInit: false` flag disables the in-plugin
apply step when the externally-applied schema is the source of truth.
## API Reference
### `DEFAULT_USER_PROFILE_FIELDS: readonly string[]`
Frozen list of the four default field names.
### `renderUserProfileSchema(profileFields): string`
Returns the DDL string. Throws `UserError(USER_INVALID_INPUT)` when
any field name fails the SurrealQL identifier grammar or the list is
empty.
### `USER_PROFILE_SCHEMA: string`
`renderUserProfileSchema(DEFAULT_USER_PROFILE_FIELDS)` — pre-rendered.
### `ApplyUserProfileSchemaOptions`
`{ profileFields, migrationRunner?, db? }`.
### `applyUserProfileSchema(options): Promise<void>`
Applies the DDL via the migration runner (preferred) or the adapter
(fallback). Throws `USER_SCHEMA_APPLY_FAILED` on runtime failure,
`USER_SERVICE_NOT_AVAILABLE` when neither transport is supplied.
## Implementation Notes
- The `DEFINE FIELD OVERWRITE` form is the SurrealDB idiomatic
idempotent definition — `DEFINE FIELD ... IF NOT EXISTS` does not
exist in SurrealDB 2.x.
- The renderer is intentionally pure so operators can diff DDL across
plugin upgrades by comparing strings.
- The migration name (`0001-user-profile-schema`) lives in the
migration-tracking table so re-applies become no-ops once the apply
is recorded.

View File

@@ -0,0 +1,77 @@
# schemas/options
## Purpose
`UserPluginOptionsSchema` is the Zod boundary contract for the
operator-supplied options accepted by `createUserPlugin`. It enforces
defaults, validates the route prefix shape, gates profile-field names
through the SurrealQL identifier grammar so they can be interpolated
into DDL without quoting, and structurally validates the optional
logger override. Invalid input causes the factory to throw
`UserError(USER_INVALID_INPUT)` carrying the `ZodError` as `cause`.
## Features
- Defaults `routePrefix` to `/users`, `allowSelfDelete` to `true`,
`applySchemaOnInit` to `true`, and `profileFields` to
`["displayName", "avatarUrl", "locale", "timezone"]`.
- `routePrefix` is regex-checked: starts with `/`, ends without a
trailing `/` (root `/` is the single allowed exception).
- `profileFields` is regex-checked against `[A-Za-z_][A-Za-z0-9_]*`
so the schema-apply step can drop each name straight into DDL.
- `logger` is structurally validated via `z.custom` so operators can
pass any object that satisfies `LoggerLike` without nominal typing.
- Two type aliases — `UserPluginOptionsInput` (pre-parse) and
`UserPluginOptionsResolved` (post-parse) — are exported so callers
can pick the variant that matches their lifecycle stage.
## Usage: Basic
```ts
import { UserPluginOptionsSchema } from "@suaveplan/api-plugin-user/schemas/options";
const resolved = UserPluginOptionsSchema.parse({});
// resolved.routePrefix === "/users"
```
## Usage: Advanced
When wiring the plugin into a custom factory, validate the operator
input separately so the error surface is explicit:
```ts
import { UserPluginOptionsSchema } from "@suaveplan/api-plugin-user/schemas/options";
const parsed = UserPluginOptionsSchema.safeParse(input);
if (!parsed.success) {
return Result.err({ code: "USER.INVALID_INPUT", issues: parsed.error });
}
const resolved = parsed.data;
```
## API Reference
### `UserPluginOptionsSchema: z.ZodObject`
Schema with five entries — `routePrefix`, `allowSelfDelete`,
`profileFields`, `applySchemaOnInit`, and `logger`. Defaults are
applied during parse.
### `UserPluginOptionsInput`
`z.input` of the schema — the pre-parse shape, every field optional.
### `UserPluginOptionsResolved`
`z.output` of the schema — defaults applied. The plugin's `defaults`
field on the descriptor types to this shape.
## Implementation Notes
- The `profileFields` regex deliberately rejects digits-at-position-0
and dashes; SurrealQL identifiers that contain those characters
would need backtick-quoting which the bundled schema does not emit.
- `.readonly()` on `profileFields` flows through to the runtime
config so the array is shared without defensive copies.
- The `logger` predicate only checks `info`; the other three methods
are inferred from the `LoggerLike` cast for downstream consumers.

View File

@@ -0,0 +1,110 @@
# schemas/user-profile
## Purpose
`user-profile` is the boundary Zod surface for the user plugin: the
response shape for `GET /users/me`, the response shape for `PATCH
/users/me`, and the body shape for `PATCH /users/me`. The schema is
built by `.extend()`-ing `PrincipalSchema` from the sibling
`@suaveplan/api-plugin-auth/schemas/principal` so the identity fields
(`id`, `email`, `emailVerified`, `roles`, `tenantId`, `metadata`)
remain in lockstep with the auth plugin — there is one canonical
Principal type, never two.
## Features
- `UserProfileSchema` extends `PrincipalSchema` with four optional
profile fields: `displayName`, `avatarUrl`, `locale`, `timezone`.
- `UserProfileResponseSchema` is the alias used by route handlers
when registering the response payload — separate name keeps the
registry self-documenting even though the schemas are identical.
- `UpdateUserProfileBodySchema` is built from
`UserProfileSchema.pick(profileFields).partial().strict()` so the
PATCH body is restricted to writable columns, every column is
optional, and unknown keys are hard-rejected.
- `buildUpdateBodySchemaFor(profileFields)` mints a strict-mode body
schema from an arbitrary operator-supplied profile-fields list —
the plugin rebuilds the body schema once during `onInit` so the
strict mask matches the configured columns exactly.
## Usage: Basic
```ts
import {
UpdateUserProfileBodySchema,
UserProfileSchema,
} from "@suaveplan/api-plugin-user/schemas/user-profile";
const body = UpdateUserProfileBodySchema.parse({ displayName: "Alice" });
const profile = UserProfileSchema.parse(rowFromDb);
```
## Usage: Advanced
Operators that ship custom `profileFields` extend the base schema and
mint the matching strict body schema explicitly:
```ts
import {
buildUpdateBodySchemaFor,
UserProfileSchema,
} from "@suaveplan/api-plugin-user/schemas/user-profile";
import z from "zod";
const ExtendedProfile = UserProfileSchema.extend({
bio: z.string().max(280).optional(),
});
const PatchBody = buildUpdateBodySchemaFor([
"displayName",
"avatarUrl",
"locale",
"timezone",
]);
```
## API Reference
### `UserProfileSchema`
Zod object that extends `PrincipalSchema` with four optional fields.
### `UserProfileSchemaType`
`z.infer<typeof UserProfileSchema>`.
### `UserProfileResponseSchema`
Alias for `UserProfileSchema` — used by route registrations.
### `UserProfileResponse`
`z.infer<typeof UserProfileResponseSchema>`.
### `UpdateUserProfileBodySchema`
Strict, partial, picked-down view of `UserProfileSchema` over the four
canonical profile fields.
### `UpdateUserProfileBody`
`z.infer<typeof UpdateUserProfileBodySchema>`.
### `buildUpdateBodySchemaFor(profileFields): z.ZodObject<z.ZodRawShape>`
Builds a strict body schema that intersects the supplied
`profileFields` list with the known profile columns. Caller-supplied
names not on the base schema are silently dropped — operators with
extended fields are responsible for composing their own schema.
## Implementation Notes
- The extension uses `.extend()` rather than a fresh `z.object` so the
PrincipalSchema evolution surface (e.g. future Principal extensions)
flows into `UserProfileSchema` automatically.
- `.strict()` on the body schema is the only thing rejecting
field-spoofing attempts — strip it and an attacker can write to
Principal fields the route never intended to expose. Tests pin this.
- The default-build of `UpdateUserProfileBodySchema` is the most
common operator path; `buildUpdateBodySchemaFor` only needs to fire
when the operator narrows or extends the writable surface.

View File

@@ -0,0 +1,70 @@
# services/registry-augmentation
## Purpose
This module is a TypeScript `declare module` augmentation. It adds the
`userService` slot to the canonical `ServiceRegistry` interface owned
by `@suaveplan/api-plugin/service-registry` so downstream plugins that
import `@suaveplan/api-plugin-user` for its side effects see
`ctx.getService("userService")` resolve to `UserService | undefined`
without a cast.
## Features
- One side-effect import of this file (which the package root barrel
performs automatically) gives every consumer typed access to the
`userService` slot.
- Mirrors the auth plugin's registry-augmentation pattern so a future
project-wide audit can grep every plugin's registry slot from a
single regex.
- Declaration merging means the augmentation composes safely with
every other plugin's registry-augmentation file — the host's
`ServiceRegistry` is the union of all loaded plugins' slots.
## Usage: Basic
```ts
import "@suaveplan/api-plugin-user/services/user-service";
// `ctx.getService("userService")` now types as `UserService | undefined`.
```
The package root barrel already imports this file for its side
effects — explicit import is only required when the consumer pulls
the user-service file directly via the subpath export.
## Usage: Advanced
Downstream plugins that depend on the user service can resolve it
inside their own `onInit`:
```ts
const userService = ctx.getService("userService");
if (!userService) {
throw new PluginError(PLUGIN_CODES.SERVICE_NOT_AVAILABLE, "userService");
}
const profile = await userService.getById(principal.id);
```
## API Reference
The module has no runtime exports — it is a pure type-level
augmentation. The augmentation adds:
```ts
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
userService: UserService;
}
}
```
## Implementation Notes
- The augmentation is intentionally minimal — host-side slots
(`app`, `openAPIRegistry`, `migrationRunner`, `db`) are declared by
the auth plugin's augmentation and merged in by TypeScript.
- The side-effect import in `src/index.ts` is what makes the
augmentation visible to consumers; removing it would silently
weaken typing.
- This file MUST NOT carry runtime code — Vite would emit a chunk
with no exports otherwise.

View File

@@ -0,0 +1,86 @@
# services/user-service
## Purpose
The user-service module owns the `UserService` interface — the
`ServiceRegistry`-resolved API downstream `@suaveplan/api-plugin-*`
packages call when they need a user-profile lookup or an in-line
patch. The service queries SurrealDB directly via the
`SurrealAdapter` injected at construction time, so resolution does
not require an in-flight Better-Auth session.
## Features
- `getById(id)` returns the merged Principal + profile fields, or
`null` when the row is missing — anonymous-friendly.
- `requireById(id)` is the throwing variant — raises
`UserError(USER_NOT_FOUND)` on miss so caller code can keep its
happy-path branch single-line.
- `getCurrent(c)` reads the request's `c.get("user")` Principal (set
by the auth plugin's session middleware) and overlays the database
row's profile columns. Returns `null` for anonymous requests.
- `updateProfile(id, patch)` filters the patch down to the
operator-configured `profileFields` and applies it via
`UPDATE ... MERGE`. Empty patches skip the database write — a real
no-op never reaches the wire.
- Both record-id forms (`abc` and `user:abc`) round-trip identically
via a `type::thing('user', $tail)` query.
## Usage: Basic
```ts
const userService = ctx.getService("userService");
const profile = await userService.getById("u1");
```
## Usage: Advanced
A downstream vault plugin scopes its writes to the principal id by
combining `getCurrent` with its own service:
```ts
app.use("/vault/*", requireAuth());
app.get("/vault/items", async (c) => {
const profile = await userService.getCurrent(c);
if (profile === null) {
throw new UserError(USER_CODES.NOT_FOUND, "no principal");
}
return c.json({ items: await vaultService.listFor(profile.id) });
});
```
## API Reference
### `UserService`
```ts
interface UserService {
getById(id: string): Promise<UserProfile | null>;
requireById(id: string): Promise<UserProfile>;
getCurrent(c: Context): Promise<UserProfile | null>;
updateProfile(
id: string,
patch: Readonly<Record<string, unknown>>
): Promise<UserProfile>;
}
```
### `CreateUserServiceOptions`
`{ db: SurrealAdapter, profileFields: readonly string[],
getPrincipal: (c: Context) => Principal | null }`.
### `createUserService(options): UserService`
Constructs the canonical implementation.
## Implementation Notes
- The service never reads from Better-Auth's API — only the database
adapter — so it can be used in worker contexts that hold the
adapter but not a live `Auth` instance.
- `updateProfile` filters out `undefined` patch values so a caller
passing `{ displayName: undefined }` does not clear the column.
- Non-string row `id` raises `USER_PROFILE_UPDATE_FAILED` rather than
`USER_NOT_FOUND` — the row exists, it just violates the projection
contract.

View File

@@ -0,0 +1,86 @@
# types/types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module declares the three TypeScript types that flow across the
public surface of `@suaveplan/api-plugin-user`: `UserProfile` (the
projection of a row in Better-Auth's `user` table merged with the
profile fields the plugin owns), `UserPluginOptions` (the
operator-supplied factory inputs), and `UserPluginConfig` (the
post-Zod-validation, defaults-applied runtime config). Splitting input
from resolved config makes the defaulting semantics explicit and
matches the convention used by the sibling auth plugin.
## Features
- `UserProfile` is the wire shape and the service-return shape — every
route handler responds with this exact projection so consumers can
reason about one type end-to-end.
- `UserPluginOptions` keeps every field optional so the factory call
is `createUserPlugin({})` for the canonical happy path.
- `UserPluginConfig` is the resolved shape after the boundary Zod
parse fills defaults; the `onInit` closure captures this value so it
is constant across the plugin lifetime.
- All four `Principal` projection fields appear on `UserProfile` so
the `GET /users/me` handler can return the merged shape without an
extra type-narrowing step.
## Usage: Basic
```ts
import type { UserPluginOptions } from "@suaveplan/api-plugin-user/types/types";
const opts: UserPluginOptions = { allowSelfDelete: true };
```
## Usage: Advanced
The `profileFields` array is reused by the schema-apply step, the
boundary Zod schemas, and the service layer:
```ts
import { createUserPlugin } from "@suaveplan/api-plugin-user";
const plugin = createUserPlugin({
profileFields: ["displayName", "avatarUrl", "bio", "locale"],
allowSelfDelete: false,
});
```
The resolved `UserPluginConfig` is what the plugin's closure carries
between `onInit` and route handlers — it never leaks out of the
plugin's internals.
## API Reference
### `UserProfile`
Combined projection of the Better-Auth `user` row plus the
profile-field columns. Carries `id`, `email?`, `emailVerified`,
`roles`, `tenantId?`, `displayName?`, `avatarUrl?`, `locale?`,
`timezone?`, `metadata?`.
### `UserPluginOptions`
Operator-supplied factory inputs. Every field is optional; defaults
are applied by `UserPluginOptionsSchema`.
### `UserPluginConfig`
The post-validation resolved config. `routePrefix`, `allowSelfDelete`,
`profileFields`, and `applySchemaOnInit` are required; `logger`
remains optional.
## Implementation Notes
- The interfaces are deliberately structural — operators with custom
Principal extensions can declare their own type that extends
`UserProfile` and pass it through `UserService<TProfile>`.
- `profileFields` is `readonly string[]` so the array can be reused
across requests without defensive copying.
- The split between options and config follows the auth-plugin
precedent: input is permissive, config is exact.