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.