Skip to content

Use class_exists() guard for Featured_Content. - #1

Merged
georgestephanis merged 1 commit into
Automattic:2.7.1from
tierra:feature-content-guard
Dec 17, 2013
Merged

georgestephanis merged 1 commit into
Automattic:2.7.1from
tierra:feature-content-guard

Conversation

@tierra

@tierra tierra commented Dec 16, 2013

Copy link
Copy Markdown
Contributor

This is a work-around to the problem exhibited by wp-cli/wp-cli#917, where the Twenty Fourteen theme manages to define the Featured_Content class first (as plugins are activated after the theme is loaded rather than before like core does).

This should ideally still be fixed in WP-CLI, but this workaround would still be helpful in the mean time.

@blobaugh

Copy link
Copy Markdown
Contributor

Putting the check around the class in modules/theme-tools/featured-content.php would probably be a better idea than around the include. That way it preserves the functionality in case the file gets included somewhere else.

If you can update it I will accept the pull request.

@georgestephanis

Copy link
Copy Markdown
Contributor

I actually think the way it is, more as it will preserve consistent merges for featured-content.php between wpcom and Jetpack when we run the merges between the source files there.

georgestephanis added a commit that referenced this pull request Dec 17, 2013
Use class_exists() guard for Featured_Content.

Necessary for wp-cli compatability, as wp-cli loads plugins -after- themes, as opposed to before like core does.

Implementing this in theme-tools.php rather than theme-tools/featured-content.php so as to maintain the file integrity of featured-content.php against its wpcom variant.

Props @tierra
@georgestephanis
georgestephanis merged commit 643c84f into Automattic:2.7.1 Dec 17, 2013
@obenland

Copy link
Copy Markdown
Member

I'm not sure this is something we should do. Featured Content in Twenty Fourteen is meant to be pluggable, which is why it checks the $pagenow global for the plugins page to avoid that error.

If I'm reading Jetpack's code correctly, FC gets loaded on 'plugins_loaded', before any theme action anyway, no?

@tierra

tierra commented Dec 17, 2013

Copy link
Copy Markdown
Contributor Author

Normally, yes, but it wasn't in the case of WP-CLI (which wasn't setting $pagenow since it's not in the admin panel at all - it does now per wp-cli/wp-cli#917 though). The check doesn't change the loading behavior anywhere else, so it just helps not break in the rare case that it doesn't load in the order it normally does.

@obenland

Copy link
Copy Markdown
Member

it just helps not break in the rare case that it doesn't load in the order it normally does.

When is that ever the case though?

@tierra

tierra commented Dec 17, 2013

Copy link
Copy Markdown
Contributor Author

In the current version of WP-CLI, like I said.

@obenland

Copy link
Copy Markdown
Member

I was under the impression WP-CLI wasn't setting $pagenow, causing it to break, not changing the load order.

@tierra

tierra commented Dec 17, 2013

Copy link
Copy Markdown
Contributor Author

Twenty Fourteen uses the $pagenow check to avoid including it during plugin activation from the admin screen (where the order changes from a normal frontend page load) where it would break. When using WP-CLI though, because it never set $pagenow to plugins.php (because it's not actually a user using the admin screen, it shouldn't have to technically), Twenty Fourteen includes FC before WP-CLI activates Jetpack (in the same order the admin panel plugin activation does), where Jetpack was missing the guard, causing a fatal error.

Sorry if I gave the impression that the order is different when using WP-CLI, I was wrong about that initially. The load order does change, but it does it in both WP and in WP-CLI in the same way: it changes order only during plugin activation. This also still wouldn't normally be a problem because Jetpack only loads FC during the plugins_loaded action anyway, and that isn't triggered during plugin activation in wp-admin, but it apparently is in WP-CLI.

Putting all of this aside though, this is in fact a problem we've run into, and this change just makes Jetpack a little more robust, and less prone to failure. It doesn't actually change anything inside of normal use, so it really doesn't hurt. Should Twenty Fourteen have even included the FC class in a theme in the first place? Probably not, but now that it's included in two incredibly popular locations (a default theme, and the number one most installed plugin), it really isn't a bad idea to use an include guard just in case for all the weird and wild situations like WP-CLI in which Jetpack is used.

joshbetz added a commit that referenced this pull request Feb 8, 2014
samhotchkiss added a commit that referenced this pull request Jan 16, 2015
samhotchkiss added a commit that referenced this pull request Jan 22, 2015
gibrown pushed a commit to gibrown/jetpack that referenced this pull request Feb 26, 2015
Update color thief naming conventions.
samhotchkiss pushed a commit that referenced this pull request Apr 15, 2015
jeherve added a commit that referenced this pull request May 22, 2015
Related Posts: Make sure we escape the excerpt
ChrissiePollock added a commit that referenced this pull request Aug 12, 2015
Gallery functions: fix a few indentation issues
samhotchkiss pushed a commit that referenced this pull request Aug 14, 2015
jeherve pushed a commit that referenced this pull request Jul 18, 2016
After The Deadline: add spaces for WP coding standards.
gravityrail pushed a commit that referenced this pull request Jul 24, 2016
georgestephanis pushed a commit that referenced this pull request Feb 1, 2017
Form #wordpress.com-errors
> Fatal error for http://[private link].1/me/sites?http_envelope=1&site_visibility=all on web1.api.bur in /home/wpcom/public_html/wp-content/mu-plugins/post-flair/sharing/sharing-sources.php:371 - Uncaught TypeError: Argument 2 passed to Share_Twitter::__construct() must be of the type array, null given, called in /home/wpcom/public_html/wp-content/mu-plugins/post-flair/sharing

This would happen if option 'sharing options' was set, and was an array, but for some reason its 'global' key didn't exist or wasn't an array

It this case, it would also trigger a warning:
> Warning: array_merge(): Argument #1 is not an array in /home/wpcom/public_html/wp-content/mu-plugins/post-flair/sharing/sharing-service.php on line 222

Merges r146793-wpcom.
millerf added a commit that referenced this pull request Aug 28, 2026
…regressions

Two failures from the wpcom/v2 work, both surfaced by CI.

Phan flagged PhanParamSuspiciousOrder three times in the route pattern test:
preg_match() was called with a variable pattern and a literal subject, which is
the shape of a swapped argument pair. Inline the pattern so argument #1 is the
literal Phan expects.

WPCOM_REST_API_V2_Endpoint_PayPal_Onboarding_Test::test_credentials_for_other_environment_are_rejected
failed because moving to per-environment constants lost a distinction the option
carried for free. One option value held every environment, so a sandbox-only
configuration asked for production was visibly present-but-incomplete --
platform_credentials_invalid, naming the environment. Reading one environment's
constants can only see that environment, so the same case came back as the
generic platform_credentials_missing, telling the caller to contact the Jetpack
team about credentials that are in fact configured.

Check the whole constant set before choosing the error: anything defined
anywhere means this environment specifically is unconfigured, and only a
completely bare install reports missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
millerf added a commit that referenced this pull request Sep 3, 2026
…regressions

Two failures from the wpcom/v2 work, both surfaced by CI.

Phan flagged PhanParamSuspiciousOrder three times in the route pattern test:
preg_match() was called with a variable pattern and a literal subject, which is
the shape of a swapped argument pair. Inline the pattern so argument #1 is the
literal Phan expects.

WPCOM_REST_API_V2_Endpoint_PayPal_Onboarding_Test::test_credentials_for_other_environment_are_rejected
failed because moving to per-environment constants lost a distinction the option
carried for free. One option value held every environment, so a sandbox-only
configuration asked for production was visibly present-but-incomplete --
platform_credentials_invalid, naming the environment. Reading one environment's
constants can only see that environment, so the same case came back as the
generic platform_credentials_missing, telling the caller to contact the Jetpack
team about credentials that are in fact configured.

