Conversation
|
Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.
Interested in more tips and information?
|
|
Thank you for your PR! When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:
This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖 Follow this PR Review Process:
If you have questions about anything, reach out in #jetpack-developers for guidance! |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Failed lock-state writes can prevent fail-closed behavior; changelog and transport-test updates are also needed.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (3)
What changed in this PR
Adds connect-time reconciliation of the protected-owner anchor with WordPress.com.
Changes:
- Verifies ownership after authorization and updates lock state.
- Adds XML-RPC querying and protected-owner state mutation.
- Adds reconciliation tests and a changelog entry.
| File | Summary |
|---|---|
projects/packages/connection/tests/php/Protected_Owner_Test.php |
Tests reconciliation outcomes and hook ordering. |
projects/packages/connection/src/class-protected-owner.php |
Adds protected-owner lock-state updates. |
projects/packages/connection/src/class-manager.php |
Adds verification hooks, reconciliation logic, and XML-RPC querying. |
projects/packages/connection/changelog/connect-410-po-pr5-verify-the-anchor-against-wpcom-on-connect |
Documents the feature; wording and plugin changelog updates remain. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Code Coverage SummaryCoverage changed in 2 files.
|
…nnect The gate is a pure local read, so nothing kept the anchor honest once it was written: a record cleared at WordPress.com never reached the site, and a site went on gating a live feature on its own copy. Verification runs on `jetpack_user_authorized` at priority 11, after promote-on-connect has settled the master slot, when the site has a fresh user token and an answer is cheap. It asks whether the identity it has anchored is still the owner of record, so the answer does not depend on who is connecting — naming the owner instead would let a secondary user reconnecting unlock a good anchor. Fails closed without forgetting. Unreachable, refused and unimplemented all unlock rather than trust the anchor; the identity survives, so a later verification restores the lock without asking the owner to confirm again. No owner of record clears the anchor outright, which is how a wrongly anchored site recovers once support clears it at that end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ording Every reconcile test stubs `query_protected_owner_record()`, so a wrong method name, payload key or identity would have reached production while the suite stayed green. The new test asserts the exact XML elements rather than substrings: `anchored_wpcom_user_id` matches inside `anchored_wpcom_user_idX`, so a looser assertion catches the method name and nothing else. The changelog entry also prefixed the package's own name, which the monorepo guidelines reserve for entries in other projects, and opened lowercase. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Promotion and verification asked WordPress.com two separate questions on the same hook: "is the connecting user the anchored owner?" and "is the anchor still right?". One `jetpack.getProtectedOwner` reply already answers both, so they are now one callback reading the whole answer instead of two reading a third of it each. Three things follow from that. The lookup behind promotion disappears: it compared a resolved ID against the local anchor, where `is_caller` compares against WordPress.com's record, which is the authority when the two have drifted. The priority 10/11 ordering goes away, and with it a test that existed only to guard it. And the reconcile now asks even with no anchor, so an owner connecting to a site that never recorded the claim — or lost it — is anchored from the answer rather than being asked to confirm all over again. Anchoring there is not establishing: WordPress.com has already accepted a claim, and the identity is disclosed only to the owner it names, so a bystander still learns nothing and can anchor nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
83b62c2 to
a7318ce
Compare
Suspending an anchor kept an identity the site could no longer vouch for, and left it for the next connection — anyone's — to complete: a bystander's request carried the stored ID, WordPress.com confirmed it, and the lock came back without the owner involved. Clearing instead leaves nothing behind, at the cost of the cheaper recovery route. Only the owner can re-anchor now, because the answer names them to nobody else. With no suspended state left, the `locked` flag has nothing to say: holding an anchor and being protected by it are the same thing. `set_locked()` goes, and `get_locked()` and `is_locked()` stay as the names gates read by. Also fixes two things the tracing turned up. Re-pointing only moves the cached local ID, so adopting a *different* owner left the anchor naming the account that no longer owns the site; it is replaced when the identity changes. And the binding was written before the anchor, so a failed anchor write left one behind — the ordering this commit exists to avoid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`getProtectedOwner` no longer gets the protected owner — it never returns the owner at all — while contract A's endpoint is already named for its contract. Renamed to `jetpack.reconcileProtectedOwner` while nothing is built against it; afterwards it would cost a deploy-window fallback. The answer now carries `caller_wpcom_user_id`, the identity on the signing token, in place of an owner ID that was only ever returned to the owner. It discloses nothing the site could not already fetch, and it restores what the old two-call path did as a side effect: writing the connecting user's binding, which connecting itself clears. That write is back for every user, not only the owner — and on the bystander path it is free of the ordering constraint, because nothing is anchored there for it to strand. Pending CONNECT-461 building the endpoint this way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replacing the promote and verify test blocks took out everything between them, which included the ten tests for `resolve_protected_owner_state()` and two for `Protected_Owner::repoint()` — neither of which this PR touches. The coverage check caught it: the uncovered lines it reported were almost entirely the state resolver's. Restored unchanged, apart from dropping the `locked` key from one fixture, and `test_connecting_without_an_anchor_changes_no_ownership` re-expressed against the reconcile: a site WordPress.com holds no owner for changes no ownership, whoever connects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // dropped rather than suspended: an anchor nobody confirmed is not state to leave lying | ||
| // around for somebody else's connection to complete. | ||
| if ( ! is_array( $record ) || ! isset( $record['has_owner'] ) ) { | ||
| Protected_Owner::clear(); |
There was a problem hiding this comment.
Hey @bindlegirl , this is very similar to #52712 (comment) .
What do you think is the best course of action here? Since failind to delete an option should be pretty rare, I think we are OK leaving the anchor as it is and returning false.
There was a problem hiding this comment.
Agreed. Leaving it sounds fine. We could add some error logging but most likely not here. Maybe in reconcile_protected_owner() ?
It still described failing closed without forgetting, which the code stopped doing when suspending gave way to clearing. It now says what happens instead, including that a refused delete leaves the anchor standing — that write is the only mechanism there is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@darssen Thanks for working on this. I like this second approach better 😄
|
Two fixtures still built anchors carrying `locked`, and `Protected_Owner::set()` still opened by saying it locks one. Harmless — the code ignores the key — but they describe a shape this PR removed, which is the kind of thing that misleads whoever reads them next. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two corrections to the shape this PR arrived at. Asking on every authorization meant a site with no protected owner made a WordPress.com request it could not act on, which is not "behaves exactly as today" for the overwhelming majority of sites. Recovering a lost anchor was the reason, and it turns out not to need this: the owner reaches the same confirm dialog, the claim answers `already_yours`, and the anchor is written. Same button, one click, no new surface — so the reconcile goes back to maintaining what a claim created. And silence is not an answer. Unreachable, refused, unimplemented and malformed now leave the anchor exactly as it was. Dropping a lock because WordPress.com had a bad minute costs a merchant their payouts until the owner happens to connect, and a request that never arrived is no evidence against a record that was confirmed once. State moves only when WordPress.com actually says something: no owner, a different owner, or this caller is the owner. Nothing is ever left half-suspended, which was the worry that argued for clearing in the first place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thanks for the review @bindlegirl! I implemented the changes asked here and also, as internally discussed, I've decided to not clear the anchor when there is a wpcom failure and only try to reconcile when an anchor exists. Note that this loses the ability to self-recovery but it avoids asking for every authorize when an anchor doesn't exist. |
bindlegirl
left a comment
There was a problem hiding this comment.
One more small test comment:
test_connecting_to_an_unowned_site_changes_no_ownership (1156) — no anchor, stubs a has_owner => false record that's never consulted. It's also stranded above the // ── reconcile_protected_owner ── section header (leftover from the restore-tests commit). The has_owner === false clear path is already properly covered — with an anchor — by test_no_record_at_wpcom_clears_the_anchor (1329).
test_a_bystander_cannot_anchor_a_site_with_no_record (1466) — no anchor, stubs bystander_answer( false ) that's never consulted.
Both collapse to the same assertion as test_an_unanchored_site_asks_nothing (1351), which tests it precisely (query… expected never()). I'd delete these two, or if you want to keep the "master slot untouched" flavor of #1, rename it and drop the dead $record.
| * them, and whether the user connecting is them — so the lock, the binding and the master slot | ||
| * are all decided together rather than from two calls that could disagree. | ||
| * | ||
| * Fails closed. Unreachable, refused and unimplemented all drop the anchor rather than trust |
There was a problem hiding this comment.
This isn't true any longer, right?
There was a problem hiding this comment.
No, it isn't. Good catch 👍 . Every sentence of that paragraph describes the
clear-on-failure behaviour, which we reverted: silence now leaves the anchor
exactly as it was. It also didn't mention the anchor gate, which is the first
thing the method does.
Fixed in d32a34a.
| return; | ||
| // The identity is disclosed only to the owner it names, so this branch is the one place the | ||
| // site can learn it. Anchoring here is not establishing: WordPress.com already accepted a | ||
| // claim, and this catches up a site that never recorded it or lost the record. |
There was a problem hiding this comment.
I'm confused by this comment too. Is it correct?
There was a problem hiding this comment.
Yes, correct too. I fixed it in f6e60d8.
| /** | ||
| * A failed anchor write leaves no binding behind for a later connection to build on. | ||
| */ | ||
| public function test_a_failed_anchor_write_leaves_no_binding() { |
There was a problem hiding this comment.
I think there is a $this->anchor(); missing here?
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…branch Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hat is precise Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thanks for the re-review. Fixed tests in 1a38294. I deleted both and folded the master-slot assertion into |
bindlegirl
left a comment
There was a problem hiding this comment.
Thank you for addressing the remaining issues. I think we are good to go.



Part of CONNECT-410
Proposed changes
Manager::reconcile_protected_owner(), hooked onjetpack_user_authorized. One call to WordPress.com settles the whole question: whether an owner exists, whether this site's anchor still names them, whether the user connecting is them, and what that user's own WordPress.com identity is.promote_protected_owner_on_connect()(CONNECT-454). Its question — "is the connecting user the anchored owner?" — is answered by the same reply, so it no longer needs a call of its own.lockedflag andProtected_Owner::set_locked(). An anchor nobody confirms is dropped rather than suspended, so holding one and being protected by it are the same thing.Depends on a WordPress.com change
This calls
jetpack.reconcileProtectedOwner, which CONNECT-461 will build. Three points to agree, none of them shipped yet, so this should not merge before that is settled:jetpack.getProtectedOwner. It no longer gets the protected owner — it never returns the owner at all now — and contract A's endpoint is already named for its contract (assertProtectedOwner). Free to change while nothing is built; a deploy-window fallback afterwards.caller_wpcom_user_id, always returned. It is the identity on the signing token, so it discloses nothing the site could not already fetch, and it restores something the old two-call path did as a side effect: writing the connecting user's binding, which connecting itself clears.wpcom_user_id, now redundant — it was non-zero only whenis_callerwas true, and in that case it equals the caller's ID by definition.The response the site expects:
Notes for review
Does this pull request change what data or activity we track or use?
No.
Testing instructions
jp test php packages/connection— 1106 pass.is_callerormatches; skipping either binding write; promoting regardless of role; re-pointing a stale anchor instead of replacing it; writing the binding before the anchor; a wrong endpoint name or payload key.jp phan packages/connection— 0 issues.