Skip to content

Add a reusable activation URL API - #181

Open
dave-green-uk wants to merge 33 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#181
dave-green-uk wants to merge 33 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

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 inside wp_localize_script() in Feature_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, reaching HarborConfig:: directly and carrying a superseded param.

This PR extracts the logic into a Portal\Activation\Url service and exposes it through stable lw_harbor_* global functions.

It also closes a latent gap: redirect_url was 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

Function Returns
lw_harbor_get_product_activation_base_url( ?string $redirect_url ) The portal subscriptions URL — referral, domain, tagged redirect. ?string
lw_harbor_get_product_activation_url( string $slug, ?string $tier, ?string $redirect_url ) The same plus sku={slug}[:{tier}]. ?string
lw_harbor_is_product_licensed( string $slug ) Whether the license covers a product at all, activated or not. bool
lw_harbor_get_product_tier( string $slug ) The licensed tier, or null when absent or licensed at several. ?string

Version-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 an href and you get a link to the current page. Null cannot be used by accident.

$tier is nullable and passes straight through. get_product_tier() returns null when a license covers a product at several tiers; that composes into an unscoped sku so the portal offers its own picker rather than us guessing. Confirmed with the portal side — a bare sku is 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 across UrlTest, Return_HandlerTest and AssetsTest. GlobalFunctionsTest covers the new functions.

What changes for existing installs

1. Feature_Manager_Page — constructor signature

-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, Catalog_Repository $catalog ) {

The inline http_build_query() block that produced harborData.activationUrl becomes $this->activation_url->get_base() — same params, same order, same PHP_QUERY_RFC3986 encoding. Catalog_Repository stays injected: its only remaining caller is the deprecated maybe_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 to Utils\Assets.

The default redirect_url also moves from admin.php?page=lw-software-manager to options-general.php?page=…, the page's canonical address and the form every other link to it uses. Both resolve to the same screen — WordPress maps admin.php?page={slug} via get_admin_page_parent() — so this is consistency, not a fix. Covered by test_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=auto 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.

  • 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 acts is a question about the install, the capability is about the user in front of it. manage_options, because the tag can land on a screen with no check of its own.
  • Behind the leadership check, so four plugins bundling Harbor make one API call between them, not four.
  • Failures are logged, not surfaced. The user is looking at a product screen, not a licensing one.

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 to Return_HandlerTest.

3. Portal\Provider — one new hook

add_action( 'admin_init', [ $this, 'maybe_refresh_after_activation' ], 0, 0 );

The only new runtime behaviour on existing installs. The hooked method is a one-line shim that resolves Return_Handler and 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. Priority 0 so the refresh happens before any screen reads the data it is about to change.

Notes for reviewers

Url::RETURN_PARAM is public but 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_RFC3986 is deliberate. add_query_arg() is RFC1738 and would send the portal + for a space and %7E for a tilde inside redirect_url.

Assets::build_dir() reads a constant. WP_DEBUG cannot 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_url now reads options-general.php?page=lw-software-manager&lw-harbor-activated=1 rather than admin.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 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 then 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 normal composer update stellarwp/harbor handles it; dropping files in by hand will not.

The lw_harbor_* global functions sidestep this entirely — 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.

Testing

JS suite passes locally — 14 suites, 87 tests. Both dev and prod webpack builds compile. markdownlint, cspell and php -l clean across every file this branch touches.

The PHP suite has not run locally. composer install 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 — 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

  • Per-product activation URL in the REST license payload, so a consumer whose tier arrives asynchronously — kadence-blocks-pro#299 is the live case — can drop its own URL-building rather than duplicating the sku contract in JS.
  • #299 adopting lw_harbor_get_product_activation_base_url() once this tags, replacing its hand-rolled query string and HarborConfig:: reach. Already flagged on that PR.
  • Remove the function_exists() guards in the consumer plugins once this tags and each bumps its bundled Harbor.

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 Outdated
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
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 Outdated
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.

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.

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.

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?

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.

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.

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 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.

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.

@johnhooks This was for convinience/better UX more than anything. For example - without the tier, the customer lands on a page like this:

Screenshot 2026-08-12 154929

But with the tier, they get taken straight to it:

Screenshot 2026-08-12 155002

I feel this is a better experience, but no so strongly that it's a hill I want to die on.

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 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',

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?

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.

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.

Comment thread src/Harbor/Utils/Assets.php Outdated
*
* @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?

@dave-green-uk dave-green-uk Aug 12, 2026

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.

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.
Comment thread changelog/smtnc-1844-product-license-lookups.yaml
Comment thread src/Harbor/Admin/Feature_Manager_Page.php Outdated
@dave-green-uk dave-green-uk changed the title Add a reusable activation URL API for PHP and JS Add a reusable activation URL API Aug 12, 2026

@d4mation d4mation left a comment

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.

Small questions

Comment thread src/Harbor/Utils/Assets.php Outdated

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.

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.

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.

If it's not used I say cut it.

Comment thread src/Harbor/Portal/Provider.php Outdated
Comment on lines 57 to 69
/**
* 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();
}

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 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@d4mation I have made this change here. But are named function better than closures? They are self documented, tracked with docblock, can be unhooked with remove_action if ever needed.

Wondering what are the cases where we should prefer closured over named functions.

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.

The main reasons I would have for using a closure here would be:

  1. If we have a public method on this class, it could be expected to be callable by other code
  2. 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?
  3. Consistency with the other hook in the file

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Enterprise

Run ID: f8bd740c-759c-4736-8f6f-56abc5091bd6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@pramodjodhani
pramodjodhani force-pushed the smtnc-1844-harbor-expose-a-reusable-activation-url-api-for-php-and-js branch from 33857a7 to 96ade84 Compare August 14, 2026 18:54
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.

6 participants