Skip to content

feat: pass legacy license keys to Herald for updates and downloads#159

Merged
d4mation merged 25 commits into
mainfrom
feature/utilize-registered-legacy-licenses-for-updates
May 19, 2026
Merged

feat: pass legacy license keys to Herald for updates and downloads#159
d4mation merged 25 commits into
mainfrom
feature/utilize-registered-legacy-licenses-for-updates

Conversation

@d4mation
Copy link
Copy Markdown
Collaborator

@d4mation d4mation commented May 15, 2026

Summary

Harbor now forwards per-plugin Legacy License Keys (registered via the lw-harbor/legacy_licenses filter, whether sourced from Uplink or any custom legacy storage) to Herald's /legacy/download endpoint, and treats an active legacy entry as an availability + in-tier grant for its matching catalog slug. This lets a customer with only a legacy key receive update notifications and download packages directly through Harbor, without a Unified key.

Why this matters

This unblocks decommissioning ZIP uploads to the legacy licensing servers: as long as Herald accepts the key on /legacy/download, Harbor can take it the rest of the way.

A simple Cloudflare-style silent redirect from the legacy licensing server's download URL to Herald isn't sufficient on its own, because update discoverability still needs to happen on the WordPress side. Catalog version metadata flows through Portal, the resolver, and the update handlers; only with that local pipeline aware of legacy entitlements does the customer ever see "an update is available" in the first place. Doing this in Harbor turns out to be the simplest path forward.

IMPORTANT

Right now, sending a Legacy License to Harbor to download Shop Kit doesn't work because of this: https://lw.slack.com/archives/C0ADJGSME5Q/p1778730684475449

We will want to wait until https://linear.app/nexcess/issue/CONS-229/re-introduce-uplink-into-shop-kit-pro is complete before deploying this change.

📹 https://www.loom.com/share/2cdde6cddbb44a75989920ec079b5bee

d4mation added 6 commits May 15, 2026 16:15
Lets customers with legacy per-plugin license keys (reported via the
lw-harbor/legacy_licenses filter) receive update checks and download
their packages via Herald's /legacy/download endpoint, even without a
Unified key. Active legacy entries grant catalog feature availability
for their matching slug and take precedence over Unified URLs in
Herald_Url_Builder. The legacy key must be non-empty and is_active must
be true for the grant to apply.
The lw-harbor/legacy_licenses filter contract requires both key and
slug. Entries missing either field were previously cached and surfaced
inconsistently (UI/notices showed them while access paths defensively
rejected them). Drop them in License_Repository::all() so they never
reach any downstream consumer.
Document how reported legacy keys feed into Resolve_Feature_Collection
(availability grant), Plugin_Handler/Theme_Handler (update checks),
and Herald_Url_Builder (download URL with precedence over Unified).
Also clarify the semantics of is_active and what happens to malformed
entries.
PHPUnit only picks up methods prefixed with test_. The it_* prefix is
the Codeception .cest format, so the 11 existing methods in this file
were silently skipped by the wpunit suite. Renaming them brings 11
previously-dormant tests into the suite (37 new assertions, all
passing).
…rder

Resolution prefers Unified because it is the canonical source of
entitlement; URL building prefers Legacy because legacy keys are
scoped to a specific slug while the Unified key only authenticates
features in its capabilities array (which the URL builder does not
consult). Preferring legacy when present avoids generating Unified
URLs that Herald would reject for slugs the Unified tier does not
include but a legacy add-on does.
@d4mation d4mation marked this pull request as ready for review May 15, 2026 20:40
@linear
Copy link
Copy Markdown

linear Bot commented May 15, 2026

CONS-271

Comment thread docs/guides/integration.md
Comment thread src/Harbor/Features/Resolve_Feature_Collection.php Outdated
Comment thread src/Harbor/Legacy/License_Repository.php
Comment thread src/Harbor/Portal/Herald_Url_Builder.php Outdated
Comment thread src/Harbor/Portal/Herald_Url_Builder.php Outdated
Comment thread src/Harbor/Portal/Herald_Url_Builder.php Outdated
d4mation added 11 commits May 18, 2026 13:05
…ity branches

