Skip to content

Add a reusable activation URL API for PHP and JS - #181

Open
dave-green-uk wants to merge 21 commits into
bucket/activation-flow-apifrom
smtnc-1844-harbor-expose-a-reusable-activation-url-api-for-php-and-js
Open

Add a reusable activation URL API for PHP and JS#181
dave-green-uk wants to merge 21 commits into
bucket/activation-flow-apifrom
smtnc-1844-harbor-expose-a-reusable-activation-url-api-for-php-and-js

Conversation

@dave-green-uk

@dave-green-uk dave-green-uk commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Closes SMTNC-1844 · Parent: SMTNC-1833

Summary

Product onboarding screens across our plugins need an "Activate" button equivalent to the one in Harbor's LicenseProductCard. Today there is no reusable way to build one — the logic lives in two places, neither reachable from a host plugin:

  • PHP — assembled inline inside wp_localize_script() in Feature_Manager_Page. Not a method, not a service, no filter.
  • JSbuildActivationUrl(), shipped only inside Harbor's own admin bundle.

Without this, each plugin would hand-roll the portal's query string, giving us five copies of a contract that drift the moment the portal changes a param.

This PR extracts the logic into a Portal\Activation\Url service, exposes it to host plugins through stable lw_harbor_* global functions, and exposes the JS helper as a shared script.

There was also a latent correctness gap: redirect_url was hardcoded to Harbor's own Software Manager page, so activating from a plugin's onboarding screen would have returned the user to the wrong place. Callers now supply their own destination.

Since the last review

Two review rounds have landed. If you reviewed this before 2026-07-28, these are the parts that moved:

  • Base merged. bucket/activation-flow-api is merged in (7c0651b) — Drop a null coalesce phpstan proved unreachable #183's phpstan fix comes with it, so the phpstan red is gone. The conflict was only in committed build artifacts; they were rebuilt from the merged source rather than hand-resolved.
  • Namespace. The three classes now live in Portal\Activation as Url, Script and Return_Handler. Return on its own is a PHP reserved word, which is why that one kept a suffix.
  • lw_harbor_get_activation_url()lw_harbor_get_activation_base_url(), since what it returns is the unscoped URL its sibling adds an sku to. All four consumer PRs are updated.
  • Both URL functions return ?string, null rather than an empty string when there is nothing to give.
  • New lw_harbor_add_activation_script_dependency() so consumers never read a Harbor constant to attach the JS helper.
  • Utils\Assets extracts the build-directory resolution that was duplicated with Feature_Manager_Page.
  • window.lwHarbor.version is gone. It ran regardless of WP_DEBUG and Version::register_debug_info() already reports the leader.

What is purely additive

Nothing below changes existing behaviour. All of it is new surface area.

File What it adds
src/Harbor/Portal/Activation/Url.php New service. get_base( ?string $redirect_url ) and for_product( string $slug, string $tier, ?string $redirect_url ). Autowired with Site\Data.
src/Harbor/Portal/Activation/Script.php New. Registers the lw-harbor-activation script behind Version::should_handle(). Admin-only.
src/Harbor/Portal/Activation/Return_Handler.php New. Refreshes cached licensing data when the portal returns a user to the site.
src/Harbor/API/Functions/Actions/Add_Activation_Script_Dependency.php New. Backs lw_harbor_add_activation_script_dependency().
src/Harbor/Utils/Assets.php New. Resolves paths and URLs into the build directory, shared with Feature_Manager_Page.
resources/js/activation-entry.ts New webpack entry. Compiles to window.lwHarbor, zero dependencies.
docs/guides/activation-urls.md New guide covering both consumption paths, enqueue timing, and choosing a canonical return URL.
tests/wpunit/Portal/Activation/UrlTest.php New, 11 tests.
tests/wpunit/Portal/Activation/ScriptTest.php New, 4 tests.
tests/wpunit/Portal/Activation/Return_HandlerTest.php New, 8 tests.
tests/js/activation-entry.test.ts New, 3 tests.
changelog/…yaml Two new entries.

tests/wpunit/API/Functions/GlobalFunctionsTest.php also gains 9 tests for the new global functions.

PHP public API — lw_harbor_* global functions

Added in response to review: rather than resolving a Harbor class from the container, host plugins call stable global functions.

Function Wraps
lw_harbor_get_activation_base_url( ?string $redirect_url ): ?string Activation\Url::get_base()
lw_harbor_get_product_activation_url( string $slug, string $tier, ?string $redirect_url ): ?string Activation\Url::for_product()
lw_harbor_add_activation_script_dependency( string $handle ): void Attaches the JS helper to a consumer's registered script

