mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-11 18:13:38 +00:00
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:
parent
1019310adf
commit
f0e2243b44
26 changed files with 869 additions and 96 deletions
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
170
frontend-modern/src/components/BusinessEstateCard.tsx
Normal file
170
frontend-modern/src/components/BusinessEstateCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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(() => {');
|
||||
|
|
|
|||
|
|
@ -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([]),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ function normalizeSessionCapabilities(
|
|||
return {
|
||||
...DEFAULT_SESSION_CAPABILITIES,
|
||||
demoMode: capabilities?.demoMode === true,
|
||||
businessEstate: capabilities?.businessEstate === true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export interface SecurityStatusSettingsCapabilities {
|
|||
export interface SecurityStatusSessionCapabilities {
|
||||
demoMode: boolean;
|
||||
assistantEnabled?: boolean;
|
||||
businessEstate?: boolean;
|
||||
}
|
||||
|
||||
export interface SecurityStatusPresentationPolicy {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue