Prepare v6 demo mode commercial boundary

This commit is contained in:
rcourtman 2026-04-06 10:53:07 +01:00
parent 1db6b41aac
commit 4e4c1b368b
31 changed files with 457 additions and 136 deletions

View file

@ -301,6 +301,14 @@ This subsystem now sits under the dedicated agent lifecycle and fleet
operations lane so install, registration, update continuity, profile
management, and fleet safety stop hiding inside architecture, migration, or
monitoring work.
That same adjacent `internal/api/` boundary now also keeps public demos from
leaking commercial state through lifecycle-adjacent surfaces. Agent install,
reporting, and setup flows may share backend helpers with billing or license
transport, but `DEMO_MODE` must continue to 404 commercial read surfaces
instead of teaching lifecycle or mock-mode paths to bypass licensing. Public
demo readiness therefore comes from hiding commercial presentation on the
shared API boundary, not from introducing a second fake-entitlement path into
lifecycle-owned install or reporting flows.
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
@ -1825,3 +1833,11 @@ may read websocket state only through
recreate app-shell providers, because `frontend-modern/src/App.tsx` owns
provider placement while lifecycle hooks must stay lazy-load safe and
shell-independent.
That same adjacent `internal/api/` boundary now also keeps public demos from
leaking commercial state through lifecycle-adjacent surfaces. Agent install,
reporting, and setup flows may share backend helpers with billing or license
transport, but `DEMO_MODE` must continue to 404 commercial read surfaces
instead of teaching lifecycle or mock-mode paths to bypass licensing. Public
demo readiness therefore comes from hiding commercial presentation on the
shared API boundary, not from introducing a second fake-entitlement path into
lifecycle-owned install or reporting flows.

View file

@ -74,6 +74,7 @@ Own canonical runtime payload shapes between backend and frontend.
50. `internal/cloudcp/portal/frontend_sync_test.go`
51. `internal/api/recovery_handlers.go`
52. `internal/api/config_setup_handlers.go`
53. `internal/api/demo_mode_commercial.go`
## Shared Boundaries
@ -2402,3 +2403,13 @@ may read websocket state through `frontend-modern/src/contexts/appRuntime.ts`,
but payload truth, bootstrap rules, and commercial identity still belong to
the governed API handlers and contract tests. Those hooks must not import
`@/App` or treat root-shell ownership as transport authority.
That same shared commercial API contract now also owns the public demo
read-side boundary. `internal/api/demo_mode_commercial.go`,
`internal/api/licensing_handlers.go`,
`internal/api/monitored_system_ledger.go`, and
`internal/api/subscription_state_handlers.go` must fail closed with a generic
`404` for public-demo billing, license-status, and monitored-system-ledger
reads whenever `DEMO_MODE` is enabled. Demo runtimes may still use real
server-side entitlement evaluation internally, but the governed browser/API
contract must not expose commercial identity, usage, or upgrade-state payloads
back to public viewers through those read surfaces.

View file

@ -148,6 +148,16 @@ agreement, and cloud-specific enforcement rules.
Cloud paid readiness is materially behind architecture work. The main concern is
contract coherence between pricing, entitlements, and runtime enforcement.
That same cloud-paid/browser boundary now also governs public demo posture.
`DEMO_MODE` may run against a real internal entitlement, but public demo
surfaces must not reveal self-hosted license metadata, hosted billing state,
monitored-system ledgers, upgrade nudges, or activation controls just because
the underlying runtime is commercially enabled. `frontend-modern/src/utils/apiClient.ts`
must treat `X-Demo-Mode` as the canonical browser signal, and shared billing
or upgrade surfaces must hide or suppress themselves from that signal rather
than teaching mock mode or frontend-only feature flags to bypass the real
licensing model. Demo readiness therefore means presentation isolation, not a
license exemption.
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
versions are consumed at hosted entitlement and workspace-limit enforcement

View file

