Add a reusable activation URL API for PHP and JS - #181
Conversation
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
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
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
|
@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? |
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
redscar
left a comment
There was a problem hiding this comment.
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
|
@redscar test fixed and has passed 👍 |
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
|
Heads up on 610a5ec, which touches phpstan started failing on 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 |
|
@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 |
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
610a5ec to
a5f52f1
Compare
|
Both done.
This PR was already based on Force-pushed, so #181 is now:
|
| /** | ||
| * 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'; |
There was a problem hiding this comment.
Does it need to be public const?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
# 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
| 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' }, | ||
| }, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| \_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; | ||
| } | ||
| } | ||
| ); |
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
Does this have a unit test?
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:wp_localize_script()inFeature_Manager_Page. Not a method, not a service, no filter.buildActivationUrl(), 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\Urlservice, exposes it to host plugins through stablelw_harbor_*global functions, and exposes the JS helper as a shared script.There was also a latent correctness gap:
redirect_urlwas 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:
bucket/activation-flow-apiis 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.Portal\ActivationasUrl,ScriptandReturn_Handler.Returnon 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 anskuto. All four consumer PRs are updated.?string, null rather than an empty string when there is nothing to give.lw_harbor_add_activation_script_dependency()so consumers never read a Harbor constant to attach the JS helper.Utils\Assetsextracts the build-directory resolution that was duplicated withFeature_Manager_Page.window.lwHarbor.versionis gone. It ran regardless ofWP_DEBUGandVersion::register_debug_info()already reports the leader.What is purely additive
Nothing below changes existing behaviour. All of it is new surface area.
src/Harbor/Portal/Activation/Url.phpget_base( ?string $redirect_url )andfor_product( string $slug, string $tier, ?string $redirect_url ). Autowired withSite\Data.src/Harbor/Portal/Activation/Script.phplw-harbor-activationscript behindVersion::should_handle(). Admin-only.src/Harbor/Portal/Activation/Return_Handler.phpsrc/Harbor/API/Functions/Actions/Add_Activation_Script_Dependency.phplw_harbor_add_activation_script_dependency().src/Harbor/Utils/Assets.phpFeature_Manager_Page.resources/js/activation-entry.tswindow.lwHarbor, zero dependencies.docs/guides/activation-urls.mdtests/wpunit/Portal/Activation/UrlTest.phptests/wpunit/Portal/Activation/ScriptTest.phptests/wpunit/Portal/Activation/Return_HandlerTest.phptests/js/activation-entry.test.tschangelog/…yamltests/wpunit/API/Functions/GlobalFunctionsTest.phpalso gains 9 tests for the new global functions.PHP public API —
lw_harbor_*global functionsAdded in response to review: rather than resolving a Harbor class from the container, host plugins call stable global functions.
lw_harbor_get_activation_base_url( ?string $redirect_url ): ?stringActivation\Url::get_base()lw_harbor_get_product_activation_url( string $slug, string $tier, ?string $redirect_url ): ?stringActivation\Url::for_product()lw_harbor_add_activation_script_dependency( string $handle ): voidLike 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 anhrefand you get a link to the current page. Null cannot be used by accident.lw_harbor_add_activation_script_dependency()exists so consumers never readScript::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: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$depsarray 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 changedThe inline
http_build_query()block that producedharborData.activationUrlis replaced by$this->activation_url->get_base().Same params, same order, same
PHP_QUERY_RFC3986encoding. 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_urlparam now points atoptions-general.php?page=lw-software-manager&refresh=autoinstead ofadmin.php?page=….1a. Minor: the default redirect now uses the page's canonical URL
redirect_urlchanges fromadmin.php?page=lw-software-manager&refresh=autotooptions-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 viaget_admin_page_parent(), which returns the real parent rather thanadmin.php.This is a consistency change, not a fix.
options-general.phpis 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=autoparam 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\Urlnow tags every return URL it builds — whichever page the caller nominated — andReturn_Handlerwatches for that tag on any admin screen, refreshes, strips it, and redirects. All onadmin_init, so pages render against current data. Host plugins need no code for this.Points worth a reviewer's attention:
lw-harbor-activated), not something generic likerefresh. It rides on a URL owned by the calling plugin and must not collide with their params.manage_optionsbecause the tag can land on a screen with no capability check of its own.admin_initfires on every admin page load and resolving the handler builds the licensing HTTP client. The handler repeats the check for its own sake.Feature_Manager_Page's own handler and itsrefresh=autoparam are removed, not left alongside — its tests move toReturn_HandlerTest. That also orphaned itsCatalog_Repositorydependency, 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 registeredTwo container registrations, plus:
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
0is defensive rather than required. WordPress resolves script dependencies when scripts are printed, not when they are enqueued, andadmin_enqueue_scriptsalways runs beforeadmin_print_scripts— so any consumer enqueuing on that hook is in time whatever priority it uses. Priority0additionally covers anything that prints scripts by hand.3.
webpack.config.js— second entryAdds an
activationentry withlibrary: { name: 'lwHarbor', type: 'window' }. The existingindexentry is untouched apart from whitespace alignment. Build output gainsbuild/activation.jsandbuild/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_Responseand its 8 call sites inLicense_ControllerProduct_Collection::to_array()resources/js/lib/activation-url.ts— already acceptedbaseUrlas a parameter, so it works unmodifiedDropping 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-activationandlwHarborare 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::HANDLEandUrl::RETURN_PARAMarepublicbut internal. Harbor reads both across class boundaries and PHP has no package-internal visibility, soprivatewill not compile. They are marked internal in their docblocks and no longer appear in the docs; consumers uselw_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:
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_RFC3986and JS'sURLSearchParamsboth emitsku=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%7Efor a tilde insideredirect_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_urlparam now readsoptions-general.php?page=lw-software-manager&refresh=autorather thanadmin.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 carriesportal-referral=plugin,domain, and a percent-encodedredirect_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=1and 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-activationis registered exactly once and served from the highest version'svendor/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.jsshould be registered but never printed.Testing
Green in CI — 23/23 checks, including:
build-frontendruns on push and commits the built assets automatically.Verified locally:
markdownlintandcspellclean;php -lclean on all changed PHP files.window.lwHarbor.buildActivationUrl()works.Local environment note (not a blocker for this PR):
composer installcurrently fails on a clean checkout —lucatume/tdd-helpersandlucatume/wp-utilsboth 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 normalcomposer update stellarwp/harborhandles 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'sfilesautoloader 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
function_exists()guards in the four consumer plugins once this tags and each bumps its bundled Harbor.composer.lockis untracked anddependency-versions: highestwith 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.wp-admin.