first commit
This commit is contained in:
203
docs/api-stack/api/api-plugin-user/README.md
Normal file
203
docs/api-stack/api/api-plugin-user/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
76
docs/api-stack/api/api-plugin-user/src/otel/instruments.md
Normal file
76
docs/api-stack/api/api-plugin-user/src/otel/instruments.md
Normal 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.
|
||||
87
docs/api-stack/api/api-plugin-user/src/plugin/plugin.md
Normal file
87
docs/api-stack/api/api-plugin-user/src/plugin/plugin.md
Normal 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.
|
||||
@@ -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.
|
||||
68
docs/api-stack/api/api-plugin-user/src/routes/delete-me.md
Normal file
68
docs/api-stack/api/api-plugin-user/src/routes/delete-me.md
Normal 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.
|
||||
57
docs/api-stack/api/api-plugin-user/src/routes/get-me.md
Normal file
57
docs/api-stack/api/api-plugin-user/src/routes/get-me.md
Normal 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.
|
||||
58
docs/api-stack/api/api-plugin-user/src/routes/patch-me.md
Normal file
58
docs/api-stack/api/api-plugin-user/src/routes/patch-me.md
Normal 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.
|
||||
96
docs/api-stack/api/api-plugin-user/src/schema/schema.md
Normal file
96
docs/api-stack/api/api-plugin-user/src/schema/schema.md
Normal 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.
|
||||
77
docs/api-stack/api/api-plugin-user/src/schemas/options.md
Normal file
77
docs/api-stack/api/api-plugin-user/src/schemas/options.md
Normal 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.
|
||||
110
docs/api-stack/api/api-plugin-user/src/schemas/user-profile.md
Normal file
110
docs/api-stack/api/api-plugin-user/src/schemas/user-profile.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
86
docs/api-stack/api/api-plugin-user/src/types/types.md
Normal file
86
docs/api-stack/api/api-plugin-user/src/types/types.md
Normal 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.
|
||||
Reference in New Issue
Block a user