Initial Commit
This commit is contained in:
90
openspec/archive/2026-05-18-add-api-plugin-user/proposal.md
Normal file
90
openspec/archive/2026-05-18-add-api-plugin-user/proposal.md
Normal 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.
|
||||
@@ -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 -->
|
||||
51
openspec/archive/2026-05-18-add-api-plugin-user/tasks.md
Normal file
51
openspec/archive/2026-05-18-add-api-plugin-user/tasks.md
Normal 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
|
||||
Reference in New Issue
Block a user