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…
- error-codes — The error-codes module defines the stable registry of programmatic failure codes raised by
- 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.…
- instruments — The instruments module owns the three OpenTelemetry counters the user plugin emits —
- plugin — The plugin module exports
createUserPlugin, the public factory for the user plugin'sPluginDescriptor. The factory…- plugin — The plugin module exports
createUserPlugin, the public factory for the user plugin'sPluginDescriptor. The factory…
- plugin — The plugin module exports
- build-routes —
buildUserRoutesreturns the threePluginRouteEntryvalues thatcreateUserPluginmounts on the host's Hono app via… - delete-me —
buildDeleteMeRoutereturns thePluginRouteEntryforDELETE /me. The route is gated by the plugin's `allowSelfDele… - get-me —
buildGetMeRoutereturns thePluginRouteEntryforGET /me. The handler resolves the current Principal from `c.get(… - patch-me —
buildPatchMeRoutereturns thePluginRouteEntryforPATCH /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
usertable that a…- schema — The schema module owns the SurrealQL DDL the user plugin ships — a small extension to Better-Auth's
usertable that a…
- schema — The schema module owns the SurrealQL DDL the user plugin ships — a small extension to Better-Auth's
- options —
UserPluginOptionsSchemais the Zod boundary contract for the operator-supplied options accepted bycreateUserPlugin… - user-profile —
user-profileis the boundary Zod surface for the user plugin: the response shape forGET /users/me, the response sh… - registry-augmentation — This module is a TypeScript
declare moduleaugmentation. It adds theuserServiceslot to the canonical `ServiceRegi… - user-service — The user-service module owns the
UserServiceinterface — theServiceRegistry-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…
- types — This module declares the three TypeScript types that flow across the public surface of
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:
- Resolves
app,authService,db, and (optional)openAPIRegistry/migrationRunnerfrom the hostServiceRegistry. - Applies
USER_PROFILE_SCHEMA—DEFINE FIELD OVERWRITEstatements that extend the Better-Authusertable with the configured profile columns. Uses the migration runner when registered, otherwise falls back todb.execute. - Builds a per-instance strict-mode
UpdateUserProfileBodySchemafrom the resolvedprofileFields. - Constructs the canonical
UserServiceand registers it asuserServicein the typedServiceRegistry. - Installs
requireAuth()from the auth plugin on the route prefix so every/users/mehandler runs under an authenticated context. - Registers the three routes (
GET,PATCH,DELETEof/me) viadefinePluginRoutes. They appear in the unified/openapi.jsondoc 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
error-codes/error-codes— error registryotel/instruments— countersplugin/plugin— factoryroutes/build-routes— route entriesroutes/get-me—GET /users/meroutes/patch-me—PATCH /users/meroutes/delete-me—DELETE /users/meschema/schema— DDL renderer + apply helperschemas/options—UserPluginOptionsSchemaschemas/user-profile— boundary Zod schemasservices/user-service—UserServiceimplservices/registry-augmentation— typeduserServiceslottypes/types— public types
OpenTelemetry
The plugin emits three counters:
user.profile_reads_total— every successfulGET /users/meuser.profile_updates_total{outcome}— everyPATCH /users/meuser.self_deletes_total— every successfulDELETE /users/me
Counters bind lazily to the global MeterProvider; tests
re-bind by calling resetUserInstruments() after swapping the
provider.