feat: drafts-only write policy (#168) - #186
Open
ifahimreza wants to merge 7 commits into
Open
Conversation
DRAFTS_ONLY_OPTION plus is_drafts_only()/set_drafts_only(), matching the existing ENFORCE_DOMAIN_OPTION pattern. Off by default, read fresh via get_option() with no caching. Refs #168
Create: an explicit status=publish on a new post/page is rewritten to draft before insert when the policy is on (apply_drafts_only_to_create). Nothing public exists yet, so this is a silent downgrade — the response gains a drafts_only_override note. Update: flipping an EXISTING post/page to publish instead goes through Saddle_Approval::gate(), the same preview -> confirm_token handshake delete_of_type() already uses (guard_publish_transition). The token is bound to a hash of the rest of the payload so a preview for one edit can't be replayed to publish a different one. update_of_type()'s actual mutation is split into execute_update() so both the direct path and the gate's execute closure share it, with a $log flag to avoid double-logging a confirmed publish (the gate already logs it). Not triggered when the item is already published (no transition), or set-blocks (it never sets post_status — nothing to intercept there). Refs #168
Same on/off pattern as domain_enforced: declared as a boolean route arg, read via array_key_exists (presence, not truthiness) in update_settings(), and reported back in get_settings(). Refs #168
Mirrors Settings.jsx's pause/OAuth Switch pattern (save-on-toggle, not part of the level/tools ApplyBar): a Card between the access-level radio and the under-levelled-apps notice, backed by GET/POST /preferences' new drafts_only field. Refs #168
New tests/drafts-only-test.php drives the real wp_get_ability()->execute() path (same style as abilities-test.php): create-post/page with status=publish lands as draft when the policy is on and is unaffected when off or when status is omitted; update-post/page to publish previews without mutating anything, a confirmed token actually publishes, an already-published post isn't gated (nothing to flip), and a token can't be replayed against a different payload. Plus the on/off context-bullet pair in context-test.php and two direct getter/setter tests plus tear_down cleanup in capabilities-test.php. composer test: 672/672 green. composer lint: 0 errors (pre-existing warnings only, unrelated files). Refs #168
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.
Closes #168
What
A Saddle → Permissions setting, off by default: agent writes always land as a draft; publishing asks. Creating a new post/page with
status=publishlands as a draft instead, and the response says so. Flipping an existing post/page to publish goes through the same preview → confirm-token approval gatedelete-postalready uses. Enforced once, in theauthorize_write()path — not duplicated per ability. The system context names the policy when it's on.Why
The single most repeated piece of community advice for agent write access is "have it write drafts only and publish yourself" — everyone does it by hand with a dedicated Editor account, nobody ships it as a checkbox. This answers the #1 fear directly: write access on a live site. Week 2, pillar 2 of the post-reset
ROADMAP.md(#166).How
create-post/create-page): no pre-existing object to protect, sostatus=publishis silently rewritten todraftbefore insert. The response gains adrafts_only_overridenote (mirrors the existingmeta_deniedflag's shape).update-post/update-page): flipping an existing item to publish is the actually risky transition, so it's gated instead of downgraded — first call returns a preview +confirm_token(no mutation), the same call with the token performs the update, including the publish. The token'sbindfolds in a hash of the rest of the payload, so a preview shown for one edit can't be replayed to publish a different one. Not triggered when the item is already published (nothing to "flip").update_of_type()'s mutation logic moved intoexecute_update()so the gate'sexecuteclosure and the direct path share it, with a$logflag so a confirmed publish isn't logged twice (the gate already logs it).set-blocksis genuinely out of scope, not an oversight — it has nostatus/post_statusfield at all; onlypost_content. Adding one would be new scope beyond this ticket.Saddle_Capabilities::DRAFTS_ONLY_OPTION(is_drafts_only()/set_drafts_only()), read fresh every call — no caching. Round-tripped throughPOST/GET saddle/v1/preferences(drafts_only) the same waydomain_enforcedalready is. ASwitchon the Permissions screen (mirrorsSettings.jsx's pause/OAuth toggles — saves on toggle, not part of the level/tools Apply bar).Testing
composer test— 672/672 green.composer lint— 0 errors (2 pre-existing warnings, unrelated files).wp option get/eval. Fixtures and log rows cleaned up afterward.WP_DEBUG—composer lint/testruns were clean.🤖 Generated with Claude Code