@ -396,6 +396,17 @@ bounds, including provider-backed alert-history wording. `frontend-modern/src/fe
backed host and VM incidents with the shared `resource-incident` vocabulary
and existing alert-history shells instead of introducing VMware-only labels,
badges, or panel copy just because the underlying signal came from vSphere.
That same shared settings-shell and banner boundary now also owns demo-mode
commercial suppression. `frontend-modern/src/components/Settings/settingsNavCatalog.ts`,
`frontend-modern/src/components/Settings/settingsNavVisibility.ts`,
`frontend-modern/src/components/shared/useTrialBannerState.ts`, and
`frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts`
must consume one shared demo-mode truth and hide billing tabs, trial nudges,
and monitored-system warning banners when the browser is rendering a public
demo runtime. Shared primitives must not perform their own ad hoc `/api/health`
polling or rebuild per-banner demo heuristics; the settings shell and shared
banner hooks stay on the canonical shared demo-mode owner so suppression stays
coherent across customer-facing surfaces.
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
@ -1819,3 +1830,14 @@ such as `frontend-modern/src/components/Settings/Settings.tsx`,
may consume that module, but they must not import `@/App` or recreate shell
providers. `frontend-modern/src/App.tsx` owns provider placement; primitives
own reusable consumption only.
That same shared settings-shell and banner boundary now also owns demo-mode
commercial suppression. `frontend-modern/src/components/Settings/settingsNavCatalog.ts`,
`frontend-modern/src/components/Settings/settingsNavVisibility.ts`,
`frontend-modern/src/components/shared/useTrialBannerState.ts`, and
`frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts`
must consume one shared demo-mode truth and hide billing tabs, trial nudges,
and monitored-system warning banners when the browser is rendering a public
demo runtime. Shared primitives must not perform their own ad hoc `/api/health`
polling or rebuild per-banner demo heuristics; the settings shell and shared
banner hooks stay on the canonical shared demo-mode owner so suppression stays
coherent across customer-facing surfaces.

View file

@ -314,6 +314,14 @@ querying, and the operator-facing storage health presentation layer.
This subsystem now sits under the dedicated storage and recovery lane so the
operator-facing storage page, recovery timeline, and recovery-point persistence
engine stop hiding inside broader monitoring and E2E buckets.
That same adjacent `internal/api/` boundary now also governs public-demo
commercial redaction for storage and recovery viewers. Shared storage/recovery
surfaces may run beside a demo runtime that has real internal entitlements,
but `DEMO_MODE` must still 404 license-status, billing-state, and monitored-
system-ledger reads so adjacent recovery or storage pages do not leak
commercial identity or upgrade posture into a public demo. Storage/recovery
must consume that redacted boundary as presentation truth rather than
reintroducing mock-only license bypasses or page-local commercial fallbacks.
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
@ -1988,3 +1996,11 @@ through `frontend-modern/src/contexts/appRuntime.ts`. They must not import
`@/App` or create storage/recovery-local shell coupling, because provider
placement remains app-shell-owned and storage/recovery surfaces must stay
lazy-load safe.
That same adjacent `internal/api/` boundary now also governs public-demo
commercial redaction for storage and recovery viewers. Shared storage/recovery
surfaces may run beside a demo runtime that has real internal entitlements,
but `DEMO_MODE` must still 404 license-status, billing-state, and monitored-
system-ledger reads so adjacent recovery or storage pages do not leak
commercial identity or upgrade posture into a public demo. Storage/recovery
must consume that redacted boundary as presentation truth rather than
reintroducing mock-only license bypasses or page-local commercial fallbacks.

View file

@ -1,22 +1,16 @@
import { createSignal, onMount, Show } from 'solid-js';
import { apiFetch } from '@/utils/apiClient';
import { logger } from '@/utils/logger';
import { demoModeEnabled, ensureDemoModeResolved } from '@/stores/demoMode';
export function DemoBanner() {
const [isDemoMode, setIsDemoMode] = createSignal(false);
const [dismissed, setDismissed] = createSignal(false);
onMount(async () => {
// Check if we're in demo mode by trying a test request
try {
const response = await apiFetch('/api/health');
const demoHeader = response.headers.get('X-Demo-Mode');
if (demoHeader === 'true') {
setIsDemoMode(true);
}
} catch (error) {
// Non-fatal: banner remains hidden when demo detection cannot be verified.
logger.debug('[DemoBanner] Failed to check demo mode', error);
void ensureDemoModeResolved();
});
onMount(() => {
if (sessionStorage.getItem('demoBannerDismissed') === 'true') {
setDismissed(true);
}
});
@ -26,15 +20,8 @@ export function DemoBanner() {
sessionStorage.setItem('demoBannerDismissed', 'true');
};
// Check if already dismissed this session
onMount(() => {
if (sessionStorage.getItem('demoBannerDismissed') === 'true') {
setDismissed(true);
}
});
return (
<Show when={isDemoMode() && !dismissed()}>
<Show when={demoModeEnabled() && !dismissed()}>
<div class="bg-blue-50 dark:bg-blue-900 border-b border-blue-200 dark:border-blue-800 px-3 py-2">
<div class="container mx-auto flex items-center justify-between text-sm">
<div class="flex items-center gap-2 text-blue-700 dark:text-blue-300">

View file

@ -1440,6 +1440,14 @@ describe('Settings architecture guardrails', () => {
expect(SETTINGS_HEADER_META['organization-billing'].description).toContain('plan limits');
});
it('keeps demo-mode billing visibility on the shared settings shell owner', () => {
expect(settingsNavigationModelSource).toContain('hideInDemoMode?: boolean;');
expect(settingsNavVisibilitySource).toContain('item.hideInDemoMode && context.demoModeEnabled');
expect(getSettingsNavItem('system-billing')?.hideInDemoMode).toBe(true);
expect(getSettingsNavItem('organization-billing')?.hideInDemoMode).toBe(true);
expect(getSettingsNavItem('organization-billing-admin')?.hideInDemoMode).toBe(true);
});
it('keeps relay shell copy on the shared relay presentation owner', () => {
expect(settingsHeaderMetaSource).toContain('RELAY_SETTINGS_DESCRIPTION');
expect(relaySettingsPanelSource).toContain('description={RELAY_SETTINGS_DESCRIPTION}');

View file

@ -96,6 +96,26 @@ describe('settingsNavigation integration scaffold', () => {
).toBe(false);
});
it('hides billing tabs in demo mode', () => {
expect(
shouldHideSettingsNavItem('system-billing', {
hasFeature: hasFeatures([]),
licenseLoaded: () => true,
demoModeEnabled: true,
hostedModeEnabled: false,
}),
).toBe(true);
expect(
shouldHideSettingsNavItem('organization-billing', {
hasFeature: hasFeatures(['multi_tenant']),
licenseLoaded: () => true,
demoModeEnabled: true,
hostedModeEnabled: true,
}),
).toBe(true);
});
it('hides tabs when the backend denies the required capability', () => {
expect(
shouldHideSettingsNavItem('api', {

View file

@ -72,6 +72,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
iconProps: { strokeWidth: 2 },
features: ['multi_tenant'],
hideWhenUnavailable: true,
hideInDemoMode: true,
},
{
id: 'organization-billing-admin',
@ -81,6 +82,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
features: ['multi_tenant'],
hideWhenUnavailable: true,
hostedOnly: true,
hideInDemoMode: true,
requiredCapability: 'billingAdmin',
},
],
@ -127,6 +129,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
id: 'system-billing',
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.shellTitle,
icon: PulseLogoIcon,
hideInDemoMode: true,
},
],
},

View file

@ -6,6 +6,8 @@ import type { SettingsTab } from './settingsNavigationModel';
export interface SettingsNavVisibilityContext {
hasFeature: (feature: string) => boolean;
licenseLoaded: () => boolean;
demoModeEnabled?: boolean;
demoModeResolved?: boolean;
hostedModeEnabled?: boolean;
settingsCapabilities?: Partial<SecurityStatusSettingsCapabilities> | null;
settingsCapabilitiesResolved?: boolean;
@ -30,6 +32,10 @@ export function shouldHideSettingsNavItem(
return true;
}
if (item.hideInDemoMode && context.demoModeEnabled) {
return true;
}
if (
item.requiredCapability &&
context.settingsCapabilitiesResolved &&

View file

@ -45,6 +45,7 @@ export interface SettingsNavItem {
locked?: boolean;
hideWhenUnavailable?: boolean;
hostedOnly?: boolean;
hideInDemoMode?: boolean;
requiredCapability?: keyof SecurityStatusSettingsCapabilities;
badge?: string;
features?: string[];

View file

@ -1,4 +1,5 @@
import { Accessor, createEffect, createMemo, createSignal } from 'solid-js';
import { demoModeEnabled, demoModeResolved } from '@/stores/demoMode';
import type { SecurityStatus } from '@/types/config';
import { logger } from '@/utils/logger';
import { hasFeature, isHostedModeEnabled, licenseLoaded } from '@/stores/license';
@ -35,6 +36,8 @@ export function useSettingsAccess({
!shouldHideSettingsNavItem(item.id, {
hasFeature,
licenseLoaded,
demoModeEnabled: demoModeEnabled(),
demoModeResolved: demoModeResolved(),
hostedModeEnabled,
settingsCapabilities,
settingsCapabilitiesResolved,
@ -70,9 +73,11 @@ export function useSettingsAccess({
const current = activeTab();
const requiresFeatureResolution = Boolean(tabFeatureRequirements[current]?.length);
const requiresCapabilityResolution = Boolean(getSettingsNavItem(current)?.requiredCapability);
const requiresDemoModeResolution = Boolean(getSettingsNavItem(current)?.hideInDemoMode);
if (
(requiresFeatureResolution && !licenseLoaded()) ||
(requiresCapabilityResolution && securityStatusLoading())
(requiresCapabilityResolution && securityStatusLoading()) ||
(requiresDemoModeResolution && !demoModeResolved())
) {
return;
}

View file

@ -5,47 +5,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/* Mocks */
/* ------------------------------------------------------------------ */
const apiFetchMock = vi.hoisted(() => vi.fn());
const demoModeEnabledMock = vi.hoisted(() => vi.fn());
const ensureDemoModeResolvedMock = vi.hoisted(() => vi.fn());
vi.mock('@/utils/apiClient', () => ({
apiFetch: apiFetchMock,
vi.mock('@/stores/demoMode', () => ({
demoModeEnabled: () => demoModeEnabledMock(),
ensureDemoModeResolved: (...args: unknown[]) => ensureDemoModeResolvedMock(...args),
}));
vi.mock('@/utils/logger', () => ({
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
/** Build a minimal Response-like object with the given demo header value. */
function fakeResponse(demoHeader: string | null) {
const headers = new Headers();
if (demoHeader !== null) {
headers.set('X-Demo-Mode', demoHeader);
}
return { ok: true, headers };
}
/** Create a deferred promise whose resolution the test controls. */
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */
describe('DemoBanner', () => {
beforeEach(() => {
apiFetchMock.mockReset();
demoModeEnabledMock.mockReset();
ensureDemoModeResolvedMock.mockReset();
demoModeEnabledMock.mockReturnValue(false);
ensureDemoModeResolvedMock.mockResolvedValue(false);
sessionStorage.clear();
});
@ -58,73 +39,28 @@ describe('DemoBanner', () => {
/* ---------- Visibility ---------- */
it('shows the banner when X-Demo-Mode header is "true"', async () => {
apiFetchMock.mockResolvedValue(fakeResponse('true'));
it('shows the banner when demo mode is enabled', async () => {
demoModeEnabledMock.mockReturnValue(true);
await renderBanner();
await waitFor(() => {
expect(screen.getByText('Demo instance with mock data (read-only)')).toBeInTheDocument();
});
expect(screen.getByText('Demo instance with mock data (read-only)')).toBeInTheDocument();
});
it('stays hidden when X-Demo-Mode header is absent', async () => {
const d = deferred();
apiFetchMock.mockReturnValue(d.promise);
it('stays hidden when demo mode is disabled', async () => {
await renderBanner();
// Banner must be hidden while request is in flight.
expect(screen.queryByText('Demo instance with mock data (read-only)')).not.toBeInTheDocument();
// Resolve with no demo header and verify banner stays hidden.
d.resolve(fakeResponse(null));
await waitFor(() => {
expect(apiFetchMock).toHaveBeenCalledTimes(1);
});
expect(screen.queryByText('Demo instance with mock data (read-only)')).not.toBeInTheDocument();
});
it('stays hidden when X-Demo-Mode header is "false"', async () => {
const d = deferred();
apiFetchMock.mockReturnValue(d.promise);
await renderBanner();
expect(screen.queryByText('Demo instance with mock data (read-only)')).not.toBeInTheDocument();
d.resolve(fakeResponse('false'));
await waitFor(() => {
expect(apiFetchMock).toHaveBeenCalledTimes(1);
});
expect(screen.queryByText('Demo instance with mock data (read-only)')).not.toBeInTheDocument();
});
it('stays hidden when the health check request fails', async () => {
const d = deferred();
apiFetchMock.mockReturnValue(d.promise);
await renderBanner();
expect(screen.queryByText('Demo instance with mock data (read-only)')).not.toBeInTheDocument();
d.reject(new Error('network error'));
await waitFor(() => {
expect(apiFetchMock).toHaveBeenCalledTimes(1);
});
expect(screen.queryByText('Demo instance with mock data (read-only)')).not.toBeInTheDocument();
});
/* ---------- Dismiss ---------- */
it('hides the banner when the dismiss button is clicked', async () => {
apiFetchMock.mockResolvedValue(fakeResponse('true'));
demoModeEnabledMock.mockReturnValue(true);
await renderBanner();
await waitFor(() => {
expect(screen.getByText('Demo instance with mock data (read-only)')).toBeInTheDocument();
});
expect(screen.getByText('Demo instance with mock data (read-only)')).toBeInTheDocument();
const dismissBtn = screen.getByTitle('Dismiss');
fireEvent.click(dismissBtn);
@ -137,13 +73,11 @@ describe('DemoBanner', () => {
});
it('persists dismissal to sessionStorage', async () => {
apiFetchMock.mockResolvedValue(fakeResponse('true'));
demoModeEnabledMock.mockReturnValue(true);
await renderBanner();
await waitFor(() => {
expect(screen.getByText('Demo instance with mock data (read-only)')).toBeInTheDocument();
});
expect(screen.getByText('Demo instance with mock data (read-only)')).toBeInTheDocument();
fireEvent.click(screen.getByTitle('Dismiss'));
@ -152,32 +86,22 @@ describe('DemoBanner', () => {
it('stays hidden when sessionStorage already has dismissal flag', async () => {
sessionStorage.setItem('demoBannerDismissed', 'true');
const d = deferred();
apiFetchMock.mockReturnValue(d.promise);
demoModeEnabledMock.mockReturnValue(true);
await renderBanner();
expect(screen.queryByText('Demo instance with mock data (read-only)')).not.toBeInTheDocument();
// Even when the API confirms demo mode, prior dismissal keeps the banner hidden.
d.resolve(fakeResponse('true'));
await waitFor(() => {
expect(apiFetchMock).toHaveBeenCalledTimes(1);
});
expect(screen.queryByText('Demo instance with mock data (read-only)')).not.toBeInTheDocument();
});
/* ---------- API call ---------- */
/* ---------- Demo-mode resolve ---------- */
it('calls /api/health exactly once on mount', async () => {
apiFetchMock.mockResolvedValue(fakeResponse(null));
it('resolves demo mode exactly once on mount', async () => {
ensureDemoModeResolvedMock.mockResolvedValue(false);
await renderBanner();
await waitFor(() => {
expect(apiFetchMock).toHaveBeenCalledTimes(1);
expect(ensureDemoModeResolvedMock).toHaveBeenCalledTimes(1);
});
expect(apiFetchMock).toHaveBeenCalledWith('/api/health');
});
});

View file

@ -469,8 +469,11 @@ describe('shared primitive guardrails', () => {
expect(trialBannerStateSource).toContain('createMemo');
expect(trialBannerStateSource).toContain('loadLicenseStatus');
expect(trialBannerStateSource).toContain('licenseStatus');
expect(trialBannerStateSource).toContain('demoModeEnabled');
expect(trialBannerStateSource).toContain('ensureDemoModeResolved');
expect(trialBannerStateSource).toContain('getUpgradeActionDestination');
expect(trialBannerStateSource).toContain('snoozeUpsell');
expect(trialBannerStateSource).not.toContain("fetch('/api/health'");
expect(trialBannerModelSource).toContain('TRIAL_BANNER_SNOOZE_KEY');
expect(trialBannerModelSource).toContain('normalizeTrialBannerDaysRemaining');
@ -661,6 +664,8 @@ describe('shared primitive guardrails', () => {
expect(monitoredSystemLimitWarningBannerStateSource).toContain('createEffect');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('createMemo');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('loadLicenseStatus');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('demoModeEnabled');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('ensureDemoModeResolved');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('trackUpgradeMetricEvent');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('legacyConnections');
expect(monitoredSystemLimitWarningBannerStateSource).toContain(
@ -670,6 +675,7 @@ describe('shared primitive guardrails', () => {
'SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID',
);
expect(monitoredSystemLimitWarningBannerStateSource).toContain('handleUpgradeClick');
expect(monitoredSystemLimitWarningBannerStateSource).not.toContain("fetch('/api/health'");
expect(monitoredSystemLimitWarningBannerModelSource).toContain(
'getMonitoredSystemMigrationMessage',

View file

@ -31,6 +31,8 @@ const mockLegacyConnections = vi.hoisted(() =>
const mockTrackUpgradeMetricEvent = vi.hoisted(() => vi.fn());
const mockTrackUpgradeClicked = vi.hoisted(() => vi.fn());
const mockLoadLicenseStatus = vi.hoisted(() => vi.fn());
const mockDemoModeEnabled = vi.hoisted(() => vi.fn(() => false));
const mockEnsureDemoModeResolved = vi.hoisted(() => vi.fn());
const mockGetUpgradeActionDestination = vi.hoisted(() => vi.fn());
const mockGetUpgradeActionUrlOrFallback = vi.hoisted(() => vi.fn());
@ -44,6 +46,11 @@ vi.mock('@/stores/license', () => ({
loadLicenseStatus: (...args: unknown[]) => mockLoadLicenseStatus(...args),
}));
vi.mock('@/stores/demoMode', () => ({
demoModeEnabled: () => mockDemoModeEnabled(),
ensureDemoModeResolved: (...args: unknown[]) => mockEnsureDemoModeResolved(...args),
}));
vi.mock('@/utils/upgradeMetrics', () => ({
UPGRADE_METRIC_EVENTS: {
LIMIT_WARNING_SHOWN: 'limit_warning_shown',
@ -63,6 +70,10 @@ describe('MonitoredSystemLimitWarningBanner', () => {
docker_hosts: 0,
kubernetes_clusters: 0,
});
mockDemoModeEnabled.mockReset();
mockDemoModeEnabled.mockReturnValue(false);
mockEnsureDemoModeResolved.mockReset();
mockEnsureDemoModeResolved.mockResolvedValue(false);
mockLoadLicenseStatus.mockReset();
mockLoadLicenseStatus.mockResolvedValue(undefined);
mockGetUpgradeActionDestination.mockReset();
@ -98,6 +109,7 @@ describe('MonitoredSystemLimitWarningBanner', () => {
expect(monitoredSystemLimitWarningBannerStateSource).toContain('createEffect');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('createMemo');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('loadLicenseStatus');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('ensureDemoModeResolved');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('trackUpgradeMetricEvent');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('legacyConnections');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('handleUpgradeClick');
@ -130,6 +142,7 @@ describe('MonitoredSystemLimitWarningBanner', () => {
));
expect(mockLoadLicenseStatus).toHaveBeenCalled();
expect(mockEnsureDemoModeResolved).toHaveBeenCalled();
expect(screen.queryByText(/Monitored systems:/i)).not.toBeInTheDocument();
});
@ -210,4 +223,24 @@ describe('MonitoredSystemLimitWarningBanner', () => {
'/settings/system/billing#pulse-pro-plan',
);
});
it('stays hidden in demo mode even when usage is urgent', async () => {
mockDemoModeEnabled.mockReturnValue(true);
mockGetLimit.mockReturnValue({
key: 'max_monitored_systems',
limit: 6,
current: 5,
state: 'warning',
});
const mod = await import('../MonitoredSystemLimitWarningBanner');
render(() => (
<Router>
<Route path="/" component={mod.MonitoredSystemLimitWarningBanner} />
</Router>
));
expect(screen.queryByText('Monitored systems: 5/6')).not.toBeInTheDocument();
expect(mockTrackUpgradeMetricEvent).not.toHaveBeenCalled();
});
});

View file

@ -8,6 +8,8 @@ import { TRIAL_BANNER_SNOOZE_KEY } from '@/components/shared/trialBannerModel';
import { getPublicPricingUrl } from '@/utils/pricingHandoff';
const {
demoModeEnabledMock,
ensureDemoModeResolvedMock,
getUpgradeActionDestinationMock,
getUpgradeActionUrlOrFallbackMock,
licenseStatusMock,
@ -16,6 +18,8 @@ const {
snoozeUpsellMock,
} =
vi.hoisted(() => ({
demoModeEnabledMock: vi.fn(),
ensureDemoModeResolvedMock: vi.fn(),
getUpgradeActionDestinationMock: vi.fn(),
getUpgradeActionUrlOrFallbackMock: vi.fn(),
licenseStatusMock: vi.fn(),
@ -31,6 +35,11 @@ vi.mock('@/stores/license', () => ({
loadLicenseStatus: (...args: unknown[]) => loadLicenseStatusMock(...args),
}));
vi.mock('@/stores/demoMode', () => ({
demoModeEnabled: () => demoModeEnabledMock(),
ensureDemoModeResolved: (...args: unknown[]) => ensureDemoModeResolvedMock(...args),
}));
vi.mock('@/utils/snooze', () => ({
isUpsellSnoozed: (...args: unknown[]) => isUpsellSnoozedMock(...args),
snoozeUpsell: (...args: unknown[]) => snoozeUpsellMock(...args),
@ -39,6 +48,8 @@ vi.mock('@/utils/snooze', () => ({
describe('TrialBanner', () => {
beforeEach(() => {
cleanup();
demoModeEnabledMock.mockReset();
ensureDemoModeResolvedMock.mockReset();
getUpgradeActionDestinationMock.mockReset();
getUpgradeActionUrlOrFallbackMock.mockReset();
licenseStatusMock.mockReset();
@ -49,6 +60,8 @@ describe('TrialBanner', () => {
href: getPublicPricingUrl('trial_banner'),
external: true,
});
demoModeEnabledMock.mockReturnValue(false);
ensureDemoModeResolvedMock.mockResolvedValue(false);
getUpgradeActionUrlOrFallbackMock.mockReturnValue(getPublicPricingUrl('trial_banner'));
loadLicenseStatusMock.mockResolvedValue(undefined);
isUpsellSnoozedMock.mockReturnValue(false);
@ -71,6 +84,7 @@ describe('TrialBanner', () => {
expect(trialBannerStateSource).toContain('createSignal');
expect(trialBannerStateSource).toContain('createMemo');
expect(trialBannerStateSource).toContain('loadLicenseStatus');
expect(trialBannerStateSource).toContain('ensureDemoModeResolved');
expect(trialBannerStateSource).toContain('licenseStatus');
expect(trialBannerStateSource).toContain('getUpgradeActionDestination');
expect(trialBannerStateSource).toContain('snoozeUpsell');
@ -92,6 +106,7 @@ describe('TrialBanner', () => {
await waitFor(() => {
expect(loadLicenseStatusMock).toHaveBeenCalled();
expect(ensureDemoModeResolvedMock).toHaveBeenCalled();
});
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByText('Pro Trial:')).toBeInTheDocument();
@ -113,6 +128,18 @@ describe('TrialBanner', () => {
expect(screen.getByText('Active')).toBeInTheDocument();
});
it('stays hidden in demo mode even when the workspace is on trial', () => {
demoModeEnabledMock.mockReturnValue(true);
licenseStatusMock.mockReturnValue({
subscription_state: 'trial',
trial_days_remaining: 2,
});
render(() => <TrialBanner />);
expect(screen.queryByRole('status')).toBeNull();
});
it('snoozes and hides the action row', async () => {
licenseStatusMock.mockReturnValue({
subscription_state: 'trial',

View file

@ -1,4 +1,5 @@
import { createEffect, createMemo, onMount } from 'solid-js';
import { demoModeEnabled, ensureDemoModeResolved } from '@/stores/demoMode';
import {
entitlements,
getLimit,
@ -33,11 +34,14 @@ import {
export function useMonitoredSystemLimitWarningBannerState() {
onMount(() => {
void loadLicenseStatus();
void ensureDemoModeResolved();
});
const monitoredSystemLimit = createMemo(() => getLimit(MONITORED_SYSTEM_LIMIT_KEY));
const isUrgent = createMemo(() => isMonitoredSystemLimitUrgent(monitoredSystemLimit()));
const showBanner = createMemo(() => shouldShowMonitoredSystemLimitBanner(monitoredSystemLimit()));
const showBanner = createMemo(
() => !demoModeEnabled() && shouldShowMonitoredSystemLimitBanner(monitoredSystemLimit()),
);
const migrationGap = createMemo(() => hasMigrationGap());
const migrationCounts = createMemo(() => legacyConnections());
const monitoredSystemSummary = createMemo(() =>
@ -69,8 +73,9 @@ export function useMonitoredSystemLimitWarningBannerState() {
let wasUrgent = false;
createEffect(() => {
const urgent = isUrgent();
const visible = showBanner();
const limit = monitoredSystemLimit();
if (urgent && !wasUrgent && limit) {
if (visible && urgent && !wasUrgent && limit) {
trackUpgradeMetricEvent({
type: UPGRADE_METRIC_EVENTS.LIMIT_WARNING_SHOWN,
surface: 'monitored_system_limit_banner',
@ -79,7 +84,7 @@ export function useMonitoredSystemLimitWarningBannerState() {
limit_value: limit.limit,
});
}
wasUrgent = urgent;
wasUrgent = visible && urgent;
});
const handleInstallCollectorsClick = () => {

View file

@ -1,4 +1,5 @@
import { createMemo, createSignal, onMount } from 'solid-js';
import { demoModeEnabled, ensureDemoModeResolved } from '@/stores/demoMode';
import { getUpgradeActionDestination, licenseStatus, loadLicenseStatus } from '@/stores/license';
import { isUpsellSnoozed, snoozeUpsell } from '@/utils/snooze';
import {
@ -13,9 +14,12 @@ export function useTrialBannerState() {
onMount(() => {
void loadLicenseStatus();
void ensureDemoModeResolved();
});
const isTrial = createMemo(() => licenseStatus()?.subscription_state === 'trial');
const isTrial = createMemo(
() => !demoModeEnabled() && licenseStatus()?.subscription_state === 'trial',
);
const daysRemaining = createMemo(() =>
normalizeTrialBannerDaysRemaining(licenseStatus()?.trial_days_remaining),
);
@ -33,7 +37,7 @@ export function useTrialBannerState() {
daysRemaining,
handleSnooze,
isTrial,
showActions: () => !snoozed(),
showActions: () => !demoModeEnabled() && !snoozed(),
toneClass,
upgradeDestination,
};

View file

@ -0,0 +1,52 @@
import { createSignal } from 'solid-js';
import { logger } from '@/utils/logger';
const [demoModeEnabled, setDemoModeEnabled] = createSignal(false);
const [demoModeResolved, setDemoModeResolved] = createSignal(false);
let pendingDemoModeCheck: Promise<boolean> | null = null;
function applyDemoModeHeaderValue(value: string | null): boolean {
const enabled = value === 'true';
setDemoModeEnabled(enabled);
setDemoModeResolved(true);
return enabled;
}
export function syncDemoModeFromResponse(response: Response): boolean {
return applyDemoModeHeaderValue(response.headers.get('X-Demo-Mode'));
}
export async function ensureDemoModeResolved(force = false): Promise<boolean> {
if (demoModeResolved() && !force) {
return demoModeEnabled();
}
if (pendingDemoModeCheck && !force) {
return pendingDemoModeCheck;
}
pendingDemoModeCheck = fetch('/api/health', {
method: 'GET',
cache: 'no-store',
credentials: 'include',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
})
.then((response) => syncDemoModeFromResponse(response))
.catch((error) => {
logger.debug('[demoModeStore] Failed to resolve demo mode from /api/health', error);
if (!demoModeResolved()) {
setDemoModeResolved(true);
}
return demoModeEnabled();
})
.finally(() => {
pendingDemoModeCheck = null;
});
return pendingDemoModeCheck;
}
export { demoModeEnabled, demoModeResolved };

View file

@ -67,6 +67,25 @@ describe('apiClient org context', () => {
expect(headers['X-Pulse-Org-ID']).toBe('tenant-ledger');
});
it('tracks demo mode from shared API response headers', async () => {
vi.resetModules();
mockFetch.mockResolvedValue(
new Response('{}', {
status: 200,
headers: { 'X-Demo-Mode': 'true' },
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const apiClientModule = await import('@/utils/apiClient');
const demoModeModule = await import('@/stores/demoMode');
await apiClientModule.apiFetch('/api/state');
expect(demoModeModule.demoModeResolved()).toBe(true);
expect(demoModeModule.demoModeEnabled()).toBe(true);
});
it('uses default org context when skipOrgContext is enabled', async () => {
mockFetch.mockResolvedValue(new Response('[]', { status: 200 }));

View file

@ -2,6 +2,7 @@
// This replaces the three separate auth utilities (api.ts, auth.ts, authInterceptor.ts)
import { logger } from '@/utils/logger';
import { syncDemoModeFromResponse } from '@/stores/demoMode';
import { STORAGE_KEYS } from '@/utils/localStorage';
const AUTH_STORAGE_KEY = STORAGE_KEYS.AUTH;
@ -626,7 +627,12 @@ class ApiClient {
credentials: 'include', // Important for session cookies
};
const response = await fetch(url, finalOptions);
const observeDemoMode = (response: Response): Response => {
syncDemoModeFromResponse(response);
return response;
};
const response = observeDemoMode(await fetch(url, finalOptions));
// Handle stale/invalid org context by clearing it and retrying once against default org.
if (
@ -649,11 +655,13 @@ class ApiClient {
this.setOrgID(null);
const retryHeaders: Record<string, string> = { ...finalHeaders };
delete retryHeaders[ORG_HEADER_NAME];
return fetch(url, {
return observeDemoMode(
await fetch(url, {
...fetchOptions,
headers: retryHeaders,
credentials: 'include',
});
}),
);
}
}
@ -694,11 +702,11 @@ class ApiClient {
this.csrfToken = refreshedToken;
logger.debug(`[apiClient] Retrying ${method} ${url} with refreshed CSRF token`);
finalHeaders['X-CSRF-Token'] = refreshedToken;
const retryResponse = await fetch(url, {
const retryResponse = observeDemoMode(await fetch(url, {
...fetchOptions,
headers: finalHeaders,
credentials: 'include',
});
}));
return retryResponse;
}
}
@ -740,11 +748,11 @@ class ApiClient {
throw err;
}
return fetch(url, {
return observeDemoMode(await fetch(url, {
...fetchOptions,
headers: finalHeaders,
credentials: 'include',
});
}));
}
return response;

View file

@ -294,6 +294,40 @@ func TestBillingStateHostedModeGate(t *testing.T) {
}
}
func TestBillingStateDemoModeGate(t *testing.T) {
router, _ := newBillingStateTestRouter(t, true)
router.config.DemoMode = true
router.mux = http.NewServeMux()
router.registerHostedRoutes(nil, nil, nil)
testCases := []struct {
method string
body string
}{
{
method: http.MethodGet,
body: "",
},
{
method: http.MethodPut,
body: `{
"capabilities":["feature_x"],
"limits":{"max_monitored_systems":10},
"meters_enabled":["api_requests"],
"plan_version":"pro-v1",
"subscription_state":"active"
}`,
},
}
for _, tc := range testCases {
rec := doBillingStateRequest(router, tc.method, "/api/admin/orgs/acme/billing-state", tc.body)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 when demo mode is enabled for %s, got %d: %s", tc.method, rec.Code, rec.Body.String())
}
}
}
func newBillingStateTestRouter(t *testing.T, hostedMode bool) (*Router, string) {
t.Helper()

View file

@ -4811,6 +4811,45 @@ func TestContract_HostedBillingStateFallbackJSONSnapshot(t *testing.T) {
assertJSONSnapshot(t, rec.Body.Bytes(), want)
}
func TestContract_DemoModeCommercialReadSurfaceReturnsNotFound(t *testing.T) {
t.Run("license status", func(t *testing.T) {
handlers := NewLicenseHandlers(nil, false, &config.Config{DemoMode: true})
req := httptest.NewRequest(http.MethodGet, "/api/license/status", nil)
rec := httptest.NewRecorder()
handlers.HandleLicenseStatus(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status=%d, want %d: %s", rec.Code, http.StatusNotFound, rec.Body.String())
}
})
t.Run("monitored system ledger", func(t *testing.T) {
router := &Router{config: &config.Config{DemoMode: true}}
req := httptest.NewRequest(http.MethodGet, "/api/license/monitored-system-ledger", nil)
rec := httptest.NewRecorder()
router.handleMonitoredSystemLedger(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status=%d, want %d: %s", rec.Code, http.StatusNotFound, rec.Body.String())
}
})
t.Run("hosted billing state", func(t *testing.T) {
handlers := NewBillingStateHandlers(config.NewFileBillingStore(t.TempDir()), true, true)
req := httptest.NewRequest(http.MethodGet, "/api/admin/orgs/t-tenant/billing-state", nil)
req.SetPathValue("id", "t-tenant")
rec := httptest.NewRecorder()
handlers.HandleGetBillingState(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status=%d, want %d: %s", rec.Code, http.StatusNotFound, rec.Body.String())
}
})
}
func TestContract_HandoffExchangeJSONSnapshot(t *testing.T) {
key := []byte("test-handoff-key")
configDir := t.TempDir()

View file

@ -0,0 +1,14 @@
package api
import "net/http"
// hideCommercialReadSurfaceInDemo returns a generic 404 for demo-only public
// runtimes so billing and license detail endpoints are not surfaced to users.
func hideCommercialReadSurfaceInDemo(w http.ResponseWriter, r *http.Request, demoMode bool) bool {
if !demoMode {
return false
}
http.NotFound(w, r)
return true
}

View file

@ -187,6 +187,20 @@ func TestHandleLicenseStatus_NoLicense(t *testing.T) {
}
}
func TestHandleLicenseStatus_DemoModeReturnsNotFound(t *testing.T) {
handler := createTestHandler(t)
handler.SetConfig(&config.Config{DemoMode: true})
req := httptest.NewRequest(http.MethodGet, "/api/license/status", nil)
rec := httptest.NewRecorder()
handler.HandleLicenseStatus(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
}
}
func TestHandleLicenseStatus_WithActiveLicense(t *testing.T) {
t.Setenv("PULSE_LICENSE_DEV_MODE", "true")

View file

@ -918,6 +918,9 @@ func (h *LicenseHandlers) HandleLicenseStatus(w http.ResponseWriter, r *http.Req
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if hideCommercialReadSurfaceInDemo(w, r, h != nil && h.cfg != nil && h.cfg.DemoMode) {
return
}
service, _, err := h.getTenantComponents(r.Context())
if err != nil {

View file

@ -103,6 +103,10 @@ func (e MonitoredSystemLedgerEntry) NormalizeCollections() MonitoredSystemLedger
}
func (r *Router) handleMonitoredSystemLedger(w http.ResponseWriter, req *http.Request) {
if hideCommercialReadSurfaceInDemo(w, req, r != nil && r.config != nil && r.config.DemoMode) {
return
}
orgID := GetOrgID(req.Context())
// Get canonical monitored systems from the unified ReadState surface.

View file

@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
@ -326,3 +327,15 @@ func TestHandleMonitoredSystemLedgerHTTP(t *testing.T) {
t.Errorf("expected explanation summary, got %+v", decoded.Systems[0].Explanation)
}
}
func TestHandleMonitoredSystemLedger_DemoModeReturnsNotFound(t *testing.T) {
router := &Router{config: &config.Config{DemoMode: true}}
req := httptest.NewRequest(http.MethodGet, "/api/license/monitored-system-ledger", nil)
rec := httptest.NewRecorder()
router.handleMonitoredSystemLedger(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
}
}

View file

@ -19,7 +19,11 @@ func (r *Router) registerHostedRoutes(hostedSignupHandlers *HostedSignupHandlers
routerConfig = &config.Config{}
}
billingHandlers := NewBillingStateHandlers(config.NewFileBillingStore(routerConfig.DataPath), r.hostedMode)
billingHandlers := NewBillingStateHandlers(
config.NewFileBillingStore(routerConfig.DataPath),
r.hostedMode,
routerConfig.DemoMode,
)
lifecycleHandlers := NewOrgLifecycleHandlers(r.multiTenant, r.hostedMode)
hostedOrgAdminHandlers := NewHostedOrgAdminHandlers(r.multiTenant, r.hostedMode)
r.mux.HandleFunc(

View file

@ -11,12 +11,19 @@ import (
type BillingStateHandlers struct {
store *config.FileBillingStore
demoMode bool
hostedMode bool
}
func NewBillingStateHandlers(store *config.FileBillingStore, hostedMode bool) *BillingStateHandlers {
func NewBillingStateHandlers(store *config.FileBillingStore, hostedMode bool, demoModes ...bool) *BillingStateHandlers {
demoMode := false
if len(demoModes) > 0 {
demoMode = demoModes[0]
}
return &BillingStateHandlers{
store: store,
demoMode: demoMode,
hostedMode: hostedMode,
}
}
@ -30,6 +37,9 @@ func (h *BillingStateHandlers) HandleGetBillingState(w http.ResponseWriter, r *h
http.NotFound(w, r)
return
}
if hideCommercialReadSurfaceInDemo(w, r, h.demoMode) {
return
}
if h.store == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "billing_store_unavailable", "Billing persistence is not configured", nil)
return
@ -83,6 +93,9 @@ func (h *BillingStateHandlers) HandlePutBillingState(w http.ResponseWriter, r *h
http.NotFound(w, r)
return
}
if hideCommercialReadSurfaceInDemo(w, r, h.demoMode) {
return
}
if h.store == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "billing_store_unavailable", "Billing persistence is not configured", nil)
return

View file

@ -3709,8 +3709,8 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 110,
"heading_line": 78,
"line": 111,
"heading_line": 79,
}
],
)