Clarify v5 migration recovery states

This commit is contained in:
rcourtman
2026-07-07 21:46:07 +01:00
parent 89d9f51258
commit 8355335e08
22 changed files with 679 additions and 38 deletions
+7
View File
@@ -157,6 +157,13 @@ canonical, but the retired rc.1 through rc.5 `/infrastructure`, `/workloads`,
- If you are upgrading directly from v5, start from the familiar platform pages
rather than looking for the temporary unified pages from early v6 RCs.
### Configuration Compatibility
Pulse v6 honors the legacy `PORT` environment variable as a deprecated fallback
only when `FRONTEND_PORT` is unset, so existing installs keep their listener
port after upgrade. Move deployments to `FRONTEND_PORT`; when both variables
are set, `FRONTEND_PORT` wins.
### API Changes
Unified Resources is now the canonical model and endpoint family:
@@ -97,6 +97,13 @@ that binary, not separate customer-facing agent products.
## Shared Boundaries
Commercial v5-to-v6 migration retry state may pass through `internal/api/`
handlers that agent-lifecycle also references, but the ownership remains
API/cloud-paid. Agent lifecycle surfaces may observe paid-migration posture for
upgrade continuity, but they must not reinterpret `commercial_migration`
reasons, reset `first_failed_at`, change the license-server retry cadence, or
turn blocked license egress into an agent update or enrollment state.
`/api/connections` command-policy comparison is lifecycle-adjacent fleet
truth. The desired side is the effective runtime config served to the agent
after token scope and binding checks, not the unsanitized profile desire. If a
@@ -139,6 +139,16 @@ product API routes free of maintainer commercial analytics.
## Shared Boundaries
Commercial migration payloads are a shared API/cloud-paid contract. The
license-server client must preserve canonical v6 nested error-envelope codes
such as `RENEWED_KEY_AVAILABLE`, and commercial posture payloads may carry
`commercial_migration.first_failed_at` so the backend can distinguish a short
transport outage from sustained blocked egress without changing retry cadence.
Browser API types and presentation helpers must treat `exchange_stale_key` plus
`retrieve_current_key` and `exchange_connectivity_required` plus
`allow_license_egress` as explicit migration states rather than inferring them
from HTTP status alone.
`GET /api/connections` consumes the agent desired-config contract for fleet
governance. When it derives `fleet.commandPolicy`, `fleet.configDrift`, and
rollout state, it must compare the agent-applied report with the effective
@@ -257,6 +257,17 @@ avoids a cloud-control-plane report data path across clients.
pending must self-retry in the background with backoff for the life of
the process so a transient license-server or DNS failure at first boot
never strands a paying upgrader on Community until a manual restart.
Signature-valid v5 JWTs that are expired beyond the v5 grace window but
correspond to a newer retrievable server-side key or live entitlement must
classify as terminal stale-key recovery (`exchange_stale_key` with
`retrieve_current_key`) rather than generic invalid-key failure; malformed,
signature-invalid, and truly lapsed keys must keep the generic terminal
rejection path. Transport-level legacy-exchange failures must preserve the
first continuous failure timestamp on `commercial_migration.first_failed_at`
and, after 24 hours, keep retrying while surfacing
`exchange_connectivity_required` with the outbound
`license.pulserelay.pro` connectivity policy instead of rendering ordinary
pending copy forever.
6. `internal/api/licensing_legacy_retry.go` shared with `api-contracts`: the background legacy-exchange retry loop carries both API payload contract and cloud-paid entitlement boundary ownership.
7. `internal/api/payments_webhook_handlers.go` shared with `api-contracts`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership.
8. `internal/api/public_signup_handlers.go` shared with `api-contracts`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership.
@@ -63,6 +63,13 @@ state.
## Extension Points
Commercial v5-to-v6 migration posture may be carried by `internal/api/`
handlers that storage/recovery references for setup or support-adjacent flows,
but it remains API/cloud-paid state. Storage and recovery surfaces may point
operators at the v6 upgrade guide when license egress is blocked, but they must
not reinterpret `commercial_migration` as backup coverage, restore readiness,
or storage-health evidence, and must not mutate `first_failed_at`.
Mobile onboarding reads exposed through `internal/api/onboarding_handlers.go`
are storage/recovery-adjacent only as hosted recovery/support handoff
surfaces. Recovery code may consume the API-owned
@@ -70,6 +70,31 @@ describe('LicenseAPI', () => {
});
});
it('preserves commercial migration timing fields from entitlements', async () => {
vi.mocked(apiFetchJSON).mockResolvedValueOnce({
tier: 'free',
subscription_state: 'expired',
capabilities: [],
limits: [],
upgrade_reasons: [],
commercial_migration: {
state: 'pending',
reason: 'exchange_connectivity_required',
recommended_action: 'allow_license_egress',
first_failed_at: 1_700_000_000,
},
});
const result = await LicenseAPI.getCommercialEntitlements();
expect(result.commercial_migration).toMatchObject({
state: 'pending',
reason: 'exchange_connectivity_required',
recommended_action: 'allow_license_egress',
first_failed_at: 1_700_000_000,
});
});
it('reads commercial posture from the public-safe commercial endpoint', async () => {
vi.mocked(apiFetchJSON).mockResolvedValueOnce({
tier: 'pro',
+1
View File
@@ -52,6 +52,7 @@ export interface CommercialMigrationStatus {
state?: string;
reason?: string;
recommended_action?: string;
first_failed_at?: number;
}
// Mirrors internal/api/subscription_entitlements.go:RuntimeCapabilitiesPayload
@@ -1,6 +1,7 @@
import { Component, For, Show } from 'solid-js';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import { Button, ButtonLink } from '@/components/shared/Button';
import { InlineNotice, type InlineNoticeTone } from '@/components/shared/InlineNotice';
import { UpgradeButtonLink } from '@/components/shared/UpgradeLink';
import { licenseEntitlementsLoadError } from '@/stores/licenseEntitlements';
import {
@@ -104,6 +105,9 @@ const formatDate = (value?: string | null) => {
return date.toLocaleDateString();
};
const commercialMigrationNoticeTone = (notice: Notice): InlineNoticeTone =>
notice.tone.includes('red-') ? 'danger' : 'warning';
const statusStateClass = (state: 'active' | 'partial' | 'missing') => {
switch (state) {
case 'active':
@@ -385,10 +389,10 @@ export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (pro
</Show>
<Show when={props.commercialMigrationNotice}>
{(notice) => (
<div class={`mb-4 rounded-md border p-3 text-sm ${notice().tone}`}>
<InlineNotice tone={commercialMigrationNoticeTone(notice())} class="mb-4">
<p class="font-medium">{notice().title}</p>
<p class="mt-1 text-xs opacity-90">{notice().body}</p>
</div>
<p class="mt-1 text-xs">{notice().body}</p>
</InlineNotice>
)}
</Show>
<Show when={props.grandfatheredPriceNotice}>
@@ -1527,6 +1527,67 @@ describe('ProLicensePanel', () => {
).not.toBeInTheDocument();
});
it('shows retrieve-license guidance when a v5 key has been superseded', async () => {
mockEntitlements = {
capabilities: [],
limits: [],
subscription_state: 'expired',
upgrade_reasons: [],
tier: 'free',
trial_eligible: false,
commercial_migration: {
source: 'v5_license',
state: 'failed',
reason: 'exchange_stale_key',
recommended_action: 'retrieve_current_key',
},
};
renderPanel();
const title = screen.getByText('v5 license migration needs attention');
expect(title).toBeInTheDocument();
expect(title.closest('.border-red-300')).not.toBeNull();
expect(screen.getByText(/superseded by a renewal/i)).toBeInTheDocument();
expect(screen.getByText(/pulserelay\.pro\/retrieve-license/i)).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /start 14-day pro trial/i }),
).not.toBeInTheDocument();
});
it('shows blocked-egress guidance after sustained exchange transport failure', async () => {
mockEntitlements = {
capabilities: [],
limits: [],
subscription_state: 'expired',
upgrade_reasons: [],
tier: 'free',
trial_eligible: false,
commercial_migration: {
source: 'v5_license',
state: 'pending',
reason: 'exchange_connectivity_required',
recommended_action: 'allow_license_egress',
first_failed_at: 1_700_000_000,
},
};
renderPanel();
const title = screen.getByText('v5 license migration pending');
expect(title).toBeInTheDocument();
expect(title.closest('.border-amber-300')).not.toBeNull();
expect(
screen.getByText(/paid v6 features require periodic outbound HTTPS/i),
).toBeInTheDocument();
expect(
screen.getByText(/allow outbound HTTPS to license\.pulserelay\.pro/i),
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /start 14-day pro trial/i }),
).not.toBeInTheDocument();
});
it('keeps Pro license split into shell, runtime, and plan owners', () => {
expect(proLicensePanelSource).toContain('./useProLicensePanelState');
expect(proLicensePanelSource).toContain('sessionPresentationPolicyResolved');
@@ -205,6 +205,53 @@ describe('licensePresentation', () => {
expect(notice?.body).not.toContain('still settling');
});
it('points superseded v5 keys at current-key retrieval instead of retrying', () => {
const notice = getCommercialMigrationNotice({
state: 'failed',
reason: 'exchange_stale_key',
recommended_action: 'retrieve_current_key',
} as never);
expect(notice).toMatchObject({
title: 'v5 license migration needs attention',
tone: expect.stringContaining('red'),
});
expect(notice?.body).toContain('superseded by a renewal');
expect(notice?.body).toContain('pulserelay.pro/retrieve-license');
expect(notice?.body).not.toContain('Retry with the original v5 Pro/Lifetime key');
});
it('keeps generic rejected-key copy distinct while offering safe license retrieval', () => {
const notice = getCommercialMigrationNotice({
state: 'failed',
reason: 'exchange_invalid',
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('rejected during v6 migration');
expect(notice?.body).toContain('pulserelay.pro/retrieve-license');
expect(notice?.body).toContain('Retry with the original v5 Pro/Lifetime key');
});
it('renders sustained transport failure as blocked-egress policy, not ordinary pending', () => {
const notice = getCommercialMigrationNotice({
state: 'pending',
reason: 'exchange_connectivity_required',
recommended_action: 'allow_license_egress',
} as never);
expect(notice).toMatchObject({
title: 'v5 license migration pending',
tone: expect.stringContaining('amber'),
});
expect(notice?.body).toContain('over a day');
expect(notice?.body).toContain('Paid v6 features require periodic outbound HTTPS');
expect(notice?.body).toContain('Core monitoring keeps running');
expect(notice?.body).toContain('license.pulserelay.pro');
expect(notice?.body).toContain('docs/UPGRADE_v6.md');
});
it('renders an unreadable persisted v5 license as terminal with re-enter-key guidance', () => {
const notice = getCommercialMigrationNotice({
state: 'failed',
@@ -1037,6 +1037,10 @@ export const getCommercialMigrationActionText = (action?: string): string => {
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.';
case 'retrieve_current_key':
return 'Retrieve your current key at pulserelay.pro/retrieve-license and paste it here.';
case 'allow_license_egress':
return 'Allow outbound HTTPS to license.pulserelay.pro, then Pulse will keep retrying automatically.';
default:
return 'Review the plan state from this instance before trying again.';
}
@@ -1060,6 +1064,10 @@ export const getCommercialMigrationNotice = (
body =
'Pulse detected a paid v5 license, but another v6 license handoff is still settling.';
break;
case 'exchange_connectivity_required':
body =
'Pulse has not been able to reach license.pulserelay.pro for over a day. Paid v6 features require periodic outbound HTTPS to that host. Core monitoring keeps running; paid features stay on Community until connectivity is allowed. See docs/UPGRADE_v6.md for the connectivity policy.';
break;
case 'exchange_unavailable':
default:
break;
@@ -1079,7 +1087,12 @@ export const getCommercialMigrationNotice = (
'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.';
body =
'Pulse detected a paid v5 license, but that key was rejected during v6 migration. Retrieve your current key at pulserelay.pro/retrieve-license if this purchase is still active.';
break;
case 'exchange_stale_key':
body =
'Pulse detected a paid v5 license, but this key has been superseded by a renewal.';
break;
case 'exchange_malformed':
body = 'Pulse detected a v5-looking key, but it is malformed and cannot be migrated.';
+26
View File
@@ -133,6 +133,32 @@ func TestContract_AlertDeliveryDiagnosisPayloadShape(t *testing.T) {
}
}
func TestContract_CommercialMigrationPayloadCarriesFailureTiming(t *testing.T) {
status := pkglicensing.CommercialMigrationStatus{
Source: pkglicensing.CommercialMigrationSourceV5License,
State: pkglicensing.CommercialMigrationStatePending,
Reason: pkglicensing.CommercialMigrationReasonExchangeConnectivity,
RecommendedAction: pkglicensing.CommercialMigrationActionAllowLicenseEgress,
FirstFailedAt: 1_700_000_000,
}
payload, err := json.Marshal(status)
if err != nil {
t.Fatalf("marshal commercial migration status: %v", err)
}
body := string(payload)
for _, field := range []string{
`"state":"pending"`,
`"reason":"exchange_connectivity_required"`,
`"recommended_action":"allow_license_egress"`,
`"first_failed_at":1700000000`,
} {
if !strings.Contains(body, field) {
t.Fatalf("commercial migration payload missing %s in %s", field, body)
}
}
}
func TestContract_ReportSchedulePayloadShape(t *testing.T) {
nextRun := time.Date(2026, 7, 8, 6, 0, 0, 0, time.UTC)
schedule := config.ReportSchedule{
+7
View File
@@ -100,6 +100,13 @@ func cloneCommercialMigrationStatusFromLicensing(state *commercialMigrationStatu
return pkglicensing.CloneCommercialMigrationStatus(state)
}
func applyCommercialMigrationFailureTimingFromLicensing(
status, previous *commercialMigrationStatusModel,
now time.Time,
) *commercialMigrationStatusModel {
return pkglicensing.ApplyCommercialMigrationFailureTiming(status, previous, now)
}
func classifyLegacyExchangeErrorFromLicensing(err error) *commercialMigrationStatusModel {
return pkglicensing.ClassifyLegacyExchangeError(err)
}
+11 -1
View File
@@ -70,6 +70,8 @@ type LicenseHandlers struct {
legacyExchangeRetries sync.Map // map[string]struct{}
// legacyExchangeRetrySchedule overrides the retry backoff in tests.
legacyExchangeRetrySchedule []time.Duration
// commercialMigrationNow overrides migration timing in tests.
commercialMigrationNow func() time.Time
}
// NewLicenseHandlers creates a new license handlers instance.
@@ -799,7 +801,15 @@ func (h *LicenseHandlers) setCommercialMigrationState(orgID string, status *comm
}
}
existing.CommercialMigration = cloneCommercialMigrationStatusFromLicensing(status)
now := time.Now
if h.commercialMigrationNow != nil {
now = h.commercialMigrationNow
}
existing.CommercialMigration = applyCommercialMigrationFailureTimingFromLicensing(
status,
existing.CommercialMigration,
now(),
)
return billingStore.SaveBillingState(orgID, existing)
}
@@ -151,6 +151,51 @@ func TestGetTenantComponents_AutoExchangesPersistedLegacyJWT(t *testing.T) {
handlers.StopAllBackgroundLoops()
}
func TestSetCommercialMigrationState_AutoMigrateEscalatesSustainedTransportFailure(t *testing.T) {
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)
handlers := NewLicenseHandlers(mtp, false)
now := time.Unix(1_700_000_000, 0)
handlers.commercialMigrationNow = func() time.Time { return now }
if err := handlers.setCommercialMigrationState("default", &pkglicensing.CommercialMigrationStatus{
Source: pkglicensing.CommercialMigrationSourceV5License,
State: pkglicensing.CommercialMigrationStatePending,
Reason: pkglicensing.CommercialMigrationReasonExchangeUnavailable,
RecommendedAction: pkglicensing.CommercialMigrationActionRetryActivation,
}); err != nil {
t.Fatalf("set initial migration state: %v", err)
}
now = now.Add(time.Duration(pkglicensing.CommercialMigrationSustainedExchangeUnavailableSeconds+1) * time.Second)
if err := handlers.setCommercialMigrationState("default", &pkglicensing.CommercialMigrationStatus{
Source: pkglicensing.CommercialMigrationSourceV5License,
State: pkglicensing.CommercialMigrationStatePending,
Reason: pkglicensing.CommercialMigrationReasonExchangeUnavailable,
RecommendedAction: pkglicensing.CommercialMigrationActionRetryActivation,
}); err != nil {
t.Fatalf("set sustained migration state: %v", err)
}
store := config.NewFileBillingStore(baseDir)
state, err := store.GetBillingState("default")
if err != nil {
t.Fatalf("GetBillingState sustained: %v", err)
}
if state == nil || state.CommercialMigration == nil {
t.Fatal("expected sustained commercial migration state")
}
if state.CommercialMigration.FirstFailedAt != 1_700_000_000 {
t.Fatalf("sustained first_failed_at=%d want 1700000000", state.CommercialMigration.FirstFailedAt)
}
if state.CommercialMigration.Reason != pkglicensing.CommercialMigrationReasonExchangeConnectivity {
t.Fatalf("sustained reason=%q want %q", state.CommercialMigration.Reason, pkglicensing.CommercialMigrationReasonExchangeConnectivity)
}
if state.CommercialMigration.RecommendedAction != pkglicensing.CommercialMigrationActionAllowLicenseEgress {
t.Fatalf("sustained action=%q want %q", state.CommercialMigration.RecommendedAction, pkglicensing.CommercialMigrationActionAllowLicenseEgress)
}
}
func TestGetTenantComponents_SkipsExchange_WhenActivationStateExists(t *testing.T) {
t.Setenv("PULSE_LICENSE_DEV_MODE", "true")
@@ -286,3 +286,62 @@ func TestGetTenantComponents_SurfacesUnreadablePersistedLicense(t *testing.T) {
t.Fatalf("commercial_migration.reason=%q, want %q", state.CommercialMigration.Reason, pkglicensing.CommercialMigrationReasonPersistedUnreadable)
}
}
func TestSetCommercialMigrationState_EscalatesSustainedTransportFailure(t *testing.T) {
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)
handlers := NewLicenseHandlers(mtp, false)
now := time.Unix(1_700_000_000, 0)
handlers.commercialMigrationNow = func() time.Time { return now }
if err := handlers.setCommercialMigrationState("default", &pkglicensing.CommercialMigrationStatus{
Source: pkglicensing.CommercialMigrationSourceV5License,
State: pkglicensing.CommercialMigrationStatePending,
Reason: pkglicensing.CommercialMigrationReasonExchangeUnavailable,
RecommendedAction: pkglicensing.CommercialMigrationActionRetryActivation,
}); err != nil {
t.Fatalf("set initial migration state: %v", err)
}
store := config.NewFileBillingStore(baseDir)
state, err := store.GetBillingState("default")
if err != nil {
t.Fatalf("GetBillingState initial: %v", err)
}
if state == nil || state.CommercialMigration == nil {
t.Fatal("expected initial commercial migration state")
}
if state.CommercialMigration.FirstFailedAt != now.Unix() {
t.Fatalf("first_failed_at=%d want %d", state.CommercialMigration.FirstFailedAt, now.Unix())
}
if state.CommercialMigration.Reason != pkglicensing.CommercialMigrationReasonExchangeUnavailable {
t.Fatalf("initial reason=%q want %q", state.CommercialMigration.Reason, pkglicensing.CommercialMigrationReasonExchangeUnavailable)
}
now = now.Add(time.Duration(pkglicensing.CommercialMigrationSustainedExchangeUnavailableSeconds+1) * time.Second)
if err := handlers.setCommercialMigrationState("default", &pkglicensing.CommercialMigrationStatus{
Source: pkglicensing.CommercialMigrationSourceV5License,
State: pkglicensing.CommercialMigrationStatePending,
Reason: pkglicensing.CommercialMigrationReasonExchangeUnavailable,
RecommendedAction: pkglicensing.CommercialMigrationActionRetryActivation,
}); err != nil {
t.Fatalf("set sustained migration state: %v", err)
}
state, err = store.GetBillingState("default")
if err != nil {
t.Fatalf("GetBillingState sustained: %v", err)
}
if state == nil || state.CommercialMigration == nil {
t.Fatal("expected sustained commercial migration state")
}
if state.CommercialMigration.FirstFailedAt != 1_700_000_000 {
t.Fatalf("sustained first_failed_at=%d want 1700000000", state.CommercialMigration.FirstFailedAt)
}
if state.CommercialMigration.Reason != pkglicensing.CommercialMigrationReasonExchangeConnectivity {
t.Fatalf("sustained reason=%q want %q", state.CommercialMigration.Reason, pkglicensing.CommercialMigrationReasonExchangeConnectivity)
}
if state.CommercialMigration.RecommendedAction != pkglicensing.CommercialMigrationActionAllowLicenseEgress {
t.Fatalf("sustained action=%q want %q", state.CommercialMigration.RecommendedAction, pkglicensing.CommercialMigrationActionAllowLicenseEgress)
}
}
+62 -3
View File
@@ -3,6 +3,7 @@ package licensing
import (
"errors"
"strings"
"time"
)
type CommercialMigrationSource string
@@ -25,13 +26,19 @@ const (
CommercialMigrationReasonExchangeRevoked CommercialMigrationReason = "exchange_revoked"
CommercialMigrationReasonExchangeNonMigratable CommercialMigrationReason = "exchange_non_migratable"
CommercialMigrationReasonExchangeUnsupportedKey CommercialMigrationReason = "exchange_unsupported"
CommercialMigrationReasonExchangeStaleKey CommercialMigrationReason = "exchange_stale_key"
CommercialMigrationReasonExchangeConnectivity CommercialMigrationReason = "exchange_connectivity_required"
CommercialMigrationActionRetryActivation CommercialMigrationAction = "retry_activation"
CommercialMigrationActionUseV6Activation CommercialMigrationAction = "use_v6_activation_key"
CommercialMigrationActionEnterSupportedV5 CommercialMigrationAction = "enter_supported_v5_key"
CommercialMigrationActionFreeInstallationSlot CommercialMigrationAction = "free_installation_slot"
CommercialMigrationActionRetrieveCurrentKey CommercialMigrationAction = "retrieve_current_key"
CommercialMigrationActionAllowLicenseEgress CommercialMigrationAction = "allow_license_egress"
)
const CommercialMigrationSustainedExchangeUnavailableSeconds int64 = 24 * 60 * 60
// CommercialMigrationStatus is the explicit v6-owned contract for unresolved
// paid-license migrations entering from pre-v6 commercial state.
type CommercialMigrationStatus struct {
@@ -39,6 +46,7 @@ type CommercialMigrationStatus struct {
State CommercialMigrationState `json:"state,omitempty"`
Reason CommercialMigrationReason `json:"reason,omitempty"`
RecommendedAction CommercialMigrationAction `json:"recommended_action,omitempty"`
FirstFailedAt int64 `json:"first_failed_at,omitempty"`
}
func (s *CommercialMigrationStatus) Active() bool {
@@ -55,6 +63,9 @@ func NormalizeCommercialMigrationStatus(status *CommercialMigrationStatus) *Comm
normalized.State = CommercialMigrationState(strings.TrimSpace(string(normalized.State)))
normalized.Reason = CommercialMigrationReason(strings.TrimSpace(string(normalized.Reason)))
normalized.RecommendedAction = CommercialMigrationAction(strings.TrimSpace(string(normalized.RecommendedAction)))
if normalized.FirstFailedAt < 0 {
normalized.FirstFailedAt = 0
}
switch normalized.State {
case CommercialMigrationStatePending, CommercialMigrationStateFailed:
@@ -85,6 +96,47 @@ func CloneCommercialMigrationStatus(status *CommercialMigrationStatus) *Commerci
return &cloned
}
func ApplyCommercialMigrationFailureTiming(status, previous *CommercialMigrationStatus, now time.Time) *CommercialMigrationStatus {
normalized := NormalizeCommercialMigrationStatus(status)
if normalized == nil {
return nil
}
if !commercialMigrationTransportUnavailableReason(normalized.Reason) || normalized.State != CommercialMigrationStatePending {
normalized.FirstFailedAt = 0
return normalized
}
if now.IsZero() {
now = time.Now()
}
firstFailedAt := normalized.FirstFailedAt
if prev := NormalizeCommercialMigrationStatus(previous); prev != nil &&
prev.State == CommercialMigrationStatePending &&
commercialMigrationTransportUnavailableReason(prev.Reason) &&
prev.FirstFailedAt > 0 {
firstFailedAt = prev.FirstFailedAt
}
if firstFailedAt <= 0 {
firstFailedAt = now.Unix()
}
normalized.FirstFailedAt = firstFailedAt
if now.Unix()-firstFailedAt >= CommercialMigrationSustainedExchangeUnavailableSeconds {
normalized.Reason = CommercialMigrationReasonExchangeConnectivity
normalized.RecommendedAction = CommercialMigrationActionAllowLicenseEgress
}
return normalized
}
func commercialMigrationTransportUnavailableReason(reason CommercialMigrationReason) bool {
switch reason {
case CommercialMigrationReasonExchangeUnavailable, CommercialMigrationReasonExchangeConnectivity:
return true
default:
return false
}
}
// ClassifyLegacyExchangeError converts startup/manual exchange errors into a
// retryable or terminal v6 migration contract.
func ClassifyLegacyExchangeError(err error) *CommercialMigrationStatus {
@@ -101,15 +153,22 @@ func ClassifyLegacyExchangeError(err error) *CommercialMigrationStatus {
var serverErr *LicenseServerError
if errors.As(err, &serverErr) {
serverCode := strings.ToUpper(strings.TrimSpace(serverErr.Code))
switch serverErr.StatusCode {
case 400:
status.State = CommercialMigrationStateFailed
status.Reason = CommercialMigrationReasonExchangeMalformed
status.RecommendedAction = CommercialMigrationActionEnterSupportedV5
case 401:
status.State = CommercialMigrationStateFailed
status.Reason = CommercialMigrationReasonExchangeInvalid
status.RecommendedAction = CommercialMigrationActionEnterSupportedV5
if serverCode == "RENEWED_KEY_AVAILABLE" {
status.State = CommercialMigrationStateFailed
status.Reason = CommercialMigrationReasonExchangeStaleKey
status.RecommendedAction = CommercialMigrationActionRetrieveCurrentKey
} else {
status.State = CommercialMigrationStateFailed
status.Reason = CommercialMigrationReasonExchangeInvalid
status.RecommendedAction = CommercialMigrationActionEnterSupportedV5
}
case 403:
status.State = CommercialMigrationStateFailed
status.Reason = CommercialMigrationReasonExchangeRevoked
@@ -3,6 +3,7 @@ package licensing
import (
"fmt"
"testing"
"time"
)
func TestClassifyLegacyExchangeError(t *testing.T) {
@@ -31,6 +32,13 @@ func TestClassifyLegacyExchangeError(t *testing.T) {
wantState: CommercialMigrationStateFailed,
wantReason: CommercialMigrationReasonExchangeInvalid,
},
{
name: "renewed key is terminal with retrieve guidance",
err: fmt.Errorf("activation failed: %w", &LicenseServerError{StatusCode: 401, Code: "RENEWED_KEY_AVAILABLE"}),
wantState: CommercialMigrationStateFailed,
wantReason: CommercialMigrationReasonExchangeStaleKey,
wantAction: CommercialMigrationActionRetrieveCurrentKey,
},
{
name: "unsupported key format is terminal",
err: fmt.Errorf("license key is not a supported v6 activation key or migratable v5 license"),
@@ -70,3 +78,54 @@ func TestClassifyLegacyExchangeError(t *testing.T) {
})
}
}
func TestApplyCommercialMigrationFailureTimingEscalatesSustainedTransportFailure(t *testing.T) {
start := time.Unix(1_700_000_000, 0)
initial := ApplyCommercialMigrationFailureTiming(&CommercialMigrationStatus{
Source: CommercialMigrationSourceV5License,
State: CommercialMigrationStatePending,
Reason: CommercialMigrationReasonExchangeUnavailable,
RecommendedAction: CommercialMigrationActionRetryActivation,
}, nil, start)
if initial == nil {
t.Fatal("expected initial migration status")
}
if initial.FirstFailedAt != start.Unix() {
t.Fatalf("first_failed_at=%d want %d", initial.FirstFailedAt, start.Unix())
}
if initial.Reason != CommercialMigrationReasonExchangeUnavailable {
t.Fatalf("initial reason=%q want %q", initial.Reason, CommercialMigrationReasonExchangeUnavailable)
}
escalated := ApplyCommercialMigrationFailureTiming(&CommercialMigrationStatus{
Source: CommercialMigrationSourceV5License,
State: CommercialMigrationStatePending,
Reason: CommercialMigrationReasonExchangeUnavailable,
RecommendedAction: CommercialMigrationActionRetryActivation,
}, initial, start.Add(time.Duration(CommercialMigrationSustainedExchangeUnavailableSeconds+1)*time.Second))
if escalated == nil {
t.Fatal("expected escalated migration status")
}
if escalated.FirstFailedAt != start.Unix() {
t.Fatalf("escalated first_failed_at=%d want %d", escalated.FirstFailedAt, start.Unix())
}
if escalated.Reason != CommercialMigrationReasonExchangeConnectivity {
t.Fatalf("escalated reason=%q want %q", escalated.Reason, CommercialMigrationReasonExchangeConnectivity)
}
if escalated.RecommendedAction != CommercialMigrationActionAllowLicenseEgress {
t.Fatalf("escalated action=%q want %q", escalated.RecommendedAction, CommercialMigrationActionAllowLicenseEgress)
}
terminal := ApplyCommercialMigrationFailureTiming(&CommercialMigrationStatus{
Source: CommercialMigrationSourceV5License,
State: CommercialMigrationStateFailed,
Reason: CommercialMigrationReasonExchangeInvalid,
RecommendedAction: CommercialMigrationActionEnterSupportedV5,
}, escalated, start.Add(48*time.Hour))
if terminal == nil {
t.Fatal("expected terminal migration status")
}
if terminal.FirstFailedAt != 0 {
t.Fatalf("terminal first_failed_at=%d want 0", terminal.FirstFailedAt)
}
}
+27 -9
View File
@@ -335,22 +335,40 @@ func (c *LicenseServerClient) parseError(resp *http.Response) error {
// Try to parse structured error response from the license server.
if len(body) > 0 {
var parsed struct {
Code string `json:"code"`
LegacyCode string `json:"error"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
Code string `json:"code"`
Error json.RawMessage `json:"error"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
if json.Unmarshal(body, &parsed) == nil {
code := strings.TrimSpace(parsed.Code)
if code == "" {
code = strings.TrimSpace(parsed.LegacyCode)
message := strings.TrimSpace(parsed.Message)
retryable := parsed.Retryable
if code == "" && len(parsed.Error) > 0 {
var legacyCode string
if json.Unmarshal(parsed.Error, &legacyCode) == nil {
code = strings.TrimSpace(legacyCode)
} else {
var nested struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
if json.Unmarshal(parsed.Error, &nested) == nil {
code = strings.TrimSpace(nested.Code)
if strings.TrimSpace(nested.Message) != "" {
message = strings.TrimSpace(nested.Message)
}
retryable = nested.Retryable
}
}
}
if code != "" {
apiErr.Code = code
if strings.TrimSpace(parsed.Message) != "" {
apiErr.Message = parsed.Message
if message != "" {
apiErr.Message = message
}
apiErr.Retryable = parsed.Retryable
apiErr.Retryable = retryable
}
}
}
@@ -160,6 +160,45 @@ func TestClientActivate(t *testing.T) {
}
})
t.Run("server returns nested v6 error envelope", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"code": "RENEWED_KEY_AVAILABLE",
"message": "Legacy license key has been superseded by a renewal",
"retryable": false,
},
})
}))
defer server.Close()
client := NewLicenseServerClient(server.URL)
_, err := client.ExchangeLegacyLicense(context.Background(), ExchangeLegacyLicenseRequest{
LegacyLicenseKey: "header.payload.signature",
})
if err == nil {
t.Fatal("expected error")
}
apiErr, ok := err.(*LicenseServerError)
if !ok {
t.Fatalf("expected *LicenseServerError, got %T", err)
}
if apiErr.StatusCode != http.StatusUnauthorized {
t.Errorf("StatusCode = %d, want 401", apiErr.StatusCode)
}
if apiErr.Code != "RENEWED_KEY_AVAILABLE" {
t.Errorf("Code = %q, want RENEWED_KEY_AVAILABLE", apiErr.Code)
}
if apiErr.Message != "Legacy license key has been superseded by a renewal" {
t.Errorf("Message = %q, want nested message", apiErr.Message)
}
if apiErr.Retryable {
t.Error("expected Retryable=false for renewed key")
}
})
t.Run("server returns legacy error field", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
@@ -2914,10 +2914,10 @@ class SubsystemLookupTest(unittest.TestCase):
self.assertEqual(
api_match["matched_contract_references"],
[
{
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 1179,
"line": 1189,
"heading_line": 140,
}
],
@@ -15,6 +15,7 @@ type EntitlementPayload = {
state?: string;
reason?: string;
recommended_action?: string;
first_failed_at?: number;
};
};
@@ -23,6 +24,13 @@ type ExpectedCopy = {
bodyFragments: RegExp[];
};
type MigrationFixture = {
state: string;
reason: string;
recommended_action: string;
first_failed_at?: number;
};
const expectedState = process.env.PULSE_E2E_EXPECT_COMMERCIAL_MIGRATION_STATE || '';
const expectedReason = process.env.PULSE_E2E_EXPECT_COMMERCIAL_MIGRATION_REASON || '';
const expectedAction = process.env.PULSE_E2E_EXPECT_COMMERCIAL_MIGRATION_ACTION || '';
@@ -80,20 +88,28 @@ function expectFieldLocator(page: Page, label: string) {
function expectedCopyFor(state: string, reason: string, action: string): ExpectedCopy {
const actionFragment =
action === 'retry_activation'
? /retry activation from this instance/i
? /retry from this instance/i
: action === 'use_v6_activation_key'
? /use the current v6 activation key for this purchase/i
? /use the current v6 key for this purchase/i
: action === 'enter_supported_v5_key'
? /retry with the original v5 pro\/lifetime key from this instance/i
: /review the activation state from this instance/i;
: action === 'free_installation_slot'
? /support@pulserelay\.pro/i
: action === 'retrieve_current_key'
? /pulserelay\.pro\/retrieve-license/i
: action === 'allow_license_egress'
? /allow outbound HTTPS to license\.pulserelay\.pro/i
: /review the plan state from this instance/i;
if (state === 'pending') {
const reasonFragment =
reason === 'exchange_rate_limited'
? /rate-limited right now/i
: reason === 'exchange_conflict'
? /another v6 activation handoff is still settling/i
: /automatic v6 exchange did not complete yet/i;
? /another v6 license handoff is still settling/i
: reason === 'exchange_connectivity_required'
? /paid v6 features require periodic outbound HTTPS/i
: /automatic v6 exchange did not complete yet/i;
return {
title: /v5 license migration pending/i,
@@ -102,17 +118,21 @@ function expectedCopyFor(state: string, reason: string, action: string): Expecte
}
const reasonFragment =
reason === 'exchange_invalid'
? /key was rejected during v6 migration/i
: reason === 'exchange_malformed'
? /malformed and cannot be migrated/i
: reason === 'exchange_revoked'
? /no longer eligible for automatic migration/i
: reason === 'exchange_non_migratable'
? /not eligible for automatic v6 migration/i
: reason === 'exchange_unsupported'
? /not a supported v5 pro\/lifetime migration input/i
: /could not be migrated automatically/i;
reason === 'exchange_installation_limit'
? /maximum number of v6 installations/i
: reason === 'exchange_invalid'
? /key was rejected during v6 migration/i
: reason === 'exchange_stale_key'
? /superseded by a renewal/i
: reason === 'exchange_malformed'
? /malformed and cannot be migrated/i
: reason === 'exchange_revoked'
? /no longer eligible for automatic migration/i
: reason === 'exchange_non_migratable'
? /not eligible for automatic v6 migration/i
: reason === 'exchange_unsupported'
? /not a supported v5 pro\/lifetime migration input/i
: /could not be migrated automatically/i;
return {
title: /v5 license migration needs attention/i,
@@ -120,6 +140,78 @@ function expectedCopyFor(state: string, reason: string, action: string): Expecte
};
}
function freeEntitlementsWithMigration(migration: MigrationFixture): EntitlementPayload {
return {
valid: false,
tier: 'free',
plan_version: 'community',
subscription_state: 'expired',
trial_eligible: false,
trial_eligibility_reason: '',
limits: [],
commercial_migration: migration,
};
}
async function stubCommercialMigrationFixture(page: Page, migration: MigrationFixture) {
const entitlements = freeEntitlementsWithMigration(migration);
const commercialPosture = {
...entitlements,
upgrade_reasons: [],
has_migration_gap: true,
};
await page.route('**/api/license/runtime-capabilities', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
capabilities: [],
limits: [],
hosted_mode: false,
max_history_days: 7,
runtime: {
build: 'community',
label: 'Pulse Community runtime',
},
}),
});
});
await page.route('**/api/license/entitlements', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(entitlements),
});
});
await page.route('**/api/license/commercial-posture', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(commercialPosture),
});
});
}
async function expectMigrationNotice(page: Page, migration: MigrationFixture) {
await page.goto('/settings/pulse-intelligence/billing/plan', { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings/, { timeout: 10_000 });
await expect(page.getByRole('heading', { name: /plans & billing/i }).first()).toBeVisible();
const expectedCopy = expectedCopyFor(
migration.state,
migration.reason,
migration.recommended_action,
);
await expect(page.getByText(expectedCopy.title)).toBeVisible();
for (const fragment of expectedCopy.bodyFragments) {
await expect(page.getByText(fragment)).toBeVisible();
}
await expect(page.getByRole('button', { name: /start 14-day pro trial/i })).toHaveCount(0);
}
test.describe.serial('v5 commercial migration notice', () => {
test.beforeEach(async ({}, testInfo) => {
test.skip(!expectedState, 'Set PULSE_E2E_EXPECT_COMMERCIAL_MIGRATION_STATE to enable migration UI checks');
@@ -175,11 +267,10 @@ test.describe.serial('v5 commercial migration notice', () => {
test('Pro settings renders the expected migration state', async ({ page }) => {
await ensureAuthenticated(page);
await page.goto('/settings/system-pro', { waitUntil: 'domcontentloaded' });
await page.goto('/settings/pulse-intelligence/billing/plan', { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings/, { timeout: 10_000 });
await expect(page.getByRole('heading', { name: /pro license/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /current license/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /plans & billing/i }).first()).toBeVisible();
if (successMode) {
await expect(page.getByText(/v5 license migration pending/i)).toHaveCount(0);
@@ -211,3 +302,38 @@ test.describe.serial('v5 commercial migration notice', () => {
await expect(page.getByRole('button', { name: /start 14-day pro trial/i })).toHaveCount(0);
});
});
test.describe('v5 commercial migration notice fixtures', () => {
test('renders a stale renewed-key failure with retrieve-license guidance', async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only migration UI coverage');
const migration = {
state: 'failed',
reason: 'exchange_stale_key',
recommended_action: 'retrieve_current_key',
};
await stubCommercialMigrationFixture(page, migration);
await ensureAuthenticated(page);
await expectMigrationNotice(page, migration);
});
test('renders a sustained license-server egress requirement without offering a trial', async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only migration UI coverage');
const migration = {
state: 'pending',
reason: 'exchange_connectivity_required',
recommended_action: 'allow_license_egress',
first_failed_at: Math.floor(Date.now() / 1000) - 90_000,
};
await stubCommercialMigrationFixture(page, migration);
await ensureAuthenticated(page);
await expectMigrationNotice(page, migration);
});
});