Files
2026-05-27 23:29:53 +01:00

202 lines
13 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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<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 invalid data + 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 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<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/plugin/`
- **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`; 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<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(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"`
<!-- markdownlint-enable MD024 -->