diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 1cdd46dde4..b93137f5b7 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -312,11 +312,12 @@ lifecycle-owned install or reporting flows. Browser-facing lifecycle surfaces must also treat `/api/security/status.sessionCapabilities.demoMode` as the canonical public-demo bootstrap signal instead of inferring demo posture from headers, `/api/health`, or hostname heuristics. -That same shared API boundary now owns the hidden-versus-redacted split as -well: lifecycle-adjacent flows may inherit redacted `/api/license/entitlements` -capability reads when demo-visible product behavior needs them, but they must -not rely on licensed identity, plan labels, upgrade reasons, or observed -usage counts surviving that public-demo contract. +That same shared API boundary now owns the hidden-versus-runtime-only split as +well: lifecycle-adjacent flows may inherit non-commercial +`/api/license/runtime-capabilities` reads when demo-visible product behavior +needs them, but `/api/license/entitlements` stays hidden in public demo mode +and those lifecycle flows must not depend on licensed identity, plan labels, +upgrade reasons, or observed usage counts surviving the public-demo contract. Lifecycle-adjacent storage and fleet surfaces now also depend on one governed physical-disk history transport. When agent-backed disk telemetry is rendered through shared drawers or lifecycle-adjacent resource context, those reads diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index d2539ed42a..2118ca8cbd 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -535,3 +535,9 @@ state only through `frontend-modern/src/contexts/appRuntime.ts`; they must not import `@/App` or create reverse dependencies into the root shell chunk, because alerts surfaces must remain lazy-load safe and must not blank the app before auth/bootstrap finishes. +Alert commercial handoffs now also follow the runtime-versus-commercial split. +`frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx` may resolve +upgrade destinations through the shared commercial store, but alert runtime +availability and chat enablement must stay governed by the non-commercial app +runtime and assistant state instead of reusing the same commercial payload as +feature truth. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 30f69e9076..1a9663062c 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -219,11 +219,11 @@ Own canonical runtime payload shapes between backend and frontend. `internal/api/demo_mode_commercial.go`, `internal/api/subscription_entitlements.go`, and `internal/api/contract_test.go` must classify commercial routes centrally - as either hidden (`404`) or redacted, and `/api/license/entitlements` - must preserve runtime capability fields while clearing licensed identity, - upgrade prompts, trial nudges, usage counts, and plan metadata for public - demo browsers instead of relying on scattered handler-local demo checks or - frontend-only suppression. + as either hidden (`404`) or runtime-safe. Public demo browsers may read the + non-commercial `/api/license/runtime-capabilities` contract, while + `/api/license/entitlements` stays hidden and must not be relied on for + runtime feature truth, upgrade prompts, trial nudges, usage counts, or plan + metadata. ## Forbidden Paths @@ -689,7 +689,7 @@ the newer catalog-owned inventory export contract when the backend does not provide it. The licensing API must also stay internally coherent in local dev mode. When backend feature gates are widened by `PULSE_DEV=true` or demo/mock mode, -`/api/license/entitlements` must advertise the same capability set in +`/api/license/runtime-capabilities` must advertise the same capability set in `capabilities`; it must not leave frontend shells on stale free-tier gating while backend `HasFeature()` already treats those features as available. That widening still has to respect runtime feature flags. A capability like @@ -2299,9 +2299,10 @@ provider bridges purely to satisfy Patrol once the Patrol runtime can operate from those canonical tenant providers directly. Hosted licensing handlers now also carry a tenant-scoped fallback contract: when hosted auth handoff preserves a non-default tenant org like `t-...`, -`/api/license/status` and `/api/license/entitlements` must still evaluate the -instance-level hosted billing lease from `default` if that tenant org has no -org-local billing state of its own, rather than failing closed into +`/api/license/status`, `/api/license/entitlements`, and +`/api/license/runtime-capabilities` must still evaluate the instance-level +hosted billing lease from `default` if that tenant org has no org-local +billing state of its own, rather than failing closed into `subscription_required` on first entry. That same hosted entitlement contract also owns lease refresh targeting: when a hosted tenant request arrives on a non-default org with no org-local diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 090acc21c3..39a83e4a8e 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -173,13 +173,16 @@ That same public-demo boundary now also owns route-level commercial classification. `internal/api/demo_middleware.go` and `internal/api/demo_mode_commercial.go` must decide centrally which commercial endpoints are fully hidden (`404`) and which remain available only as a -redacted public contract. `/api/license/entitlements` is the canonical -redacted exception: it may continue to carry capability and history-retention -fields needed for demo-visible product behavior, but it must not expose +non-commercial public contract. `/api/license/runtime-capabilities` is the +canonical public exception: it may carry capability and history-retention +fields needed for demo-visible product behavior, but +`/api/license/entitlements` must stay hidden and public browsers must not see licensed identity, plan labels, upgrade reasons, trial urgency, or observed -usage counts to public browsers. The governed browser proof for that posture -lives in `tests/integration/tests/53-demo-mode-commercial-boundary.spec.ts` -and is expected to stay runnable through +usage counts. The commercial store must also fail closed locally in demo mode +so hidden routes are not probed from the browser shell. The governed browser +proof for that posture lives in +`tests/integration/tests/53-demo-mode-commercial-boundary.spec.ts` and is +expected to stay runnable through `tests/integration/scripts/run-tests.sh demo-contract`. Legacy Cloud plan aliases are now expected to canonicalize to the `cloud_*` contract not only when Stripe metadata is parsed, but also when persisted plan diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 354e9d57b0..f5f3787624 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -406,6 +406,8 @@ commercial suppression. `frontend-modern/src/components/Settings/settingsNavCata `frontend-modern/src/components/Settings/settingsNavVisibility.ts`, `frontend-modern/src/stores/sessionCapabilities.ts`, `frontend-modern/src/stores/demoMode.ts`, +`frontend-modern/src/stores/license.ts`, +`frontend-modern/src/stores/licenseCommercial.ts`, `frontend-modern/src/useAppRuntimeState.ts`, `frontend-modern/src/components/shared/useTrialBannerState.ts`, and `frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts`, @@ -423,6 +425,12 @@ response-header inference, hostname heuristics, or per-banner demo branching; the runtime bootstrap, shared session-capability store, and shared banner hooks stay on one canonical owner so suppression stays coherent across customer-facing surfaces. +That same shared primitive boundary now also treats runtime capability reads +and commercial reads as separate stores. Shared settings shells and banner +hooks may read feature truth from `frontend-modern/src/stores/license.ts`, but +commercial identity, upgrade routing, and trial state must stay in +`frontend-modern/src/stores/licenseCommercial.ts`, which suppresses public-demo +loads locally instead of depending on route-local guards. Storage disk drawers now also sit on that same shared-primitives floor. `frontend-modern/src/components/Storage/DiskDetail.tsx` must render physical- disk read, write, and busy charts through `HistoryChart` plus diff --git a/docs/release-control/v6/internal/subsystems/organization-settings.md b/docs/release-control/v6/internal/subsystems/organization-settings.md index 29a833f65f..61d6f4f5c0 100644 --- a/docs/release-control/v6/internal/subsystems/organization-settings.md +++ b/docs/release-control/v6/internal/subsystems/organization-settings.md @@ -182,3 +182,8 @@ The RBAC feature-gate state now also depends on the shared success/error orchestration. Organization settings paywalls must not keep a lane-local `startProTrial()` branch once that shared helper covers the same runtime contract. +That same RBAC paywall surface now also depends on the runtime-versus- +commercial license split: RBAC enablement must stay on the runtime capability +store, while free-trial eligibility and upgrade routing stay on the commercial +store. Organization settings must not collapse those two concerns back into +one payload just because the same paywall shell renders both. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 00794fe4f3..eeb8d8fe09 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -592,3 +592,8 @@ through `frontend-modern/src/routing/resourceLinks.ts` where those surfaces exist, rather than forcing operators to pivot manually through search or assistant prompts to continue investigating API-backed platforms such as TrueNAS. +Patrol upgrade and trial posture now follows the same runtime-versus- +commercial split as the rest of the app. Patrol runtime availability must stay +on the non-commercial capability store, while approval/trial CTAs use the +shared commercial store and trial-start helper. Patrol surfaces must not +recombine those two contracts into one entitlement payload. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 8dce35a7d6..67eab6d36e 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -702,3 +702,8 @@ consumption. `frontend-modern/src/components/Dashboard/useDashboardState.ts` may read connection and alert state only through `frontend-modern/src/contexts/appRuntime.ts`; it must not import `@/App` or create a reverse dependency into the root shell just to read websocket state. +The same hot-path discipline now applies to public-demo commercial reads. +Shared shells may consume the small `/api/license/runtime-capabilities` +contract for feature truth, but commercial demo routes stay hidden and the +browser must not keep retrying `/api/license/entitlements` or other hidden +commercial endpoints from performance-sensitive settings or dashboard shells. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 220120194f..c9a7423b24 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -326,11 +326,12 @@ Browser-facing storage/recovery surfaces must also treat `/api/security/status.sessionCapabilities.demoMode` as the canonical public-demo bootstrap signal instead of inferring demo posture from headers, `/api/health`, or hostname heuristics. -That same shared boundary now also owns the one redacted commercial exception: -storage and recovery may inherit demo-safe `/api/license/entitlements` -capability and history-retention reads, but they must not expect licensed -identity, upgrade prompts, trial urgency, or observed usage counts to remain -present once the public-demo contract is applied. +That same shared boundary now also owns the one runtime-safe exception: +storage and recovery may inherit demo-safe `/api/license/runtime-capabilities` +reads for capability and history-retention truth, but +`/api/license/entitlements` stays hidden and those surfaces must not expect +licensed identity, upgrade prompts, trial urgency, or observed usage counts +to remain present once the public-demo contract is applied. Physical-disk live I/O drawers now also sit on the canonical storage surface. Storage disk drawers may show read, write, busy, and SMART history, but every chart must route through the shared `HistoryChart` API contract using the disk diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index 77304b8be4..e11d0b3791 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -6,6 +6,7 @@ import { createMemo, createSignal, onCleanup, + onMount, } from 'solid-js'; import type { JSX } from 'solid-js'; import { useLocation, useNavigate } from '@solidjs/router'; @@ -39,7 +40,11 @@ import { getKioskModePreference, setKioskMode } from '@/utils/url'; import { updateStore } from '@/stores/updates'; import { aiChatStore } from '@/stores/aiChat'; import { demoModeEnabled } from '@/stores/demoMode'; -import { isMultiTenantEnabled, isPro } from '@/stores/license'; +import { isMultiTenantEnabled } from '@/stores/license'; +import { + isPro, + loadLicenseStatus as loadCommercialLicenseStatus, +} from '@/stores/licenseCommercial'; import type { AppConnectionStatus } from '@/useAppRuntimeState'; const ROOT_INFRASTRUCTURE_PATH = buildInfrastructurePath(); @@ -173,6 +178,12 @@ export function AppLayout(props: AppLayoutProps) { } }; + onMount(() => { + if (!demoModeEnabled()) { + void loadCommercialLicenseStatus(); + } + }); + const showHeader = () => { clearHeaderHideTimeout(); setHeaderVisible(true); diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index a65a5a1a05..8c2648ee32 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -54,6 +54,12 @@ describe('App architecture', () => { expect(appLayoutSource).toContain('Preview'); expect(appLayoutSource).not.toContain('props.connected()'); expect(appLayoutSource).toContain('const utilityTabs = createMemo(() =>'); + expect(appLayoutSource).toContain("import { isMultiTenantEnabled } from '@/stores/license';"); + expect(appLayoutSource).toContain( + "loadLicenseStatus as loadCommercialLicenseStatus", + ); + expect(appLayoutSource).toContain('if (!demoModeEnabled()) {'); + expect(appLayoutSource).toContain('void loadCommercialLicenseStatus();'); expect(appSource).not.toContain("eventBus.on('theme_changed'"); expect(appSource).not.toContain("eventBus.on('websocket_reconnected'"); expect(appSource).not.toContain("apiFetch('/api/security/status')"); @@ -66,6 +72,9 @@ describe('App architecture', () => { expect(appRuntimeStateSource).toContain("const checkBackendHealth = async () => {"); expect(appRuntimeStateSource).toContain('const loadOrganizations = async () =>'); expect(appRuntimeStateSource).toContain('const handleOrgSwitch = (nextOrgID: string) =>'); + expect(appRuntimeStateSource).toContain( + "import {\n isHostedModeEnabled,\n isMultiTenantEnabled,\n licenseLoaded,\n loadLicenseStatus,\n} from '@/stores/license';", + ); expect(appRuntimeStateSource).toContain( 'const [activeOrgID, setActiveOrgID] = createSignal(', ); diff --git a/frontend-modern/src/api/__tests__/license.test.ts b/frontend-modern/src/api/__tests__/license.test.ts new file mode 100644 index 0000000000..9c29a3ab89 --- /dev/null +++ b/frontend-modern/src/api/__tests__/license.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LicenseAPI } from '../license'; +import { apiClient, apiFetchJSON } from '@/utils/apiClient'; + +vi.mock('@/utils/apiClient', () => ({ + apiClient: { + fetch: vi.fn(), + }, + apiFetchJSON: vi.fn(), +})); + +describe('LicenseAPI', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('reads runtime capabilities from the canonical runtime endpoint', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ + capabilities: ['relay'], + limits: [], + hosted_mode: false, + max_history_days: 14, + }); + + const result = await LicenseAPI.getRuntimeCapabilities(); + + expect(apiFetchJSON).toHaveBeenCalledWith('/api/license/runtime-capabilities'); + expect(result).toMatchObject({ + capabilities: ['relay'], + max_history_days: 14, + }); + }); + + it('reads commercial entitlements from the commercial endpoint', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ + tier: 'pro', + subscription_state: 'active', + capabilities: ['relay'], + limits: [], + upgrade_reasons: [], + }); + + const result = await LicenseAPI.getCommercialEntitlements(); + + expect(apiFetchJSON).toHaveBeenCalledWith('/api/license/entitlements'); + expect(result).toMatchObject({ + tier: 'pro', + subscription_state: 'active', + }); + }); + + it('keeps getEntitlements as a compatibility alias for the commercial endpoint', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ + tier: 'free', + subscription_state: 'expired', + capabilities: [], + limits: [], + upgrade_reasons: [], + }); + + await LicenseAPI.getEntitlements(); + + expect(apiFetchJSON).toHaveBeenCalledWith('/api/license/entitlements'); + }); + + it('starts trials through the raw license transport', async () => { + vi.mocked(apiClient.fetch).mockResolvedValueOnce(new Response(null, { status: 202 })); + + const response = await LicenseAPI.startTrial(); + + expect(apiClient.fetch).toHaveBeenCalledWith('/api/license/trial/start', { + method: 'POST', + headers: { Accept: 'application/json' }, + }); + expect(response.status).toBe(202); + }); +}); diff --git a/frontend-modern/src/api/license.ts b/frontend-modern/src/api/license.ts index 1f2425b0ec..9df2f3c149 100644 --- a/frontend-modern/src/api/license.ts +++ b/frontend-modern/src/api/license.ts @@ -43,8 +43,16 @@ export interface CommercialMigrationStatus { recommended_action?: string; } -// Mirrors internal/api/entitlement_handlers.go:EntitlementPayload -export interface LicenseEntitlements { +// Mirrors internal/api/subscription_entitlements.go:RuntimeCapabilitiesPayload +export interface LicenseRuntimeCapabilities { + capabilities: string[]; + limits: EntitlementLimitStatus[]; + hosted_mode?: boolean; + max_history_days?: number; +} + +// Mirrors internal/api/subscription_entitlements.go:EntitlementPayload +export interface LicenseCommercialEntitlements { capabilities: string[]; limits: EntitlementLimitStatus[]; subscription_state: string; @@ -70,6 +78,8 @@ export interface LicenseEntitlements { commercial_migration?: CommercialMigrationStatus; } +export type LicenseEntitlements = LicenseCommercialEntitlements; + export interface ActivateLicenseResponse { success: boolean; message?: string; @@ -94,8 +104,18 @@ export class LicenseAPI { return apiFetchJSON(`${this.baseUrl}/status`) as Promise; } - static async getEntitlements(): Promise { - return apiFetchJSON(`${this.baseUrl}/entitlements`) as Promise; + static async getRuntimeCapabilities(): Promise { + return apiFetchJSON( + `${this.baseUrl}/runtime-capabilities`, + ) as Promise; + } + + static async getCommercialEntitlements(): Promise { + return apiFetchJSON(`${this.baseUrl}/entitlements`) as Promise; + } + + static async getEntitlements(): Promise { + return this.getCommercialEntitlements(); } static async getFeatures(): Promise { diff --git a/frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx b/frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx index 7bf5a295ea..8d04fc65f6 100644 --- a/frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx +++ b/frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx @@ -5,7 +5,7 @@ import { useUpgradeNavigation } from '@/components/shared/useUpgradeNavigation'; import { getCanonicalAlertId } from '@/features/alerts/identity'; import { formatAlertValue } from '@/utils/alertFormatters'; import { resolveAlertTargetType } from '@/utils/alertTargetTypes'; -import { getUpgradeActionDestination } from '@/stores/license'; +import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; import { trackUpgradeClicked } from '@/utils/upgradeMetrics'; interface InvestigateAlertButtonProps { diff --git a/frontend-modern/src/components/Alerts/__tests__/InvestigateAlertButton.test.tsx b/frontend-modern/src/components/Alerts/__tests__/InvestigateAlertButton.test.tsx index 339feaf2fe..b0a80f7d3e 100644 --- a/frontend-modern/src/components/Alerts/__tests__/InvestigateAlertButton.test.tsx +++ b/frontend-modern/src/components/Alerts/__tests__/InvestigateAlertButton.test.tsx @@ -44,7 +44,7 @@ vi.mock('@/stores/aiChat', () => ({ aiChatStore: mockAiChatStore, })); -vi.mock('@/stores/license', () => ({ +vi.mock('@/stores/licenseCommercial', () => ({ getUpgradeActionDestination: (...args: unknown[]) => getUpgradeActionDestinationMock(...args), getUpgradeActionUrlOrFallback: (...args: unknown[]) => getUpgradeActionUrlOrFallbackMock(...args), })); diff --git a/frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx b/frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx index 44c169f556..6cd69a6e52 100644 --- a/frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx +++ b/frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx @@ -4,7 +4,7 @@ import X from 'lucide-solid/icons/x'; import { Card } from '@/components/shared/Card'; import { UpgradeLink } from '@/components/shared/UpgradeLink'; import { demoModeEnabled } from '@/stores/demoMode'; -import { getUpgradeActionDestination } from '@/stores/license'; +import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; import { RELAY_ONBOARDING_DESCRIPTION, RELAY_ONBOARDING_DISCONNECTED_LABEL, diff --git a/frontend-modern/src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx b/frontend-modern/src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx index bdd95db1f4..63f2dc6fcd 100644 --- a/frontend-modern/src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx +++ b/frontend-modern/src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx @@ -44,10 +44,13 @@ vi.mock('@solidjs/router', () => ({ })); vi.mock('@/stores/license', () => ({ - getUpgradeActionDestination: (...args: unknown[]) => getUpgradeActionDestinationMock(...args), hasFeature: (...args: unknown[]) => hasFeatureMock(...args), loadLicenseStatus: (...args: unknown[]) => loadLicenseStatusMock(...args), licenseLoaded: (...args: unknown[]) => licenseLoadedMock(...args), +})); + +vi.mock('@/stores/licenseCommercial', () => ({ + getUpgradeActionDestination: (...args: unknown[]) => getUpgradeActionDestinationMock(...args), startProTrial: (...args: unknown[]) => startProTrialMock(...args), getUpgradeActionUrlOrFallback: (...args: unknown[]) => getUpgradeActionUrlOrFallbackMock(...args), })); diff --git a/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx b/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx index 20db29a847..aba1e355cd 100644 --- a/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx +++ b/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx @@ -1,7 +1,7 @@ import { Component, For, Show } from 'solid-js'; import RefreshCw from 'lucide-solid/icons/refresh-cw'; import { UpgradeLink } from '@/components/shared/UpgradeLink'; -import { getUpgradeActionDestination, licenseLoadError } from '@/stores/license'; +import { getUpgradeActionDestination, licenseLoadError } from '@/stores/licenseCommercial'; import { getInactiveProUpsellNotice, getLicenseStatusLoadingState, diff --git a/frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx b/frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx index 2abcc2cb68..0d0ff75413 100644 --- a/frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx +++ b/frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx @@ -1,6 +1,6 @@ import { Component, Show } from 'solid-js'; import { UpgradeLink } from '@/components/shared/UpgradeLink'; -import { getUpgradeActionDestination } from '@/stores/license'; +import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; import type { RBACFeatureGateCopy } from '@/utils/rbacPresentation'; import { trackUpgradeClicked } from '@/utils/upgradeMetrics'; import { diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 51733ba967..a9ff036c14 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -18,7 +18,7 @@ import { useSystemSettingsState } from './useSystemSettingsState'; import { useSettingsNavigation } from './useSettingsNavigation'; import { getSettingsLoadingState } from '@/utils/settingsShellPresentation'; -import { getLimit, isPro, loadLicenseStatus } from '@/stores/license'; +import { getLimit, isPro, loadLicenseStatus } from '@/stores/licenseCommercial'; interface SettingsProps { darkMode: () => boolean; diff --git a/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx b/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx index 787c37487d..a5985af71a 100644 --- a/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx @@ -68,11 +68,14 @@ vi.mock('@/utils/logger', () => ({ })); vi.mock('@/stores/license', () => ({ - entitlements: (...args: unknown[]) => entitlementsMock(...args), - getUpgradeActionDestination: () => ({ href: 'https://example.com/upgrade', external: true }), hasFeature: (...args: unknown[]) => hasFeatureMock(...args), loadLicenseStatus: (...args: unknown[]) => loadLicenseStatusMock(...args), - getUpgradeActionUrlOrFallback: () => '/upgrade', +})); + +vi.mock('@/stores/licenseCommercial', () => ({ + entitlements: (...args: unknown[]) => entitlementsMock(...args), + getUpgradeActionDestination: () => ({ href: 'https://example.com/upgrade', external: true }), + loadLicenseStatus: (...args: unknown[]) => loadLicenseStatusMock(...args), })); vi.mock('@/utils/trialStartAction', () => ({ diff --git a/frontend-modern/src/components/Settings/__tests__/AgentProfilesPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/AgentProfilesPanel.test.tsx index 694f249078..a98f1686c9 100644 --- a/frontend-modern/src/components/Settings/__tests__/AgentProfilesPanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/AgentProfilesPanel.test.tsx @@ -32,14 +32,20 @@ vi.mock('@/contexts/appRuntime', () => ({ })); vi.mock('@/stores/license', () => ({ - getUpgradeActionDestination: (...args: unknown[]) => getUpgradeActionDestinationMock(...args), - getUpgradeActionUrlOrFallback: (...args: unknown[]) => getUpgradeActionUrlOrFallbackMock(...args), hasFeature: () => true, licenseLoaded: () => true, loadLicenseStatus: () => Promise.resolve(), licenseLoading: () => false, })); +vi.mock('@/stores/licenseCommercial', () => ({ + entitlements: () => ({ trial_eligible: true }), + getUpgradeActionDestination: (...args: unknown[]) => getUpgradeActionDestinationMock(...args), + getUpgradeActionUrlOrFallback: (...args: unknown[]) => getUpgradeActionUrlOrFallbackMock(...args), + loadLicenseStatus: () => Promise.resolve(), + startProTrial: vi.fn(), +})); + vi.mock('@/api/agentProfiles', () => ({ MISSING_AGENT_PROFILE_ASSIGNMENT_MESSAGE: 'Selected profile no longer exists. Refresh and choose another profile.', diff --git a/frontend-modern/src/components/Settings/__tests__/NodeModal.guardrails.test.ts b/frontend-modern/src/components/Settings/__tests__/NodeModal.guardrails.test.ts index d7b0b2f000..fbbe213158 100644 --- a/frontend-modern/src/components/Settings/__tests__/NodeModal.guardrails.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/NodeModal.guardrails.test.ts @@ -53,6 +53,8 @@ describe('NodeModal guardrails', () => { it('routes proxmox agent-install commands through the canonical NodesAPI client for both PVE and PBS', () => { expect(nodeModalSource).toContain('useNodeModalState'); + expect(nodeModalStateSource).toContain("from '@/stores/licenseCommercial';"); + expect(nodeModalStateSource).not.toContain("from '@/stores/license';\n"); expect(nodeModalStateSource).toContain('const copyProxmoxAgentInstallCommand = async ('); expect(nodeModalStateSource).toContain("const data = await NodesAPI.getAgentInstallCommand({"); expect(nodeModalStateSource).toContain('type,'); @@ -127,6 +129,9 @@ describe('NodeModal guardrails', () => { expect(nodeModalStateSource).toContain( 'API-managed PVE/PBS/PMG connections are governed when the connection is', ); + expect(nodeModalStateSource).toContain('const hostLimitReached = createMemo(() => false);'); + expect(nodeModalStateSource).toContain("if (ent.subscription_state === 'active' || ent.subscription_state === 'trial')"); + expect(nodeModalStateSource).toContain('return ent.trial_eligible !== false;'); expect(nodeModalStateSource).toContain('monitored-system limit banner.'); expect(nodeModalSettingsOwnerSource).not.toContain('Under agents-only counting'); expect(nodeModalSettingsOwnerSource).not.toContain('the agent limit'); diff --git a/frontend-modern/src/components/Settings/__tests__/NodeModal.installCommand.test.tsx b/frontend-modern/src/components/Settings/__tests__/NodeModal.installCommand.test.tsx index f79343c062..3b540d8729 100644 --- a/frontend-modern/src/components/Settings/__tests__/NodeModal.installCommand.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/NodeModal.installCommand.test.tsx @@ -39,8 +39,9 @@ vi.mock('@/components/shared/Dialog', () => ({ props.isOpen ?
{props.children}
: null, })); -vi.mock('@/stores/license', () => ({ +vi.mock('@/stores/licenseCommercial', () => ({ licenseStatus: () => 'active', + loadLicenseStatus: vi.fn(), startProTrial: vi.fn(), })); diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index ba4c4a32f7..48341cecc1 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -39,9 +39,12 @@ vi.mock('@solidjs/router', () => ({ })); vi.mock('@/stores/license', () => ({ + isMultiTenantEnabled: () => true, +})); + +vi.mock('@/stores/licenseCommercial', () => ({ getUpgradeActionDestination: (...args: unknown[]) => getUpgradeActionDestinationMock(...args), getUpgradeActionUrlOrFallback: (...args: unknown[]) => getUpgradeActionUrlOrFallbackMock(...args), - isMultiTenantEnabled: () => true, licenseLoadError: () => false, licenseStatus: () => mockEntitlements, loadLicenseStatus: (...args: unknown[]) => loadLicenseStatusMock(...args), diff --git a/frontend-modern/src/components/Settings/__tests__/RBACPaywallPanels.test.tsx b/frontend-modern/src/components/Settings/__tests__/RBACPaywallPanels.test.tsx index 10da9928e6..d63e58d7f2 100644 --- a/frontend-modern/src/components/Settings/__tests__/RBACPaywallPanels.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/RBACPaywallPanels.test.tsx @@ -6,6 +6,7 @@ import { UserAssignmentsPanel } from '../UserAssignmentsPanel'; const hasFeatureMock = vi.fn(); const loadLicenseStatusMock = vi.fn(); +const loadCommercialLicenseStatusMock = vi.fn(); const startProTrialMock = vi.fn(); const entitlementsMock = vi.fn(); const trackPaywallViewedMock = vi.fn(); @@ -19,14 +20,18 @@ const notificationErrorMock = vi.fn(); const loggerErrorMock = vi.fn(); vi.mock('@/stores/license', () => ({ + hasFeature: (...args: unknown[]) => hasFeatureMock(...args), + loadLicenseStatus: (...args: unknown[]) => loadLicenseStatusMock(...args), + licenseLoaded: () => true, +})); + +vi.mock('@/stores/licenseCommercial', () => ({ getUpgradeActionDestination: (feature: string) => ({ href: `https://example.com/upgrade?feature=${feature}`, external: true, }), getUpgradeActionUrlOrFallback: (feature: string) => `/upgrade?feature=${feature}`, - hasFeature: (...args: unknown[]) => hasFeatureMock(...args), - loadLicenseStatus: (...args: unknown[]) => loadLicenseStatusMock(...args), - licenseLoaded: () => true, + loadLicenseStatus: (...args: unknown[]) => loadCommercialLicenseStatusMock(...args), startProTrial: (...args: unknown[]) => startProTrialMock(...args), entitlements: (...args: unknown[]) => entitlementsMock(...args), })); @@ -66,6 +71,7 @@ describe('RBAC paywall settings panels', () => { beforeEach(() => { hasFeatureMock.mockReset(); loadLicenseStatusMock.mockReset(); + loadCommercialLicenseStatusMock.mockReset(); startProTrialMock.mockReset(); entitlementsMock.mockReset(); trackPaywallViewedMock.mockReset(); @@ -80,6 +86,7 @@ describe('RBAC paywall settings panels', () => { hasFeatureMock.mockReturnValue(true); loadLicenseStatusMock.mockResolvedValue(undefined); + loadCommercialLicenseStatusMock.mockResolvedValue(undefined); startProTrialMock.mockResolvedValue({ outcome: 'started' }); entitlementsMock.mockReturnValue({ trial_eligible: false }); getRolesMock.mockResolvedValue([ diff --git a/frontend-modern/src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx b/frontend-modern/src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx index a61b827ab8..b508bf41e7 100644 --- a/frontend-modern/src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx @@ -23,10 +23,13 @@ vi.mock('@/stores/license', () => ({ hasFeature: (...args: unknown[]) => hasFeatureMock(...args), licenseLoaded: () => true, loadLicenseStatus: (...args: unknown[]) => loadLicenseStatusMock(...args), +})); + +vi.mock('@/stores/licenseCommercial', () => ({ getUpgradeActionDestination: () => ({ href: 'https://example.com/upgrade', external: true }), - getUpgradeActionUrlOrFallback: () => '/upgrade', - startProTrial: vi.fn(), entitlements: () => ({ trial_eligible: false }), + loadLicenseStatus: (...args: unknown[]) => loadLicenseStatusMock(...args), + startProTrial: vi.fn(), })); vi.mock('@/api/relay', () => ({ diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index c4ae3644ec..03fe7c5903 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -1498,4 +1498,24 @@ describe('Settings architecture guardrails', () => { 'RecoverySettingsPanel should not wrap its single shell in an extra page spacer', ).not.toContain('
'); }); + + it('splits runtime capability reads from commercial entitlement reads across settings state owners', () => { + expect(settingsSource).toContain("from '@/stores/licenseCommercial';"); + expect(settingsSource).not.toContain("getLimit, isPro, loadLicenseStatus } from '@/stores/license';"); + + expect(aiSettingsStateSource).toContain("} from '@/stores/license';"); + expect(aiSettingsStateSource).toContain("} from '@/stores/licenseCommercial';"); + expect(aiSettingsStateSource).toContain('loadCommercialLicenseStatus();'); + + expect(reportingPanelStateSource).toContain("} from '@/stores/license';"); + expect(reportingPanelStateSource).toContain("} from '@/stores/licenseCommercial';"); + expect(reportingPanelStateSource).toContain('loadCommercialLicenseStatus();'); + + expect(auditLogStateSource).toContain("} from '@/stores/license';"); + expect(auditLogStateSource).toContain("} from '@/stores/licenseCommercial';"); + expect(auditWebhookStateSource).toContain("} from '@/stores/licenseCommercial';"); + expect(ssoProvidersStateSource).toContain("} from '@/stores/licenseCommercial';"); + expect(rbacFeatureGateStateSource).toContain("} from '@/stores/licenseCommercial';"); + expect(nodeModalStateSource).toContain("from '@/stores/licenseCommercial';"); + }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts b/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts index 0be7e1af13..b04c0899c3 100644 --- a/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts @@ -79,7 +79,8 @@ describe('useReportingPanelState', () => { let useReportingPanelState: UseReportingPanelStateModule['useReportingPanelState']; let apiFetchMock: ReturnType; let hasReportingFeature: boolean; - let loadLicenseStatusMock: ReturnType; + let loadRuntimeLicenseStatusMock: ReturnType; + let loadCommercialLicenseStatusMock: ReturnType; beforeEach(async () => { vi.resetModules(); @@ -88,7 +89,8 @@ describe('useReportingPanelState', () => { .fn() .mockResolvedValue(new Response(JSON.stringify(catalogPayload), { status: 200 })); hasReportingFeature = true; - loadLicenseStatusMock = vi.fn(); + loadRuntimeLicenseStatusMock = vi.fn(); + loadCommercialLicenseStatusMock = vi.fn(); vi.doMock('@/utils/apiClient', async () => { const actual = await vi.importActual('@/utils/apiClient'); @@ -112,15 +114,20 @@ describe('useReportingPanelState', () => { })); vi.doMock('@/stores/license', () => ({ + hasFeature: vi.fn((feature: string) => feature === 'advanced_reporting' && hasReportingFeature), + licenseLoaded: vi.fn(() => true), + loadLicenseStatus: loadRuntimeLicenseStatusMock, + })); + + vi.doMock('@/stores/licenseCommercial', () => ({ entitlements: vi.fn(() => ({ trial_eligible: true })), getUpgradeActionDestination: vi.fn((feature?: string) => ({ href: getPublicPricingUrl(feature), external: true, })), getUpgradeActionUrlOrFallback: vi.fn((feature?: string) => getPublicPricingUrl(feature)), - hasFeature: vi.fn((feature: string) => feature === 'advanced_reporting' && hasReportingFeature), - licenseLoaded: vi.fn(() => true), - loadLicenseStatus: loadLicenseStatusMock, + loadLicenseStatus: loadCommercialLicenseStatusMock, + startProTrial: vi.fn(), })); ({ useReportingPanelState } = await import('../useReportingPanelState')); @@ -149,7 +156,8 @@ describe('useReportingPanelState', () => { await flushAsync(); await flushAsync(); - expect(loadLicenseStatusMock).toHaveBeenCalledOnce(); + expect(loadRuntimeLicenseStatusMock).toHaveBeenCalledOnce(); + expect(loadCommercialLicenseStatusMock).toHaveBeenCalledOnce(); expect(apiFetchMock).toHaveBeenCalledWith('/api/admin/reports/catalog'); expect(hookState.reportingCatalog()?.performanceReport.defaultFormat).toBe('csv'); expect(hookState.format()).toBe('csv'); @@ -180,7 +188,8 @@ describe('useReportingPanelState', () => { .fn() .mockResolvedValue(new Response(JSON.stringify(catalogPayload), { status: 200 })); hasReportingFeature = false; - loadLicenseStatusMock = vi.fn(); + loadRuntimeLicenseStatusMock = vi.fn(); + loadCommercialLicenseStatusMock = vi.fn(); vi.doMock('@/utils/apiClient', async () => { const actual = await vi.importActual('@/utils/apiClient'); @@ -204,15 +213,20 @@ describe('useReportingPanelState', () => { })); vi.doMock('@/stores/license', () => ({ + hasFeature: vi.fn(() => false), + licenseLoaded: vi.fn(() => false), + loadLicenseStatus: loadRuntimeLicenseStatusMock, + })); + + vi.doMock('@/stores/licenseCommercial', () => ({ entitlements: vi.fn(() => ({ trial_eligible: true })), getUpgradeActionDestination: vi.fn((feature?: string) => ({ href: getPublicPricingUrl(feature), external: true, })), getUpgradeActionUrlOrFallback: vi.fn((feature?: string) => getPublicPricingUrl(feature)), - hasFeature: vi.fn(() => false), - licenseLoaded: vi.fn(() => false), - loadLicenseStatus: loadLicenseStatusMock, + loadLicenseStatus: loadCommercialLicenseStatusMock, + startProTrial: vi.fn(), })); ({ useReportingPanelState } = await import('../useReportingPanelState')); @@ -222,7 +236,8 @@ describe('useReportingPanelState', () => { await flushAsync(); await flushAsync(); - expect(loadLicenseStatusMock).toHaveBeenCalledOnce(); + expect(loadRuntimeLicenseStatusMock).toHaveBeenCalledOnce(); + expect(loadCommercialLicenseStatusMock).toHaveBeenCalledOnce(); expect(apiFetchMock).toHaveBeenCalledWith('/api/admin/reports/catalog'); expect(hookState.reportingCatalog()?.title).toBe('Detailed Reporting'); expect(hookState.isLocked()).toBe(false); @@ -261,15 +276,20 @@ describe('useReportingPanelState', () => { })); vi.doMock('@/stores/license', () => ({ + hasFeature: vi.fn((feature: string) => feature === 'advanced_reporting' && hasReportingFeature), + licenseLoaded: vi.fn(() => true), + loadLicenseStatus: loadRuntimeLicenseStatusMock, + })); + + vi.doMock('@/stores/licenseCommercial', () => ({ entitlements: vi.fn(() => ({ trial_eligible: true })), getUpgradeActionDestination: vi.fn((feature?: string) => ({ href: getPublicPricingUrl(feature), external: true, })), getUpgradeActionUrlOrFallback: vi.fn((feature?: string) => getPublicPricingUrl(feature)), - hasFeature: vi.fn((feature: string) => feature === 'advanced_reporting' && hasReportingFeature), - licenseLoaded: vi.fn(() => true), - loadLicenseStatus: loadLicenseStatusMock, + loadLicenseStatus: loadCommercialLicenseStatusMock, + startProTrial: vi.fn(), })); ({ useReportingPanelState } = await import('../useReportingPanelState')); @@ -322,15 +342,20 @@ describe('useReportingPanelState', () => { })); vi.doMock('@/stores/license', () => ({ + hasFeature: vi.fn((feature: string) => feature === 'advanced_reporting' && hasReportingFeature), + licenseLoaded: vi.fn(() => true), + loadLicenseStatus: loadRuntimeLicenseStatusMock, + })); + + vi.doMock('@/stores/licenseCommercial', () => ({ entitlements: vi.fn(() => ({ trial_eligible: true })), getUpgradeActionDestination: vi.fn((feature?: string) => ({ href: getPublicPricingUrl(feature), external: true, })), getUpgradeActionUrlOrFallback: vi.fn((feature?: string) => getPublicPricingUrl(feature)), - hasFeature: vi.fn((feature: string) => feature === 'advanced_reporting' && hasReportingFeature), - licenseLoaded: vi.fn(() => true), - loadLicenseStatus: loadLicenseStatusMock, + loadLicenseStatus: loadCommercialLicenseStatusMock, + startProTrial: vi.fn(), })); ({ useReportingPanelState } = await import('../useReportingPanelState')); @@ -372,15 +397,20 @@ describe('useReportingPanelState', () => { })); vi.doMock('@/stores/license', () => ({ + hasFeature: vi.fn((feature: string) => feature === 'advanced_reporting' && hasReportingFeature), + licenseLoaded: vi.fn(() => true), + loadLicenseStatus: loadRuntimeLicenseStatusMock, + })); + + vi.doMock('@/stores/licenseCommercial', () => ({ entitlements: vi.fn(() => ({ trial_eligible: true })), getUpgradeActionDestination: vi.fn((feature?: string) => ({ href: getPublicPricingUrl(feature), external: true, })), getUpgradeActionUrlOrFallback: vi.fn((feature?: string) => getPublicPricingUrl(feature)), - hasFeature: vi.fn((feature: string) => feature === 'advanced_reporting' && hasReportingFeature), - licenseLoaded: vi.fn(() => true), - loadLicenseStatus: loadLicenseStatusMock, + loadLicenseStatus: loadCommercialLicenseStatusMock, + startProTrial: vi.fn(), })); ({ useReportingPanelState } = await import('../useReportingPanelState')); diff --git a/frontend-modern/src/components/Settings/useAISettingsState.ts b/frontend-modern/src/components/Settings/useAISettingsState.ts index 769e9cb683..eef910b7ce 100644 --- a/frontend-modern/src/components/Settings/useAISettingsState.ts +++ b/frontend-modern/src/components/Settings/useAISettingsState.ts @@ -11,11 +11,14 @@ import { } from '@/components/Settings/aiSettingsModel'; import { aiChatStore } from '@/stores/aiChat'; import { - entitlements, - getUpgradeActionDestination, hasFeature, loadLicenseStatus, } from '@/stores/license'; +import { + entitlements, + getUpgradeActionDestination, + loadLicenseStatus as loadCommercialLicenseStatus, +} from '@/stores/licenseCommercial'; import { notificationStore } from '@/stores/notifications'; import type { AISettings as AISettingsType, AIProvider, AuthMethod } from '@/types/ai'; import { @@ -828,6 +831,7 @@ export const useAISettingsState = () => { onMount(() => { loadLicenseStatus(); + loadCommercialLicenseStatus(); if (typeof window !== 'undefined') { const params = new URLSearchParams(window.location.search); diff --git a/frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts b/frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts index d5df99825d..b169927880 100644 --- a/frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts +++ b/frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts @@ -11,13 +11,16 @@ import { import { useResources } from '@/hooks/useResources'; import { notificationStore } from '@/stores/notifications'; import { - entitlements, - getUpgradeActionDestination, hasFeature as hasEntitlement, licenseLoaded, licenseLoading, - loadLicenseStatus, } from '@/stores/license'; +import { + entitlements, + getUpgradeActionDestination, + loadLicenseStatus as loadCommercialLicenseStatus, +} from '@/stores/licenseCommercial'; +import { loadLicenseStatus } from '@/stores/license'; import type { ConnectedInfrastructureItem } from '@/types/api'; import type { Resource } from '@/types/resource'; import { formatRelativeTime } from '@/utils/format'; @@ -235,6 +238,7 @@ export const useAgentProfilesPanelState = () => { onMount(async () => { await loadLicenseStatus(); + await loadCommercialLicenseStatus(); try { const aiSettings = await AIAPI.getSettings(); diff --git a/frontend-modern/src/components/Settings/useAuditLogPanelState.ts b/frontend-modern/src/components/Settings/useAuditLogPanelState.ts index 7a721d4269..4cef977f9c 100644 --- a/frontend-modern/src/components/Settings/useAuditLogPanelState.ts +++ b/frontend-modern/src/components/Settings/useAuditLogPanelState.ts @@ -8,12 +8,15 @@ import { import { apiFetch } from '@/utils/apiClient'; import { showSuccess, showToast, showWarning } from '@/utils/toast'; import { - entitlements, - getUpgradeActionDestination, hasFeature, licenseLoaded, - loadLicenseStatus, } from '@/stores/license'; +import { + entitlements, + getUpgradeActionDestination, + loadLicenseStatus as loadCommercialLicenseStatus, +} from '@/stores/licenseCommercial'; +import { loadLicenseStatus } from '@/stores/license'; import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics'; import { runStartProTrialAction } from '@/utils/trialStartAction'; @@ -542,6 +545,7 @@ export const useAuditLogPanelState = () => { onMount(() => { setIsMounted(true); void loadLicenseStatus(); + void loadCommercialLicenseStatus(); }); createEffect(() => { diff --git a/frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts b/frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts index 3369e792df..8db1689064 100644 --- a/frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts +++ b/frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts @@ -3,12 +3,15 @@ import { apiFetchJSON } from '@/utils/apiClient'; import { logger } from '@/utils/logger'; import { showSuccess, showWarning } from '@/utils/toast'; import { - entitlements, - getUpgradeActionDestination, hasFeature, licenseLoaded, - loadLicenseStatus, } from '@/stores/license'; +import { + entitlements, + getUpgradeActionDestination, + loadLicenseStatus as loadCommercialLicenseStatus, +} from '@/stores/licenseCommercial'; +import { loadLicenseStatus } from '@/stores/license'; import { trackPaywallViewed } from '@/utils/upgradeMetrics'; import { getAuditWebhookDuplicateUrlMessage, @@ -99,6 +102,7 @@ export const useAuditWebhookPanelState = (canManageOverride?: boolean) => { onMount(() => { loadLicenseStatus(); + loadCommercialLicenseStatus(); }); createEffect((wasPaywallVisible: boolean) => { diff --git a/frontend-modern/src/components/Settings/useNodeModalState.ts b/frontend-modern/src/components/Settings/useNodeModalState.ts index 82f96dd06a..b819218c37 100644 --- a/frontend-modern/src/components/Settings/useNodeModalState.ts +++ b/frontend-modern/src/components/Settings/useNodeModalState.ts @@ -1,10 +1,10 @@ -import { createEffect, createMemo, createSignal } from 'solid-js'; +import { createEffect, createMemo, createSignal, onMount } from 'solid-js'; import type { NodeConfig } from '@/types/nodes'; import { notificationStore } from '@/stores/notifications'; import { NodesAPI } from '@/api/nodes'; import type { ProxmoxSetupCommandResponse } from '@/api/nodes'; -import { licenseStatus } from '@/stores/license'; +import { licenseStatus, loadLicenseStatus } from '@/stores/licenseCommercial'; import { copyToClipboard } from '@/utils/clipboard'; import { logger } from '@/utils/logger'; import { @@ -43,6 +43,10 @@ export const useNodeModalState = (props: NodeModalProps) => { const [loadingAgentCommand, setLoadingAgentCommand] = createSignal(false); const [agentCommandError, setAgentCommandError] = createSignal(null); + onMount(() => { + void loadLicenseStatus(); + }); + const isAdvancedSetupMode = () => formData().setupMode === 'auto' || formData().setupMode === 'manual'; const showTemperatureMonitoringSection = () => diff --git a/frontend-modern/src/components/Settings/useProLicensePanelState.ts b/frontend-modern/src/components/Settings/useProLicensePanelState.ts index 16fdf59f73..171b1faf33 100644 --- a/frontend-modern/src/components/Settings/useProLicensePanelState.ts +++ b/frontend-modern/src/components/Settings/useProLicensePanelState.ts @@ -2,11 +2,11 @@ import { createMemo, createSignal, onMount } from 'solid-js'; import { useLocation, useNavigate } from '@solidjs/router'; import { notificationStore } from '@/stores/notifications'; import { - isMultiTenantEnabled, licenseLoadError, licenseStatus, loadLicenseStatus, -} from '@/stores/license'; +} from '@/stores/licenseCommercial'; +import { isMultiTenantEnabled } from '@/stores/license'; import { LicenseAPI } from '@/api/license'; import { formatLicensePlanVersion, diff --git a/frontend-modern/src/components/Settings/useRBACFeatureGateState.ts b/frontend-modern/src/components/Settings/useRBACFeatureGateState.ts index 6ce32c1cf9..004d4453f0 100644 --- a/frontend-modern/src/components/Settings/useRBACFeatureGateState.ts +++ b/frontend-modern/src/components/Settings/useRBACFeatureGateState.ts @@ -1,10 +1,13 @@ import { Accessor, createEffect, createMemo, createSignal, onMount } from 'solid-js'; import { - entitlements, hasFeature, licenseLoaded, - loadLicenseStatus, } from '@/stores/license'; +import { + entitlements, + loadLicenseStatus as loadCommercialLicenseStatus, +} from '@/stores/licenseCommercial'; +import { loadLicenseStatus } from '@/stores/license'; import { notificationStore } from '@/stores/notifications'; import { getRBACFeatureGateCopy, type RBACFeatureGateCopy } from '@/utils/rbacPresentation'; import { trackPaywallViewed } from '@/utils/upgradeMetrics'; @@ -36,6 +39,7 @@ export function useRBACFeatureGateState(options: UseRBACFeatureGateStateOptions) onMount(() => { void loadLicenseStatus(); + void loadCommercialLicenseStatus(); }); createEffect((wasPaywallVisible) => { diff --git a/frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts b/frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts index 0b088957bb..4b39c6de1c 100644 --- a/frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts +++ b/frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts @@ -1,11 +1,14 @@ import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; +import { + hasFeature, + licenseLoaded, +} from '@/stores/license'; import { entitlements, getUpgradeActionDestination, - hasFeature, - licenseLoaded, - loadLicenseStatus, -} from '@/stores/license'; + loadLicenseStatus as loadCommercialLicenseStatus, +} from '@/stores/licenseCommercial'; +import { loadLicenseStatus } from '@/stores/license'; import { trackPaywallViewed } from '@/utils/upgradeMetrics'; import { showError, showSuccess } from '@/utils/toast'; import { RelayAPI, type RelayConfig, type RelayStatus } from '@/api/relay'; @@ -131,7 +134,7 @@ export function useRelaySettingsPanelState(props: RelaySettingsPanelProps) { }; onMount(async () => { - await loadLicenseStatus(); + await Promise.all([loadLicenseStatus(), loadCommercialLicenseStatus()]); if (!relayEnabled()) { setLoading(false); return; diff --git a/frontend-modern/src/components/Settings/useReportingPanelState.ts b/frontend-modern/src/components/Settings/useReportingPanelState.ts index 7439e02a73..3ebd0239a1 100644 --- a/frontend-modern/src/components/Settings/useReportingPanelState.ts +++ b/frontend-modern/src/components/Settings/useReportingPanelState.ts @@ -3,12 +3,15 @@ import { apiErrorFromResponse, apiFetch } from '@/utils/apiClient'; import { showSuccess, showWarning } from '@/utils/toast'; import type { SelectedResource } from '@/components/Settings/ResourcePicker'; import { - entitlements, - getUpgradeActionDestination, hasFeature, licenseLoaded, - loadLicenseStatus, } from '@/stores/license'; +import { + entitlements, + getUpgradeActionDestination, + loadLicenseStatus as loadCommercialLicenseStatus, +} from '@/stores/licenseCommercial'; +import { loadLicenseStatus } from '@/stores/license'; import { trackPaywallViewed } from '@/utils/upgradeMetrics'; import { getReportingCatalogErrorMessage, @@ -67,6 +70,7 @@ export const useReportingPanelState = () => { onMount(() => { loadLicenseStatus(); + loadCommercialLicenseStatus(); }); createEffect((wasVisible: boolean) => { diff --git a/frontend-modern/src/components/Settings/useSSOProvidersState.ts b/frontend-modern/src/components/Settings/useSSOProvidersState.ts index 88fe200149..a6fd87b1c6 100644 --- a/frontend-modern/src/components/Settings/useSSOProvidersState.ts +++ b/frontend-modern/src/components/Settings/useSSOProvidersState.ts @@ -3,12 +3,15 @@ import { createStore } from 'solid-js/store'; import { notificationStore } from '@/stores/notifications'; import { logger } from '@/utils/logger'; import { - getUpgradeActionDestination, hasFeature, - loadLicenseStatus, licenseLoaded, - entitlements, } from '@/stores/license'; +import { + entitlements, + getUpgradeActionDestination, + loadLicenseStatus as loadCommercialLicenseStatus, +} from '@/stores/licenseCommercial'; +import { loadLicenseStatus } from '@/stores/license'; import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics'; import { getSSOCopySuccessMessage, @@ -128,6 +131,7 @@ export const useSSOProvidersState = (props: SSOProvidersPanelProps) => { onMount(() => { loadLicenseStatus(); + loadCommercialLicenseStatus(); void loadProviders(); }); diff --git a/frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx b/frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx index 98236f78d5..442b8991b1 100644 --- a/frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx +++ b/frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx @@ -4,6 +4,7 @@ import { createEffect, createMemo, onCleanup, + onMount, Show, For, } from 'solid-js'; @@ -33,7 +34,7 @@ import { import { loadLicenseStatus, entitlements, -} from '@/stores/license'; +} from '@/stores/licenseCommercial'; import { RELAY_ONBOARDING_SETUP_LABEL, RELAY_ONBOARDING_SETUP_WIZARD_TRIAL_LABEL, @@ -130,6 +131,10 @@ export const SetupCompletionPanel: Component = (props) => { const [relayPaywallTracked, setRelayPaywallTracked] = createSignal(false); let firstConnectionTracked = false; + onMount(() => { + void loadLicenseStatus(); + }); + createEffect(() => { if (connectedAgents().length > 0 && !relayPaywallTracked()) { trackPaywallViewed('relay', 'setup_wizard'); diff --git a/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.guardrails.test.ts b/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.guardrails.test.ts index 608d37ae29..8c51646b2d 100644 --- a/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.guardrails.test.ts +++ b/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.guardrails.test.ts @@ -3,6 +3,7 @@ import setupCompletionPanelSource from '../SetupCompletionPanel.tsx?raw'; describe('SetupCompletionPanel guardrails', () => { it('keeps setup completion aligned with the canonical infrastructure install workspace', () => { + expect(setupCompletionPanelSource).toContain("from '@/stores/licenseCommercial';"); expect(setupCompletionPanelSource).toContain( "const INFRASTRUCTURE_INSTALL_PATH = '/settings/infrastructure/install';", ); @@ -24,6 +25,7 @@ describe('SetupCompletionPanel guardrails', () => { expect(setupCompletionPanelSource).toContain('Use the Platform connections workspace when:'); expect(setupCompletionPanelSource).toContain('configure TLS and custom CA options'); expect(setupCompletionPanelSource).toContain('runStartProTrialAction({'); + expect(setupCompletionPanelSource).toContain('await loadLicenseStatus(true);'); expect(setupCompletionPanelSource).not.toContain('getUpgradeActionUrlOrFallback'); }); diff --git a/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.test.tsx b/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.test.tsx index 5295250ea8..6b1d99722c 100644 --- a/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.test.tsx +++ b/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.test.tsx @@ -53,7 +53,7 @@ vi.mock('@/utils/url', () => ({ getPulseBaseUrl: () => 'https://pulse.example.com', })); -vi.mock('@/stores/license', () => ({ +vi.mock('@/stores/licenseCommercial', () => ({ loadLicenseStatus: (...args: unknown[]) => loadLicenseStatusMock(...args), entitlements: () => ({ relay: false }), getUpgradeActionUrlOrFallback: () => 'https://pulse.example.com/upgrade', diff --git a/frontend-modern/src/components/patrol/ApprovalSection.tsx b/frontend-modern/src/components/patrol/ApprovalSection.tsx index 25d536e076..46325b0ae3 100644 --- a/frontend-modern/src/components/patrol/ApprovalSection.tsx +++ b/frontend-modern/src/components/patrol/ApprovalSection.tsx @@ -5,11 +5,12 @@ * States: Pending, Expired, Executed, Denied, Failed, Verified, VerificationFailed. */ -import { Component, Show, createSignal, createResource, createMemo } from 'solid-js'; +import { Component, Show, createSignal, createResource, createMemo, onMount } from 'solid-js'; import { aiIntelligenceStore } from '@/stores/aiIntelligence'; import { notificationStore } from '@/stores/notifications'; import { aiChatStore } from '@/stores/aiChat'; -import { hasFeature, licenseStatus } from '@/stores/license'; +import { licenseStatus, loadLicenseStatus } from '@/stores/licenseCommercial'; +import { hasFeature } from '@/stores/license'; import { AIAPI, type ApprovalRequest, type ApprovalExecutionResult } from '@/api/ai'; import { getApprovalRiskPresentation } from '@/utils/approvalRiskPresentation'; import { RemediationStatus } from './RemediationStatus'; @@ -28,6 +29,10 @@ export const ApprovalSection: Component = (props) => { const [actionLoading, setActionLoading] = createSignal(null); const [executionResult, setExecutionResult] = createSignal(null); + onMount(() => { + void loadLicenseStatus(); + }); + // Find the pending approval for this finding from the store const pendingApproval = createMemo(() => { return ( diff --git a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx index d4423ef056..213442a0b6 100644 --- a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx +++ b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx @@ -55,7 +55,11 @@ vi.mock('@/stores/aiChat', () => ({ vi.mock('@/stores/license', () => ({ hasFeature: (feature: string) => feature === 'ai_autofix' && state.hasAutoFix, +})); + +vi.mock('@/stores/licenseCommercial', () => ({ licenseStatus: () => state.entitlements, + loadLicenseStatus: vi.fn(), startProTrial: (...args: unknown[]) => startProTrialMock(...args), })); diff --git a/frontend-modern/src/components/shared/__tests__/ActiveUseTrialNudge.test.tsx b/frontend-modern/src/components/shared/__tests__/ActiveUseTrialNudge.test.tsx index 536424e3e3..246d81d84c 100644 --- a/frontend-modern/src/components/shared/__tests__/ActiveUseTrialNudge.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/ActiveUseTrialNudge.test.tsx @@ -25,8 +25,9 @@ const { snoozeUpsellMock: vi.fn(), })); -vi.mock('@/stores/license', () => ({ +vi.mock('@/stores/licenseCommercial', () => ({ licenseStatus: (...args: unknown[]) => licenseStatusMock(...args), + loadLicenseStatus: vi.fn(), startProTrial: (...args: unknown[]) => startProTrialMock(...args), })); diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx index 736237518f..e4a244c3bc 100644 --- a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx @@ -36,12 +36,15 @@ HTMLCanvasElement.prototype.getContext = vi.fn(() => ({ })) as unknown as typeof HTMLCanvasElement.prototype.getContext; vi.mock('@/stores/license', () => ({ - getUpgradeActionDestination: () => ({ href: 'https://example.com/upgrade', external: true }), - getUpgradeActionUrlOrFallback: () => 'https://example.com/upgrade', isRangeLocked: () => false, - licenseStatus: () => ({ subscription_state: 'active' }), loadLicenseStatus: vi.fn(), maxHistoryDays: () => 30, +})); + +vi.mock('@/stores/licenseCommercial', () => ({ + getUpgradeActionDestination: () => ({ href: 'https://example.com/upgrade', external: true }), + licenseStatus: () => ({ subscription_state: 'active' }), + loadLicenseStatus: vi.fn(), startProTrial: vi.fn(), })); diff --git a/frontend-modern/src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx b/frontend-modern/src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx index 647537e025..9a34d87a83 100644 --- a/frontend-modern/src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx @@ -35,7 +35,7 @@ const mockDemoModeEnabled = vi.hoisted(() => vi.fn(() => false)); const mockGetUpgradeActionDestination = vi.hoisted(() => vi.fn()); const mockGetUpgradeActionUrlOrFallback = vi.hoisted(() => vi.fn()); -vi.mock('@/stores/license', () => ({ +vi.mock('@/stores/licenseCommercial', () => ({ entitlements: mockEntitlements, getLimit: mockGetLimit, getUpgradeActionDestination: (...args: unknown[]) => mockGetUpgradeActionDestination(...args), diff --git a/frontend-modern/src/components/shared/__tests__/TrialBanner.test.tsx b/frontend-modern/src/components/shared/__tests__/TrialBanner.test.tsx index 36d41ec276..500274ee57 100644 --- a/frontend-modern/src/components/shared/__tests__/TrialBanner.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/TrialBanner.test.tsx @@ -26,7 +26,7 @@ const { snoozeUpsellMock: vi.fn(), })); -vi.mock('@/stores/license', () => ({ +vi.mock('@/stores/licenseCommercial', () => ({ getUpgradeActionDestination: (...args: unknown[]) => getUpgradeActionDestinationMock(...args), getUpgradeActionUrlOrFallback: (...args: unknown[]) => getUpgradeActionUrlOrFallbackMock(...args), licenseStatus: (...args: unknown[]) => licenseStatusMock(...args), diff --git a/frontend-modern/src/components/shared/useActiveUseTrialNudgeState.ts b/frontend-modern/src/components/shared/useActiveUseTrialNudgeState.ts index a3ab6f204c..b84e10291c 100644 --- a/frontend-modern/src/components/shared/useActiveUseTrialNudgeState.ts +++ b/frontend-modern/src/components/shared/useActiveUseTrialNudgeState.ts @@ -1,5 +1,5 @@ import { createMemo, createSignal, onCleanup, onMount } from 'solid-js'; -import { licenseStatus } from '@/stores/license'; +import { licenseStatus, loadLicenseStatus } from '@/stores/licenseCommercial'; import { notificationStore } from '@/stores/notifications'; import { isUpsellSnoozed, snoozeUpsell } from '@/utils/snooze'; import { runStartProTrialAction } from '@/utils/trialStartAction'; @@ -38,6 +38,7 @@ export function useActiveUseTrialNudgeState() { const [startingTrial, setStartingTrial] = createSignal(false); onMount(() => { + void loadLicenseStatus(); setFirstSeen(getActiveUseTrialNudgeFirstSeenTimestamp()); const timer = window.setInterval(() => setNow(Date.now()), ACTIVE_USE_TRIAL_NUDGE_REFRESH_MS); onCleanup(() => window.clearInterval(timer)); diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts index 572cb87dd7..2b5dc68624 100644 --- a/frontend-modern/src/components/shared/useHistoryChartState.ts +++ b/frontend-modern/src/components/shared/useHistoryChartState.ts @@ -7,12 +7,15 @@ import { } from 'solid-js'; import { ChartsAPI, type HistoryTimeRange } from '@/api/charts'; import { - getUpgradeActionDestination, isRangeLocked, - licenseStatus, loadLicenseStatus, maxHistoryDays, } from '@/stores/license'; +import { + getUpgradeActionDestination, + licenseStatus, + loadLicenseStatus as loadCommercialLicenseStatus, +} from '@/stores/licenseCommercial'; import { calculateOptimalPoints } from '@/utils/downsample'; import { setupCanvasDPR } from '@/utils/canvasRenderQueue'; import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics'; @@ -76,6 +79,7 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar onMount(() => { loadLicenseStatus(); + loadCommercialLicenseStatus(); }); createEffect(() => { diff --git a/frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts b/frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts index c2bfdfe0d5..f0c6931fce 100644 --- a/frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts +++ b/frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts @@ -7,7 +7,7 @@ import { hasMigrationGap, legacyConnections, loadLicenseStatus, -} from '@/stores/license'; +} from '@/stores/licenseCommercial'; import { resolveUpgradeDestination } from '@/utils/upgradeNavigation'; import { scopeSelfHostedBillingDestination, diff --git a/frontend-modern/src/components/shared/useTrialBannerState.ts b/frontend-modern/src/components/shared/useTrialBannerState.ts index 8600b06043..d6b5157beb 100644 --- a/frontend-modern/src/components/shared/useTrialBannerState.ts +++ b/frontend-modern/src/components/shared/useTrialBannerState.ts @@ -1,6 +1,10 @@ import { createMemo, createSignal, onMount } from 'solid-js'; import { demoModeEnabled } from '@/stores/demoMode'; -import { getUpgradeActionDestination, licenseStatus, loadLicenseStatus } from '@/stores/license'; +import { + getUpgradeActionDestination, + licenseStatus, + loadLicenseStatus, +} from '@/stores/licenseCommercial'; import { isUpsellSnoozed, snoozeUpsell } from '@/utils/snooze'; import { getTrialBannerToneClass, diff --git a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts index 968645a934..fa9cff1529 100644 --- a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts +++ b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts @@ -23,11 +23,14 @@ import { notificationStore } from '@/stores/notifications'; import { hasTriggeringAlert } from '@/utils/findingAlertIdentity'; import { usePatrolStream } from '@/hooks/usePatrolStream'; import { - getUpgradeActionDestination, hasFeature, - licenseStatus, loadLicenseStatus, } from '@/stores/license'; +import { + getUpgradeActionDestination, + licenseStatus, + loadLicenseStatus as loadCommercialLicenseStatus, +} from '@/stores/licenseCommercial'; import { getCanonicalScopeResourceIds } from '@/utils/patrolFormat'; import { buildPatrolInvestigationContextSummary } from './patrolInvestigationContextModel'; import { trackPaywallViewed } from '@/utils/upgradeMetrics'; @@ -670,6 +673,7 @@ export function usePatrolIntelligenceState() { onMount(async () => { await Promise.all([ loadLicenseStatus(), + loadCommercialLicenseStatus(), loadAllData(), loadAutonomySettings(), loadModels(), diff --git a/frontend-modern/src/pages/HostedSignup.tsx b/frontend-modern/src/pages/HostedSignup.tsx index 79c1afb62d..156879a4f8 100644 --- a/frontend-modern/src/pages/HostedSignup.tsx +++ b/frontend-modern/src/pages/HostedSignup.tsx @@ -4,7 +4,7 @@ import { Card } from '@/components/shared/Card'; import { PageHeader } from '@/components/shared/PageHeader'; import { UpgradeLink } from '@/components/shared/UpgradeLink'; import { HostedSignupAPI, type HostedAPIError } from '@/api/hostedSignup'; -import { getUpgradeActionDestination } from '@/stores/license'; +import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; import { logger } from '@/utils/logger'; import { HOSTED_SIGNUP_PRESENTATION, diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index 9f15c9cfbf..7fbf36426c 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -129,8 +129,12 @@ vi.mock('@/utils/apiClient', () => ({ })); vi.mock('@/stores/license', () => ({ - getUpgradeActionDestination: (...args: unknown[]) => getUpgradeActionDestinationMock(...args), hasFeature: (...args: unknown[]) => hasFeatureMock(...args), + loadLicenseStatus: (...args: unknown[]) => loadLicenseStatusMock(...args), +})); + +vi.mock('@/stores/licenseCommercial', () => ({ + getUpgradeActionDestination: (...args: unknown[]) => getUpgradeActionDestinationMock(...args), licenseStatus: (...args: unknown[]) => licenseStatusMock(...args), loadLicenseStatus: (...args: unknown[]) => loadLicenseStatusMock(...args), startProTrial: (...args: unknown[]) => startProTrialMock(...args), diff --git a/frontend-modern/src/pages/__tests__/HostedSignup.test.tsx b/frontend-modern/src/pages/__tests__/HostedSignup.test.tsx index 3d15962a61..3765a0aff6 100644 --- a/frontend-modern/src/pages/__tests__/HostedSignup.test.tsx +++ b/frontend-modern/src/pages/__tests__/HostedSignup.test.tsx @@ -14,7 +14,7 @@ vi.mock('@/api/hostedSignup', () => ({ }, })); -vi.mock('@/stores/license', () => ({ +vi.mock('@/stores/licenseCommercial', () => ({ getUpgradeActionDestination: () => ({ href: '/cloud', external: false }), getUpgradeActionUrlOrFallback: () => 'https://cloud.pulserelay.pro', })); diff --git a/frontend-modern/src/pages/__tests__/PricingHandoff.test.tsx b/frontend-modern/src/pages/__tests__/PricingHandoff.test.tsx index 133e54dbb5..a2f43c0db8 100644 --- a/frontend-modern/src/pages/__tests__/PricingHandoff.test.tsx +++ b/frontend-modern/src/pages/__tests__/PricingHandoff.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render, screen, waitFor } from '@solidjs/testing-library'; import { Route, Router } from '@solidjs/router'; import PricingHandoff from '@/pages/PricingHandoff'; +import { getPulseAccountPortalUpgradeUrl } from '@/utils/pricingHandoff'; const trackPaywallViewedMock = vi.fn(); const handoffToExternalPricingMock = vi.fn(); @@ -34,6 +35,11 @@ describe('PricingHandoff', () => { it('hands self-hosted upgrade requests off to Pulse Account', async () => { window.history.replaceState({}, '', '/pricing?feature=relay'); + const expectedDestination = getPulseAccountPortalUpgradeUrl( + 'relay', + undefined, + 'http://localhost:3000/pricing?feature=relay', + ); render(() => ( @@ -43,14 +49,14 @@ describe('PricingHandoff', () => { await waitFor(() => { expect(handoffToExternalPricingMock).toHaveBeenCalledWith( - 'https://cloud.pulserelay.pro/portal?feature=relay&service=upgrade', + expectedDestination, ); }); expect(screen.getByRole('heading', { name: 'Redirecting to Pulse Account' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /continue to Pulse Account/i })).toHaveAttribute( 'href', - 'https://cloud.pulserelay.pro/portal?feature=relay&service=upgrade', + expectedDestination, ); }); diff --git a/frontend-modern/src/stores/__tests__/license.test.ts b/frontend-modern/src/stores/__tests__/license.test.ts index f1272966c0..df6a3292ee 100644 --- a/frontend-modern/src/stores/__tests__/license.test.ts +++ b/frontend-modern/src/stores/__tests__/license.test.ts @@ -1,43 +1,64 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { LicenseAPI, type LicenseEntitlements } from '@/api/license'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + LicenseAPI, + type LicenseCommercialEntitlements, + type LicenseRuntimeCapabilities, +} from '@/api/license'; +import { demoModeEnabled } from '@/stores/demoMode'; import { getPulseAccountPortalUpgradeUrl } from '@/utils/pricingHandoff'; import { - loadLicenseStatus, - startProTrial, - isPro, - hasFeature, - hasMigrationGap, - isMultiTenantEnabled, - isHostedModeEnabled, - getUpgradeReason, - getUpgradeActionUrl, - getFirstUpgradeActionUrl, - getUpgradeActionUrlOrFallback, getLimit, + hasFeature, + isHostedModeEnabled, + isMultiTenantEnabled, isRangeLocked, - legacyConnections, + licenseLoadError as runtimeLicenseLoadError, + licenseLoaded as runtimeLicenseLoaded, + loadLicenseStatus as loadRuntimeLicenseStatus, maxHistoryDays, - entitlements, - licenseLoaded, - licenseLoadError, + runtimeCapabilities, } from '@/stores/license'; +import { + entitlements, + getFirstUpgradeActionUrl, + getUpgradeActionUrl, + getUpgradeActionUrlOrFallback, + getUpgradeReason, + hasMigrationGap, + isPro, + legacyConnections, + licenseLoadError as commercialLicenseLoadError, + loadLicenseStatus as loadCommercialLicenseStatus, + startProTrial, +} from '@/stores/licenseCommercial'; vi.mock('@/api/license'); +vi.mock('@/stores/demoMode', () => ({ + demoModeEnabled: vi.fn(() => false), +})); vi.mock('@/stores/events', () => ({ eventBus: { on: vi.fn() }, })); -describe('license store', () => { - const mockProEntitlements: LicenseEntitlements = { - tier: 'pro' as const, - subscription_state: 'active', +describe('license stores', () => { + const mockRuntimeCapabilities: LicenseRuntimeCapabilities = { capabilities: ['feature1', 'feature2', 'multi_tenant'], limits: [{ key: 'limit1', limit: 100, current: 25, state: 'ok' }], + hosted_mode: false, + max_history_days: 90, + }; + + const mockCommercialEntitlements: LicenseCommercialEntitlements = { + tier: 'pro', + subscription_state: 'active', + capabilities: mockRuntimeCapabilities.capabilities, + limits: mockRuntimeCapabilities.limits, upgrade_reasons: [ { key: 'reason1', reason: 'Reason 1', action_url: '/upgrade/reason1' }, { key: 'reason2', reason: 'Reason 2', action_url: '/upgrade/reason2' }, ], hosted_mode: false, + max_history_days: 90, legacy_connections: { proxmox_nodes: 2, docker_hosts: 1, @@ -46,228 +67,360 @@ describe('license store', () => { has_migration_gap: true, }; - const mockFreeEntitlements: LicenseEntitlements = { - tier: 'free' as const, + const mockFreeRuntimeCapabilities: LicenseRuntimeCapabilities = { + capabilities: [], + limits: [], + hosted_mode: false, + max_history_days: 7, + }; + + const mockFreeCommercialEntitlements: LicenseCommercialEntitlements = { + tier: 'free', subscription_state: 'expired', - capabilities: [] as string[], + capabilities: [], limits: [], upgrade_reasons: [], hosted_mode: false, + max_history_days: 7, }; beforeEach(() => { vi.clearAllMocks(); + vi.mocked(demoModeEnabled).mockReturnValue(false); }); - it('loads entitlements from API', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockProEntitlements); + describe('runtime capability store', () => { + it('loads runtime capabilities from API', async () => { + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockResolvedValue(mockRuntimeCapabilities); - await loadLicenseStatus(); + await loadRuntimeLicenseStatus(); - expect(LicenseAPI.getEntitlements).toHaveBeenCalled(); - expect(entitlements()).toEqual(mockProEntitlements); - expect(licenseLoaded()).toBe(true); - }); - - it('returns early if already loaded without force', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockProEntitlements); - - await loadLicenseStatus(true); - await loadLicenseStatus(); - - expect(LicenseAPI.getEntitlements).toHaveBeenCalledTimes(1); - }); - - it('sets fallback entitlements on error', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockRejectedValue(new Error('API error')); - - await loadLicenseStatus(true); - - expect(entitlements()).toEqual({ - capabilities: ['update_alerts', 'sso', 'ai_patrol'], - limits: [], - subscription_state: 'expired', - upgrade_reasons: [], - tier: 'free', - hosted_mode: false, - trial_eligible: false, - legacy_connections: { - proxmox_nodes: 0, - docker_hosts: 0, - kubernetes_clusters: 0, - }, - has_migration_gap: false, - commercial_migration: undefined, - }); - }); - - it('sets loadError on API failure', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockRejectedValue(new Error('Network timeout')); - - await loadLicenseStatus(true); - - expect(licenseLoadError()).toBeInstanceOf(Error); - expect(licenseLoadError()?.message).toBe('Network timeout'); - }); - - it('clears loadError on successful load after failure', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockRejectedValue(new Error('Network timeout')); - await loadLicenseStatus(true); - expect(licenseLoadError()).toBeInstanceOf(Error); - - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockProEntitlements); - await loadLicenseStatus(true); - expect(licenseLoadError()).toBeNull(); - }); - - it('startProTrial throws error if trial start fails', async () => { - vi.mocked(LicenseAPI.startTrial).mockResolvedValue({ - ok: false, - status: 400, - headers: new Headers(), - json: vi.fn().mockResolvedValue({ code: 'trial_failed' }), - } as unknown as Response); - - await expect(startProTrial()).rejects.toThrow('Failed to start trial'); - }); - - it('startProTrial preserves backend error details for trial-not-available responses', async () => { - vi.mocked(LicenseAPI.startTrial).mockResolvedValue({ - ok: false, - status: 409, - headers: new Headers(), - json: vi.fn().mockResolvedValue({ - code: 'trial_not_available', - error: 'Trial cannot be started while a paid v5 license migration is pending', - details: { org_id: 'default' }, - }), - } as unknown as Response); - - await expect(startProTrial()).rejects.toMatchObject({ - status: 409, - code: 'trial_not_available', - message: 'Trial cannot be started while a paid v5 license migration is pending', - details: { org_id: 'default' }, - }); - }); - - it('startProTrial returns redirect action when hosted signup is required', async () => { - vi.mocked(LicenseAPI.startTrial).mockResolvedValue({ - ok: false, - status: 409, - json: vi.fn().mockResolvedValue({ - code: 'trial_signup_required', - details: { action_url: 'https://pulserelay.pro/pricing?intent=pro_trial' }, - }), - } as unknown as Response); - - await expect(startProTrial()).resolves.toEqual({ - outcome: 'redirect', - actionUrl: 'https://pulserelay.pro/pricing?intent=pro_trial', - }); - }); - - it('startProTrial refreshes entitlements when local activation succeeds', async () => { - vi.mocked(LicenseAPI.startTrial).mockResolvedValue({ ok: true } as Response); - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockProEntitlements); - - await expect(startProTrial()).resolves.toEqual({ outcome: 'activated' }); - expect(LicenseAPI.getEntitlements).toHaveBeenCalled(); - }); - - describe('isPro', () => { - it('returns true for pro tier', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockProEntitlements); - await loadLicenseStatus(true); - expect(isPro()).toBe(true); + expect(LicenseAPI.getRuntimeCapabilities).toHaveBeenCalled(); + expect(runtimeCapabilities()).toEqual(mockRuntimeCapabilities); + expect(runtimeLicenseLoaded()).toBe(true); }); - it('returns true for pro_plus tier', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue({ - ...mockProEntitlements, - tier: 'pro_plus', + it('returns early if already loaded without force', async () => { + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockResolvedValue(mockRuntimeCapabilities); + + await loadRuntimeLicenseStatus(true); + await loadRuntimeLicenseStatus(); + + expect(LicenseAPI.getRuntimeCapabilities).toHaveBeenCalledTimes(1); + }); + + it('sets fallback runtime capabilities on error', async () => { + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockRejectedValue(new Error('API error')); + + await loadRuntimeLicenseStatus(true); + + expect(runtimeCapabilities()).toEqual({ + capabilities: ['update_alerts', 'sso', 'ai_patrol'], + limits: [], + hosted_mode: false, + max_history_days: 7, }); - await loadLicenseStatus(true); - expect(isPro()).toBe(true); }); - it('returns false for relay tier (paid but not Pro)', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue({ - ...mockProEntitlements, - tier: 'relay', - }); - await loadLicenseStatus(true); - expect(isPro()).toBe(false); + it('sets loadError on runtime API failure', async () => { + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockRejectedValue(new Error('Network timeout')); + + await loadRuntimeLicenseStatus(true); + + expect(runtimeLicenseLoadError()).toBeInstanceOf(Error); + expect(runtimeLicenseLoadError()?.message).toBe('Network timeout'); }); - it('returns false for free tier', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockFreeEntitlements); - await loadLicenseStatus(true); - expect(isPro()).toBe(false); + it('clears runtime loadError on successful load after failure', async () => { + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockRejectedValue(new Error('Network timeout')); + await loadRuntimeLicenseStatus(true); + expect(runtimeLicenseLoadError()).toBeInstanceOf(Error); + + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockResolvedValue(mockRuntimeCapabilities); + await loadRuntimeLicenseStatus(true); + expect(runtimeLicenseLoadError()).toBeNull(); }); - it('returns false when entitlements is null', () => { - expect(isPro()).toBe(false); - }); - }); - - describe('hasFeature', () => { it('returns true when feature is in capabilities', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockProEntitlements); - await loadLicenseStatus(true); + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockResolvedValue(mockRuntimeCapabilities); + await loadRuntimeLicenseStatus(true); expect(hasFeature('feature1')).toBe(true); }); it('returns false when feature is not in capabilities', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockProEntitlements); - await loadLicenseStatus(true); + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockResolvedValue(mockRuntimeCapabilities); + await loadRuntimeLicenseStatus(true); expect(hasFeature('missing_feature')).toBe(false); }); - }); - describe('isMultiTenantEnabled', () => { it('returns true when multi_tenant feature exists', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockProEntitlements); - await loadLicenseStatus(true); + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockResolvedValue(mockRuntimeCapabilities); + await loadRuntimeLicenseStatus(true); expect(isMultiTenantEnabled()).toBe(true); }); - it('returns false when feature does not exist', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockFreeEntitlements); - await loadLicenseStatus(true); + it('returns false when multi_tenant feature does not exist', async () => { + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockResolvedValue(mockFreeRuntimeCapabilities); + await loadRuntimeLicenseStatus(true); expect(isMultiTenantEnabled()).toBe(false); }); - }); - describe('isHostedModeEnabled', () => { it('returns true when hosted_mode is true', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue({ - ...mockProEntitlements, + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockResolvedValue({ + ...mockRuntimeCapabilities, hosted_mode: true, }); - await loadLicenseStatus(true); + await loadRuntimeLicenseStatus(true); expect(isHostedModeEnabled()).toBe(true); }); + + it('returns limit by key', async () => { + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockResolvedValue(mockRuntimeCapabilities); + await loadRuntimeLicenseStatus(true); + expect(getLimit('limit1')?.limit).toBe(100); + }); + + it('returns false for ranges within free limit (7d)', async () => { + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockResolvedValue({ + ...mockFreeRuntimeCapabilities, + max_history_days: 7, + }); + await loadRuntimeLicenseStatus(true); + expect(isRangeLocked('1h')).toBe(false); + expect(isRangeLocked('7d')).toBe(false); + expect(isRangeLocked('168h')).toBe(false); + }); + + it('returns true for ranges exceeding free limit (7d)', async () => { + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockResolvedValue({ + ...mockFreeRuntimeCapabilities, + max_history_days: 7, + }); + await loadRuntimeLicenseStatus(true); + expect(isRangeLocked('8d')).toBe(true); + expect(isRangeLocked('200h')).toBe(true); + }); + + it('uses tier-specific max_history_days (relay = 14d)', async () => { + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockResolvedValue({ + ...mockRuntimeCapabilities, + max_history_days: 14, + }); + await loadRuntimeLicenseStatus(true); + expect(isRangeLocked('14d')).toBe(false); + expect(isRangeLocked('15d')).toBe(true); + expect(maxHistoryDays()).toBe(14); + }); + + it('uses tier-specific max_history_days (pro = 90d)', async () => { + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockResolvedValue(mockRuntimeCapabilities); + await loadRuntimeLicenseStatus(true); + expect(isRangeLocked('90d')).toBe(false); + expect(isRangeLocked('91d')).toBe(true); + expect(maxHistoryDays()).toBe(90); + }); + + it('defaults to 7 days when max_history_days is not set', async () => { + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockResolvedValue({ + ...mockFreeRuntimeCapabilities, + max_history_days: undefined, + }); + await loadRuntimeLicenseStatus(true); + expect(maxHistoryDays()).toBe(7); + expect(isRangeLocked('8d')).toBe(true); + }); + + it('handles invalid range strings', async () => { + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockResolvedValue(mockFreeRuntimeCapabilities); + await loadRuntimeLicenseStatus(true); + expect(isRangeLocked('invalid')).toBe(false); + }); }); - describe('getUpgradeReason', () => { + describe('commercial entitlement store', () => { + it('suppresses commercial entitlement reads in demo mode', async () => { + vi.mocked(demoModeEnabled).mockReturnValue(true); + + await loadCommercialLicenseStatus(true); + + expect(LicenseAPI.getCommercialEntitlements).not.toHaveBeenCalled(); + expect(entitlements()).toEqual({ + capabilities: ['update_alerts', 'sso', 'ai_patrol'], + limits: [], + subscription_state: 'expired', + upgrade_reasons: [], + tier: 'free', + hosted_mode: false, + trial_eligible: false, + legacy_connections: { + proxmox_nodes: 0, + docker_hosts: 0, + kubernetes_clusters: 0, + }, + has_migration_gap: false, + commercial_migration: undefined, + }); + expect(commercialLicenseLoadError()).toBeNull(); + }); + + it('loads commercial entitlements from API', async () => { + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue(mockCommercialEntitlements); + + await loadCommercialLicenseStatus(true); + + expect(LicenseAPI.getCommercialEntitlements).toHaveBeenCalled(); + expect(entitlements()).toEqual(mockCommercialEntitlements); + }); + + it('sets commercial fallback entitlements on error', async () => { + vi.mocked(LicenseAPI.getCommercialEntitlements).mockRejectedValue(new Error('API error')); + + await loadCommercialLicenseStatus(true); + + expect(entitlements()).toEqual({ + capabilities: ['update_alerts', 'sso', 'ai_patrol'], + limits: [], + subscription_state: 'expired', + upgrade_reasons: [], + tier: 'free', + hosted_mode: false, + trial_eligible: false, + legacy_connections: { + proxmox_nodes: 0, + docker_hosts: 0, + kubernetes_clusters: 0, + }, + has_migration_gap: false, + commercial_migration: undefined, + }); + }); + + it('sets commercial loadError on API failure', async () => { + vi.mocked(LicenseAPI.getCommercialEntitlements).mockRejectedValue( + new Error('Network timeout'), + ); + + await loadCommercialLicenseStatus(true); + + expect(commercialLicenseLoadError()).toBeInstanceOf(Error); + expect(commercialLicenseLoadError()?.message).toBe('Network timeout'); + }); + + it('startProTrial throws error if trial start fails', async () => { + vi.mocked(LicenseAPI.startTrial).mockResolvedValue({ + ok: false, + status: 400, + headers: new Headers(), + json: vi.fn().mockResolvedValue({ code: 'trial_failed' }), + } as unknown as Response); + + await expect(startProTrial()).rejects.toThrow('Failed to start trial'); + }); + + it('startProTrial fails closed in demo mode', async () => { + vi.mocked(demoModeEnabled).mockReturnValue(true); + + await expect(startProTrial()).rejects.toMatchObject({ + message: 'Trial activation unavailable in demo mode', + status: 404, + code: 'demo_mode_unavailable', + }); + expect(LicenseAPI.startTrial).not.toHaveBeenCalled(); + }); + + it('startProTrial preserves backend error details for trial-not-available responses', async () => { + vi.mocked(LicenseAPI.startTrial).mockResolvedValue({ + ok: false, + status: 409, + headers: new Headers(), + json: vi.fn().mockResolvedValue({ + code: 'trial_not_available', + error: 'Trial cannot be started while a paid v5 license migration is pending', + details: { org_id: 'default' }, + }), + } as unknown as Response); + + await expect(startProTrial()).rejects.toMatchObject({ + status: 409, + code: 'trial_not_available', + message: 'Trial cannot be started while a paid v5 license migration is pending', + details: { org_id: 'default' }, + }); + }); + + it('startProTrial returns redirect action when hosted signup is required', async () => { + vi.mocked(LicenseAPI.startTrial).mockResolvedValue({ + ok: false, + status: 409, + json: vi.fn().mockResolvedValue({ + code: 'trial_signup_required', + details: { action_url: 'https://pulserelay.pro/pricing?intent=pro_trial' }, + }), + } as unknown as Response); + + await expect(startProTrial()).resolves.toEqual({ + outcome: 'redirect', + actionUrl: 'https://pulserelay.pro/pricing?intent=pro_trial', + }); + }); + + it('startProTrial refreshes commercial and runtime state when local activation succeeds', async () => { + vi.mocked(LicenseAPI.startTrial).mockResolvedValue({ ok: true } as Response); + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue(mockCommercialEntitlements); + vi.mocked(LicenseAPI.getRuntimeCapabilities).mockResolvedValue(mockRuntimeCapabilities); + + await expect(startProTrial()).resolves.toEqual({ outcome: 'activated' }); + expect(LicenseAPI.getCommercialEntitlements).toHaveBeenCalled(); + expect(LicenseAPI.getRuntimeCapabilities).toHaveBeenCalled(); + }); + + it('returns true for pro tier', async () => { + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue(mockCommercialEntitlements); + await loadCommercialLicenseStatus(true); + expect(isPro()).toBe(true); + }); + + it('returns true for pro_plus tier', async () => { + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue({ + ...mockCommercialEntitlements, + tier: 'pro_plus', + }); + await loadCommercialLicenseStatus(true); + expect(isPro()).toBe(true); + }); + + it('returns false for relay tier (paid but not Pro)', async () => { + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue({ + ...mockCommercialEntitlements, + tier: 'relay', + }); + await loadCommercialLicenseStatus(true); + expect(isPro()).toBe(false); + }); + + it('returns false for free tier', async () => { + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue( + mockFreeCommercialEntitlements, + ); + await loadCommercialLicenseStatus(true); + expect(isPro()).toBe(false); + }); + it('returns upgrade reason by key', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockProEntitlements); - await loadLicenseStatus(true); + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue(mockCommercialEntitlements); + await loadCommercialLicenseStatus(true); expect(getUpgradeReason('reason2')?.key).toBe('reason2'); }); it('returns undefined for unknown key', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockProEntitlements); - await loadLicenseStatus(true); + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue(mockCommercialEntitlements); + await loadCommercialLicenseStatus(true); expect(getUpgradeReason('unknown')).toBeUndefined(); }); - }); - describe('migration helpers', () => { it('returns legacy connection counts from entitlements', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockProEntitlements); - await loadLicenseStatus(true); + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue(mockCommercialEntitlements); + await loadCommercialLicenseStatus(true); expect(legacyConnections()).toEqual({ proxmox_nodes: 2, @@ -278,12 +431,12 @@ describe('license store', () => { }); it('falls back to zero legacy counts when absent', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue({ - ...mockFreeEntitlements, + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue({ + ...mockFreeCommercialEntitlements, legacy_connections: undefined, has_migration_gap: undefined, }); - await loadLicenseStatus(true); + await loadCommercialLicenseStatus(true); expect(legacyConnections()).toEqual({ proxmox_nodes: 0, @@ -292,59 +445,59 @@ describe('license store', () => { }); expect(hasMigrationGap()).toBe(false); }); - }); - describe('getUpgradeActionUrl', () => { it('returns action URL for upgrade reason', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockProEntitlements); - await loadLicenseStatus(true); + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue(mockCommercialEntitlements); + await loadCommercialLicenseStatus(true); expect(getUpgradeActionUrl('reason1')).toBe('/upgrade/reason1'); }); - }); - describe('getFirstUpgradeActionUrl', () => { it('returns first upgrade reason URL', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockProEntitlements); - await loadLicenseStatus(true); + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue(mockCommercialEntitlements); + await loadCommercialLicenseStatus(true); expect(getFirstUpgradeActionUrl()).toBe('/upgrade/reason1'); }); - }); - describe('getUpgradeActionUrlOrFallback', () => { it('routes missing self-hosted upgrades to Pulse Account when no upgrade reasons exist', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockFreeEntitlements); - await loadLicenseStatus(true); + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue( + mockFreeCommercialEntitlements, + ); + await loadCommercialLicenseStatus(true); expect(getUpgradeActionUrlOrFallback('relay')).toBe(getPulseAccountPortalUpgradeUrl('relay')); }); it('routes monitored-system limit fallbacks to the billing upgrade arrival', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue({ - ...mockProEntitlements, + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue({ + ...mockCommercialEntitlements, upgrade_reasons: [{ key: 'reason1', reason: 'Reason 1', action_url: '/upgrade/reason1' }], }); - await loadLicenseStatus(true); + await loadCommercialLicenseStatus(true); expect(getUpgradeActionUrlOrFallback('max_monitored_systems')).toBe( '/settings/system/billing/plan?intent=max_monitored_systems', ); }); it('routes cloud fallbacks to the in-product cloud plans page', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockFreeEntitlements); - await loadLicenseStatus(true); + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue( + mockFreeCommercialEntitlements, + ); + await loadCommercialLicenseStatus(true); expect(getUpgradeActionUrlOrFallback('cloud')).toBe('/cloud'); }); it('routes generic upgrade fallbacks to Pulse Account', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockFreeEntitlements); - await loadLicenseStatus(true); + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue( + mockFreeCommercialEntitlements, + ); + await loadCommercialLicenseStatus(true); expect(getUpgradeActionUrlOrFallback('upgrade')).toBe( getPulseAccountPortalUpgradeUrl('upgrade'), ); }); it('prefers a specific monitored-system upgrade action when provided', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue({ - ...mockProEntitlements, + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue({ + ...mockCommercialEntitlements, upgrade_reasons: [ { key: 'max_monitored_systems', @@ -353,87 +506,20 @@ describe('license store', () => { }, ], }); - await loadLicenseStatus(true); + await loadCommercialLicenseStatus(true); expect(getUpgradeActionUrlOrFallback('max_monitored_systems')).toBe( '/upgrade/max-monitored-systems', ); }); it('routes unknown keys to Pulse Account as a last resort', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockFreeEntitlements); - await loadLicenseStatus(true); + vi.mocked(LicenseAPI.getCommercialEntitlements).mockResolvedValue( + mockFreeCommercialEntitlements, + ); + await loadCommercialLicenseStatus(true); expect(getUpgradeActionUrlOrFallback('feature1')).toBe( getPulseAccountPortalUpgradeUrl('feature1'), ); }); }); - - describe('getLimit', () => { - it('returns limit by key', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockProEntitlements); - await loadLicenseStatus(true); - expect(getLimit('limit1')?.limit).toBe(100); - }); - }); - - describe('isRangeLocked', () => { - it('returns false for ranges within free limit (7d)', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue({ - ...mockFreeEntitlements, - max_history_days: 7, - }); - await loadLicenseStatus(true); - expect(isRangeLocked('1h')).toBe(false); - expect(isRangeLocked('7d')).toBe(false); - expect(isRangeLocked('168h')).toBe(false); // exactly 7 days - }); - - it('returns true for ranges exceeding free limit (7d)', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue({ - ...mockFreeEntitlements, - max_history_days: 7, - }); - await loadLicenseStatus(true); - expect(isRangeLocked('8d')).toBe(true); - expect(isRangeLocked('200h')).toBe(true); // 8.3 days - }); - - it('uses tier-specific max_history_days (relay = 14d)', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue({ - ...mockProEntitlements, - tier: 'relay', - max_history_days: 14, - }); - await loadLicenseStatus(true); - expect(isRangeLocked('14d')).toBe(false); - expect(isRangeLocked('15d')).toBe(true); - expect(maxHistoryDays()).toBe(14); - }); - - it('uses tier-specific max_history_days (pro = 90d)', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue({ - ...mockProEntitlements, - max_history_days: 90, - }); - await loadLicenseStatus(true); - expect(isRangeLocked('90d')).toBe(false); - expect(isRangeLocked('91d')).toBe(true); - expect(maxHistoryDays()).toBe(90); - }); - - it('defaults to 7 days when max_history_days is not set', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue({ - ...mockFreeEntitlements, - }); - await loadLicenseStatus(true); - expect(maxHistoryDays()).toBe(7); - expect(isRangeLocked('8d')).toBe(true); - }); - - it('handles invalid range strings', async () => { - vi.mocked(LicenseAPI.getEntitlements).mockResolvedValue(mockFreeEntitlements); - await loadLicenseStatus(true); - expect(isRangeLocked('invalid')).toBe(false); - }); - }); }); diff --git a/frontend-modern/src/stores/license.ts b/frontend-modern/src/stores/license.ts index fb3a235bb6..adcbd99ca7 100644 --- a/frontend-modern/src/stores/license.ts +++ b/frontend-modern/src/stores/license.ts @@ -1,99 +1,42 @@ -import { createSignal, createMemo, createRoot } from 'solid-js'; -import { - LicenseAPI, - type EntitlementLegacyConnections, - type LicenseEntitlements, -} from '@/api/license'; +import { createSignal } from 'solid-js'; +import { LicenseAPI, type LicenseRuntimeCapabilities } from '@/api/license'; import { eventBus } from '@/stores/events'; import { logger } from '@/utils/logger'; -import { - getInProductPricingDestination, - getUpgradeFallbackDestination, -} from '@/utils/pricingHandoff'; -import { - resolveUpgradeDestination, - type UpgradeDestination, -} from '@/utils/upgradeNavigation'; -// Reactive signals for entitlements (canonical gating source). -const [entitlements, setEntitlements] = createSignal(null); +const FREE_RUNTIME_CAPABILITIES_FALLBACK: LicenseRuntimeCapabilities = { + capabilities: ['update_alerts', 'sso', 'ai_patrol'], + limits: [], + hosted_mode: false, + max_history_days: 7, +}; + +const [runtimeCapabilities, setRuntimeCapabilities] = + createSignal(null); const [loading, setLoading] = createSignal(false); const [loaded, setLoaded] = createSignal(false); const [loadError, setLoadError] = createSignal(null); -type TrialStartErrorPayload = { - error?: string; - code?: string; - details?: Record; -}; - -type TrialStartRequestError = Error & { - status: number; - code?: string; - details?: Record; - retryAfterSeconds?: number; -}; - -export type StartProTrialResult = - | { outcome: 'activated' } - | { outcome: 'redirect'; actionUrl: string }; - -function parseRetryAfterSeconds(value: string | null | undefined): number | undefined { - const normalized = value?.trim(); - if (!normalized) return undefined; - - const parsed = Number(normalized); - if (Number.isFinite(parsed) && parsed > 0) { - return Math.ceil(parsed); - } - - const retryAt = Date.parse(normalized); - if (Number.isNaN(retryAt)) return undefined; - - const waitMs = retryAt - Date.now(); - if (waitMs <= 0) return 1; - return Math.ceil(waitMs / 1000); -} - /** - * Load the entitlements payload from the server. - * - * This is the canonical feature-gating source (do not gate on tier strings). + * Load the canonical runtime capability payload from the server. */ export async function loadLicenseStatus(force = false): Promise { if (loaded() && !force) return; setLoading(true); try { - const next = await LicenseAPI.getEntitlements(); - setEntitlements(next); + const next = await LicenseAPI.getRuntimeCapabilities(); + setRuntimeCapabilities(next); setLoadError(null); setLoaded(true); - logger.debug('[licenseStore] Entitlements loaded', { - tier: next.tier, - sub_state: next.subscription_state, + logger.debug('[licenseStore] Runtime capabilities loaded', { + capability_count: next.capabilities.length, + limit_count: next.limits.length, + max_history_days: next.max_history_days, }); } catch (err) { - logger.error('[licenseStore] Failed to load entitlements', err); + logger.error('[licenseStore] Failed to load runtime capabilities', err); setLoadError(err instanceof Error ? err : new Error(String(err))); - // Best-effort fallback to avoid breaking the UI. - setEntitlements({ - capabilities: ['update_alerts', 'sso', 'ai_patrol'], - limits: [], - // Match backend behavior when no license is present. - subscription_state: 'expired', - upgrade_reasons: [], - tier: 'free', - hosted_mode: false, - trial_eligible: false, - legacy_connections: { - proxmox_nodes: 0, - docker_hosts: 0, - kubernetes_clusters: 0, - }, - has_migration_gap: false, - commercial_migration: undefined, - }); + setRuntimeCapabilities(FREE_RUNTIME_CAPABILITIES_FALLBACK); setLoaded(true); } finally { setLoading(false); @@ -101,69 +44,10 @@ export async function loadLicenseStatus(force = false): Promise { } /** - * Start a Pro trial for the current org, then refresh entitlements. - */ -export async function startProTrial(): Promise { - const res = await LicenseAPI.startTrial(); - if (!res.ok) { - let payload: TrialStartErrorPayload | null = null; - try { - payload = (await res.json()) as TrialStartErrorPayload; - } catch { - payload = null; - } - - if (res.status === 409 && payload?.code === 'trial_signup_required') { - const actionUrl = - payload.details?.action_url || - getFirstUpgradeActionUrl() || - getUpgradeActionUrlOrFallback('relay'); - return { outcome: 'redirect', actionUrl }; - } - - const err = new Error( - payload?.error?.trim() || `Failed to start trial (status ${res.status})`, - ) as TrialStartRequestError; - const retryAfterSeconds = - parseRetryAfterSeconds(res.headers.get('Retry-After')) ?? - parseRetryAfterSeconds(payload?.details?.retry_after_seconds); - err.status = res.status; - err.code = payload?.code; - err.details = payload?.details; - err.retryAfterSeconds = retryAfterSeconds; - throw err; - } - await loadLicenseStatus(true); - return { outcome: 'activated' }; -} - -/** Tiers that qualify as "Pro" for UI gating (support link, Pro badges, etc.). */ -const PRO_TIERS = new Set([ - 'pro', - 'pro_annual', - 'pro_plus', - 'lifetime', - 'cloud', - 'msp', - 'enterprise', -]); - -/** - * Helper to check if the current license is Pulse Pro (Pro tier or above). - * Relay is a paid tier but does NOT count as Pro. - */ -export const isPro = createRoot(() => - createMemo(() => { - const current = entitlements(); - return Boolean(current && PRO_TIERS.has(current.tier)); - }), -); - -/** - * Check if a specific feature is enabled by the current license. + * Check if a specific feature is enabled by the current license/runtime. */ export function hasFeature(feature: string): boolean { - const current = entitlements(); + const current = runtimeCapabilities(); if (!current) return false; return current.capabilities.includes(feature); } @@ -173,58 +57,16 @@ export function isMultiTenantEnabled(): boolean { } export function isHostedModeEnabled(): boolean { - return Boolean(entitlements()?.hosted_mode); -} - -export function getUpgradeReason(key: string) { - const current = entitlements(); - if (!current?.upgrade_reasons?.length) return undefined; - return current.upgrade_reasons.find((reason) => reason.key === key); -} - -export function getUpgradeActionUrl(key: string): string | undefined { - return getUpgradeReason(key)?.action_url; -} - -export function getFirstUpgradeActionUrl(): string | undefined { - const current = entitlements(); - return current?.upgrade_reasons?.[0]?.action_url; -} - -export function getUpgradeActionDestination(key: string): UpgradeDestination { - return resolveUpgradeDestination( - getUpgradeActionUrl(key) || - getInProductPricingDestination(key) || - getFirstUpgradeActionUrl() || - getUpgradeFallbackDestination(key), - ); -} - -export function getUpgradeActionUrlOrFallback(key: string): string { - return getUpgradeActionDestination(key).href; + return Boolean(runtimeCapabilities()?.hosted_mode); } export function getLimit(key: string) { - const current = entitlements(); + const current = runtimeCapabilities(); if (!current?.limits?.length) return undefined; return current.limits.find((limit) => limit.key === key); } -export function legacyConnections(): EntitlementLegacyConnections { - return ( - entitlements()?.legacy_connections ?? { - proxmox_nodes: 0, - docker_hosts: 0, - kubernetes_clusters: 0, - } - ); -} - -export function hasMigrationGap(): boolean { - return Boolean(entitlements()?.has_migration_gap); -} - -/** Default max history days when entitlements aren't loaded yet. */ +/** Default max history days when runtime capabilities aren't loaded yet. */ const DEFAULT_MAX_HISTORY_DAYS = 7; function parseRangeDays(range: string): number { @@ -235,35 +77,29 @@ function parseRangeDays(range: string): number { } /** - * Return the tier-specific max history days from the entitlements payload. - * Falls back to DEFAULT_MAX_HISTORY_DAYS when entitlements aren't loaded. + * Return the runtime max history days from the canonical capability payload. */ export function maxHistoryDays(): number { - return entitlements()?.max_history_days ?? DEFAULT_MAX_HISTORY_DAYS; + return runtimeCapabilities()?.max_history_days ?? DEFAULT_MAX_HISTORY_DAYS; } /** - * Check if a time range exceeds the current tier's history limit. - * The limit is tier-aware (Free=7d, Relay=14d, Pro/Pro+=90d) via `max_history_days`. + * Check if a time range exceeds the current runtime history limit. */ export function isRangeLocked(range: string): boolean { return parseRangeDays(range) > maxHistoryDays(); } -// Ensure org-scoped entitlements do not leak across tenant switches. +// Ensure org-scoped runtime capabilities do not leak across tenant switches. eventBus.on('org_switched', () => { - setEntitlements(null); + setRuntimeCapabilities(null); setLoaded(false); setLoadError(null); void loadLicenseStatus(true); }); -/** - * Get the full license status. - */ export { - entitlements, - entitlements as licenseStatus, + runtimeCapabilities, loading as licenseLoading, loaded as licenseLoaded, loadError as licenseLoadError, diff --git a/frontend-modern/src/stores/licenseCommercial.ts b/frontend-modern/src/stores/licenseCommercial.ts new file mode 100644 index 0000000000..c944ed068e --- /dev/null +++ b/frontend-modern/src/stores/licenseCommercial.ts @@ -0,0 +1,240 @@ +import { createSignal, createMemo, createRoot } from 'solid-js'; +import { + LicenseAPI, + type EntitlementLegacyConnections, + type LicenseCommercialEntitlements, +} from '@/api/license'; +import { demoModeEnabled } from '@/stores/demoMode'; +import { eventBus } from '@/stores/events'; +import { logger } from '@/utils/logger'; +import { + getInProductPricingDestination, + getUpgradeFallbackDestination, +} from '@/utils/pricingHandoff'; +import { + resolveUpgradeDestination, + type UpgradeDestination, +} from '@/utils/upgradeNavigation'; +import { loadLicenseStatus as loadRuntimeLicenseStatus } from '@/stores/license'; + +const FREE_COMMERCIAL_ENTITLEMENTS_FALLBACK: LicenseCommercialEntitlements = { + capabilities: ['update_alerts', 'sso', 'ai_patrol'], + limits: [], + subscription_state: 'expired', + upgrade_reasons: [], + tier: 'free', + hosted_mode: false, + trial_eligible: false, + legacy_connections: { + proxmox_nodes: 0, + docker_hosts: 0, + kubernetes_clusters: 0, + }, + has_migration_gap: false, + commercial_migration: undefined, +}; + +const [commercialEntitlements, setCommercialEntitlements] = + createSignal(null); +const [loading, setLoading] = createSignal(false); +const [loaded, setLoaded] = createSignal(false); +const [loadError, setLoadError] = createSignal(null); + +type TrialStartErrorPayload = { + error?: string; + code?: string; + details?: Record; +}; + +type TrialStartRequestError = Error & { + status: number; + code?: string; + details?: Record; + retryAfterSeconds?: number; +}; + +export type StartProTrialResult = + | { outcome: 'activated' } + | { outcome: 'redirect'; actionUrl: string }; + +function parseRetryAfterSeconds(value: string | null | undefined): number | undefined { + const normalized = value?.trim(); + if (!normalized) return undefined; + + const parsed = Number(normalized); + if (Number.isFinite(parsed) && parsed > 0) { + return Math.ceil(parsed); + } + + const retryAt = Date.parse(normalized); + if (Number.isNaN(retryAt)) return undefined; + + const waitMs = retryAt - Date.now(); + if (waitMs <= 0) return 1; + return Math.ceil(waitMs / 1000); +} + +/** + * Load the commercial entitlement payload used by billing, trial, and upgrade UI. + */ +export async function loadLicenseStatus(force = false): Promise { + if (demoModeEnabled()) { + setCommercialEntitlements(FREE_COMMERCIAL_ENTITLEMENTS_FALLBACK); + setLoadError(null); + setLoaded(true); + setLoading(false); + logger.debug('[licenseCommercialStore] Commercial entitlements suppressed in demo mode'); + return; + } + + if (loaded() && !force) return; + + setLoading(true); + try { + const next = await LicenseAPI.getCommercialEntitlements(); + setCommercialEntitlements(next); + setLoadError(null); + setLoaded(true); + logger.debug('[licenseCommercialStore] Commercial entitlements loaded', { + tier: next.tier, + sub_state: next.subscription_state, + }); + } catch (err) { + logger.error('[licenseCommercialStore] Failed to load commercial entitlements', err); + setLoadError(err instanceof Error ? err : new Error(String(err))); + setCommercialEntitlements(FREE_COMMERCIAL_ENTITLEMENTS_FALLBACK); + setLoaded(true); + } finally { + setLoading(false); + } +} + +/** + * Start a Pro trial for the current org, then refresh both runtime and commercial state. + */ +export async function startProTrial(): Promise { + if (demoModeEnabled()) { + const err = new Error('Trial activation unavailable in demo mode') as TrialStartRequestError; + err.status = 404; + err.code = 'demo_mode_unavailable'; + throw err; + } + + const res = await LicenseAPI.startTrial(); + if (!res.ok) { + let payload: TrialStartErrorPayload | null = null; + try { + payload = (await res.json()) as TrialStartErrorPayload; + } catch { + payload = null; + } + + if (res.status === 409 && payload?.code === 'trial_signup_required') { + const actionUrl = + payload.details?.action_url || + getFirstUpgradeActionUrl() || + getUpgradeActionUrlOrFallback('relay'); + return { outcome: 'redirect', actionUrl }; + } + + const err = new Error( + payload?.error?.trim() || `Failed to start trial (status ${res.status})`, + ) as TrialStartRequestError; + const retryAfterSeconds = + parseRetryAfterSeconds(res.headers.get('Retry-After')) ?? + parseRetryAfterSeconds(payload?.details?.retry_after_seconds); + err.status = res.status; + err.code = payload?.code; + err.details = payload?.details; + err.retryAfterSeconds = retryAfterSeconds; + throw err; + } + await Promise.all([loadLicenseStatus(true), loadRuntimeLicenseStatus(true)]); + return { outcome: 'activated' }; +} + +const PRO_TIERS = new Set([ + 'pro', + 'pro_annual', + 'pro_plus', + 'lifetime', + 'cloud', + 'msp', + 'enterprise', +]); + +/** + * Helper to check if the current commercial entitlement qualifies as Pro. + * Relay is a paid tier but does not count as Pro. + */ +export const isPro = createRoot(() => + createMemo(() => { + const current = commercialEntitlements(); + return Boolean(current && PRO_TIERS.has(current.tier)); + }), +); + +export function getUpgradeReason(key: string) { + const current = commercialEntitlements(); + if (!current?.upgrade_reasons?.length) return undefined; + return current.upgrade_reasons.find((reason) => reason.key === key); +} + +export function getUpgradeActionUrl(key: string): string | undefined { + return getUpgradeReason(key)?.action_url; +} + +export function getFirstUpgradeActionUrl(): string | undefined { + const current = commercialEntitlements(); + return current?.upgrade_reasons?.[0]?.action_url; +} + +export function getUpgradeActionDestination(key: string): UpgradeDestination { + return resolveUpgradeDestination( + getUpgradeActionUrl(key) || + getInProductPricingDestination(key) || + getFirstUpgradeActionUrl() || + getUpgradeFallbackDestination(key), + ); +} + +export function getUpgradeActionUrlOrFallback(key: string): string { + return getUpgradeActionDestination(key).href; +} + +export function getLimit(key: string) { + const current = commercialEntitlements(); + if (!current?.limits?.length) return undefined; + return current.limits.find((limit) => limit.key === key); +} + +export function legacyConnections(): EntitlementLegacyConnections { + return ( + commercialEntitlements()?.legacy_connections ?? { + proxmox_nodes: 0, + docker_hosts: 0, + kubernetes_clusters: 0, + } + ); +} + +export function hasMigrationGap(): boolean { + return Boolean(commercialEntitlements()?.has_migration_gap); +} + +// Ensure org-scoped commercial entitlements do not leak across tenant switches. +eventBus.on('org_switched', () => { + setCommercialEntitlements(null); + setLoaded(false); + setLoadError(null); + void loadLicenseStatus(true); +}); + +export { + commercialEntitlements, + commercialEntitlements as entitlements, + commercialEntitlements as licenseStatus, + loading as licenseLoading, + loaded as licenseLoaded, + loadError as licenseLoadError, +}; diff --git a/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts b/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts index bd68a0cef9..20b6012bd1 100644 --- a/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts +++ b/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + getSelfHostedPurchaseReturnUrl, getSelfHostedBillingHref, getSelfHostedBillingPlanIntent, getSelfHostedBillingUsageDetail, @@ -18,6 +19,7 @@ import { SELF_HOSTED_PRO_BILLING_USAGE_COUNTING_RULES_HREF, SELF_HOSTED_PRO_BILLING_USAGE_HREF, SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT, + SELF_HOSTED_PURCHASE_RETURN_PATH, } from '@/utils/pricingHandoff'; describe('pricingHandoff', () => { @@ -37,6 +39,12 @@ describe('pricingHandoff', () => { ); }); + it('derives the canonical self-hosted purchase return URL from the app origin', () => { + expect(getSelfHostedPurchaseReturnUrl('https://pulse.example.com/settings/system/billing/plan')).toBe( + `https://pulse.example.com${SELF_HOSTED_PURCHASE_RETURN_PATH}`, + ); + }); + it('returns the canonical public pricing URL when no feature is provided', () => { expect(getPublicPricingUrl()).toBe( 'https://pulserelay.pro/pricing?utm_source=pulse&utm_medium=app&utm_campaign=upgrade', @@ -136,4 +144,17 @@ describe('pricingHandoff', () => { expect(isExternalPricingDestination(getPulseAccountPortalUpgradeUrl('relay'))).toBe(true); expect(isExternalPricingDestination('/cloud')).toBe(false); }); + + it('adds the product return URL to Pulse Account upgrade links when the app origin is known', () => { + const withOrigin = getPulseAccountPortalUpgradeUrl( + 'relay', + undefined, + 'https://pulse.example.com/settings/system/billing/plan', + ); + expect(withOrigin).toContain('feature=relay'); + expect(withOrigin).toContain('service=upgrade'); + expect(withOrigin).toContain( + 'return_url=' + encodeURIComponent(`https://pulse.example.com${SELF_HOSTED_PURCHASE_RETURN_PATH}`), + ); + }); }); diff --git a/frontend-modern/src/utils/__tests__/trialStartAction.test.ts b/frontend-modern/src/utils/__tests__/trialStartAction.test.ts index 9ff0f1332a..a69d85c6b3 100644 --- a/frontend-modern/src/utils/__tests__/trialStartAction.test.ts +++ b/frontend-modern/src/utils/__tests__/trialStartAction.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const startProTrialMock = vi.hoisted(() => vi.fn()); -vi.mock('@/stores/license', () => ({ +vi.mock('@/stores/licenseCommercial', () => ({ startProTrial: (...args: unknown[]) => startProTrialMock(...args), })); diff --git a/frontend-modern/src/utils/pricingHandoff.ts b/frontend-modern/src/utils/pricingHandoff.ts index 367816834e..a59acadb19 100644 --- a/frontend-modern/src/utils/pricingHandoff.ts +++ b/frontend-modern/src/utils/pricingHandoff.ts @@ -7,6 +7,8 @@ const DEFAULT_PUBLIC_PRICING_URL = 'https://pulserelay.pro/pricing?utm_source=pulse&utm_medium=app&utm_campaign=upgrade'; const DEFAULT_PULSE_ACCOUNT_PORTAL_URL = 'https://cloud.pulserelay.pro/portal'; const PULSE_ACCOUNT_PORTAL_UPGRADE_SERVICE = 'upgrade'; +export const SELF_HOSTED_PURCHASE_RETURN_PATH = '/auth/license-purchase-activate'; +export const PULSE_ACCOUNT_PORTAL_RETURN_URL_QUERY_PARAM = 'return_url'; export const SELF_HOSTED_PRO_BILLING_ROUTE = '/settings/system/billing'; export const SELF_HOSTED_PRO_BILLING_PLAN_ROUTE = `${SELF_HOSTED_PRO_BILLING_ROUTE}/plan`; @@ -74,6 +76,33 @@ function normalizeFeatureKey(feature: string | null | undefined): string | undef return normalized ? normalized : undefined; } +function resolvePulseProductOrigin(locationHref?: string): string | undefined { + const candidateHref = + typeof locationHref === 'string' && locationHref.trim() + ? locationHref + : typeof window !== 'undefined' + ? window.location.href + : undefined; + if (!candidateHref) return undefined; + + try { + return new URL(candidateHref).origin; + } catch { + return undefined; + } +} + +export function getSelfHostedPurchaseReturnUrl(locationHref?: string): string | undefined { + const origin = resolvePulseProductOrigin(locationHref); + if (!origin) return undefined; + + try { + return new URL(SELF_HOSTED_PURCHASE_RETURN_PATH, origin).toString(); + } catch { + return undefined; + } +} + export function getInProductPricingDestination( feature: string | null | undefined, ): string | undefined { @@ -94,6 +123,8 @@ export function getPublicPricingUrl(feature?: string | null): string { export function getPulseAccountPortalUrl(options: { email?: string | null; feature?: string | null; + locationHref?: string; + returnUrl?: string | null; service?: string | null; searchParams?: URLSearchParams; } = {}): string { @@ -122,15 +153,22 @@ export function getPulseAccountPortalUrl(options: { url.searchParams.set('service', normalizedService); } + const normalizedReturnUrl = options.returnUrl?.trim() || getSelfHostedPurchaseReturnUrl(options.locationHref); + if (normalizedReturnUrl) { + url.searchParams.set(PULSE_ACCOUNT_PORTAL_RETURN_URL_QUERY_PARAM, normalizedReturnUrl); + } + return url.toString(); } export function getPulseAccountPortalUpgradeUrl( feature?: string | null, searchParams?: URLSearchParams, + locationHref?: string, ): string { return getPulseAccountPortalUrl({ feature, + locationHref, searchParams, service: PULSE_ACCOUNT_PORTAL_UPGRADE_SERVICE, }); diff --git a/frontend-modern/src/utils/trialStartAction.ts b/frontend-modern/src/utils/trialStartAction.ts index 3a3f4eeefc..d9ed72c59d 100644 --- a/frontend-modern/src/utils/trialStartAction.ts +++ b/frontend-modern/src/utils/trialStartAction.ts @@ -1,4 +1,4 @@ -import { startProTrial } from '@/stores/license'; +import { startProTrial } from '@/stores/licenseCommercial'; import { getProTrialStartedMessage, getTrialStartErrorMessage } from '@/utils/upgradePresentation'; export type StartProTrialActionOutcome = 'activated' | 'redirect' | 'error'; diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 88e695389e..a51799454e 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -4824,6 +4824,7 @@ func TestContract_DemoModeCommercialSurfacePolicy(t *testing.T) { }{ {method: http.MethodGet, path: "/api/license/status"}, {method: http.MethodGet, path: "/api/license/features"}, + {method: http.MethodGet, path: "/api/license/entitlements"}, {method: http.MethodPost, path: "/api/license/activate"}, {method: http.MethodPost, path: "/api/license/clear"}, {method: http.MethodPost, path: "/api/license/trial/start"}, @@ -4845,10 +4846,11 @@ func TestContract_DemoModeCommercialSurfacePolicy(t *testing.T) { } }) - t.Run("entitlements are redacted instead of hidden", func(t *testing.T) { + t.Run("runtime capabilities stay available but sanitize public demo limit details", func(t *testing.T) { t.Setenv("PULSE_LICENSE_DEV_MODE", "true") handlers := createTestHandler(t) + handlers.SetConfig(&config.Config{DemoMode: true}) licenseKey, err := pkglicensing.GenerateLicenseForTesting("contract-demo@example.com", pkglicensing.TierPro, 24*time.Hour) if err != nil { t.Fatalf("GenerateLicenseForTesting: %v", err) @@ -4857,32 +4859,19 @@ func TestContract_DemoModeCommercialSurfacePolicy(t *testing.T) { t.Fatalf("Activate() error = %v", err) } - req := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil) - req = withPublicDemoCommercialContext(req, publicDemoCommercialExposureRedacted) + req := httptest.NewRequest(http.MethodGet, "/api/license/runtime-capabilities", nil) rec := httptest.NewRecorder() - handlers.HandleEntitlements(rec, req) + handlers.HandleRuntimeCapabilities(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) } - var payload EntitlementPayload + var payload RuntimeCapabilitiesPayload if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { t.Fatalf("decode payload: %v", err) } if len(payload.Capabilities) == 0 { - t.Fatalf("expected sanitized entitlements to preserve capabilities, got %+v", payload) - } - if payload.LicensedEmail != "" { - t.Fatalf("licensed_email=%q, want empty", payload.LicensedEmail) - } - if payload.Tier != string(pkglicensing.TierFree) { - t.Fatalf("tier=%q, want %q", payload.Tier, pkglicensing.TierFree) - } - if payload.SubscriptionState != string(pkglicensing.SubStateActive) { - t.Fatalf("subscription_state=%q, want %q", payload.SubscriptionState, pkglicensing.SubStateActive) - } - if len(payload.UpgradeReasons) != 0 { - t.Fatalf("upgrade_reasons=%v, want empty", payload.UpgradeReasons) + t.Fatalf("expected sanitized runtime capabilities to preserve capabilities, got %+v", payload) } for _, limit := range payload.Limits { if limit.Limit != 0 || limit.Current != 0 || limit.State != "ok" { diff --git a/internal/api/demo_middleware.go b/internal/api/demo_middleware.go index 776fd2cb1d..e006d7dacc 100644 --- a/internal/api/demo_middleware.go +++ b/internal/api/demo_middleware.go @@ -21,12 +21,9 @@ func DemoModeMiddleware(cfg *config.Config, next http.Handler) http.Handler { w.Header().Set("X-Demo-Mode", "true") if exposure, ok := publicDemoCommercialPolicyForRequest(r); ok { - switch exposure { - case publicDemoCommercialExposureHidden: + if exposure == publicDemoCommercialExposureHidden { http.NotFound(w, r) return - case publicDemoCommercialExposureRedacted: - r = withPublicDemoCommercialContext(r, exposure) } } diff --git a/internal/api/demo_middleware_test.go b/internal/api/demo_middleware_test.go index 0344629420..2511d2b555 100644 --- a/internal/api/demo_middleware_test.go +++ b/internal/api/demo_middleware_test.go @@ -53,10 +53,12 @@ func TestDemoModeMiddleware(t *testing.T) { {"demo on oidc provider callback", true, http.MethodPost, "/api/oidc/acme/callback", "", true, http.StatusOK, true}, {"demo on logout", true, http.MethodPost, "/api/logout", "", true, http.StatusOK, true}, - // Demo mode enabled - commercial surfaces are hidden or redacted centrally + // Demo mode enabled - commercial surfaces are hidden centrally {"demo on hidden license status", true, http.MethodGet, "/api/license/status", "", false, http.StatusNotFound, true}, + {"demo on hidden license entitlements", true, http.MethodGet, "/api/license/entitlements", "", false, http.StatusNotFound, true}, {"demo on hidden license activate", true, http.MethodPost, "/api/license/activate", "", false, http.StatusNotFound, true}, {"demo on hidden trial activation", true, http.MethodGet, "/auth/trial-activate", "", false, http.StatusNotFound, true}, + {"demo on runtime capabilities", true, http.MethodGet, "/api/license/runtime-capabilities", "", true, http.StatusOK, true}, // Demo mode enabled - modification requests blocked {"demo on POST", true, http.MethodPost, "/api/users", "", false, http.StatusForbidden, true}, @@ -113,28 +115,6 @@ func TestDemoModeMiddleware(t *testing.T) { } } -func TestDemoModeMiddleware_RedactsCommercialEntitlements(t *testing.T) { - nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !isPublicDemoCommercialRedactedRequest(r) { - t.Fatal("expected demo middleware to mark entitlements request as redacted") - } - w.WriteHeader(http.StatusNoContent) - }) - - middleware := DemoModeMiddleware(&config.Config{DemoMode: true}, nextHandler) - - req := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil) - rr := httptest.NewRecorder() - middleware.ServeHTTP(rr, req) - - if rr.Code != http.StatusNoContent { - t.Fatalf("status = %d, want %d", rr.Code, http.StatusNoContent) - } - if rr.Header().Get("X-Demo-Mode") != "true" { - t.Fatalf("X-Demo-Mode header = %q, want %q", rr.Header().Get("X-Demo-Mode"), "true") - } -} - func TestDemoModeMiddleware_BlockedResponse(t *testing.T) { // Verify the error response format when a request is blocked cfg := &config.Config{ diff --git a/internal/api/demo_mode_commercial.go b/internal/api/demo_mode_commercial.go index 2229ed382e..81c4ab3c33 100644 --- a/internal/api/demo_mode_commercial.go +++ b/internal/api/demo_mode_commercial.go @@ -1,22 +1,16 @@ package api import ( - "context" "net/http" "strings" - - pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" ) type publicDemoCommercialExposure string const ( - publicDemoCommercialExposureHidden publicDemoCommercialExposure = "hidden" - publicDemoCommercialExposureRedacted publicDemoCommercialExposure = "redacted" + publicDemoCommercialExposureHidden publicDemoCommercialExposure = "hidden" ) -type publicDemoCommercialContextKey struct{} - type publicDemoCommercialRoutePolicy struct { route string exposure publicDemoCommercialExposure @@ -46,7 +40,7 @@ var publicDemoCommercialPolicies = []publicDemoCommercialRoutePolicy{ }, { route: "GET /api/license/entitlements", - exposure: publicDemoCommercialExposureRedacted, + exposure: publicDemoCommercialExposureHidden, matches: exactDemoCommercialMethodPath(http.MethodGet, "/api/license/entitlements"), }, { @@ -117,31 +111,6 @@ func publicDemoCommercialPolicyForRequest( return "", false } -func withPublicDemoCommercialContext( - r *http.Request, - exposure publicDemoCommercialExposure, -) *http.Request { - if r == nil || exposure != publicDemoCommercialExposureRedacted { - return r - } - - ctx := context.WithValue( - r.Context(), - publicDemoCommercialContextKey{}, - exposure, - ) - return r.WithContext(ctx) -} - -func isPublicDemoCommercialRedactedRequest(r *http.Request) bool { - if r == nil { - return false - } - - exposure, _ := r.Context().Value(publicDemoCommercialContextKey{}).(publicDemoCommercialExposure) - return exposure == publicDemoCommercialExposureRedacted -} - func publicDemoCommercialRouteInventory() []string { routes := make([]string, 0, len(publicDemoCommercialPolicies)) for _, policy := range publicDemoCommercialPolicies { @@ -150,35 +119,18 @@ func publicDemoCommercialRouteInventory() []string { return routes } -func sanitizeEntitlementPayloadForPublicDemo(payload EntitlementPayload) EntitlementPayload { +func sanitizeRuntimeCapabilitiesPayloadForPublicDemo( + payload RuntimeCapabilitiesPayload, +) RuntimeCapabilitiesPayload { sanitized := payload - sanitized.Limits = sanitizeEntitlementLimitsForPublicDemo(payload.Limits) - sanitized.SubscriptionState = string(pkglicensing.SubStateActive) - sanitized.UpgradeReasons = []UpgradeReason{} - sanitized.PlanVersion = "" - sanitized.Tier = string(pkglicensing.TierFree) - sanitized.TrialExpiresAt = nil - sanitized.TrialDaysRemaining = nil - sanitized.Valid = false - sanitized.LicensedEmail = "" - sanitized.ExpiresAt = nil - sanitized.IsLifetime = false - sanitized.DaysRemaining = 0 - sanitized.InGracePeriod = false - sanitized.GracePeriodEnd = nil - sanitized.TrialEligible = false - sanitized.TrialEligibilityReason = "" - sanitized.OverflowDaysRemaining = nil - sanitized.LegacyConnections = legacyConnectionCountsModel{} - sanitized.HasMigrationGap = false - sanitized.CommercialMigration = nil + sanitized.Limits = sanitizeLimitStatusesForPublicDemo(payload.Limits) if sanitized.Capabilities == nil { sanitized.Capabilities = []string{} } return sanitized } -func sanitizeEntitlementLimitsForPublicDemo(limits []LimitStatus) []LimitStatus { +func sanitizeLimitStatusesForPublicDemo(limits []LimitStatus) []LimitStatus { if len(limits) == 0 { return []LimitStatus{} } diff --git a/internal/api/demo_mode_commercial_test.go b/internal/api/demo_mode_commercial_test.go index 268c4d5b75..797fc5020a 100644 --- a/internal/api/demo_mode_commercial_test.go +++ b/internal/api/demo_mode_commercial_test.go @@ -32,14 +32,8 @@ func TestPublicDemoCommercialRouteInventoryCoverage(t *testing.T) { } } -func TestSanitizeEntitlementPayloadForPublicDemo(t *testing.T) { - trialExpiresAt := int64(1_746_892_800) - trialDaysRemaining := 7 - expiresAt := "2026-05-05T00:00:00Z" - gracePeriodEnd := "2026-05-12T00:00:00Z" - overflowDaysRemaining := 11 - - sanitized := sanitizeEntitlementPayloadForPublicDemo(EntitlementPayload{ +func TestSanitizeRuntimeCapabilitiesPayloadForPublicDemo(t *testing.T) { + sanitized := sanitizeRuntimeCapabilitiesPayloadForPublicDemo(RuntimeCapabilitiesPayload{ Capabilities: []string{"relay", "ai_patrol"}, Limits: []LimitStatus{ { @@ -49,33 +43,8 @@ func TestSanitizeEntitlementPayloadForPublicDemo(t *testing.T) { State: "enforced", }, }, - SubscriptionState: string(subscriptionStateTrialValue), - UpgradeReasons: []UpgradeReason{{Key: "relay", Reason: "Upgrade", ActionURL: "/pricing"}}, - PlanVersion: "pro_monthly", - Tier: "pro", - TrialExpiresAt: &trialExpiresAt, - TrialDaysRemaining: &trialDaysRemaining, - HostedMode: true, - Valid: true, - LicensedEmail: "owner@example.com", - ExpiresAt: &expiresAt, - IsLifetime: true, - DaysRemaining: 30, - InGracePeriod: true, - GracePeriodEnd: &gracePeriodEnd, - TrialEligible: true, - TrialEligibilityReason: "eligible", - MaxHistoryDays: 90, - OverflowDaysRemaining: &overflowDaysRemaining, - LegacyConnections: legacyConnectionCountsModel{ - ProxmoxNodes: 1, - DockerHosts: 2, - KubernetesClusters: 3, - }, - HasMigrationGap: true, - CommercialMigration: &commercialMigrationStatusModel{ - State: "failed", - }, + HostedMode: true, + MaxHistoryDays: 90, }) if len(sanitized.Capabilities) != 2 { @@ -87,48 +56,17 @@ func TestSanitizeEntitlementPayloadForPublicDemo(t *testing.T) { if sanitized.Limits[0].Limit != 0 || sanitized.Limits[0].Current != 0 || sanitized.Limits[0].State != "ok" { t.Fatalf("sanitized limit=%+v, want limit=0 current=0 state=ok", sanitized.Limits[0]) } - if sanitized.SubscriptionState != string(subscriptionStateActiveValue) { - t.Fatalf("subscription_state=%q, want %q", sanitized.SubscriptionState, subscriptionStateActiveValue) - } - if len(sanitized.UpgradeReasons) != 0 { - t.Fatalf("upgrade_reasons=%v, want empty", sanitized.UpgradeReasons) - } - if sanitized.PlanVersion != "" || sanitized.Tier != "free" { - t.Fatalf("plan/tier=(%q,%q), want empty/free", sanitized.PlanVersion, sanitized.Tier) - } - if sanitized.TrialExpiresAt != nil || sanitized.TrialDaysRemaining != nil { - t.Fatalf("trial fields should be cleared, got expires=%v days=%v", sanitized.TrialExpiresAt, sanitized.TrialDaysRemaining) - } - if sanitized.Valid { - t.Fatal("valid should be false in sanitized public demo entitlements") - } - if sanitized.LicensedEmail != "" || sanitized.ExpiresAt != nil || sanitized.GracePeriodEnd != nil { - t.Fatalf("commercial identity should be cleared, got email=%q expires=%v grace_end=%v", sanitized.LicensedEmail, sanitized.ExpiresAt, sanitized.GracePeriodEnd) - } - if sanitized.IsLifetime || sanitized.DaysRemaining != 0 || sanitized.InGracePeriod { - t.Fatalf("lifecycle display should be cleared, got %+v", sanitized) - } - if sanitized.TrialEligible || sanitized.TrialEligibilityReason != "" { - t.Fatalf("trial prompt should be cleared, got eligible=%v reason=%q", sanitized.TrialEligible, sanitized.TrialEligibilityReason) - } if sanitized.MaxHistoryDays != 90 || !sanitized.HostedMode { t.Fatalf("non-commercial runtime capability fields should be preserved, got max_history_days=%d hosted_mode=%v", sanitized.MaxHistoryDays, sanitized.HostedMode) } - if sanitized.OverflowDaysRemaining != nil { - t.Fatalf("overflow days should be cleared, got %v", sanitized.OverflowDaysRemaining) - } - if sanitized.LegacyConnections.Total() != 0 { - t.Fatalf("legacy connections should be cleared, got %+v", sanitized.LegacyConnections) - } - if sanitized.HasMigrationGap || sanitized.CommercialMigration != nil { - t.Fatalf("migration state should be cleared, got has_gap=%v migration=%+v", sanitized.HasMigrationGap, sanitized.CommercialMigration) - } } func routeBelongsToPublicDemoCommercialBoundary(route string) bool { switch { case route == "/auth/trial-activate": return true + case route == "GET /api/license/runtime-capabilities": + return false case strings.HasPrefix(route, "/api/license/"): return true case strings.HasPrefix(route, "GET /api/license/"): diff --git a/internal/api/license_handlers_test.go b/internal/api/license_handlers_test.go index b0ecb3f281..4dbcb96336 100644 --- a/internal/api/license_handlers_test.go +++ b/internal/api/license_handlers_test.go @@ -673,6 +673,119 @@ func TestHandleActivateLicense_ValidKey(t *testing.T) { } } +func TestHandleCheckoutActivation_RedeemsCompletedCheckoutAndRedirectsToBilling(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "false") + + grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ + LicenseID: "lic_checkout_success", + Tier: "pro_plus", + State: "active", + Features: []string{"relay", "ai_alerts"}, + MaxMonitoredSystems: 50, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + Email: "buyer@example.com", + }) + if err != nil { + t.Fatalf("generate grant jwt: %v", err) + } + license.SetPublicKey(grantPublicKey) + t.Cleanup(func() { license.SetPublicKey(nil) }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/checkout/session": + if r.Method != http.MethodGet { + t.Fatalf("checkout session method = %s, want GET", r.Method) + } + if got := r.URL.Query().Get("session_id"); got != "cs_success" { + t.Fatalf("session_id = %q, want cs_success", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"fulfilled","activation_key":"ppk_live_checkout_activation"}`)) + case "/v1/activate": + if r.Method != http.MethodPost { + t.Fatalf("activate method = %s, want POST", r.Method) + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "license": map[string]any{ + "license_id": "lic_checkout_success", + "state": "active", + "tier": "pro_plus", + "features": []string{"relay", "ai_alerts"}, + "max_monitored_systems": 50, + }, + "installation": map[string]any{ + "installation_id": "inst_checkout_success", + "installation_token": "pit_live_checkout_success", + "status": "active", + }, + "grant": map[string]any{ + "jwt": grantJWT, + "jti": "grant_checkout_success", + "expires_at": time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339), + }, + }) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer server.Close() + t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL) + + handler := createTestHandler(t) + req := httptest.NewRequest( + http.MethodPost, + licensePurchaseActivationPath, + strings.NewReader("session_id=cs_success&feature=max_monitored_systems"), + ) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + handler.HandleCheckoutActivation(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusSeeOther, rec.Body.String()) + } + if got := rec.Header().Get("Location"); got != "/settings/system/billing/plan?intent=max_monitored_systems" { + t.Fatalf("location = %q, want monitored-system billing plan route", got) + } + if !handler.Service(context.Background()).IsActivated() { + t.Fatal("expected completed checkout to activate the local license") + } +} + +func TestHandleCheckoutActivation_RendersFailurePageWhenCheckoutIsNotFulfilled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/checkout/session" { + t.Fatalf("unexpected path %q", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"pending","message":"Checkout is not complete yet."}`)) + })) + defer server.Close() + t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL) + + handler := createTestHandler(t) + req := httptest.NewRequest( + http.MethodPost, + licensePurchaseActivationPath, + strings.NewReader("session_id=cs_pending"), + ) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + handler.HandleCheckoutActivation(rec, req) + + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusConflict, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "Checkout is not complete yet.") { + t.Fatalf("body = %q, want pending checkout message", rec.Body.String()) + } +} + // ======================================== // userFriendlyActivationError tests // ======================================== diff --git a/internal/api/licensing_bridge.go b/internal/api/licensing_bridge.go index 1f391f0c3d..2438939732 100644 --- a/internal/api/licensing_bridge.go +++ b/internal/api/licensing_bridge.go @@ -40,6 +40,7 @@ type billingStoreModel = pkglicensing.BillingStore type billingState = pkglicensing.BillingState type subscriptionState = pkglicensing.SubscriptionState type entitlementPayloadModel = pkglicensing.EntitlementPayload +type runtimeCapabilitiesPayloadModel = pkglicensing.RuntimeCapabilitiesPayload type limitStatusModel = pkglicensing.LimitStatus type upgradeReasonModel = pkglicensing.UpgradeReason type entitlementUsageSnapshotModel = pkglicensing.EntitlementUsageSnapshot @@ -213,6 +214,13 @@ func buildEntitlementPayloadFromLicensing(status *licenseStatus, subscriptionSta return pkglicensing.BuildEntitlementPayload(status, subscriptionState) } +func buildRuntimeCapabilitiesPayloadFromLicensing( + status *licenseStatus, + subscriptionState string, +) runtimeCapabilitiesPayloadModel { + return pkglicensing.BuildRuntimeCapabilitiesPayload(status, subscriptionState) +} + func buildFeatureMapFromLicensing(service *licenseService) map[string]bool { return pkglicensing.BuildFeatureMap(service, nil) } @@ -226,6 +234,14 @@ func buildEntitlementPayloadWithUsageFromLicensing( return pkglicensing.BuildEntitlementPayloadWithUsage(status, subscriptionState, usage, trialEndsAtUnix) } +func buildRuntimeCapabilitiesPayloadWithUsageFromLicensing( + status *licenseStatus, + subscriptionState string, + usage entitlementUsageSnapshotModel, +) runtimeCapabilitiesPayloadModel { + return pkglicensing.BuildRuntimeCapabilitiesPayloadWithUsage(status, subscriptionState, usage) +} + func limitStateFromLicensing(current, limit int64) string { return pkglicensing.LimitState(current, limit) } diff --git a/internal/api/licensing_handlers.go b/internal/api/licensing_handlers.go index 919e743081..5077ccb79c 100644 --- a/internal/api/licensing_handlers.go +++ b/internal/api/licensing_handlers.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "html" "net" "net/http" "net/url" @@ -21,8 +22,9 @@ import ( ) const ( - trialStartRateLimitBurst = 6 - trialStartRateLimitWindow = 15 * time.Minute + trialStartRateLimitBurst = 6 + trialStartRateLimitWindow = 15 * time.Minute + licensePurchaseActivationPath = "/auth/license-purchase-activate" ) // revocationFeedToken returns the relay feed token for revocation polling. @@ -203,6 +205,36 @@ func trialSignupActionURLForRequest(cfg *config.Config, orgID, returnURL, instan return parsed.String(), nil } +func licensePurchaseActivationRedirectPath(feature string) string { + normalizedFeature := strings.TrimSpace(feature) + switch normalizedFeature { + case "max_monitored_systems": + return "/settings/system/billing/plan?intent=max_monitored_systems" + default: + return "/settings/system/billing/plan" + } +} + +func writeLicensePurchaseActivationFailurePage(w http.ResponseWriter, statusCode int, feature, message string) { + if statusCode < http.StatusBadRequest { + statusCode = http.StatusBadRequest + } + redirectPath := licensePurchaseActivationRedirectPath(feature) + escapedMessage := html.EscapeString(strings.TrimSpace(message)) + if escapedMessage == "" { + escapedMessage = "Purchase activation could not be completed." + } + escapedLink := html.EscapeString(redirectPath) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(statusCode) + _, _ = fmt.Fprintf( + w, + "Pulse Pro activation

Pulse Pro activation failed

%s

Open Pulse Pro billing

", + escapedMessage, + escapedLink, + ) +} + func normalizeHostForTrial(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { @@ -1008,63 +1040,25 @@ func userFriendlyActivationError(err error) string { // HandleActivateLicense handles POST /api/license/activate // Validates and activates a license key. -func (h *LicenseHandlers) HandleActivateLicense(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req ActivateLicenseRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(ActivateLicenseResponse{ - Success: false, - Message: "Invalid request body", - }) - return - } - - if req.LicenseKey == "" { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(ActivateLicenseResponse{ - Success: false, - Message: "License key is required", - }) - return - } - - // Activate the license - service, persistence, err := h.getTenantComponents(r.Context()) +func (h *LicenseHandlers) activateLicenseKey(ctx context.Context, licenseKey string) (ActivateLicenseResponse, error) { + service, persistence, err := h.getTenantComponents(ctx) if err != nil { - log.Error().Err(err).Msg("Failed to get license components") - http.Error(w, "Tenant error", http.StatusInternalServerError) - return + return ActivateLicenseResponse{}, fmt.Errorf("resolve tenant licensing components: %w", err) } - orgID := GetOrgID(r.Context()) - migratedLegacyKey := !strings.HasPrefix(strings.TrimSpace(req.LicenseKey), activationKeyPrefixValue) + orgID := GetOrgID(ctx) + trimmedKey := strings.TrimSpace(licenseKey) + migratedLegacyKey := !strings.HasPrefix(trimmedKey, activationKeyPrefixValue) - lic, err := service.Activate(req.LicenseKey) + lic, err := service.Activate(trimmedKey) if err != nil { - log.Warn().Err(err).Msg("Failed to activate license") - h.emitConversionEvent(orgID, conversionEvent{ Type: conversionEventLicenseActivationFailed, Surface: "license_api", }) - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(ActivateLicenseResponse{ - Success: false, - Message: userFriendlyActivationError(err), - }) - return + return ActivateLicenseResponse{}, err } - // Persist based on activation type. if service.IsActivated() { h.stopHostedEntitlementRefreshLoop(orgID) if clearErr := h.setCommercialMigrationState(orgID, nil); clearErr != nil { @@ -1075,12 +1069,10 @@ func (h *LicenseHandlers) HandleActivateLicense(w http.ResponseWriter, r *http.R if feedToken := revocationFeedToken(); feedToken != "" { service.StartRevocationPoll(context.Background(), feedToken) } - // Preserve migrated v5 keys for downgrade/recovery, but remove stale - // legacy persistence after a native v6 activation-key activation. if persistence != nil { - if strings.HasPrefix(strings.TrimSpace(req.LicenseKey), activationKeyPrefixValue) { + if strings.HasPrefix(trimmedKey, activationKeyPrefixValue) { _ = persistence.Delete() - } else if err := persistence.Save(req.LicenseKey); err != nil { + } else if err := persistence.Save(trimmedKey); err != nil { log.Warn().Err(err).Msg("Failed to persist migrated legacy license for downgrade fallback") } } @@ -1106,12 +1098,111 @@ func (h *LicenseHandlers) HandleActivateLicense(w http.ResponseWriter, r *http.R successMessage = "Pulse v5 license migrated and activated successfully" } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(ActivateLicenseResponse{ + return ActivateLicenseResponse{ Success: true, Message: successMessage, Status: service.Status(), - }) + }, nil +} + +func (h *LicenseHandlers) HandleActivateLicense(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req ActivateLicenseRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ActivateLicenseResponse{ + Success: false, + Message: "Invalid request body", + }) + return + } + + if strings.TrimSpace(req.LicenseKey) == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ActivateLicenseResponse{ + Success: false, + Message: "License key is required", + }) + return + } + + response, err := h.activateLicenseKey(r.Context(), req.LicenseKey) + if err != nil { + log.Warn().Err(err).Msg("Failed to activate license") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ActivateLicenseResponse{ + Success: false, + Message: userFriendlyActivationError(err), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// HandleCheckoutActivation handles POST /auth/license-purchase-activate. +// It redeems a completed commercial checkout session into a local activation +// and returns the operator to the canonical billing route. +func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, "", "Purchase activation request was invalid.") + return + } + + sessionID := strings.TrimSpace(r.FormValue("session_id")) + feature := strings.TrimSpace(r.FormValue("feature")) + if sessionID == "" { + writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, feature, "Purchase activation requires a completed checkout session.") + return + } + + lsClient := newLicenseServerClientFromLicensing("") + if lsClient == nil { + writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, "Pulse could not contact the commercial activation service.") + return + } + + checkoutResult, err := lsClient.GetCheckoutSessionResult(r.Context(), sessionID) + if err != nil { + log.Warn().Err(err).Str("checkout_session_id", sessionID).Msg("Failed to resolve checkout session during purchase activation") + writeLicensePurchaseActivationFailurePage(w, http.StatusBadGateway, feature, "Pulse could not confirm the completed checkout yet. Please try again in a moment.") + return + } + + if checkoutResult == nil || strings.TrimSpace(checkoutResult.Status) != "fulfilled" { + message := "Checkout has not completed yet." + if checkoutResult != nil && strings.TrimSpace(checkoutResult.Message) != "" { + message = strings.TrimSpace(checkoutResult.Message) + } + writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, message) + return + } + + activationKey := strings.TrimSpace(checkoutResult.ActivationKey) + if activationKey == "" { + writeLicensePurchaseActivationFailurePage(w, http.StatusBadGateway, feature, "The completed checkout did not return an activation key.") + return + } + + if _, err := h.activateLicenseKey(r.Context(), activationKey); err != nil { + log.Warn().Err(err).Str("checkout_session_id", sessionID).Msg("Failed to activate completed checkout locally") + writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, feature, userFriendlyActivationError(err)) + return + } + + http.Redirect(w, r, licensePurchaseActivationRedirectPath(feature), http.StatusSeeOther) } // HandleClearLicense handles POST /api/license/clear diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index 3317aadf38..f6c57283c5 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -278,6 +278,7 @@ var publicRouteAllowlist = []string{ "/api/auto-register", "/auth/cloud-handoff", "/auth/trial-activate", + licensePurchaseActivationPath, "/install.sh", "/install.ps1", "/download/pulse-agent", @@ -336,6 +337,7 @@ var bareRouteAllowlist = []string{ "/api/clusters/", "/auth/cloud-handoff", "/auth/trial-activate", + licensePurchaseActivationPath, "/ws", "/api/saml/", } @@ -442,6 +444,7 @@ var allRouteAllowlist = []string{ "/api/license/features", "/api/license/activate", "/api/license/clear", + "GET /api/license/runtime-capabilities", "GET /api/license/entitlements", "POST /api/license/trial/start", "GET /api/license/monitored-system-ledger", @@ -484,6 +487,7 @@ var allRouteAllowlist = []string{ "/api/cloud/handoff/exchange", "/auth/cloud-handoff", "/auth/trial-activate", + licensePurchaseActivationPath, "/api/saml/", "/api/admin/roles", "/api/admin/roles/", diff --git a/internal/api/router.go b/internal/api/router.go index 9f60104757..6e92fc6d86 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -3466,21 +3466,22 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { "/api/security/validate-bootstrap-token", "/api/security/quick-setup", // Handler does its own auth (bootstrap token or session) "/api/version", - "/api/login", // Add login endpoint as public - "/api/public/signup", // Hosted mode: public signup - "/api/public/magic-link/request", // Hosted mode: request magic link - "/api/public/magic-link/verify", // Hosted mode: verify magic link - "/api/cloud/handoff/exchange", // Hosted mode: control-plane workspace handoff (token-authenticated) - "/api/webhooks/stripe", // Hosted mode: Stripe webhook (signature verification is auth) - "/install.sh", // Unified agent installer - "/install.ps1", // Unified agent Windows installer - "/download/pulse-agent", // Unified agent binary - "/api/agent/version", // Agent update checks need to work before auth - "/api/agent/ws", // Agent WebSocket has its own auth via registration - "/api/server/info", // Server info for installer script - "/api/ai/oauth/callback", // OAuth callback from Anthropic for Claude subscription auth - "/auth/cloud-handoff", // Cloud control plane handoff (token-authenticated) - "/auth/trial-activate", // Hosted trial signup callback (token-authenticated) + "/api/login", // Add login endpoint as public + "/api/public/signup", // Hosted mode: public signup + "/api/public/magic-link/request", // Hosted mode: request magic link + "/api/public/magic-link/verify", // Hosted mode: verify magic link + "/api/cloud/handoff/exchange", // Hosted mode: control-plane workspace handoff (token-authenticated) + "/api/webhooks/stripe", // Hosted mode: Stripe webhook (signature verification is auth) + "/install.sh", // Unified agent installer + "/install.ps1", // Unified agent Windows installer + "/download/pulse-agent", // Unified agent binary + "/api/agent/version", // Agent update checks need to work before auth + "/api/agent/ws", // Agent WebSocket has its own auth via registration + "/api/server/info", // Server info for installer script + "/api/ai/oauth/callback", // OAuth callback from Anthropic for Claude subscription auth + "/auth/cloud-handoff", // Cloud control plane handoff (token-authenticated) + "/auth/trial-activate", // Hosted trial signup callback (token-authenticated) + "/auth/license-purchase-activate", // Self-hosted checkout return (session-authenticated via commercial backend) } // Also allow static assets without auth (JS, CSS, etc) @@ -3638,6 +3639,10 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { if req.URL.Path == "/auth/trial-activate" { skipCSRF = true } + // Skip CSRF for self-hosted checkout activation return (POST from Pulse Account, no prior session required). + if req.URL.Path == "/auth/license-purchase-activate" { + skipCSRF = true + } // Skip CSRF for control-plane workspace handoff exchange (POST with signed handoff token). if req.URL.Path == "/api/cloud/handoff/exchange" { skipCSRF = true diff --git a/internal/api/router_csrf_skip_inventory_test.go b/internal/api/router_csrf_skip_inventory_test.go index e1507bda5b..e74581cb06 100644 --- a/internal/api/router_csrf_skip_inventory_test.go +++ b/internal/api/router_csrf_skip_inventory_test.go @@ -78,4 +78,5 @@ var csrfSkipAllowlist = []string{ "/api/setup-script-url", "/auth/cloud-handoff", "/auth/trial-activate", + licensePurchaseActivationPath, } diff --git a/internal/api/router_public_paths_inventory_test.go b/internal/api/router_public_paths_inventory_test.go index d960c105a2..452a126e07 100644 --- a/internal/api/router_public_paths_inventory_test.go +++ b/internal/api/router_public_paths_inventory_test.go @@ -125,4 +125,5 @@ var publicPathsAllowlist = []string{ "/api/ai/oauth/callback", "/auth/cloud-handoff", "/auth/trial-activate", + licensePurchaseActivationPath, } diff --git a/internal/api/router_routes_cloud.go b/internal/api/router_routes_cloud.go index 6152ef531d..904aa28253 100644 --- a/internal/api/router_routes_cloud.go +++ b/internal/api/router_routes_cloud.go @@ -74,6 +74,8 @@ func (r *Router) registerHostedRoutes(hostedSignupHandlers *HostedSignupHandlers if r.licenseHandlers != nil { // Hosted trial signup callback: signed token activation flow for self-hosted Pulse Pro trials. r.mux.HandleFunc("/auth/trial-activate", r.licenseHandlers.HandleTrialActivation) + // Self-hosted commercial checkout return: complete purchase activation without manual key entry. + r.mux.HandleFunc("/auth/license-purchase-activate", r.licenseHandlers.HandleCheckoutActivation) } // Workspace switch handoff: control-plane posts a short-lived JWT for session exchange. // Handler is token-authenticated, self-guards with the tenant handoff key, and diff --git a/internal/api/router_routes_licensing.go b/internal/api/router_routes_licensing.go index 22e4c9d8f0..1f341cda98 100644 --- a/internal/api/router_routes_licensing.go +++ b/internal/api/router_routes_licensing.go @@ -37,6 +37,7 @@ func (r *Router) registerOrgLicenseRoutesGroup(orgHandlers *OrgHandlers, rbacHan r.mux.HandleFunc("/api/license/features", RequireAuth(r.config, r.licenseHandlers.HandleLicenseFeatures)) r.mux.HandleFunc("/api/license/activate", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.licenseHandlers.HandleActivateLicense))) r.mux.HandleFunc("/api/license/clear", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.licenseHandlers.HandleClearLicense))) + r.mux.HandleFunc("GET /api/license/runtime-capabilities", RequireAuth(r.config, r.licenseHandlers.HandleRuntimeCapabilities)) r.mux.HandleFunc("GET /api/license/entitlements", RequireAuth(r.config, r.licenseHandlers.HandleEntitlements)) r.mux.HandleFunc("GET /api/license/monitored-system-ledger", RequireAuth(r.config, r.handleMonitoredSystemLedger)) r.mux.HandleFunc("POST /api/license/trial/start", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.licenseHandlers.HandleStartTrial))) diff --git a/internal/api/subscription_entitlements.go b/internal/api/subscription_entitlements.go index a878b463ba..7b87869801 100644 --- a/internal/api/subscription_entitlements.go +++ b/internal/api/subscription_entitlements.go @@ -14,6 +14,10 @@ import ( // Frontend should use this instead of inferring capabilities from tier names. type EntitlementPayload = entitlementPayloadModel +// RuntimeCapabilitiesPayload is the canonical non-commercial runtime capability +// response for feature gating and operational limit checks. +type RuntimeCapabilitiesPayload = runtimeCapabilitiesPayloadModel + // LimitStatus represents a quantitative limit with current usage state. type LimitStatus = limitStatusModel @@ -21,7 +25,7 @@ type LimitStatus = limitStatusModel type UpgradeReason = upgradeReasonModel // HandleEntitlements returns the normalized entitlement payload for the current tenant. -// This is the primary endpoint the frontend should use for feature gating decisions. +// This is the commercial entitlement endpoint for billing, trial, and upgrade presentation. func (h *LicenseHandlers) HandleEntitlements(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil) @@ -64,8 +68,37 @@ func (h *LicenseHandlers) HandleEntitlements(w http.ResponseWriter, r *http.Requ } payload.TrialEligible, payload.TrialEligibilityReason = h.trialStartEligibility(r.Context(), svc, existing) payload.HostedMode = h != nil && h.hostedMode - if isPublicDemoCommercialRedactedRequest(r) { - payload = sanitizeEntitlementPayloadForPublicDemo(payload) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(payload) +} + +// HandleRuntimeCapabilities returns the canonical non-commercial runtime capability payload. +func (h *LicenseHandlers) HandleRuntimeCapabilities(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil) + return + } + + svc, _, err := h.getTenantComponents(r.Context()) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "internal_error", "Internal server error", nil) + return + } + + status := svc.Status() + usage := h.entitlementUsageSnapshot(r.Context()) + + overflowGrantedAt := h.ensureOnboardingOverflow(r.Context(), status.Tier) + now := time.Now() + if bonus := overflowBonusFromLicensing(status.Tier, overflowGrantedAt, now); bonus > 0 { + status.MaxMonitoredSystems += bonus + } + + payload := buildRuntimeCapabilitiesPayloadWithUsage(status, svc.SubscriptionState(), usage) + payload.HostedMode = h != nil && h.hostedMode + if h != nil && h.cfg != nil && h.cfg.DemoMode { + payload = sanitizeRuntimeCapabilitiesPayloadForPublicDemo(payload) } w.Header().Set("Content-Type", "application/json") @@ -131,6 +164,13 @@ func buildEntitlementPayload(status *licenseStatus, subscriptionState string) En return buildEntitlementPayloadFromLicensing(status, subscriptionState) } +func buildRuntimeCapabilitiesPayload( + status *licenseStatus, + subscriptionState string, +) RuntimeCapabilitiesPayload { + return buildRuntimeCapabilitiesPayloadFromLicensing(status, subscriptionState) +} + // buildEntitlementPayloadWithUsage constructs the normalized payload from LicenseStatus and observed usage. func buildEntitlementPayloadWithUsage( status *licenseStatus, @@ -141,6 +181,14 @@ func buildEntitlementPayloadWithUsage( return buildEntitlementPayloadWithUsageFromLicensing(status, subscriptionState, usage, trialEndsAtUnix) } +func buildRuntimeCapabilitiesPayloadWithUsage( + status *licenseStatus, + subscriptionState string, + usage entitlementUsageSnapshot, +) RuntimeCapabilitiesPayload { + return buildRuntimeCapabilitiesPayloadWithUsageFromLicensing(status, subscriptionState, usage) +} + // limitState returns the over-limit UX state string. // Exported for testing. func limitState(current, limit int64) string { diff --git a/internal/cloudcp/portal/commercial_proxy.go b/internal/cloudcp/portal/commercial_proxy.go index c14409bda8..f084e961bc 100644 --- a/internal/cloudcp/portal/commercial_proxy.go +++ b/internal/cloudcp/portal/commercial_proxy.go @@ -12,16 +12,41 @@ import ( const defaultCommercialProxyBaseURL = "https://license.pulserelay.pro" -var allowedCommercialProxyEndpoints = map[string]string{ - "/v1/manage/request": http.MethodPost, - "/v1/manage": http.MethodPost, - "/v1/retrieve-license/request": http.MethodPost, - "/v1/retrieve-license": http.MethodPost, - "/v1/gdpr/request-export": http.MethodPost, - "/v1/gdpr/export": http.MethodPost, - "/v1/gdpr/request-delete": http.MethodPost, - "/v1/gdpr/confirm-delete": http.MethodPost, - "/v1/self-refund": http.MethodPost, +var allowedCommercialProxyEndpoints = map[string]map[string]struct{}{ + "/v1/public/pricing-model": { + http.MethodGet: {}, + }, + "/v1/checkout/session": { + http.MethodGet: {}, + http.MethodPost: {}, + }, + "/v1/manage/request": { + http.MethodPost: {}, + }, + "/v1/manage": { + http.MethodPost: {}, + }, + "/v1/retrieve-license/request": { + http.MethodPost: {}, + }, + "/v1/retrieve-license": { + http.MethodPost: {}, + }, + "/v1/gdpr/request-export": { + http.MethodPost: {}, + }, + "/v1/gdpr/export": { + http.MethodPost: {}, + }, + "/v1/gdpr/request-delete": { + http.MethodPost: {}, + }, + "/v1/gdpr/confirm-delete": { + http.MethodPost: {}, + }, + "/v1/self-refund": { + http.MethodPost: {}, + }, } type CommercialProxyConfig struct { @@ -37,15 +62,14 @@ func HandleCommercialProxy(cfg CommercialProxyConfig) http.HandlerFunc { client := &http.Client{Timeout: 15 * time.Second} return func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + targetPath := normalizeCommercialProxyPath(r.PathValue("commercial_path")) + allowedMethods, ok := allowedCommercialProxyEndpoints[targetPath] + if !ok { + http.Error(w, "Not found", http.StatusNotFound) return } - - targetPath := normalizeCommercialProxyPath(r.PathValue("commercial_path")) - expectedMethod, ok := allowedCommercialProxyEndpoints[targetPath] - if !ok || expectedMethod != r.Method { - http.Error(w, "Not found", http.StatusNotFound) + if _, allowed := allowedMethods[r.Method]; !allowed { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } @@ -60,6 +84,7 @@ func HandleCommercialProxy(cfg CommercialProxyConfig) http.HandlerFunc { http.Error(w, "internal error", http.StatusInternalServerError) return } + upstreamURL.RawQuery = strings.TrimSpace(r.URL.RawQuery) req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL.String(), bytes.NewReader(body)) if err != nil { diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 73676ee8fe..c6e4688668 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "5bb02fe9e6ac203e482678c5afae4d00462fc57457971908f10beb6e9a1ed579", + "source_hash": "8296295bb975b67afbc4c6c7a19e1273fe505688feb0dfd6af629f73e5e5d00c", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.css b/internal/cloudcp/portal/dist/portal_app.css index 74d82df2e4..1184093d7a 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -1201,6 +1201,90 @@ header .logout-btn:hover, border-radius: var(--radius); background: var(--bg-subtle); } +.billing-upgrade-root { + display: flex; + flex-direction: column; + gap: 12px; +} +.billing-upgrade-plan-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} +.billing-upgrade-plan-card { + position: relative; + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + border: 1px solid var(--border-muted); + border-radius: var(--radius); + background: var(--bg-subtle); +} +.billing-upgrade-plan-card.highlight { + border-color: var(--accent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent); +} +.billing-upgrade-plan-badge { + align-self: flex-start; + padding: 4px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} +.billing-upgrade-plan-header { + display: flex; + flex-direction: column; + gap: 4px; +} +.billing-upgrade-plan-kicker { + font-size: 12px; + font-weight: 600; + color: var(--ink-muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} +.billing-upgrade-plan-header h4 { + margin: 0; + font-size: 18px; + font-weight: 700; +} +.billing-upgrade-plan-price { + font-size: 22px; + font-weight: 700; + color: var(--ink); +} +.billing-upgrade-plan-period, +.billing-upgrade-plan-blurb { + font-size: 13px; + color: var(--ink-secondary); +} +.billing-upgrade-plan-blurb { + margin: 0; +} +.billing-upgrade-plan-features { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 8px; +} +.billing-upgrade-plan-feature { + display: flex; + gap: 8px; + align-items: flex-start; + font-size: 13px; + color: var(--ink); +} +.billing-upgrade-plan-feature::before { + content: "\2022"; + color: var(--accent); + font-weight: 700; +} .billing-result label { display: block; margin-bottom: 4px; diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 21ff4c8657..3f69937fc1 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -662,6 +662,11 @@ method: "POST" }, "Failed to sign out."); }, + getCommercialJSON: function(path) { + return request(bootstrap().commercial_api_base_url + path, { + headers: { Accept: "application/json" } + }, "Commercial request failed."); + }, postCommercialJSON: function(path, body) { return request(bootstrap().commercial_api_base_url + path, { method: "POST", @@ -826,6 +831,12 @@ return { openBillingPanelID: "", upgradeFeatureKey: "", + upgradeReturnURL: "", + upgradeCheckoutSessionID: "", + upgradeCheckoutStatus: "", + upgradePricing: createQueryState(null), + upgradeCheckout: createMutationState(), + upgradeCheckoutResult: createQueryState(null), flows: { manage: newVerificationFlowState(), retrieve: newVerificationFlowState(), @@ -1388,6 +1399,82 @@ el.textContent = status.message; el.className = "billing-status visible" + (status.error ? " error" : " success"); } + function formatCheckoutDate(value) { + if (!value) return ""; + var parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return ""; + return parsed.toLocaleDateString(void 0, { month: "short", day: "numeric", year: "numeric" }); + } + function renderUpgradePlansHTML(billingState) { + var pricing = billingState.upgradePricing.data; + if (!pricing || !Array.isArray(pricing.plans)) { + return ""; + } + var plans = pricing.plans.filter(function(plan) { + return Array.isArray(plan.buttons) && plan.buttons.some(function(button) { + return button.kind === "checkout" && button.planKey && button.billingCycle; + }); + }); + if (plans.length === 0) { + return ""; + } + return '
' + plans.map(function(plan) { + var buttons = Array.isArray(plan.buttons) ? plan.buttons : []; + var checkoutButtons = buttons.filter(function(button) { + return button.kind === "checkout" && button.planKey && button.billingCycle; + }); + return '
' + (plan.badge ? '
' + escapeText(plan.badge) + "
" : "") + '
' + escapeText(plan.tierKicker) + "

" + escapeText(plan.title) + '

' + escapeText(plan.price) + '
' + escapeText(plan.period) + '

' + escapeText(plan.blurb) + '

    ' + plan.features.map(function(feature) { + return '
  • ' + String(feature.html || "") + "
  • "; + }).join("") + "
" + (plan.note ? '
' + escapeText(plan.note) + "
" : "") + '
' + checkoutButtons.map(function(button) { + return '"; + }).join("") + "
"; + }).join("") + "
"; + } + function renderUpgradePanel(billingState, bootstrap) { + var root = getElement3("upgrade-billing-root"); + if (!root) return; + var featureKey = String(billingState.upgradeFeatureKey || "").trim(); + var pricingState = billingState.upgradePricing; + var checkoutResultState = billingState.upgradeCheckoutResult; + var result = checkoutResultState.data; + var returnURL = String(billingState.upgradeReturnURL || "").trim(); + var sessionID = String(billingState.upgradeCheckoutSessionID || "").trim(); + var explainer = pricingState.data && pricingState.data.explainer ? pricingState.data.explainer : ""; + var summaryItems = []; + if (billingState.upgradeCheckoutStatus === "cancelled") { + summaryItems.push('
Checkout was cancelled before completion.
'); + } + if (billingState.upgradeCheckout.pending) { + summaryItems.push('
Redirecting to secure checkout...
'); + } + if (billingState.upgradeCheckout.error) { + summaryItems.push('
' + escapeText(billingState.upgradeCheckout.error) + "
"); + } + if (pricingState.status === "loading" && !pricingState.data) { + summaryItems.push("

Loading self-hosted plan options...

"); + } + if (pricingState.status === "error") { + summaryItems.push( + '
' + escapeText(pricingState.error || "Failed to load self-hosted plans.") + '
' + ); + } + if (explainer) { + summaryItems.push('
' + explainer + "
"); + } + if (checkoutResultState.status === "loading") { + summaryItems.push('
Finalizing the completed checkout...
'); + } else if (checkoutResultState.status === "error") { + summaryItems.push('
' + escapeText(checkoutResultState.error || "Could not confirm the completed checkout.") + "
"); + } else if (result && result.status === "fulfilled") { + var activationForm = returnURL && sessionID ? '
' : '
Return to Pulse Pro billing, then use Pulse Account to retrieve the latest active license if automatic activation is unavailable.
'; + summaryItems.push( + '

Checkout complete

Plan
' + escapeText(result.tier || result.plan_key || "Purchased") + '
Purchase email
' + escapeText(result.owner_email || bootstrap.email || "") + '
Activation key
' + escapeText(result.activation_key_prefix || "Issued") + '
Monitored systems
' + escapeText(typeof result.max_monitored_systems === "number" && result.max_monitored_systems > 0 ? String(result.max_monitored_systems) : "Included") + "
" + activationForm + "
" + ); + } else if (result && result.message) { + summaryItems.push('
' + escapeText(result.message) + "
"); + } + root.innerHTML = '
' + summaryItems.join("") + renderUpgradePlansHTML(billingState) + (pricingState.status === "ready" && pricingState.data && pricingState.data.description ? '
' + escapeText(pricingState.data.description) + "
" : "") + (result && result.status === "fulfilled" && formatCheckoutDate(result.current_period_end) ? '
Next renewal: ' + escapeText(formatCheckoutDate(result.current_period_end)) + "
" : "") + "
"; + } function renderButton(id, disabled, label) { if (!id || !label) return; var button = getElement3(id); @@ -1544,6 +1631,18 @@ event.preventDefault(); deps.submitRefund(); return; + case "upgrade-reload-pricing": + event.preventDefault(); + deps.reloadUpgradePricing(); + return; + case "upgrade-start-checkout": + event.preventDefault(); + deps.startUpgradeCheckout( + target.getAttribute("data-upgrade-plan-key") || "", + target.getAttribute("data-upgrade-tier") || "", + target.getAttribute("data-upgrade-billing-cycle") || "" + ); + return; case "data-export-request": event.preventDefault(); deps.requestVerificationCode("export"); @@ -1592,6 +1691,13 @@ if (!billingState.flows) { var nextState = createPortalBillingState(); billingState.openBillingPanelID = nextState.openBillingPanelID; + billingState.upgradeFeatureKey = nextState.upgradeFeatureKey; + billingState.upgradeReturnURL = nextState.upgradeReturnURL; + billingState.upgradeCheckoutSessionID = nextState.upgradeCheckoutSessionID; + billingState.upgradeCheckoutStatus = nextState.upgradeCheckoutStatus; + billingState.upgradePricing = nextState.upgradePricing; + billingState.upgradeCheckout = nextState.upgradeCheckout; + billingState.upgradeCheckoutResult = nextState.upgradeCheckoutResult; billingState.flows = nextState.flows; billingState.refund = nextState.refund; } @@ -1630,6 +1736,7 @@ } } function renderAllFlows() { + renderUpgrade(); renderFlow("manage"); renderFlow("retrieve"); renderFlow("export"); @@ -1642,6 +1749,9 @@ renderButton("refund-inline-submit", refundState.submit.pending, refundState.submit.pending ? "Processing..." : "Process Refund"); renderBillingStatus("refund-inline-status", refundState.status); } + function renderUpgrade() { + renderUpgradePanel(getBillingState(), store.getBootstrap()); + } function resetVerificationFlow(flowID) { var flow = verificationFlows[flowID]; if (!flow) return; @@ -1652,6 +1762,117 @@ setValue(flow.codeInputID, ""); } } + function currentPortalBaseURL() { + try { + var locationURL = new URL(window.location.href); + var portalPath = String(store.getBootstrap().portal_path || "/portal").trim() || "/portal"; + var portalURL = new URL(portalPath, locationURL.origin); + return portalURL.toString(); + } catch { + return String(store.getBootstrap().portal_path || "/portal"); + } + } + function buildUpgradeCheckoutReturnURL(status) { + var url = new URL(currentPortalBaseURL()); + var billingState = getBillingState(); + if (billingState.flows.manage.emailValue) { + url.searchParams.set("email", billingState.flows.manage.emailValue); + } + if (billingState.upgradeFeatureKey) { + url.searchParams.set("feature", billingState.upgradeFeatureKey); + } + if (billingState.upgradeReturnURL) { + url.searchParams.set("return_url", billingState.upgradeReturnURL); + } + url.searchParams.set("service", "upgrade"); + url.searchParams.set("checkout", status); + if (status === "success") { + url.searchParams.set("session_id", "{CHECKOUT_SESSION_ID}"); + } + return url.toString(); + } + async function loadUpgradePricing(force) { + var billingState = getBillingState(); + if (!force && (billingState.upgradePricing.status === "loading" || billingState.upgradePricing.status === "ready")) { + return; + } + updateBillingState(function(nextBillingState) { + beginQueryState(nextBillingState.upgradePricing, null); + }); + try { + var pricing = await api.getCommercialJSON("/v1/public/pricing-model?track=v6"); + updateBillingState(function(nextBillingState) { + resolveQueryState(nextBillingState.upgradePricing, pricing); + }); + } catch (err) { + updateBillingState(function(nextBillingState) { + failQueryState( + nextBillingState.upgradePricing, + null, + err instanceof Error ? err.message : "Failed to load self-hosted plans." + ); + }); + } + } + async function resolveCompletedCheckout(force) { + var billingState = getBillingState(); + var sessionID = billingState.upgradeCheckoutSessionID; + if (!sessionID) return; + if (!force && (billingState.upgradeCheckoutResult.status === "loading" || billingState.upgradeCheckoutResult.status === "ready")) { + return; + } + updateBillingState(function(nextBillingState) { + beginQueryState(nextBillingState.upgradeCheckoutResult, null); + }); + try { + var result = await api.getCommercialJSON( + "/v1/checkout/session?session_id=" + encodeURIComponent(sessionID) + ); + updateBillingState(function(nextBillingState) { + resolveQueryState(nextBillingState.upgradeCheckoutResult, result); + }); + } catch (err) { + updateBillingState(function(nextBillingState) { + failQueryState( + nextBillingState.upgradeCheckoutResult, + null, + err instanceof Error ? err.message : "Failed to confirm the completed checkout." + ); + }); + } + } + async function startUpgradeCheckout(planKey, tier, billingCycle) { + if (!planKey || !tier || !billingCycle) return; + updateBillingState(function(nextBillingState) { + beginMutationState(nextBillingState.upgradeCheckout); + nextBillingState.upgradeCheckoutResult = createPortalBillingState().upgradeCheckoutResult; + nextBillingState.upgradeCheckoutSessionID = ""; + nextBillingState.upgradeCheckoutStatus = ""; + }); + try { + var data = await api.postCommercialJSON("/v1/checkout/session", { + plan_key: planKey, + tier, + billing_cycle: billingCycle, + success_url: buildUpgradeCheckoutReturnURL("success"), + cancel_url: buildUpgradeCheckoutReturnURL("cancelled") + }); + if (!data || !data.url) { + throw new Error("Checkout URL was not returned."); + } + updateBillingState(function(nextBillingState) { + succeedMutationState(nextBillingState.upgradeCheckout); + }); + window.location.href = data.url; + } catch (err) { + updateBillingState(function(nextBillingState) { + failMutationState( + nextBillingState.upgradeCheckout, + err instanceof Error ? err.message : "Failed to start checkout." + ); + }); + } + } var verificationFlows = { manage: { requestPath: "/v1/manage/request", @@ -1931,6 +2152,13 @@ } } function renderBillingRuntime() { + var billingState = getBillingState(); + if (billingState.openBillingPanelID === "upgrade-billing-panel" || !!billingState.upgradeFeatureKey || billingState.upgradeCheckoutStatus === "success") { + void loadUpgradePricing(false); + } + if (billingState.upgradeCheckoutStatus === "success" && billingState.upgradeCheckoutSessionID) { + void resolveCompletedCheckout(false); + } renderOpenBillingPanels(getBillingState().openBillingPanelID); renderAllFlows(); } @@ -1959,6 +2187,12 @@ submitRefund: function() { void submitRefund(); }, + reloadUpgradePricing: function() { + void loadUpgradePricing(true); + }, + startUpgradeCheckout: function(planKey, tier, billingCycle) { + void startUpgradeCheckout(planKey, tier, billingCycle); + }, updateInputValue: function(inputKind, value) { updateBillingState(function(billingState) { updateBillingInputValue(billingState, inputKind, value); @@ -2030,22 +2264,6 @@ function selfHostedUpgradeActionHighlights(featureKey) { return normalizeUpgradeFeatureKey(featureKey) === "max_monitored_systems" ? ["Monitored-system cap", "Plan comparison"] : ["Plan comparison", "Checkout handoff"]; } - function selfHostedUpgradePricingURL(bootstrap, featureKey) { - var publicSiteURL = String(bootstrap.public_site_url || "https://pulserelay.pro").trim(); - var normalizedFeature = normalizeUpgradeFeatureKey(featureKey); - try { - var url = new URL("/pricing", publicSiteURL); - if (normalizedFeature) { - url.searchParams.set("feature", normalizedFeature); - } - url.searchParams.set("utm_source", "pulse-account"); - url.searchParams.set("utm_medium", "portal"); - url.searchParams.set("utm_campaign", "self_hosted_upgrade"); - return url.toString(); - } catch { - return "https://pulserelay.pro/pricing"; - } - } function renderSelfHostedUpgradeActionRow(context) { var featureKey = normalizeUpgradeFeatureKey(context.billingState.upgradeFeatureKey); return renderBillingActionRow( @@ -2060,14 +2278,12 @@ } function renderSelfHostedUpgradeBillingPanel(context) { var featureKey = normalizeUpgradeFeatureKey(context.billingState.upgradeFeatureKey); - var pricingURL = selfHostedUpgradePricingURL(context.bootstrap, featureKey); - var linkLabel = featureKey === "max_monitored_systems" ? "Compare monitored-system plans" : "View self-hosted plans"; - var helperCopy = featureKey === "max_monitored_systems" ? "Choose the self-hosted tier that matches the monitored-system allowance you need, then return to Pulse Pro to activate the new license." : "Choose the self-hosted tier that fits this upgrade, then return to Pulse Pro to activate the new license."; + var helperCopy = featureKey === "max_monitored_systems" ? "Choose the self-hosted tier that matches the monitored-system allowance you need, then send the completed purchase back to Pulse Pro for activation." : "Choose the self-hosted tier that fits this upgrade, then send the completed purchase back to Pulse Pro for activation."; return renderBillingTaskPanel( selfHostedUpgradeActionTitle(featureKey), - "Pulse Account owns the commercial handoff. Plan selection and checkout continue on the public self-hosted pricing site.", + "Pulse Account owns self-hosted plan selection and checkout for Pulse Pro upgrades.", "upgrade-billing-panel", - '
' + escapeHTML(helperCopy) + "
" + '
' + escapeHTML(helperCopy) + "
" ); } function countWorkspaces(accounts) { @@ -2739,19 +2955,41 @@ function normalizeUpgradeFeatureKey2(value) { return String(value || "").trim(); } + function normalizeUpgradeReturnURL(value) { + return String(value || "").trim(); + } + function normalizeUpgradeCheckoutSessionID(value) { + return String(value || "").trim(); + } + function normalizeUpgradeCheckoutStatus(value) { + switch (String(value || "").trim()) { + case "success": + return "success"; + case "cancelled": + return "cancelled"; + default: + return ""; + } + } function readPortalRuntimeHandoff(locationHref = window.location.href) { try { var params = new URL(locationHref).searchParams; return { email: normalizeHandoffEmail(params.get("email")), openBillingPanelID: normalizeHandoffBillingPanel(params.get("service")), - upgradeFeatureKey: normalizeUpgradeFeatureKey2(params.get("feature")) + upgradeFeatureKey: normalizeUpgradeFeatureKey2(params.get("feature")), + upgradeReturnURL: normalizeUpgradeReturnURL(params.get("return_url")), + upgradeCheckoutSessionID: normalizeUpgradeCheckoutSessionID(params.get("session_id")), + upgradeCheckoutStatus: normalizeUpgradeCheckoutStatus(params.get("checkout")) }; } catch { return { email: "", openBillingPanelID: "", - upgradeFeatureKey: "" + upgradeFeatureKey: "", + upgradeReturnURL: "", + upgradeCheckoutSessionID: "", + upgradeCheckoutStatus: "" }; } } @@ -2791,11 +3029,17 @@ store.updateBillingState(function(billingState) { billingState.openBillingPanelID = handoff.openBillingPanelID; billingState.upgradeFeatureKey = handoff.upgradeFeatureKey; + billingState.upgradeReturnURL = handoff.upgradeReturnURL; + billingState.upgradeCheckoutSessionID = handoff.upgradeCheckoutSessionID; + billingState.upgradeCheckoutStatus = handoff.upgradeCheckoutStatus; }, { notify: false }); } else if (handoff.upgradeFeatureKey) { store.setActiveShellSection("billing"); store.updateBillingState(function(billingState) { billingState.upgradeFeatureKey = handoff.upgradeFeatureKey; + billingState.upgradeReturnURL = handoff.upgradeReturnURL; + billingState.upgradeCheckoutSessionID = handoff.upgradeCheckoutSessionID; + billingState.upgradeCheckoutStatus = handoff.upgradeCheckoutStatus; }, { notify: false }); } return { diff --git a/internal/cloudcp/portal/frontend/dev.mjs b/internal/cloudcp/portal/frontend/dev.mjs index c1033bb3e8..20d1ec5530 100644 --- a/internal/cloudcp/portal/frontend/dev.mjs +++ b/internal/cloudcp/portal/frontend/dev.mjs @@ -476,14 +476,85 @@ function routeAccountAPI(request, response, url, bootstrap, scenario) { } function routeCommercialAPI(request, response, url, scenario) { + const successRedirect = previewReturnURL(request, scenario, 'Preview mode: external billing or email verification would complete here.'); + const route = url.pathname.replace('/__portal_preview/commercial', ''); + + if (request.method === 'GET' && route === '/v1/public/pricing-model') { + sendJSON(response, 200, { + title: 'Simple self-hosted pricing for Pulse', + description: 'Preview pricing contract', + explainer: 'Pulse counts monitored systems, not every child resource.', + plans: [ + { + badge: 'Recommended', + highlight: true, + tierKicker: 'Relay', + title: 'Relay', + price: '$4.99', + period: '$39/year available too', + blurb: 'Secure remote access and mobile access.', + features: [ + { tone: 'check', html: 'Up to 8 monitored systems' }, + { tone: 'check', html: 'Remote access' }, + ], + buttons: [ + { kind: 'checkout', className: 'btn btn-secondary', tier: 'relay', planKey: 'price_relay_monthly', billingCycle: 'monthly', label: 'Buy Monthly' }, + { kind: 'checkout', className: 'btn btn-primary', tier: 'relay', planKey: 'price_relay_annual', billingCycle: 'annual', label: 'Buy Annual' }, + ], + }, + { + tierKicker: 'Pro', + title: 'Pro', + price: '$8.99', + period: '$79/year available too', + blurb: 'Investigation, alert analysis, and auto-fix.', + features: [ + { tone: 'check', html: 'Up to 15 monitored systems' }, + { tone: 'check', html: 'Everything in Relay' }, + ], + buttons: [ + { kind: 'checkout', className: 'btn btn-secondary', tier: 'pro', planKey: 'price_pro_monthly', billingCycle: 'monthly', label: 'Buy Monthly' }, + { kind: 'checkout', className: 'btn btn-primary', tier: 'pro', planKey: 'price_pro_annual', billingCycle: 'annual', label: 'Buy Annual' }, + ], + }, + { + tierKicker: 'Pro+', + title: 'Pro+', + price: '$14.99', + period: '$129/year available too', + blurb: 'More room for larger self-hosted environments.', + features: [ + { tone: 'check', html: 'Up to 50 monitored systems' }, + { tone: 'check', html: 'Everything in Pro' }, + ], + buttons: [ + { kind: 'checkout', className: 'btn btn-secondary', tier: 'pro_plus', planKey: 'price_pro_plus_monthly', billingCycle: 'monthly', label: 'Buy Monthly' }, + { kind: 'checkout', className: 'btn btn-primary', tier: 'pro_plus', planKey: 'price_pro_plus_annual', billingCycle: 'annual', label: 'Buy Annual' }, + ], + }, + ], + }); + return; + } + + if (request.method === 'GET' && route === '/v1/checkout/session') { + sendJSON(response, 200, { + status: 'fulfilled', + owner_email: 'buyer@example.com', + tier: 'pro_plus', + plan_key: 'price_pro_plus_annual', + activation_key_prefix: 'ppk_live_preview', + max_monitored_systems: 50, + current_period_end: iso('2027-03-20T10:00:00Z'), + }); + return; + } + if (request.method !== 'POST') { sendText(response, 405, 'Method not allowed'); return; } - const successRedirect = previewReturnURL(request, scenario, 'Preview mode: external billing or email verification would complete here.'); - const route = url.pathname.replace('/__portal_preview/commercial', ''); - if ( route === '/v1/manage/request' || route === '/v1/retrieve-license/request' || @@ -539,6 +610,21 @@ function routeCommercialAPI(request, response, url, scenario) { return; } + if (route === '/v1/checkout/session') { + readJSONBody(request).then(function(body) { + const successURL = String(body.success_url || '').replace('{CHECKOUT_SESSION_ID}', 'cs_preview_success'); + sendJSON(response, 200, { + url: successURL || successRedirect, + plan_key: body.plan_key || 'price_pro_plus_annual', + tier: body.tier || 'pro_plus', + billing_cycle: body.billing_cycle || 'annual', + }); + }).catch(function() { + sendJSON(response, 400, { error: 'Invalid JSON.' }); + }); + return; + } + sendText(response, 404, 'Not found'); } diff --git a/internal/cloudcp/portal/frontend/src/api.test.ts b/internal/cloudcp/portal/frontend/src/api.test.ts index 98fb2416e7..d91c03bde2 100644 --- a/internal/cloudcp/portal/frontend/src/api.test.ts +++ b/internal/cloudcp/portal/frontend/src/api.test.ts @@ -114,4 +114,31 @@ describe('portal api', function() { ); expect(result.message).toContain('If that email is registered'); }); + + it('supports commercial GET requests for pricing and checkout resolution', async function() { + var fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async function() { + return { title: 'pricing' }; + }, + }); + vi.stubGlobal('fetch', fetchMock); + + var api = createPortalAPI({ + getBootstrap: function() { + return bootstrap; + }, + }); + + var result = await api.getCommercialJSON<{ title: string }>('/v1/public/pricing-model?track=v6'); + expect(fetchMock).toHaveBeenCalledWith( + '/api/portal/commercial/v1/public/pricing-model?track=v6', + expect.objectContaining({ + headers: { Accept: 'application/json' }, + }), + ); + expect(result.title).toBe('pricing'); + }); }); diff --git a/internal/cloudcp/portal/frontend/src/api.ts b/internal/cloudcp/portal/frontend/src/api.ts index 80aab61883..3357ade022 100644 --- a/internal/cloudcp/portal/frontend/src/api.ts +++ b/internal/cloudcp/portal/frontend/src/api.ts @@ -41,6 +41,7 @@ export interface PortalAPI { fetchBootstrap(): Promise; requestMagicLink(email: string): Promise; logout(): Promise; + getCommercialJSON(path: string): Promise; postCommercialJSON(path: string, body: Record): Promise; createWorkspace(accountID: string, body: PortalWorkspaceCreateRequest): Promise; suspendWorkspace(accountID: string, tenantID: string): Promise; @@ -140,6 +141,11 @@ export function createPortalAPI(context: PortalAPIContext): PortalAPI { method: 'POST', }, 'Failed to sign out.'); }, + getCommercialJSON: function(path: string) { + return request(bootstrap().commercial_api_base_url + path, { + headers: { Accept: 'application/json' }, + }, 'Commercial request failed.'); + }, postCommercialJSON: function(path: string, body: Record) { return request(bootstrap().commercial_api_base_url + path, { method: 'POST', diff --git a/internal/cloudcp/portal/frontend/src/app.test.ts b/internal/cloudcp/portal/frontend/src/app.test.ts index a854bf05f0..0f70c8fb73 100644 --- a/internal/cloudcp/portal/frontend/src/app.test.ts +++ b/internal/cloudcp/portal/frontend/src/app.test.ts @@ -151,6 +151,9 @@ describe('portal app', function() { email: 'buyer@example.com', openBillingPanelID: 'retrieve-billing-panel', upgradeFeatureKey: '', + upgradeReturnURL: '', + upgradeCheckoutSessionID: '', + upgradeCheckoutStatus: '', } ); diff --git a/internal/cloudcp/portal/frontend/src/billing.ts b/internal/cloudcp/portal/frontend/src/billing.ts index 8dda9f6207..b440b0d678 100644 --- a/internal/cloudcp/portal/frontend/src/billing.ts +++ b/internal/cloudcp/portal/frontend/src/billing.ts @@ -1,4 +1,11 @@ -import { beginMutationState, failMutationState, succeedMutationState } from './async_state'; +import { + beginMutationState, + beginQueryState, + failMutationState, + failQueryState, + resolveQueryState, + succeedMutationState, +} from './async_state'; import type { PortalAPI } from './api'; import { installBillingController } from './billing_controller'; import { @@ -26,10 +33,18 @@ import { renderRefundPanel, renderRetrievePanel, renderBillingStatus, + renderUpgradePanel, setValue, setVisible, } from './billing_view'; -import type { PortalBillingFlowID, PortalBillingState, VerificationFlowState } from './types'; +import type { + PortalBillingFlowID, + PortalBillingState, + PortalCheckoutSessionCreateResponse, + PortalCheckoutSessionResult, + PortalUpgradePricingModel, + VerificationFlowState, +} from './types'; interface VerificationFlowDefinition { requestPath: string; @@ -72,6 +87,13 @@ export function installBillingRuntime(deps: BillingRuntimeDeps): void { if (!billingState.flows) { var nextState = createPortalBillingState(); billingState.openBillingPanelID = nextState.openBillingPanelID; + billingState.upgradeFeatureKey = nextState.upgradeFeatureKey; + billingState.upgradeReturnURL = nextState.upgradeReturnURL; + billingState.upgradeCheckoutSessionID = nextState.upgradeCheckoutSessionID; + billingState.upgradeCheckoutStatus = nextState.upgradeCheckoutStatus; + billingState.upgradePricing = nextState.upgradePricing; + billingState.upgradeCheckout = nextState.upgradeCheckout; + billingState.upgradeCheckoutResult = nextState.upgradeCheckoutResult; billingState.flows = nextState.flows; billingState.refund = nextState.refund; } @@ -116,6 +138,7 @@ export function installBillingRuntime(deps: BillingRuntimeDeps): void { } function renderAllFlows() { + renderUpgrade(); renderFlow('manage'); renderFlow('retrieve'); renderFlow('export'); @@ -130,6 +153,10 @@ export function installBillingRuntime(deps: BillingRuntimeDeps): void { renderBillingStatus('refund-inline-status', refundState.status); } + function renderUpgrade() { + renderUpgradePanel(getBillingState(), store.getBootstrap()); + } + function resetVerificationFlow(flowID: PortalBillingFlowID) { var flow = verificationFlows[flowID]; if (!flow) return; @@ -141,6 +168,122 @@ export function installBillingRuntime(deps: BillingRuntimeDeps): void { } } + function currentPortalBaseURL(): string { + try { + var locationURL = new URL(window.location.href); + var portalPath = String(store.getBootstrap().portal_path || '/portal').trim() || '/portal'; + var portalURL = new URL(portalPath, locationURL.origin); + return portalURL.toString(); + } catch { + return String(store.getBootstrap().portal_path || '/portal'); + } + } + + function buildUpgradeCheckoutReturnURL(status: 'success' | 'cancelled'): string { + var url = new URL(currentPortalBaseURL()); + var billingState = getBillingState(); + if (billingState.flows.manage.emailValue) { + url.searchParams.set('email', billingState.flows.manage.emailValue); + } + if (billingState.upgradeFeatureKey) { + url.searchParams.set('feature', billingState.upgradeFeatureKey); + } + if (billingState.upgradeReturnURL) { + url.searchParams.set('return_url', billingState.upgradeReturnURL); + } + url.searchParams.set('service', 'upgrade'); + url.searchParams.set('checkout', status); + if (status === 'success') { + url.searchParams.set('session_id', '{CHECKOUT_SESSION_ID}'); + } + return url.toString(); + } + + async function loadUpgradePricing(force: boolean) { + var billingState = getBillingState(); + if (!force && (billingState.upgradePricing.status === 'loading' || billingState.upgradePricing.status === 'ready')) { + return; + } + updateBillingState(function(nextBillingState) { + beginQueryState(nextBillingState.upgradePricing, null); + }); + try { + var pricing = await api.getCommercialJSON('/v1/public/pricing-model?track=v6'); + updateBillingState(function(nextBillingState) { + resolveQueryState(nextBillingState.upgradePricing, pricing); + }); + } catch (err) { + updateBillingState(function(nextBillingState) { + failQueryState( + nextBillingState.upgradePricing, + null, + err instanceof Error ? err.message : 'Failed to load self-hosted plans.', + ); + }); + } + } + + async function resolveCompletedCheckout(force: boolean) { + var billingState = getBillingState(); + var sessionID = billingState.upgradeCheckoutSessionID; + if (!sessionID) return; + if (!force && (billingState.upgradeCheckoutResult.status === 'loading' || billingState.upgradeCheckoutResult.status === 'ready')) { + return; + } + updateBillingState(function(nextBillingState) { + beginQueryState(nextBillingState.upgradeCheckoutResult, null); + }); + try { + var result = await api.getCommercialJSON( + '/v1/checkout/session?session_id=' + encodeURIComponent(sessionID), + ); + updateBillingState(function(nextBillingState) { + resolveQueryState(nextBillingState.upgradeCheckoutResult, result); + }); + } catch (err) { + updateBillingState(function(nextBillingState) { + failQueryState( + nextBillingState.upgradeCheckoutResult, + null, + err instanceof Error ? err.message : 'Failed to confirm the completed checkout.', + ); + }); + } + } + + async function startUpgradeCheckout(planKey: string, tier: string, billingCycle: string) { + if (!planKey || !tier || !billingCycle) return; + updateBillingState(function(nextBillingState) { + beginMutationState(nextBillingState.upgradeCheckout); + nextBillingState.upgradeCheckoutResult = createPortalBillingState().upgradeCheckoutResult; + nextBillingState.upgradeCheckoutSessionID = ''; + nextBillingState.upgradeCheckoutStatus = ''; + }); + try { + var data = await api.postCommercialJSON('/v1/checkout/session', { + plan_key: planKey, + tier: tier, + billing_cycle: billingCycle, + success_url: buildUpgradeCheckoutReturnURL('success'), + cancel_url: buildUpgradeCheckoutReturnURL('cancelled'), + }); + if (!data || !data.url) { + throw new Error('Checkout URL was not returned.'); + } + updateBillingState(function(nextBillingState) { + succeedMutationState(nextBillingState.upgradeCheckout); + }); + window.location.href = data.url; + } catch (err) { + updateBillingState(function(nextBillingState) { + failMutationState( + nextBillingState.upgradeCheckout, + err instanceof Error ? err.message : 'Failed to start checkout.', + ); + }); + } + } + var verificationFlows: Record = { manage: { requestPath: '/v1/manage/request', @@ -425,6 +568,17 @@ export function installBillingRuntime(deps: BillingRuntimeDeps): void { } function renderBillingRuntime() { + var billingState = getBillingState(); + if ( + billingState.openBillingPanelID === 'upgrade-billing-panel' || + !!billingState.upgradeFeatureKey || + billingState.upgradeCheckoutStatus === 'success' + ) { + void loadUpgradePricing(false); + } + if (billingState.upgradeCheckoutStatus === 'success' && billingState.upgradeCheckoutSessionID) { + void resolveCompletedCheckout(false); + } renderOpenBillingPanels(getBillingState().openBillingPanelID); renderAllFlows(); } @@ -455,6 +609,12 @@ export function installBillingRuntime(deps: BillingRuntimeDeps): void { submitRefund: function() { void submitRefund(); }, + reloadUpgradePricing: function() { + void loadUpgradePricing(true); + }, + startUpgradeCheckout: function(planKey, tier, billingCycle) { + void startUpgradeCheckout(planKey, tier, billingCycle); + }, updateInputValue: function(inputKind, value) { updateBillingState(function(billingState) { updateBillingInputValue(billingState, inputKind, value); diff --git a/internal/cloudcp/portal/frontend/src/billing_controller.test.ts b/internal/cloudcp/portal/frontend/src/billing_controller.test.ts index aae7dee66b..ef134404e4 100644 --- a/internal/cloudcp/portal/frontend/src/billing_controller.test.ts +++ b/internal/cloudcp/portal/frontend/src/billing_controller.test.ts @@ -23,6 +23,8 @@ describe('services controller', function() { confirmVerificationCode: vi.fn(), copyRetrievedLicense: vi.fn(), submitRefund: vi.fn(), + reloadUpgradePricing: vi.fn(), + startUpgradeCheckout: vi.fn(), updateInputValue: vi.fn(), updateDeleteConfirmation: vi.fn(), }; @@ -37,6 +39,8 @@ describe('services controller', function() { '' + '' + '' + + '' + + '' + '
'; var scrollIntoView = vi.fn(); @@ -68,6 +72,16 @@ describe('services controller', function() { document.getElementById('refund')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(deps.submitRefund).toHaveBeenCalled(); + + document.getElementById('reload')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(deps.reloadUpgradePricing).toHaveBeenCalled(); + + document.getElementById('checkout')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(deps.startUpgradeCheckout).toHaveBeenCalledWith( + 'relay_professional', + 'professional', + 'monthly', + ); }); it('routes input and checkbox changes into state update hooks', function() { @@ -81,6 +95,8 @@ describe('services controller', function() { confirmVerificationCode: vi.fn(), copyRetrievedLicense: vi.fn(), submitRefund: vi.fn(), + reloadUpgradePricing: vi.fn(), + startUpgradeCheckout: vi.fn(), updateInputValue: vi.fn(), updateDeleteConfirmation: vi.fn(), }; diff --git a/internal/cloudcp/portal/frontend/src/billing_controller.ts b/internal/cloudcp/portal/frontend/src/billing_controller.ts index 95c1231b14..7c74351f13 100644 --- a/internal/cloudcp/portal/frontend/src/billing_controller.ts +++ b/internal/cloudcp/portal/frontend/src/billing_controller.ts @@ -10,6 +10,8 @@ export interface BillingControllerDeps { confirmVerificationCode: (flowID: 'manage' | 'retrieve' | 'export' | 'delete') => void; copyRetrievedLicense: () => void; submitRefund: () => void; + reloadUpgradePricing: () => void; + startUpgradeCheckout: (planKey: string, tier: string, billingCycle: string) => void; updateInputValue: (inputKind: string, value: string) => void; updateDeleteConfirmation: (checked: boolean) => void; } @@ -83,6 +85,18 @@ export function installBillingController(deps: BillingControllerDeps): void { event.preventDefault(); deps.submitRefund(); return; + case 'upgrade-reload-pricing': + event.preventDefault(); + deps.reloadUpgradePricing(); + return; + case 'upgrade-start-checkout': + event.preventDefault(); + deps.startUpgradeCheckout( + target.getAttribute('data-upgrade-plan-key') || '', + target.getAttribute('data-upgrade-tier') || '', + target.getAttribute('data-upgrade-billing-cycle') || '', + ); + return; case 'data-export-request': event.preventDefault(); deps.requestVerificationCode('export'); diff --git a/internal/cloudcp/portal/frontend/src/billing_view.test.ts b/internal/cloudcp/portal/frontend/src/billing_view.test.ts index 26d16fcd03..b5e4f2db8d 100644 --- a/internal/cloudcp/portal/frontend/src/billing_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/billing_view.test.ts @@ -9,8 +9,10 @@ import { renderRefundPanel, renderRetrievePanel, renderBillingStatus, + renderUpgradePanel, } from './billing_view'; import type { PortalBootstrapData, RefundState, BillingStatus, VerificationFlowState } from './types'; +import { createPortalBillingState } from './state'; function createBootstrap(overrides: Partial = {}): PortalBootstrapData { return { @@ -220,4 +222,54 @@ describe('services view', function() { expect(document.getElementById('manage-inline-status')?.className).toContain('success'); expect(document.getElementById('manage-inline-status')?.textContent).toBe('Code sent.'); }); + + it('renders upgrade panel with canonical plans and a return-to-product activation form', function() { + document.body.innerHTML = '
'; + + var billingState = createPortalBillingState(); + billingState.upgradeFeatureKey = 'max_monitored_systems'; + billingState.upgradeReturnURL = 'https://pulse.example.com/auth/license-purchase-activate'; + billingState.upgradeCheckoutSessionID = 'cs_success'; + billingState.upgradePricing.status = 'ready'; + billingState.upgradePricing.data = { + title: 'Pricing', + description: 'Canonical pricing model', + explainer: 'Pulse counts monitored systems.', + plans: [ + { + tierKicker: 'Pro+', + title: 'Pro+', + price: '$14.99', + period: '$129/year available too', + blurb: 'More room.', + features: [{ tone: 'check', html: 'Up to 50 monitored systems' }], + buttons: [ + { + kind: 'checkout', + className: 'btn btn-primary', + tier: 'pro_plus', + planKey: 'price_pro_plus_annual', + billingCycle: 'annual', + label: 'Buy Annual', + }, + ], + }, + ], + }; + billingState.upgradeCheckoutResult.status = 'ready'; + billingState.upgradeCheckoutResult.data = { + status: 'fulfilled', + owner_email: 'buyer@example.com', + tier: 'pro_plus', + activation_key_prefix: 'ppk_live_preview', + max_monitored_systems: 50, + }; + + renderUpgradePanel(billingState, createBootstrap()); + + expect(document.getElementById('upgrade-billing-root')?.innerHTML).toContain('Buy Annual'); + expect(document.getElementById('upgrade-billing-root')?.innerHTML).toContain('Activate in Pulse Pro'); + expect(document.getElementById('upgrade-billing-root')?.innerHTML).toContain('session_id'); + expect(document.getElementById('upgrade-billing-root')?.innerHTML).toContain('ppk_live_preview'); + }); }); diff --git a/internal/cloudcp/portal/frontend/src/billing_view.ts b/internal/cloudcp/portal/frontend/src/billing_view.ts index 8663f674cb..18b67a8e46 100644 --- a/internal/cloudcp/portal/frontend/src/billing_view.ts +++ b/internal/cloudcp/portal/frontend/src/billing_view.ts @@ -1,4 +1,10 @@ -import type { PortalBootstrapData, RefundState, BillingStatus, VerificationFlowState } from './types'; +import type { + PortalBillingState, + PortalBootstrapData, + RefundState, + BillingStatus, + VerificationFlowState, +} from './types'; type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; @@ -67,6 +73,149 @@ export function renderBillingStatus(id: string, status: BillingStatus): void { el.className = 'billing-status visible' + (status.error ? ' error' : ' success'); } +function formatCheckoutDate(value: string | undefined): string { + if (!value) return ''; + var parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return ''; + return parsed.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function renderUpgradePlansHTML(billingState: PortalBillingState): string { + var pricing = billingState.upgradePricing.data; + if (!pricing || !Array.isArray(pricing.plans)) { + return ''; + } + + var plans = pricing.plans.filter(function(plan) { + return Array.isArray(plan.buttons) && plan.buttons.some(function(button) { + return button.kind === 'checkout' && button.planKey && button.billingCycle; + }); + }); + if (plans.length === 0) { + return ''; + } + + return '
' + plans.map(function(plan) { + var buttons = Array.isArray(plan.buttons) ? plan.buttons : []; + var checkoutButtons = buttons.filter(function(button) { + return button.kind === 'checkout' && button.planKey && button.billingCycle; + }); + return ( + '
' + + (plan.badge ? '
' + escapeText(plan.badge) + '
' : '') + + '
' + + '
' + escapeText(plan.tierKicker) + '
' + + '

' + escapeText(plan.title) + '

' + + '
' + escapeText(plan.price) + '
' + + '
' + escapeText(plan.period) + '
' + + '
' + + '

' + escapeText(plan.blurb) + '

' + + '
    ' + plan.features.map(function(feature) { + return ( + '
  • ' + + '' + String(feature.html || '') + '' + + '
  • ' + ); + }).join('') + '
' + + (plan.note ? '
' + escapeText(plan.note) + '
' : '') + + '
' + checkoutButtons.map(function(button) { + return ( + '' + ); + }).join('') + '
' + + '
' + ); + }).join('') + '
'; +} + +export function renderUpgradePanel(billingState: PortalBillingState, bootstrap: PortalBootstrapData): void { + var root = getElement('upgrade-billing-root'); + if (!root) return; + + var featureKey = String(billingState.upgradeFeatureKey || '').trim(); + var pricingState = billingState.upgradePricing; + var checkoutResultState = billingState.upgradeCheckoutResult; + var result = checkoutResultState.data; + var returnURL = String(billingState.upgradeReturnURL || '').trim(); + var sessionID = String(billingState.upgradeCheckoutSessionID || '').trim(); + var explainer = pricingState.data && pricingState.data.explainer ? pricingState.data.explainer : ''; + var summaryItems = [] as string[]; + + if (billingState.upgradeCheckoutStatus === 'cancelled') { + summaryItems.push('
Checkout was cancelled before completion.
'); + } + if (billingState.upgradeCheckout.pending) { + summaryItems.push('
Redirecting to secure checkout...
'); + } + if (billingState.upgradeCheckout.error) { + summaryItems.push('
' + escapeText(billingState.upgradeCheckout.error) + '
'); + } + if (pricingState.status === 'loading' && !pricingState.data) { + summaryItems.push('

Loading self-hosted plan options...

'); + } + if (pricingState.status === 'error') { + summaryItems.push( + '
' + escapeText(pricingState.error || 'Failed to load self-hosted plans.') + '
' + + '
', + ); + } + if (explainer) { + summaryItems.push('
' + explainer + '
'); + } + + if (checkoutResultState.status === 'loading') { + summaryItems.push('
Finalizing the completed checkout...
'); + } else if (checkoutResultState.status === 'error') { + summaryItems.push('
' + escapeText(checkoutResultState.error || 'Could not confirm the completed checkout.') + '
'); + } else if (result && result.status === 'fulfilled') { + var activationForm = returnURL && sessionID + ? ( + '
' + + '' + + '' + + '
' + + '' + + '
' + + '
' + ) + : '
Return to Pulse Pro billing, then use Pulse Account to retrieve the latest active license if automatic activation is unavailable.
'; + summaryItems.push( + '
' + + '

Checkout complete

' + + '
' + + '
Plan
' + escapeText(result.tier || result.plan_key || 'Purchased') + '
' + + '
Purchase email
' + escapeText(result.owner_email || bootstrap.email || '') + '
' + + '
Activation key
' + escapeText(result.activation_key_prefix || 'Issued') + '
' + + '
Monitored systems
' + escapeText(typeof result.max_monitored_systems === 'number' && result.max_monitored_systems > 0 ? String(result.max_monitored_systems) : 'Included') + '
' + + '
' + + activationForm + + '
', + ); + } else if (result && result.message) { + summaryItems.push('
' + escapeText(result.message) + '
'); + } + + root.innerHTML = + '
' + + summaryItems.join('') + + renderUpgradePlansHTML(billingState) + + (pricingState.status === 'ready' && pricingState.data && pricingState.data.description + ? '
' + escapeText(pricingState.data.description) + '
' + : '') + + (result && result.status === 'fulfilled' && formatCheckoutDate(result.current_period_end) + ? '
Next renewal: ' + escapeText(formatCheckoutDate(result.current_period_end)) + '
' + : '') + + '
'; +} + export function renderButton(id: string | undefined, disabled: boolean, label: string | undefined): void { if (!id || !label) return; var button = getElement(id); diff --git a/internal/cloudcp/portal/frontend/src/runtime.test.ts b/internal/cloudcp/portal/frontend/src/runtime.test.ts index 3ef7440e46..5b79e6a9ad 100644 --- a/internal/cloudcp/portal/frontend/src/runtime.test.ts +++ b/internal/cloudcp/portal/frontend/src/runtime.test.ts @@ -63,11 +63,14 @@ describe('portal runtime', function() { }); it('derives canonical email and billing handoff from the portal URL', function() { - var handoff = readPortalRuntimeHandoff('https://cloud.pulserelay.pro/portal?email=buyer%40example.com&service=upgrade&feature=max_monitored_systems'); + var handoff = readPortalRuntimeHandoff('https://cloud.pulserelay.pro/portal?email=buyer%40example.com&service=upgrade&feature=max_monitored_systems&return_url=https%3A%2F%2Fpulse.example.com%2Fauth%2Flicense-purchase-activate&checkout=success&session_id=cs_success'); expect(handoff.email).toBe('buyer@example.com'); expect(handoff.openBillingPanelID).toBe('upgrade-billing-panel'); expect(handoff.upgradeFeatureKey).toBe('max_monitored_systems'); + expect(handoff.upgradeReturnURL).toBe('https://pulse.example.com/auth/license-purchase-activate'); + expect(handoff.upgradeCheckoutStatus).toBe('success'); + expect(handoff.upgradeCheckoutSessionID).toBe('cs_success'); }); it('applies email and billing handoff to the initial portal store', function() { @@ -82,6 +85,9 @@ describe('portal runtime', function() { email: 'buyer@example.com', openBillingPanelID: 'refund-billing-panel', upgradeFeatureKey: '', + upgradeReturnURL: '', + upgradeCheckoutSessionID: '', + upgradeCheckoutStatus: '', } ); @@ -103,11 +109,19 @@ describe('portal runtime', function() { email: '', openBillingPanelID: 'upgrade-billing-panel', upgradeFeatureKey: 'max_monitored_systems', + upgradeReturnURL: 'https://pulse.example.com/auth/license-purchase-activate', + upgradeCheckoutSessionID: 'cs_success', + upgradeCheckoutStatus: 'success', } ); expect(runtime.store.getShellState().activeSection).toBe('billing'); expect(runtime.store.getBillingState().openBillingPanelID).toBe('upgrade-billing-panel'); expect(runtime.store.getBillingState().upgradeFeatureKey).toBe('max_monitored_systems'); + expect(runtime.store.getBillingState().upgradeReturnURL).toBe( + 'https://pulse.example.com/auth/license-purchase-activate', + ); + expect(runtime.store.getBillingState().upgradeCheckoutSessionID).toBe('cs_success'); + expect(runtime.store.getBillingState().upgradeCheckoutStatus).toBe('success'); }); }); diff --git a/internal/cloudcp/portal/frontend/src/runtime.ts b/internal/cloudcp/portal/frontend/src/runtime.ts index cc2d3477eb..442fc0fcc6 100644 --- a/internal/cloudcp/portal/frontend/src/runtime.ts +++ b/internal/cloudcp/portal/frontend/src/runtime.ts @@ -6,6 +6,9 @@ export interface PortalRuntimeHandoff { email: string; openBillingPanelID: string; upgradeFeatureKey: string; + upgradeReturnURL: string; + upgradeCheckoutSessionID: string; + upgradeCheckoutStatus: '' | 'success' | 'cancelled'; } export interface PortalRuntime { @@ -52,6 +55,25 @@ function normalizeUpgradeFeatureKey(value: string | null): string { return String(value || '').trim(); } +function normalizeUpgradeReturnURL(value: string | null): string { + return String(value || '').trim(); +} + +function normalizeUpgradeCheckoutSessionID(value: string | null): string { + return String(value || '').trim(); +} + +function normalizeUpgradeCheckoutStatus(value: string | null): '' | 'success' | 'cancelled' { + switch (String(value || '').trim()) { + case 'success': + return 'success'; + case 'cancelled': + return 'cancelled'; + default: + return ''; + } +} + export function readPortalRuntimeHandoff(locationHref: string | undefined = window.location.href): PortalRuntimeHandoff { try { var params = new URL(locationHref).searchParams; @@ -59,12 +81,18 @@ export function readPortalRuntimeHandoff(locationHref: string | undefined = wind email: normalizeHandoffEmail(params.get('email')), openBillingPanelID: normalizeHandoffBillingPanel(params.get('service')), upgradeFeatureKey: normalizeUpgradeFeatureKey(params.get('feature')), + upgradeReturnURL: normalizeUpgradeReturnURL(params.get('return_url')), + upgradeCheckoutSessionID: normalizeUpgradeCheckoutSessionID(params.get('session_id')), + upgradeCheckoutStatus: normalizeUpgradeCheckoutStatus(params.get('checkout')), }; } catch { return { email: '', openBillingPanelID: '', upgradeFeatureKey: '', + upgradeReturnURL: '', + upgradeCheckoutSessionID: '', + upgradeCheckoutStatus: '', }; } } @@ -113,11 +141,17 @@ export function createPortalRuntime( store.updateBillingState(function(billingState) { billingState.openBillingPanelID = handoff.openBillingPanelID; billingState.upgradeFeatureKey = handoff.upgradeFeatureKey; + billingState.upgradeReturnURL = handoff.upgradeReturnURL; + billingState.upgradeCheckoutSessionID = handoff.upgradeCheckoutSessionID; + billingState.upgradeCheckoutStatus = handoff.upgradeCheckoutStatus; }, { notify: false }); } else if (handoff.upgradeFeatureKey) { store.setActiveShellSection('billing'); store.updateBillingState(function(billingState) { billingState.upgradeFeatureKey = handoff.upgradeFeatureKey; + billingState.upgradeReturnURL = handoff.upgradeReturnURL; + billingState.upgradeCheckoutSessionID = handoff.upgradeCheckoutSessionID; + billingState.upgradeCheckoutStatus = handoff.upgradeCheckoutStatus; }, { notify: false }); } return { diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index 8bfb4def8d..d31deab0f7 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { PortalBillingState, PortalBootstrapData, PortalLoginState } from './types'; +import { createPortalBillingState } from './state'; import { renderAccountsHTML, renderAuthenticatedPortalHTML, @@ -44,60 +45,7 @@ function createLoginState(overrides: Partial = {}): PortalLogi function createBillingState(overrides: Partial = {}): PortalBillingState { return { - openBillingPanelID: '', - upgradeFeatureKey: '', - flows: { - manage: { - pendingEmail: '', - request: { pending: false, error: '' }, - confirm: { pending: false, error: '' }, - step2Visible: false, - status: { visible: false, message: '', error: false }, - result: null, - emailValue: '', - codeValue: '', - checkboxChecked: false, - }, - retrieve: { - pendingEmail: '', - request: { pending: false, error: '' }, - confirm: { pending: false, error: '' }, - step2Visible: false, - status: { visible: false, message: '', error: false }, - result: null, - emailValue: '', - codeValue: '', - checkboxChecked: false, - }, - export: { - pendingEmail: '', - request: { pending: false, error: '' }, - confirm: { pending: false, error: '' }, - step2Visible: false, - status: { visible: false, message: '', error: false }, - result: null, - emailValue: '', - codeValue: '', - checkboxChecked: false, - }, - delete: { - pendingEmail: '', - request: { pending: false, error: '' }, - confirm: { pending: false, error: '' }, - step2Visible: false, - status: { visible: false, message: '', error: false }, - result: null, - emailValue: '', - codeValue: '', - checkboxChecked: false, - }, - }, - refund: { - emailValue: '', - tokenValue: '', - submit: { pending: false, error: '' }, - status: { visible: false, message: '', error: false }, - }, + ...createPortalBillingState(), ...overrides, }; } @@ -689,8 +637,8 @@ describe('shell view', function() { expect(html).toContain('Pulse Account owns the commercial handoff for self-hosted upgrades from the app.'); expect(html).toContain('Upgrade monitored-system cap'); - expect(html).toContain('Compare monitored-system plans'); - expect(html).toContain('utm_source=pulse-account'); + expect(html).toContain('id="upgrade-billing-root"'); + expect(html).toContain('Pulse Account owns self-hosted plan selection and checkout for Pulse Pro upgrades.'); expect(html).not.toContain('id="open-manage-billing"'); expect(html).not.toContain('id="open-retrieve-billing"'); }); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 7598b34b1b..4bcddd3434 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -152,26 +152,6 @@ function selfHostedUpgradeActionHighlights(featureKey: string): string[] { : ['Plan comparison', 'Checkout handoff']; } -function selfHostedUpgradePricingURL( - bootstrap: PortalBootstrapData, - featureKey: string, -): string { - var publicSiteURL = String(bootstrap.public_site_url || 'https://pulserelay.pro').trim(); - var normalizedFeature = normalizeUpgradeFeatureKey(featureKey); - try { - var url = new URL('/pricing', publicSiteURL); - if (normalizedFeature) { - url.searchParams.set('feature', normalizedFeature); - } - url.searchParams.set('utm_source', 'pulse-account'); - url.searchParams.set('utm_medium', 'portal'); - url.searchParams.set('utm_campaign', 'self_hosted_upgrade'); - return url.toString(); - } catch { - return 'https://pulserelay.pro/pricing'; - } -} - function renderSelfHostedUpgradeActionRow(context: ShellViewContext): string { var featureKey = normalizeUpgradeFeatureKey(context.billingState.upgradeFeatureKey); return renderBillingActionRow( @@ -187,24 +167,14 @@ function renderSelfHostedUpgradeActionRow(context: ShellViewContext): string { function renderSelfHostedUpgradeBillingPanel(context: ShellViewContext): string { var featureKey = normalizeUpgradeFeatureKey(context.billingState.upgradeFeatureKey); - var pricingURL = selfHostedUpgradePricingURL(context.bootstrap, featureKey); - var linkLabel = featureKey === 'max_monitored_systems' - ? 'Compare monitored-system plans' - : 'View self-hosted plans'; var helperCopy = featureKey === 'max_monitored_systems' - ? 'Choose the self-hosted tier that matches the monitored-system allowance you need, then return to Pulse Pro to activate the new license.' - : 'Choose the self-hosted tier that fits this upgrade, then return to Pulse Pro to activate the new license.'; + ? 'Choose the self-hosted tier that matches the monitored-system allowance you need, then send the completed purchase back to Pulse Pro for activation.' + : 'Choose the self-hosted tier that fits this upgrade, then send the completed purchase back to Pulse Pro for activation.'; return renderBillingTaskPanel( selfHostedUpgradeActionTitle(featureKey), - 'Pulse Account owns the commercial handoff. Plan selection and checkout continue on the public self-hosted pricing site.', + 'Pulse Account owns self-hosted plan selection and checkout for Pulse Pro upgrades.', 'upgrade-billing-panel', - '' + + '
' + '
' + escapeHTML(helperCopy) + '
', ); } diff --git a/internal/cloudcp/portal/frontend/src/state.test.ts b/internal/cloudcp/portal/frontend/src/state.test.ts index 1b953a473f..0f873cb00f 100644 --- a/internal/cloudcp/portal/frontend/src/state.test.ts +++ b/internal/cloudcp/portal/frontend/src/state.test.ts @@ -35,6 +35,11 @@ describe('portal state', function() { expect(billingState.flows.manage.confirm.pending).toBe(false); expect(billingState.refund.submit.pending).toBe(false); expect(billingState.upgradeFeatureKey).toBe(''); + expect(billingState.upgradeReturnURL).toBe(''); + expect(billingState.upgradeCheckoutSessionID).toBe(''); + expect(billingState.upgradeCheckoutStatus).toBe(''); + expect(billingState.upgradePricing.status).toBe('idle'); + expect(billingState.upgradeCheckoutResult.status).toBe('idle'); billingState.flows.manage.emailValue = 'override@example.com'; syncBillingStateBootstrapEmail(billingState, 'owner@example.com'); diff --git a/internal/cloudcp/portal/frontend/src/state.ts b/internal/cloudcp/portal/frontend/src/state.ts index 9c4b7b08b3..3b0d208898 100644 --- a/internal/cloudcp/portal/frontend/src/state.ts +++ b/internal/cloudcp/portal/frontend/src/state.ts @@ -118,6 +118,12 @@ export function createPortalBillingState(): PortalBillingState { return { openBillingPanelID: '', upgradeFeatureKey: '', + upgradeReturnURL: '', + upgradeCheckoutSessionID: '', + upgradeCheckoutStatus: '', + upgradePricing: createQueryState(null), + upgradeCheckout: createMutationState(), + upgradeCheckoutResult: createQueryState(null), flows: { manage: newVerificationFlowState(), retrieve: newVerificationFlowState(), diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css index f8d710f017..2b64290486 100644 --- a/internal/cloudcp/portal/frontend/src/styles.css +++ b/internal/cloudcp/portal/frontend/src/styles.css @@ -1034,6 +1034,104 @@ header .logout-btn:hover, background: var(--bg-subtle); } +.billing-upgrade-root { + display: flex; + flex-direction: column; + gap: 12px; +} + +.billing-upgrade-plan-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.billing-upgrade-plan-card { + position: relative; + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + border: 1px solid var(--border-muted); + border-radius: var(--radius); + background: var(--bg-subtle); +} + +.billing-upgrade-plan-card.highlight { + border-color: var(--accent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent); +} + +.billing-upgrade-plan-badge { + align-self: flex-start; + padding: 4px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.billing-upgrade-plan-header { + display: flex; + flex-direction: column; + gap: 4px; +} + +.billing-upgrade-plan-kicker { + font-size: 12px; + font-weight: 600; + color: var(--ink-muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.billing-upgrade-plan-header h4 { + margin: 0; + font-size: 18px; + font-weight: 700; +} + +.billing-upgrade-plan-price { + font-size: 22px; + font-weight: 700; + color: var(--ink); +} + +.billing-upgrade-plan-period, +.billing-upgrade-plan-blurb { + font-size: 13px; + color: var(--ink-secondary); +} + +.billing-upgrade-plan-blurb { + margin: 0; +} + +.billing-upgrade-plan-features { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 8px; +} + +.billing-upgrade-plan-feature { + display: flex; + gap: 8px; + align-items: flex-start; + font-size: 13px; + color: var(--ink); +} + +.billing-upgrade-plan-feature::before { + content: "•"; + color: var(--accent); + font-weight: 700; +} + .billing-result label { display: block; margin-bottom: 4px; font-size: 13px; font-weight: 500; } .billing-result textarea { width: 100%; min-height: 60px; padding: 8px; border: 1px solid var(--border); border-radius: var(--radius); font: inherit; font-size: 13px; resize: vertical; } .result-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-top: 8px; } diff --git a/internal/cloudcp/portal/frontend/src/types.ts b/internal/cloudcp/portal/frontend/src/types.ts index ebc2ac49e2..fb0d3d8703 100644 --- a/internal/cloudcp/portal/frontend/src/types.ts +++ b/internal/cloudcp/portal/frontend/src/types.ts @@ -44,6 +44,63 @@ export interface PortalBootstrapData { accounts: PortalAccountSummary[]; } +export interface PortalUpgradePricingFeature { + tone: string; + html: string; +} + +export interface PortalUpgradePricingButton { + kind: string; + href?: string; + className: string; + tier?: string; + planKey?: string; + billingCycle?: string; + label: string; +} + +export interface PortalUpgradePricingPlan { + badge?: string; + highlight?: boolean; + tierKicker: string; + title: string; + price: string; + period: string; + blurb: string; + features: PortalUpgradePricingFeature[]; + buttons?: PortalUpgradePricingButton[]; + note?: string; +} + +export interface PortalUpgradePricingModel { + title: string; + description: string; + explainer?: string; + plans: PortalUpgradePricingPlan[]; +} + +export type PortalUpgradeCheckoutStatus = '' | 'success' | 'cancelled'; + +export interface PortalCheckoutSessionCreateResponse { + url?: string; + plan_key?: string; + tier?: string; + billing_cycle?: string; +} + +export interface PortalCheckoutSessionResult { + status?: string; + message?: string; + checkout_status?: string; + payment_status?: string; + owner_email?: string; + tier?: string; + plan_key?: string; + activation_key_prefix?: string; + max_monitored_systems?: number; + current_period_end?: string; +} + export interface PortalLoginState { emailValue: string; request: PortalMutationState; @@ -116,6 +173,12 @@ export interface RefundState { export interface PortalBillingState { openBillingPanelID: string; upgradeFeatureKey: string; + upgradeReturnURL: string; + upgradeCheckoutSessionID: string; + upgradeCheckoutStatus: PortalUpgradeCheckoutStatus; + upgradePricing: PortalQueryState; + upgradeCheckout: PortalMutationState; + upgradeCheckoutResult: PortalQueryState; flows: Record; refund: RefundState; } diff --git a/internal/cloudcp/portal/handlers_test.go b/internal/cloudcp/portal/handlers_test.go index 328bd7f8d0..789635d3db 100644 --- a/internal/cloudcp/portal/handlers_test.go +++ b/internal/cloudcp/portal/handlers_test.go @@ -1261,3 +1261,33 @@ func TestCommercialProxy_UpstreamFailureReturnsBadGateway(t *testing.T) { t.Fatalf("body = %q", got) } } + +func TestCommercialProxy_AllowsPricingAndCheckoutGETRequests(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + if r.URL.Path != "/v1/public/pricing-model" { + t.Fatalf("path = %s, want /v1/public/pricing-model", r.URL.Path) + } + if got := r.URL.Query().Get("track"); got != "v6" { + t.Fatalf("track = %q, want v6", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"title":"pricing"}`)) + })) + defer upstream.Close() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, PortalCommercialProxyPath+"v1/public/pricing-model?track=v6", nil) + req.SetPathValue("commercial_path", "v1/public/pricing-model") + + HandleCommercialProxy(CommercialProxyConfig{BaseURL: upstream.URL})(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String()) + } + if got := strings.TrimSpace(rec.Body.String()); got != `{"title":"pricing"}` { + t.Fatalf("body = %q", got) + } +} diff --git a/pkg/licensing/entitlement_payload.go b/pkg/licensing/entitlement_payload.go index 9e3e5e1afd..2ac2af358b 100644 --- a/pkg/licensing/entitlement_payload.go +++ b/pkg/licensing/entitlement_payload.go @@ -84,6 +84,22 @@ type EntitlementPayload struct { CommercialMigration *CommercialMigrationStatus `json:"commercial_migration,omitempty"` } +// RuntimeCapabilitiesPayload is the canonical non-commercial license contract +// for feature gating and runtime retention/limit decisions. +type RuntimeCapabilitiesPayload struct { + // Capabilities lists all granted capability keys. + Capabilities []string `json:"capabilities"` + + // Limits lists quantitative limits with current usage. + Limits []LimitStatus `json:"limits"` + + // HostedMode indicates that this server is running in Pulse hosted mode. + HostedMode bool `json:"hosted_mode"` + + // MaxHistoryDays is the maximum metrics history retention in days for the current tier. + MaxHistoryDays int `json:"max_history_days"` +} + // LimitStatus represents a quantitative limit with current usage state. type LimitStatus struct { // Key is the limit identifier (e.g., "max_monitored_systems"). @@ -141,6 +157,35 @@ func BuildEntitlementPayload(status *LicenseStatus, subscriptionState string) En return BuildEntitlementPayloadWithUsage(status, subscriptionState, EntitlementUsageSnapshot{}, nil) } +// BuildRuntimeCapabilitiesPayload constructs the canonical non-commercial +// runtime capability payload from LicenseStatus. +func BuildRuntimeCapabilitiesPayload( + status *LicenseStatus, + subscriptionState string, +) RuntimeCapabilitiesPayload { + return BuildRuntimeCapabilitiesPayloadWithUsage( + status, + subscriptionState, + EntitlementUsageSnapshot{}, + ) +} + +// BuildRuntimeCapabilitiesPayloadWithUsage constructs the canonical +// non-commercial runtime capability payload from LicenseStatus and observed usage. +func BuildRuntimeCapabilitiesPayloadWithUsage( + status *LicenseStatus, + subscriptionState string, + usage EntitlementUsageSnapshot, +) RuntimeCapabilitiesPayload { + entitlementPayload := BuildEntitlementPayloadWithUsage(status, subscriptionState, usage, nil) + return RuntimeCapabilitiesPayload{ + Capabilities: append([]string(nil), entitlementPayload.Capabilities...), + Limits: append([]LimitStatus(nil), entitlementPayload.Limits...), + HostedMode: entitlementPayload.HostedMode, + MaxHistoryDays: entitlementPayload.MaxHistoryDays, + } +} + // BuildEntitlementPayloadWithUsage constructs the normalized payload from LicenseStatus and observed usage. func BuildEntitlementPayloadWithUsage( status *LicenseStatus, diff --git a/pkg/licensing/entitlement_payload_test.go b/pkg/licensing/entitlement_payload_test.go index b710db5d52..70b9511f0e 100644 --- a/pkg/licensing/entitlement_payload_test.go +++ b/pkg/licensing/entitlement_payload_test.go @@ -240,6 +240,45 @@ func TestBuildEntitlementPayload_NilStatus_MaxHistoryDays(t *testing.T) { } } +func TestBuildRuntimeCapabilitiesPayloadWithUsage_CurrentValues(t *testing.T) { + status := &LicenseStatus{ + Valid: true, + Tier: TierRelay, + Features: append([]string(nil), TierFeatures[TierRelay]...), + MaxMonitoredSystems: 12, + } + + payload := BuildRuntimeCapabilitiesPayloadWithUsage(status, "", EntitlementUsageSnapshot{ + MonitoredSystems: 5, + }) + + if !reflect.DeepEqual(payload.Capabilities, status.Features) { + t.Fatalf("expected capabilities to match status features") + } + if payload.MaxHistoryDays != TierHistoryDays[TierRelay] { + t.Fatalf("expected relay max history days %d, got %d", TierHistoryDays[TierRelay], payload.MaxHistoryDays) + } + if len(payload.Limits) != 1 { + t.Fatalf("expected one runtime limit, got %d", len(payload.Limits)) + } + if payload.Limits[0].Key != MaxMonitoredSystemsLicenseGateKey { + t.Fatalf("expected runtime limit key %q, got %q", MaxMonitoredSystemsLicenseGateKey, payload.Limits[0].Key) + } + if payload.Limits[0].Current != 5 { + t.Fatalf("expected runtime current 5, got %d", payload.Limits[0].Current) + } +} + +func TestBuildRuntimeCapabilitiesPayload_NilStatusDefaultsToFreeTier(t *testing.T) { + payload := BuildRuntimeCapabilitiesPayload(nil, "") + if payload.MaxHistoryDays != TierHistoryDays[TierFree] { + t.Fatalf("expected MaxHistoryDays=%d for nil status, got %d", TierHistoryDays[TierFree], payload.MaxHistoryDays) + } + if len(payload.Capabilities) != 0 { + t.Fatalf("expected no runtime capabilities for nil status, got %+v", payload.Capabilities) + } +} + func TestLimitState(t *testing.T) { tests := []struct { name string diff --git a/pkg/licensing/license_server_client.go b/pkg/licensing/license_server_client.go index bc4894995c..8b62bef5a4 100644 --- a/pkg/licensing/license_server_client.go +++ b/pkg/licensing/license_server_client.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "strings" "time" @@ -20,6 +21,21 @@ type LicenseServerClient struct { httpClient *http.Client } +// CheckoutSessionResult is the canonical purchase-fulfillment response returned +// by the commercial checkout session endpoint. +type CheckoutSessionResult struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + CheckoutStatus string `json:"checkout_status,omitempty"` + PaymentStatus string `json:"payment_status,omitempty"` + LicenseID string `json:"license_id,omitempty"` + ActivationKey string `json:"activation_key,omitempty"` + ActivationKeyPrefix string `json:"activation_key_prefix,omitempty"` + OwnerEmail string `json:"owner_email,omitempty"` + Tier string `json:"tier,omitempty"` + PlanKey string `json:"plan_key,omitempty"` +} + // NewLicenseServerClient creates a client for the license server. // The base URL defaults to DefaultLicenseServerURL. The PULSE_LICENSE_SERVER_URL // env var override is only allowed in non-release builds. @@ -140,6 +156,42 @@ func (c *LicenseServerClient) RefreshGrant(ctx context.Context, installationID, return &result, nil } +// GetCheckoutSessionResult resolves a commercial checkout session into its +// authoritative fulfillment payload. +func (c *LicenseServerClient) GetCheckoutSessionResult(ctx context.Context, sessionID string) (*CheckoutSessionResult, error) { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return nil, fmt.Errorf("checkout session id is required") + } + + httpReq, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + c.baseURL+"/v1/checkout/session?session_id="+url.QueryEscape(sessionID), + nil, + ) + if err != nil { + return nil, fmt.Errorf("create checkout session result request: %w", err) + } + httpReq.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("checkout session result request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, c.parseError(resp) + } + + var result CheckoutSessionResult + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil { + return nil, fmt.Errorf("decode checkout session result response: %w", err) + } + return &result, nil +} + // BootstrapQuickstart exchanges a runtime identity for a short-lived // quickstart token and the current authoritative quickstart inventory. // Callers must pass a server-verified quickstart authority token as diff --git a/pkg/licensing/license_server_client_test.go b/pkg/licensing/license_server_client_test.go index eb08a78959..38e5e7c273 100644 --- a/pkg/licensing/license_server_client_test.go +++ b/pkg/licensing/license_server_client_test.go @@ -270,6 +270,58 @@ func TestClientExchangeLegacyLicense(t *testing.T) { }) } +func TestClientGetCheckoutSessionResult(t *testing.T) { + t.Run("successful checkout session lookup", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("Method = %q, want GET", r.Method) + } + if r.URL.Path != "/v1/checkout/session" { + t.Errorf("Path = %q, want /v1/checkout/session", r.URL.Path) + } + if got := r.URL.Query().Get("session_id"); got != "cs_test_123" { + t.Errorf("session_id = %q, want cs_test_123", got) + } + if r.Header.Get("Accept") != "application/json" { + t.Errorf("Accept = %q, want application/json", r.Header.Get("Accept")) + } + + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(CheckoutSessionResult{ + Status: "fulfilled", + CheckoutStatus: "complete", + PaymentStatus: "paid", + ActivationKeyPrefix: "ppk_live_", + Tier: "pro", + }) + })) + defer server.Close() + + client := NewLicenseServerClient(server.URL) + result, err := client.GetCheckoutSessionResult(context.Background(), " cs_test_123 ") + if err != nil { + t.Fatalf("GetCheckoutSessionResult failed: %v", err) + } + if result.Status != "fulfilled" { + t.Fatalf("Status = %q, want fulfilled", result.Status) + } + if result.CheckoutStatus != "complete" { + t.Fatalf("CheckoutStatus = %q, want complete", result.CheckoutStatus) + } + }) + + t.Run("empty session id fails before transport", func(t *testing.T) { + client := NewLicenseServerClient("https://license.example.com") + _, err := client.GetCheckoutSessionResult(context.Background(), " ") + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "checkout session id is required" { + t.Fatalf("err = %q, want checkout session id is required", err.Error()) + } + }) +} + func TestClientRefreshGrant(t *testing.T) { t.Run("successful refresh", func(t *testing.T) { newGrantJWT := makeTestGrantJWT(t, &GrantClaims{ diff --git a/tests/integration/tests/53-demo-mode-commercial-boundary.spec.ts b/tests/integration/tests/53-demo-mode-commercial-boundary.spec.ts index 4be63a0791..392d1a339a 100644 --- a/tests/integration/tests/53-demo-mode-commercial-boundary.spec.ts +++ b/tests/integration/tests/53-demo-mode-commercial-boundary.spec.ts @@ -19,7 +19,7 @@ const SECURITY_STATUS_DEMO_PAYLOAD = { }, }; -const DEMO_PUBLIC_ENTITLEMENTS = { +const DEMO_PUBLIC_RUNTIME_CAPABILITIES = { capabilities: ['ai_patrol', 'relay'], limits: [ { @@ -29,19 +29,8 @@ const DEMO_PUBLIC_ENTITLEMENTS = { state: 'ok', }, ], - subscription_state: 'active', - upgrade_reasons: [], - tier: 'free', hosted_mode: false, - valid: false, - trial_eligible: false, max_history_days: 90, - legacy_connections: { - proxmox_nodes: 0, - docker_hosts: 0, - kubernetes_clusters: 0, - }, - has_migration_gap: false, }; const authenticatedTest = base.extend<{}, WorkerFixtures>({ @@ -112,6 +101,7 @@ base.describe('Demo mode commercial boundary', () => { async ({ page, }, testInfo) => { + let runtimeCapabilitiesRequests = 0; let entitlementsRequests = 0; let licenseStatusRequests = 0; let monitoredSystemLedgerRequests = 0; @@ -129,12 +119,21 @@ base.describe('Demo mode commercial boundary', () => { }); }); - await page.route('**/api/license/entitlements', async (route) => { - entitlementsRequests += 1; + await page.route('**/api/license/runtime-capabilities', async (route) => { + runtimeCapabilitiesRequests += 1; await route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify(DEMO_PUBLIC_ENTITLEMENTS), + body: JSON.stringify(DEMO_PUBLIC_RUNTIME_CAPABILITIES), + }); + }); + + await page.route('**/api/license/entitlements', async (route) => { + entitlementsRequests += 1; + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ error: 'not found' }), }); }); @@ -169,8 +168,11 @@ base.describe('Demo mode commercial boundary', () => { await page.waitForURL('**/settings/infrastructure/install', { timeout: 15_000 }); await expect - .poll(() => entitlementsRequests, { message: 'expected demo shell to load entitlements' }) + .poll(() => runtimeCapabilitiesRequests, { + message: 'expected demo shell to load runtime capabilities', + }) .toBeGreaterThan(0); + expect(entitlementsRequests, 'demo settings route should not read commercial entitlements').toBe(0); await expect( page.getByText('Demo instance with mock data (read-only)', { exact: true }), @@ -210,7 +212,7 @@ base.describe('Demo mode commercial boundary', () => { ); authenticatedTest( - 'exposes only the redacted commercial contract in the browser', + 'exposes only the runtime capability contract in the browser', async ({ page }) => { await page.route('**/api/security/status', async (route) => { await route.fulfill({ @@ -220,11 +222,11 @@ base.describe('Demo mode commercial boundary', () => { }); }); - await page.route('**/api/license/entitlements', async (route) => { + await page.route('**/api/license/runtime-capabilities', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify(DEMO_PUBLIC_ENTITLEMENTS), + body: JSON.stringify(DEMO_PUBLIC_RUNTIME_CAPABILITIES), }); }); @@ -240,6 +242,7 @@ base.describe('Demo mode commercial boundary', () => { await hiddenNotFound('**/api/license/status'); await hiddenNotFound('**/api/license/features'); + await hiddenNotFound('**/api/license/entitlements'); await hiddenNotFound('**/api/license/activate'); await hiddenNotFound('**/api/license/clear'); await hiddenNotFound('**/api/license/trial/start'); @@ -266,9 +269,10 @@ base.describe('Demo mode commercial boundary', () => { }; return { - entitlements: await probe('/api/license/entitlements'), + runtimeCapabilities: await probe('/api/license/runtime-capabilities'), status: await probe('/api/license/status'), features: await probe('/api/license/features'), + entitlements: await probe('/api/license/entitlements'), activate: await probe('/api/license/activate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -287,23 +291,21 @@ base.describe('Demo mode commercial boundary', () => { }; }); - expect(responses.entitlements.status).toBe(200); - expect(responses.entitlements.body).toMatchObject({ + expect(responses.runtimeCapabilities.status).toBe(200); + expect(responses.runtimeCapabilities.body).toMatchObject({ capabilities: ['ai_patrol', 'relay'], - subscription_state: 'active', - upgrade_reasons: [], - tier: 'free', - valid: false, - trial_eligible: false, + hosted_mode: false, max_history_days: 90, }); - expect(responses.entitlements.body).not.toHaveProperty('licensed_email'); - expect(responses.entitlements.body).not.toHaveProperty('trial_days_remaining'); - expect(responses.entitlements.body).not.toHaveProperty('trial_expires_at'); - expect((responses.entitlements.body as { limits?: Array<{ key: string; current: number; limit: number; state: string }> }).limits).toEqual([ + expect(responses.runtimeCapabilities.body).not.toHaveProperty('licensed_email'); + expect(responses.runtimeCapabilities.body).not.toHaveProperty('tier'); + expect(responses.runtimeCapabilities.body).not.toHaveProperty('subscription_state'); + expect(responses.runtimeCapabilities.body).not.toHaveProperty('upgrade_reasons'); + expect((responses.runtimeCapabilities.body as { limits?: Array<{ key: string; current: number; limit: number; state: string }> }).limits).toEqual([ { key: 'max_monitored_systems', limit: 0, current: 0, state: 'ok' }, ]); + expect(responses.entitlements.status).toBe(404); expect(responses.status.status).toBe(404); expect(responses.features.status).toBe(404); expect(responses.activate.status).toBe(404);