8.9 KiB
Capability Spec Delta: api-plugin-user (add-api-plugin-user)
Package: @suaveplan/api-plugin-user
Status: PROPOSED
ADDED Requirements
Requirement: createUserPlugin returns a Zod-validated descriptor with auth dependency
The package SHALL export createUserPlugin(options): PluginDescriptor<UserPluginConfig> that Zod-validates options via UserPluginOptionsSchema. The descriptor SHALL declare name: "user" and dependencies: ["auth"] so the registry topo-sorts after api-plugin-auth. Invalid options raise UserError(USER_INVALID_INPUT) with cause instanceof ZodError.
Scenario: descriptor declares auth dependency
- WHEN
createUserPlugin({})is called - THEN the returned descriptor has
dependenciescontaining"auth"
Scenario: invalid options raise USER_INVALID_INPUT
- WHEN
createUserPlugin({ allowSelfDelete: "yes" as never })is called - THEN
UserError(USER_INVALID_INPUT)is thrown with ZodError as cause
Verified by: src/plugin/plugin.test.ts::"descriptor declares auth dependency" AND src/plugin/plugin.test.ts::"invalid options raise USER_INVALID_INPUT"
Requirement: onInit applies USER_PROFILE_SCHEMA (plug-and-play point 1)
onInit(ctx) SHALL resolve migrationRunner (preferred) or db (fallback) from ServiceRegistry. When applySchemaOnInit === true (default), it SHALL render USER_PROFILE_SCHEMA from the configured profileFields and apply it. Missing services raise UserError(USER_SERVICE_NOT_AVAILABLE) listing missing names.
Scenario: schema extends user table with profile fields
- WHEN
createUserPlugin({ profileFields: ["bio", "locale"] })initializes against a real SurrealDB testcontainer with the auth schema applied first - THEN the
usertable now hasbioandlocaleasoption<string>fields AND existing rows are unchanged
Scenario: applySchemaOnInit: false skips the step
- WHEN
createUserPlugin({ applySchemaOnInit: false })initializes - THEN the schema apply step is skipped (operator manages externally)
Verified by: src/plugin/on-init.test.ts::"applies USER_PROFILE_SCHEMA via migrationRunner" AND src/plugin/on-init.test.ts::"profileFields override extends specified columns" AND src/plugin/on-init.test.ts::"applySchemaOnInit: false skips apply"
Requirement: 3 routes registered with RouteBuilder + Zod end-to-end (plug-and-play points 2 + 3)
onInit SHALL call definePluginRoutes(buildUserRoutes(authService), { app, registry, pluginName: "user" }) registering: GET /users/me, PATCH /users/me, DELETE /users/me. Each route SHALL use requireAuth() middleware upstream. Each request body / response SHALL be a Zod-typed schema from ./schemas/user-profile. DELETE /users/me SHALL invoke authService.getBetterAuthInstance().api.removeUser({ userId }) for cascade.
Scenario: GET /users/me returns Principal + profile fields
- WHEN an authenticated request hits
GET /users/me - THEN the response is
UserProfileResponseSchema-shaped:{ id, email, emailVerified, roles, displayName?, avatarUrl?, locale?, timezone? }
Scenario: PATCH /users/me rejects unknown fields
- WHEN an authenticated request PATCHes
{ displayName: "X", evil: "Y" } - THEN the response is 400 from
UpdateUserProfileBodySchemaZod validation (strict mode); the user row is unchanged
Scenario: DELETE /users/me cascades via Better-Auth
- WHEN
allowSelfDelete: true(default) and an authenticated DELETE hits/users/me - THEN
authService.getBetterAuthInstance().api.removeUser({ userId: principal.id })is invoked; the response is{ deleted: true }; subsequentfindOne({ model: "user", where: [{ field: "id", value: principal.id }] })returns null AND everysession/accountfor that user is gone too
Scenario: DELETE /users/me disabled raises USER_SELF_DELETE_DISABLED
- WHEN
allowSelfDelete: falseand an authenticated DELETE hits/users/me - THEN the response is 403 with
UserError(USER_SELF_DELETE_DISABLED)
Verified by: src/routes/get-me.test.ts::"returns Principal + profile fields" AND src/routes/patch-me.test.ts::"rejects unknown fields" AND src/routes/delete-me.test.ts::"cascades via Better-Auth" AND src/routes/delete-me.test.ts::"disabled raises USER_SELF_DELETE_DISABLED" AND tests/integration/openapi-doc.test.ts::"served /openapi.json includes /users/me routes with Zod-typed schemas"
Requirement: userService registered with typed slot
onInit SHALL register ctx.registerService("userService", { getById, getCurrent, updateProfile }). ServiceRegistry module augmentation adds userService: UserService so downstream plugins resolve it with full type inference.
Scenario: getById returns null on missing user
- WHEN
userService.getById("missing-id")is awaited - THEN the result is
null
Scenario: getCurrent reads c.get("user")
- WHEN
userService.getCurrent(c)runs on an authenticated request - THEN the result equals the merge of
c.get("user")+ the row's profile fields
Verified by: src/services/user-service.test.ts::"getById returns null on missing" AND src/services/user-service.test.ts::"getCurrent reads c.get user" AND src/services/user-service.spec-conformance.test.ts::"ServiceRegistry slot typed as UserService"
Requirement: UserProfileSchema extends PrincipalSchema (plug-and-play point 2)
The package SHALL export UserProfileSchema (Zod) at ./schemas/user-profile that extends PrincipalSchema re-imported from @suaveplan/api-plugin-auth/schemas/principal with optional profile fields displayName, avatarUrl, locale, timezone. UpdateUserProfileBodySchema SHALL be UserProfileSchema.pick(profileFields).partial().strict() — operator-controlled subset, strict-mode rejects unknown keys.
Scenario: UserProfileSchema imports PrincipalSchema from sibling
- WHEN the conformance test grep's
src/schemas/user-profile.tsfor an import from@suaveplan/api-plugin-auth/schemas/principal - THEN the import exists (no duplicated Principal type)
Scenario: UpdateUserProfileBodySchema rejects extra fields
- WHEN
UpdateUserProfileBodySchema.safeParse({ displayName: "X", evil: "Y" })is called - THEN
success === false(strict-mode failure onevil)
Verified by: src/schemas/user-profile.test.ts::"extends PrincipalSchema from sibling" AND src/schemas/user-profile.test.ts::"UpdateUserProfileBodySchema is strict" AND src/schemas/user-profile.spec-conformance.test.ts::"no duplicated Principal definition"
Requirement: Every USER_* code is thrown from a production path
USER_CODES SHALL define exactly 7 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). Every code SHALL be raised by ≥1 production call site.
Verified by: src/api-plugin-user.spec-conformance.test.ts::"every USER_* code is thrown from a production path"
Requirement: OpenTelemetry instruments emit per route
The package SHALL register counters user.profile_reads_total, user.profile_updates_total{outcome}, user.self_deletes_total. Each route handler emits the appropriate counter on completion.
Scenario: profile_reads_total emits per GET /users/me
- WHEN
GET /users/mesucceeds against a real in-memoryMeterProvider - THEN
user.profile_reads_totalhas 1 observation
Verified by: src/otel/instruments.test.ts::"profile_reads_total emits" AND src/otel/instruments.test.ts::"profile_updates_total emits success+error" AND src/otel/instruments.test.ts::"self_deletes_total emits"
Requirement: Server-tier package shape + full integration test
Package SHALL run in Bun + Node. package.json SHALL declare subpath exports per feature module. Every implementation .ts SHALL have a sibling .md ≥200 words. No export * in feature barrels. 100/100/100/100 coverage. Integration test SHALL exercise the full plugin in a real PluginRegistry alongside api-plugin-auth against a SurrealDB testcontainer: sign-up via auth, then GET/PATCH/DELETE /users/me via this plugin.
Scenario: full integration boot with auth
- WHEN
tests/integration/full-boot.test.tsbuilds a real app withcreateAuthPlugin+createUserPluginagainst SurrealDB testcontainer, sign-up, GET /users/me, PATCH displayName, DELETE /users/me - THEN every step succeeds AND a subsequent GET /users/me returns 401 AUTH_UNAUTHENTICATED (session cascaded)
Verified by: tests/integration/full-boot.test.ts::"sign-up → /users/me → PATCH → DELETE roundtrip against real SurrealDB" AND tests/integration/openapi-doc.test.ts::"served doc lists 3 /users/me routes" AND tests/dep-graph.test.ts::"every subpath export resolves" AND tests/dep-graph.test.ts::"every impl .ts has a sibling .md"