Files
2026-05-27 23:29:53 +01:00

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> — definePlugin consumer. options Zod-validated; dependencies: ["auth"] so the registry topo-sorts after api-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: UserProfileResponseSchema
  • DELETE /users/me — empty body; response: { deleted: true }. Disabled when allowSelfDelete: false → 403 USER_SELF_DELETE_DISABLED. When enabled, invokes Better-Auth's user-delete API via authService.getBetterAuthInstance() so all sessions/accounts cascade.

userService

  • userService registered via ctx.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)

  • ./schema exports USER_PROFILE_SCHEMA (frozen string) — DDL extending Better-Auth's user table with the configured profileFields (string typed, optional). DDL uses DEFINE FIELD OVERWRITE for idempotency. Genesis-original (no upstream port — Better-Auth's schema covers identity, not profile).
  • applyUserProfileSchema(adapter, profileFields): Promise<void> convenience helper. onInit applies via migrationRunner when registered (preferred) or db fallback.

Zod schemas (plug-and-play point 2) — exposed at ./schemas/*

  • ./schemas/user-profile — UserProfileSchema, UserProfileResponseSchema, UpdateUserProfileBodySchema (Zod). UserProfile extends Principal (re-imported from @suaveplan/api-plugin-auth/schemas/principal) with displayName?, avatarUrl?, locale?, timezone?. Operators with custom profileFields extend via UserProfileSchema.extend({...}).
  • ./schemas/options — UserPluginOptionsSchema.

Errors + observability

  • UserError extends SuaveplanError<UserErrorCode> via defineCodes("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, counter user.profile_updates_total{outcome}, counter user.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-admin follow-up.
  • Avatar upload + resizing. The plugin stores avatarUrl as a string; resize/upload pipelines live in a separate api-plugin-media follow-up.
  • GDPR export. Belongs in api-plugin-gdpr follow-up (DELETE /users/me triggers Better-Auth's user-delete cascade; the export side is a separate flow).
  • Organisations / team membership CRUD. Better-Auth ships an organization plugin; surfacing those routes is api-plugin-organizations (follow-up).
  • Custom-shape profile fields beyond the operator-supplied flat-string list. Operators who need nested objects or relations augment UserProfileSchema in 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/principal re-export pattern.
  • The legacy enterprise-api-plugin-user.md proposal's route surface (verbatim: /users/me GET/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.