feat(commercial): revise self-hosted commercial surfaces (supersede RA5 opt-in posture)

Free self-hosted sessions now see reactive commercial surfaces: paid-feature
nav items stay visible with panel-owned inline gates (Relay precedent extended
to reporting, roles, users, audit log, audit webhooks), gate CTAs render, and
Plans & Billing is discoverable and carries an MSP/provider section with the
free 60-day evaluation. Adds a one-shot business-estate card for authenticated
free sessions whose monitored estate crosses business-scale thresholds
(>=5 PVE nodes, >=10 Docker hosts, or >=3 VMware hosts); the flag rides
sessionCapabilities only, never the pre-auth presentation policy, so estate
size cannot leak to anonymous visitors. Demo mode and white-label runtimes
(including MSP tenant containers) keep full commercial suppression; the
multi_tenant organization nav items deliberately stay hidden.

Decision record supersedes the 2026-04-25 opt-in record:
docs/release-control/v6/internal/records/self-hosted-commercial-surfaces-revision-2026-08-07.md
RA5 summary, contract pins, nav integration pins, and the RA5 browser spec
are updated to the revised invariant in the same commit.
This commit is contained in:
rcourtman 2026-08-07 13:47:46 +01:00
parent 1019310adf
commit f0e2243b44
26 changed files with 869 additions and 96 deletions

View file

@ -0,0 +1,78 @@
# Self-Hosted Commercial Surfaces Revision Record
- Date: `2026-08-07`
- Assertion: `RA5`
- Lane: `interactive (Claude Code, Richard-approved)`
- Result: `pass`
- Supersedes: `records/self-hosted-paid-services-opt-in-surface-2026-04-25.md`
## Decision
Ordinary free self-hosted Pulse v6 sessions see reactive commercial surfaces
by default: paid-feature navigation items are visible with panel-owned inline
gates, gate sections render their upgrade call-to-action, and the Plans &
Billing page is discoverable. One proactive surface exists — a one-shot
business-estate card shown only to authenticated sessions of free installs
whose monitored estate crosses business-scale thresholds (>=5 PVE nodes,
>=10 Docker hosts, or >=3 VMware hosts), permanently dismissible in a single
interaction.
Commercial suppression remains absolute for demo mode and white-label
runtimes (which covers MSP tenant containers via their chained white-label
entitlement), and kiosk sessions inherit suppression through the existing
kiosk mount gate. Monitoring surfaces — dashboards, infrastructure pages,
alerts, incident flows, onboarding, and notification channels — carry no
commercial content. Trial ceremony and hosted handoff flows remain absent.
## Why the April decision is superseded
The 2026-04-25 record optimized for a clean community-first GA, before any
post-GA evidence existed. Measured on 2026-08-07 (production telemetry,
mock-fleet rows excluded): weekly active installs grew from ~330 pre-GA to
9,421, while new paid subscriptions stayed flat (~30-40/month) because no
commercial surface was reachable — `hideUpgrade` was forced on for every
non-hosted session, making upgrade CTAs dead code for the entire self-hosted
fleet, and paid-only navigation hiding made deliberate reach impossible.
671 business-scale estates (which convert at 7.9% vs 0.98% for smaller
installs) were active in the trailing 7 days with no in-product path to any
paid tier. The April record's own boundary ("commercial surfaces remain
available when the user deliberately reaches for them") is better served by
visible-but-gated navigation than by hiding, which concealed the existence
of the capabilities users would deliberately reach for.
## Product Boundary (revised)
- Core self-hosted monitoring stays free and uncapped.
- Reactive surfaces (feature-gate CTAs, paid-feature navigation with inline
gates, Plans & Billing) are visible to ordinary self-hosted sessions.
- Exactly one proactive commercial surface exists: the one-shot
business-estate card. It never shows in demo, white-label, kiosk, hosted,
or paid sessions; never on the first qualifying day; never before the
GitHub star prompt has been interacted with; and never again after any
dismissal action.
- The business-estate signal is served only on the authenticated security
status response (`sessionCapabilities.businessEstate`), never on the
pre-auth presentation policy, so estate size cannot leak to anonymous
visitors.
- Demo mode and white-label runtimes hide all commercial surfaces
(`hideCommercial` and `hideUpgrade` both forced).
- `multi_tenant` organization navigation stays hidden without the feature.
- No trial flows, no hosted handoff prompts, no commercial content in
monitoring or notification surfaces.
## Proof
- `go test ./internal/api ./pkg/licensing -count=1` — pass (includes new
`TestContract_SecurityStatusPresentationPolicyShowsUpgradeByDefault`,
`TestContract_SecurityStatusPresentationPolicySuppressionInputs`,
`TestContract_BusinessScaleEstateThresholds`; demo-mode suppression pins
unchanged and passing).
- `npm --prefix frontend-modern run type-check` — pass.
- RA5 frontend proof suite (`npx vitest run` over the pinned files) — pass
after honest re-pinning of `settingsNavigation.integration.test.tsx` to
the revised navigation invariant.
- `tests/integration/tests/58-self-hosted-trial-rate-limit-ui.spec.ts`
re-pinned: paid-only navigation and inline gates visible, trial ceremony
and hosted handoff still absent.
- Live browser verification recorded in
`frontend-modern/browser-verification.json` in the landing commit.

View file

