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

13 KiB

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.

  • 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"