Promote $has_legacy_grant into the unconditional-true branch alongside
wporg and free-tier, then collapse the remaining elseif/else into a
single else guarded by $capabilities !== null short-circuit. The null
guard the original elseif provided against in_array() is preserved.
Both fields are typed strings, so the empty-string comparison and the
truthiness check are equivalent. The tighter form reads better and was
the form requested in review.
build() now decides which license type covers the slug; the legacy and
Unified URL string assembly each live in their own private helper, and
both go through a single herald_url() composer that owns the base-URL
concatenation and add_query_arg() call. The public Download_Url_Builder
contract is unchanged.
Site\Data::get_domain() returns string today, so the existing === ''
check is functionally identical. Swapping to ! $domain future-proofs
the guard against the return type ever loosening to ?string.
…plitting legacy out

The earlier change added Legacy_License_Repository as a required third
argument to Herald_Url_Builder, altering the class's expected constructor
shape and breaking the public contract for any caller that instantiates
it directly.

Restore the original Herald_Url_Builder( License_Repository, Data )
signature and move the new behavior into sibling classes:

- Herald_Url_Builder: Unified license URL only -- original 2-arg
  signature preserved verbatim from main
- Herald_Legacy_Url_Builder: legacy license URL only
- Herald_Routing_Url_Builder: implements Download_Url_Builder, holds
  both and returns the first non-empty result (legacy first, Unified
  fallback)

Portal Provider now binds Download_Url_Builder to
Herald_Routing_Url_Builder, so consumers of the contract get the same
end-to-end behavior the previous design provided, without
Herald_Url_Builder itself being touched.
…nt optional

Resolve_Feature_Collection previously required Legacy_License_Repository
as a fourth constructor argument, breaking the original three-argument
shape any caller would have been relying on.

Make the new argument nullable with a null default and lazily build a
fresh Legacy_License_Repository when omitted. Both the container-managed
singleton and the default fresh instance read the same
`lw-harbor/legacy_licenses` filter, so callers using the three-arg form
still see legacy entries registered elsewhere in the request.

A new test instantiates the resolver without the optional argument and
asserts that a filter-registered legacy entry still grants availability,
guarding the compatibility shim against future regressions.
…branch

Eight new methods in Resolve_Feature_CollectionTest and one new method in
License_RepositoryTest (test_drops_entries_with_empty_key_or_slug) were
introduced without the project's PHPDoc convention. Each now leads with
a "Tests..." sentence and declares @return void, matching the form used
elsewhere in the suite.

Pre-existing tests on main are left untouched.
- Add `rawurlencoded` to the inline cspell ignore directives in both
  Herald_Url_BuilderTest and Herald_Legacy_Url_BuilderTest. The earlier
  directive only ignored `rawurlencodes` (method-name form); the new
  test docblocks use the past-tense form.
- Realign the Herald URL builder table in docs/subsystems/portal.md so
  pipes line up under the header, satisfying markdownlint MD060.
Adds a per-entry `use_for_updates` flag (default false) to the
`lw-harbor/legacy_licenses` filter payload. Only opt-in entries grant
catalog feature availability, contribute a Herald `/legacy/download`
URL, and trigger Plugin/Theme update handlers. Non-opt-in entries
continue to appear in the licensing UI and admin notices.

Protects Harbor from advertising "update available" badges for legacy
keys whose backend is not Stellar Licensing v3 compatible (e.g. SolidWP
API keys, some Give legacy keys), which would otherwise fail validation
when the user clicks Update. Addresses PR #159 review feedback.
@d4mation d4mation requested review from dpanta94, johnhooks and shvlv May 18, 2026 18:51
dpanta94
dpanta94 previously approved these changes May 18, 2026
johnhooks
johnhooks previously approved these changes May 18, 2026
@shvlv
Copy link
Copy Markdown
Contributor

shvlv commented May 19, 2026

I'm trying to test, but got:

[19-May-2026 11:37:34 UTC] PHP Fatal error:  Allowed memory size of 1073741824 bytes exhausted (tried to allocate 20480 bytes) in /app/wordpress/wp-content/plugins/solid-backups/lib/updater/packages.php on line 169
[19-May-2026 11:37:34 UTC] PHP Fatal error:  Allowed memory size of 1073741824 bytes exhausted (tried to allocate 65536 bytes) in /app/wordpress/wp-includes/Requests/src/Autoload.php on line 150

It looks like an endless loop happens, will check.

@shvlv
Copy link
Copy Markdown
Contributor

shvlv commented May 19, 2026

AI slop Why it didn't loop before this branch

The regression was introduced by commit 5dd88c0 feat: pass legacy license keys to Herald for updates and downloads (Eric Defore, 2026-05-15).

The structural change

Before 5dd88c0, Resolve_Feature_Collection::hydrate_feature() only consulted:

  • Catalog_Repository (product/feature catalog)
  • License_Manager (Unified capabilities + tier)
  • Data (site data)

After 5dd88c0, hydration got a new collaborator — Legacy_License_Repository — and now does this for every feature being resolved (Resolve_Feature_Collection.php:302-306):

$legacy_license = $this->legacy_repository->find( $catalog_feature->get_slug() );
$has_legacy_grant = $legacy_license !== null
&& $legacy_license->is_active
&& $legacy_license->key !== '';

Why that closes the loop

Legacy_License_Repository::find() → ::all() → apply_filters( 'lw-harbor/legacy_licenses', … ) (License_Repository.php:32).

That filter has had a closure registered by Solid Backups' updater since forever (init.php:189-195) which calls Ithemes_Updater_Harbor::get_legacy_licenses() → Ithemes_Updater_Packages::get_full_details() → Ithemes_Updater_API::get_request_package_details() → Ithemes_Updater_Harbor::is_product_managed()
(api.php:404) → lw_harbor_is_feature_available().

Before 5dd88c0, the chain stopped at lw_harbor_is_feature_available because feature resolution never re-entered the legacy repository. After 5dd88c0, it does — and the recursion closes.

Summary

┌───────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐

Before 5dd88c0, the chain stopped at lw_harbor_is_feature_available because feature resolution never re-entered the legacy repository. After 5dd88c0, it does — and the recursion closes.

Summary

┌───────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Pre-5dd88c0b │ Post-5dd88c0b │
├───────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ is_product_managed → feature resolution → catalog + Unified only → return │ is_product_managed → feature resolution → legacy repo find() → apply_filters('lw-harbor/legacy_licenses') → updater closure → get_full_details() → is_product_managed → … ∞ │
└───────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

The two follow-ups on the branch (73f2c19, cf739c3) tweaked the legacy-grant logic but didn't change the fundamental coupling — the moment hydrate_feature() started calling legacy_repository->find(), anything wired into the lw-harbor/legacy_licenses filter became reachable from inside Harbor's own
feature-resolution path, which is exactly what the Solid Backups updater is hooked to.

Where to fix

You have a few choices, in roughly increasing invasiveness:

  1. Cheapest: In Harbor, cache the legacy_repository->all() result for the duration of a single apply_filters run, or guard re-entry in License_Repository::all() so the filter is only applied once per request (subsequent calls return the cached pre-filter array). That makes it safe for filter callbacks to
    themselves consult licensing/features.
  2. More targeted: In the Solid Backups updater closure (init.php:189-195), add a reentry guard around get_legacy_licenses() — which I described in the previous reply.
  3. Architectural: In hydrate_feature(), don't call legacy_repository->find() on the hot path. Resolve legacy grants once per request (e.g. in Resolve_Feature_Collection::__invoke) before iterating features, then pass the precomputed map to hydrate_feature(). This both avoids the recursion and avoids N
    apply_filters calls per page load.

Option 1 or 3 belongs in Harbor; option 2 belongs in Solid Backups. If you control both, I'd lean toward 3 — calling apply_filters once per feature is wasteful even when it doesn't recurse.

So the issue is inside the lw-harbor/legacy_licenses filter, we need to detect what licenses are legacy. To understand that we use lw_harbor_is_feature_available so we can distinguish between legacy-only-managed products and harbor-managed products. But now lw_harbor_is_feature_available runs lw-harbor/legacy_licenses again.

@d4mation
Copy link
Copy Markdown
Collaborator Author

@johnhooks @shvlv Weird... I haven't seen this issue in any of my testing :| I'll investigate more closely in a bit.

@shvlv
Copy link
Copy Markdown
Contributor

shvlv commented May 19, 2026

I don't know what the contract is. If we can report from the plugin side all legacy licenses we have, regardless of whether they are managed by Harbor as well, we can do it for sure. Because I see other plugins didn't do that - https://github.com/stellarwp/kadence-shop-kit/blob/515ba5033a7266e4f3cc1f29642d2387cd2b5a77/inc/Common/Harbor/Legacy_License_Integration.php#L89-L115.

So we can simplify our logic and remove that check too https://github.com/ithemes/updater/blob/master/harbor.php#L133-L135 🤔

