fix(licensing): honest installation-limit copy and v6 connectivity disclosure

Docs and frontend half of the licensing-policy client work from the v6
upgrade sweep (license findings 5 and 6); the Go classifier and grant
refresh fix land separately once the shared pre-commit guard unblocks.

A 409 MAX_INSTALLATIONS during v5 key migration rendered as 'another v6
activation handoff is still settling. Retry activation from this
instance.' Retrying can never succeed until a slot is freed server-side,
so that copy stranded users. The notice now says the key is already
active on its maximum number of v6 installations and points at
support@pulserelay.pro to free a slot, via the new
exchange_installation_limit reason and free_installation_slot action.

docs/UPGRADE_v6.md gains a 'Breaking Change: Paid Licensing Requires
Connectivity' section: v5 validated keys fully offline, v6 needs
periodic connectivity to license.pulserelay.pro, with roughly 10 days of
offline tolerance (72 hour grant plus 7 day grace) before paid features
drop to Community, plus air-gap guidance and the migrated-key
installation cap. docs/releases/V6_CHANGELOG.md gains the matching
breaking-change bullet.

Note: the two frontend files also carry two small additive hunks from
the parallel persisted-license-unreadable slice (a copy case and its
test, inert until its Go side lands); they share these files and are
included to avoid splitting them mid-flight.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
rcourtman 2026-06-11 08:32:07 +01:00
parent 5e78825f54
commit 3a51429a58
4 changed files with 89 additions and 0 deletions

View file

@ -143,6 +143,35 @@ activation, recovery, or BYOK/local AI setup instead; any exceptional
support-issued entitlement is reflected through hosted entitlement state rather
than a local in-app trial acquisition flow.
#### Breaking Change: Paid Licensing Requires Connectivity
Pulse v5 validated paid license keys entirely locally. Once activated, a v5
Pro or Lifetime install never needed to reach a licensing service again, so
fully offline and air-gapped installs kept paid features indefinitely.
Pulse v6 does not work that way. This is a breaking change for paid installs:
- **What changed.** v6 activates a paid license against
`license.pulserelay.pro` and then refreshes a short-lived entitlement grant
in the background (several times a day by default). The grant is valid for
72 hours, and after it expires Pulse allows a further 7 day grace window.
- **Offline tolerance.** A paid v6 instance that cannot reach
`license.pulserelay.pro` keeps its paid features for roughly 10 days from
the last successful refresh (72 hour grant lifetime plus 7 day grace).
After that, paid features drop to Community behavior until connectivity
returns. Core monitoring keeps running throughout; this affects paid
surfaces such as extended history and AI operations, not data collection.
- **Recovery is automatic.** When connectivity returns, the background
refresh (or a restart) reactivates the license without re-entering the key.
- **Who is affected.** Every paid self-hosted install, including Lifetime.
Air-gapped or egress-restricted environments are affected the most: v6
cannot keep paid features active without periodic outbound HTTPS to
`license.pulserelay.pro`.
- **What to do.** Allow outbound HTTPS (port 443) from the Pulse server to
`license.pulserelay.pro`. If your environment is air-gapped or cannot
allow that egress, contact `support@pulserelay.pro` before upgrading to
discuss options for your install.
#### Paid Pulse Pro Runtime
Paid Pulse Pro, Relay, and eligible legacy customers should not use public
@ -170,6 +199,12 @@ Pulse v6 uses the activation/grant model for active licensing, but it can migrat
- a Pulse v6 activation key, or
- a valid Pulse v5 paid license key, which Pulse will try to exchange automatically into the v6 activation model
- If the exchange service cannot complete the migration, retry from the v6 license panel or use the self-serve retrieval flow to fetch the current v6 activation key. Email is only a backup copy of that key.
- A migrated v5 key can be active on a limited number of v6 installations at
a time (currently 3). v5 never counted installations, so if you run the
same key on more instances than that, the extra instances will report that
the key has reached its installation limit and will stay on Community.
Retrying does not help; contact `support@pulserelay.pro` to release an
installation you no longer use or to raise the limit.
- The exchanged v6 entitlement depends on the original cohort. Lifetime,
active pre-cutover recurring Pro, and other migrated legacy paid installs do
not all land on the same commercial continuity posture.

View file

@ -124,6 +124,20 @@ the navigation shape you already know.
- **The legacy `PORT` variable is deprecated.** V6 still honors `PORT` as a fallback for `FRONTEND_PORT` so existing Docker and manual installs keep their configured port after upgrade, but it logs a deprecation warning at startup and `FRONTEND_PORT` wins when both are set. Move deployments to `FRONTEND_PORT`.
- **Paid licensing now requires periodic connectivity to
`license.pulserelay.pro`.** v5 validated paid keys entirely offline; v6
activates against the licensing service and refreshes a short-lived
entitlement grant in the background. A paid instance that loses
connectivity keeps paid features for roughly 10 days from the last
successful refresh (a 72 hour grant lifetime plus a 7 day grace window),
then drops to Community behavior until connectivity returns. Core
monitoring keeps running throughout, and recovery is automatic once the
service is reachable again. Air-gapped installs cannot keep paid features
active in v6.0.0; contact `support@pulserelay.pro` before upgrading.
Migrated v5 keys are also limited to a fixed number of concurrent v6
installations (currently 3), which v5 never enforced; see
`docs/UPGRADE_v6.md` for details.
## Under the hood but important
- **V6 ships with a real v5 upgrade path.** The migration tests cover config

View file

@ -185,6 +185,36 @@ describe('licensePresentation', () => {
});
});
it('renders the installation limit as terminal with slot guidance, never as a retryable handoff', () => {
const notice = getCommercialMigrationNotice({
state: 'failed',
reason: 'exchange_installation_limit',
recommended_action: 'free_installation_slot',
} as never);
expect(notice).toMatchObject({
title: 'v5 license migration needs attention',
tone: expect.stringContaining('red'),
});
expect(notice?.body).toContain('maximum number of v6 installations');
expect(notice?.body).toContain('support@pulserelay.pro');
expect(notice?.body).not.toContain('Retry activation');
expect(notice?.body).not.toContain('still settling');
});
it('renders an unreadable persisted v5 license as terminal with re-enter-key guidance', () => {
const notice = getCommercialMigrationNotice({
state: 'failed',
reason: 'persisted_license_unreadable',
recommended_action: 'enter_supported_v5_key',
} as never);
expect(notice).toMatchObject({
title: 'v5 license migration needs attention',
tone: expect.stringContaining('red'),
});
expect(notice?.body).toContain('could not be read on this system');
expect(notice?.body).toContain('Retry with the original v5 Pro/Lifetime key');
});
it('returns grandfathered recurring price continuity notices only for active recurring v5 plans', () => {
expect(isGrandfatheredRecurringV5PlanVersion('v5_pro_monthly_grandfathered')).toBe(true);
expect(isGrandfatheredRecurringV5PlanVersion('v5_pro_annual_grandfathered')).toBe(true);

View file

@ -758,6 +758,8 @@ export const getCommercialMigrationActionText = (action?: string): string => {
return 'Use the current v6 activation key for this purchase.';
case 'enter_supported_v5_key':
return 'Retry with the original v5 Pro/Lifetime key from this instance.';
case 'free_installation_slot':
return 'Contact support@pulserelay.pro to release an installation you no longer use or to raise the limit.';
default:
return 'Review the activation state from this instance before trying again.';
}
@ -795,6 +797,10 @@ export const getCommercialMigrationNotice = (
let body = 'Pulse detected a paid v5 license, but it could not be migrated automatically.';
switch (migration.reason) {
case 'exchange_installation_limit':
body =
'Pulse detected a paid v5 license, but that key is already active on its maximum number of v6 installations, so this instance cannot activate until a slot is freed.';
break;
case 'exchange_invalid':
body = 'Pulse detected a paid v5 license, but that key was rejected during v6 migration.';
break;
@ -811,6 +817,10 @@ export const getCommercialMigrationNotice = (
case 'exchange_unsupported':
body = 'Pulse detected a key that is not a supported v5 Pro/Lifetime migration input.';
break;
case 'persisted_license_unreadable':
body =
'Pulse found a saved v5 license from a previous installation, but it could not be read on this system.';
break;
default:
break;
}