# Capability Spec: api-plugin-cms **Package**: `@suaveplan/api-plugin-cms` **Status**: SHIPPED **Source change**: `openspec/archive/2026-05-19-add-api-plugin-cms/` > Every requirement carries a `**Verified by:**` line pointing at the concrete test that pins it. ## 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`. #### 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 invalid data + duplicate slugs within the same model"` --- ### Requirement: Status-transition guard The package SHALL expose `VALID_STATUS_TRANSITIONS` (a `Record`) 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 seven stable error codes via `defineCodes("CMS", [...])`: `CONTENT_NOT_FOUND`, `MODEL_NOT_FOUND`, `INVALID_TRANSITION`, `VALIDATION_FAILED`, `DUPLICATE_SLUG`, `MODEL_IN_USE`, `INVALID_INPUT`. Each code SHALL be raised by at least one production code path. The `CmsError` class SHALL extend `SuaveplanError` 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/plugin/` - **THEN** every value of `CMS_CODES` appears in at least one `new CmsError(...)` or `CMS_CODES.` 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`; 500 for `INVALID_INPUT`). 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` 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(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 `./services/registry-augmentation.js` so consumers of `import "@suaveplan/api-plugin-cms"` see the `cms` `ServiceRegistry` slot augmentation without further wiring. The package SHALL declare exactly the runtime workspace dependencies enumerated in `package.json` (`@suaveplan/api-plugin`, `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`) 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 registry augmentation"` AND `tests/dep-graph.test.ts::"runtime workspace dependencies are pinned"`