# @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`. ## 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… ## 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 ON user TYPE option` 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.