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
ContentModelhasid,createdAt, andupdatedAtpopulated and themodel.createdevent 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 oneContentEntry - THEN
CmsError(CMS.MODEL_IN_USE)is thrown and the model remains inlistModels()
Scenario: getModel rejects unknown ids
- WHEN the caller invokes
engine.getModel("unknown") - THEN
CmsError(CMS.MODEL_NOT_FOUND)is thrown withcontext.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
ContentEntryis returned withstatus: "draft",version: 1, generatedslugwhen omitted, and thecontent.createdevent is emitted
Scenario: createContent rejects invalid data
- WHEN the caller invokes
engine.createContent(...)with data violating arequired/minLength/patternrule on the model - THEN
CmsError(CMS.VALIDATION_FAILED)is thrown carrying the joined error list inmessage
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 samemodelId - 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 adraftentry - THEN the entry's
statusis now"published",versionis incremented,publishedAtis set, andcontent.publishedis emitted
Scenario: illegal transition raises
- WHEN the caller invokes
engine.transitionStatus(id, "published")on anarchivedentry (not inVALID_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 increasingversionfields
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/, andsrc/plugin/ - THEN every value of
CMS_CODESappears in at least onenew CmsError(...)orCMS_CODES.<NAME>call site
Scenario: CmsError preserves cause chain
- WHEN a factory helper is invoked with a
cause - THEN the resulting
CmsError's.causereferences the supplied error andinstanceof CmsErrornarrows.codetoCmsErrorCode
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", andtype: "urn:suaveplan:error:CMS.CONTENT_NOT_FOUND"
Scenario: respondProblemJson writes header + body to the response
- WHEN the caller invokes
respondProblemJson(res, modelInUse("m1"))against aHttpResponseLikedouble - THEN the content-type header is
application/problem+json, the body is the JSON-stringifiedProblemDetails, 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 exposesonInit/onClosefunctions
Scenario: invalid options raise CMS.INVALID_INPUT
- WHEN the caller invokes
createCmsPlugin(42 as never) - THEN
CmsError(CMS.INVALID_INPUT)is thrown with theZodErrorattached ascause
Scenario: onInit registers the engine
- WHEN the registry executes the plugin's
onInitagainst aPluginContext - THEN
ctx.getService("cms")returns aCmsEngineinstance
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, callsresetCmsInstruments(), creates a model + entry, publishes the entry, and flushes - THEN the exported metrics include
cms.models_created_total == 1,cms.content_created_total == 1, andcms.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"