Repository navigation
Align REST API read permissions with WordPress core conventions - #1563
Merged
Merged
Conversation
…h core conventions WordPress core gates settings/options REST reads behind a capability (WP_REST_Settings_Controller) while object meta reads stay public. Add an opt-in (default-off) gate that applies the same convention to options-page boxes: when the cmb2_rest_enforce_options_page_read_permissions filter is enabled, REST reads of an options-page box require the box's capability (default manage_options). Non-options-page boxes, writes, deletes, and the existing cmb2_api_*_permissions_check filters are unchanged. Adds CMB2_REST::is_options_page_box() as a reusable detection helper. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
…ions alignment Add eligibility tests for the upcoming-change admin notice: an affected (REST-readable options-page) box makes it eligible; no boxes, only post boxes, the alignment gate already enabled, or a persisted dismissal make it ineligible. Also covers the filterable guide URL and render output. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
…nment Add CMB2_Rest_Read_Permissions_Notice, a self-contained admin notice that tells site owners about the upcoming alignment of options-page REST reads with WordPress core conventions. It is shown only to users who can manage options, only when the site registers at least one affected (REST-readable options-page) box whose reads are still ungated (cmb2_rest_enforce_options_page_read_permissions returning its default false). Sites with no affected boxes, or that already enabled the alignment, are not nagged. Dismissal persists via the cmb2_rest_read_permissions_notice_dismissed option, wired through the notice's is-dismissible control and a dependency- free inline AJAX handler. The explainer guide URL is a class constant (GUIDE_URL) passed through the cmb2_rest_read_permissions_guide_url filter. Hooked from the admin branch of cmb2_bootstrap(); admin_notices fires after all boxes are registered, so detection iterates CMB2_Boxes::get_all() via CMB2_REST::is_options_page_box(). Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
…rap generic Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
… convention
Rework CMB2_Rest_Read_Permissions_Notice to hook in per-box via the
cmb2_init_hookup_{$cmb_id} action (matching CMB2_Hookup and CMB2_REST) instead
of a global cmb2_admin_init registration that scanned all boxes.
- CMB2 constructor registers the notice's maybe_init_and_hookup alongside the
hookup and REST registrations.
- Notice replaces hookup()/get_affected_boxes() with a static
maybe_init_and_hookup( CMB2 $cmb ) that bails unless is_admin() and the box is
a REST-readable options-page box, tracks affected boxes in a static array, and
registers the admin_notices + dismiss handlers once on the first affected box.
- should_show() now consults the tracked-boxes array; add reset() for tests.
- Kept as a standalone static singleton rather than extending CMB2_Hookup_Base,
which is instance-per-box with an abstract universal_hooks() and does not fit a
single admin notice.
- Tests updated to the new flow (feed boxes via the constructor action), reset
static state per test, and add coverage for the constructor wiring and the
non-admin bail.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
One entry per convention/gotcha with canonical in-code example, anti-pattern, and back-compat blast radius. Includes the capture ritual (review catches -> new entry on the same branch) and the delegation rule (quote entries into subagent work orders). Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
A temporary notice doesn't warrant permanent filter API surface; a filter can be added later if a real repointing need appears. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
Failing tests for a box-registration property that declares how a box's REST reads align with WordPress core conventions: - `false`: reads stay public even with the site-wide filter enabled. - `true`: reads gated by the box `capability` immediately, filter off. - capability string: gates reads on any object type (post box + `edit_posts`). - prop unset: existing behavior unchanged, and the `cmb2_api_*` permission filters still run last and keep final say. - notice: a box declaring the prop is no longer nag-eligible; a box leaving it unset still is. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
…missions Gives boxes a first-class registration property to declare how their REST reads are permissioned, so the alignment with WordPress core conventions is reachable through configuration rather than a site-wide filter only. - `false`: reads are explicitly public; the site-wide filter never applies. - `true`: reads require the box `capability` (fallback `manage_options`), immediately, on any object type. - capability string: reads require that capability, immediately, any object type. - unset (default `null`): unchanged behavior — public reads, except options-page boxes when `cmb2_rest_enforce_options_page_read_permissions` is enabled. The declaration therefore takes precedence over the site-wide filter, which now only governs boxes that have not declared one. Precedence lives in the new `CMB2_REST::get_rest_read_capability()`, consumed by the base controller's `maybe_gate_read_by_capability()` (renamed from `maybe_gate_options_page_read()`, which no longer told the truth now that any object type can be gated). The `cmb2_api_get_box_permissions_check`/`cmb2_api_get_field_permissions_check` filters still run afterward and keep the final say (CONVENTIONS.md C4). A box that declares the property has stated its intent, so the read-permissions notice no longer counts it as affected. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
Captures the convention the `rest_read_capability` work embodies: per-box behavior belongs in the registration array, with filters as the secondary site-wide layer, and the prop taking precedence over the filter. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
…th predicates has_explicit_rest_read_capability() now mirrors exactly the values get_rest_read_capability() honors, so the notice cannot skip a box the gate still treats as undeclared. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
…unset
`'rest_read_capability' => false` reads ambiguously in plain English ("no
capability required" vs "no read capability"). WordPress already has a
canonical spelling for "everyone": the `exist` pseudo-capability, which
WP_User::has_cap() grants unconditionally ("Everyone is allowed to exist"),
logged-out visitors included.
So the declared-public value becomes `'exist'`, and `false` becomes an
unrecognized value treated as unset. These tests pin both halves:
- an options-page box with `'rest_read_capability' => 'exist'` stays publicly
readable (box + field endpoints, anonymous user) even with the site-wide
alignment filter on — empirically proving `current_user_can( 'exist' )` is
true for anonymous visitors against a real WordPress
- a box with `false` is treated as unset: the site-wide filter gates its reads,
and the admin notice still finds it nag-eligible
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
…alse` value `'rest_read_capability' => false` was the only value needing a special-cased branch in the resolver, and the one that failed the plain-English test a box prop has to pass (CONVENTIONS.md C8): "false" could just as easily mean "no capability required" as "no read capability". WordPress already spells this: `WP_User::has_cap()` grants the `exist` pseudo-capability unconditionally — "Everyone is allowed to exist", logged-out visitors included. So declaring reads public is now `'rest_read_capability' => 'exist'`, which needs no special case at all: it resolves like any other capability string and `current_user_can( 'exist' )` is true for every visitor. - get_rest_read_capability(): the `false === $declared` branch is gone; `false` now falls through as an unrecognized value and is treated as unset - has_explicit_rest_read_capability(): mirrors it exactly — explicit means `true` or a non-empty capability string - docblocks + the `$mb_defaults` prop comment document `'exist'` as the public-reads spelling, and note that being an explicit declaration it opts the box out of the site-wide filter and survives a future default change The `cmb2_api_get_box_permissions_check` / `cmb2_api_get_field_permissions_check` filters still run last and keep the final say (C4). No consumer change needed in CMB2_REST_Controller::maybe_gate_read_by_capability(). Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
Records why 'exist' (WP's everyone-capability) replaced false as the declared-public spelling for rest_read_capability. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
…cade Restates the recognized `rest_read_capability` values, at both box and field level, and adds the field-level cascade coverage: - `false` = "no": reads not permitted for anyone, administrators included (box endpoint, field endpoint, and dropped from the collections). - `true` = "yes, everyone": an alias of WordPress's `exist` capability, so reads stay public even with the site-wide alignment filter enabled. - non-empty string = that capability is required. - A field's declaration takes precedence over its box's; without one the field falls back to the box's. The two boolean values reverse their previous meaning, so the tests which pinned the old readings are rewritten (names + docblocks) to describe what the values mean now, rather than being deleted. Same for the notice: a box-level `false` is now a declaration, so it is no longer nag-eligible. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
`'box-capability'` gates reads behind the box's own `capability` property without restating its value, at box level and at field level (where a field borrows the box's capability even if the box declared its reads public). Also not nag-eligible for the notice — it is an explicit declaration. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
CMB2_REST_Controller::maybe_hook_registered_callback() passes the request's current default into CMB2_Base::maybe_hook_parameter(), which hands it to prop() as a fallback — and prop() stores a truthy fallback on the box. So the first read that defaults to "allowed" writes `get_field_permissions_check_cb => true` onto the box config, and every later read of that box inherits it as a declared answer. That is invisible while every read of a box shares one default, and breaks the moment they differ — e.g. two fields on one box with different read permissions, in one request. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
…filter maybe_hook_parameter() passed the caller's default to prop() as a fallback, and CMB2_Base::prop()/CMB2::prop() store a truthy fallback on the object. A read defaulting to "allowed" therefore wrote `get_field_permissions_check_cb => true` onto the box, and every later read of that box then found a declared parameter saying "allowed" — overriding whatever the current read had resolved. Consult the declared parameter only, and return the caller's default untouched when there is none. Callers already treat a returned value as "the parameter spoke", so the value-level behavior is unchanged; the stored side effect is gone. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
…es to fields
Every recognized value now reads as a straight answer to "who may read this?",
and each resolves to a capability string so there is one kind of answer to act
on:
- false No one may read it, administrators included — WordPress's
`do_not_allow`, which WP_User::has_cap() denies outright.
- true Everyone may read it — WordPress's `exist`, which every
visitor holds.
- 'box-capability' Holders of the box's own `capability` (fallback
`manage_options`). The only spelling of that which does not
duplicate a value living elsewhere, free to drift.
- string Holders of the named capability.
- unset The default policy, as before.
Both booleans reverse their previous meaning, which is the point: `false` used
to mean "public" while reading as "no", and `true` used to mean "gate by the
box capability" while reading as "yes". The box capability now has its own
spelling, so neither boolean has to carry a meaning its reading contradicts.
The property is also honored on an individual field, cascading exactly like
`show_in_rest`: field declaration, else box, else default policy. Precedence
stays in one place, CMB2_REST::get_rest_read_capability(), which now takes the
field being read; the fields controller passes the field it has already
resolved, so the fields collection drops fields the current user cannot read
without a second code path.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
A box response carries no field configs of its own — get_rest_box() drops them — so `_embed` reaches fields only through the embeddable fields-collection link, which is dispatched as its own request and therefore already resolves reads per field. Pinned so that stays true. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
…n-English lesson Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
…nd-forget keepalive lets the request complete across an immediate navigation, and a non-OK or failed response now logs a console warning instead of failing silently (the notice would otherwise quietly reappear on the next load). Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014MBFkWgYTTtvSKHXJcQYea
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## develop #1563 +/- ##
=============================================
+ Coverage 68.41% 70.51% +2.10%
- Complexity 1763 1823 +60
=============================================
Files 52 53 +1
Lines 4774 4888 +114
=============================================
+ Hits 3266 3447 +181
+ Misses 1508 1441 -67
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jtsternberg
changed the base branch from
develop
to
playwright-browser-qa-skill
September 20, 2026 19:42
Release-prep version strings only: init.php header/VERSION/PRIORITY and the version-encoded bootstrap class name, package.json + lockfile, the readme.txt and README.md stable tags, and the regenerated CSS banners. PRIORITY decrements 9956 -> 9955 so the newer copy hooks earlier than 2.12.0 when both are bundled on the same site, and CMB2_Bootstrap_2121_Develop becomes CMB2_Bootstrap_2130 (the _Develop suffix is flipped off at release prep). Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CWsz3J4MfzkanHyPrf3DU8
The REST read-permissions work was written against 2.12.0 as the next release; 2.12.0 has since shipped, so all 19 @SInCE tags this branch introduced now name 2.13.0. Pre-existing 2.12.0 tags elsewhere are untouched. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CWsz3J4MfzkanHyPrf3DU8
Renames the Unreleased section to 2.13.0 (carrying its two existing bullets) and documents the REST read-permissions alignment: the rest_read_capability box/field property, the site-wide cmb2_rest_enforce_options_page_read_permissions filter (default off), the admin notice for affected options-page sites, and the maybe_hook_parameter() fix. Mirrored into readme.txt one heading level deeper; that section is 2,492 words, well under wp.org's 5,000-word cap, so no trim was needed. The release date is today's; re-stamp it if 2.13.0 ships on a different day. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CWsz3J4MfzkanHyPrf3DU8
Adds it as a commented parameter on the REST example box and on its field-level counterpart, so the cascade is visible where developers copy from. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CWsz3J4MfzkanHyPrf3DU8
Regenerates languages/cmb2.pot so the admin notice's three new strings are translatable. The rest of the diff is line-number churn. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CWsz3J4MfzkanHyPrf3DU8
jtsternberg
changed the base branch from
playwright-browser-qa-skill
to
develop
September 20, 2026 19:50
A successful oEmbed lookup is cached against the object named in the request, so the handler must only accept a target the caller may already edit. These tests state that contract in both directions for each object type, and pin the options-page target to the set of option keys a registered box actually declared. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CWsz3J4MfzkanHyPrf3DU8
…rite paths
The handler read object_id and object_type straight out of the request and
cached the lookup result against them, with the nonce as the only gate. It
now validates both parameters and requires the caller to hold rights over
the named target before anything is written, per object type:
post -> edit_post on that post
user -> edit_user on that user
comment -> edit_comment on that comment
term -> the taxonomy's edit_terms capability
options-page -> the registered box's 'capability' prop
For an options-page target the object id IS the option name the result is
written into, so the capability comes from the box that declared that
option key -- which also keeps the accepted targets inside the set of
option keys CMB2 boxes actually declared. An object type outside CMB2's
core set names no store to check rights against, and is declined.
The gate sits in the AJAX handler only. get_oembed() stays open, since
CMB2_Type_Oembed::render() calls it from a screen (or a front-end form)
that has already decided who may be there.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CWsz3J4MfzkanHyPrf3DU8
…s a write gate Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CWsz3J4MfzkanHyPrf3DU8
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CWsz3J4MfzkanHyPrf3DU8
Conflict was .beads/interactions.jsonl: both sides appended to the append-only log. Resolved as the deduped union ordered by created_at; develop's 15 events turned out to be a superset of this branch's 7, so no event was dropped. CHANGELOG.md auto-merged but left a duplicate "### Enhancements" heading inside 2.13.0 — develop's security-policy entry sat under Unreleased, which this branch had already renamed to 2.13.0. Folded that bullet into the section's existing Enhancements list, and mirrored it into readme.txt, which carries [Development] entries too and would otherwise have shipped a wp.org changelog missing one. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01FkgKbPd1yuKrT4E5kHEtLs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Developers get a first-class way to say who may read a box's data over the CMB2 REST API, in the same vocabulary WordPress already uses. Today every REST-readable box is world-readable; after this change a registration can declare it:
Recognized values: a capability string (e.g.
'edit_posts'),'box-capability'(resolves to the box's owncapability, so the value can't drift),true(everyone — maps to core'sexist),false(nobody, admins included — maps to core'sdo_not_allow), unset (current behavior). The property cascades field → box → default, modeled onshow_in_rest; fields the current user may not read are omitted from collection listings (collections filter, single resources deny — core's pattern).For options-page boxes specifically, a site-wide filter (
cmb2_rest_enforce_options_page_read_permissions, default off) gates reads by the box's capability, aligning settings-data reads with core's convention (post meta reads are public; settings reads aremanage_options-gated). A dismissible admin notice appears only on sites that register REST-readable options-page boxes with no declared read capability, explaining the upcoming default change and linking to the migration guide.Also included:
CMB2_Base::maybe_hook_parameter(): it passed the caller's default toprop(), which stores truthy fallbacks — so the first REST read of a box persistedget_field_permissions_check_cb => trueonto it, overriding later resolutions. Declared parameters are now consulted without the storage side effect.edit_post/edit_user/edit_commentfor those object types, the taxonomy'sedit_termsfor terms, and the declaring box's owncapabilityfor options-page targets (which also constrains the accepted option keys to ones registered boxes actually declare) — and the requested object type is sanitized. This mirrors the checksCMB2_Hookupalready applies on its write paths. Commits b245df8 (tests) / c6e6e4b (handler).PRIORITY(release flow step 1),@sincetags on this branch's additions reconciled to 2.13.0, CHANGELOG/readme 2.13.0 sections (including the Props Mutantgun contributor credit), regenerated.potand CSS banners, and arest_read_capabilitymention inexample-functions.php.#1564 (the QA-skill base this branch was originally stacked on) has already merged into
develop, and this PR is retargeted todevelop.Motivation and Context
CMB2's REST API (2016-10-25) predates WordPress 4.7's settings endpoint (2016-12-06), so its read semantics were designed before core established the convention that settings reads are capability-gated while meta reads are public. This aligns CMB2 with that convention on a staged rollout: this release makes declaration possible and notifies affected sites; a later release flips the options-page default. No wire behavior changes in this PR unless a site opts in — the existing
cmb2_api_get_box_permissions_check/cmb2_api_get_field_permissions_checkfilters still run last and keep final say.No linked issue.
Risk Level
Low-to-minimal by design: the enforcement filter defaults off and the new property defaults unset, so unmodified sites see zero REST behavior change. The admin notice renders only for
manage_optionsusers on sites that actually register affected boxes. Themaybe_hook_parameter()fix touches allcmb2_api_*hooked parameters — behavior for declared parameters is unchanged and pinned by tests; only the unintended storage side effect is removed.Testing procedure
_embed-ed field reads, notice eligibility/suppression/dismissal-persistence, and themaybe_hook_parameter()regression. 12 of those tests pin the oEmbed handler's permission contract per object type (7 decline cases, 5 serve cases).manage_options, hidden with a declared property or the filter on, AJAX dismissal persists across reload, deleting the dismissal option restores it, guide link href correct. All pass, with anonymous REST spot-checks confirming the gate (401 with the filter on; a declared'exist'still reads 200).composer phpcsclean.Types of changes
Checklist:
Screenshots
N/A (admin-notice appearance is covered by the browser QA evidence; available on request).
🤖 Generated with Claude Code
https://claude.ai/code/session_01CWsz3J4MfzkanHyPrf3DU8