Add a reusable activation URL API - #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
# 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.
There was a problem hiding this comment.
Removed in e58fb2f — window global, webpack entry, build artifacts, script handle and the sixth global function all gone. The clincher was that the sku contract was already duplicated: PHP guards an empty tier and sends a bare slug, the TS didn't. Kadence Blocks Pro had independently hand-rolled the same helper and has exactly that bug live (sku=kadence:), which I've flagged on stellarwp/kadence-blocks-pro#299.
| \_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?
There was a problem hiding this comment.
Both covered. Tier is ?string $tier = null with tests for null and empty-string. For getting one, lw_harbor_get_product_tier() landed in 6ebaeef after you commented — it returns null when a license covers a product at several tiers, and you pass that straight through. I confirmed the portal handles a bare sku fine: still shows the customer the products on their subscription.
There was a problem hiding this comment.
I agree with @jonwaldstein. The portal is in the best position to answer what tiers are available, why do we need to pass it a suggestion, and add a new global function. I really don't like expanding this API unnecessarily.
There was a problem hiding this comment.
@johnhooks This was for convinience/better UX more than anything. For example - without the tier, the customer lands on a page like this:
But with the tier, they get taken straight to it:
I feel this is a better experience, but no so strongly that it's a hill I want to die on.
There was a problem hiding this comment.
I can live with it. I still think it's a bit odd, but I understand the intend and why it would be useful. I would just prefer the LW Portal to handle receiving a request for product activation and intelligently recognizing the customer has multiple tiers and offer the options.
| ); | ||
|
|
||
| \_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?
There was a problem hiding this comment.
Renamed in 2c50995. lw_harbor_get_product_activation_base_url() now, reading as "the base URL product activation is built on" rather than a URL that's itself product-scoped; the docblock spells that out since the list does arrive unfiltered. Test names and docs follow.
| * | ||
| * @since TBD | ||
| */ | ||
| final class Assets { |
There was a problem hiding this comment.
Does this have a unit test?
There was a problem hiding this comment.
Added in ae32efd, 9 tests. The two that matter are the dirname() chain resolving to Harbor's own root (fails loudly if the class moves, which would break inside a Strauss-prefixed copy) and path/url agreeing on the same build dir. One limitation: WP_DEBUG is a constant so only the booted branch is reachable; the test derives its expectation independently, which catches an inverted ternary but can't exercise both directions.
…lasses Every consumer plugin was resolving License_Repository, Product_Collection and Product_Entry from its own Strauss-prefixed copy, because the published API had no way to ask whether a license covers a product, or at which tier. Only the highest-version copy refreshes the catalog, so those consumers were hydrating the leader's payload with their own, possibly older, code — safe only while every plugin bundles the same Harbor. Adds lw_harbor_is_product_licensed() and lw_harbor_get_product_tier(), and makes the tier argument to lw_harbor_get_product_activation_url() optional. get_product_tier() returns null when a license covers a product at several tiers rather than picking the first. A null tier sends an unscoped sku, and the portal answers that with a product and tier picker still limited to the activating domain — the right interface for a genuine choice, and confirmed against the portal rather than assumed.
There was a problem hiding this comment.
Do we need this change anymore? It isn't necessarily bad, but since we are no longer creating a new asset file in this PR it seems strange to include. It may be better to revert this part and wait until we need this kind of functionality to re-introduce it. Particularly because the way our Assets may be stored/loaded could be different at that point.
There was a problem hiding this comment.
If it's not used I say cut it.
| /** | ||
| * Refreshes cached licensing data when the portal returns a user to the site. | ||
| * | ||
| * Whether this request is a return trip at all is the handler's own first | ||
| * question, so it is not asked again here. | ||
| * | ||
| * @since TBD | ||
| * | ||
| * @return void | ||
| */ | ||
| public function maybe_refresh_after_activation(): void { | ||
| $this->container->get( Return_Handler::class )->maybe_refresh(); | ||
| } |
There was a problem hiding this comment.
Does this need to be a named method? Look at how the hook on lw-harbor/unified_license_key_changed is handled in this same provider with an anonymous callback.
There was a problem hiding this comment.
There was a problem hiding this comment.
The main reasons I would have for using a closure here would be:
- If we have a public method on this class, it could be expected to be callable by other code
- We aren't doing anything here but calling another class method
- Considering this, however, we probably could just use
$this->container->callback( Return_Handler::class, 'maybe_refresh' ) );attached to the hook?
- Considering this, however, we probably could just use
- Consistency with the other hook in the file
…for-php-and-js' of https://github.com/stellarwp/harbor into smtnc-1844-harbor-expose-a-reusable-activation-url-api-for-php-and-js
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Enterprise Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
33857a7 to
96ade84
Compare
Closes SMTNC-1844 · Parent: SMTNC-1833
Summary
Onboarding screens across our plugins need an "Activate" button equivalent to the one in Harbor's
LicenseProductCard. The logic to build one is assembled inline insidewp_localize_script()inFeature_Manager_Page— not a method, not a service, and not reachable from a host plugin. Without an API, every plugin hand-rolls the portal's query string and the copies drift the moment the portal changes a param. That is already happening: kadence-blocks-pro#299 reimplements it, reachingHarborConfig::directly and carrying a superseded param.This PR extracts the logic into a
Portal\Activation\Urlservice and exposes it through stablelw_harbor_*global functions.It also closes a latent gap:
redirect_urlwas hardcoded to Harbor's own Software Manager page, so activating from a plugin's onboarding screen returned the user to the wrong place. Callers now supply their own destination, and the return trip refreshes cached licensing data automatically.Activation URLs are built in PHP and passed to a consumer's own screens. Harbor ships no browser API for this.
The API
lw_harbor_get_product_activation_base_url( ?string $redirect_url )?stringlw_harbor_get_product_activation_url( string $slug, ?string $tier, ?string $redirect_url )sku={slug}[:{tier}].?stringlw_harbor_is_product_licensed( string $slug )boollw_harbor_get_product_tier( string $slug )?stringVersion-keyed like the rest of the
lw_harbor_*surface, so they always resolve to the loaded, highest-version copy and a consumer never builds a class from its own possibly-stale vendor tree.The URL functions return
null, not an empty string, when no Harbor 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.$tieris nullable and passes straight through.get_product_tier()returns null when a license covers a product at several tiers; that composes into an unscopedskuso the portal offers its own picker rather than us guessing. Confirmed with the portal side — a bareskuis supported and still shows the customer the products on their subscription.New files:
Portal/Activation/Url.php,Portal/Activation/Return_Handler.php,Utils/Assets.php,docs/guides/activation-urls.md, three changelog entries, and 25 tests acrossUrlTest,Return_HandlerTestandAssetsTest.GlobalFunctionsTestcovers the new functions.What changes for existing installs
1.
Feature_Manager_Page— constructor signatureThe inline
http_build_query()block that producedharborData.activationUrlbecomes$this->activation_url->get_base()— same params, same order, samePHP_QUERY_RFC3986encoding.Catalog_Repositorystays injected: its only remaining caller is the deprecatedmaybe_redirect_after_refresh(), and keeping it in the constructor means removing that method removes its dependency with it, rather than leaving a service-located lookup behind. The container autowires, so nothing outside the tests needed updating. Build-directory resolution moves toUtils\Assets.The default
redirect_urlalso moves fromadmin.php?page=lw-software-managertooptions-general.php?page=…, the page's canonical address and the form every other link to it uses. Both resolve to the same screen — WordPress mapsadmin.php?page={slug}viaget_admin_page_parent()— so this is consistency, not a fix. Covered bytest_get_base_uses_the_canonical_page_url.2. The return trip 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 — an Activate button that should have gone, a feature that should have unlocked. Harbor's own page worked around this with
refresh=autobound 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.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_options, because the tag can land on a screen with no check of its own.Feature_Manager_Page::maybe_redirect_after_refresh()is no longer hooked, and is deprecated rather than deleted —_deprecated_function()notice as well as the docblock tag, since the class is not final and the method is public. Its tests move toReturn_HandlerTest.3.
Portal\Provider— one new hookThe only new runtime behaviour on existing installs. The hooked method is a one-line shim that resolves
Return_Handlerand calls it; the handler's own first question is whether this request is a return trip, and on almost every request it is not and it returns immediately. Priority0so the refresh happens before any screen reads the data it is about to change.Notes for reviewers
Url::RETURN_PARAMispublicbut internal.Return_Handler, its sibling in the same namespace, reads it to recognize the return trip and strip the tag, and PHP offers nothing narrower. Marked internal in its docblock, absent from the docs — consumers never need it, the round trip is handled for them.PHP_QUERY_RFC3986is deliberate.add_query_arg()is RFC1738 and would send the portal+for a space and%7Efor a tilde insideredirect_url.Assets::build_dir()reads a constant.WP_DEBUGcannot be redefined mid-run, so its test only exercises the branch the suite booted with. The expectation is derived from the constant independently, which catches the ternary being inverted but cannot cover both directions. Injecting the flag felt like more surface than a three-method utility warrants — happy to be argued out of that.QA notes
Regression surface is narrow but specific.
1. Feature Manager page — no visible change expected. The Activate buttons should behave exactly as before, and activating should still return you to the Software Manager with the product activated.
redirect_urlnow readsoptions-general.php?page=lw-software-manager&lw-harbor-activated=1rather thanadmin.php?page=…&refresh=auto. Both page forms resolve to the same screen, so this should be invisible — but it is the value that changed. 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=1then redirect to the clean URL. Verified in a real install: with the tag, one redirect and the param is stripped; without it, no redirect.3. Unscoped activation. With a license covering a product at more than one tier, the URL should carry a bare
sku={slug}with no trailing colon, and the portal should present its own picker scoped to the domain.4. Multiple Harbor copies. With two or more plugins bundling different Harbor versions active, confirm the return-trip refresh happens exactly once, from the highest version.
Integration note — Strauss classmap
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; dropping files in by hand will not.The
lw_harbor_*global functions sidestep this entirely — 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.Testing
JS suite passes locally — 14 suites, 87 tests. Both dev and prod webpack builds compile.
markdownlint,cspellandphp -lclean across every file this branch touches.The PHP suite has not run locally.
composer installfails 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 — unrelated to this branch, and worth its own ticket. The PHP tests here are written but have only been exercised in CI.Follow-ups, not in this PR
skucontract in JS.lw_harbor_get_product_activation_base_url()once this tags, replacing its hand-rolled query string andHarborConfig::reach. Already flagged on that PR.function_exists()guards in the consumer plugins once this tags and each bumps its bundled Harbor.