Like the rest of Harbor's lw_harbor_* surface these are version-keyed — they always resolve to the loaded, highest-version copy, so a consumer never builds a class from its own possibly-stale vendor tree.

The two URL functions return null, not an empty string, when no Harbor instance is active or the URL cannot be built. An empty string is still a string: paste it into an href and you get a link to the current page. Null cannot be used by accident.

lw_harbor_add_activation_script_dependency() exists so consumers never read Script::HANDLE. A constant read from the copy a plugin bundled can disagree with the copy that actually registered the script. The consumer names its own handle and Harbor wires itself in:

wp_register_script( 'my-onboarding', $url, [], $ver, true );
lw_harbor_add_activation_script_dependency( 'my-onboarding' );

It retries at the end of admin_enqueue_scripts, so it does not matter whether the caller runs before or after Harbor's own registration — a property that naming the handle in a $deps array had for free. It is a no-op when no Harbor is active, so a consumer's script is never held back by a dependency that will never resolve.

What actually changed

Three files have modified behaviour or signatures. This is the part worth reviewing closely.

1. Feature_Manager_Page — constructor signature changed

-public function __construct( Data $site_data, License_Manager $license_manager, Catalog_Repository $catalog ) {
+public function __construct( Data $site_data, License_Manager $license_manager, Url $activation_url ) {

The inline http_build_query() block that produced harborData.activationUrl is replaced by $this->activation_url->get_base().

Same params, same order, same PHP_QUERY_RFC3986 encoding. The container resolves the new dependency by autowiring, so no call-site changes were needed outside tests.

Its build-directory resolution also moves to Utils\Assets, which the activation script shares.

One value changed on purpose — see 1a below. The redirect_url param now points at options-general.php?page=lw-software-manager&refresh=auto instead of admin.php?page=….

1a. Minor: the default redirect now uses the page's canonical URL

redirect_url changes from admin.php?page=lw-software-manager&refresh=auto to options-general.php?page=….

Both forms resolve to the same page — verified against a real install, each returns HTTP 200 and renders the Software Manager. WordPress resolves an admin.php?page={slug} URL to a submenu page via get_admin_page_parent(), which returns the real parent rather than admin.php.

This is a consistency change, not a fix. options-general.php is the page's canonical address and the form used by every other link to it. Only the redirect builder differed.

Covered by test_get_base_uses_the_canonical_page_url.

1b. The return trip now refreshes licensing data

Licensing data is cached, so a user who has just activated in the portal lands back on a site that still believes they are unlicensed. Whatever was gated on that state is wrong on arrival — an Activate button that should have gone, a feature that should have unlocked.

Harbor's own page already worked around this with a refresh=auto param and a handler bound to its page slug. A host plugin's return URL got nothing, so every plugin would have reimplemented the same handler and any that forgot would ship the stale-state bug.

Activation\Url now tags every return URL it builds — whichever page the caller nominated — and Return_Handler watches for that tag on any admin screen, refreshes, strips it, and redirects. All on admin_init, so pages render against current data. Host plugins need no code for this.

Points worth a reviewer's attention:

  • The tag is namespaced (lw-harbor-activated), not something generic like refresh. It rides on a URL owned by the calling plugin and must not collide with their params.
  • Leadership is checked before the capability check, and the two are one conditional. Whether this copy is the one that acts is a question about the install; the capability is a question about the user in front of it.
  • It checks manage_options because the tag can land on a screen with no capability check of its own.
  • It is behind the version leadership check, so four active plugins using Harbor make one API call between them, not four.
  • The tag is checked in the provider before the handler is resolved. admin_init fires on every admin page load and resolving the handler builds the licensing HTTP client. The handler repeats the check for its own sake.
  • Failures are logged, not surfaced. The user has just come back from activating and is looking at a product screen, not a licensing one.

Feature_Manager_Page's own handler and its refresh=auto param are removed, not left alongside — its tests move to Return_HandlerTest. That also orphaned its Catalog_Repository dependency, which is dropped; the container autowires the constructor, so nothing outside the tests needed changing.

maybe_redirect_after_refresh() is kept and deprecated rather than deleted, with a _deprecated_function() notice as well as the docblock tag, since the class is not final and the method is public.

2. Portal\Provider — new hook registered

Two container registrations, plus:

add_action( 'admin_enqueue_scripts', [ $this, 'register_activation_script' ], 0, 0 );

This is the only new runtime behaviour on existing installs. Every admin page load now runs the leader check. On a non-leading instance it early-returns before touching wp_register_script. On the leader it registers one dependency-free script — registered, not enqueued, so nothing is served unless a plugin asks for it.

Priority 0 is defensive rather than required. WordPress resolves script dependencies when scripts are printed, not when they are enqueued, and admin_enqueue_scripts always runs before admin_print_scripts — so any consumer enqueuing on that hook is in time whatever priority it uses. Priority 0 additionally covers anything that prints scripts by hand.

3. webpack.config.js — second entry

Adds an activation entry with library: { name: 'lwHarbor', type: 'window' }. The existing index entry is untouched apart from whitespace alignment. Build output gains build/activation.js and build/activation.asset.php.

Also modified

tests/wpunit/Admin/Feature_Manager_PageTest.php — two constructor call sites updated for the new parameter. No assertions changed.

Explicitly not changed

Worth stating, because an earlier draft of this design did touch them:

  • License_Response and its 8 call sites in License_Controller
  • Product_Collection::to_array()
  • The REST contract — no endpoint response shape changes in this PR
  • resources/js/lib/activation-url.ts — already accepted baseUrl as a parameter, so it works unmodified

Dropping the idea of a server-side pre-baked per-product URL removed all of the above from scope, and with it the open-redirect surface that returning caller-supplied URLs through REST would have created.

Notes for reviewers

The script handle and window global are not vendor-prefixed, on purpose. Strauss rewrites class names but not strings, so lw-harbor-activation and lwHarbor are what every Harbor copy on the site agrees on — that is what allows a single registration to serve all of them. Please don't "fix" this.

Script::HANDLE and Url::RETURN_PARAM are public but internal. Harbor reads both across class boundaries and PHP has no package-internal visibility, so private will not compile. They are marked internal in their docblocks and no longer appear in the docs; consumers use lw_harbor_add_activation_script_dependency() instead.

Consumers should still feature-detect, but not for the reason an earlier draft of this description gave. The leader is never older than the copy your plugin ships — if yours is newest, yours becomes the leader. The case worth guarding is that no Harbor is active on the request at all:

if ( window.lwHarbor?.buildActivationUrl ) {  }

Failure mode to be aware of: WordPress silently drops a script whose dependency is still unregistered at print time — no error, no console warning. Because registration happens during admin_enqueue_scripts, that means Harbor is absent from the request entirely rather than a timing problem. Feature-detect in the browser as above.

Encoding parity. PHP's PHP_QUERY_RFC3986 and JS's URLSearchParams both emit sku=givewp%3Aelite. There is a test pinning this on each side; they must not drift. add_query_arg() is deliberately not used for this query — it is RFC1738, so it would send the portal + for a space and %7E for a tilde inside redirect_url.

QA notes

Regression surface is narrow but specific.

1. Feature Manager page — no visible change expected. The Activate buttons in the license section should behave exactly as before. Activating should still return you to the Software Manager with the product showing as activated.

The redirect_url param now reads options-general.php?page=lw-software-manager&refresh=auto rather than admin.php?page=…. Both resolve to the same page, so this should be invisible — but it is the one value that changed, so worth an eye on the round trip. Confirm the URL still carries portal-referral=plugin, domain, and a percent-encoded redirect_url.

2. The return trip. Activate a product in the portal and come back. The screen you land on must already reflect the activation — no manual refresh, no stale Activate button. The URL should briefly carry lw-harbor-activated=1 and then redirect to your clean URL.

Verified in a real install: with the tag, one redirect and the param is stripped; without it, no redirect.

3. Multiple Harbor copies. With two or more plugins using Harbor active on different versions, confirm lw-harbor-activation is registered exactly once and served from the highest version's vendor/ path.

4. Non-admin pages. The script is admin-only and should not appear on the front end.

5. Nothing enqueued by default. On a site with no consumer plugin, build/activation.js should be registered but never printed.

Testing

Green in CI — 23/23 checks, including:

  • PHP test matrix across 10 jobs — PHP 7.4 through 8.3, on WP 6.9.5 and latest. The 7.4 jobs confirm no PHP 8.0+ syntax crept in.
  • PHPStan, PHP Code Standards, General Code Standards, Markdown Lint, spell-check and E2E.
  • build-frontend runs on push and commits the built assets automatically.

Verified locally:

  • JS suite: 15 suites, 90 tests, all passing.
  • Both dev and prod webpack builds compile.
  • markdownlint and cspell clean; php -l clean on all changed PHP files.
  • Runtime smoke test confirming window.lwHarbor.buildActivationUrl() works.

Local environment note (not a blocker for this PR):

composer install currently fails on a clean checkout — lucatume/tdd-helpers and lucatume/wp-utils both return "Repository not found" from GitHub. They are transitive dev dependencies of the Codeception tooling and appear to have been deleted or made private upstream. CI is unaffected, but local PHP test runs are blocked until that is sorted. Unrelated to this branch.

Integration note

Validated against a real install by injecting the API into The Events Calendar's Strauss-prefixed vendor tree (TEC\Common\LiquidWeb\Harbor). The service resolved from TEC's container and produced correct URLs for all three call shapes.

One constraint worth knowing before rolling this out: TEC's Strauss autoloader runs setClassMapAuthoritative(true), so PSR-4 is bypassed and new Harbor classes do not load until the classmap is regenerated. A normal composer update stellarwp/harbor handles it, but dropping files in by hand will not.

The lw_harbor_* global functions are the answer to exactly this: their shells load via Composer's files autoloader rather than the classmap, and they resolve the service from the leader internally — so a consumer calls a function that is always present and never references the new class, sidestepping the classmap gap entirely.

Follow-ups, not in this PR

  • Remove the function_exists() guards in the four consumer plugins once this tags and each bumps its bundled Harbor.
  • Harbor's CI floats its dependenciescomposer.lock is untracked and dependency-versions: highest with loose constraints means an upstream release can redden an unrelated PR.
  • build-dev/ is gitignored but tracked, which is what produced the merge conflict on this branch.
  • Front-end support, if any onboarding flow ever runs outside wp-admin.
  • Per-product REST field, if a consumer later wants finished URLs server-side rather than building them from a base.

dave-green-uk and others added 2 commits July 22, 2026 13:52
Product onboarding screens need an Activate button equivalent to the one
in Harbor's LicenseProductCard, but the logic for building a portal
activation URL lived in two private places: assembled inline inside
wp_localize_script() on the Feature Manager page, and in a JS helper
shipped only in Harbor's own admin bundle. Neither was reachable from a
host plugin.

Extract it into an Activation_Url service and expose the JS helper as a
shared, leader-gated script handle. Callers pass product, tier and return
URL as parameters, so no user-supplied URL reaches the REST layer and the
open-redirect surface a pre-baked URL would create never exists.

The script handle and window global are deliberately not vendor-prefixed.
Strauss rewrites class names but not strings, so every Harbor copy on the
site agrees on them, which is what lets a single registration serve all
of them.

SMTNC-1844

Claude-Session: https://claude.ai/code/session_01BQPHeqkVhHwUUeaem4Rc8M
@linear

linear Bot commented Jul 22, 2026

Copy link
Copy Markdown

SMTNC-1844

SMTNC-1834

@dave-green-uk dave-green-uk self-assigned this Jul 22, 2026
The default return destination was built as admin.php?page=lw-software-manager,
but the Software Manager is registered as a submenu of Settings. WordPress
resolves a plugin page by a hook name derived from its parent, so the admin.php
form looks up admin_page_lw-software-manager while the page is registered as
settings_page_lw-software-manager. The lookup misses and the request ends in
wp_die( 'Cannot load lw-software-manager.' ).

The effect was that a user who activated a product in the portal was returned to
an error page rather than the Software Manager.

The rest of the codebase already used the options-general.php form: the
Global_Function_Registry accessor, the Feature_Manager_Page docblock, and the JS
helper's own test fixture. Only the redirect builder disagreed. Bring it into
line and add a regression test.

Also document how to pick a correct return URL, since a consumer registering a
submenu can hit exactly the same trap, and fix an undefined variable in the
guide's enqueue example.

SMTNC-1844

Claude-Session: https://claude.ai/code/session_01BQPHeqkVhHwUUeaem4Rc8M
@dave-green-uk
dave-green-uk marked this pull request as ready for review July 22, 2026 13:39
redscar
redscar previously approved these changes Jul 22, 2026
The guide claimed a consumer enqueuing earlier than priority 0 would lose a
race against Harbor's registration. That is wrong. WordPress resolves script
dependencies in WP_Dependencies::all_deps() when scripts are printed, not when
they are enqueued, and admin_enqueue_scripts always runs before
admin_print_scripts. Any consumer enqueuing on admin_enqueue_scripts is in time
whatever priority it uses.

Priority 0 is still worth keeping as a defensive measure for anything that
prints scripts by hand, but the documented hazard overstated the risk and would
have pushed integrating plugins into unnecessary hook gymnastics.

The genuine failure mode is Harbor not being present on the request at all.
Describe that instead.

SMTNC-1844

Claude-Session: https://claude.ai/code/session_01BQPHeqkVhHwUUeaem4Rc8M
The previous commit claimed admin.php?page=lw-software-manager left the user on
a "Cannot load" error page. That is wrong. Verified against a real install:
both admin.php and options-general.php return HTTP 200 and render the Software
Manager.

The earlier analysis stopped at get_plugin_page_hookname() and assumed the
parent stayed admin.php. It does not. get_admin_page_parent() scans the
registered submenu, matches the plugin page, and returns its real parent, so the
hook resolves to settings_page_lw-software-manager and the page loads normally.

Keep the options-general.php form, since it is the page's canonical address and
matches every other link to it in the codebase, but describe it as the
consistency change it is. Rename the test accordingly and drop its assertion
that admin.php is absent, which pinned a behaviour that was never broken.

SMTNC-1844

Claude-Session: https://claude.ai/code/session_01BQPHeqkVhHwUUeaem4Rc8M
The repository dictionary is US English and the spell check rejected
"behaviour".

SMTNC-1844

Claude-Session: https://claude.ai/code/session_01BQPHeqkVhHwUUeaem4Rc8M
Licensing data is cached, so a site that has just activated a product in the
portal still believes it is unlicensed when the user lands back on it. Anything
gated on that data is wrong on arrival: an Activate button that should have
disappeared is still there, a feature that should be available is still locked.
Harbor's own page already worked around this with a refresh=auto param and a
handler bound to its page slug, but a host plugin's return URL got nothing.

Generalise it. Activation_Url now tags every return URL it builds, whichever
page the caller nominated, and Activation_Return watches for that tag on any
admin screen. It refreshes the license products and the catalog, strips the tag,
and redirects, all on admin_init so the page renders against current data.

Host plugins need no code for this. Sending a user through a URL from
Activation_Url is the whole opt-in, which is the point: the alternative was
every plugin reimplementing the same handler and any that forgot shipping the
stale-state bug.

The tag is namespaced rather than called something generic like "refresh",
because it rides on a URL owned by the calling plugin and must not collide with
their own params. The handler checks manage_options for the same reason: it can
land on a screen that does not gate on that capability itself. It also sits
behind the usual version leadership check, so four active Liquid Web plugins
make one API call between them rather than four.

Harbor's page-specific handler and its refresh=auto param are removed rather
than left alongside, and its tests move to the new class.

SMTNC-1844

Claude-Session: https://claude.ai/code/session_01BQPHeqkVhHwUUeaem4Rc8M
Three things the previous commit broke, all caught by CI:

Feature_Manager_Page kept a Catalog_Repository it no longer reads, since the
refresh that used it now lives in Activation_Return. PHPStan flagged the
write-only property. Drop the dependency rather than leave it dangling; the
container autowires the constructor, so only the test needed updating.

Activation_Return called the debug trait statically, which phpcs rejects in a
final class. Use self:: instead.

The new test unset $_SERVER['REQUEST_URI'] in teardown, which left the suite
without one and killed the run after the last test. Restore the original value
instead.

SMTNC-1844

Claude-Session: https://claude.ai/code/session_01BQPHeqkVhHwUUeaem4Rc8M
Removing the Catalog_Repository parameter left the type column padded to the
width of a type that is no longer there.

SMTNC-1844

Claude-Session: https://claude.ai/code/session_01BQPHeqkVhHwUUeaem4Rc8M
The handler runs on admin_init, so it is reached on every admin page load, and
resolving it constructs License_Manager and with it the licensing HTTP client.
Almost no admin request is a return trip from the portal, so that construction
was wasted on nearly all of them.

Check for the tag in the provider first and only resolve when it is there. The
handler keeps its own check so it stays correct and testable on its own.

SMTNC-1844

Claude-Session: https://claude.ai/code/session_01BQPHeqkVhHwUUeaem4Rc8M
@dave-green-uk

Copy link
Copy Markdown
Contributor Author

@redscar Do we need to test this in a staging/dev environment or are we just merging when the reviewer has approved? I tagged you for a re-review as I added a couple of new commits after battle testing it with the TEC changes.

@redscar

redscar commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

@redscar Do we need to test this in a staging/dev environment or are we just merging when the reviewer has approved? I tagged you for a re-review as I added a couple of new commits after battle testing it with the TEC changes.

That's a great question. I'm not positive how we tested Harbor in the past. Maybe we should reach out to QA to figure out a game plan?

redscar
redscar previously approved these changes Jul 23, 2026
Consumers were told to resolve Activation_Url from the container, which ties
them to whichever Harbor version's class their own copy ships rather than the
loaded, highest-version one. Add lw_harbor_get_activation_url() and
lw_harbor_get_product_activation_url() -- version-keyed global functions, the
stable public API -- wrapping Activation_Url::get_base() and for_product().
Point the activation-URL guide and the API reference at the functions instead
of the class.

Claude-Session: https://claude.ai/code/session_011uxoDruAaZXzRy3hk8mr4W
@dave-green-uk

Copy link
Copy Markdown
Contributor Author

@d4mation @redscar Could I ask for some 👀 on this additional commit please guys? dc9f24f

It adds something Eric requested in the LD PR I've worked on to consume this. Thanks!

@redscar redscar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look's like you have a failing test. Other than that, the code looks good to me.

MD060 (aligned) flagged the two tables added for the activation-URL global
functions -- the new rows pushed their column past the header pipe. Re-align
both: the README function table and the guide's "From PHP" table.

Claude-Session: https://claude.ai/code/session_011uxoDruAaZXzRy3hk8mr4W
@dave-green-uk

Copy link
Copy Markdown
Contributor Author

@redscar test fixed and has passed 👍

redscar
redscar previously approved these changes Jul 24, 2026
Comment thread src/Harbor/Admin/Feature_Manager_Page.php
Comment thread src/Harbor/Portal/Activation_Url.php Outdated
Comment thread src/Harbor/Portal/Provider.php
Comment thread tests/wpunit/API/Functions/GlobalFunctionsTest.php Outdated
Comment thread tests/wpunit/Portal/Activation_ReturnTest.php Outdated
Comment thread tests/wpunit/Portal/Activation_ReturnTest.php Outdated
Build the default redirect with add_query_arg() rather than concatenating
a query onto admin_url().

Explain why both Portal provider hooks run at priority 0: the script has
to be registered before anything enqueues it by handle, and the return
trip has to be handled before any screen reads the data it refreshes.

Restore Feature_Manager_Page::maybe_redirect_after_refresh() as
deprecated. The class is not final and the method is public, so a
consumer could be calling it. It stays unhooked, and resolves the catalog
from the container so the constructor keeps taking Activation_Url.

Pin the RFC3986 query encoding with a test. It is what separates
http_build_query() from add_query_arg() here: redirect_url carries a
whole URL, and RFC1738 would encode a space as "+" and a tilde as "%7E".

Claude-Session: https://claude.ai/code/session_019G9uUzMJSWzoFatSxvzGoW
Suppressing exit() with uopz_allow_exit() lets a failing test carry on
past the point it should have stopped, which can leave the failure
unreported. Tests now stand in for the call immediately before the exit
and throw, so execution stops where production would end.

Activation_ReturnTest mocks wp_safe_redirect(). The three CLI command
tests mock WP_CLI::error(), which logs before it exits, so the stand-in
writes to the spy logger first and every existing assertion on it holds.

Feature_Manager_PageTest needed no stand-in: none of its cases reaches an
exit, so its guard and the stale $_GET cleanup were dead code. Dropped
the redundant $_GET unset in Activation_ReturnTest too, since the WP test
case already clears superglobals between methods.

Claude-Session: https://claude.ai/code/session_019G9uUzMJSWzoFatSxvzGoW
uopz executes a replacement closure with no class scope, so reading the
message from a constant inside it raised an Error rather than the
TestException the test expected. Capture it first, as the CLI stand-ins
already do.

Also drop the extra padding after @SInCE, which the docblock sniff reads
as more than one space.

Claude-Session: https://claude.ai/code/session_019G9uUzMJSWzoFatSxvzGoW
@dave-green-uk

Copy link
Copy Markdown
Contributor Author

Heads up on 610a5ec, which touches Features/Types/Plugin.php and is nothing to do with this feature.

phpstan started failing on $plugin_data['Version'] ?? null — a file this branch does not touch, and green on the previous commit here. The cause: composer.lock is untracked and the analysis workflow resolves dependency-versions: highest, so a newer WordPress stub release started describing get_plugin_data() as always filling every header key, which makes the coalesce provably dead. Removed it; behaviour is unchanged, and the is_installed() guard above still covers the null return the docblock promises.

Worth flagging that this will now fail on every Harbor pull request until it is fixed, so it is not specific to this branch — and the floating-dependency setup means the same thing can happen again on an unrelated PR at any time. Might be worth a ticket.

@d4mation d4mation mentioned this pull request Jul 27, 2026
@d4mation

Copy link
Copy Markdown
Collaborator

Heads up on 610a5ec, which touches Features/Types/Plugin.php and is nothing to do with this feature.

phpstan started failing on $plugin_data['Version'] ?? null — a file this branch does not touch, and green on the previous commit here. The cause: composer.lock is untracked and the analysis workflow resolves dependency-versions: highest, so a newer WordPress stub release started describing get_plugin_data() as always filling every header key, which makes the coalesce provably dead. Removed it; behaviour is unchanged, and the is_installed() guard above still covers the null return the docblock promises.

Worth flagging that this will now fail on every Harbor pull request until it is fixed, so it is not specific to this branch — and the floating-dependency setup means the same thing can happen again on an unrelated PR at any time. Might be worth a ticket.

@dave-green-uk Would you mind fixing that on its own branch, please? I encountered it while preparing #180 as well.

(Also, it would be better to point these PRs somewhere other than main so that we don't accidentally interfere with other releases)

Comment thread src/Harbor/Admin/Feature_Manager_Page.php Outdated
@dave-green-uk
dave-green-uk changed the base branch from main to release/1.5.1 July 27, 2026 13:33
@d4mation

Copy link
Copy Markdown
Collaborator

@dave-green-uk Release v1.5.1 is minutes from merging, so we won't want to merge it there 😅

Maybe you could make a bucket/ branch for this related work?

@dave-green-uk
dave-green-uk changed the base branch from release/1.5.1 to bucket/activation-flow-api July 27, 2026 13:56
The docblock tag only reaches someone reading the source. A consumer
still calling the method finds out from the notice.

Claude-Session: https://claude.ai/code/session_019G9uUzMJSWzoFatSxvzGoW
@dave-green-uk
dave-green-uk force-pushed the smtnc-1844-harbor-expose-a-reusable-activation-url-api-for-php-and-js branch from 610a5ec to a5f52f1 Compare July 27, 2026 14:07
@dave-green-uk
dave-green-uk requested a review from d4mation July 27, 2026 14:09
@dave-green-uk

Copy link
Copy Markdown
Contributor Author

@d4mation Back to you, updated the deprecated method, spun up #183 for the fix to unblock CI.

@dave-green-uk

Copy link
Copy Markdown
Contributor Author

Both done.

610a5ec is out of this branch and now lives in #183, targeting bucket/activation-flow-api rather than main so it stays clear of release/1.5.1. This branch no longer touches Features/Types/Plugin.php at all.

This PR was already based on bucket/activation-flow-api, so it should be clear of the release either way.

Force-pushed, so #181 is now:

  • 081c4613 — source review feedback
  • 189b9c1d — the exit pattern, suite-wide
  • cf6ed65e — CI fixes
  • a5f52f15 — the _deprecated_function() call you asked for

Comment thread build-dev/index.js
Comment thread src/Harbor/Portal/Activation/Return_Handler.php
Comment thread docs/guides/activation-urls.md
Comment thread docs/guides/activation-urls.md Outdated
Comment thread docs/guides/activation-urls.md Outdated
Comment thread src/Harbor/Portal/Activation_Return.php Outdated
Comment thread src/Harbor/Portal/Activation_Script.php
Comment thread src/Harbor/Portal/Activation_Script.php Outdated
Comment thread src/Harbor/Portal/Activation_Script.php Outdated
Comment on lines +28 to +41
/**
* Query param added to the return URL to mark a portal round trip.
*
* Harbor caches licensing data, so a site that has just activated in the
* portal still believes it is unlicensed until that cache is refreshed.
* Activation_Return watches for this param and refreshes before the page
* renders, so callers do not have to think about it.
*
* Deliberately namespaced. It rides on a URL owned by the calling plugin,
* so a generic name like "refresh" would risk colliding with theirs.
*
* @since TBD
*/
public const RETURN_PARAM = 'lw-harbor-activated';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it need to be public const?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reason as the script handle — Return_Handler and Provider both read it, so private will not compile. Marked internal in the docblock; consumers never need it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But does it need to be there? The callback methods you're hooking in Provider seem like they would be better placed in Return_Handler directly.

dave-green-uk and others added 5 commits July 28, 2026 16:53
# Conflicts:
#	build-dev/index.asset.php
#	build-dev/index.js.map
Namespace. The three activation classes move into Portal\Activation and drop
the prefix they were carrying instead: Url, Script, Return_Handler. Return on
its own is a reserved word, so that one keeps a suffix rather than becoming
something vaguer.

Public API. lw_harbor_get_activation_url() is now
lw_harbor_get_activation_base_url(), which is what it actually returns — the
unscoped URL its sibling adds an sku to. Nothing consumes the old name outside
this repo yet, so renaming now costs a find-and-replace rather than a
deprecation cycle later.

Script handle. Consumers no longer name Activation_Script::HANDLE to declare
the dependency. A constant read from the copy a plugin bundled can disagree
with the copy that actually registered the script, which is the same trap the
global functions exist to avoid. lw_harbor_add_activation_script_dependency()
takes the consumer's own handle instead and wires Harbor in. It retries at the
end of admin_enqueue_scripts so the caller does not have to run after Harbor,
which is a property naming the handle in a $deps array had for free and would
otherwise have been lost. The constants stay public because Harbor reads them
across class boundaries and PHP has no narrower visibility, but they are marked
internal and no longer documented.

Asset paths. The build-dir and plugin-URL resolution was duplicated between
Feature_Manager_Page and the activation script. It is now Utils\Assets, so a
change to the build pipeline lands in one place.

Return handler. Leadership is checked before the capability check and the two
are one conditional: whether this copy acts is a question about the install,
the capability is a question about the user. The single-line nonce suppression
is a phpcs:ignore rather than a disable/enable pair.

The window.lwHarbor.version inline script is gone. It ran regardless of
WP_DEBUG, was only ever read by its own test, and Version::register_debug_info()
already reports the leader under WP_DEBUG.

Docs. The claim that the leader may be older than the copy your plugin ships
was wrong — a newer copy becomes the leader — so the feature-detection advice
now rests on the case that does happen, no Harbor at all. Query strings in the
examples are built with add_query_arg() rather than typed out.

Claude-Session: https://claude.ai/code/session_01F9MXc12PHkbPd2XaxnWwGN
Firing admin_enqueue_scripts runs every callback on it, including WordPress
core's WP_Site_Health::enqueue_scripts, which reads a current screen that a
wpunit run does not have. Four new tests fired it directly and errored there
rather than on anything they were asserting.

The hook is emptied before the function under test registers its own callback,
so what fires is only what the test put there.

Claude-Session: https://claude.ai/code/session_01F9MXc12PHkbPd2XaxnWwGN
An empty string is a valid string, so a consumer that forgets to check gets
href="" — a link to the current page — rather than anything that looks wrong.
Null cannot be pasted into markup by accident, and it distinguishes "Harbor has
nothing for you" from a URL that happens to be blank.

Both activation URL functions now return ?string, and the registry closures
return null on failure rather than an empty string, so the two agree.

Claude-Session: https://claude.ai/code/session_01F9MXc12PHkbPd2XaxnWwGN
Comment thread webpack.config.js
Comment on lines +14 to +21
index: path.resolve( process.cwd(), 'resources', 'js', 'index.tsx' ),
// Shared helper consumed by host plugins' onboarding screens. Exposed
// as a window global so it works from an inline script, with no build
// step required on the consuming side.
activation: {
import: path.resolve( process.cwd(), 'resources', 'js', 'activation-entry.ts' ),
library: { name: 'lwHarbor', type: 'window' },
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this a little concerning to release in this plugin. There seems to be a lot of effort in making this window object available in this PR and i'm honestly not sure its worth it. Maintaining a js global object between plugin dependencies from my experience comes with a lot of responsibility.

This is one of things we need to be very intentional of for the future of this package and i'm not following what the actual intent is.

I think we might be better off having each branch enqueue their own js global object to whatever screen they would need and just use global functions to provide static activation urls.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had similar thoughts and wasn't too concerned at first, but I think you've got a good point here:

In order to use this, the plugin including Harbor already has JS enqueued. They could just as easily just localize the output of the global php functions that Harbor is providing here and using them as-needed.

Comment on lines +150 to +162
\_lw_harbor_global_function_registry(
'lw_harbor_get_product_activation_url',
$version,
static function ( string $product_slug, string $tier, ?string $redirect_url = null ): ?string {
try {
return Config::get_container()->get( Url::class )->for_product( $product_slug, $tier, $redirect_url );
} catch ( Throwable $e ) {
self::debug_log_throwable( $e, 'Error building product activation URL' );

return null;
}
}
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure there is some default logic on the portal side to figure out the $tier so it might be okay to let it be nullable.

Although how do you plan on actually getting the tiers from inside a plugin to even pass to this function?

);

\_lw_harbor_global_function_registry(
'lw_harbor_get_activation_base_url',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of this function sounds potentially too generic when the next would specifies its for a production_activation_url so should it be lw_harbor_get_product_activation_base_url?

*
* @since TBD
*/
final class Assets {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this have a unit test?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants