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

143 lines
8.9 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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 `dependencies` containing `"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 `user` table now has `bio` and `locale` as `option<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 `UpdateUserProfileBodySchema` Zod 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 }`; subsequent `findOne({ model: "user", where: [{ field: "id", value: principal.id }] })` returns null AND every `session`/`account` for that user is gone too
#### Scenario: DELETE /users/me disabled raises USER_SELF_DELETE_DISABLED
- **WHEN** `allowSelfDelete: false` and 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.ts` for 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 on `evil`)
**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/me` succeeds against a real in-memory `MeterProvider`
- **THEN** `user.profile_reads_total` has 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.ts` builds a real app with `createAuthPlugin` + `createUserPlugin` against 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"`
<!-- markdownlint-enable MD024 -->