Skip to content

Releases: dynamic/silverstripe-content-api

2.9.0 — SilverStripe 6 line

Choose a tag to compare

@jsirish jsirish released this 18 Oct 16:11
e26fdfd

Behavior changes to check before upgrading (details in each entry below):

  • Composition prune also 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=0 and non-numeric/negative limit/offset are 400; an empty filter value (Title=) now filters.
  • PATCH records/$ClassRef/$ID is new and open on live by default; close it with RecordUpdateHandler.disabled_environments: [live].
  • Stricter authorization on compositions, assets, publish cascades, convert (new convert verb) 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/$ID with {"fields": {...}, "publish": "none|single"}:
    a single-record field update that needs CONTENT_API_ACCESS only and is not behind the
    population gate, so it works on a live site. Same checks as a batch update op (class verb on
    the record's own class, canEdit(), write policy and allowlist, action for publish: single); no creates or relations. It is open on live by default:
    RecordUpdateHandler.disabled_environments closes it per environment (403 ENV_FORBIDDEN,
    details[0].config names the key), and GET schema/site reports recordUpdateEnabled. See
    docs/en/04_security-model.md#live-writes-patch-recordsclassrefid for what it can reach. New
    content_records_update tool in schema/endpoints.json.
    Landed on branch 1 first (unreleased), merged up here.
  • (#231) Opt-in grant roots for ContentApiGrantExtension: grant_roots names ancestors
    (e.g. SiteTree, BaseElement) whose undeclared subclasses are granted the verbs they inherit;
    grant_exclude (shipping with ErrorPage, 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. New ClassRegistry::grantVerbs(); CheckGrantExtensionReachability reports the
    roots and exclusions and checks inherited grants. See docs/en/04_security-model.md#grant-roots-opt-in.
    Landed on branch 1 first (unreleased), merged up here.
  • (#207) Opt-in structured server-side request log (Dynamic\ContentApi\Logging\RequestLogger):
    one info entry per content-api/v1 request (endpoint, method, ClassRef/action, HTTP status,
    error code, duration, response byte size, authenticated member ID). Off everywhere by default;
    enabled_environments config (widen-only via project YAML, per #199) plus a force-ON-only
    SS_CONTENT_API_REQUEST_LOG override. A partially-failed POST batch carries its opFailures
    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 /api surface and a route matching no
    $url_handlers pattern 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 branch 1 first (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 (new PermissionPolicy::authorize()). See
    docs/en/04_security-model.md#composition-and-asset-paths for the full table.

    • Composition prune needs delete on every archived element plus canDelete(). All
      candidates are checked before any is archived.
    • A match-only composition (no page.fields) needs the page class update verb.
      createIfMissing needs the page class create verb. A parentId must exist (404 NOT_FOUND otherwise) and reaches canCreate()'s context as Parent.
    • POST assets and composition assets[] share one authorization
      (AssetService::authorizeIngest()). A new file needs create plus canCreate() with its
      folder as Parent (and Folder::canCreate() for a missing folder). overwrite needs
      update plus canEdit() on the existing file, found on the draft stage. skip needs
      read. Publishing needs action.

    Behavior changes:

    • An asset upload that does not send publish, on a class without action, is now written
      draft-only with an ACTION_VERB_MISSING warning in data.warnings; it used to publish. An
      explicit "publish": true without action is 403 FORBIDDEN_CLASS.
    • A composition assets[] entry without its own publish now follows the composition: it is
      draft-only under publish: "none" (it used to publish).
    • Composition asset ingest, overwrite and prune now need the verbs above, where they
      previously needed none of them.
      Landed on branch 1 first (unreleased), merged up here.
  • (#239) An asset publish value is parsed strictly, in JSON and multipart alike: the string
    "false" no longer reads as true, and anything other than a boolean, 1/0 or
    "true"/"false"/"1"/"0" is 400 PAYLOAD_INVALID.
    Landed on branch 1 first (unreleased), merged up here.

  • (#226) Publish mode recursive (POST records/$Class/$ID/publish and a payload publish
    key) now checks every $owns-reachable record publishRecursive() will publish: each needs
    the class action verb and the record check, the same walk and gate owns mode 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,
    since publishRecursive() leaves it untouched. Behavior change: a recursive publish
    reaching a changed owned record of a class without action (a draft-only image on a
    read/create-only File, say) is now 403. The walk reads the draft stage, so an owned
    record that exists only on Live (deleted from draft) and that publishRecursive() removes
    from Live is not checked.
    Landed on branch 1 first (unreleased), merged up here.

  • (#235) Publish and unpublish now honour a model's canPublish()/canUnpublish() (new
    PermissionPolicy::checkStageAccess()), falling back to canEdit() 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 delete in unpublish mode, and
      archive/hard on a published record (the CMS's canArchive() asks canUnpublish()
      too);
    • asset publishes of an existing file.

    Behavior change: a class or extension that allows edit but denies publish or unpublish now
    gets 403 FORBIDDEN_RECORD for those operations. ContentApiGrantExtension defines no
    canPublish(), so service accounts it covers publish on its canEdit() grant. The exception
    is a model that overrides canPublish() directly without extendedCan(), where the model's
    own rule now applies.
    Landed on branch 1 first (unreleased), merged up here.

  • (#228) Changing a page's class (POST pages/$ID/convert and a composition's
    page.convertTo) needs a new bare convert verb in api_access on both the page's current
    class and the target class, in addition to update (and action when publishing). It was
    previously allowed between any two classes granting update. api_access: true includes
    convert; a CSV list must name it ('GET,POST,PUT,action,convert'). Behavior change:
    projects that convert pages must add convert to the classes involved.
    Landed on branch 1 first (unreleased), merged up here.

  • (#235) apply-template needs the page class update verb (not just canEdit()),
    canView() on the template, and the create verb plus canCreate() on every class the apply
    will create: each template element's class and every child class duplicate() cascades to.
    All are checked before anything is written.
    Landed on branch 1 first (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_many set clears, needs the update verb plus canEdit()
    (its foreign key is written); a many_many item needs read plus canView(). They previously
    checked only the owning record. Behavior change: projects writing has_many relations need
    update on the child class.
    Landed on branch 1 first (unreleased), merged up here.

  • (#225) HTML-typed field writes (DBHTMLText/DBHTMLVarchar) are sanitised with
    HTMLEditorSanitiser and the cms editor 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 with update on 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> and on* attributes, that includes, under the stock cms rules: 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-*, srcset and loading. A module write that lost markup carries a new
    VALUE_SANITISED...

Read more

1.15.0 — SilverStripe 5.2 line

Choose a tag to compare

@jsirish jsirish released this 18 Oct 16:06
5c66a52

Behavior changes to check before upgrading (details in each entry below):

  • Composition prune also 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=0 and non-numeric/negative limit/offset are 400; an empty filter value (Title=) now filters.
  • PATCH records/$ClassRef/$ID is new and open on live by default; close it with RecordUpdateHandler.disabled_environments: [live].
  • Stricter authorization on compositions, assets, publish cascades, convert (new convert verb) 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/$ID with {"fields": {...}, "publish": "none|single"}:
    a single-record field update that needs CONTENT_API_ACCESS only and is not behind the
    population gate, so it works on a live site. Same checks as a batch update op (class verb on
    the record's own class, canEdit(), write policy and allowlist, action for publish: single); no creates or relations. It is open on live by default:
    RecordUpdateHandler.disabled_environments closes it per environment (403 ENV_FORBIDDEN,
    details[0].config names the key), and GET schema/site reports recordUpdateEnabled. See
    docs/en/04_security-model.md#live-writes-patch-recordsclassrefid for what it can reach. New
    content_records_update tool in schema/endpoints.json.
  • (#231) Opt-in grant roots for ContentApiGrantExtension: grant_roots names ancestors
    (e.g. SiteTree, BaseElement) whose undeclared subclasses are granted the verbs they inherit;
    grant_exclude (shipping with ErrorPage, 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. New ClassRegistry::grantVerbs(); CheckGrantExtensionReachability reports 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):
    one info entry per content-api/v1 request (endpoint, method, ClassRef/action, HTTP status,
    error code, duration, response byte size, authenticated member ID). Off everywhere by default;
    enabled_environments config (widen-only via project YAML, per #199) plus a force-ON-only
    SS_CONTENT_API_REQUEST_LOG override. A partially-failed POST batch carries its opFailures
    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 /api surface and a route matching no
    $url_handlers pattern 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 (new PermissionPolicy::authorize()). See
    docs/en/04_security-model.md#composition-and-asset-paths for the full table.

    • Composition prune needs delete on every archived element plus canDelete(). All
      candidates are checked before any is archived.
    • A match-only composition (no page.fields) needs the page class update verb.
      createIfMissing needs the page class create verb. A parentId must exist (404 NOT_FOUND otherwise) and reaches canCreate()'s context as Parent.
    • POST assets and composition assets[] share one authorization
      (AssetService::authorizeIngest()). A new file needs create plus canCreate() with its
      folder as Parent (and Folder::canCreate() for a missing folder). overwrite needs
      update plus canEdit() on the existing file, found on the draft stage. skip needs
      read. Publishing needs action.

    Behavior changes:

    • An asset upload that does not send publish, on a class without action, is now written
      draft-only with an ACTION_VERB_MISSING warning in data.warnings; it used to publish. An
      explicit "publish": true without action is 403 FORBIDDEN_CLASS.
    • A composition assets[] entry without its own publish now follows the composition: it is
      draft-only under publish: "none" (it used to publish).
    • Composition asset ingest, overwrite and prune now need the verbs above, where they
      previously needed none of them.
  • (#239) An asset publish value is parsed strictly, in JSON and multipart alike: the string
    "false" no longer reads as true, and anything other than a boolean, 1/0 or
    "true"/"false"/"1"/"0" is 400 PAYLOAD_INVALID.

  • (#226) Publish mode recursive (POST records/$Class/$ID/publish and a payload publish
    key) now checks every $owns-reachable record publishRecursive() will publish: each needs
    the class action verb and the record check, the same walk and gate owns mode 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,
    since publishRecursive() leaves it untouched. Behavior change: a recursive publish
    reaching a changed owned record of a class without action (a draft-only image on a
    read/create-only File, say) is now 403. The walk reads the draft stage, so an owned
    record that exists only on Live (deleted from draft) and that publishRecursive() removes
    from Live is not checked.

  • (#235) Publish and unpublish now honour a model's canPublish()/canUnpublish() (new
    PermissionPolicy::checkStageAccess()), falling back to canEdit() 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 delete in unpublish mode, and
      archive/hard on a published record (the CMS's canArchive() asks canUnpublish()
      too);
    • asset publishes of an existing file.

    Behavior change: a class or extension that allows edit but denies publish or unpublish now
    gets 403 FORBIDDEN_RECORD for those operations. ContentApiGrantExtension defines no
    canPublish(), so service accounts it covers publish on its canEdit() grant. The exception
    is a model that overrides canPublish() directly without extendedCan(), where the model's
    own rule now applies.

  • (#228) Changing a page's class (POST pages/$ID/convert and a composition's
    page.convertTo) needs a new bare convert verb in api_access on both the page's current
    class and the target class, in addition to update (and action when publishing). It was
    previously allowed between any two classes granting update. api_access: true includes
    convert; a CSV list must name it ('GET,POST,PUT,action,convert'). Behavior change:
    projects that convert pages must add convert to the classes involved.

  • (#235) apply-template needs the page class update verb (not just canEdit()),
    canView() on the template, and the create verb plus canCreate() on every class the apply
    will create: each template element's class and every child class duplicate() 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_many set clears, needs the update verb plus canEdit()
    (its foreign key is written); a many_many item needs read plus canView(). They previously
    checked only the owning record. Behavior change: projects writing has_many relations need
    update on the child class.

  • (#225) HTML-typed field writes (DBHTMLText/DBHTMLVarchar) are sanitised with
    HTMLEditorSanitiser and the cms editor 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 with update on 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> and on* attributes, that includes, under the stock cms rules: 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-*, srcset and loading. A module write that lost markup carries a new
    VALUE_SANITISED warning. New config WriteApplicator.sanitise_html (default true) turns
    sanitising off, as does the CMS's own HTMLEditorField.sanitise_server_side: false;
    html_editor_config (default cms) 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 /api writes drop any payload key that isn't a schema name for the class
    (a DB column, a has_one or its ID/Class/Relation column, a many relation,
    ManyManyExtraFields). colymba assigns every key with $model->{$key} = $value, which runs a...

Read more

2.8.0

Choose a tag to compare

@jsirish jsirish released this 02 Sep 05:19
abd94b1

[2.8.0] - 2026-09-02

Added

  • (#198) A write to a Versioned class whose api_access/content_api_access grants
    create/update but not the bare action token now carries an ACTION_VERB_MISSING warning
    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 granting action, is unaffected. Landed on
    branch 1 first (1.14.0), merged up here.
  • (#197) CheckGrantExtensionReachabilityTask gained a second diagnostic,
    GrantExtensionReachabilityChecker::checkMissingGrantExtension(): flags any class declaring its
    own api_access/api_writable_fields that carries no ContentApiGrantExtension anywhere 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 plain DataObject ancestor rather
    than SiteTree/BaseElement). Landed on branch 1 first (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 — only Config::modify()->set()
    in _config.php actually restricts it. Landed on branch 1 first (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 branch 1 first (1.14.0), merged up here.

2.7.0 — SilverStripe 6 line

Choose a tag to compare

@jsirish jsirish released this 02 Sep 03:29
89f5944

Fixed

  • (#201) A composition's page.match.id resolving to the wrong page used to silently
    reparent every element in the payload whose externalId already 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_REPARENT error code, 409) when a server-derived ParentID assignment (composition
    only — an explicit client fields.ParentID write, e.g. via content_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 forbids delete by design. The owner comparison checks
    both id and base class — getOwnerPage() resolves against any ElementalAreasExtension
    owner, not just SiteTree, so two owners from different base tables can share a numeric id.
    Documented in docs/en/12_error-codes.md, docs/en/08_page-compositions.md, and the MCP tool
    schema (schema/endpoints.json v1.17). Landed on branch 1 first (1.13.0), merged up here.

  • (#202) A content_batch has_many add, remove, or set (the removeAll() half) that
    touched an already-published, clean related record left it desynced from LIVE with nothing to
    notice or fix it — HasManyList unconditionally repoints/clears the related record's foreign
    key via an ordinary draft write regardless of its Versioned state, leaving an attach stranded
    modifiedOnDraft or a detach's LIVE row still pointing at the old parent. RecordWriter::write()
    now republishes any already-published, NOT-already-modifiedOnDraft record a has_many relation
    write touches this way, when the operation's own publish/defaultPublish isn't none —
    authorization-checked against the related record's own class action verb first, the same way a
    subtree/owns publish 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_many add was
    already unaffected for the related item (it only writes it when it isn't already in the
    database). Landed on branch 1 first (1.13.0), merged up here.

  • (#203) dryRun: true (and a genuine atomic-failure rollback) already correctly roll back
    an ordinary DB-only onBeforeWrite()/ValueTransformer side 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 orphan EmbedObject rows behind both a rolled-back
    composition and a dryRun probe. Added Dynamic\ContentApi\Write\DryRunContext::isActive(),
    a static flag any project write hook or ValueTransformer can 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 branch 1 first (1.13.0), merged
    up here.

  • (#204) Investigated whether non-image (PDF) asset uploads have a working route — they do;
    AssetService has never hardcoded Image, and content_asset_upload correctly resolves a
    .pdf (or any other extension) to plain File via the framework's own
    File::get_class_for_file_extension(). The confirmed field incident's second half was real
    though: AssetService::ingest() let SilverStripe\Core\Validation\ValidationException (a
    disallowed extension, File.allowed_extensions) escape unmapped, surfacing as 500 SERVER_ERROR with a raw exception message instead of the 422 VALIDATION_FAILED every 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-level Throwable catch
    already turns an uncaught exception into a proper (if less specific) error envelope. Landed on
    branch 1 first (1.13.0, as SilverStripe\ORM\ValidationException there — branch 1 is 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 /api write 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_VALUE error code. DBMultiEnum (a set-backed multi-select Enum subclass) is
      handled correctly — each comma-separated value is checked independently.
    • (#191) A has_one relation named under a relations payload (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 leave ParentID untouched, then
      fail on unrelated-looking root-level SiteTree validation instead of ever surfacing that
      Parent was 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. fileId with
      "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 accepts areaRelation at the request's
      top level, as a defensive fallback for a caller reaching the endpoint directly over HTTP
      (page.areaRelation remains 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_VALUE for an out-of-list enum value,
    400 PAYLOAD_INVALID for a has_one under relations, or 422 UNKNOWN_FIELD for an
    unrecognized/wrong-type link key. Landed on branch 1 first (1.12.0), merged up here.

  • (#198) GenerateContentApiExposureTask/ExposureScaffolder generated api_access: 'GET,POST,PUT' for every class — no HTTP method maps to the action (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 branch 1 first (1.13.0), merged up here — this
    branch's own ExposureScaffolder (shared with branch 1) gets the same fix.

  • (#186) ContentApiTestCase now pins SilverStripe\Assets\File/Image to
    api_access: false in setUp(). Two AssetsTest cases failed on this branch's testbed
    specifically: its own exposure config grants Image its own api_access (added while closing
    #174), which short-circuits AssetHandler::governingAssetClass()'s ancestry walk before either
    test's File-only Config::modify() override was ever consulted — a host-config leak into the
    module's own suite, not a silverstripe/assets version 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 absence ContentApiTestCase never actually enforced. Landed on
    branch 1 first, merged up here.

Changed

  • (#115, #118) Merge-up of branch 1's backport of this branch's own GenerateContentApiExposure
    task (shipped here in 2.2.0) — no new capability on this branch, but the shared
    ExposureScaffolder::AUTO_GENERATED_BANNER no longer hardcodes this branch's sake tasks:
    invocation syntax directly into the generated YAML's own header comment (it needed to stop
    doing that so branch 1's key=value adapter 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
    separate Colymba\RESTfulAPI\QueryHandlers\DefaultQueryHandler.models entry — the base both
    this module's own /content-api/v1 endpoints and colymba's generic /api surface merge from,
    per ClassRegistry's own docblock, with ClassRegistry.models as the narrower,
    content-api-only overlay. A class registered only under the overlay resolves for
    content_schema_class while every actual /api/$Model request 404s or is silently invisible.
    Landed on branch 1 first (1.13.0), merged up here.

1.14.0

Choose a tag to compare

@jsirish jsirish released this 02 Sep 04:53
85be28b

[1.14.0] - 2026-09-02

Added

  • (#198) A write to a Versioned class whose api_access/content_api_access grants
    create/update but not the bare action token now carries an ACTION_VERB_MISSING warning
    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 granting action, is unaffected.
  • (#197) CheckGrantExtensionReachabilityTask gained a second diagnostic,
    GrantExtensionReachabilityChecker::checkMissingGrantExtension(): flags any class declaring its
    own api_access/api_writable_fields that carries no ContentApiGrantExtension anywhere 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 plain DataObject ancestor rather
    than SiteTree/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 — only Config::modify()->set()
    in _config.php actually 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

Choose a tag to compare

@jsirish jsirish released this 02 Sep 02:54
9dcfa28

Fixed

  • (#201) A composition's page.match.id resolving to the wrong page used to silently
    reparent every element in the payload whose externalId already 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_REPARENT error code, 409) when a server-derived ParentID assignment (composition
    only — an explicit client fields.ParentID write, e.g. via content_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 forbids delete by design. The owner comparison checks
    both id and base class — getOwnerPage() resolves against any ElementalAreasExtension
    owner, not just SiteTree, so two owners from different base tables can share a numeric id.
    Documented in docs/en/12_error-codes.md, docs/en/08_page-compositions.md, and the MCP tool
    schema (schema/endpoints.json v1.17).

  • (#202) A content_batch has_many add, remove, or set (the removeAll() half) that
    touched an already-published, clean related record left it desynced from LIVE with nothing to
    notice or fix it — HasManyList unconditionally repoints/clears the related record's foreign
    key via an ordinary draft write regardless of its Versioned state, leaving an attach stranded
    modifiedOnDraft or a detach's LIVE row still pointing at the old parent. RecordWriter::write()
    now republishes any already-published, NOT-already-modifiedOnDraft record a has_many relation
    write touches this way, when the operation's own publish/defaultPublish isn't none —
    authorization-checked against the related record's own class action verb first, the same way a
    subtree/owns publish 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_many add was
    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-only onBeforeWrite()/ValueTransformer side 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 orphan EmbedObject rows behind both a rolled-back
    composition and a dryRun probe. Added Dynamic\ContentApi\Write\DryRunContext::isActive(),
    a static flag any project write hook or ValueTransformer can 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;
    AssetService has never hardcoded Image, and content_asset_upload correctly resolves a
    .pdf (or any other extension) to plain File via the framework's own
    File::get_class_for_file_extension(). The confirmed field incident's second half was real
    though: AssetService::ingest() let DBFile::setFromString()'s ValidationException (a
    disallowed extension, File.allowed_extensions) escape unmapped, surfacing as 500 SERVER_ERROR with a raw exception message instead of the 422 VALIDATION_FAILED every 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-level Throwable catch
    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 /api write 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_VALUE error code. DBMultiEnum (a set-backed multi-select Enum subclass) is
      handled correctly — each comma-separated value is checked independently.
    • (#191) A has_one relation named under a relations payload (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 leave ParentID untouched, then
      fail on unrelated-looking root-level SiteTree validation instead of ever surfacing that
      Parent was 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. fileId with
      "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 accepts areaRelation at the request's
      top level, as a defensive fallback for a caller reaching the endpoint directly over HTTP
      (page.areaRelation remains 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 under relations, an unrecognized/wrong-type link key) will
    now get a 422 instead of a silent no-op.

  • (#198) GenerateContentApiExposureTask/ExposureScaffolder generated api_access: 'GET,POST,PUT' for every class — no HTTP method maps to the action (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.models entry — the base both this module's own
    /content-api/v1 endpoints and colymba's generic /api surface merge from, per
    ClassRegistry's own docblock, with ClassRegistry.models as the narrower, content-api-only
    overlay. A class registered only under the overlay resolves for content_schema_class while
    every actual /api/$Model request 404s or is silently invisible.

2.6.0 — SilverStripe 6 line

Choose a tag to compare

@jsirish jsirish released this 18 Aug 21:06
fe33b99

Added

  • (#119) New owns unpublish mode — records/$ClassRef/$ID/unpublish ({"mode": "owns"}):
    unpublishes the record, then every descendant reachable through its $owns config
    (OwnedTreeWalker::walkOwnedExcluding(), a new sibling of the walker's existing walk()),
    authorization-checked the same way owns publish mode already is (class action verb +
    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: $owns routinely names File/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.

    owns mode never unpublishes a File/Image reached through the walk — is_a()-excluded at
    the walker itself (covers a project's own File subclass 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 new skipped list
    ({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 owns publish 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 $additional parameter — publish's internal callers use it to reach an
    element's own has_many children (since BaseElement declares no $owns), and unpublish has no
    call site with the same problem.

    Composes with, rather than replaces, the existing Hierarchy stranded-descendants guard (#71)
    and its force bypass — checked on the root AND every walked target, not just the root: nothing
    prevents an owned relation from itself being a SiteTree with its own live tree children, and
    the guard is a per-record risk, not a per-call-site one (/review-pr caught the root-only cut
    of this before merge). RecordActionsHandler's owns-mode write now runs inside a DbTransaction
    (matching how PageHandler::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. Takes dryRun (previously 400 PAYLOAD_INVALID on every
    unpublish call — narrowed to permit dryRun with mode: "owns" specifically; a plain
    single-mode unpublish and archive still reject it outright). Real (non-dryRun) response
    adds meta.unpublished (root first)
    and meta.skipped alongside the existing serialized-record shape; dryRun responds with
    {"data": {"wouldUnpublish": [...], "skipped": [...]}, "meta": {"operation": "unpublishDryRun", "mode": "owns"}}. Spec bumped to v1.15.

2.5.0 — SilverStripe 6 line

Choose a tag to compare

@jsirish jsirish released this 18 Aug 19:47
b03d4f6

Fixed

  • (#174, closing the known gap 1.9.0 left open) pages/$ID/apply-template with
    {"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(), so DataObject::duplicate() decides what children get created. An
    element's children are only reachable by the publish walk if that element declares $owns
    itself — BaseElement doesn't — so anything duplicate() created that $owns didn'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 $owns as 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_duplicates without a matching $owns for a versioned child, and that
    combination silently loses content.

    PageHandler::applyTemplate() now walks each element via the new
    OwnedTreeWalker::walkDuplicates() and passes the result to publishOwnedTree() alongside the
    area and elements. That walk reproduces what duplicate() creates rather than reading
    $cascade_duplicates directly, which takes two corrections in opposite directions:

    • An empty $cascade_duplicates on a Versioned record 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 $owns walk 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 newly 403 on 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 grants create/update but withholds action now gets 403 FORBIDDEN_CLASS on 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 $owns fallback reaches versioned records sitting below an unversioned intermediate,
      which nothing previously walked to at all.

    So an apply-template call 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: grant action on 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-templates remains suggested rather than required; the tests
    drive stubs via two new PageHandler config values, template_class and
    template_applicator_class, which default to the real package's class names.

2.4.0 — SilverStripe 6 line

Choose a tag to compare

@jsirish jsirish released this 18 Aug 05:36
c2c1f87

Added

  • (#119) New owns publish mode on PublishOrchestrator, records/$ClassRef/$ID/publish
    ({"mode": "owns"}), pages/$ID/convert, and batch's publish/defaultPublish: publishes a
    record plus every descendant reachable through its $owns config
    (Dynamic\ContentApi\Verify\OwnedTreeWalker, built for #120's parity endpoint), authorization-
    checking every one of them (class action verb + canEdit()) before writing anything — the
    same check-everything-first contract subtree already has, over an owned-relation graph
    instead of a Hierarchy tree. Takes dryRun (preview the would-publish set); liveOnly is
    subtree-only and refused with 400 PAYLOAD_INVALID on owns. Spec bumped to v1.14.

Fixed

  • (#168, closing the last gap #114 left open, see #119) CompositionService::publishAll()
    and PageHandler::applyTemplate() used to publish a composition's/template's elemental area,
    every element, and every element child via publish($record, 'single', $member) — single
    mode performs no authorization at all, and nothing upstream checked the action verb for those
    classes either, since element writes always pass "publish": "none" explicitly. Both call
    sites now route through the new PublishOrchestrator::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 a DbTransaction — 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 how compose() already behaves. publishOwnedTree()'s $additional parameter
    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-DataObject has_many child).

    Behavior change: any class in the walked $owns cascade — not just elements — that grants
    create/update in api_access but withholds action now gets 403 FORBIDDEN_CLASS on a
    composition or apply-template publish, where it previously published silently with no error at
    all. This reaches further than "elements": File/Image are 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: grant action on every affected
    class, or pass publish: "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 way CompositionService::publishAll()'s does — if
    dynamic/silverstripe-elemental-templates' TemplateApplicator duplicates 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

Choose a tag to compare

@jsirish jsirish released this 18 Aug 03:33
854531a

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 current AllChildren()) only fires on SiteTree
    itself, and only when SiteTree::config()->get('enforce_strict_hierarchy') is true (Hierarchy
    declares no onBeforeDelete/onAfterDelete; Versioned only has onAfterDelete). A project
    that sets enforce_strict_hierarchy: false, or a Hierarchy-extended non-SiteTree model, was
    refused with 409 UNPUBLISH_STRANDS_DESCENDANTS on every ordinary unpublish/archive and had to
    pass force: 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/unpublish with {"force": true} bypasses the
    stranded-descendants guard, and the resulting cascade is delete-shaped — the same live-subtree
    loss archive produces — but was gated on the action verb alone, unlike every other
    delete-shaped path in the module (archive; the batch delete op with mode: "unpublish",
    which already required delete via RecordWriter::delete()). RecordActionsHandler now also
    requires the delete verb, at both the class and record level, whenever force: true is
    passed to unpublish — plain, non-forced unpublish is unchanged and still needs only
    action. New PublishOrchestrator::forceCouldStrandDescendants() gates this: per #89, force
    can only ever bypass a real cascade risk on a SiteTree class with enforce_strict_hierarchy
    enabled — on every other class the bypass is already a no-op, and demanding delete for 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 its action verb checked. RecordWriter::upsert()/update() only ever checked
    update/create; a class granting update but withholding action still had its root
    published. PageHandler::convert() and CompositionService::convertPage() were worse: both
    checked only the pre-conversion record's update verb, never the target class's verbs at
    all, before publishing the converted instance as root.

    RecordWriter::write() now also requires the class-level action verb whenever its payload's
    publish key is anything other than none on a Versioned class — 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
    subtree publish (#90), now also covering the root. PageHandler::convert() and
    CompositionService::convertPage() now check the target class's update verb
    unconditionally, plus its action verb whenever the request's publish mode isn't none.
    PageHandler::applyTemplate() gets the equivalent class-level action check for its own
    "publish": "recursive" option, since it bypasses PublishOrchestrator entirely
    (publishSingle()/publishRecursive() called directly) and so isn't covered by the
    RecordWriter fix either. CompositionService::compose() had a third, non-conversion path to
    the same gap: publishAll() calls $page->publishRecursive() directly, reachable with no
    page.convertTo in the payload at all, and the page's own field write (if any) never carries a
    publish key, so RecordWriter's fix above never reaches it either — compose() now checks
    the page's own action verb before publishAll() runs, whenever the composition's top-level
    publish is "recursive".

    Breaking: any class whose api_access grants update/create but not action, and that
    receives writes carrying a publish key other than "none" — including a composition's page,
    whether or not convertTo is used — now 403s FORBIDDEN_CLASS where it previously succeeded.

    Not covered, and deliberately out of scope: publishAll() also publishes the composition's
    area and every element (and element child) via PublishOrchestrator::publish($record, 'single', $member) — single mode performs no authorization at all, and nothing upstream
    checks action for 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 $owns tree, not a single root record's own verb — closing it
    here would be scope creep onto that issue. Filed as #168.