@ -322,7 +322,7 @@
},
{
"id": "RA5",
"summary": "Ordinary self-hosted v6 users do not see paid service prompts, trial CTAs, plan upsells, or paid-only navigation by default; commercial surfaces are reachable only through explicit handoff, direct route, hosted mode, or active entitlement/recovery state.",
"summary": "Ordinary free self-hosted v6 sessions see reactive commercial surfaces (visible paid-feature navigation with panel-owned inline gates, gate CTAs, discoverable Plans & Billing) plus exactly one proactive one-shot business-estate card gated to authenticated sessions; demo mode and white-label runtimes hide all commercial surfaces, the business-estate signal never rides the pre-auth policy, and trial ceremony plus hosted handoff stay absent.",
"kind": "invariant",
"blocking_level": "rc-ready",
"proof_type": "hybrid",
@ -385,7 +385,7 @@
"evidence": [
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/records/self-hosted-paid-services-opt-in-surface-2026-04-25.md",
"path": "docs/release-control/v6/internal/records/self-hosted-commercial-surfaces-revision-2026-08-07.md",
"kind": "file"
},
{

View file

@ -3075,13 +3075,15 @@ records for private Pro runtime hooks. Lifecycle-adjacent flows must treat
those records as executable-runtime identity, not as agent enrollment,
installer success, or lifecycle capability evidence, and must leave private
runtime download guidance to the licensing/settings surfaces that own it.
The same presentation-policy split now governs paid lifecycle extensions in
ordinary self-hosted v6 installs. Agent profile management may remain an
entitled lifecycle surface, but default Infrastructure navigation must not
advertise agent-profile upgrades, trial prompts, or paid helper links while
`presentationPolicy.hideUpgrade` is true; it should stay on the free source
manager unless an explicit entitlement or recovery context makes the paid
lifecycle surface relevant.
The same presentation-policy split still governs paid lifecycle extensions in
ordinary self-hosted v6 installs, under the revised default (2026-08-07
commercial-surfaces revision): `presentationPolicy.hideUpgrade` is no longer
forced true outside hosted mode, but wherever it is true (demo mode,
white-label runtimes) default Infrastructure navigation must not advertise
agent-profile upgrades, trial prompts, or paid helper links. Agent lifecycle
flows themselves carry no commercial content either way; the revision changes
browser presentation posture only, not agent auth, registration, or the
security-status auth snapshot.
The normal Infrastructure installer also follows that contract. Agent-command
execution controls may describe the runtime trust and command-execution effect,
but their default labels and tooltips must not mention Pro requirements or paid

View file

@ -3118,7 +3118,13 @@ query...`, and `Reading storage...` before streamed tool arguments are
server-owned WebSocket state during stream recovery. That navigation
evidence must remain independent from Assistant session, handoff, and drawer
state, and browser-local Assistant or resource metadata must not resolve
platform navigation on an otherwise evidence-free first load.
platform navigation on an otherwise evidence-free first load. The app
shell's global prompt mounts (GitHub star and, since the 2026-08-07
commercial-surfaces revision, the one-shot business-estate card) stay
behind the kiosk/settings-access gate and outside every AI runtime
surface: Assistant, Patrol, Actions, and Discovery chrome must not host
commercial prompts, and the business-estate card must not read or alter
Assistant or Patrol state.
Reloaded Assistant sessions may consume the backend-owned
`handoff_summary` only as safe presentation state and a Patrol finding
pointer; hidden model context, command payloads, preflight data, and action

View file

@ -5214,10 +5214,16 @@ must therefore defer their first read until that policy has resolved, so
public demos fail closed without probing hidden commercial routes during
bootstrap.
For ordinary self-hosted v6 installs, that same security-status contract owns
the free-first commercial posture: `hideUpgrade` defaults to true outside
hosted mode, and API consumers must treat it as a prompt-suppression contract
for upgrade links, trial CTAs, plan upsells, and paid-only navigation rather
than as a billing entitlement change.
the revised commercial posture (2026-08-07, supersedes the 2026-04-25 opt-in
posture; see records/self-hosted-commercial-surfaces-revision-2026-08-07.md):
`hideUpgrade` and `hideCommercial` default to false and are forced true only
for demo mode and white-label runtimes. API consumers must still treat them as
the prompt-suppression contract for upgrade links, plan upsells, and
commercial navigation rather than as a billing entitlement change. The
authenticated `sessionCapabilities` payload additionally carries
`businessEstate`, a business-scale estate marker for free installs; it must
never move onto the pre-auth presentation policy, because the public response
would leak estate size to anonymous visitors.
That same contract split also makes the licensing boundary explicit:
`/api/license/runtime-capabilities` is the public runtime feature contract,
`/api/license/commercial-posture` is the non-billing upgrade posture

View file

@ -1310,17 +1310,27 @@ hands-on Patrol modes, issue investigation, verified fixes, and longer history`.
pricing/checkout, upgrade-metrics, or infrastructure-onboarding analytics
fields, controls, routes, stores, or startup DB artifacts to normal product
users.
17. Keep ordinary self-hosted v6 commercial prompts opt-in. Cloud-paid runtime
may keep checkout, activation, recovery, and support-only trial plumbing
available for explicit handoffs and entitled installs, but default
self-hosted browser surfaces must honor `presentationPolicy.hideUpgrade`
and suppress Relay/Pro plan comparison, Pro trial CTAs, monitored-system
limit pressure, paid-only settings navigation, and feature upsells unless
hosted mode, direct intent, activation/recovery state, or active entitlement
makes them relevant. The authenticated app shell must also skip background
`/api/license/commercial-posture` bootstrap while `presentationPolicy.hideUpgrade`
is true; explicit self-hosted plan, activation/recovery, and hosted or
prompt-allowed flows may still refresh the shared posture store.
17. Keep ordinary self-hosted v6 commercial surfaces reactive and
policy-governed (2026-08-07 revision, supersedes the 2026-04-25 opt-in
posture; see
records/self-hosted-commercial-surfaces-revision-2026-08-07.md). Default
free self-hosted browser surfaces show paid-feature settings navigation
with panel-owned inline gates, render gate CTAs, and keep Plans & Billing
discoverable including the MSP/provider path with its self-issued
evaluation. Exactly one proactive commercial surface is allowed: the
one-shot business-estate card driven by the authenticated
`sessionCapabilities.businessEstate` flag, shown only to free installs
and permanently dismissible in a single interaction.
`presentationPolicy.hideUpgrade` remains the suppression contract and is
forced true for demo mode and white-label runtimes (which covers MSP
tenant containers); every commercial surface must keep honoring it
wherever it is true. Trial CTAs, hosted handoff prompts, and
monitored-system limit pressure stay out of default self-hosted UI. The
authenticated app shell must still skip background
`/api/license/commercial-posture` bootstrap while
`presentationPolicy.hideUpgrade` is true; explicit self-hosted plan,
activation/recovery, and prompt-allowed flows may refresh the shared
posture store.
18. Keep hosted and trial billing construction separate from retired hosted-AI
quickstart inventory: `pkg/licensing/trial_start.go` and hosted
entitlement refresh paths may preserve historical billing fields for old
@ -2117,7 +2127,9 @@ Browser and shell coverage now guard that retired boundary:
`tests/integration/scripts/retired-trial-acquisition-contract.sh` must expect `404` from the
retired route and prove entitlements remain unchanged. The paid-prompt browser
proof in `tests/integration/tests/58-self-hosted-trial-rate-limit-ui.spec.ts`
must keep trial CTAs and paid-only navigation out of the default self-hosted UI.
must keep trial CTAs and hosted handoff prompts out of the default
self-hosted UI while proving paid-feature navigation stays visible with
panel-owned inline gates (2026-08-07 commercial-surfaces revision).
`scripts/tests/test-retired-trial-acquisition-docs.sh` guards the same documentation posture
so active operator docs and eval metadata describe the retired route instead of
the old hosted-signup acquisition contract.

View file

@ -238,7 +238,14 @@ item stays visible without the `relay` feature so Relay remains discoverable
to free installs, and the panel-owned feature gate — not nav hiding —
communicates the paid boundary. Capability-based hiding (`relayRead`) still
applies; feature-based `hideWhenUnavailable` must not be reintroduced on that
item.
item. Per the 2026-08-07 self-hosted commercial-surfaces revision
(`records/self-hosted-commercial-surfaces-revision-2026-08-07.md`), the same
rule extends to `support-reporting`, `security-roles`, `security-users`,
`security-audit`, and `security-webhooks`: their nav items stay visible
without the paid feature, the panel-owned gates communicate the paid
boundary, capability-based hiding still applies, and feature-based
`hideWhenUnavailable` must not be reintroduced on them. The `multi_tenant`
organization items are deliberately excluded and keep `hideWhenUnavailable`.
Candidate import-plan presentation inside the Infrastructure settings dialog is
a shared primitive composition boundary. `NodeCandidateImportPlan.tsx` may use

View file

@ -1618,7 +1618,11 @@ availability fact through
`/api/security/status.sessionCapabilities.assistantEnabled`, so general
settings or security surfaces do not probe `/api/settings/ai` or other
assistant endpoints merely to decide whether dormant assistant chrome may be
opened.
opened. The same tiering rule protects estate size: the business-scale
marker `sessionCapabilities.businessEstate` (2026-08-07 commercial-surfaces
revision) is authenticated-session data and must never move onto the
pre-auth `presentationPolicy` payload, where it would disclose to anonymous
visitors that an install monitors a business-scale estate.
Security status disclosure is tiered by construction: public callers receive
only login/setup discovery, authenticated callers receive their own identity
and capability context, and deployment, network, credential, token-hint,

View file

@ -3304,6 +3304,12 @@ items, recovery events, and latest points so PBS backups, TrueNAS snapshots,
Kubernetes artifacts, and future providers all fit the same first-class UI
frame without removing the source badges and row-level cues that make Proxmox
operators productive.
License recovery keeps the same shape under the 2026-08-07
commercial-surfaces revision: the self-hosted license recovery section on the
Plans & Billing page (`SelfHostedCommercialRecoverySection` inside
`ProLicensePanel.tsx`) stays reachable and functionally unchanged while that
page gains commercial discoverability (MSP/provider section, visible plan
surfaces); recovery flows must not acquire commercial prompts of their own.
Operator-facing filter and detail labels should likewise prefer `platform`
wording over implementation-facing `provider` wording, so the recovery surface
describes the monitored platform families Pulse covers rather than exposing

View file

@ -1,37 +1,54 @@
{
"version": 1,
"base_sha": "bb75580073b6b161bcfe773307a54ac17217fa88",
"verified_at": "2026-08-06T22:24:12Z",
"result": "passed",
"base_sha": "1019310adf4aebaa88d5de0933be95dfc61284c8",
"verified_at": "2026-08-07T12:40:13Z",
"changed_paths": [
"frontend-modern/src/features/alerts/useAlertOverridesState.ts"
"frontend-modern/src/App.tsx",
"frontend-modern/src/components/BusinessEstateCard.tsx",
"frontend-modern/src/components/Settings/ProLicensePanel.tsx",
"frontend-modern/src/components/Settings/settingsNavCatalog.ts",
"frontend-modern/src/stores/sessionCapabilities.ts",
"frontend-modern/src/types/config.ts"
],
"content_sha256": {
"frontend-modern/src/features/alerts/useAlertOverridesState.ts": "558a4be683bc7b9ba712298955ffe2d61e43a4f74fa73f08f39252a1019eb273"
"frontend-modern/src/App.tsx": "7c22f48d04053556f07df34511b49847e9f9b2bd1217f819ff7d8b4f41ed83ef",
"frontend-modern/src/components/BusinessEstateCard.tsx": "af4463ea3a33de6a3a24b8ed89d41fcc42ceadb12736b6443a88556e18c88362",
"frontend-modern/src/components/Settings/ProLicensePanel.tsx": "c9d3a0405c626370239eaa3ff5fd1f7118887553ab8e80ef26b55008b817af3f",
"frontend-modern/src/components/Settings/settingsNavCatalog.ts": "b6a8f9c622c72a86dc7a0034c479e86fb9a1919ad0eb4a5fcd81222242fae865",
"frontend-modern/src/stores/sessionCapabilities.ts": "455c254df2ea7236bb9a1202e848f7226b963e1e36c761799d6eeca70a845b19",
"frontend-modern/src/types/config.ts": "36f050dba43af75781f98435faaa9921072d696d6133fb7f10bdfe04506c39bd"
},
"routes": [
"/alerts/thresholds/truenas",
"/alerts/thresholds/proxmox"
"/",
"/settings/pulse-intelligence/billing/plan",
"/settings/security-roles"
],
"states": [
"free Community tier with hideUpgrade=false served by the rebuilt dev backend (verified via /api/security/status)",
"sessionCapabilities.businessEstate=true from 6 mock PVE nodes on the free tier",
"business-estate card eligible state (star prompt dismissed, first-seen recorded on a prior day), re-exercised after the storage keys moved local to the component",
"business-estate card permanently dismissed state after each dismissal action",
"settings navigation for a free install showing Data & Reports, Roles, Users, Audit Log, Audit Webhooks, Remote Access, and Plans & Billing",
"Roles panel feature gate with visible View plans CTA on the free tier",
"Plans & Billing Community plan with the MSPs and multi-client providers section"
],
"interactions": [
"logged in as admin via real clicks and typing at 1280x800",
"clicked 'See business plans' on the business-estate card: navigated to /settings/pulse-intelligence/billing/plan and set the permanent dismissal key",
"reset dismissal, reloaded, clicked 'This is a homelab': card removed from DOM and dismissal persisted (repeated against the final component bytes)",
"navigated to Roles via settings navigation and confirmed the inline gate with View plans CTA",
"read the Plans & Billing page including the MSP provider section and pulserelay.pro/msp link",
"repeated the card render and homelab dismissal at mobile viewport with the card clearing the bottom navigation bar"
],
"viewports": [
{
"width": 1440,
"height": 1000
"width": 1280,
"height": 800
},
{
"width": 390,
"height": 844
"width": 375,
"height": 812
}
],
"states": [
"TrueNAS system threshold row at its inherited 85% memory default and after a custom 95% override",
"Proxmox Virtualization Hosts section containing only PVE-scoped host rows",
"narrow TrueNAS threshold route with the selected platform tab visible and the saved 95% override rendered after reload"
],
"interactions": [
"edited truenas-main memory from 85% to 95%, committed the inline row edit, and confirmed Save Changes appeared without resetting the value",
"saved the alert configuration, reloaded /alerts/thresholds/truenas, and confirmed the custom 95% value persisted",
"switched to /alerts/thresholds/proxmox and confirmed the TrueNAS system was absent from Virtualization Hosts",
"reloaded /alerts/thresholds/truenas at 390x844 and confirmed the active TrueNAS tab and persisted 95% value remained visible"
]
}

View file

@ -11,6 +11,7 @@ import { WhatsNewCard } from './components/WhatsNewCard';
import { DemoBanner } from './components/DemoBanner';
import { CommercialMigrationBanner } from './components/CommercialMigrationBanner';
import { GitHubStarBanner } from './components/GitHubStarBanner';
import { BusinessEstateCard } from './components/BusinessEstateCard';
// Modals are only mounted when opened, so their code can stay out of the
// entry bundle until first use (same pattern as AIChat below).
const KeyboardShortcutsModal = lazy(() =>
@ -510,6 +511,7 @@ function App() {
<UpdateBanner />
<WhatsNewCard />
<GitHubStarBanner />
<BusinessEstateCard />
<GlobalUpdateProgressWatcher />
</Show>
{/* Main layout container - flexbox to allow AI panel to push content */}

View file

@ -665,3 +665,22 @@ describe('App architecture', () => {
expect(appRuntimeStateSource).not.toContain('/api/license/entitlements');
});
});
// 2026-08-07 commercial-surfaces revision: the one-shot business-estate card
// is the only proactive commercial surface, and it must stay inside the same
// kiosk/settings-access mount gate as the other global prompts so kiosk
// sessions and viewers without settings access never see it.
describe('App commercial prompt mounting', () => {
it('mounts the business estate card behind the kiosk and settings-access gate', () => {
const gateStart = appSource.indexOf('<Show when={!kioskMode() && hasSettingsAccess()}>');
expect(gateStart).toBeGreaterThan(-1);
const gatedBlock = appSource.slice(
gateStart,
appSource.indexOf('<GlobalUpdateProgressWatcher />', gateStart),
);
expect(gatedBlock).toContain('<GitHubStarBanner />');
expect(gatedBlock).toContain('<BusinessEstateCard />');
// Exactly one mount, and only the gated one.
expect(appSource.split('<BusinessEstateCard />').length).toBe(2);
});
});

View file

@ -0,0 +1,170 @@
import { Show, createSignal, createEffect, onCleanup } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import {
createLocalStorageBooleanSignal,
createLocalStorageStringSignal,
STORAGE_KEYS,
} from '@/utils/localStorage';
import { useWebSocket } from '@/contexts/appRuntime';
import { sessionCapabilities } from '@/stores/sessionCapabilities';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { SELF_HOSTED_PRO_BILLING_PLAN_ROUTE } from '@/utils/pricingHandoff';
import { ActionIconButton, Button } from '@/components/shared/Button';
import BriefcaseIcon from 'lucide-solid/icons/briefcase';
import XIcon from 'lucide-solid/icons/x';
function getTodayDateString(): string {
return new Date().toISOString().split('T')[0];
}
// Deliberately local rather than in the STORAGE_KEYS registry: these keys are
// read nowhere else, and utils/localStorage.ts sits inside the
// deployment-installability verification blast radius.
const BUSINESS_ESTATE_DISMISSED_KEY = 'pulse-business-estate-dismissed';
const BUSINESS_ESTATE_FIRST_SEEN_KEY = 'pulse-business-estate-first-seen';
/**
* One-shot commercial prompt for free installs whose monitored estate crosses
* the backend's business-scale thresholds (sessionCapabilities.businessEstate).
* Deliberately stricter than the GitHub star prompt: every action dismisses
* permanently there is no snooze loop, so an install sees this card once.
* It also waits until the star prompt has been interacted with, so the two
* cards never stack in the same corner.
*/
export function BusinessEstateCard() {
const navigate = useNavigate();
const { initialDataReceived } = useWebSocket();
const [dismissed, setDismissed] = createLocalStorageBooleanSignal(
BUSINESS_ESTATE_DISMISSED_KEY,
false,
);
const [firstSeenDate, setFirstSeenDate] = createLocalStorageStringSignal(
BUSINESS_ESTATE_FIRST_SEEN_KEY,
'',
);
const [starDismissed] = createLocalStorageBooleanSignal(
STORAGE_KEYS.GITHUB_STAR_DISMISSED,
false,
);
const [starSnoozedUntil] = createLocalStorageStringSignal(
STORAGE_KEYS.GITHUB_STAR_SNOOZED_UNTIL,
'',
);
const [showCard, setShowCard] = createSignal(false);
createEffect(() => {
if (dismissed()) {
setShowCard(false);
return;
}
if (!initialDataReceived()) {
setShowCard(false);
return;
}
if (presentationPolicyHidesUpgradePrompts()) {
setShowCard(false);
return;
}
if (sessionCapabilities().businessEstate !== true) {
setShowCard(false);
return;
}
// The star prompt owns this corner until the user has interacted with it
// (dismissed or snoozed). Never show two asks at once.
const starInteracted = starDismissed() || starSnoozedUntil() !== '';
if (!starInteracted) {
setShowCard(false);
return;
}
const today = getTodayDateString();
const firstSeen = firstSeenDate();
// First qualifying day: record it, stay quiet. Never prompt on the same
// day the estate first crossed the threshold (or during initial setup).
if (!firstSeen) {
setFirstSeenDate(today);
setShowCard(false);
return;
}
if (firstSeen !== today) {
setShowCard(true);
}
});
const handleDismiss = () => {
setDismissed(true);
setShowCard(false);
};
const handleSeePlans = () => {
setDismissed(true);
setShowCard(false);
navigate(SELF_HOSTED_PRO_BILLING_PLAN_ROUTE);
};
createEffect(() => {
if (!showCard()) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
handleDismiss();
};
document.addEventListener('keydown', handleKeyDown);
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
});
return (
<Show when={showCard()}>
<section
class="fixed left-4 right-20 bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] z-30 max-w-md overflow-hidden rounded-lg border border-border bg-surface text-base-content shadow-xl md:right-auto md:bottom-4"
aria-labelledby="business-estate-title"
aria-live="polite"
>
<div class="flex items-start gap-3 p-4">
<div
class="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-hover"
aria-hidden="true"
>
<BriefcaseIcon class="h-5 w-5 text-base-content" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<div class="min-w-0 flex-1">
<h2 id="business-estate-title" class="text-sm font-semibold text-base-content">
Monitoring a business environment?
</h2>
<p class="mt-1 text-xs leading-5 text-muted">
Pulse is free and stays free. If it's earning its keep at work, the business plans
fund its development, and MSPs get a free 60-day evaluation.
</p>
</div>
<ActionIconButton
onClick={handleDismiss}
label="Close and don't show again"
title="Don't show again"
tone="muted"
size="sm"
type="button"
>
<XIcon class="h-4 w-4" aria-hidden="true" />
</ActionIconButton>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<Button onClick={handleSeePlans} variant="primary" size="mdCompact" type="button">
See business plans
</Button>
<Button onClick={handleDismiss} variant="ghost" size="mdCompact" type="button">
This is a homelab
</Button>
</div>
</div>
</div>
</section>
</Show>
);
}

View file

@ -16,6 +16,25 @@ import {
SELF_HOSTED_PRO_BILLING_RECOVERY_SECTION_ID,
} from '@/utils/pricingHandoff';
const MSPProviderSection: Component = () => (
<div class="rounded-lg border border-border bg-surface px-4 py-4 text-sm">
<p class="font-semibold text-base-content">MSPs and multi-client providers</p>
<p class="mt-1 text-muted">
Run an isolated Pulse instance per client with central provisioning, backups, and branded
reporting. Plans start at $149/mo, and the provider bundle self-issues a free 60-day
evaluation.
</p>
<a
href="https://pulserelay.pro/msp"
target="_blank"
rel="noopener noreferrer"
class="mt-2 inline-block text-blue-500 hover:underline"
>
Learn more at pulserelay.pro/msp
</a>
</div>
);
const ProLicensePolicyLoadingPanel: Component = () => (
<CommercialBillingShell
title={SELF_HOSTED_PRO_BILLING_PRESENTATION.hiddenShellTitle}
@ -104,6 +123,8 @@ const ProLicensePanelContent: Component = () => {
purchaseActivationNotice={state.purchaseActivationNotice()}
/>
<MSPProviderSection />
<SelfHostedCommercialRecoverySection
sectionId={SELF_HOSTED_PRO_BILLING_RECOVERY_SECTION_ID}
open={state.showRecoveryByDefault()}

View file

@ -274,6 +274,23 @@ describe('ProLicensePanel', () => {
expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument();
});
// 2026-08-07 commercial-surfaces revision: the plans page is the landing
// surface for business-estate prompts, so it must carry the MSP/provider
// path including the self-issued evaluation.
it('surfaces the MSP provider section with the self-issued evaluation', async () => {
renderPanel();
await waitFor(() => {
expect(loadLicenseEntitlementsMock).toHaveBeenCalled();
});
expect(screen.getByText('MSPs and multi-client providers')).toBeInTheDocument();
expect(screen.getByText(/Plans start at \$149\/mo/)).toBeInTheDocument();
expect(screen.getByText(/free\s*60-day evaluation/)).toBeInTheDocument();
const mspLink = screen.getByRole('link', { name: /pulserelay\.pro\/msp/i });
expect(mspLink).toHaveAttribute('href', 'https://pulserelay.pro/msp');
});
it('opens compare-plan checkout from the explicit self-hosted billing handoff', async () => {
useLocationMock.mockReturnValue({
search: `?intent=${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT}`,

View file

@ -233,6 +233,46 @@ describe('settings architecture guardrails', () => {
);
});
// 2026-08-07 commercial-surfaces revision (see
// docs/release-control/v6/internal/records/
// self-hosted-commercial-surfaces-revision-2026-08-07.md): paid-feature
// tabs stay visible for free installs and their panels gate inline, while
// the multi_tenant organization tabs deliberately stay feature-hidden.
it('keeps paid-feature settings tabs visible with panel-owned inline gates', () => {
const itemBlock = (id: string): string => {
const start = settingsNavCatalogSource.indexOf(`id: '${id}'`);
expect(start, `nav item ${id} present in catalog`).toBeGreaterThan(-1);
const next = settingsNavCatalogSource.indexOf("id: '", start + 1);
return settingsNavCatalogSource.slice(start, next === -1 ? undefined : next);
};
for (const id of [
'support-reporting',
'security-roles',
'security-users',
'security-audit',
'security-webhooks',
'system-relay',
]) {
expect(
itemBlock(id),
`${id} must stay visible without the paid feature (panel gates inline)`,
).not.toContain('hideWhenUnavailable');
}
for (const id of [
'organization-overview',
'organization-access',
'organization-sharing',
'organization-billing',
'organization-billing-admin',
]) {
expect(itemBlock(id), `${id} stays hidden without multi_tenant`).toContain(
'hideWhenUnavailable: true',
);
}
});
it('keeps allowed organization deep links on the canonical settings shell', () => {
expect(settingsSource).toContain("import { useSettingsAccess } from './useSettingsAccess';");
expect(settingsSource).toContain('const activeSettingsPanelEntry = createMemo(() => {');

View file

@ -159,7 +159,10 @@ describe('settingsNavigation integration scaffold', () => {
).toBe(false);
});
it('hides paid-only self-hosted tabs from free installs', () => {
// 2026-08-07 commercial-surfaces revision: paid-only tabs stay visible for
// free installs and their panels gate inline, following the Relay
// precedent. Hiding them made the capabilities undiscoverable.
it('shows paid-only self-hosted tabs to free installs (panels gate inline)', () => {
for (const tab of [
'security-roles',
'security-users',
@ -181,11 +184,11 @@ describe('settingsNavigation integration scaffold', () => {
users: true,
},
}),
).toBe(true);
).toBe(false);
}
});
it('shows paid reporting navigation only after advanced reporting is available', () => {
it('keeps reporting navigation visible with and without advanced reporting', () => {
expect(
shouldHideSettingsNavItem('support-reporting', {
hasFeature: hasFeatures([]),
@ -194,7 +197,7 @@ describe('settingsNavigation integration scaffold', () => {
presentationPolicyIsDemoMode: false,
hostedModeEnabled: false,
}),
).toBe(true);
).toBe(false);
expect(
shouldHideSettingsNavItem('support-reporting', {
@ -207,7 +210,7 @@ describe('settingsNavigation integration scaffold', () => {
).toBe(false);
});
it('keeps direct panel-owned feature gates routeable even when hidden from navigation', () => {
it('keeps panel-owned feature gate tabs visible and routeable for free installs', () => {
for (const tab of [
'support-reporting',
'security-roles',
@ -224,7 +227,7 @@ describe('settingsNavigation integration scaffold', () => {
hostedModeEnabled: false,
settingsCapabilitiesResolved: false,
}),
).toBe(true);
).toBe(false);
expect(
shouldBlockSettingsRouteItem(tab, {
hasFeature: hasFeatures([]),

View file

@ -199,7 +199,9 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
icon: FileText,
iconProps: { strokeWidth: 2 },
features: ['advanced_reporting'],
hideWhenUnavailable: true,
// Visible without the feature (2026-08-07 commercial-surfaces
// revision): the panel renders its own upgrade gate, and hiding the
// item made the capability undiscoverable — same rationale as Relay.
hideWhenDemoMode: true,
},
{
@ -254,7 +256,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
icon: ShieldCheck,
iconProps: { strokeWidth: 2 },
features: ['rbac'],
hideWhenUnavailable: true,
// Visible without the feature: the panel gates inline (see Relay).
requiredCapability: 'roles',
},
{
@ -263,7 +265,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
icon: Users,
iconProps: { strokeWidth: 2 },
features: ['rbac'],
hideWhenUnavailable: true,
// Visible without the feature: the panel gates inline (see Relay).
requiredCapability: 'users',
},
{
@ -272,7 +274,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
icon: Activity,
iconProps: { strokeWidth: 2 },
features: ['audit_logging'],
hideWhenUnavailable: true,
// Visible without the feature: the panel gates inline (see Relay).
requiredCapability: 'auditLog',
},
{
@ -281,7 +283,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
icon: Globe,
iconProps: { strokeWidth: 2 },
features: ['audit_logging'],
hideWhenUnavailable: true,
// Visible without the feature: the panel gates inline (see Relay).
requiredCapability: 'auditWebhooksRead',
},
{

View file

@ -0,0 +1,194 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/* ------------------------------------------------------------------ */
/* Mocks */
/* ------------------------------------------------------------------ */
const mockInitialDataReceived = vi.hoisted(() => vi.fn<() => boolean>(() => true));
const capabilityState = vi.hoisted(() => ({ businessEstate: true }));
const policyState = vi.hoisted(() => ({ hidesUpgradePrompts: false }));
const mockNavigate = vi.hoisted(() => vi.fn());
vi.mock('@/contexts/appRuntime', () => ({
useWebSocket: () => ({
initialDataReceived: mockInitialDataReceived,
}),
}));
vi.mock('@/stores/sessionCapabilities', () => ({
sessionCapabilities: () => ({ demoMode: false, businessEstate: capabilityState.businessEstate }),
}));
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesUpgradePrompts: () => policyState.hidesUpgradePrompts,
}));
vi.mock('@solidjs/router', () => ({
useNavigate: () => mockNavigate,
}));
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const DISMISSED_KEY = 'pulse-business-estate-dismissed';
const FIRST_SEEN_KEY = 'pulse-business-estate-first-seen';
const STAR_DISMISSED_KEY = 'pulse-github-star-dismissed';
const STAR_SNOOZED_KEY = 'pulse-github-star-snoozed-until';
const YESTERDAY = '2020-01-01';
async function renderCard() {
const mod = await import('../BusinessEstateCard');
render(() => <mod.BusinessEstateCard />);
}
/** Seed the state under which the card is expected to show. */
function seedEligibleState() {
capabilityState.businessEstate = true;
policyState.hidesUpgradePrompts = false;
localStorage.setItem(STAR_DISMISSED_KEY, 'true');
localStorage.setItem(FIRST_SEEN_KEY, YESTERDAY);
}
function queryCard() {
return screen.queryByText('Monitoring a business environment?');
}
/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */
describe('BusinessEstateCard', () => {
beforeEach(() => {
localStorage.clear();
mockNavigate.mockReset();
mockInitialDataReceived.mockReturnValue(true);
capabilityState.businessEstate = true;
policyState.hidesUpgradePrompts = false;
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('shows for an eligible business estate on a later day', async () => {
seedEligibleState();
await renderCard();
await waitFor(() => expect(queryCard()).not.toBeNull());
expect(screen.getByRole('button', { name: 'See business plans' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'This is a homelab' })).toBeTruthy();
});
it('stays hidden when the session is not a business estate', async () => {
seedEligibleState();
capabilityState.businessEstate = false;
await renderCard();
expect(queryCard()).toBeNull();
});
it('stays hidden when the presentation policy hides upgrade prompts', async () => {
seedEligibleState();
policyState.hidesUpgradePrompts = true;
await renderCard();
expect(queryCard()).toBeNull();
});
it('stays hidden until initial data is received', async () => {
seedEligibleState();
mockInitialDataReceived.mockReturnValue(false);
await renderCard();
expect(queryCard()).toBeNull();
});
it('waits until the GitHub star prompt has been interacted with', async () => {
seedEligibleState();
localStorage.removeItem(STAR_DISMISSED_KEY);
localStorage.removeItem(STAR_SNOOZED_KEY);
await renderCard();
expect(queryCard()).toBeNull();
});
it('treats a snoozed star prompt as interacted', async () => {
seedEligibleState();
localStorage.removeItem(STAR_DISMISSED_KEY);
localStorage.setItem(STAR_SNOOZED_KEY, '2099-01-01');
await renderCard();
await waitFor(() => expect(queryCard()).not.toBeNull());
});
it('records the first qualifying day and stays quiet that day', async () => {
seedEligibleState();
localStorage.removeItem(FIRST_SEEN_KEY);
await renderCard();
expect(queryCard()).toBeNull();
await waitFor(() => expect(localStorage.getItem(FIRST_SEEN_KEY)).not.toBeNull());
const recorded = (localStorage.getItem(FIRST_SEEN_KEY) ?? '').replace(/"/g, '');
expect(recorded).toBe(new Date().toISOString().split('T')[0]);
});
it('never shows again after the close control', async () => {
seedEligibleState();
await renderCard();
await waitFor(() => expect(queryCard()).not.toBeNull());
fireEvent.click(screen.getByRole('button', { name: "Close and don't show again" }));
expect(queryCard()).toBeNull();
expect(JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? 'false')).toBe(true);
});
it('never shows again after "This is a homelab"', async () => {
seedEligibleState();
await renderCard();
await waitFor(() => expect(queryCard()).not.toBeNull());
fireEvent.click(screen.getByRole('button', { name: 'This is a homelab' }));
expect(queryCard()).toBeNull();
expect(JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? 'false')).toBe(true);
});
it('navigates to the plan route and dismisses permanently on the primary action', async () => {
seedEligibleState();
await renderCard();
await waitFor(() => expect(queryCard()).not.toBeNull());
fireEvent.click(screen.getByRole('button', { name: 'See business plans' }));
expect(mockNavigate).toHaveBeenCalledWith('/settings/pulse-intelligence/billing/plan');
expect(queryCard()).toBeNull();
expect(JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? 'false')).toBe(true);
});
it('dismisses permanently on Escape', async () => {
seedEligibleState();
await renderCard();
await waitFor(() => expect(queryCard()).not.toBeNull());
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => expect(queryCard()).toBeNull());
expect(JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? 'false')).toBe(true);
});
});

View file

@ -17,28 +17,28 @@ describe('session capabilities store', () => {
it('default-fills capabilities when no status payload is provided', () => {
const next = syncSessionCapabilities();
expect(next).toEqual({ demoMode: false });
expect(sessionCapabilities()).toEqual({ demoMode: false });
expect(next).toEqual({ demoMode: false, businessEstate: false });
expect(sessionCapabilities()).toEqual({ demoMode: false, businessEstate: false });
});
it('default-fills capabilities when the status payload is null', () => {
const next = syncSessionCapabilities(null);
expect(next).toEqual({ demoMode: false });
expect(sessionCapabilities()).toEqual({ demoMode: false });
expect(next).toEqual({ demoMode: false, businessEstate: false });
expect(sessionCapabilities()).toEqual({ demoMode: false, businessEstate: false });
});
it('default-fills capabilities when sessionCapabilities is omitted from status', () => {
const next = syncSessionCapabilities({});
expect(next).toEqual({ demoMode: false });
expect(sessionCapabilities()).toEqual({ demoMode: false });
expect(next).toEqual({ demoMode: false, businessEstate: false });
expect(sessionCapabilities()).toEqual({ demoMode: false, businessEstate: false });
});
it('default-fills capabilities when sessionCapabilities is explicitly undefined', () => {
const next = syncSessionCapabilities({ sessionCapabilities: undefined });
expect(next).toEqual({ demoMode: false });
expect(next).toEqual({ demoMode: false, businessEstate: false });
});
it('default-fills capabilities when sessionCapabilities is null', () => {
@ -46,7 +46,7 @@ describe('session capabilities store', () => {
typeof syncSessionCapabilities
>[0]);
expect(next).toEqual({ demoMode: false });
expect(next).toEqual({ demoMode: false, businessEstate: false });
});
it('preserves an explicit demoMode===true capability', () => {
@ -54,8 +54,8 @@ describe('session capabilities store', () => {
sessionCapabilities: { demoMode: true },
});
expect(next).toEqual({ demoMode: true });
expect(sessionCapabilities()).toEqual({ demoMode: true });
expect(next).toEqual({ demoMode: true, businessEstate: false });
expect(sessionCapabilities()).toEqual({ demoMode: true, businessEstate: false });
});
it('coerces an explicit demoMode===false capability to the default', () => {
@ -63,7 +63,7 @@ describe('session capabilities store', () => {
sessionCapabilities: { demoMode: false },
});
expect(next).toEqual({ demoMode: false });
expect(next).toEqual({ demoMode: false, businessEstate: false });
});
// The normalizer gates demoMode on strict equality with `true`, so any
@ -79,7 +79,7 @@ describe('session capabilities store', () => {
sessionCapabilities: { demoMode: demoMode as unknown as boolean },
});
expect(next).toEqual({ demoMode: false });
expect(next).toEqual({ demoMode: false, businessEstate: false });
});
it.each([
@ -93,7 +93,7 @@ describe('session capabilities store', () => {
sessionCapabilities: { demoMode: demoMode as unknown as boolean },
});
expect(next).toEqual({ demoMode: false });
expect(next).toEqual({ demoMode: false, businessEstate: false });
});
it('returns the same normalized value it publishes to the signal', () => {
@ -112,12 +112,12 @@ describe('session capabilities store', () => {
it('overwrites the previous capability value instead of merging', () => {
syncSessionCapabilities({ sessionCapabilities: { demoMode: true } });
expect(sessionCapabilities()).toEqual({ demoMode: true });
expect(sessionCapabilities()).toEqual({ demoMode: true, businessEstate: false });
const next = syncSessionCapabilities(null);
expect(next).toEqual({ demoMode: false });
expect(sessionCapabilities()).toEqual({ demoMode: false });
expect(next).toEqual({ demoMode: false, businessEstate: false });
expect(sessionCapabilities()).toEqual({ demoMode: false, businessEstate: false });
});
it('strips unrecognized capability fields rather than passing them through', () => {
@ -128,8 +128,34 @@ describe('session capabilities store', () => {
},
});
expect(next).toEqual({ demoMode: true });
expect(next).toEqual({ demoMode: true, businessEstate: false });
expect(next).not.toHaveProperty('assistantEnabled');
expect(sessionCapabilities()).not.toHaveProperty('assistantEnabled');
});
it('preserves an explicit businessEstate===true capability', () => {
const next = syncSessionCapabilities({
sessionCapabilities: { demoMode: false, businessEstate: true },
});
expect(next).toEqual({ demoMode: false, businessEstate: true });
expect(sessionCapabilities()).toEqual({ demoMode: false, businessEstate: true });
});
// Same strict-equality gate as demoMode: only the boolean `true` survives.
it.each([
['number 1', 1],
['string "true"', 'true'],
['null', null],
['undefined', undefined],
])('coerces a non-boolean businessEstate (%s) to false', (_label, businessEstate) => {
const next = syncSessionCapabilities({
sessionCapabilities: {
demoMode: false,
businessEstate: businessEstate as unknown as boolean,
},
});
expect(next).toEqual({ demoMode: false, businessEstate: false });
});
});

View file

@ -15,6 +15,7 @@ function normalizeSessionCapabilities(
return {
...DEFAULT_SESSION_CAPABILITIES,
demoMode: capabilities?.demoMode === true,
businessEstate: capabilities?.businessEstate === true,
};
}

View file

@ -115,6 +115,7 @@ export interface SecurityStatusSettingsCapabilities {
export interface SecurityStatusSessionCapabilities {
demoMode: boolean;
assistantEnabled?: boolean;
businessEstate?: boolean;
}
export interface SecurityStatusPresentationPolicy {

View file

@ -10853,7 +10853,10 @@ func TestContract_PublicSecurityStatusIncludesDemoPresentationPolicy(t *testing.
}
}
func TestContract_SecurityStatusPresentationPolicyDefaultsHideUpgradeOutsideHosted(t *testing.T) {
func TestContract_SecurityStatusPresentationPolicyShowsUpgradeByDefault(t *testing.T) {
// 2026-08-07 self-hosted commercial-surfaces revision: ordinary free
// self-hosted sessions see upgrade CTAs; suppression is reserved for demo
// mode and white-label runtimes (pinned separately below).
cfg := newTestConfigWithTokens(t)
cfg.DemoMode = false
@ -10879,7 +10882,7 @@ func TestContract_SecurityStatusPresentationPolicyDefaultsHideUpgradeOutsideHost
"demoMode": false,
"readOnly": false,
"hideCommercial": false,
"hideUpgrade": true,
"hideUpgrade": false,
} {
if got, _ := presentationPolicy[key].(bool); got != want {
t.Fatalf("presentationPolicy.%s = %v, want %v", key, presentationPolicy[key], want)
@ -10887,6 +10890,55 @@ func TestContract_SecurityStatusPresentationPolicyDefaultsHideUpgradeOutsideHost
}
}
func TestContract_SecurityStatusPresentationPolicySuppressionInputs(t *testing.T) {
cases := []struct {
name string
demoMode bool
whiteLabel bool
wantHidden bool
}{
{"default shows commercial surfaces", false, false, false},
{"demo mode hides commercial surfaces", true, false, true},
{"white-label hides commercial surfaces", false, true, true},
{"demo and white-label hides commercial surfaces", true, true, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
policy := resolveSecurityStatusPresentationPolicy(tc.demoMode, tc.whiteLabel)
if policy.HideUpgrade != tc.wantHidden || policy.HideCommercial != tc.wantHidden {
t.Fatalf("policy = %+v, want hideUpgrade=hideCommercial=%v", policy, tc.wantHidden)
}
if policy.DemoMode != tc.demoMode || policy.ReadOnly != tc.demoMode {
t.Fatalf("policy = %+v, want demoMode=readOnly=%v", policy, tc.demoMode)
}
})
}
}
func TestContract_BusinessScaleEstateThresholds(t *testing.T) {
// Thresholds mirror the 2026-08-07 telemetry segmentation: >=5 PVE nodes,
// >=10 Docker hosts, or >=3 VMware hosts marks a business-scale estate.
cases := []struct {
name string
pveNodes, dockerHosts, vmwareHosts int
want bool
}{
{"empty estate", 0, 0, 0, false},
{"homelab scale", 4, 9, 2, false},
{"pve threshold", 5, 0, 0, true},
{"docker threshold", 0, 10, 0, true},
{"vmware threshold", 0, 0, 3, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := businessScaleEstateCounts(tc.pveNodes, tc.dockerHosts, tc.vmwareHosts); got != tc.want {
t.Fatalf("businessScaleEstateCounts(%d, %d, %d) = %v, want %v",
tc.pveNodes, tc.dockerHosts, tc.vmwareHosts, got, tc.want)
}
})
}
}
func TestContract_SecurityStatusSplitsAuditLogCapabilityFromSettingsRead(t *testing.T) {
prevAuthorizer := authpkg.GetAuthorizer()
authpkg.SetAuthorizer(&allowRulesAuthorizer{

View file

@ -362,7 +362,7 @@ func (r *Router) registerAuthSecurityInstallRoutes() {
SSOEnabled: hasEnabledSSO,
HideLocalLogin: r.config.HideLocalLogin,
SSOProviders: ssoProviders,
PresentationPolicy: r.securityStatusPresentationPolicy(),
PresentationPolicy: r.securityStatusPresentationPolicy(req.Context()),
}
// Bearer and query-string tokens are intentionally not accepted here. The

View file

@ -6,7 +6,9 @@ import (
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
)
type securityStatusSettingsCapabilities struct {
@ -29,6 +31,11 @@ type securityStatusSettingsCapabilities struct {
type securityStatusSessionCapabilities struct {
DemoMode bool `json:"demoMode"`
AssistantEnabled bool `json:"assistantEnabled"`
// BusinessEstate marks a free self-hosted install whose monitored estate
// crosses the business-scale thresholds. Authenticated-session only: it
// must never ride the pre-auth presentation policy, where it would leak
// estate size to anonymous visitors.
BusinessEstate bool `json:"businessEstate"`
}
type securityStatusPresentationPolicy struct {
@ -263,19 +270,92 @@ func (r *Router) securityStatusSessionCapabilities(ctx context.Context) security
if r != nil && r.aiSettingsHandler != nil {
assistantEnabled = r.aiSettingsHandler.AssistantEnabled(ctx)
}
businessEstate := false
if r != nil && !demoMode && !r.hostedMode {
paidOrBranded := false
if svc := getLicenseServiceForContext(ctx); svc != nil {
if lic := svc.Current(); lic != nil && lic.Claims.Tier != pkglicensing.TierFree {
paidOrBranded = true
}
if svc.HasFeature(featureWhiteLabelValue) {
paidOrBranded = true
}
}
if !paidOrBranded {
businessEstate = r.businessScaleEstate(ctx)
}
}
return securityStatusSessionCapabilities{
DemoMode: demoMode,
AssistantEnabled: assistantEnabled,
BusinessEstate: businessEstate,
}
}
func (r *Router) securityStatusPresentationPolicy() securityStatusPresentationPolicy {
demoMode := r != nil && r.config != nil && r.config.DemoMode
hideUpgrade := demoMode || r == nil || !r.hostedMode
// Business-scale estate thresholds. These mirror the segmentation used in the
// 2026-08-07 telemetry read that motivated the commercial-surface revision:
// installs at or above any one of these convert to paid at ~8x the rate of
// smaller estates.
const (
businessEstateMinPVENodes = 5
businessEstateMinDockerHosts = 10
businessEstateMinVMwareHosts = 3
)
func businessScaleEstateCounts(pveNodes, dockerHosts, vmwareHosts int) bool {
return pveNodes >= businessEstateMinPVENodes ||
dockerHosts >= businessEstateMinDockerHosts ||
vmwareHosts >= businessEstateMinVMwareHosts
}
func (r *Router) businessScaleEstate(ctx context.Context) bool {
if r == nil || r.configHandlers == nil {
return false
}
monitor := r.configHandlers.getMonitor(ctx)
if monitor == nil {
return false
}
pveNodes, dockerHosts := 0, 0
if readState := monitor.GetUnifiedReadStateOrSnapshot(); readState != nil {
pveNodes = len(readState.Nodes())
dockerHosts = len(readState.DockerHosts())
}
vmwareHosts := 0
resources, _ := monitor.UnifiedResourceSnapshot()
for _, resource := range resources {
if resource.VMware == nil {
continue
}
if unifiedresources.CanonicalResourceType(resource.Type) == unifiedresources.ResourceTypeAgent {
vmwareHosts++
}
}
return businessScaleEstateCounts(pveNodes, dockerHosts, vmwareHosts)
}
// resolveSecurityStatusPresentationPolicy maps commercial suppression inputs
// to the served policy. Per the 2026-08-07 self-hosted commercial-surfaces
// revision (supersedes the 2026-04-25 opt-in record, RA5), upgrade CTAs are
// visible by default; demo mode and white-label runtimes stay suppressed so
// demos, kiosks, and MSP tenant containers never show commercial content.
func resolveSecurityStatusPresentationPolicy(demoMode, whiteLabel bool) securityStatusPresentationPolicy {
hideCommercial := demoMode || whiteLabel
return securityStatusPresentationPolicy{
DemoMode: demoMode,
ReadOnly: demoMode,
HideCommercial: demoMode,
HideUpgrade: hideUpgrade,
HideCommercial: hideCommercial,
HideUpgrade: hideCommercial,
}
}
func (r *Router) securityStatusPresentationPolicy(ctx context.Context) securityStatusPresentationPolicy {
demoMode := r != nil && r.config != nil && r.config.DemoMode
whiteLabel := false
if r != nil && !demoMode {
if svc := getLicenseServiceForContext(ctx); svc != nil {
whiteLabel = svc.HasFeature(featureWhiteLabelValue)
}
}
return resolveSecurityStatusPresentationPolicy(demoMode, whiteLabel)
}

View file

@ -16,12 +16,16 @@ const SELF_HOSTED_SECURITY_STATUS = {
ssoProviders: [],
sessionCapabilities: {
demoMode: false,
businessEstate: false,
},
// 2026-08-07 commercial-surfaces revision: the canonical free self-hosted
// policy shows upgrade CTAs; suppression is reserved for demo mode and
// white-label runtimes.
presentationPolicy: {
demoMode: false,
readOnly: false,
hideCommercial: false,
hideUpgrade: true,
hideUpgrade: false,
},
settingsCapabilities: {
apiAccessRead: true,
@ -37,7 +41,7 @@ const SELF_HOSTED_SECURITY_STATUS = {
};
test.describe.serial('Self-hosted paid prompt visibility', () => {
test('keeps paid-only navigation and trial CTAs out of the default self-hosted UI', async ({
test('surfaces paid-only navigation with inline gates and no trial ceremony', async ({
page,
}, testInfo) => {
test.skip(
@ -87,16 +91,19 @@ test.describe.serial('Self-hosted paid prompt visibility', () => {
await page.goto('/settings/security-roles');
await expect(page.getByRole('heading', { level: 1, name: 'Roles' })).toBeVisible();
await expect(page.getByText('Custom Roles (Pro)')).toHaveCount(0);
// Remote Access stays in the nav without the relay feature on purpose:
// the panel renders its own upgrade gate, and hiding the item made Relay
// undiscoverable for free installs (settingsNavCatalog system-relay).
// The panel gates inline with the canonical upgrade CTA (2026-08-07
// commercial-surfaces revision: paid-only tabs follow the Relay
// precedent instead of hiding).
await expect(page.getByRole('heading', { name: 'Custom Roles' }).first()).toBeVisible();
await expect(page.getByRole('link', { name: 'View plans' }).first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Remote Access' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Roles' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Users' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Audit Log' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Audit Webhooks' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Self-hosted plan' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Roles' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Users' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Audit Log' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Audit Webhooks' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Plans & Billing' })).toBeVisible();
// Trial ceremony and hosted handoff remain absent: the revision opens
// discoverability, not trial or hosted flows.
await expect(page.getByRole('link', { name: /upgrade to pro/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /start free trial/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /start trial/i })).toHaveCount(0);