Files
skills/plugins/document-navigator/docs/api-stack/api/api-plugin-user/README.md
2026-06-09 21:06:27 +01:00

11 KiB

@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 — The error-codes module defines the stable registry of programmatic failure codes raised by @suaveplan/api-plugin-user…
    • error-codes — The error-codes module defines the stable registry of programmatic failure codes raised by @suaveplan/api-plugin-user…
  • otel — The instruments module owns the three OpenTelemetry counters the user plugin emits — user.profile_reads_total, `user.…
    • instruments — The instruments module owns the three OpenTelemetry counters the user plugin emits — user.profile_reads_total, `user.…
  • plugin — The plugin module exports createUserPlugin, the public factory for the user plugin's PluginDescriptor. The factory…
    • plugin — The plugin module exports createUserPlugin, the public factory for the user plugin's PluginDescriptor. The factory…
  • build-routes — buildUserRoutes returns the three PluginRouteEntry values that createUserPlugin mounts on the host's Hono app via…
  • delete-me — buildDeleteMeRoute returns the PluginRouteEntry for DELETE /me. The route is gated by the plugin's `allowSelfDele…
  • get-me — buildGetMeRoute returns the PluginRouteEntry for GET /me. The handler resolves the current Principal from `c.get(…
  • patch-me — buildPatchMeRoute returns the PluginRouteEntry for PATCH /me. The handler reads the principal from `c.get("user")…
  • schema — The schema module owns the SurrealQL DDL the user plugin ships — a small extension to Better-Auth's user table that a…
    • schema — The schema module owns the SurrealQL DDL the user plugin ships — a small extension to Better-Auth's user table that a…
  • options — UserPluginOptionsSchema is the Zod boundary contract for the operator-supplied options accepted by createUserPlugin…
  • user-profile — user-profile is the boundary Zod surface for the user plugin: the response shape for GET /users/me, the response sh…
  • registry-augmentation — This module is a TypeScript declare module augmentation. It adds the userService slot to the canonical `ServiceRegi…
  • user-service — The user-service module owns the UserService interface — the ServiceRegistry-resolved API downstream `@suaveplan/ap…
  • types — This module declares the three TypeScript types that flow across the public surface of @suaveplan/api-plugin-user: `U…
    • types — This module declares the three TypeScript types that flow across the public surface of @suaveplan/api-plugin-user: `U…

Install

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

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

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

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

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

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

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.