Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
# 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.

View File

@@ -0,0 +1,142 @@
<!-- 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 -->

View File

@@ -0,0 +1,51 @@
# Tasks — add-api-plugin-user
## 1. Scaffold
- [ ] 1.1 Create `packages/api/api-plugin-user/` with `src/{plugin,routes,services,schemas,schema,error-codes,otel,types}/` + `tests/{setup,integration}/`
- [ ] 1.2 `package.json` v0.1.0. Deps: `@suaveplan/api-plugin` `@suaveplan/api-openapi` `@suaveplan/api-migrations` `@suaveplan/api-plugin-auth` `@suaveplan/error` `@suaveplan/types` `tslib`, `hono` `@hono/zod-openapi` (`catalog:server`), `zod` (`catalog:forms`), `@opentelemetry/api`
- [ ] 1.3 10 subpath exports per the proposal
- [ ] 1.4 tsconfigs + vite + vitest (100/100/100/100, testTimeout 60000 for integration)
- [ ] 1.5 `tests/setup/polyfills.ts`
## 2. Implementation
- [ ] 2.1 `src/error-codes.{ts,test.ts,md,index.ts}` — 7 USER.* codes + `UserError`
- [ ] 2.2 `src/types/types.{ts,md,index.ts}` — `UserProfile`, `UserPluginOptions`, `UserPluginConfig`
- [ ] 2.3 `src/schemas/options.{ts,test.ts,md,index.ts}` — `UserPluginOptionsSchema`
- [ ] 2.4 `src/schemas/user-profile.{ts,test.ts,md,index.ts}` — `UserProfileSchema` (extends `PrincipalSchema` from `@suaveplan/api-plugin-auth/schemas/principal`), `UserProfileResponseSchema`, `UpdateUserProfileBodySchema` (strict mode)
- [ ] 2.5 `src/otel/instruments.{ts,test.ts,md,index.ts}` — 3 counters
- [ ] 2.6 `src/schema/schema.{ts,test.ts,md,index.ts}` — `USER_PROFILE_SCHEMA` template + `applyUserProfileSchema(adapter, profileFields)` helper. Genesis-original DDL using `DEFINE FIELD OVERWRITE` on the `user` table.
- [ ] 2.7 `src/services/user-service.{ts,test.ts,md,index.ts}` — `UserService` interface + impl; ServiceRegistry augmentation
- [ ] 2.8 `src/routes/build-routes.{ts,test.ts,md,index.ts}` — `buildUserRoutes(authService)` returning 3 `PluginRouteEntry` via `RouteBuilder`; all use `requireAuth()` upstream
- [ ] 2.9 `src/plugin/plugin.{ts,test.ts,md,index.ts}` — `createUserPlugin(options)` factory; `dependencies: ["auth"]`; onInit applies schema → registers userService → registers routes
- [ ] 2.10 `src/index.ts` — named re-exports only
## 3. Tests
- [ ] 3.1 Co-located `*.test.ts` per spec scenario
- [ ] 3.2 `src/api-plugin-user.spec-conformance.test.ts` — every USER.* code thrown; `expectTypeOf` on `UserService`; `UpdateUserProfileBodySchema` strict-mode pinned
- [ ] 3.3 `tests/integration/full-boot.test.ts` — real `PluginRegistry` + `api-plugin-auth` + this plugin + SurrealDB testcontainer; sign-up → GET /users/me → PATCH → DELETE roundtrip
- [ ] 3.4 `tests/integration/openapi-doc.test.ts` — served `/openapi.json` includes 3 `/users/me` routes with Zod-typed schemas
- [ ] 3.5 `tests/property.test.ts` — fast-check 200 runs on `UpdateUserProfileBodySchema`: arbitrary keysets including extras → strict-mode rejects when extras present
- [ ] 3.6 `tests/dep-graph.test.ts`
- [ ] 3.7 All tests via `@suaveplan/testing/runner`, `{ timeout: 5000 }` (60000 for integration)
## 4. Documentation
- [ ] 4.1 Sibling `.md` per impl (≥200 words)
- [ ] 4.2 `README.md` — install + `defineApp().use(createAuthPlugin(...)).use(createUserPlugin(...))` example; `userService.getById` usage from downstream plugins; profileFields customisation; schema apply notes
## 5. Gates
- [ ] 5.1 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-user --force`
- [ ] 5.2 Coverage 100/100/100/100
- [ ] 5.3 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-user` exit 0
- [ ] 5.4 Build green
- [ ] 5.5 Banned-pattern + no `export *` + no direct runner imports
## 6. Archive
- [ ] 6.1 Fold spec → `openspec/specs/api-plugin-user/spec.md`
- [ ] 6.2 mv change → `openspec/archive/<date>-add-api-plugin-user/`
- [ ] 6.3 DO NOT refresh MANIFEST or STATUS — orchestrator consolidates