diff --git a/docs/UPGRADE_v6.md b/docs/UPGRADE_v6.md index 5f4f0a0ad..b53485c2d 100644 --- a/docs/UPGRADE_v6.md +++ b/docs/UPGRADE_v6.md @@ -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. diff --git a/docs/releases/V6_CHANGELOG.md b/docs/releases/V6_CHANGELOG.md index 0b094b9ac..fbd72fc02 100644 --- a/docs/releases/V6_CHANGELOG.md +++ b/docs/releases/V6_CHANGELOG.md @@ -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 diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts index e528261e1..8f8801013 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts @@ -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); diff --git a/frontend-modern/src/utils/licensePresentation.ts b/frontend-modern/src/utils/licensePresentation.ts index 53c67c56e..241b561e9 100644 --- a/frontend-modern/src/utils/licensePresentation.ts +++ b/frontend-modern/src/utils/licensePresentation.ts @@ -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; }