Check the whole constant set before choosing the error: anything defined
anywhere means this environment specifically is unconfigured, and only a
completely bare install reports missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jeherve added a commit that referenced this pull request Sep 4, 2026
resolve_host_ips() trims brackets and URL-decodes before it resolves, and
both steps can produce something that is no longer a host name. "[]"
trims to the empty string, and "example%00.com" decodes to a string with
a NUL byte in it.

The NUL case was a fatal, not a failed lookup. Since PHP 8.0
gethostbynamel() and dns_get_record() throw a ValueError on a NUL byte
rather than warning and returning false, and a thrown exception walks
straight past the '@' in front of the call:

  Utils::url_is_public( 'http://example%00.com/img.jpg' )
  => ValueError: gethostbynamel(): Argument #1 ($hostname) must not
     contain any null bytes

Core's wp_http_validate_url() does not stand in the way of this. It
strips raw NULs through wp_kses_no_null() and then rejects the URL for
having changed, but a percent-encoded NUL passes through it unchanged,
and resolve_host_ips() is public in its own right.

Reject an empty host, and any host carrying a byte in \x00-\x20 or \x7f,
before either lookup. The range covers NUL, tab, newline, carriage
return, space and DEL, and stops short of \x80 so a raw UTF-8 host is
left to fail on its own at resolution time rather than here.
millerf added a commit that referenced this pull request Sep 9, 2026
* PayPal Payment Buttons: port the V2 API-driven integration onto trunk

