Repository navigation
Releases: dynamic/silverstripe-content-api
Release list
2.9.0 — SilverStripe 6 line
Behavior changes to check before upgrading (details in each entry below):
- Composition
prunealso removes element children a payload omits under a named relation. - A link edited with
"publish": "none"now waits for its owner to be published. GET records:limit=0and non-numeric/negativelimit/offsetare400; an empty filter value (Title=) now filters.PATCH records/$ClassRef/$IDis new and open on live by default; close it withRecordUpdateHandler.disabled_environments: [live].- Stricter authorization on compositions, assets, publish cascades, convert (new
convertverb) and relation far sides; HTML fields are sanitised on write. - Regenerated exposure files drop framework-maintained fields/relations; fingerprint output shifts once for affected sites.
[2.9.0] - 2026-10-18
The 2026-10 security/correctness audit (epic #133: #221/#222, #225-#246, #231, #232), plus
#207 and #277, all landed on branch 1 first (1.15.0). Behavior changes are called out in their
entries; the main ones are listed in the release notes.
Added
- (#232)
PATCH records/$ClassRef/$IDwith{"fields": {...}, "publish": "none|single"}:
a single-record field update that needsCONTENT_API_ACCESSonly and is not behind the
population gate, so it works on a live site. Same checks as a batchupdateop (class verb on
the record's own class,canEdit(), write policy and allowlist,actionforpublish: single); no creates or relations. It is open on live by default:
RecordUpdateHandler.disabled_environmentscloses it per environment (403 ENV_FORBIDDEN,
details[0].confignames the key), andGET schema/sitereportsrecordUpdateEnabled. See
docs/en/04_security-model.md#live-writes-patch-recordsclassrefid for what it can reach. New
content_records_updatetool inschema/endpoints.json.
Landed on branch1first (unreleased), merged up here. - (#231) Opt-in grant roots for
ContentApiGrantExtension:grant_rootsnames ancestors
(e.g.SiteTree,BaseElement) whose undeclared subclasses are granted the verbs they inherit;
grant_exclude(shipping withErrorPage,RedirectorPage,VirtualPage,UserDefinedForm)
takes classes back out. A class's own declaration (an explicit deny included) still wins, the
inherited value never comes from an extension or discovery, and with no roots nothing
changes. NewClassRegistry::grantVerbs();CheckGrantExtensionReachabilityreports the
roots and exclusions and checks inherited grants. See docs/en/04_security-model.md#grant-roots-opt-in.
Landed on branch1first (unreleased), merged up here. - (#207) Opt-in structured server-side request log (
Dynamic\ContentApi\Logging\RequestLogger):
oneinfoentry percontent-api/v1request (endpoint, method,ClassRef/action, HTTP status,
error code, duration, response byte size, authenticated member ID). Off everywhere by default;
enabled_environmentsconfig (widen-only via project YAML, per #199) plus a force-ON-only
SS_CONTENT_API_REQUEST_LOGoverride. A partially-failedPOST batchcarries itsopFailures
count even though the wire response is a plain 200 — a status-only log would otherwise record
that case as a clean success, the exact "200 but silently wrong" defect class this audit's other
fixes address at the code level. Colymba's generic/apisurface and a route matching no
$url_handlerspattern are both outside this hook's coverage — see
docs/en/14_architecture.md#request-lifecycle. Also the missing piece for
silverstripe-content-api-mcp#25's open success criterion (a server-side way to verify a real
restructure session ran through MCP tools).
Landed on branch1first (unreleased), merged up here.
Security
-
(#227, #229, #235) Composition and asset write paths now check the verb for what they do to
each record, at both the class and record level (newPermissionPolicy::authorize()). See
docs/en/04_security-model.md#composition-and-asset-paths for the full table.- Composition
pruneneedsdeleteon every archived element pluscanDelete(). All
candidates are checked before any is archived. - A match-only composition (no
page.fields) needs the page classupdateverb.
createIfMissingneeds the page classcreateverb. AparentIdmust exist (404 NOT_FOUNDotherwise) and reachescanCreate()'s context asParent. POST assetsand compositionassets[]share one authorization
(AssetService::authorizeIngest()). A new file needscreatepluscanCreate()with its
folder asParent(andFolder::canCreate()for a missing folder).overwriteneeds
updatepluscanEdit()on the existing file, found on the draft stage.skipneeds
read. Publishing needsaction.
Behavior changes:
- An asset upload that does not send
publish, on a class withoutaction, is now written
draft-only with anACTION_VERB_MISSINGwarning indata.warnings; it used to publish. An
explicit"publish": truewithoutactionis403 FORBIDDEN_CLASS. - A composition
assets[]entry without its ownpublishnow follows the composition: it is
draft-only underpublish: "none"(it used to publish). - Composition asset ingest,
overwriteandprunenow need the verbs above, where they
previously needed none of them.
Landed on branch1first (unreleased), merged up here.
- Composition
-
(#239) An asset
publishvalue is parsed strictly, in JSON and multipart alike: the string
"false"no longer reads as true, and anything other than a boolean,1/0or
"true"/"false"/"1"/"0"is400 PAYLOAD_INVALID.
Landed on branch1first (unreleased), merged up here. -
(#226) Publish mode
recursive(POST records/$Class/$ID/publishand a payloadpublish
key) now checks every$owns-reachable recordpublishRecursive()will publish: each needs
the classactionverb and the record check, the same walk and gateownsmode applies. It
previously published owned drafts of classes the caller had no verb on. All are checked
before anything is published. An owned record whose draft and live already match is skipped,
sincepublishRecursive()leaves it untouched. Behavior change: a recursive publish
reaching a changed owned record of a class withoutaction(a draft-only image on a
read/create-onlyFile, say) is now403. The walk reads the draft stage, so an owned
record that exists only on Live (deleted from draft) and thatpublishRecursive()removes
from Live is not checked.
Landed on branch1first (unreleased), merged up here. -
(#235) Publish and unpublish now honour a model's
canPublish()/canUnpublish()(new
PermissionPolicy::checkStageAccess()), falling back tocanEdit()without them. This applies
to:- the root of every publish mode;
- every record a
subtree/owns/recursive/relation cascade reaches; - every unpublish;
- any delete that takes a record off Live: batch
deleteinunpublishmode, and
archive/hardon a published record (the CMS'scanArchive()askscanUnpublish()
too); - asset publishes of an existing file.
Behavior change: a class or extension that allows edit but denies publish or unpublish now
gets403 FORBIDDEN_RECORDfor those operations.ContentApiGrantExtensiondefines no
canPublish(), so service accounts it covers publish on itscanEdit()grant. The exception
is a model that overridescanPublish()directly withoutextendedCan(), where the model's
own rule now applies.
Landed on branch1first (unreleased), merged up here. -
(#228) Changing a page's class (
POST pages/$ID/convertand a composition's
page.convertTo) needs a new bareconvertverb inapi_accesson both the page's current
class and the target class, in addition toupdate(andactionwhen publishing). It was
previously allowed between any two classes grantingupdate.api_access: trueincludes
convert; a CSV list must name it ('GET,POST,PUT,action,convert'). Behavior change:
projects that convert pages must addconvertto the classes involved.
Landed on branch1first (unreleased), merged up here. -
(#235)
apply-templateneeds the page classupdateverb (not justcanEdit()),
canView()on the template, and thecreateverb pluscanCreate()on every class the apply
will create: each template element's class and every child classduplicate()cascades to.
All are checked before anything is written.
Landed on branch1first (unreleased), merged up here. -
(#235) Relation writes authorize the far side, before any list changes: a has_many item,
and every existing child a has_manysetclears, needs theupdateverb pluscanEdit()
(its foreign key is written); a many_many item needsreadpluscanView(). They previously
checked only the owning record. Behavior change: projects writing has_many relations need
updateon the child class.
Landed on branch1first (unreleased), merged up here. -
(#225) HTML-typed field writes (
DBHTMLText/DBHTMLVarchar) are sanitised with
HTMLEditorSanitiserand thecmseditor config's rules, on the module's pipeline and on
colymba's/api, so an API write stores what a CMS save would. Any token withupdateon a
class with an HTML field could previously put script into a page that renders it raw.
Behavior change: markup outside the editor rules is stripped, exactly as a CMS save strips
it. Beyond<script>andon*attributes, that includes, under the stockcmsrules: empty
<i>/<span>/<div>(icon and clearfix markup), HTML5 elements such as<section>,
<figure>,<picture>,<svg>,<video>and<button>(unwrapped), and attributes such as
data-*,srcsetandloading. A module write that lost markup carries a new
VALUE_SANITISED...
1.15.0 — SilverStripe 5.2 line
Behavior changes to check before upgrading (details in each entry below):
- Composition
prunealso removes element children a payload omits under a named relation. - A link edited with
"publish": "none"now waits for its owner to be published. GET records:limit=0and non-numeric/negativelimit/offsetare400; an empty filter value (Title=) now filters.PATCH records/$ClassRef/$IDis new and open on live by default; close it withRecordUpdateHandler.disabled_environments: [live].- Stricter authorization on compositions, assets, publish cascades, convert (new
convertverb) and relation far sides; HTML fields are sanitised on write. - Regenerated exposure files drop framework-maintained fields/relations; fingerprint output shifts once for affected sites.
[1.15.0] - 2026-10-18
The 2026-10 security/correctness audit (epic #133: #221/#222, #225-#246, #231, #232), plus
#207 and #277. Behavior changes are called out in their entries; the main ones are listed
in the release notes.
Added
- (#232)
PATCH records/$ClassRef/$IDwith{"fields": {...}, "publish": "none|single"}:
a single-record field update that needsCONTENT_API_ACCESSonly and is not behind the
population gate, so it works on a live site. Same checks as a batchupdateop (class verb on
the record's own class,canEdit(), write policy and allowlist,actionforpublish: single); no creates or relations. It is open on live by default:
RecordUpdateHandler.disabled_environmentscloses it per environment (403 ENV_FORBIDDEN,
details[0].confignames the key), andGET schema/sitereportsrecordUpdateEnabled. See
docs/en/04_security-model.md#live-writes-patch-recordsclassrefid for what it can reach. New
content_records_updatetool inschema/endpoints.json. - (#231) Opt-in grant roots for
ContentApiGrantExtension:grant_rootsnames ancestors
(e.g.SiteTree,BaseElement) whose undeclared subclasses are granted the verbs they inherit;
grant_exclude(shipping withErrorPage,RedirectorPage,VirtualPage,UserDefinedForm)
takes classes back out. A class's own declaration (an explicit deny included) still wins, the
inherited value never comes from an extension or discovery, and with no roots nothing
changes. NewClassRegistry::grantVerbs();CheckGrantExtensionReachabilityreports the
roots and exclusions and checks inherited grants. See docs/en/04_security-model.md#grant-roots-opt-in. - (#207) Opt-in structured server-side request log (
Dynamic\ContentApi\Logging\RequestLogger):
oneinfoentry percontent-api/v1request (endpoint, method,ClassRef/action, HTTP status,
error code, duration, response byte size, authenticated member ID). Off everywhere by default;
enabled_environmentsconfig (widen-only via project YAML, per #199) plus a force-ON-only
SS_CONTENT_API_REQUEST_LOGoverride. A partially-failedPOST batchcarries itsopFailures
count even though the wire response is a plain 200 — a status-only log would otherwise record
that case as a clean success, the exact "200 but silently wrong" defect class this audit's other
fixes address at the code level. Colymba's generic/apisurface and a route matching no
$url_handlerspattern are both outside this hook's coverage — see
docs/en/14_architecture.md#request-lifecycle. Also the missing piece for
silverstripe-content-api-mcp#25's open success criterion (a server-side way to verify a real
restructure session ran through MCP tools).
Security
-
(#227, #229, #235) Composition and asset write paths now check the verb for what they do to
each record, at both the class and record level (newPermissionPolicy::authorize()). See
docs/en/04_security-model.md#composition-and-asset-paths for the full table.- Composition
pruneneedsdeleteon every archived element pluscanDelete(). All
candidates are checked before any is archived. - A match-only composition (no
page.fields) needs the page classupdateverb.
createIfMissingneeds the page classcreateverb. AparentIdmust exist (404 NOT_FOUNDotherwise) and reachescanCreate()'s context asParent. POST assetsand compositionassets[]share one authorization
(AssetService::authorizeIngest()). A new file needscreatepluscanCreate()with its
folder asParent(andFolder::canCreate()for a missing folder).overwriteneeds
updatepluscanEdit()on the existing file, found on the draft stage.skipneeds
read. Publishing needsaction.
Behavior changes:
- An asset upload that does not send
publish, on a class withoutaction, is now written
draft-only with anACTION_VERB_MISSINGwarning indata.warnings; it used to publish. An
explicit"publish": truewithoutactionis403 FORBIDDEN_CLASS. - A composition
assets[]entry without its ownpublishnow follows the composition: it is
draft-only underpublish: "none"(it used to publish). - Composition asset ingest,
overwriteandprunenow need the verbs above, where they
previously needed none of them.
- Composition
-
(#239) An asset
publishvalue is parsed strictly, in JSON and multipart alike: the string
"false"no longer reads as true, and anything other than a boolean,1/0or
"true"/"false"/"1"/"0"is400 PAYLOAD_INVALID. -
(#226) Publish mode
recursive(POST records/$Class/$ID/publishand a payloadpublish
key) now checks every$owns-reachable recordpublishRecursive()will publish: each needs
the classactionverb and the record check, the same walk and gateownsmode applies. It
previously published owned drafts of classes the caller had no verb on. All are checked
before anything is published. An owned record whose draft and live already match is skipped,
sincepublishRecursive()leaves it untouched. Behavior change: a recursive publish
reaching a changed owned record of a class withoutaction(a draft-only image on a
read/create-onlyFile, say) is now403. The walk reads the draft stage, so an owned
record that exists only on Live (deleted from draft) and thatpublishRecursive()removes
from Live is not checked. -
(#235) Publish and unpublish now honour a model's
canPublish()/canUnpublish()(new
PermissionPolicy::checkStageAccess()), falling back tocanEdit()without them. This applies
to:- the root of every publish mode;
- every record a
subtree/owns/recursive/relation cascade reaches; - every unpublish;
- any delete that takes a record off Live: batch
deleteinunpublishmode, and
archive/hardon a published record (the CMS'scanArchive()askscanUnpublish()
too); - asset publishes of an existing file.
Behavior change: a class or extension that allows edit but denies publish or unpublish now
gets403 FORBIDDEN_RECORDfor those operations.ContentApiGrantExtensiondefines no
canPublish(), so service accounts it covers publish on itscanEdit()grant. The exception
is a model that overridescanPublish()directly withoutextendedCan(), where the model's
own rule now applies. -
(#228) Changing a page's class (
POST pages/$ID/convertand a composition's
page.convertTo) needs a new bareconvertverb inapi_accesson both the page's current
class and the target class, in addition toupdate(andactionwhen publishing). It was
previously allowed between any two classes grantingupdate.api_access: trueincludes
convert; a CSV list must name it ('GET,POST,PUT,action,convert'). Behavior change:
projects that convert pages must addconvertto the classes involved. -
(#235)
apply-templateneeds the page classupdateverb (not justcanEdit()),
canView()on the template, and thecreateverb pluscanCreate()on every class the apply
will create: each template element's class and every child classduplicate()cascades to.
All are checked before anything is written. -
(#235) Relation writes authorize the far side, before any list changes: a has_many item,
and every existing child a has_manysetclears, needs theupdateverb pluscanEdit()
(its foreign key is written); a many_many item needsreadpluscanView(). They previously
checked only the owning record. Behavior change: projects writing has_many relations need
updateon the child class. -
(#225) HTML-typed field writes (
DBHTMLText/DBHTMLVarchar) are sanitised with
HTMLEditorSanitiserand thecmseditor config's rules, on the module's pipeline and on
colymba's/api, so an API write stores what a CMS save would. Any token withupdateon a
class with an HTML field could previously put script into a page that renders it raw.
Behavior change: markup outside the editor rules is stripped, exactly as a CMS save strips
it. Beyond<script>andon*attributes, that includes, under the stockcmsrules: empty
<i>/<span>/<div>(icon and clearfix markup), HTML5 elements such as<section>,
<figure>,<picture>,<svg>,<video>and<button>(unwrapped), and attributes such as
data-*,srcsetandloading. A module write that lost markup carries a new
VALUE_SANITISEDwarning. New configWriteApplicator.sanitise_html(defaulttrue) turns
sanitising off, as does the CMS's ownHTMLEditorField.sanitise_server_side: false;
html_editor_config(defaultcms) picks the editor config whose rules apply, and one with no
usable rules refuses the write (500 SERVER_ERROR) rather than storing raw or stripped HTML. -
(#230) colymba's
/apiwrites drop any payload key that isn't a schema name for the class
(a DB column, a has_one or itsID/Class/Relationcolumn, a many relation,
ManyManyExtraFields). colymba assigns every key with$model->{$key} = $value, which runs a...
2.8.0
[2.8.0] - 2026-09-02
Added
- (#198) A write to a
Versionedclass whoseapi_access/content_api_accessgrants
create/updatebut not the bareactiontoken now carries anACTION_VERB_MISSINGwarning
in the response — that combination reads as full CRUD but has no way to ever publish, leaving
writes permanently draft-only with previously nothing anywhere saying so. Confirmed on two real
projects. A non-versioned class, or a class already grantingaction, is unaffected. Landed on
branch1first (1.14.0), merged up here. - (#197)
CheckGrantExtensionReachabilityTaskgained a second diagnostic,
GrantExtensionReachabilityChecker::checkMissingGrantExtension(): flags any class declaring its
ownapi_access/api_writable_fieldsthat carries noContentApiGrantExtensionanywhere in its
hierarchy at all — the existing check only ever examined a class that already had the extension
applied. Confirmed on two real projects (classes extending a plainDataObjectancestor rather
thanSiteTree/BaseElement). Landed on branch1first (1.14.0), merged up here.
Changed
- (#199) Documented that
EnvironmentGate::population_enabled_environments(and any other
array-typed security-gating config this module exposes) cannot be narrowed by a project's own
YAML override, due to SilverStripe's additive array-config merge — onlyConfig::modify()->set()
in_config.phpactually restricts it. Landed on branch1first (1.14.0), merged up here. - (#193) Added a companion "published tags are immutable" note to
docs/en/15_testing-and-contributing.md, cross-linking the tag-recovery steps this branch
already carries from PR #194. Landed on branch1first (1.14.0), merged up here.
2.7.0 — SilverStripe 6 line
Fixed
-
(#201) A composition's
page.match.idresolving to the wrong page used to silently
reparent every element in the payload whoseexternalIdalready existed anywhere on the
site —ExternalIdResolver::tryFind()is a global lookup by design, and composition force-
writes the resolved area's id onto whatever record it matches, with no check that the record
already belonged to a different page.RecordWriter::write()now rejects (new
CROSS_PAGE_REPARENTerror code, 409) when a server-derivedParentIDassignment (composition
only — an explicit clientfields.ParentIDwrite, e.g. viacontent_batch, is unaffected and
still governed solely by the existing element-type-allowed-on-page check) would move an
already-parented element onto a different page's area. Confirmed live (Rockline Industrial):
recovery needed raw SQL, since the API forbidsdeleteby design. The owner comparison checks
both id and base class —getOwnerPage()resolves against anyElementalAreasExtension
owner, not justSiteTree, so two owners from different base tables can share a numeric id.
Documented indocs/en/12_error-codes.md,docs/en/08_page-compositions.md, and the MCP tool
schema (schema/endpoints.jsonv1.17). Landed on branch1first (1.13.0), merged up here. -
(#202) A
content_batchhas_manyadd,remove, orset(theremoveAll()half) that
touched an already-published, clean related record left it desynced from LIVE with nothing to
notice or fix it —HasManyListunconditionally repoints/clears the related record's foreign
key via an ordinary draft write regardless of its Versioned state, leaving an attach stranded
modifiedOnDraftor a detach's LIVE row still pointing at the old parent.RecordWriter::write()
now republishes any already-published, NOT-already-modifiedOnDraftrecord a has_many relation
write touches this way, when the operation's ownpublish/defaultPublishisn'tnone—
authorization-checked against the related record's own classactionverb first, the same way a
subtree/ownspublish cascade checks every non-root record it touches. A record that was never
published, or that already had unrelated in-progress draft edits before this write touched it, is
left exactly as it was — this never forces an editor's own draft to LIVE. A many_manyaddwas
already unaffected for the related item (it only writes it when it isn't already in the
database). Landed on branch1first (1.13.0), merged up here. -
(#203)
dryRun: true(and a genuine atomic-failure rollback) already correctly roll back
an ordinary DB-onlyonBeforeWrite()/ValueTransformerside effect — verified with new
regression coverage rather than assumed. What no DB transaction can ever undo is a side effect
OUTSIDE the database (an HTTP call, a queued job, an external cache write) — confirmed live,
ElementOembed's oEmbed lookup left orphanEmbedObjectrows behind both a rolled-back
composition and adryRunprobe. AddedDynamic\ContentApi\Write\DryRunContext::isActive(),
a static flag any project write hook orValueTransformercan check to skip a non-DB side
effect during a dry run — this module can't intercept a project class's side effect on its
behalf, only make the dry-run state visible to it. Landed on branch1first (1.13.0), merged
up here. -
(#204) Investigated whether non-image (PDF) asset uploads have a working route — they do;
AssetServicehas never hardcodedImage, andcontent_asset_uploadcorrectly resolves a
.pdf(or any other extension) to plainFilevia the framework's own
File::get_class_for_file_extension(). The confirmed field incident's second half was real
though:AssetService::ingest()letSilverStripe\Core\Validation\ValidationException(a
disallowed extension,File.allowed_extensions) escape unmapped, surfacing as500 SERVER_ERRORwith a raw exception message instead of the422 VALIDATION_FAILEDevery other
write path in this module gives for the same failure class. Now mapped the same way. It never
silently reported success on a failed write — the controller's own top-levelThrowablecatch
already turns an uncaught exception into a proper (if less specific) error envelope. Landed on
branch1first (1.13.0, asSilverStripe\ORM\ValidationExceptionthere — branch1is SS5)
— merged up here with the exception import swapped to this branch's SS6 namespace. -
(#191, #195, #192, #205) Four write-path gaps that used to accept a request and silently do
something other than what was asked, found via a field audit of real usage on two production
consumer projects (~1,500 recorded MCP calls mined from session transcripts). All four now
reject rather than silently drop or coerce:WriteApplicator::applyFields()(and, on the colymba/apiwrite surface,
WriteGuardExtension::onBeforeWrite()) now validates a DBEnum-backed field's value against
its own declared list before writing —DBEnum::setValue()never validates, and MySQL
itself silently coerces an out-of-list value to''rather than rejecting it. New
INVALID_VALUEerror code.DBMultiEnum(aset-backed multi-select Enum subclass) is
handled correctly — each comma-separated value is checked independently.- (#191) A has_one relation named under a
relationspayload (has_many/many_many only) is
now rejected — with an actionable message — before the record is written, not after: a
relations: {"Parent": 74}on a create used to silently leaveParentIDuntouched, then
fail on unrelated-looking root-levelSiteTreevalidation instead of ever surfacing that
Parentwas the actual problem. - (#195)
LinkTransformer::transform()now rejects an unrecognized key in a structured
link payload, and a key that's only valid for a different link type (e.g.fileIdwith
"type": "ExternalLink") — both used to be silently dropped (DataObject::setCastedField()
falls back to a bare, never-persisted dynamic property when the resolved link class has no
matching column), leaving an empty or partially-populated link record with no signal
anything was wrong. - (#192)
CompositionService::compose()now also acceptsareaRelationat the request's
top level, as a defensive fallback for a caller reaching the endpoint directly over HTTP
(page.areaRelationremains the one documented shape); a mismatch between the two locations
is reported as a warning rather than resolved silently.
Compatibility note: three of these are newly-rejecting a write that previously returned
200 — a consumer currently sending a payload that happened to hit one of these gaps will now
get an error instead of a silent no-op:422 INVALID_VALUEfor an out-of-list enum value,
400 PAYLOAD_INVALIDfor a has_one underrelations, or422 UNKNOWN_FIELDfor an
unrecognized/wrong-type link key. Landed on branch1first (1.12.0), merged up here. -
(#198)
GenerateContentApiExposureTask/ExposureScaffoldergeneratedapi_access: 'GET,POST,PUT'for every class — no HTTP method maps to theaction(publish/unpublish/
archive) verb, so a project that pasted the generator's own output in as-is had a class that
looked fully CRUD-configured but could never actually publish a write through this API. Now
generates'GET,POST,PUT,action'. Landed on branch1first (1.13.0), merged up here — this
branch's ownExposureScaffolder(shared with branch1) gets the same fix. -
(#186)
ContentApiTestCasenow pinsSilverStripe\Assets\File/Imageto
api_access: falseinsetUp(). TwoAssetsTestcases failed on this branch's testbed
specifically: its own exposure config grantsImageits ownapi_access(added while closing
#174), which short-circuitsAssetHandler::governingAssetClass()'s ancestry walk before either
test'sFile-onlyConfig::modify()override was ever consulted — a host-config leak into the
module's own suite, not asilverstripe/assetsversion regression as originally suspected. The
same pin also restores the strength of the #119 unpublish-owns shared-asset regression test,
which previously depended on an absenceContentApiTestCasenever actually enforced. Landed on
branch1first, merged up here.
Changed
- (#115, #118) Merge-up of branch
1's backport of this branch's ownGenerateContentApiExposure
task (shipped here in 2.2.0) — no new capability on this branch, but the shared
ExposureScaffolder::AUTO_GENERATED_BANNERno longer hardcodes this branch'ssake tasks:
invocation syntax directly into the generated YAML's own header comment (it needed to stop
doing that so branch1'skey=valueadapter could reuse the same class); the emitted banner
text is worded generically now instead. - The shared
ExposureScaffolder's generated output now also documents (as a comment) the
separateColymba\RESTfulAPI\QueryHandlers\DefaultQueryHandler.modelsentry — the base both
this module's own/content-api/v1endpoints and colymba's generic/apisurface merge from,
perClassRegistry's own docblock, withClassRegistry.modelsas the narrower,
content-api-only overlay. A class registered only under the overlay resolves for
content_schema_classwhile every actual/api/$Modelrequest 404s or is silently invisible.
Landed on branch1first (1.13.0), merged up here.
1.14.0
[1.14.0] - 2026-09-02
Added
- (#198) A write to a
Versionedclass whoseapi_access/content_api_accessgrants
create/updatebut not the bareactiontoken now carries anACTION_VERB_MISSINGwarning
in the response — that combination reads as full CRUD but has no way to ever publish, leaving
writes permanently draft-only with previously nothing anywhere saying so. Confirmed on two real
projects. A non-versioned class, or a class already grantingaction, is unaffected. - (#197)
CheckGrantExtensionReachabilityTaskgained a second diagnostic,
GrantExtensionReachabilityChecker::checkMissingGrantExtension(): flags any class declaring its
ownapi_access/api_writable_fieldsthat carries noContentApiGrantExtensionanywhere in its
hierarchy at all — the existing check only ever examined a class that already had the extension
applied. Confirmed on two real projects (classes extending a plainDataObjectancestor rather
thanSiteTree/BaseElement).
Changed
- (#199) Documented that
EnvironmentGate::population_enabled_environments(and any other
array-typed security-gating config this module exposes) cannot be narrowed by a project's own
YAML override, due to SilverStripe's additive array-config merge — onlyConfig::modify()->set()
in_config.phpactually restricts it. - (#193) The tag-recovery doc added to branch
2(PR #194) now also appears on this branch,
which never had it; added a companion "published tags are immutable" note to
docs/en/15_testing-and-contributing.md.
1.13.0 — SilverStripe 5.2 line
Fixed
-
(#201) A composition's
page.match.idresolving to the wrong page used to silently
reparent every element in the payload whoseexternalIdalready existed anywhere on the
site —ExternalIdResolver::tryFind()is a global lookup by design, and composition force-
writes the resolved area's id onto whatever record it matches, with no check that the record
already belonged to a different page.RecordWriter::write()now rejects (new
CROSS_PAGE_REPARENTerror code, 409) when a server-derivedParentIDassignment (composition
only — an explicit clientfields.ParentIDwrite, e.g. viacontent_batch, is unaffected and
still governed solely by the existing element-type-allowed-on-page check) would move an
already-parented element onto a different page's area. Confirmed live (Rockline Industrial):
recovery needed raw SQL, since the API forbidsdeleteby design. The owner comparison checks
both id and base class —getOwnerPage()resolves against anyElementalAreasExtension
owner, not justSiteTree, so two owners from different base tables can share a numeric id.
Documented indocs/en/12_error-codes.md,docs/en/08_page-compositions.md, and the MCP tool
schema (schema/endpoints.jsonv1.17). -
(#202) A
content_batchhas_manyadd,remove, orset(theremoveAll()half) that
touched an already-published, clean related record left it desynced from LIVE with nothing to
notice or fix it —HasManyListunconditionally repoints/clears the related record's foreign
key via an ordinary draft write regardless of its Versioned state, leaving an attach stranded
modifiedOnDraftor a detach's LIVE row still pointing at the old parent.RecordWriter::write()
now republishes any already-published, NOT-already-modifiedOnDraftrecord a has_many relation
write touches this way, when the operation's ownpublish/defaultPublishisn'tnone—
authorization-checked against the related record's own classactionverb first, the same way a
subtree/ownspublish cascade checks every non-root record it touches. A record that was never
published, or that already had unrelated in-progress draft edits before this write touched it, is
left exactly as it was — this never forces an editor's own draft to LIVE. A many_manyaddwas
already unaffected for the related item (it only writes it when it isn't already in the
database). -
(#203)
dryRun: true(and a genuine atomic-failure rollback) already correctly roll back
an ordinary DB-onlyonBeforeWrite()/ValueTransformerside effect — verified with new
regression coverage rather than assumed. What no DB transaction can ever undo is a side effect
OUTSIDE the database (an HTTP call, a queued job, an external cache write) — confirmed live,
ElementOembed's oEmbed lookup left orphanEmbedObjectrows behind both a rolled-back
composition and adryRunprobe. AddedDynamic\ContentApi\Write\DryRunContext::isActive(),
a static flag any project write hook orValueTransformercan check to skip a non-DB side
effect during a dry run — this module can't intercept a project class's side effect on its
behalf, only make the dry-run state visible to it. -
(#204) Investigated whether non-image (PDF) asset uploads have a working route — they do;
AssetServicehas never hardcodedImage, andcontent_asset_uploadcorrectly resolves a
.pdf(or any other extension) to plainFilevia the framework's own
File::get_class_for_file_extension(). The confirmed field incident's second half was real
though:AssetService::ingest()letDBFile::setFromString()'sValidationException(a
disallowed extension,File.allowed_extensions) escape unmapped, surfacing as500 SERVER_ERRORwith a raw exception message instead of the422 VALIDATION_FAILEDevery other
write path in this module gives for the same failure class. Now mapped the same way. It never
silently reported success on a failed write — the controller's own top-levelThrowablecatch
already turns an uncaught exception into a proper (if less specific) error envelope. -
(#191, #195, #192) Four write-path gaps that used to accept a request and silently do
something other than what was asked, found via a field audit of real usage on two production
consumer projects (~1,500 recorded MCP calls mined from session transcripts). All four now
reject rather than silently drop or coerce:WriteApplicator::applyFields()(and, on the colymba/apiwrite surface,
WriteGuardExtension::onBeforeWrite()) now validates a DBEnum-backed field's value against
its own declared list before writing —DBEnum::setValue()never validates, and MySQL
itself silently coerces an out-of-list value to''rather than rejecting it. New
INVALID_VALUEerror code.DBMultiEnum(aset-backed multi-select Enum subclass) is
handled correctly — each comma-separated value is checked independently.- (#191) A has_one relation named under a
relationspayload (has_many/many_many only) is
now rejected — with an actionable message — before the record is written, not after: a
relations: {"Parent": 74}on a create used to silently leaveParentIDuntouched, then
fail on unrelated-looking root-levelSiteTreevalidation instead of ever surfacing that
Parentwas the actual problem. - (#195)
LinkTransformer::transform()now rejects an unrecognized key in a structured
link payload, and a key that's only valid for a different link type (e.g.fileIdwith
"type": "ExternalLink") — both used to be silently dropped (DataObject::setCastedField()
falls back to a bare, never-persisted dynamic property when the resolved link class has no
matching column), leaving an empty or partially-populated link record with no signal
anything was wrong. - (#192)
CompositionService::compose()now also acceptsareaRelationat the request's
top level, as a defensive fallback for a caller reaching the endpoint directly over HTTP
(page.areaRelationremains the one documented shape); a mismatch between the two locations
is reported as a warning rather than resolved silently.
Compatibility note: three of these are newly-rejecting a write that previously returned
200 — a consumer currently sending a payload that happened to hit one of these gaps (an
out-of-list enum value, a has_one underrelations, an unrecognized/wrong-type link key) will
now get a 422 instead of a silent no-op. -
(#198)
GenerateContentApiExposureTask/ExposureScaffoldergeneratedapi_access: 'GET,POST,PUT'for every class — no HTTP method maps to theaction(publish/unpublish/
archive) verb, so a project that pasted the generator's own output in as-is had a class that
looked fully CRUD-configured but could never actually publish a write through this API. Now
generates'GET,POST,PUT,action'. -
The generator's output now also documents (as a comment) the separate
Colymba\RESTfulAPI\ QueryHandlers\DefaultQueryHandler.modelsentry — the base both this module's own
/content-api/v1endpoints and colymba's generic/apisurface merge from, per
ClassRegistry's own docblock, withClassRegistry.modelsas the narrower, content-api-only
overlay. A class registered only under the overlay resolves forcontent_schema_classwhile
every actual/api/$Modelrequest 404s or is silently invisible.
2.6.0 — SilverStripe 6 line
Added
-
(#119) New
ownsunpublish mode —records/$ClassRef/$ID/unpublish({"mode": "owns"}):
unpublishes the record, then every descendant reachable through its$ownsconfig
(OwnedTreeWalker::walkOwnedExcluding(), a new sibling of the walker's existingwalk()),
authorization-checked the same wayownspublish mode already is (classactionverb +
canEdit()), before writing anything. This is the unpublish half #119 left open when its
publish half shipped (1.9.0/2.4.0) — the issue itself flagged it as the harder, riskier half,
not a symmetric mirror:$ownsroutinely namesFile/Image, and a single asset is routinely
owned by more than one live record at once (one file ID simultaneously serving a hero slide, a
CTA card, and a page's own product image is a realistic shape). Unpublishing a
shared asset out from under a page that never asked to be touched would 403 that page via
AssetControlExtension, with no way to see why.ownsmode never unpublishes aFile/Imagereached through the walk —is_a()-excluded at
the walker itself (covers a project's ownFilesubclass too), not filtered from the result
afterwards, so nothing reachable only through an excluded asset leaks into the target set
either. Every excluded node is still reported, in a newskippedlist
({id, className, reason: "SHARED_ASSET_CLASS"}), not silently dropped. This is a deliberate
type exclusion, not a reference count: a count would need to be exhaustive across
has_one/has_many/many_many to be safe, and getting that wrong reproduces the exact hazard the
exclusion exists to prevent — an owned asset is simply never touched by this cascade.Two more deliberate asymmetries with
ownspublish mode: the root unpublishes first, then
descendants (the opposite order from publish, which writes leaves before the root) — so there
is never a window where a page is live but points at elements that have already been pulled;
and there is no$additionalparameter — publish's internal callers use it to reach an
element's own has_many children (sinceBaseElementdeclares no$owns), and unpublish has no
call site with the same problem.Composes with, rather than replaces, the existing
Hierarchystranded-descendants guard (#71)
and itsforcebypass — checked on the root AND every walked target, not just the root: nothing
prevents an owned relation from itself being aSiteTreewith its own live tree children, and
the guard is a per-record risk, not a per-call-site one (/review-prcaught the root-only cut
of this before merge).RecordActionsHandler'sowns-mode write now runs inside aDbTransaction
(matching howPageHandler::applyTemplate()wraps its own owned-tree cascade), so a
doUnpublish()hook throwing partway through the descendant loop rolls the whole cascade back
instead of leaving a live orphan (root unpublished, only some descendants following it) with no
way to tell from the response alone. TakesdryRun(previously400 PAYLOAD_INVALIDon every
unpublish call — narrowed to permitdryRunwithmode: "owns"specifically; a plain
single-mode unpublish andarchivestill reject it outright). Real (non-dryRun) response
addsmeta.unpublished(root first)
andmeta.skippedalongside the existing serialized-record shape;dryRunresponds with
{"data": {"wouldUnpublish": [...], "skipped": [...]}, "meta": {"operation": "unpublishDryRun", "mode": "owns"}}. Spec bumped tov1.15.
2.5.0 — SilverStripe 6 line
Fixed
-
(#174, closing the known gap 1.9.0 left open)
pages/$ID/apply-templatewith
{"publish": "recursive"}now publishes each element's own duplicated descendants, not just the
elemental area and its top-level elements.1.9.0 flagged this as unverified. The mechanism is now confirmed:
TemplateElementDuplicator::duplicateElements()duplicates each template element with a bare
$element->duplicate(), soDataObject::duplicate()decides what children get created. An
element's children are only reachable by the publish walk if that element declares$owns
itself —BaseElementdoesn't — so anythingduplicate()created that$ownsdidn't name
stayed on draft after a"recursive"apply, unpublished and unauthorized, with no error and no
signal.Scope, stated precisely: no stock Dynamic element actually hits this today. The elements
with versioned children list them in$ownsas well (ElementPhotoGallery=>Images,
ElementCard=>ElementLink), so the existing walk already reached them; the one genuine
cascade-only case,ElementStatCounters=>Stats, has an unversioned child with nothing to
publish. The gap is structural rather than currently-triggered — nothing stops an element
declaring$cascade_duplicateswithout a matching$ownsfor a versioned child, and that
combination silently loses content.PageHandler::applyTemplate()now walks each element via the new
OwnedTreeWalker::walkDuplicates()and passes the result topublishOwnedTree()alongside the
area and elements. That walk reproduces whatduplicate()creates rather than reading
$cascade_duplicatesdirectly, which takes two corrections in opposite directions:- An empty
$cascade_duplicateson aVersionedrecord makes the framework substitute
$owns ∩ (many_many + belongs_to + has_many)(RecursivePublishable::onBeforeDuplicate()).
Without this, grandchildren went missing at depth 2+, where the page-level$ownswalk can no
longer cover for it. - A many_many entry creates nothing —
duplicateManyManyRelation()link-copies, attaching
the same pre-existing records to the clone. Publishing those would push shared, possibly
deliberately-draft records live and would newly403on classes no project allowlists.
OwnedTreeWalker's cycle guard, depth cap, diamond handling, and unversioned-record handling are
unchanged and now serve both walks.walk()'s signature is unchanged.Behavior change, worth reading before upgrading: a class reached only through the new walk
that grantscreate/updatebut withholdsactionnow gets403 FORBIDDEN_CLASSon a
"recursive"apply-template, and the whole call rolls back — the same behavior 1.9.0 introduced
for the composition path, now reaching these records too.Two things widen that further than it first looks, both deliberate:
- It applies to children of every element on the page, not only the ones the template just
added. The endpoint has always published every element on the page; that breadth now extends
to their children. - The
$ownsfallback reaches versioned records sitting below an unversioned intermediate,
which nothing previously walked to at all.
So an
apply-templatecall that succeeded before can now hard-fail on a class the project never
had to allowlist. That is the correct direction — those records really were created by the
duplicate and really do need publishing, and failing loudly beats leaving them silently on draft
— but it is a live endpoint changing behavior. Fix: grantactionon the affected classes, or
pass"publish": "none"if the cascade was never meant to publish them.This is also the first test coverage of any kind for
apply-template's publish behavior.
dynamic/silverstripe-elemental-templatesremainssuggested rather than required; the tests
drive stubs via two newPageHandlerconfig values,template_classand
template_applicator_class, which default to the real package's class names. - An empty
2.4.0 — SilverStripe 6 line
Added
- (#119) New
ownspublish mode onPublishOrchestrator,records/$ClassRef/$ID/publish
({"mode": "owns"}),pages/$ID/convert, and batch'spublish/defaultPublish: publishes a
record plus every descendant reachable through its$ownsconfig
(Dynamic\ContentApi\Verify\OwnedTreeWalker, built for #120's parity endpoint), authorization-
checking every one of them (classactionverb +canEdit()) before writing anything — the
same check-everything-first contractsubtreealready has, over an owned-relation graph
instead of aHierarchytree. TakesdryRun(preview the would-publish set);liveOnlyis
subtree-only and refused with400 PAYLOAD_INVALIDonowns. Spec bumped tov1.14.
Fixed
-
(#168, closing the last gap #114 left open, see #119)
CompositionService::publishAll()
andPageHandler::applyTemplate()used to publish a composition's/template's elemental area,
every element, and every element child viapublish($record, 'single', $member)—single
mode performs no authorization at all, and nothing upstream checked theactionverb for those
classes either, since element writes always pass"publish": "none"explicitly. Both call
sites now route through the newPublishOrchestrator::publishOwnedTree()instead of their own
hand-rolled publish loops, closing the gap the same way #114 closed it for each cascade's root
record.PageHandler::applyTemplate()is now also wrapped in aDbTransaction— previously its
publish step could never fail, so nothing rolled it back; now that it can (the same
authorization check as everywhere else), a mid-cascade refusal rolls back the template write
too, matching howcompose()already behaves.publishOwnedTree()'s$additionalparameter
also now refuses a non-DataObject/not-yet-written entry loudly (400 PAYLOAD_INVALID) instead
of silently dropping it, and logs a warning for the one genuinely expected drop case (an
unversioned owned relation, e.g. a plain-DataObjecthas_many child).Behavior change: any class in the walked
$ownscascade — not just elements — that grants
create/updateinapi_accessbut withholdsactionnow gets403 FORBIDDEN_CLASSon a
composition or apply-template publish, where it previously published silently with no error at
all. This reaches further than "elements":File/Imageare versioned, so an owned image
relation (a common pattern for image-bearing elements) is checked too, and asset classes are the
ones most likely to be configured read/create-only today, since publishing an asset was never
previously something this module authorization-checked. Fix: grantactionon every affected
class, or passpublish: "none"if the cascade was never meant to publish it.Known gap, not fixed here (#174):
PageHandler::applyTemplate()'s cascade doesn't include
each element's own has_many children the wayCompositionService::publishAll()'s does — if
dynamic/silverstripe-elemental-templates'TemplateApplicatorduplicates such children, they
can stay on draft after a"recursive"apply. Not verified either way: that package is optional
and isn't a dev dependency of this module, so its actual output shape can't be checked here.
2.3.0 — SilverStripe 6 line
Fixed
-
(#89)
PublishOrchestrator::findDescendantIDs()'s descendant-cascade guard used to key on
hasExtension(Hierarchy::class), but the cascade it protects against
(SiteTree::onBeforeDelete()deleting every currentAllChildren()) only fires onSiteTree
itself, and only whenSiteTree::config()->get('enforce_strict_hierarchy')is true (Hierarchy
declares noonBeforeDelete/onAfterDelete;Versionedonly hasonAfterDelete). A project
that setsenforce_strict_hierarchy: false, or aHierarchy-extended non-SiteTreemodel, was
refused with409 UNPUBLISH_STRANDS_DESCENDANTSon every ordinary unpublish/archive and had to
passforce: true— whose entire meaning is "accept the loss" — for a cascade that was never
actually going to happen. The guard now checks$record instanceof SiteTree && SiteTree::config()->get('enforce_strict_hierarchy')instead. -
(#80)
POST records/$Class/$ID/unpublishwith{"force": true}bypasses the
stranded-descendants guard, and the resulting cascade is delete-shaped — the same live-subtree
lossarchiveproduces — but was gated on theactionverb alone, unlike every other
delete-shaped path in the module (archive; the batchdeleteop withmode: "unpublish",
which already requireddeleteviaRecordWriter::delete()).RecordActionsHandlernow also
requires thedeleteverb, at both the class and record level, wheneverforce: trueis
passed tounpublish— plain, non-forcedunpublishis unchanged and still needs only
action. NewPublishOrchestrator::forceCouldStrandDescendants()gates this: per #89,force
can only ever bypass a real cascade risk on aSiteTreeclass withenforce_strict_hierarchy
enabled — on every other class the bypass is already a no-op, and demandingdeletefor it
there would itself be a breaking change for a client that defensively always sends
force: true. Exposing this as one shared method, rather than duplicating the
instanceof/config check in the handler, keeps the guard (#89) and this verb gate from being
able to silently drift apart the next time either is rescoped. -
(#114) BREAKING. A payload write's root record could publish — including
"publish": "subtree", turning one authorized write into a whole-tree publish — without ever
having itsactionverb checked.RecordWriter::upsert()/update()only ever checked
update/create; a class grantingupdatebut withholdingactionstill had its root
published.PageHandler::convert()andCompositionService::convertPage()were worse: both
checked only the pre-conversion record'supdateverb, never the target class's verbs at
all, before publishing the converted instance as root.RecordWriter::write()now also requires the class-levelactionverb whenever its payload's
publishkey is anything other thannoneon aVersionedclass — matching
PublishOrchestrator::publish()'s own no-op for a non-versioned record, so a class that can
never actually be published isn't newly required to grant a verb for it. This is the same
class-level check
PublishOrchestrator::collectSubtreeTargets()already runs for every descendant of a
subtreepublish (#90), now also covering the root.PageHandler::convert()and
CompositionService::convertPage()now check the target class'supdateverb
unconditionally, plus itsactionverb whenever the request's publish mode isn'tnone.
PageHandler::applyTemplate()gets the equivalent class-levelactioncheck for its own
"publish": "recursive"option, since it bypassesPublishOrchestratorentirely
(publishSingle()/publishRecursive()called directly) and so isn't covered by the
RecordWriterfix either.CompositionService::compose()had a third, non-conversion path to
the same gap:publishAll()calls$page->publishRecursive()directly, reachable with no
page.convertToin the payload at all, and the page's own field write (if any) never carries a
publishkey, soRecordWriter's fix above never reaches it either —compose()now checks
the page's ownactionverb beforepublishAll()runs, whenever the composition's top-level
publishis"recursive".Breaking: any class whose
api_accessgrantsupdate/createbut notaction, and that
receives writes carrying apublishkey other than"none"— including a composition's page,
whether or notconvertTois used — now 403sFORBIDDEN_CLASSwhere it previously succeeded.Not covered, and deliberately out of scope:
publishAll()also publishes the composition's
area and every element (and element child) viaPublishOrchestrator::publish($record, 'single', $member)—singlemode performs no authorization at all, and nothing upstream
checksactionfor those classes either, since element writes always pass"publish": "none"
explicitly. This is the owned-relation publish cascade #119 exists to formalize with real
authorization across a whole$ownstree, not a single root record's own verb — closing it
here would be scope creep onto that issue. Filed as #168.