Files
suaveplan/openspec/archive/2026-05-18-add-api-plugin-user/specs/api-plugin-user/spec.md
2026-05-27 23:29:53 +01:00

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 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"