Replays the V2 work from slash1andy/jetpack@paypal-payment-buttons-v2
(head c3dbf9f4f, 26 May 2026) onto current trunk. The fork branch was
2,278 commits behind, and three prior upstream PRs (#48807, #48808,
#49172) failed on "needs a trunk merge or rebase" because each carried
the whole V2 base as a 21k-line diff against a V1-only trunk.

Reconciled against trunk's drift:
- edit.js, edit.test.js and paypal-payment-buttons-block-tests/edit.test.js
  remapped to the .jsx paths trunk renamed them to.
- block.json: kept trunk's inline SVG icon and "monetize" category
  (#50528 matched the inserter icon to the Payment Buttons block); took
  the V2 description, keywords, supports and textdomain.
- readme.txt: kept trunk's WP compat headers and stable tag.

Dropped from the port:
- A tracked symlink (plugins/paypal-payment-buttons/paypal-payment-buttons).
- Duplicate e2e specs: paypal-api-mock.cjs was byte-identical to the .js,
  and the .cjs spec was a stale partial copy.
- Local-only dev artifacts: .wp-env.json, playwright.local.config.cjs,
  auth-setup.cjs.
- tests/php/edit.test.js, a JS test misfiled under the PHP suite.

Also corrects three inaccurate security claims in readme.txt: credentials
are encrypted with libsodium's sodium_crypto_secretbox (XSalsa20-Poly1305)
keyed from AUTH_KEY via BLAKE2b, not AES-256-CBC keyed from LOGGED_IN_KEY
and LOGGED_IN_SALT. WOOPTP-261 recorded this fix in March; it never
reached this branch.

Refs WOOPTP-156, WOOPTP-389, WOOPTP-390, WOOPTP-391, WOOPTP-393.

* PayPal Payment Buttons: move JSX-bearing V2 modules to .jsx

Trunk stopped enabling the JSX babel plugin for .js files (b531b5e113,
"Clean up JSX-in-.js"), so six V2 source modules and two of their test
files failed to build with "Support for the experimental syntax 'jsx'
isn't currently enabled".

Renames deprecated, paypal-button-preview, paypal-logo, save,
shipping-panel and variant-builder, plus the save and
paypal-button-preview tests. Every importer is extensionless, so no
import sites needed changing.

* PayPal Payment Buttons: restore the SimplePayments editor test

The fork replaced tests/js/edit.test.js with a copy of the V2 block
test, dropping SimplePayments editor coverage entirely (both files
carried identical test names). Restores trunk's version; the V2 test
keeps its own home at paypal-payment-buttons-block-tests/edit.test.jsx.

* PayPal Payment Buttons: update V2 tests to the shipped UI

The fork's May design work changed the UI without updating its tests, so
19 specs across three suites were failing on the branch before the port.
None of these are behaviour changes — only the tests move.

- @wordpress/components mock was missing ButtonGroup and
  __experimentalConfirmDialog, added by the format switcher and the
  delete-confirmation work. Both resolved to undefined, so the whole
  edit component threw "Element type is invalid" and rendered nothing.
  That alone accounted for 14 of the 19.
- Checkout button label is "Buy Now With" plus a wordmark, and the link
  class is __checkout-link, not __paypal-link.
- The debit/credit button no longer exists; the theme-native
  wp-element-button replaced the gold + debit pair. Its two specs now
  assert the theme-native button and its absence.
- buttonText and buttonType are no longer preview props.
- Headings are "Create/Edit PayPal Payment Button".
- The wizard skips the welcome step when the connection response reports
  no partner referrals, which is what the default mock describes.

Jest: 105 tests across 11 suites, all passing.

* PayPal Payment Buttons: fix the V2 PHP suite

52 of 243 PHP tests were failing on the fork before the port. Three
causes, one of them a real gap.

Cached-token fixtures stored plaintext (47 failures). get_access_token()
reads the transient as ciphertext, so decryption failed, the client fell
through to a live token request, and PayPal answered 401 — meaning the
suite was reaching the network. Fixtures now store PayPal_OAuth::encrypt()
output, which keeps the tests offline.

PayPal_OAuth_Test leaked credentials between tests (4 failures). Its
tearDown deleted the options but not the request-scoped credentials
cache, so has_credentials() reported a stale true. Uses disconnect(),
which clears both.

store_credentials() did not strip markup (1 failure). The code chose
trim() over sanitize_text_field() to preserve OAuth characters (+, /, =),
which is a fair concern, but left tags intact. wp_strip_all_tags() keeps
those characters and still removes markup that no real PayPal credential
contains.

PHPUnit: 243 tests, 502 assertions, all passing.

* PayPal Payment Buttons: fix CI lint, Phan, and PHP test failures

- Stylelint: unitless line-height, no empty SCSS comments, short hex.
- ESLint/Prettier: reformat, drop indented JSDoc, use locators in the
  Playwright spec so the suite passes --max-warnings=0.
- Phan: replace isset() ternaries with ??, qualify \WP_Error in
  namespaced docblocks, widen the return types that propagate WP_Error,
  implement WP_List_Table::ajax_user_can(), and type the REST
  validate_callback parameter as mixed.
- PHPUnit: set WP_Block_Supports::$block_to_render before calling the
  render callback, so WP releases without the null guard stop warning.
- Remove the changelog entries added by this PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: add changelog entries for the V2 integration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: de-duplicate the E2E suite and refresh the docs

The package carried an unrunnable copy of the plugin's Playwright spec and
mock, drifted a few hundred lines behind it. Delete it, porting over the one
test the plugin copy lacked; the stale debit/credit-link test is dropped, as
that markup is no longer rendered.

Wire the surviving suite up the way every other plugin does it: move the spec
under specs/ and the mock under helpers/ so the shared e2e-commons config
matches them, drop the hand-written playwright.config.js that was shadowing
it, and convert both files from CJS to ESM so they load in a type=module
package.

Rewrite DEVELOPMENT.md and docs/test_plan.md against the monorepo: real jp
commands and paths, current test counts, and the actual option keys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: cover the onboarding and REST paths with unit tests

The V2 port landed the credential exchange, the Partner Referrals flow, and
the REST controller with almost no unit coverage: 10% on
PayPal_Partner_Onboarding, 18% on PayPal_REST_Controller, and 18% on the
wpcom onboarding endpoint. These handle merchant credentials and payment
links, so they are the ones worth pinning down.

Adds a URL-routing pre_http_request mock to each suite and covers the signup
link, the auth-code exchange, merchant status, the connect/disconnect
lifecycle, and the button read routes -- including the cases where a partial
connection would otherwise be left behind, and the sandbox/production host
selection on both sides.

packages/paypal-payments: 243 -> 275 tests, 502 -> 605 assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: register the REST routes in Jetpack and proxy onboarding through WordPress.com

The Jetpack block loader only wired up register_block() and the editor assets,
never init_api() -- which is the sole caller of
add_action( 'rest_api_init', PayPal_REST_Controller::register_routes ).
Only the standalone plugin called it, so under Jetpack the block rendered but
every jetpack/v4/paypal/* request the editor made returned 404.

generate_signup_link() also diverged from its own design: the wpcom endpoint's
docblock says the plugin calls it via Client::wpcom_json_api_request_as_blog(),
but the plugin called PayPal directly using a partner ID no code ever set, so
onboarding could not have succeeded even with the routes registered. It now
proxies through wpcom/v2/paypal/onboarding/signup-link and stores the partner
merchant ID that endpoint returns, keeping Automattic's platform credentials
off the site. The seller nonce stays local -- it is the PKCE code_verifier the
auth code exchange needs.

The standalone plugin now defines JETPACK__WPCOM_JSON_API_BASE, which only
plugins/jetpack defined; without it the proxied request URL has no host and
signing fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: stop the Jetpack block loader shadowing jetpack-script-data

Calling init_api() from the Jetpack block loader also hooked
register_standalone_script_stubs() onto init priority 1. Script_Data registers
the real jetpack-script-data handle on wp_loaded, which fires after init, so
the stub won and the editor was left without window.JetpackScriptData --
breaking the post editor for every block, not just this one.

Split the REST wiring into init_rest_api() and call that from Jetpack, so the
routes still register and the stubs stay with the standalone plugin that needs
them. register_standalone_script_stubs() also now bails outright when the
Jetpack plugin is present, since its wp_script_is() guard cannot be correct at
init priority 1.

Adds regression tests for both, and casts the json_decode() results in the new
tests so Phan can narrow them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: move the REST routes to the wpcom/v2 namespace

The block editor could not reach any PayPal route on a WordPress.com Simple
site. Every call 404'd at the proxy:

  POST /jetpack/v4/sites/<id>/paypal/connect -> {"error":"not_found"}

The routes register fine; the namespace is the problem.
public-api.wordpress.com only routes wpcom/v2 for Simple sites -- jetpack/v4
is the Jetpack plugin's own namespace, and a Simple site has no Jetpack plugin
to proxy to. A bare /jetpack/v4/site 404s there for the same reason.

Move REST_NAMESPACE to wpcom/v2 unconditionally rather than branching on
IS_WPCOM. wpcom/v2 is served in all three environments: by WordPress.com for
Simple sites, and by the Jetpack plugin itself on Atomic and self-hosted via
_inc/lib/core-api/load-wpcom-endpoints.php. This matches the Donations block,
which reaches wpcom/v2/memberships/* the same way on every host.

A conditional constant would also not have compiled -- function calls are not
allowed in PHP constant expressions, so `defined( 'IS_WPCOM' ) ? ... : ...` on
a class const is a fatal error.

The WPCOM-side onboarding endpoint is unaffected; it already lives in wpcom/v2
and still holds Automattic's PayPal platform credentials server-side.

Updates the client API_BASE, the PHP and JS tests, the E2E route mocks and the
REST reference doc to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: register the Payment Links admin page in Jetpack

PayPal_Admin_Page::init() was only reachable through init_admin(), which the
standalone plugin calls from its own bootstrap but the Jetpack block loader
never did. On any site running Jetpack -- Simple, Atomic or self-hosted --
class-paypal-admin-page.php and class-paypal-payment-links-list-table.php
never executed, so there was no menu entry and no page to manage links from.

Wire init_admin() into the Jetpack loader behind an is_admin() guard, mirroring
how the standalone plugin does it. Left init_api() alone: the standalone plugin
already calls init_admin() separately, so folding it in there would drop that
is_admin() guard and duplicate the call on the one host that already worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: cover REST route registration

register_routes() was never called from a test, leaving it and the
get_button_create_args() schema builder it calls entirely uncovered -- roughly
400 of the 461 uncovered lines the coverage check flagged on this file.

Add tests that register the routes the way production does, on rest_api_init,
and assert the resulting route table: that all nine routes land under wpcom/v2,
that none are left behind under jetpack/v4, that each answers only its intended
HTTP methods, that every endpoint carries a callable permission_callback, and
that the button creation schema declares the arguments the editor sends.

Registering on rest_api_init rather than calling register_routes() directly
matters: register_rest_route() calls _doing_it_wrong() outside that action, and
this suite runs with failOnNotice="true".

The permission_callback assertion is the load-bearing one -- a route registered
without it would expose merchant credentials and button management to anonymous
requests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: read the platform credentials from constants

WordPress.com keeps secrets in its configuration as constants, not options, and
the jetpack_paypal_platform_credentials option was never provisioned -- so
onboarding could not work on WordPress.com as written.

Read the platform client ID, client secret and partner merchant ID from
PAYPAL_BUTTONS_{PRODUCTION,SANDBOX}_* instead.

Two details worth recording, because the obvious spelling of this change is
broken in both:

Only the constant *names* are stored in the class constant, not the values. A
constant expression that references an undefined constant parses cleanly and
then throws an Error the first time it is evaluated. These constants exist only
on WordPress.com, and this file also ships to self-hosted sites through the
Jetpack plugin, so embedding them directly would fatal there.

The values are read with Constants::get_constant() rather than
defined()/constant(). A real constant cannot be undefined once set, which would
make the missing-credentials and wrong-environment cases untestable after any
earlier test defined them. subscribers.php already uses the Constants package
in this same synced directory, so it is available on WordPress.com.

Error codes and their meanings are unchanged: platform_credentials_missing when
nothing is configured, platform_credentials_invalid when the environment is only
partially configured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: stop the onboarding route colliding with itself on WPCOM

Moving the package's routes to wpcom/v2 put two different classes on one path.
The package registers the editor-facing route:

  wpcom/v2 + /paypal + /onboarding/signup-link

and plugins/jetpack registers the privileged platform endpoint:

  wpcom/v2 + paypal/onboarding + /signup-link

Byte-identical. While the package was on jetpack/v4 these were distinct and the
proxy between them made sense; afterwards they were the same route with two
owners, on every host that runs the Jetpack plugin, since it ships both.

generate_signup_link() then made it worse by calling
wpcom_json_api_request_as_blog() unconditionally: on WordPress.com that asks
public-api for the very route the current request is serving. The dispatch came
back rest_no_route, and it could not have worked regardless -- the client sends
a raw JSON body, so the endpoint's required environment and referral arguments
never resolve.

Give the platform endpoint its own path, wpcom/v2/paypal/platform/signup-link,
and short-circuit on WordPress.com: rest_do_request() dispatches in-process,
keeping the permission check and argument validation, instead of leaving the
process to come back to it. Self-hosted sites still proxy, unchanged.

Adds a regression test asserting the platform route is not one the package's own
controller registers, which is the invariant that broke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Remove unecessary fix

* PayPal Payment Buttons: fix the static analysis and credential error regressions

Two failures from the wpcom/v2 work, both surfaced by CI.

Phan flagged PhanParamSuspiciousOrder three times in the route pattern test:
preg_match() was called with a variable pattern and a literal subject, which is
the shape of a swapped argument pair. Inline the pattern so argument #1 is the
literal Phan expects.

WPCOM_REST_API_V2_Endpoint_PayPal_Onboarding_Test::test_credentials_for_other_environment_are_rejected
failed because moving to per-environment constants lost a distinction the option
carried for free. One option value held every environment, so a sandbox-only
configuration asked for production was visibly present-but-incomplete --
platform_credentials_invalid, naming the environment. Reading one environment's
constants can only see that environment, so the same case came back as the
generic platform_credentials_missing, telling the caller to contact the Jetpack
team about credentials that are in fact configured.

Check the whole constant set before choosing the error: anything defined
anywhere means this environment specifically is unconfigured, and only a
completely bare install reports missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: move the platform endpoint to jetpack-mu-wpcom

The onboarding endpoint was in plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/,
which never reaches a WordPress.com Simple site. Simple loads endpoints from
wp-content/rest-api-plugins/jetpack-endpoints/, populated by WordPress.com's own
sync rather than by anything rsynced from here, so the route did not exist and
every signup-link call came back rest_no_route.

packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/README.md is explicit that
this directory replaces both that location and the classic plugin's for
WPCom-only endpoints. This endpoint qualifies without ambiguity: it exists to
hold Automattic's PayPal platform credentials, which live nowhere but
WordPress.com. Self-hosted sites call it through public-api and must never serve
it -- registering it there only ever produced platform_credentials_missing.

Moving it means the endpoint ships in mu-wpcom-plugin and wpcomsh, so it reaches
Simple and Atomic through the same rsync developers already use, with no
WordPress.com repository change needed to test it.

The route keeps its wpcom/v2/paypal/platform/signup-link path. Reverting to
/onboarding/ would collide again: the package's editor-facing route registers on
these same hosts, and the move changes which project ships the endpoint, not
whether the two coexist.

Adapted for the package: WorDBless instead of the plugin's Jetpack_REST_TestCase,
assertNotInstanceOf instead of the WP core assertNotWPError, and the
jetpack-mu-wpcom text domain.

The route assertion no longer calls rest_get_server(). Building the whole route
table cost enough memory to push this suite past its 256M limit -- it already
peaks at 241M -- so it reads the controller's own namespace and base instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: dispatch the platform call in-process on WordPress.com

Client::wpcom_json_api_request_as_blog() does short-circuit on WordPress.com,
which is why this branch was removed earlier as redundant. It short-circuits
through WPCOM_API_Direct, and WPCOM_API_Direct does not resolve this route.

A sandbox request log settles it. All of these lines belong to one PHP process
serving one request:

  => public-api.../wpcom/v2/sites/<id>/paypal/onboarding/signup-link
     PAYPAL ENDPOINT FILE LOADED
     PAYPAL load_plugin ran; class_exists: yes
     PAYPAL register_routes CALLED
     404 {"code":"rest_no_route", ...}

register_routes() ran, so the platform routes were on rest_get_server() before
the outgoing call was made, and that call still came back rest_no_route.
Whatever route table WPCOM_API_Direct consults, endpoints registered by
jetpack-mu-wpcom's plugins_loaded loader are not in it.

rest_do_request() dispatches against rest_get_server(), the server the log
proves the routes are on, and still runs the endpoint's permission check and
argument validation. Self-hosted sites keep the HTTP path unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: opt the platform endpoint out of the WPCOM site rewrite

WordPress.com's centralize.php filters rest_endpoints and moves every wpcom/v2
route to /wpcom/v2/sites/<site>/... unless the controller opts out. Meanwhile
Client::wpcom_json_api_request_as_blog() builds a flat URL -- the blog ID
travels as a signed argument, never in the path:

  $url = sprintf( '%s/%s/v%s/%s', $base, $base_api_path, $version, $path );

So the caller asked for /wpcom/v2/paypal/platform/signup-link while the route
had been renamed to /wpcom/v2/sites/<site>/paypal/platform/signup-link. The two
never met, and every call came back rest_no_route. That is the whole bug; the
route was registered the entire time, which is why instrumenting the endpoint
showed register_routes() running in the same request that 404'd.

Set wpcom_is_site_specific_endpoint = false so the flat route survives, and
wpcom_is_wpcom_only_endpoint = true so public-api's proxy_jetpack() stops trying
to forward the call to a Jetpack site and answering rest_not_implemented. The
first implies the second, but both are set explicitly, as
WPCOM_REST_API_V2_Endpoint_Following does. The flat shape is also the honest
one: no per-site data is involved, only Automattic's platform credentials, and
the merchant is identified by the referral body.

Reverts the IS_WPCOM branch added in the previous commit. With a flat route the
plain Client call resolves on WordPress.com through WPCOM_API_Direct, and it
sets the is_jetpack_authorized_for_site filter that a blog-token call expects --
which a hand-rolled rest_do_request() would have skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: clear the connect error when the wizard changes step

* PayPal Payment Buttons: show payment links in the admin list

* PayPal Payment Buttons: include the description error in form validity

* PayPal Payment Buttons: attach the admin inline script to fix Copy to Clipboard and Send Email buttons

* PayPal Payment Buttons: enable email in admin

* PayPal Payment Buttons: fix missing delete warning in admin list and detail view

* PayPal Payment Buttons: remove the unused standalone plugin entry point

index.js, block-v2.json and save.jsx were the entry point for running the
block as a standalone plugin outside the monorepo. Nothing references them:
the webpack entry list only builds editor.js, which registers the block with
`save: () => null` and renders the frontend in PHP.

save.jsx was also stale — it renders the PayPal-wordmark button that
WOOPTP-269 replaced with the theme-native `wp-element-button`.

paypal-logo.jsx and currency-symbols.js stay; paypal-button-preview.jsx
still uses both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01781PzAZGvxVGEt3z7UWCXr

* PayPal Payment Buttons: stop sending unit_amount at both product and variant level

PayPal rejects a line item that specifies `unit_amount` at both the product
level and the variant level ("unit_amount is specified at both product level
and variant level"), so adding per-option prices always failed.

Two things caused it. The editor always sent a product-level `unit_amount`,
and the REST layer re-added one even when the editor omitted it — and turned
the editor's empty per-option `value: ''` into `'0.00'`, which made every
option look priced.

Per-option prices now replace the product-level price rather than sitting
alongside it:

- The product price is only required when the options aren't priced
  individually. The field relabels to "Price (not used)" and explains why.
- Per-option pricing is all-or-nothing within the group. A half-priced group
  would leave options unpriced, since there is no base price to fall back on.
- Empty per-option values are dropped as "no price" rather than becoming
  0.00, and prices on non-primary dimensions are dropped.
- `unit_amount` is no longer `required` in the REST schema, and request
  validation reads the variants so it knows a missing amount is legitimate.
- With no product price, the frontend and editor preview show the cheapest
  option as "From $10.00" instead of no price at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01781PzAZGvxVGEt3z7UWCXr

* PayPal Payment Buttons: surface the disconnected state instead of failing on save

The PayPal connection is stored per-site, not per-block, so disconnecting
from one block's sidebar disconnects the whole site. Other blocks kept
rendering "PayPal Connected" — the status pill was hardcoded, not driven by
`isConnected` — and only failed once the merchant tried to save an edit.

- The status pill now reflects the real connection state, and a warning
  notice explains that the payment link still works for buyers but needs a
  reconnect before it can be edited, with a Reconnect action.
- The wizard opens on an explicit reconnect from a block that still holds a
  button, with a Cancel back to its preview. The sidebar's Disconnect
  becomes Reconnect, and Update/Delete are disabled while disconnected.
- Connecting or disconnecting now broadcasts to the other blocks in the
  editor, which previously only read the connection once on mount. Their
  attributes are deliberately left alone — the links they hold keep working.
- The disconnect confirmation says the change applies to the whole site.

WOOPTP-393, WOOPTP-391, WOOPTP-166

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01781PzAZGvxVGEt3z7UWCXr

* PayPal Payment Buttons: include the partner attribution code in every shared link

Only the rendered button appended `at_code`. Every other way a merchant
hands a payment link to a buyer — the Copy button in the editor, Copy in the
admin list, Copy Link and Copy to Clipboard on the detail page, the "Open
Payment Page" and "View on PayPal" links, and the emailed link — passed the
bare URL along, so those sales weren't attributed to us.

Adds PayPal_Payment_Buttons::add_partner_attribution(), which is idempotent
(add_query_arg replaces an existing at_code) and leaves non-PayPal URLs
alone, and applies it at every one of those points.

For the editor, the connection endpoint now returns partner_attribution_id
so the block appends the same code to the link it displays and copies,
including links stored before this change.

WOOPTP-187

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01781PzAZGvxVGEt3z7UWCXr

* PayPal Payment Buttons: fix the seller nonce being one character too short

Every "Connect with PayPal" attempt failed with PayPal's generic
"Request is not well-formed, syntactically incorrect, or violates schema."

PayPal's schema sets minLength 44 on
`first_party_details.seller_nonce`, but the prose in its own field
description says "43-128". The generator followed the prose: 32 random bytes
base64url-encode to exactly 43 characters, one below the enforced minimum, so
the referral was rejected every time.

48 bytes give exactly 64 characters with no padding to strip.

The test asserted the same misread bound (>= 43), which is why it passed while
the real call never worked; it now asserts 44-128 and PayPal's character
pattern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01781PzAZGvxVGEt3z7UWCXr

* PayPal Payment Buttons: save the merchant ID that onboarding returns

After completing onboarding the site reported "Merchant integration info not
available. Please reconnect your PayPal account." The connection itself had
succeeded; the merchant ID was simply never stored.

PayPal splits the onboarding result across two channels: `authCode` and
`sharedId` arrive by postMessage, while `merchantIdInPayPal` is a query
parameter on the return-URL redirect. The editor read `merchantIdInPayPal`
from the postMessage, where it never appears, so it fell back to '' and
complete_onboarding() wrote that empty value straight into the option.

The credentials call already made during onboarding returns the same value as
`payer_id` — which PayPal recommends as the way to identify a merchant — so
use it when no ID was supplied, and fail rather than store an empty one.

The editor also now reads the parameter from the popup's return URL, which is
same-origin once PayPal redirects back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01781PzAZGvxVGEt3z7UWCXr

* PayPal Payment Buttons: make onboarding failures say what went wrong

Two silent failures made the same class of problem surface far from its cause.

PayPal answers every schema violation with one generic sentence; everything
actionable is in `details`, which names the offending field and issue, and
`debug_id`, which PayPal support traces on. The platform endpoint kept only
the message and dropped both, so a rejected referral reached the site as an
unexplained 400. Neither is credential material, so both now travel back, and
the plugin carries them in the error data while keeping the merchant-facing
message friendly.

The partner merchant ID -- Automattic's own PayPal account ID, not anything
onboarding returns -- was optional in the platform credentials check and
degraded to an empty string. It is a path segment in every call made once the
seller finishes, so without it the signup link still opened, the seller
completed the whole PayPal flow, and then credential retrieval and every
status check failed. Refuse to issue a link that cannot be completed, naming
the constant that is missing.

check_merchant_status() now reports which of the two IDs it is missing. They
are stored at different points in the flow, so which one is absent says where
the flow broke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01781PzAZGvxVGEt3z7UWCXr

* PayPal Payment Buttons: close the onboarding popup when PayPal returns

PayPal does not close the popup it redirects. Once onboarding finished the
window just sat on a wp-admin screen, so the flow looked stalled and the
merchant had to dismiss it by hand.

The redirect lands the popup back on our own origin, which makes its location
readable to the opener again -- that is both how we can tell the flow is done
and where `merchantIdInPayPal` arrives. Close it from there, and poll a little
faster so the admin screen barely flashes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01781PzAZGvxVGEt3z7UWCXr

* PayPal Payment Buttons: assert PayPal's documented bounds on the referral body

The seller nonce failed because a length constraint was taken from PayPal's
prose rather than its schema, and nothing in the suite would have caught it.
Cover the other bounds on the same request while they are in hand: PayPal caps
return_url and return_url_description at 127 characters and tracking_id at
1-127, and rejects the whole referral when any of them is exceeded.

The return URL is built from the editor's own address, so it is the one most
likely to drift past the limit on a long domain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01781PzAZGvxVGEt3z7UWCXr

* PayPal Payment Buttons: complete onboarding through PayPal's onboarding SDK

Connecting an account never finished. The seller completed the PayPal flow and
the site then reported "Merchant integration info not available", because
complete_onboarding() had never run: it needs an authCode and sharedId, and
nothing was delivering them.

PayPal hands those over in exactly one place -- the callback named by
`data-paypal-onboard-complete` on a link rendered by its onboarding SDK, opened
in minibrowser display mode. They are not posted to an arbitrary window, and
they are not on the return URL, which carries only merchantIdInPayPal,
permissionsGranted, accountStatus, consentStatus, productIntentID,
isEmailConfirmed and returnMessage. Opening the action URL ourselves with
window.open and waiting for a postMessage could therefore never work.

So load PayPal's partner.js for the active environment, render the referral
link with the attributes its SDK binds to, and exchange the auth code from the
callback it invokes. The postMessage listener stays as a fallback for older
mini-browser integrations, and both routes now share one completion path.

The merchant ID is no longer read from the browser at all -- it comes from the
payer_id returned alongside the seller's credentials, which is why that
fallback matters here.

Removes the popup, its polling, and the status check that only ran when the
popup closed: it could not report a status before onboarding had stored a
merchant ID, so it had nothing left to do.

Not verifiable from the monorepo -- the SDK only runs against PayPal, so this
needs a live sandbox to confirm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01781PzAZGvxVGEt3z7UWCXr

* PayPal Payment Buttons: let PayPal's SDK own the connect button

The previous attempt fetched the referral link on click, injected the anchor,
then clicked it from script. Nothing opened. Two things in that sequence are
hostile to the SDK: the synthetic click happens after an async fetch, so it is
no longer a user gesture, and partner.js was asked to bind to an anchor that
appeared only after the merchant had already clicked.

Turn it around and follow PayPal's documented shape. The referral link is
fetched as soon as the welcome step is on screen, the anchor PayPal binds to is
the connect button rather than something revealed behind it, and the SDK is
appended once that anchor is committed to the DOM. The merchant's own click
then lands on the SDK's handler as a real gesture.

Also gives the script tag the id PayPal's own snippets use, since some SDK
builds look themselves up by it.

Still needs a live sandbox to confirm: the SDK does nothing outside a real
PayPal origin, so the tests cover the wiring and not the handshake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01781PzAZGvxVGEt3z7UWCXr

* PayPal Payment Buttons: report platform misconfiguration instead of "try again"

Every 500 from the platform signup-link route means WordPress.com is missing
or misconfiguring the PayPal platform credentials -- including the partner
merchant ID gate added earlier in this branch. Retrying cannot clear any of
them, so the generic "Please try again or use the manual credentials option"
was both wrong and hid the one message that names the constant to set.

Pass the upstream message through for `platform_*` codes, and keep the error
code in the error data. PayPal-side failures keep the friendly message, with
the raw reason still available to whoever is debugging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01781PzAZGvxVGEt3z7UWCXr

* PayPal Payment Buttons: name the platform credentials that are missing

"PayPal platform credentials for sandbox environment are not configured" does
not say which of the three constants is absent, so whoever is provisioning an
environment has to guess at the one they missed.

List them instead, and keep the partner merchant ID on its own error code: it
is Automattic's own PayPal account ID rather than an API credential, and it is
the one most easily overlooked, because a referral link generates happily
without it and the flow only breaks later, once the seller has already
finished onboarding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01781PzAZGvxVGEt3z7UWCXr

* PayPal Payment Buttons: open a window, not a tab, when PayPal's SDK is absent

The connect link carries target="PPFrame" because that is where partner.js
points it once the SDK has created its lightbox. When the SDK has not loaded
nothing creates that frame, so the browser makes a new tab of its own and the
merchant is dropped onto a full-page PayPal signup with no way back.

Take the click only in that case and open a sized window instead, closing it
when PayPal returns the merchant to our origin. With the SDK present the click
is left entirely alone, so the lightbox path is unchanged.

The window is a fallback for the surroundings, not a second route to a
connection: the auth code only ever arrives through the SDK callback, so if
this path runs at all the connection cannot complete. It now says so and
points at manual credentials, rather than leaving the wizard looking busy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01781PzAZGvxVGEt3z7UWCXr

* PayPal Payment Buttons: load PayPal's SDK into the editor's iframe

The connect link opened a browser tab instead of PayPal's window, and neither
the SDK nor its callback existed at runtime.

This block is apiVersion 3, so the editor canvas is an iframe: the link renders
into that document while this module runs in the parent. partner.js was being
appended to the parent, and it only scans its own document for the anchors it
binds to -- so it never saw the link, nothing created the PPFrame it targets,
and the browser opened a plain tab. The callback was on the wrong window for
the same reason, so even a successful signup had nowhere to report back to.

Load the SDK into the link's own document and hang the callback off that
window, resolved from the anchor's ownerDocument rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01781PzAZGvxVGEt3z7UWCXr

* PayPal Payment Buttons: fix blank block editor when the Jetpack plugin is active

* PayPal Payment Buttons: Start at welcome screen when Connect can work

* PayPal Payment Buttons: Stop retrying the onboarding link after a failure

* PayPal Payment Buttons: hide the welcome step and the Back link into it where it cannot work

* PayPal Payment Buttons: stop a dismissed error from calling PayPal again

* PayPal Payment Buttons: fix the QR toggle encoding the page URL

* PayPal Payment Buttons: wire the copy button in the button's QR panel

* PayPal Payment Buttons: connect in a popup so the editor survives onboarding

PayPal's onboarding SDK redirects `window.top` to the return URL once the
seller finishes. The SDK was loaded into the editor's canvas iframe, so that
redirect navigated the whole wp-admin window: the editor reloaded, and on an
unsaved post the block was gone. The auth code had already been exchanged by
then, which is why the account came back connected on the next load.

Move the SDK into a same-origin popup, following the pattern Publicize uses
to connect a keyring account. In a window of its own the redirect lands on the
popup, where it costs nothing, and the auth code -- the one thing the return
URL never carries -- comes back to the editor over a BroadcastChannel.

The editor no longer fetches the referral link or loads partner.js: the popup
page generates the link server-side, so `/onboarding/signup-link` is now
unused by the editor, though it stays registered.

Also break the disconnect confirmation into a summary and a list, instead of
one 40-word sentence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: let the seller's own click open PayPal

partner.js reports mb_use_old_script on this account, so it falls back to its
pre-zoid minibrowser -- which opens a window rather than an in-page lightbox.
A window needs user activation, and the activation from the click in the editor
does not carry into the popup, so the scripted click was silently blocked and
the page sat on "Opening PayPal...".

Drop the scripted click and give the popup a real button for the seller to
press. The anchor is no longer hidden either: render() skips hidden elements,
so it was never being bound as a PayPal button in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: redirect the popup straight to PayPal

The popup needed a second click because partner.js's minibrowser opens a
window, and a window needs a user activation the popup does not carry. Drop
partner.js: send the popup straight to PayPal's onboarding URL instead, so the
click in the editor is the only one the flow needs.

Without displayMode=minibrowser the auth code rides the return URL rather than
going to the SDK callback, so the return branch reads it from there and
broadcasts it to the editor as before.

When PayPal returns without credentials the popup says so and broadcasts the
names of the parameters it did get, so the editor can offer the manual path
instead of leaving the seller on a window that never resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: restore the SDK in the onboarding popup

Testing the redirect flow answered the open question: PayPal withholds the
auth code from the return URL for this integration, so onboarding cannot be
completed without partner.js. Put the SDK and its connect button back.

That costs a second click -- the seller presses Continue in the popup -- which
partner.js's window-based minibrowser requires and no redirect can avoid.

Keep the one gain from the experiment: a referral link that fails to generate
now broadcasts the reason to the editor, instead of only being readable in a
popup the seller has to close by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: exchange the auth code in the right environment

complete_onboarding() exchanges against whichever base URL the stored
environment names, but nothing on the Partner Referrals path ever stored it:
the sandbox toggle only sets editor state, and the option keeps its production
default. A sandbox referral was therefore exchanged against production, which
PayPal rejects with "Client Authentication failed".

Store the environment when the referral is created, so both halves of the
flow agree on which PayPal they are talking to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: run onboarding over the editor, in no extra window

The popup opened a window of its own and then nested PayPal's lightbox inside
it, and the minibrowser cannot be dropped to flatten that: it is what gives
partner.js a child context to watch, and without it the auth code never comes
back.

So do not open a window at all. The SDK goes back in the editor, with its
lightbox inside a transparent full-viewport frame whose sandbox withholds
allow-top-navigation. PayPal paints over the editor at full size the way it
did originally, while the redirect that used to reload the editor is blocked.

One click, no window, no nesting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: drop the onboarding popup page

Nothing opens it now that the SDK runs over the editor, so the admin-post
handler, its test, the connection-status field advertising its URL and the
popup-monitor dependency all go with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: discard a connection that fails its final checks

complete_onboarding() stores the seller's credentials before validating them,
so any failure past that point left the site connected while handing the
editor an error: every other screen treated the account as connected, and the
merchant was told to reconnect one that appeared to be working.

Roll the partial state back -- credentials, merchant ID and onboarding method
-- on all three failure paths past storage: a missing merchant ID, credentials
that do not authenticate, and an account without Payment Links & Buttons
access.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* PayPal Payment Buttons: open PayPal's window from the merchant's own click

Two fixes for the overlay flow, both mine.

The connection poll raced the real completion signal: it could see credentials
that complete_onboarding() was about to roll back, flip the editor to a
success it never reached, and leave the create form talking to a site with no
credentials. The SDK callback demonstrably fires, so the poll goes, and a
failed exchange now hides the frame so the error is not covered by an open
lightbox.

The scripted click on PayPal's link did nothing because the frame was created
after the merchant's click: transient activation only propagates to
same-origin frames that exist when the user acts, and without it the SDK's
window.open is silently blocked. Prefetch the referral link, mount the frame
hidden with the SDK already bound, and relay the click synchronously inside
the merchant's own gesture -- one click, and the Continue button is only ever
seen if that relay could not run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PayPal Payment Buttons: say which app lacks access, and what PayPal said

The 403 message told the merchant to enable Payment Links & Buttons on "your
PayPal app" -- but onboarding stores the seller's own app credentials, not the
partner app a merchant goes looking for, so the advice sent them to the wrong
app and the error discarded PayPal's actual diagnosis.

Append the connected app's Client ID prefix (public by design; it ships in SDK
URLs), plus the error name and debug_id from PayPal's response body, so the
merchant can find the right app and support can resolve the debug ID.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PayPal Payment Buttons: list the granted scopes when access is refused

The app onboarding provisions for the seller appears in no dashboard, so
whether it was ever granted the Payment Links & Buttons API cannot be looked
up -- but the token PayPal issues names every scope the app holds. Put that
list in the 403 message: a missing checkout scope means the referral did not
grant the product, and a present one means the probe is wrong, which are fixed
in different places.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PayPal Payment Buttons: onboard sellers onto PPCP_STANDARD

A seller onboarded with products EXPRESS_CHECKOUT gets a token whose scopes
carry nothing for the Payment Links & Buttons API, and the API answers
NOT_AUTHORIZED -- so the claim that NCPS is bundled within it does not hold.
PPCP_STANDARD is the other product that claim named; the scope list on the
next onboarding will say whether it fares better.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PayPal Payment Buttons: open the dashboard for the environment being connected

The credentials step always linked the live app list, and a merchant
connecting in sandbox finds no sandbox app there to copy credentials from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PayPal Payment Buttons: say what PayPal rejected about a referral

A rejected referral already carried PayPal's diagnosis -- the offending
field, the issue code, the debug ID -- in the error data, where a merchant
reading the editor notice never sees it. Only platform misconfigurations got
their message through; every other rejection collapsed to "please try again",
including ones retrying cannot fix.

Append the details and debug ID to the visible message, the way the
Payment Links & Buttons access refusal already reports itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PayPal Payment Buttons: PPCP is the referral enum, not PPCP_STANDARD

PayPal's schema rejected the referral outright: "The value 'PPCP_STANDARD'
does not match with one of the expected value of this enumerated field"
(debug ID f500231b25843). PPCP_STANDARD is what the same product is called in
merchant-integrations status responses; in the Partner Referrals products
array it is PPCP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PayPal Payment Buttons: onboard with EXPRESS_CHECKOUT again

PPCP grants a seller the byte-for-byte identical scope set EXPRESS_CHECKOUT
does -- nothing NCPS-shaped in either, and the Payment Links & Buttons API
answers NOT_AUTHORIZED to both (debug IDs ca34cb3b20dec, f19934388a92a). So
the product swap buys nothing and PPCP onboarding puts sellers through
advanced-card vetting for it. Back to EXPRESS_CHECKOUT; what remains is
partner-side, and the constant's docblock now records that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PayPal Payment Buttons: retire the entitlement-hunt instrumentation

The scope dump and client-ID prefix on the access-refused message did their
job: they established that referral-onboarded apps never receive the Payment
Links & Buttons scope regardless of product, which is now with PayPal. A
merchant notice is no place for an OAuth scope list, so out they go.

PayPal's error name and debug ID stay -- that is how their support traces a
refusal, whoever hits it next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PayPal Payment Buttons: restore the connect-error tests dropped in the frame rewrite

* PayPal Payment Buttons: request Payment Links & Buttons in the referral

* PayPal Payment Buttons: fix the published price ignoring per-option prices

* PayPal Payment Buttons: fix Connect showing a blank overlay instead of opening PayPal

* PayPal Payment Buttons: fix closing PayPal's window leaving the editor stuck

* PayPal Payment Buttons: take the lowest price from the primary option group only (#51929)

PayPal only prices the primary option group, but the "From" headline on the
published page and in the editor preview scanned every group. A stale amount
on a non-primary option could therefore become a price PayPal never holds.

Both lowest-price helpers now ask the same question as variants_have_pricing()
and hasVariantPricing(). validate_attributes() also rejects the option groups
sanitize_variants() would silently discard - unnamed groups, unlabeled
options, and more groups or options than the sanitizer keeps - so the
validator and the request builder can no longer disagree about which options
carry pricing.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* PayPal Payment Buttons: show the payment a block points at, not the copy it was duplicated from (#51930)

Duplicating a block copies its resourceId, so two blocks share one PayPal
payment. Editing either replaced the payment with a PUT while the other went
on showing the product and price it was duplicated with, and the published
page charged a different amount from the one it displayed.

The editor now reads the payment back when a block with a resource loads and
corrects the attributes PayPal is the source of truth for, using the
block-editor store's non-persistent marker so opening a post does not dirty
it. GET /paypal/buttons/{id} returns the block shape alongside the raw
resource for that. A block also says when other blocks on the page point at
the same payment, so a merchant who duplicated to sell a second product finds
out what happened and how to get a new one.

Sharing itself is kept: it is how one product is shown as a button, a link
and a QR code.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* PayPal Payment Buttons: reject decimal prices for zero-decimal currencies (#51931)

PayPal prices JPY, HUF and TWD as whole numbers and rejects a decimal amount
outright, but both validators shared one currency-blind regex and the price
inputs offered step="0.01" whatever the currency. A JPY price of 1500.50
passed here and failed at PayPal, in PayPal's words, and a failed update
left the block showing an amount PayPal never held.

The validators now take the currency, on the product price and on every
per-option price, and the inputs step in whole numbers for those three
currencies so the decimal cannot be typed. The PHP side reads the decimal
column of the legacy currency table rather than adding a third list. INR,
which PayPal does not support, is dropped from the selector.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* PayPal Payment Buttons: log when stored credentials cannot be decrypted (#51935)

A decrypt failure in get_credentials() deletes the merchant's stored
credentials, and it fires from read-only paths: opening the admin page or
the block editor is enough. That is by design (WOOPTP-189), but it has
always been silent, so nobody can say whether it happens in practice.

Log it before deleting, with the phpcs:ignore the other packages use for
deliberate logging. The delete itself is left alone; overturning the
design needs sign-off, and a quarter of log data to argue from.

The block still saying "PayPal Connected" after the delete is a separate
issue.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* PayPal Payment Buttons: bring the lockfile up to date with trunk after the rebase

The rebase kept the branch's pre-rebase lockfile, which still carried
trunk's old dependency resolutions and dropped the qrcode entry.
Regenerated from trunk's lockfile, so the only change is qrcode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* PayPal Payment Buttons: move API_BASE into its own file

* PayPal Payment Buttons: move the currency list into its own file

* PayPal Payment Buttons: move the full-color logo into its own file

* PayPal Payment Buttons: move the format switcher into its own file

* PayPal Payment Buttons: give api-base.js the @since its siblings carry

* PayPal Payment Buttons: drop the @since the split files never should have carried

* PayPal Payment Buttons: move the partner SDK loader into its own file

* PayPal Payment Buttons: move the connection-changed broadcast into its own file

* PayPal Payment Buttons: move the connection machine into its own hook

* PayPal Payment Buttons: put the mount-fetch JSDoc back on the effect it documents

* PayPal Payment Buttons: rename the connection hook file

* PayPal Payment Buttons: move the payment resource into its own hook

* PayPal Payment Buttons: move the connection wizard into its own file

* PayPal Payment Buttons: move the product form into its own file

* PayPal Payment Buttons: move the block inspector into its own file

* PayPal Payment Buttons: move the legacy block into its own file

* PayPal Payment Buttons: move the confirmation dialogs into their own file

* PayPal Payment Buttons: exclude the .js source files from the production mirror

* PayPal Payment Buttons: move the block JS into components/, hooks/ and utils/

* PayPal Payment Buttons: rename the block inspector and full-color logo files and exports

* PayPal Payment Buttons: document where new block files go and fix the block metadata row

* PayPal Payment Buttons: warn that deleting a link breaks published blocks (#51933)

Deleting a payment link from the admin leaves every published block that
embeds it rendering a working-looking buy button on a dead PayPal link,
and nothing on the page or in the editor says so. The two admin confirms
carried the right wording but as two separate literals.

Both admin deletes now read their confirmation from one place,
PayPal_Admin_Page::delete_confirm_text(), and say how many published
posts embed the link. The count comes from a single capped search for
the block across published posts, since the resource id only lives
inside the block comment in post_content, so the list table pays for one
query per page rather than one per row.

The block's own confirm and its stray "payment button" wording are left
for WOOPTP-393.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Julian Strahan <julian.strahan@automattic.com>
jeherve added a commit that referenced this pull request Sep 23, 2026
…ons (#51993)

* IP: add Utils::url_is_public() to vet server-side request targets

Code that fetches a URL supplied by a request has, until now, had only
core's wp_http_validate_url() to lean on. That leaves several addresses
open that no plugin should ever reach out to: Azure's metadata "Wire
Server" at 168.63.129.16, IPv6 link-local and unique-local ranges, CGNAT,
and the IETF protocol-assignment block. The package already knows how to
classify those through ip_is_public(), but every caller had to resolve
the host itself and remember to check each address it got back.

url_is_public() does that once. It runs core's check first, resolves the
host to every IPv4 and IPv6 address a request could connect to, and
requires all of them to pass ip_is_public(). A host that resolves to
nothing is rejected rather than handed on, so an unresolvable or
partly-internal host is never assumed safe.

It returns a plain bool so it can be used directly as a REST
validate_callback, and so a rejection tells a caller nothing about why.
Two limits are documented on the method rather than papered over: it
validates a single URL, so callers that follow redirects still have to
check each hop, and the resolved address is not pinned for the request
that follows, so this is not a defence against DNS rebinding.

resolve_host_ips() is public alongside it for callers that need the
addresses themselves. It undoes the encodings a host can hide behind --
percent-encoding and IPv6 zone identifiers -- before deciding whether it
is looking at an IP literal or a name to resolve.

* IP: reject a zone identifier on a non-IPv6 host instead of trimming it

resolve_host_ips() stripped everything after a '%' unconditionally, so
"8.8.8.8%foo" came back as array( '8.8.8.8' ) and url_is_public() called
the URL public. ip_is_public() has rejected that same string since
970e81a, which landed in #50175 for exactly this reason: a zone
identifier is only meaningful on IPv6, so a '%' anywhere else is
malformed and must not be normalized into a host that looks safe.
Resolving the host separately quietly undid that guard.

Mirror ip_is_public()'s handling instead. A '%' on input with no ':' now
returns an empty list, which callers already treat as unsafe. IPv6 zone
ids are still stripped, so "[fe80::1%25eth0]" continues to resolve to
fe80::1 and get rejected on its own merits.

Also parse the URL wp_http_validate_url() returns rather than the one
passed in. Core normalizes the URL it validates and returns that, so the
argument is the wrong string to read the host from. No divergence in the
host itself is reachable today, since core bails when kses changes the
string and _wp_normalize_parsed_url() copies the host across untouched,
but reading the value core approved is the contract the function offers.

Swap 203.0.113.10 out of the accepted-hosts test for 1.1.1.1. TEST-NET-3
is documentation space that core's own wp_http_validate_url() rejects
outright, so holding it up as an example of a public address described an
outcome that cannot happen once core's gate is closed again.

* IP: reject empty and control-character hosts before resolving them

resolve_host_ips() trims brackets and URL-decodes before it resolves, and
both steps can produce something that is no longer a host name. "[]"
trims to the empty string, and "example%00.com" decodes to a string with
a NUL byte in it.

The NUL case was a fatal, not a failed lookup. Since PHP 8.0
gethostbynamel() and dns_get_record() throw a ValueError on a NUL byte
rather than warning and returning false, and a thrown exception walks
straight past the '@' in front of the call:

  Utils::url_is_public( 'http://example%00.com/img.jpg' )
  => ValueError: gethostbynamel(): Argument #1 ($hostname) must not
     contain any null bytes

Core's wp_http_validate_url() does not stand in the way of this. It
strips raw NULs through wp_kses_no_null() and then rejects the URL for
having changed, but a percent-encoded NUL passes through it unchanged,
and resolve_host_ips() is public in its own right.

Reject an empty host, and any host carrying a byte in \x00-\x20 or \x7f,
before either lookup. The range covers NUL, tab, newline, carriage
return, space and DEL, and stops short of \x80 so a raw UTF-8 host is
left to fail on its own at resolution time rather than here.

* IP: return only valid, distinct addresses from resolve_host_ips()

The method took whatever gethostbynamel() and dns_get_record() handed
back and returned it, so its "@return string[] List of IP addresses"
rested on the resolvers behaving. Filter the list to strings that pass
FILTER_VALIDATE_IP, and drop repeats.

Nothing was reachable through this. PHP only sets a record's 'ipv6' key
once it has formatted an AAAA answer, so the value is well-formed by
construction, and CNAME records in an AAAA response carry 'target'
instead and were already skipped. Every value also passes through
ip_is_public(), which rejects non-strings and non-addresses before doing
anything, so a bad entry could only ever have made url_is_public() return
false.

The point is the contract rather than the safety. resolve_host_ips() is
public so that callers can use the addresses without going through
ip_is_public() themselves, and those callers should not have to re-check
what a resolver returned.

The accompanying test asserts the shape of the result rather than a fixed
set of addresses, since the lookup needs DNS. With no network the list is
empty and it holds trivially.

* IP: cover host resolution without depending on real DNS

Every url_is_public() test so far used an IP-literal host, so the loop
that requires *all* of a host's addresses to be public — the reason the
helper exists — was never exercised. Rewriting it to accept a host as
soon as one address passes kept the whole suite green.

Real DNS cannot pin that down: a host that resolves to one public and one
internal address is not something to go looking for on the internet, and
a test that depends on finding one is a test that fails on a runner
without a network.

Shadow the two lookups instead. PHP resolves an unqualified call to a
global function against the current namespace first, so defining
gethostbynamel() and dns_get_record() in Automattic\Jetpack\IP intercepts
resolve_host_ips() and nothing else. With no canned answers set they
delegate to the real functions, so the tests that do want live DNS still
get it.

That buys three tests: a host with one internal address among public ones
is rejected, both A and AAAA answers are returned rather than whichever
came first, and junk or repeated records are dropped. The first one now
fails against the any-address-passes rewrite above.

* IP: stop the test stubs depending on ext-dns

The dns_get_record() shadow declared DNS_ANY as its default parameter
value, and the package requires only php >=7.4 — the production code
guards the real call with function_exists() precisely because ext-dns may
be absent. Drop the constant: the shadow takes null and lets the real
function apply its own default, so the file names nothing from the
extension.

The reported failure mode was not reachable, though. PHP resolves a
constant in a default parameter value when the parameter is used, not
when the file loads, and this one never is: resolve_host_ips() always
passes DNS_AAAA explicitly, and on a build without the extension
function_exists() skips that call altogether.

Auditing it did turn up a real one. Three of the new tests assert on AAAA
records, so they fail rather than skip on a build without ext-dns. They
now check for it, verified by pointing both the production guard and the
test guard at a function that does not exist: 3 skipped, 43 passing.

That gap is worth stating in url_is_public() too. Without the extension
only a host's IPv4 addresses get checked, so an internal IPv6 address
sitting behind a public IPv4 one is missed — a caller trusting "every
address the host resolves to is checked" should know where that stops.

* IP: unwrap one bracket pair instead of trimming every bracket

trim( $host, '[]' ) removes any number of brackets from both ends, so it
did not just unwrap an IPv6 literal — it rewrote malformed input into a
clean address:

  resolve_host_ips( ']8.8.8.8[' )   => array( '8.8.8.8' )
  resolve_host_ips( '[[8.8.8.8]]' ) => array( '8.8.8.8' )
  resolve_host_ips( '[8.8.8.8' )    => array( '8.8.8.8' )

parse_url() hands those through as the host verbatim, so they reach the
method intact. None of them is a host, and none should come back as one.

This is the same laundering the '%' handling already guards against:
malformed input normalized into something that looks safe rather than
rejected. Match it. Strip a single wrapping pair, then let the existing
post-decode guard reject anything still carrying a bracket, which also
covers the encoded form ("%5B8.8.8.8%5D") that only becomes visible after
rawurldecode().

Nothing was exploitable through url_is_public(): core's
wp_http_validate_url() runs first and cannot resolve a bracketed host, so
it rejects the URL before any of this. resolve_host_ips() is public in
its own right, though, and a caller reading a host back out of it should
get the host it passed in.

"[::1]" and "[fe80::1%25eth0]" still unwrap as before.

* IP: reject raw non-ASCII hosts and tighten url_is_public() docs

resolve_host_ips() now rejects hosts containing raw non-ASCII bytes, since
the request layer converts them to punycode before connecting and would reach
a different name than the one resolved here. Punycode (xn--) hosts still work.

Also trims the new docblocks and inline comments to the AGENTS.md comment
budget, notes that core rejects IPv6-literal hosts, drops the url_is_public()
test cases that asserted bracketed IPv6 URLs pass (core never lets them
through), and removes a comment claiming core normalizes the URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Brandon Kraft <public@brandonkraft.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fgiannar fgiannar removed their assignment Sep 28, 2026
adamwoodnz added a commit that referenced this pull request Oct 18, 2026
…otting (Codex P2 #1, #2)

The palette assigns colors by group, so regrouping into current and previous painted both bars of a slot alike for a caller that sets no stroke. A previous period with no counterpart had no front bar to stand behind and was drawn in the wrong slot.
adamwoodnz added a commit that referenced this pull request Oct 20, 2026
…otting (Codex P2 #1, #2)

The palette assigns colors by group, so regrouping into current and previous painted both bars of a slot alike for a caller that sets no stroke. A previous period with no counterpart had no front bar to stand behind and was drawn in the wrong slot.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants