Skip to content

Align REST API read permissions with WordPress core conventions - #1563

Merged
jtsternberg merged 34 commits into
developfrom
align-rest-read-permissions
Sep 21, 2026
Merged

jtsternberg merged 34 commits into
developfrom
align-rest-read-permissions

Conversation

@jtsternberg

@jtsternberg jtsternberg commented Sep 20, 2026 •

Copy link
Copy Markdown
Member

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:

'rest_read_capability' => 'box-capability', // holders of the box's own capability

Recognized values: a capability string (e.g. 'edit_posts'), 'box-capability' (resolves to the box's own capability, so the value can't drift), true (everyone — maps to core's exist), false (nobody, admins included — maps to core's do_not_allow), unset (current behavior). The property cascades field → box → default, modeled on show_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 are manage_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:

  • A fix for CMB2_Base::maybe_hook_parameter(): it passed the caller's default to prop(), which stores truthy fallbacks — so the first REST read of a box persisted get_field_permissions_check_cb => true onto it, overriding later resolutions. Declared parameters are now consulted without the storage side effect.
  • The oEmbed AJAX handler's permission checks now line up with CMB2's other write paths. The handler requires the caller to hold rights over the object the embed result is cached against — edit_post / edit_user / edit_comment for those object types, the taxonomy's edit_terms for terms, and the declaring box's own capability for 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 checks CMB2_Hookup already applies on its write paths. Commits b245df8 (tests) / c6e6e4b (handler).
  • Release housekeeping for 2.13.0 now rides along on the branch: version bump in the bootstrap class and PRIORITY (release flow step 1), @since tags on this branch's additions reconciled to 2.13.0, CHANGELOG/readme 2.13.0 sections (including the Props Mutantgun contributor credit), regenerated .pot and CSS banners, and a rest_read_capability mention in example-functions.php.

#1564 (the QA-skill base this branch was originally stacked on) has already merged into develop, and this PR is retargeted to develop.

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_check filters 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_options users on sites that actually register affected boxes. The maybe_hook_parameter() fix touches all cmb2_api_* hooked parameters — behavior for declared parameters is unchanged and pinned by tests; only the unintended storage side effect is removed.

Testing procedure

  • Full PHPUnit suite green in wp-env (PHP 8.3 / WP latest): 268 tests, 877 assertions — including new coverage for every property value, the field→box→default cascade, collection-listing omission, _embed-ed field reads, notice eligibility/suppression/dismissal-persistence, and the maybe_hook_parameter() regression. 12 of those tests pin the oEmbed handler's permission contract per object type (7 decline cases, 5 serve cases).
  • Browser QA (Playwright, isolated wp-env): 7-claim admin-notice matrix — visible for admin, hidden for non-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 phpcs clean.
  • This PR's checks run the full PHP 7.4–8.3 × WP matrix for the first time; all checks are green on head fe38ed5.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)

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

jtsternberg and others added 24 commits September 20, 2026 15:14
…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
… 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
…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

codecov Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.33333% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.51%. Comparing base (e93bf11) to head (f583fdb).

Files with missing lines Patch % Lines
includes/CMB2_Rest_Read_Permissions_Notice.php 85.36% 6 Missing ⚠️
includes/CMB2_Ajax.php 96.87% 1 Missing ⚠️
includes/rest-api/CMB2_REST_Controller.php 88.88% 1 Missing ⚠️
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     
Flag Coverage Δ
phpunit 70.51% <93.33%> (+2.10%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jtsternberg
jtsternberg changed the base branch from develop to playwright-browser-qa-skill September 20, 2026 19:42
jtsternberg and others added 3 commits September 20, 2026 15:48
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
@jtsternberg
jtsternberg deleted the branch develop September 20, 2026 19:49
jtsternberg and others added 2 commits September 20, 2026 15:49
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 jtsternberg reopened this Sep 20, 2026
@jtsternberg
jtsternberg changed the base branch from playwright-browser-qa-skill to develop September 20, 2026 19:50
jtsternberg and others added 5 commits September 20, 2026 19:36
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
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
@jtsternberg
jtsternberg merged commit 06783db into develop Sep 21, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant