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.
|
||||
|
||||
Reference in New Issue
Block a user