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,131 @@
# Add @suaveplan/api-plugin-cms
**Package**: `@suaveplan/api-plugin-cms`
**Status**: PROPOSED
**Type**: new-package
## Why
Headless-CMS primitives for `@suaveplan/api` applications. The upstream
package on main (`/mnt/s/web/suaveplan/packages/server/api-plugin-cms`)
ships a `CmsEngine` that manages flexible `ContentModel` definitions,
`ContentEntry` records with `draft → review → published → archived`
status transitions guarded by `VALID_STATUS_TRANSITIONS`, versioned
`ContentVersion` history, slug uniqueness, an RFC 7807 problem+json
helper, and a `CmsError` hierarchy registered into the shared error
metadata registry on module load. Genesis needs that surface ported at
full functional parity so downstream `@suaveplan/api` apps can author
content models, validate entries, and walk the status lifecycle without
reaching for main.
## What changes
- ADDED: `CmsEngine` — in-memory CMS core managing models, entries, and
version history; emits eight typed events through `@suaveplan/events`.
- ADDED: `CmsError`, `CMS_CODES`, `CmsErrorCode` — `SuaveplanError`
subclass and seven stable error codes (`CONTENT_NOT_FOUND`,
`MODEL_NOT_FOUND`, `INVALID_TRANSITION`, `VALIDATION_FAILED`,
`DUPLICATE_SLUG`, `MODEL_IN_USE`, `INVALID_INPUT`). The first six are
verbatim upstream; `INVALID_INPUT` is raised by the new
`createCmsPlugin` factory when Zod boundary validation fails (genesis
convention, mirrors `api-plugin-audit` / `api-plugin-database`).
- ADDED: `contentNotFound`, `modelNotFound`, `invalidStatusTransition`,
`validationFailed`, `duplicateSlug`, `modelInUse` — error-factory
helpers.
- ADDED: `respondProblemJson`, `formatProblemJson`,
`PROBLEM_JSON_CONTENT_TYPE`, `HttpResponseLike`, `ProblemDetails` —
RFC 7807 problem+json rendering.
- ADDED: `ContentStatus`, `ContentType`, `FieldType`, `ContentField`,
`FieldValidation`, `ContentModel`, `ContentEntry`, `ContentVersion`,
`CreateContentInput`, `UpdateContentInput`, `ContentQuery`,
`CmsEventMap` — content-model types.
- ADDED: `generateSlug`, `validateContentData`,
`VALID_STATUS_TRANSITIONS` — utility surface.
- ADDED: `createCmsPlugin` — `definePlugin()` factory that registers
the constructed `CmsEngine` as the `cms` slot on the host
`ServiceRegistry`, matching the genesis api-plugin convention.
- ADDED: `CmsPluginOptionsSchema`, `CmsPluginOptions`, `CmsPluginConfig`
— Zod-validated plugin options bundle (injectable
`Clock` / `IdGenerator`).
- ADDED: `CmsInstruments`, `getCmsInstruments`, `resetCmsInstruments` —
OTel meter bundle (content created / updated / published / archived /
deleted counters; model created counter; validation-failure counter).
## Out of scope
- SurrealDB storage adapter — upstream is in-memory only; matched.
- HTTP route mounting on `app` — upstream ships `respondProblemJson` but
no actual routes; matched.
- Auth/role gating, audit-log integration, webhook/preview tokens,
rich-text/media handling, full-text search hook — every item listed
under §4 of `enterprise/api-plugin-cms.md` is an aspirational future
enhancement, not in the upstream baseline.
- Counter-based ID replacement with ULID — kept as an injectable
`IdGenerator` so callers wanting ULID supply it; default mirrors
upstream `cms_<n>_<ts>` for byte-identical parity.
## Source mapping
This change derives from
`../suaveplan/enterprise/api-plugin-cms.md` and the upstream source at
`../suaveplan/packages/server/api-plugin-cms/`.
| Requirement | Source lines | Notes |
|---|---|---|
| `CmsEngine` lifecycle (`createModel`, `getModel`, `listModels`, `deleteModel`) | upstream `src/routes/cms-engine.ts` L38–L70 | verbatim port — same method bodies, adapted to `EventBus<CmsEventMap>` instead of upstream `EventEmitter` |
| Content entry CRUD (`createContent`, `getContent`, `queryContent`, `updateContent`, `deleteContent`) | upstream `src/routes/cms-engine.ts` L71–L210 | verbatim port — query filtering / sorting / paging preserved |
| Status-transition guard via `VALID_STATUS_TRANSITIONS` | upstream `src/routes/cms-engine.ts` L179–L202 + `src/types/cms.types.ts` L134–L142 | verbatim |
| Versioned history (`getVersions`, `saveVersion`) | upstream `src/routes/cms-engine.ts` L213–L230 | verbatim |
| `CmsError` + six factory helpers | upstream `src/errors/cms-errors.ts` L1–L71 | adapted from `BaseError<Code, Context>` to genesis `SuaveplanError<Code>` (genesis has no second-generic context — context lives on `options.context`) |
| Stable code registry (`CMS_CODES`) | upstream `src/errors/error-codes.ts` L20–L74 | adapted from upstream `ErrorCodes.registerMany(metadata)` (no equivalent in genesis `@suaveplan/error`) to `defineCodes("CMS", [...])`; HTTP-status metadata folded into the problem-json renderer (mirrors `api-plugin-database`) |
| Side-effect import of code registry on barrel load | upstream `src/index.ts` L3 | matched — `src/index.ts` side-effect-imports `./error-codes/error-codes.js` |
| `respondProblemJson`, `PROBLEM_JSON_CONTENT_TYPE` | upstream `src/routes/problem-json.ts` L1–L47 | adapted — genesis `@suaveplan/error` does not yet ship `HttpError` / `formatErrorAsProblemJson` / `ProblemDetails`, so a local `formatProblemJson` mirrors `api-plugin-database`'s pattern |
| `ContentStatus`, `ContentType`, `FieldType`, `ContentField`, `FieldValidation`, `ContentModel`, `ContentEntry`, `ContentVersion`, `CreateContentInput`, `UpdateContentInput`, `ContentQuery`, `CmsEventMap` | upstream `src/types/cms.types.ts` L1–L131 | verbatim |
| `generateSlug` | upstream `src/types/cms.types.ts` L145–L152 | verbatim |
| `validateContentData` + private `validateField` | upstream `src/types/cms.types.ts` L155–L230 | verbatim |
| `VALID_STATUS_TRANSITIONS` | upstream `src/types/cms.types.ts` L134–L142 | verbatim |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `createCmsPlugin` factory + `cms` ServiceRegistry slot | Every other genesis `api-plugin-*` ships a `definePlugin()` factory wiring its primary service into the registry. Matching the convention costs <50 lines and aligns with `enterprise/api-plugin-cms.md` §3 "Clarify identity → wrap in `definePlugin()`". |
| `CmsPluginOptionsSchema` (Zod) | Genesis-wide policy: every public option bundle is Zod-validated at the boundary (CLAUDE.md). |
| `Clock` / `IdGenerator` injection | Genesis-wide policy: no `new Date()` in hot paths, no non-deterministic ID generation in tests (CLAUDE.md "Code Organization"). Default factory mirrors upstream behavior byte-for-byte. |
| OTel instrument bundle | Genesis-wide policy: every plugin emits OTel metrics from its production hot path and asserts them with an in-memory `MeterProvider`. |
| 100% test coverage | Genesis-wide policy, not package-specific. |
## Verification approach
Every requirement in the spec delta is pinned by concrete `it(...)`
titles. Test files that will exist after this change ships:
- `src/engine/cms-engine.test.ts` — covers model + content CRUD,
query filtering / sorting / paging, status transitions, version
history, event emission, and every error-throwing path.
- `src/engine/cms-engine.spec-conformance.test.ts` — pins type-level
claims (`expectTypeOf` on `CmsEngine`'s event map), determinism with
injected `Clock` / `IdGenerator`, and the full `CMS_CODES` enumeration.
- `src/error-codes/error-codes.test.ts` — every factory raises the
expected code; `cause` chain is preserved; `instanceof CmsError`
narrows.
- `src/routes/problem-json.test.ts` — covers `CmsError` → 4xx/409
mapping, generic `SuaveplanError` → 500, non-error throwables → 500.
- `src/schemas/options.test.ts` — Zod boundary accept/reject,
default-fill behavior.
- `src/plugin/plugin.test.ts` — factory returns valid `PluginDescriptor`,
`onInit` registers `cms` in the `ServiceRegistry`, `onClose` clears it,
invalid options raise `CMS_CODES.INVALID_INPUT`.
- `src/otel/instruments.test.ts` — instruments are emitted from the
engine and observed via in-memory `MeterProvider`.
- `src/types/cms.types.test.ts` — `generateSlug`, `validateContentData`,
`VALID_STATUS_TRANSITIONS` shape.
- `src/api-plugin-cms.spec-conformance.test.ts` — every `CMS.*` code is
thrown from at least one production path; barrel side-effect-imports
the code registry.
- `tests/dep-graph.test.ts` — pins the eight runtime workspace deps and
forbids drift.
## Migration impact
None — additive only. New package; no existing consumers.

View File

@@ -0,0 +1,200 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-cms (add-api-plugin-cms)
**Package**: `@suaveplan/api-plugin-cms`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: Content-model lifecycle
The package SHALL expose a `CmsEngine` class that manages `ContentModel` records in an in-memory `Map`, supporting `createModel`, `getModel`, `listModels`, and `deleteModel`. Deleting a model with existing content entries SHALL raise `CmsError(CMS.MODEL_IN_USE)`; looking up an unknown model SHALL raise `CmsError(CMS.MODEL_NOT_FOUND)`. Successful `createModel` SHALL emit a `model.created` event on the engine's bound `EventBus<CmsEventMap>`.
#### Scenario: createModel registers and emits
- **WHEN** the caller invokes `engine.createModel({ name, slug, contentType, fields })`
- **THEN** the returned `ContentModel` has `id`, `createdAt`, and `updatedAt` populated and the `model.created` event is emitted to every listener
#### Scenario: deleteModel rejects models with entries
- **WHEN** the caller invokes `engine.deleteModel(id)` for a model that has at least one `ContentEntry`
- **THEN** `CmsError(CMS.MODEL_IN_USE)` is thrown and the model remains in `listModels()`
#### Scenario: getModel rejects unknown ids
- **WHEN** the caller invokes `engine.getModel("unknown")`
- **THEN** `CmsError(CMS.MODEL_NOT_FOUND)` is thrown with `context.modelId === "unknown"`
**Verified by:** `src/engine/cms-engine.test.ts::"createModel registers and emits model.created"` AND `src/engine/cms-engine.test.ts::"deleteModel rejects when content entries exist"` AND `src/engine/cms-engine.test.ts::"getModel throws MODEL_NOT_FOUND for unknown id"`
---
### Requirement: Content-entry CRUD and querying
The package SHALL expose `createContent`, `getContent`, `queryContent`, `updateContent`, and `deleteContent` on `CmsEngine`. `createContent` SHALL run `validateContentData` against the parent model and raise `CmsError(CMS.VALIDATION_FAILED)` on any error. Slug uniqueness within the same `modelId` SHALL be enforced — collisions raise `CmsError(CMS.DUPLICATE_SLUG)`. `queryContent` SHALL filter by `modelId`, `status`, `locale`, and case-insensitive `search` over `title`/`slug`; sort by an arbitrary field with `asc`/`desc`; and slice by `offset`/`limit`.
#### Scenario: createContent validates and persists
- **WHEN** the caller invokes `engine.createContent({ modelId, title, data, ... })` with data passing model validation
- **THEN** a `ContentEntry` is returned with `status: "draft"`, `version: 1`, generated `slug` when omitted, and the `content.created` event is emitted
#### Scenario: createContent rejects invalid data
- **WHEN** the caller invokes `engine.createContent(...)` with data violating a `required`/`minLength`/`pattern` rule on the model
- **THEN** `CmsError(CMS.VALIDATION_FAILED)` is thrown carrying the joined error list in `message`
#### Scenario: queryContent supports the documented filter/sort/page surface
- **WHEN** the caller invokes `engine.queryContent({ modelId, status, locale, search, sortBy, sortOrder, offset, limit })`
- **THEN** results reflect every filter, are sorted, and are paged in the same order as the upstream implementation
#### Scenario: updateContent honours slug uniqueness
- **WHEN** the caller invokes `engine.updateContent(id, { slug: someOtherEntrysSlug })` within the same `modelId`
- **THEN** `CmsError(CMS.DUPLICATE_SLUG)` is thrown and the existing entry remains unchanged
**Verified by:** `src/engine/cms-engine.test.ts::"createContent persists and emits content.created"` AND `src/engine/cms-engine.test.ts::"createContent rejects data failing model validation"` AND `src/engine/cms-engine.test.ts::"queryContent applies modelId/status/locale/search/sort/page filters"` AND `src/engine/cms-engine.test.ts::"updateContent rejects duplicate slugs within the same model"`
---
### Requirement: Status-transition guard
The package SHALL expose `VALID_STATUS_TRANSITIONS` (a `Record<ContentStatus, readonly ContentStatus[]>`) and a `CmsEngine.transitionStatus(id, target)` method that consults the table; disallowed transitions SHALL raise `CmsError(CMS.INVALID_TRANSITION)`. On transition the entry's `version` SHALL increment and the matching event (`content.published` / `content.drafted` / `content.archived` / fallback `content.updated`) SHALL be emitted. Transitions to `published` SHALL stamp `publishedAt` with the current clock reading.
#### Scenario: legal transition advances state
- **WHEN** the caller invokes `engine.transitionStatus(id, "published")` on a `draft` entry
- **THEN** the entry's `status` is now `"published"`, `version` is incremented, `publishedAt` is set, and `content.published` is emitted
#### Scenario: illegal transition raises
- **WHEN** the caller invokes `engine.transitionStatus(id, "published")` on an `archived` entry (not in `VALID_STATUS_TRANSITIONS["archived"]`)
- **THEN** `CmsError(CMS.INVALID_TRANSITION)` is thrown and the entry is unchanged
**Verified by:** `src/engine/cms-engine.test.ts::"transitionStatus advances draft to published and emits content.published"` AND `src/engine/cms-engine.test.ts::"transitionStatus rejects an illegal target"`
---
### Requirement: Versioned history
The package SHALL retain a `ContentVersion[]` per entry, appended on every `createContent`, `updateContent`, and `transitionStatus` call. `engine.getVersions(entryId)` SHALL return the full history; deleting the entry SHALL drop its versions.
#### Scenario: versions accumulate
- **WHEN** the caller creates an entry then runs two updates and one transition
- **THEN** `engine.getVersions(id)` returns four versions with monotonically increasing `version` fields
**Verified by:** `src/engine/cms-engine.test.ts::"getVersions returns one entry per mutation"`
---
### Requirement: Error-code registry
The package SHALL define exactly six stable error codes via `defineCodes("CMS", [...])`: `CONTENT_NOT_FOUND`, `MODEL_NOT_FOUND`, `INVALID_TRANSITION`, `VALIDATION_FAILED`, `DUPLICATE_SLUG`, `MODEL_IN_USE`. Each code SHALL be raised by at least one production code path. The `CmsError` class SHALL extend `SuaveplanError<CmsErrorCode>` and expose a `cmsCode` readonly mirror for `instanceof` narrowing.
#### Scenario: every CMS.* code is raised from a production path
- **WHEN** the spec-conformance audit scans `src/engine/`, `src/error-codes/`, and `src/routes/`
- **THEN** every value of `CMS_CODES` appears in at least one `new CmsError(...)` or `CMS_CODES.<NAME>` call site
#### Scenario: CmsError preserves cause chain
- **WHEN** a factory helper is invoked with a `cause`
- **THEN** the resulting `CmsError`'s `.cause` references the supplied error and `instanceof CmsError` narrows `.code` to `CmsErrorCode`
**Verified by:** `src/api-plugin-cms.spec-conformance.test.ts::"every CMS.* code is thrown from a production path"` AND `src/error-codes/error-codes.test.ts::"every factory raises the matching CMS_CODES.* value"` AND `src/error-codes/error-codes.test.ts::"CmsError preserves cause chain and narrows code"`
---
### Requirement: RFC 7807 problem+json rendering
The package SHALL export `respondProblemJson(res, err)` and `formatProblemJson(err)` plus the `PROBLEM_JSON_CONTENT_TYPE` constant. `CmsError` instances SHALL map to specific HTTP statuses (404 for `CONTENT_NOT_FOUND`/`MODEL_NOT_FOUND`; 409 for `INVALID_TRANSITION`/`DUPLICATE_SLUG`/`MODEL_IN_USE`; 400 for `VALIDATION_FAILED`). Generic `SuaveplanError` instances SHALL fall through to 500; non-error throwables SHALL render `{ type: "about:blank", status: 500, title: "Internal Server Error" }`.
#### Scenario: CmsError renders the documented status
- **WHEN** the caller invokes `formatProblemJson(contentNotFound("x"))`
- **THEN** the returned body has `status: 404`, `code: "CMS.CONTENT_NOT_FOUND"`, and `type: "urn:suaveplan:error:CMS.CONTENT_NOT_FOUND"`
#### Scenario: respondProblemJson writes header + body to the response
- **WHEN** the caller invokes `respondProblemJson(res, modelInUse("m1"))` against a `HttpResponseLike` double
- **THEN** the content-type header is `application/problem+json`, the body is the JSON-stringified `ProblemDetails`, and the status is 409
**Verified by:** `src/routes/problem-json.test.ts::"formatProblemJson maps every CmsError code to the documented HTTP status"` AND `src/routes/problem-json.test.ts::"respondProblemJson sets header and body on the response"` AND `src/routes/problem-json.test.ts::"formatProblemJson falls back to 500 for generic SuaveplanError and unknown throwables"`
---
### Requirement: Content-model types, slug utility, and validator
The package SHALL export the full type surface (`ContentStatus`, `ContentType`, `FieldType`, `ContentField`, `FieldValidation`, `ContentModel`, `ContentEntry`, `ContentVersion`, `CreateContentInput`, `UpdateContentInput`, `ContentQuery`, `CmsEventMap`). It SHALL export `generateSlug` (lowercases, strips non-alphanumerics, collapses whitespace and hyphens) and `validateContentData` (runs `required` plus per-field `minLength`/`maxLength`/`min`/`max`/`pattern`/`custom` rules and returns a `string[]` of error messages).
#### Scenario: generateSlug normalises arbitrary titles
- **WHEN** the caller invokes `generateSlug(" Hello, World! ")`
- **THEN** the result is `"hello-world"`
#### Scenario: validateContentData reports every failing rule
- **WHEN** the caller invokes `validateContentData(model, data)` on data that violates two rules
- **THEN** the result is a 2-length array of human-readable messages
**Verified by:** `src/types/cms.types.test.ts::"generateSlug normalises whitespace, punctuation, and case"` AND `src/types/cms.types.test.ts::"validateContentData enforces required + every FieldValidation type"` AND `src/types/cms.types.test.ts::"VALID_STATUS_TRANSITIONS lists every legal edge"`
---
### Requirement: Plugin factory and ServiceRegistry slot
The package SHALL export `createCmsPlugin(options)` that returns a `PluginDescriptor<CmsPluginConfig>` consumable by `@suaveplan/api-plugin`'s `PluginRegistry`. Options SHALL be Zod-validated; invalid input raises `CmsError(CMS.INVALID_INPUT)` carrying the `ZodError`. `onInit` SHALL construct a `CmsEngine` using the supplied `Clock`/`IdGenerator` (or genesis defaults) and register it under the `cms` slot of the host `ServiceRegistry`. `onClose` SHALL drop the engine reference.
#### Scenario: createCmsPlugin returns a usable descriptor
- **WHEN** the caller invokes `createCmsPlugin({})`
- **THEN** the returned descriptor has `name === "cms"`, `version === "0.1.0"`, and exposes `onInit` / `onClose` functions
#### Scenario: invalid options raise CMS.INVALID_INPUT
- **WHEN** the caller invokes `createCmsPlugin({ defaultStatus: 42 } as never)`
- **THEN** `CmsError(CMS.INVALID_INPUT)` is thrown with the `ZodError` attached as `cause`
#### Scenario: onInit registers the engine
- **WHEN** the registry executes the plugin's `onInit` against a `PluginContext`
- **THEN** `ctx.getService("cms")` returns a `CmsEngine` instance
**Verified by:** `src/plugin/plugin.test.ts::"createCmsPlugin returns a valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise CMS.INVALID_INPUT"` AND `src/plugin/plugin.test.ts::"onInit registers the CmsEngine under the cms slot"`
---
### Requirement: OpenTelemetry instruments
The package SHALL expose a `CmsInstruments` bundle resolved lazily via `getCmsInstruments()` exposing seven counters (`cms.models_created_total`, `cms.content_created_total`, `cms.content_updated_total`, `cms.content_published_total`, `cms.content_archived_total`, `cms.content_deleted_total`, `cms.validation_failures_total`). Every counter SHALL be incremented from the engine method that produces the corresponding event. A `resetCmsInstruments()` test helper SHALL be exported.
#### Scenario: counters fire on engine mutations
- **WHEN** the test installs an in-memory `MeterProvider`, calls `resetCmsInstruments()`, creates a model + entry, publishes the entry, and flushes
- **THEN** the exported metrics include `cms.models_created_total == 1`, `cms.content_created_total == 1`, and `cms.content_published_total == 1`
**Verified by:** `src/otel/instruments.test.ts::"counters fire on engine mutations and are observable via in-memory MeterProvider"`
---
### Requirement: Barrel side-effect registration and dependency invariants
The package barrel (`src/index.ts`) SHALL side-effect-import `./error-codes/error-codes.js` and `./services/registry-augmentation.js` so consumers of `import "@suaveplan/api-plugin-cms"` see both the `CMS_CODES` namespace and the `cms` `ServiceRegistry` slot without further wiring. The package SHALL declare exactly the runtime workspace dependencies enumerated in `package.json` and nothing more.
#### Scenario: barrel side-effects fire on load
- **WHEN** any consumer imports `@suaveplan/api-plugin-cms`
- **THEN** `CMS_CODES.CONTENT_NOT_FOUND === "CMS.CONTENT_NOT_FOUND"` is observable without an additional import of `./error-codes/error-codes.js`
#### Scenario: dependency set is pinned
- **WHEN** the dep-graph test runs against `package.json`
- **THEN** the runtime workspace deps are exactly `@suaveplan/api-plugin`, `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`
**Verified by:** `src/api-plugin-cms.spec-conformance.test.ts::"barrel side-effect imports the error-codes registry"` AND `tests/dep-graph.test.ts::"runtime workspace dependencies are pinned"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,71 @@
# Tasks — Add @suaveplan/api-plugin-cms
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify. Retroactive ticking is the failure mode this template guards against.
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/api-plugin-cms.md` end-to-end
- [x] 1.2 Read `openspec/specs/` for prior art (api-plugin-database, api-plugin-audit) — no existing `api-plugin-cms/spec.md`
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.5 Fill in `## Source mapping` table — every requirement cites a line range OR appears in the "no source line" table with justification
- [x] 1.6 Draft `specs/api-plugin-cms/spec.md` from `openspec/templates/spec-delta.md`
- [x] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
## 2. Implementation
- [x] 2.1 `src/types/cms.types.ts` — verbatim port of upstream content-model types, slug + validator utilities, status transitions, event map (adapted to `EventBus<EventMap>` single-payload style)
- [x] 2.2 `src/error-codes/error-codes.ts` — `defineCodes("CMS", [...])` + `CmsError extends SuaveplanError<CmsErrorCode>` + six factory helpers
- [x] 2.3 `src/engine/cms-engine.ts` — port `CmsEngine` (model CRUD, content CRUD, query, transitions, versions); compose with `EventBus<CmsEventMap>`; inject `Clock` + `IdGenerator`; emit OTel counters
- [x] 2.4 `src/routes/problem-json.ts` — local `ProblemDetails` + `formatProblemJson` + `respondProblemJson` with full `CMS_CODES` → HTTP status table
- [x] 2.5 `src/schemas/options.ts` — Zod `CmsPluginOptionsSchema` with `Clock` / `IdGenerator` accepted as opaque `unknown` (genesis convention from audit plugin)
- [x] 2.6 `src/otel/instruments.ts` — `CmsInstruments` bundle with counters for the seven engine events + validation failures
- [x] 2.7 `src/services/registry-augmentation.ts` — `declare module "@suaveplan/api-plugin/service-registry"` adding the `cms: CmsEngine` slot
- [x] 2.8 `src/plugin/plugin.ts` — `createCmsPlugin(options)` factory returning a `PluginDescriptor`; `onInit` constructs `CmsEngine`, registers it; `onClose` drops the reference
- [x] 2.9 `src/index.ts` — barrel with explicit named re-exports + side-effect imports of code registry and registry augmentation
## 3. Tests
- [x] 3.1 `src/types/cms.types.test.ts` — `generateSlug`, `validateContentData`, `VALID_STATUS_TRANSITIONS`
- [x] 3.2 `src/error-codes/error-codes.test.ts` — every factory raises expected code; cause chain; `instanceof` narrowing
- [x] 3.3 `src/engine/cms-engine.test.ts` — model + content CRUD, query, transitions, versions, events
- [x] 3.4 `src/engine/cms-engine.spec-conformance.test.ts` — determinism with injected Clock/IdGenerator; full code enumeration
- [x] 3.5 `src/routes/problem-json.test.ts` — CmsError mapping, generic SuaveplanError, non-error throwables
- [x] 3.6 `src/schemas/options.test.ts` — Zod boundary
- [x] 3.7 `src/plugin/plugin.test.ts` — descriptor shape, onInit/onClose
- [x] 3.8 `src/otel/instruments.test.ts` — counter emission asserted with in-memory `MeterProvider`
- [x] 3.9 `src/api-plugin-cms.spec-conformance.test.ts` — every code thrown from production source, side-effect barrel imports
- [x] 3.10 `tests/dep-graph.test.ts` — pins runtime dep set
- [x] 3.11 Every `it(...)` carries `{ timeout: 5000 }`
## 4. Documentation
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [x] 4.2 README at package root linking every co-located `.md`
- [x] 4.3 CLAUDE.md package listing — covered by the api-plugin family entry already present (no edit needed for additive plugin)
## 5. Pre-archive verification (the 5-gate triple)
- [x] 5.1 `bun run check` — Biome 0/0/0
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-cms` — green
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-cms` — 100/100/100/100
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-cms` — exit 0, no `error` findings
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-plugin-cms` — green
## 6. Pre-archive 9-row checklist (skill §7)
- [x] 6.1 Generics threaded; `expectTypeOf` on every literal-type claim (event-map keys)
- [x] 6.2 Zod schemas wired at every declared boundary (`createCmsPlugin`)
- [x] 6.3 Every error code in `error-codes.ts` thrown by ≥1 production path
- [x] 6.4 Strict-mode behaviors tested both on and off — N/A; no strict-mode flag in this package
- [x] 6.5 OTel instruments emitted from production paths and asserted with real `MeterProvider`
- [x] 6.6 Lifecycle dedup tested where claimed — N/A; engine state is per-instance, no in-flight Promise dedup
- [x] 6.7 No retroactive tick — every box reflects work done in this change
- [x] 6.8 No `export *` in any feature-module barrel (no feature-module barrels; flat module per dir)
- [x] 6.9 No direct `vitest` / `bun:test` imports — every test imports `@suaveplan/testing/runner`
## 7. Archive
- [x] 7.1 Fold spec delta into `openspec/specs/api-plugin-cms/spec.md`
- [x] 7.2 Move `openspec/changes/add-api-plugin-cms/` → `openspec/archive/2026-05-19-add-api-plugin-cms/`
- [x] 7.3 Refresh `MANIFEST.md` — skipped per session protocol (concurrent sessions own MANIFEST.md)