6.4 KiB
Add @suaveplan/api-plugin-user — user CRUD on top of api-plugin-auth (3-way plug-and-play)
Package: @suaveplan/api-plugin-user (NEW, server-tier)
Status: PROPOSED
Type: new-package
Position: depends on add-api-plugin-auth
- Spec delta: ./specs/api-plugin-user/spec.md
Why
@suaveplan/api-plugin-auth (sibling) owns identity: it ships the user table (via the bundled Better-Auth schema), the Principal type, the session resolver, and the account-lifecycle routes (sign-up / sign-in / oauth). What it deliberately does NOT ship is user-profile CRUD for the authenticated principal — operators inevitably need /users/me for profile reads and updates and DELETE /users/me for account-erasure flows.
This package adds exactly that — three routes plus a userService for downstream plugins to look up users by id. It is the second instance of the 3-way plug-and-play contract: its .surql schema extends Better-Auth's user table with profile fields, its Zod schemas validate every body, and its OpenAPI route definitions register through the same definePluginRoutes path the host serves at /openapi.json.
What changes
Net-new package at 0.1.0. ADDED:
Plugin factory + descriptor
createUserPlugin(options): PluginDescriptor<UserPluginConfig>—definePluginconsumer.optionsZod-validated;dependencies: ["auth"]so the registry topo-sorts afterapi-plugin-auth.UserPluginOptions(Zod-validated):{ routePrefix?: string (default "/users"); allowSelfDelete?: boolean (default true); profileFields?: readonly string[] (default ["displayName", "avatarUrl", "locale", "timezone"]); applySchemaOnInit?: boolean (default true); logger?: LoggerLike }.
Routes (3)
All built with RouteBuilder from @suaveplan/api-openapi; all gated by requireAuth() from @suaveplan/api-plugin-auth:
GET /users/me— response:UserProfileResponseSchema(Principal + profile fields)PATCH /users/me— body:UpdateUserProfileBodySchema(subset of profile fields); response:UserProfileResponseSchemaDELETE /users/me— empty body; response:{ deleted: true }. Disabled whenallowSelfDelete: false→ 403USER_SELF_DELETE_DISABLED. When enabled, invokes Better-Auth's user-delete API viaauthService.getBetterAuthInstance()so all sessions/accounts cascade.
userService
userServiceregistered viactx.registerService("userService", ...). Surface:{ getById(id: string): Promise<UserProfile | null>; getCurrent(c: Context): UserProfile | null; updateProfile(id: string, patch: Partial<UserProfile>): Promise<UserProfile> }.- ServiceRegistry module augmentation:
declare module "@suaveplan/api-plugin/service-registry" { interface ServiceRegistry { userService: UserService } }.
.surql schema (plug-and-play point 1)
./schemaexportsUSER_PROFILE_SCHEMA(frozen string) — DDL extending Better-Auth'susertable with the configuredprofileFields(string typed, optional). DDL usesDEFINE FIELD OVERWRITEfor idempotency. Genesis-original (no upstream port — Better-Auth's schema covers identity, not profile).applyUserProfileSchema(adapter, profileFields): Promise<void>convenience helper.onInitapplies viamigrationRunnerwhen registered (preferred) ordbfallback.
Zod schemas (plug-and-play point 2) — exposed at ./schemas/*
./schemas/user-profile—UserProfileSchema,UserProfileResponseSchema,UpdateUserProfileBodySchema(Zod).UserProfileextendsPrincipal(re-imported from@suaveplan/api-plugin-auth/schemas/principal) withdisplayName?,avatarUrl?,locale?,timezone?. Operators with customprofileFieldsextend viaUserProfileSchema.extend({...})../schemas/options—UserPluginOptionsSchema.
Errors + observability
UserError extends SuaveplanError<UserErrorCode>viadefineCodes("USER", [...]). Codes:USER_NOT_FOUND,USER_INVALID_INPUT,USER_SELF_DELETE_DISABLED,USER_PROFILE_UPDATE_FAILED,USER_SERVICE_NOT_AVAILABLE,USER_SCHEMA_APPLY_FAILED,USER_ROUTE_PREFIX_COLLISION.- OTel: counter
user.profile_reads_total, counteruser.profile_updates_total{outcome}, counteruser.self_deletes_total.
Subpath exports
., ./plugin/plugin, ./services/user-service, ./schemas/user-profile, ./schemas/options, ./routes/routes, ./schema/schema, ./error-codes/error-codes, ./otel/instruments, ./types/types.
Out of scope
- Admin-side user listing / search / role-edit endpoints. Belong in a separate
api-plugin-adminfollow-up. - Avatar upload + resizing. The plugin stores
avatarUrlas a string; resize/upload pipelines live in a separateapi-plugin-mediafollow-up. - GDPR export. Belongs in
api-plugin-gdprfollow-up (DELETE /users/metriggers Better-Auth's user-delete cascade; the export side is a separate flow). - Organisations / team membership CRUD. Better-Auth ships an
organizationplugin; surfacing those routes isapi-plugin-organizations(follow-up). - Custom-shape profile fields beyond the operator-supplied flat-string list. Operators who need nested objects or relations augment
UserProfileSchemain their own plugin layer.
Source mapping
This package is genesis-original. Design intent draws from:
- The 3-way plug-and-play contract established by
add-api-plugin-auth(sibling). @suaveplan/api-plugin-auth/schemas/principalre-export pattern.- The legacy
enterprise-api-plugin-user.mdproposal's route surface (verbatim:/users/meGET/PATCH/DELETE).
| Requirement | Source |
|---|---|
definePlugin consumer with dependencies: ["auth"] |
@suaveplan/api-plugin 4-phase lifecycle |
requireAuth() middleware |
@suaveplan/api-plugin-auth (sibling) |
RouteBuilder end-to-end Zod inference |
@suaveplan/api-openapi shipped 99a213ed |
userService ServiceRegistry slot pattern |
api-plugin-auth's authService pattern (verbatim) |
Better-Auth user-delete cascade on DELETE /users/me |
Better-Auth auth.api.removeUser (or equivalent) |
Schema extension via DEFINE FIELD OVERWRITE |
genesis target schema convention (/mnt/f/surrealdb/surql/) |
Impact
Affected dependents
None today — net-new. Will be consumed by add-api-plugin-vault (vaults belong to a user — the vault plugin reads the principal's id from authService.getCurrentPrincipal(c) and writes vault items scoped to it).
Catalog additions
None — every dep already in catalogs from sibling packages.