d4mation added 2 commits May 19, 2026 09:41
Solid Backups' lw-harbor/legacy_licenses callback consults
lw_harbor_is_feature_available, which since 5dd88c0 routes through
feature resolution back into License_Repository::all() before the outer
apply_filters dispatch has completed. WordPress does not detect a hook
re-dispatching itself, so WP_Hook re-fires every registered callback at
a new nesting level until the PHP call stack is exhausted.

Add a per-instance applying_filter flag wrapped around the apply_filters
call so re-entrant calls during dispatch return an empty array and break
the cycle. Re-entrant callers are answered from Unified data alone,
which is the only consistent source while dispatch is in progress.
Previously hydrate_feature() called legacy_repository->find() once per
catalog feature, which dispatched the lw-harbor/legacy_licenses filter on
every iteration. Beyond the wasted work that hurt scaling with catalog
size, it widened the surface for re-entrant filter callbacks: any
callback consulting Harbor itself (e.g. Solid Backups via
lw_harbor_is_feature_available) compounded that cost per feature.

Hoist the lookup to __invoke(), build a slug => Legacy_License map once,
and pass each feature's match (or null) into hydrate_feature(). Filter
dispatches drop from O(features) to O(1) per resolution, and any
re-entry guard in License_Repository now only has to cover a single
dispatch window.

Existing reflection-based hydrate_feature() tests in Feature_RepositoryTest
pass the new fifth argument as null since they exercise paths that don't
touch the legacy grant.
d4mation added 2 commits May 19, 2026 09:45
The previous docblock framed the test as a regression guard against a
prior behavior ("Before the legacy lookup was hoisted out of
hydrate_feature()..."), but the per-feature lookup it described was
only ever present in-progress on this feature branch and never shipped.
Rephrase as forward-looking design intent: the resolver must dispatch
the filter once per __invoke() and any naive per-feature implementation
is what this test prevents.
@d4mation
Copy link
Copy Markdown
Collaborator Author

d4mation commented May 19, 2026

@johnhooks @shvlv I'm trying to ensure I can reproduce the issue with iThemes Updater on my end, but I think this should address the issue within Harbor in a way that won't require an update to iThemes Updater.

https://github.com/stellarwp/harbor/pull/159/files/1d6299f76fd6ddd08d7e26b0e718a418e0348b2d..ae5f2a2daac5a3f6a93acd2b530dedc32b3cb428

Edit: I was able to reproduce the issue. Turns out my xdebug config was masking it 😅 Once I disabled xdebug I was able to reproduce it immediately. I can confirm that with the latest commits, the issue goes away :)

@d4mation d4mation requested review from dpanta94 and johnhooks May 19, 2026 14:37
Comment thread src/Harbor/Legacy/License_Repository.php
shvlv
shvlv previously approved these changes May 19, 2026
Copy link
Copy Markdown
Contributor

@shvlv shvlv left a comment

Choose a reason for hiding this comment

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

It works well with iThemes updater!

The single question I have is about UI. Should we update this notice?

Image

Or legacy users should not see that at all because of the new Welcome screen, and they can update from the WordPress update screen (while the new unified key might not have the Kadence tier in theory).

Image

johnhooks
johnhooks previously approved these changes May 19, 2026
@d4mation
Copy link
Copy Markdown
Collaborator Author

The single question I have is about UI. Should we update this notice?

@shvlv Yeah, I found that notice strange 🤔 Not being allowed to update from this screen is one thing, but the text is misleading.

Maybe

Upgrade your license to manage updates from here.

?

They should still be getting updates and support with a Legacy License.

@johnhooks
Copy link
Copy Markdown
Collaborator

@d4mation that works for me

Upgrade your license to manage updates from here.

@shvlv
Copy link
Copy Markdown
Contributor

shvlv commented May 19, 2026

from here

Maybe we should call this place somehow 😂 Software License Manager?

They _do_ get updates and support with a Legacy License
@d4mation d4mation dismissed stale reviews from johnhooks and shvlv via 561a0fd May 19, 2026 15:30
@d4mation d4mation requested review from johnhooks and shvlv May 19, 2026 15:30
@d4mation d4mation merged commit f44a6bb into main May 19, 2026
@d4mation d4mation deleted the feature/utilize-registered-legacy-licenses-for-updates branch May 19, 2026 15:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants