From 0ee95a6a429130c4c1e3ea542d0b555f46c5993f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 25 Apr 2026 15:26:11 +0100 Subject: [PATCH] Keep self-hosted commercial copy opt-in --- docs/CONFIGURATION.md | 6 +- docs/PRIVACY.md | 16 +++--- docs/architecture/v6-pricing-and-tiering.md | 57 +++++++++++-------- .../HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md | 37 ++++++------ .../v6/internal/SOURCE_OF_TRUTH.md | 4 +- ...-promotion-readiness-blocked-2026-04-04.md | 15 ++--- ...mercial-ga-coherence-blocked-2026-04-20.md | 2 +- ...paid-services-opt-in-surface-2026-04-25.md | 24 ++++++++ docs/release-control/v6/internal/status.json | 2 +- .../v6/internal/subsystems/agent-lifecycle.md | 4 ++ .../v6/internal/subsystems/ai-runtime.md | 7 ++- .../v6/internal/subsystems/api-contracts.md | 2 +- .../v6/internal/subsystems/cloud-paid.md | 9 ++- .../subsystems/frontend-primitives.md | 2 +- .../internal/subsystems/security-privacy.md | 4 +- .../internal/subsystems/storage-recovery.md | 5 ++ frontend-modern/public/docs/CONFIGURATION.md | 6 +- frontend-modern/public/docs/PRIVACY.md | 16 +++--- .../src/api/__tests__/patrol.test.ts | 8 +-- .../components/Settings/AISettingsDialogs.tsx | 10 ++-- .../Settings/GeneralSettingsPanel.tsx | 8 +-- .../Settings/__tests__/AISettings.test.tsx | 12 ++-- .../GeneralSettingsPanel.guardrails.test.ts | 2 +- .../__tests__/settingsArchitecture.test.ts | 17 ++++++ .../Settings/selfHostedBillingPresentation.ts | 11 +--- .../Settings/useSystemSettingsState.ts | 11 ++-- .../pages/__tests__/AIIntelligence.test.tsx | 4 +- .../stores/__tests__/systemSettings.test.ts | 2 + .../src/stores/licenseCommercial.ts | 10 ++-- frontend-modern/src/stores/systemSettings.ts | 4 +- frontend-modern/src/types/config.ts | 4 +- .../__tests__/aiSettingsPresentation.test.ts | 6 +- .../__tests__/licensePresentation.test.ts | 7 +-- .../utils/__tests__/relayPresentation.test.ts | 8 --- .../utils/__tests__/selfHostedPlans.test.ts | 5 +- .../systemSettingsPresentation.test.ts | 4 +- .../src/utils/aiQuickstartContract.ts | 2 +- .../src/utils/aiSettingsPresentation.ts | 2 +- .../src/utils/relayPresentation.ts | 4 -- frontend-modern/src/utils/selfHostedPlans.ts | 6 +- .../src/utils/systemSettingsPresentation.ts | 4 +- internal/ai/patrol.go | 2 +- internal/ai/quickstart_test.go | 4 ++ internal/api/router_routes_licensing.go | 4 +- internal/config/config.go | 2 +- internal/config/config_load_test.go | 4 ++ internal/config/persistence.go | 4 +- .../tests/51-quickstart-cross-surface.spec.ts | 2 +- ...58-self-hosted-trial-rate-limit-ui.spec.ts | 3 + 49 files changed, 223 insertions(+), 171 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 44d4817d1..4b8c58bb3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -182,8 +182,8 @@ Numeric intervals are **seconds** unless noted otherwise. | `metricsRetentionHourlyDays` | Hourly metrics retention (days) | | `metricsRetentionDailyDays` | Daily metrics retention (days) | | `disableDockerUpdateActions` | Hide Docker update actions in UI | -| `reduceProUpsellNoise` | Reduce proactive Pro prompts (paywalls still appear when accessing gated features) | -| `disableLocalUpgradeMetrics` | Disable local-only upgrade event collection | +| `reduceProUpsellNoise` | Legacy compatibility preference for proactive commercial prompts. Default self-hosted v6 sessions already hide paid prompts unless the user enters an explicit commercial handoff or has an existing entitlement. | +| `disableLocalUpgradeMetrics` | Disable local-only commercial handoff event collection | | `backendPort` | Legacy (unused) | | `frontendPort` | Legacy (ignored; use `FRONTEND_PORT`) | @@ -219,7 +219,7 @@ Environment variables take precedence over `system.json`. | Variable | Description | Default | | ---------- | ------------- | --------- | | `PULSE_PUBLIC_URL` | URL for UI links, notifications, and OIDC. For reverse proxies, keep this as the public URL and use `PULSE_AGENT_CONNECT_URL` for agent installs if you need a direct/internal address. | Auto-detected | -| `PULSE_PRO_TRIAL_SIGNUP_URL` | Hosted signup/checkout URL used when users click **Start Free Pro Trial**. Must be absolute `http(s)` URL. | `https://cloud.pulserelay.pro/start-pro-trial?...` | +| `PULSE_PRO_TRIAL_SIGNUP_URL` | Support-only hosted trial signup URL used by explicit commercial handoff flows. Normal self-hosted v6 UI does not surface this path. Must be absolute `http(s)` URL. | `https://cloud.pulserelay.pro/start-pro-trial?...` | | `PULSE_AGENT_CONNECT_URL` | Dedicated direct URL for agents (overrides `PULSE_PUBLIC_URL` for agent install commands). Alias: `PULSE_AGENT_URL`. | *(unset)* | | `PULSE_AGENT_CONFIG_SIGNING_KEY` | Base64 Ed25519 private key used to sign remote agent config payloads. | *(unset)* | | `PULSE_AGENT_CONFIG_PUBLIC_KEYS` | Comma-separated base64 Ed25519 public keys (raw 32-byte or PKIX-encoded) trusted by agents. | *(unset)* | diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index fbd16d100..251c785bf 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -7,9 +7,9 @@ Pulse is designed to run locally. By default, your monitoring data stays on your Pulse currently has two usage-data scopes: 1. **Anonymous outbound telemetry** to help me understand active installations, release uptake, and feature use in aggregate. -2. **Local-only upgrade events** to help debug and improve upgrade flows on the instance where they were recorded. +2. **Local-only commercial handoff events** to help debug explicit paid-plan and activation handoffs on the instance where they were recorded. -These are separate scopes on purpose. The outbound telemetry path stays coarse and anonymous. The local-only upgrade-event path stays on the Pulse instance and is not exported to third parties. +These are separate scopes on purpose. The outbound telemetry path stays coarse and anonymous. The local-only commercial-event path stays on the Pulse instance and is not exported to third parties. ### Anonymous outbound telemetry @@ -95,17 +95,17 @@ The telemetry implementation is in [`internal/telemetry/telemetry.go`](../intern Pulse can make outbound connections when you enable specific features: -- **AI (BYOK)**: when AI features are enabled, Pulse sends only the context required for your request to the provider you configured (OpenAI, Anthropic, etc.). See `docs/AI.md`. +- **AI (BYOK)**: when AI features are configured with your own API key, Pulse sends only the context required for your request directly to the provider you chose (OpenAI, Anthropic, etc.). Requests do not transit Pulse infrastructure. See `docs/AI.md`. +- **AI (Quickstart)**: when the Pulse-hosted Quickstart provider is selected, your chat request — including messages, system prompt, and tool definitions — is forwarded through `license.pulserelay.pro/v1/quickstart/patrol` to the underlying model provider. This path exists so eligible quickstart users can try AI without their own API key; it means requests transit Pulse infrastructure. To keep prompts off Pulse infrastructure, use a BYOK provider instead. - **Relay / Remote Access**: when relay is enabled, Pulse connects to the configured relay endpoint to enable mobile access. See Settings → Remote Access. - **Update checks**: Pulse can check for new releases/updates (for example via GitHub release metadata) depending on your deployment and configuration. -### Local-only upgrade events +### Local-only commercial handoff events -Pulse can record local-only usage events such as "paywall viewed" or "trial started" to improve and debug in-app upgrade flows. +Pulse can record local-only usage events such as plan-link views or activation handoff attempts to improve and debug voluntary paid flows. - These events are stored locally and are not exported to third parties. -- Disable via **Settings → System → General → Disable local-only upgrade events** or set: +- Disable via **Settings → System → General → Disable local-only commercial events** or set: - `PULSE_DISABLE_LOCAL_UPGRADE_METRICS=true` -If you prefer fewer upgrade prompts, you can also enable: -- **Settings → System → General → Reduce Pro prompts** +The legacy `reduceProUpsellNoise` preference remains for older proactive prompt behavior, but default self-hosted v6 sessions already hide paid prompts unless the user enters an explicit commercial handoff or has an existing entitlement. diff --git a/docs/architecture/v6-pricing-and-tiering.md b/docs/architecture/v6-pricing-and-tiering.md index c6b62979e..66ebcfc6d 100644 --- a/docs/architecture/v6-pricing-and-tiering.md +++ b/docs/architecture/v6-pricing-and-tiering.md @@ -302,25 +302,34 @@ must apply manually. Pro users see the same information with an "Apply Fix" butt Patrol finding is a conversion moment. ### 3. AI Quickstart Credits (25 hosted Patrol runs) -Activated or trial-backed installs get 25 Patrol runs powered by MiniMax 2.5M with no API -key setup. This gives users one bounded Patrol-first activation path. Unactivated Community -installs activate, start a trial, or connect their own key (BYOK, stays free). Upgrading -later unlocks auto-fix, alert analysis, and other paid operations features; it does not -replace BYOK for self-hosted AI runtime. Cost: ~$0.002–0.01 per run. +Eligible activated installs get 25 Patrol runs powered by MiniMax 2.5M with no API key setup. +This gives support and commercial handoff flows one bounded Patrol-first activation path. +Ordinary self-hosted Community installs should connect their own key (BYOK, stays free) and +must not be pushed into a trial just to use Patrol. Paid operations features such as auto-fix, +alert analysis, and longer history remain opt-in extras; they do not replace BYOK for +self-hosted AI runtime. Cost: ~$0.002-0.01 per run. -### 4. Relay as impulse buy ($39/yr) -Fills the $0 → Pro gap. Relay is not the automation tier and it is not a capacity tier. +### 4. Relay as optional convenience ($39/yr) +Gives remote-access and mobile users a small paid convenience option without turning core +self-hosted monitoring into a capacity tier. Remote access + mobile + push notifications + custom URL + 14-day history should make it an easy purchase for users who want Pulse available outside their LAN. -### 5. Contextual trial triggers -14-day Pro trial offered at moments of maximum desire: -- Patrol finds a fixable issue → "Apply this fix automatically? Start your free trial" -- An alert needs deeper explanation → "Let Pulse investigate this alert? Start your free trial" -- User taps 30-day chart range → "See your full history — start your free trial" -- User tries Relay from free tier → "Monitor from anywhere — upgrade to Relay ($39/yr) or - start a Pro trial" -- 7+ days of active use → proactive "Experience the full power" nudge +### 5. Opt-in commercial discovery +Default self-hosted v6 sessions must not present ordinary users with trial, paywall, or +proactive paid-service prompts. Commercial discovery is allowed only when the user explicitly +opens pricing/activation/recovery/support flows, when the session is hosted/cloud, or when an +existing entitlement requires a clear renewal or recovery path. + +High-intent product moments should stay useful without becoming sales surfaces: +- Patrol finds a fixable issue -> show the manual fix and BYOK/provider path first; auto-fix can + remain an opt-in Pro capability where commercial prompts are explicitly allowed. +- An alert needs deeper explanation -> keep free investigation context useful; paid alert + analysis stays an optional, discoverable extra. +- User taps a longer chart range -> explain the local retention state without implying a + monitoring capacity limit. +- User wants remote access or mobile pairing -> point to Relay only inside the explicit Relay + setup/commercial handoff, not as a global nudge. ### 6. No self-hosted monitored-system overflow gate Self-hosted Community users should not need a temporary monitored-system overflow path because @@ -333,9 +342,10 @@ show exactly which top-level systems Pulse sees, which collection path is being is included under each system. On self-hosted Community/Relay/Pro, it must not create a false "X/Y systems" paywall or imply that users need to buy more monitoring room. -### 8. Upsell snooze (7-day, not permanent) -Users can snooze upgrade prompts for 7 days. No permanent mute option. Power users who -use the product heavily are highest-potential converters — don't let them opt out forever. +### 8. No default upsell cadence +There is no proactive self-hosted upsell cadence in v6 GA. If older compatibility settings +mention prompt reduction, treat them as legacy controls; the v6 default is already quiet unless +the user enters an explicit commercial path or has an entitlement state that needs attention. ### 9. Cloud launches with v6 Not behind a waitlist. Real pricing, real signup. Captures convenience buyers who don't @@ -392,7 +402,7 @@ want to self-host. Unlimited Unlimited Unlimited monitoring monitoring monitoring - [Get Started] [Buy Relay] [Start Trial] + [Get Started] [Buy Relay] [Choose Pro] ``` Below the table: @@ -499,7 +509,7 @@ explain monitored-system identity: - [x] Remove self-hosted monitored-system cap pressure from billing and pricing surfaces - [x] Present the public self-hosted ladder as Community / Relay / Pro - [ ] Keep ledger and inventory language focused on what Pulse monitors, not paid capacity pressure -- [ ] Keep upgrade prompts focused on Relay convenience, Pro investigation, Pro auto-fix, and history +- [ ] Keep paid prompts out of ordinary self-hosted runtime surfaces; commercial copy belongs in explicit pricing, activation, recovery, hosted, or entitlement-aware paths ### License server / checkout / landing pages (`pulse-pro`) @@ -518,11 +528,10 @@ explain monitored-system identity: ## Review Checkpoints ### 30-day post-launch review -- Free → Relay conversion rate -- Free → Pro trial start rate -- Trial → paid conversion rate +- Free → Relay opt-in purchase rate +- Free → Pro opt-in purchase rate - Relay → Pro upgrade rate -- Which paywall surfaces fire most (Relay vs alert investigation vs auto-fix vs history) +- Which explicit commercial handoffs are used most (pricing, activation, recovery, hosted) - Support load per tier ### 60-day post-launch review diff --git a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md index 5c28ab884..1a420ac9d 100644 --- a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md +++ b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md @@ -46,17 +46,17 @@ Companion drill: `cd frontend-modern && npx vitest run src/pages/__tests__/HostedSignup.test.tsx src/components/Settings/__tests__/BillingAdminPanel.test.tsx` `cd tests/integration && PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 npm test -- tests/07-trial-signup-return.spec.ts --project=chromium` `cd tests/integration && PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 npm test -- tests/58-self-hosted-trial-rate-limit-ui.spec.ts --project=chromium` - The self-hosted handoff proof must keep the canonical trial-start transport - contract: `POST /api/license/trial/start` returns `409 - trial_signup_required` while the hosted-signup retry burst remains open, - then transitions to `429 trial_rate_limited` plus `Retry-After` backoff once - the limiter actually engages. The Pulse Pro browser CTA proof on - `/settings/system/billing` must surface that same canonical `Retry-After` - backoff even when `details.retry_after_seconds` disagrees. + The self-hosted support/explicit-handoff proof must keep the canonical + trial-start transport contract: `POST /api/license/trial/start` returns `409 + trial_signup_required` while the hosted-signup retry burst remains open, then + transitions to `429 trial_rate_limited` plus `Retry-After` backoff once the + limiter actually engages. Ordinary self-hosted runtime surfaces must still + suppress trial prompts unless an explicit commercial handoff or entitlement + state allows them. - Live rehearsal helper: `python3 scripts/release_control/hosted_signup_billing_replay_rehearsal.py --base-url --signup-email --org-name ...` - Manual scenario: - 1. Start a hosted signup from the self-hosted trial/upgrade path. + 1. Start a hosted signup from an explicit self-hosted support/commercial handoff path. 2. Confirm a missing hosted public URL fails closed before any org or RBAC tenant is created. 3. Confirm the user is sent to hosted checkout instead of receiving a local @@ -201,10 +201,11 @@ Companion drill: - Why this is risky: A technically promotable build is still a broken GA release if the - self-hosted commercial path reads like a bridge product. Public landing copy, - in-app upgrade and trial prompts, checkout, Pulse Account/license-management, - and GA-facing guidance all need to describe the same Community / Relay / Pro - offer without preview-only, legacy-v5, or contradictory framing. + self-hosted commercial path reads like a bridge product or puts paid prompts in + front of ordinary self-hosted users. Public landing copy, explicit in-app + commercial handoffs, checkout, Pulse Account/license-management, and GA-facing + guidance all need to describe the same Community / Relay / Pro offer without + preview-only, legacy-v5, or contradictory framing. - Primary runtime surfaces: `pulse-pro/landing-page/index.html` `pulse-pro/landing-page/README.md` @@ -223,19 +224,19 @@ Companion drill: 2. Confirm the public landing surface presents the actual v6 self-hosted Community / Relay / Pro ladder and product promise rather than a live v5 bridge with preview-only v6 packaging. - 3. Follow a real self-hosted upgrade or trial entry point from the app and + 3. Follow a real explicit self-hosted commercial handoff from the app and confirm the wording, plan identity, and customer expectation match the - public site. + public site, while ordinary runtime pages stay free-first and quiet. 4. Continue through the real checkout and Pulse Account/license-management path and confirm it stays on the same product story instead of switching back to legacy Pulse Pro framing or ambiguous plan rules. 5. Review the GA-facing guidance/release communication intended to ship with promotion and confirm it matches the same package and customer path. - Pass when: - A new self-hosted customer can move from public understanding to upgrade or - trial entry, checkout/account management, and GA-facing guidance without - encountering contradictory plan copy, preview-only posture, or legacy-v5 - bridge messaging. + A new self-hosted customer can move from public understanding to an explicit + paid-plan handoff, checkout/account management, and GA-facing guidance without + encountering proactive in-app trial pressure, contradictory plan copy, + preview-only posture, or legacy-v5 bridge messaging. - Latest exercised record: `docs/release-control/v6/internal/records/self-hosted-commercial-ga-coherence-2026-04-20.md` - Block release if: diff --git a/docs/release-control/v6/internal/SOURCE_OF_TRUTH.md b/docs/release-control/v6/internal/SOURCE_OF_TRUTH.md index 0a58923bd..68c0cbab0 100644 --- a/docs/release-control/v6/internal/SOURCE_OF_TRUTH.md +++ b/docs/release-control/v6/internal/SOURCE_OF_TRUTH.md @@ -423,7 +423,9 @@ Assertion design rules: returning `409 trial_signup_required` while the hosted-signup retry burst remains open, then `429 trial_rate_limited` plus `Retry-After` backoff once that limiter engages; the local runtime may redeem signed trial activations - but must not mint local trial state directly. + but must not mint local trial state directly. This transport is support-only + or explicit-handoff plumbing for self-hosted v6 GA; ordinary self-hosted + runtime surfaces must not promote trial entry. 4. v5 to v6 commercial migration must preserve unresolved paid-license state and downgrade safety. 5. Cloud and MSP Stripe `price_*` IDs are operational fill-in items, not diff --git a/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-04-04.md b/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-04-04.md index 5dd10a15f..2c18e0018 100644 --- a/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-04-04.md +++ b/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-04-04.md @@ -8,24 +8,21 @@ 1. The latest shipped Pulse v6 prerelease tag is `v6.0.0-rc.2`. 2. That shipped prerelease tag resolves to commit `7b0bdebdff8127228708e84eb836cfd3d7214c08`. -3. The selected remote ref `origin/pulse/v6-release` is still behind the current - local governed branch state, so `Release Dry Run` would exercise stale remote - control-plane metadata instead of the intended candidate. -4. The governed release profile in `docs/release-control/control_plane.json` +3. The governed release profile in `docs/release-control/control_plane.json` currently declares both `prerelease_branch` and `stable_branch` as `pulse/v6-release`. -5. The active control-plane target is still `v6-product-lane-expansion`, not +4. The active control-plane target is still `v6-product-lane-expansion`, not `v6-ga-promotion`. -6. The active local `pulse/v6-release` branch currently reports `VERSION=6.0.0`, so a +5. The active local `pulse/v6-release` branch currently reports `VERSION=6.0.0`, so a local GA candidate exists on the governed stable line. -7. There is still no governed `Prerelease-to-GA Rehearsal Record` proving a successful +6. There is still no governed `Prerelease-to-GA Rehearsal Record` proving a successful non-publish `Release Dry Run` for the current `6.0.0` candidate. -8. `docs/releases/RELEASE_NOTES_v6.md` and +7. `docs/releases/RELEASE_NOTES_v6.md` and `docs/release-control/v6/internal/V5_MAINTENANCE_SUPPORT_POLICY.md` now carry the currently proposed exact dates for the eventual GA notice: - `v6` GA date: `2026-04-20` - `v5` end-of-support date: `2026-07-19` -9. There is still no governed `Release Dry Run` artifact or rehearsal record +8. There is still no governed `Release Dry Run` artifact or rehearsal record exercising stable inputs for: - `version=6.0.0` - `promoted_from_tag=v6.0.0-rc.2` diff --git a/docs/release-control/v6/internal/records/self-hosted-commercial-ga-coherence-blocked-2026-04-20.md b/docs/release-control/v6/internal/records/self-hosted-commercial-ga-coherence-blocked-2026-04-20.md index 70bd1cefe..fbea57993 100644 --- a/docs/release-control/v6/internal/records/self-hosted-commercial-ga-coherence-blocked-2026-04-20.md +++ b/docs/release-control/v6/internal/records/self-hosted-commercial-ga-coherence-blocked-2026-04-20.md @@ -20,7 +20,7 @@ `v5` track while the `v6` model is retained as preview data. 5. The governed commercial lane (`L2`) had previously been treated as complete at the RC floor even though GA still lacks a named self-hosted commercial - package pass covering the public site, in-app upgrade/trial path, + package pass covering the public site, explicit in-app commercial handoff, checkout/account/license-management flow, and GA-facing guidance together. ## Why The Gate Cannot Be Cleared Yet diff --git a/docs/release-control/v6/internal/records/self-hosted-paid-services-opt-in-surface-2026-04-25.md b/docs/release-control/v6/internal/records/self-hosted-paid-services-opt-in-surface-2026-04-25.md index ba1fe601d..cfd999d93 100644 --- a/docs/release-control/v6/internal/records/self-hosted-paid-services-opt-in-surface-2026-04-25.md +++ b/docs/release-control/v6/internal/records/self-hosted-paid-services-opt-in-surface-2026-04-25.md @@ -42,6 +42,30 @@ journey. - `PULSE_E2E_USE_LOCAL_BACKEND=1 npm --prefix tests/integration test -- tests/58-self-hosted-trial-rate-limit-ui.spec.ts --project=chromium` - Result: pass in Chromium against a managed local backend. +## Follow-Up Copy Audit + +The 2026-04-25 copy audit removed lingering trial-first language from the +ordinary self-hosted quickstart reason, settings privacy labels, shared +self-hosted plan presentation helpers, and current commercial strategy docs. +The trial transport remains available for hosted/support handoffs, but default +self-hosted runtime copy now points users to BYOK Patrol or explicit +commercial/entitlement handoff paths instead of asking them to start a trial. + +- `npm --prefix frontend-modern test -- src/components/Settings/__tests__/AISettings.test.tsx src/components/Settings/__tests__/GeneralSettingsPanel.guardrails.test.ts src/utils/__tests__/aiSettingsPresentation.test.ts src/utils/__tests__/systemSettingsPresentation.test.ts src/utils/__tests__/relayPresentation.test.ts src/utils/__tests__/selfHostedPlans.test.ts src/utils/__tests__/licensePresentation.test.ts src/api/__tests__/patrol.test.ts src/pages/__tests__/AIIntelligence.test.tsx` + - Result: pass, 9 files and 86 tests. +- `npm --prefix frontend-modern run type-check` + - Result: pass. +- `go test ./internal/ai -run 'Test.*Quickstart|TestQuickstart|TestService.*Quickstart' -count=1` + - Result: pass. +- `go test ./internal/config -count=1` + - Result: pass. +- `PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 npm --prefix tests/integration test -- tests/58-self-hosted-trial-rate-limit-ui.spec.ts --project=chromium` + - Result: pass in Chromium against a managed local backend; the proof now also asserts that `Start Trial`, `free 14-day trial`, and `Open hosted handoff` do not appear in the default self-hosted UI. +- `python3 scripts/release_control/documentation_currentness_test.py` + - Result: pass. +- `python3 scripts/release_control/release_promotion_policy_test.py` + - Result: pass after regenerating the static blocked RC-to-GA record from current branch facts without creating any release, tag, or promotion artifact. + ## Known Unrelated State - Repository-wide frontend format check still reports pre-existing warnings diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index d2a4cece3..98d99b976 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -4027,7 +4027,7 @@ }, { "id": "self-hosted-commercial-ga-coherence", - "summary": "Confirm the self-hosted GA commercial package is coherent end-to-end: public landing, in-app upgrade/trial path, checkout/account/license-management flows, and GA-facing guidance all describe the same Community / Relay / Pro product without legacy v5 bridge posture or contradictory plan copy.", + "summary": "Confirm the self-hosted GA commercial package is coherent end-to-end: public landing, explicit in-app commercial handoff, checkout/account/license-management flows, and GA-facing guidance all describe the same Community / Relay / Pro product without proactive self-hosted trial pressure, legacy v5 bridge posture, or contradictory plan copy.", "owner": "project-owner", "blocking_level": "release-ready", "minimum_evidence_tier": "real-external-e2e", diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 90c4d38eb..95fb24421 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1177,6 +1177,10 @@ consume the settings-owned referral copy from `frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts` instead of carrying workspace-local commercial guidance or reaching back into generic commercial presentation helpers from the hosted infrastructure route. +Shared licensing routes under `internal/api/` may retain legacy +`upgrade-metrics` route names for compatibility, but lifecycle-adjacent +settings and install flows must treat them as local commercial handoff events, +not as a reason to reintroduce default self-hosted upgrade prompts. That canonical /api/auto-register behavior now also includes hostname/IP continuity: reruns that arrive through a different canonical host form must reuse the same Pulse-managed node record and token instead of forking duplicate fleet entries. diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 6aad99be0..c4f14d762 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -95,9 +95,10 @@ runtime cost control, and shared AI transport surfaces. deny fails. 9. Keep self-hosted Patrol quickstart messaging aligned with backend runtime truth: the governed quickstart contract is Patrol-only first-run - acceleration on activated or trial-backed installs with server-authoritative - run inventory, not a general hosted chat entitlement or a replacement for - BYOK once Patrol leaves the quickstart path. + acceleration on installs with an activated entitlement and + server-authoritative run inventory, not a trial prompt, a general hosted chat + entitlement, or a replacement for BYOK once Patrol leaves the quickstart + path. 10. Keep discovery-analysis prompt bounds and response budgets aligned across `internal/ai/service.go` and the shared service-discovery prompt builders: the runtime must reserve enough output tokens for structured discovery JSON, diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 36f211200..80d1cd49b 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -697,7 +697,7 @@ for logs and response headers. That same shared settings/licensing contract now also owns the split usage-data payload model. `frontend-modern/src/api/settings.ts`, `internal/api/router_routes_licensing.go`, and adjacent settings callers must -keep anonymous outbound telemetry and local-only upgrade events as separate +keep anonymous outbound telemetry and local-only commercial handoff events as separate browser-visible scopes, and the telemetry preview payload must ship normalized version identity fields (`version`, `version_raw`, `version_channel`, `version_build`, `version_is_development`, and diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 87c6c25e2..cba9388fa 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -390,8 +390,9 @@ Community limit enforcement. 3. Add or tighten drift tests when a pricing/runtime mismatch is fixed 4. Keep self-hosted pricing and public docs on runtime-backed commercial truth: Patrol quickstart may be presented only as Patrol-only first-run activation - support backed by the license server, while Relay and Pro remain the - canonical commercial story. + support backed by the license server and an activated entitlement, never as + a default self-hosted trial CTA, while Relay and Pro remain the canonical + commercial story. 5. Treat grandfathered `lifetime` licenses as uncapped commercial entitlements: they keep the Pro feature set, but they must not inherit monitored-system or guest caps from recurring Pro contracts anywhere in runtime, issuance, or @@ -2012,6 +2013,10 @@ only for explicit handoff/direct routes, activation or recovery state, hosted mode, or an already entitled install. Trial checkout plumbing may remain for support-only or externally initiated flows, but it is not a normal GA self-hosted app journey. +Shared self-hosted plan presentation helpers must carry that same policy: +Community copy may mention BYOK Patrol and hosted quickstart only as an +activated-entitlement benefit, not as a default self-hosted trial CTA or a +reason to put a paid prompt in front of ordinary users. That public-demo commercial boundary also owns monitored-system preview unavailability wording. Browser presentation may keep the unavailable reason nullable until the formatting edge, but it must normalize the message through diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0d29563b9..63577fd6c 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -804,7 +804,7 @@ vocabulary. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`, `frontend-modern/src/components/Settings/useSystemSettingsState.ts`, and `frontend-modern/src/utils/systemSettingsPresentation.ts` must present one explicit `Usage data and privacy` model with the separate labels -`Anonymous outbound telemetry` and `Disable local-only upgrade events`, and +`Anonymous outbound telemetry` and `Disable local-only commercial events`, and must describe telemetry with normalized release identity rather than falling back to ambiguous `telemetry`, `upgrade metrics`, or raw-version wording. Shared table, disclosure, and form primitives must also stay explicitly typed diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 9ae0990a9..72b61bafb 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -18,7 +18,7 @@ ## Purpose Own Pulse's canonical privacy disclosures, usage-data boundary (anonymous -outbound telemetry plus local-only upgrade events), and the security-facing +outbound telemetry plus local-only commercial handoff events), and the security-facing settings surfaces that expose authentication posture, token-management visibility, and privacy controls to operators. @@ -107,7 +107,7 @@ This subsystem now gives `L14` an explicit governed home for privacy guidance and telemetry disclosures instead of leaving those trust surfaces as lane-level evidence with no subsystem ownership. That same governed home now also owns the single "usage data" vocabulary for -anonymous outbound telemetry and local-only upgrade events, so Pulse stops +anonymous outbound telemetry and local-only commercial handoff events, so Pulse stops describing those two privacy scopes as unrelated systems. That same operator-reporting boundary now also owns reusable latest-install adoption baselines. `scripts/telemetry_adoption_report.py` must emit diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 60e5644bd..9e1c5decc 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -726,6 +726,11 @@ backend capability fact remains `sessionCapabilities.demoMode`, but storage and recovery browsers must consume the shared resolved `presentationPolicy` instead of inferring demo posture from headers, `/api/health`, or hostname heuristics. +Shared licensing routes under `internal/api/` may retain legacy +`upgrade-metrics` names for local commercial handoff telemetry, but storage and +recovery surfaces must continue to consume the presentation policy instead of +using those route names as a cue to render paid history prompts in ordinary +self-hosted sessions. That same shared boundary now also owns the one runtime-safe exception: storage and recovery may inherit demo-safe `/api/license/runtime-capabilities` reads for capability and history-retention truth, but diff --git a/frontend-modern/public/docs/CONFIGURATION.md b/frontend-modern/public/docs/CONFIGURATION.md index 44d4817d1..4b8c58bb3 100644 --- a/frontend-modern/public/docs/CONFIGURATION.md +++ b/frontend-modern/public/docs/CONFIGURATION.md @@ -182,8 +182,8 @@ Numeric intervals are **seconds** unless noted otherwise. | `metricsRetentionHourlyDays` | Hourly metrics retention (days) | | `metricsRetentionDailyDays` | Daily metrics retention (days) | | `disableDockerUpdateActions` | Hide Docker update actions in UI | -| `reduceProUpsellNoise` | Reduce proactive Pro prompts (paywalls still appear when accessing gated features) | -| `disableLocalUpgradeMetrics` | Disable local-only upgrade event collection | +| `reduceProUpsellNoise` | Legacy compatibility preference for proactive commercial prompts. Default self-hosted v6 sessions already hide paid prompts unless the user enters an explicit commercial handoff or has an existing entitlement. | +| `disableLocalUpgradeMetrics` | Disable local-only commercial handoff event collection | | `backendPort` | Legacy (unused) | | `frontendPort` | Legacy (ignored; use `FRONTEND_PORT`) | @@ -219,7 +219,7 @@ Environment variables take precedence over `system.json`. | Variable | Description | Default | | ---------- | ------------- | --------- | | `PULSE_PUBLIC_URL` | URL for UI links, notifications, and OIDC. For reverse proxies, keep this as the public URL and use `PULSE_AGENT_CONNECT_URL` for agent installs if you need a direct/internal address. | Auto-detected | -| `PULSE_PRO_TRIAL_SIGNUP_URL` | Hosted signup/checkout URL used when users click **Start Free Pro Trial**. Must be absolute `http(s)` URL. | `https://cloud.pulserelay.pro/start-pro-trial?...` | +| `PULSE_PRO_TRIAL_SIGNUP_URL` | Support-only hosted trial signup URL used by explicit commercial handoff flows. Normal self-hosted v6 UI does not surface this path. Must be absolute `http(s)` URL. | `https://cloud.pulserelay.pro/start-pro-trial?...` | | `PULSE_AGENT_CONNECT_URL` | Dedicated direct URL for agents (overrides `PULSE_PUBLIC_URL` for agent install commands). Alias: `PULSE_AGENT_URL`. | *(unset)* | | `PULSE_AGENT_CONFIG_SIGNING_KEY` | Base64 Ed25519 private key used to sign remote agent config payloads. | *(unset)* | | `PULSE_AGENT_CONFIG_PUBLIC_KEYS` | Comma-separated base64 Ed25519 public keys (raw 32-byte or PKIX-encoded) trusted by agents. | *(unset)* | diff --git a/frontend-modern/public/docs/PRIVACY.md b/frontend-modern/public/docs/PRIVACY.md index fbd16d100..251c785bf 100644 --- a/frontend-modern/public/docs/PRIVACY.md +++ b/frontend-modern/public/docs/PRIVACY.md @@ -7,9 +7,9 @@ Pulse is designed to run locally. By default, your monitoring data stays on your Pulse currently has two usage-data scopes: 1. **Anonymous outbound telemetry** to help me understand active installations, release uptake, and feature use in aggregate. -2. **Local-only upgrade events** to help debug and improve upgrade flows on the instance where they were recorded. +2. **Local-only commercial handoff events** to help debug explicit paid-plan and activation handoffs on the instance where they were recorded. -These are separate scopes on purpose. The outbound telemetry path stays coarse and anonymous. The local-only upgrade-event path stays on the Pulse instance and is not exported to third parties. +These are separate scopes on purpose. The outbound telemetry path stays coarse and anonymous. The local-only commercial-event path stays on the Pulse instance and is not exported to third parties. ### Anonymous outbound telemetry @@ -95,17 +95,17 @@ The telemetry implementation is in [`internal/telemetry/telemetry.go`](../intern Pulse can make outbound connections when you enable specific features: -- **AI (BYOK)**: when AI features are enabled, Pulse sends only the context required for your request to the provider you configured (OpenAI, Anthropic, etc.). See `docs/AI.md`. +- **AI (BYOK)**: when AI features are configured with your own API key, Pulse sends only the context required for your request directly to the provider you chose (OpenAI, Anthropic, etc.). Requests do not transit Pulse infrastructure. See `docs/AI.md`. +- **AI (Quickstart)**: when the Pulse-hosted Quickstart provider is selected, your chat request — including messages, system prompt, and tool definitions — is forwarded through `license.pulserelay.pro/v1/quickstart/patrol` to the underlying model provider. This path exists so eligible quickstart users can try AI without their own API key; it means requests transit Pulse infrastructure. To keep prompts off Pulse infrastructure, use a BYOK provider instead. - **Relay / Remote Access**: when relay is enabled, Pulse connects to the configured relay endpoint to enable mobile access. See Settings → Remote Access. - **Update checks**: Pulse can check for new releases/updates (for example via GitHub release metadata) depending on your deployment and configuration. -### Local-only upgrade events +### Local-only commercial handoff events -Pulse can record local-only usage events such as "paywall viewed" or "trial started" to improve and debug in-app upgrade flows. +Pulse can record local-only usage events such as plan-link views or activation handoff attempts to improve and debug voluntary paid flows. - These events are stored locally and are not exported to third parties. -- Disable via **Settings → System → General → Disable local-only upgrade events** or set: +- Disable via **Settings → System → General → Disable local-only commercial events** or set: - `PULSE_DISABLE_LOCAL_UPGRADE_METRICS=true` -If you prefer fewer upgrade prompts, you can also enable: -- **Settings → System → General → Reduce Pro prompts** +The legacy `reduceProUpsellNoise` preference remains for older proactive prompt behavior, but default self-hosted v6 sessions already hide paid prompts unless the user enters an explicit commercial handoff or has an existing entitlement. diff --git a/frontend-modern/src/api/__tests__/patrol.test.ts b/frontend-modern/src/api/__tests__/patrol.test.ts index d79751311..a3b4dab49 100644 --- a/frontend-modern/src/api/__tests__/patrol.test.ts +++ b/frontend-modern/src/api/__tests__/patrol.test.ts @@ -79,14 +79,14 @@ describe('patrol api', () => { apiFetchJSONMock.mockResolvedValueOnce({ runtime_state: 'blocked', blocked_reason: - 'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.', + 'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.', healthy: false, } as any); await expect(getPatrolStatus()).resolves.toMatchObject({ runtime_state: 'blocked', blocked_reason: - 'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.', + 'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.', healthy: false, }); }); @@ -95,7 +95,7 @@ describe('patrol api', () => { apiFetchJSONMock.mockResolvedValueOnce({ runtime_state: 'blocked', blocked_reason: - 'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.', + 'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.', healthy: false, quickstart_credits_total: 0, quickstart_credits_remaining: 0, @@ -105,7 +105,7 @@ describe('patrol api', () => { await expect(getPatrolStatus()).resolves.toMatchObject({ runtime_state: 'blocked', blocked_reason: - 'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.', + 'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.', healthy: false, quickstart_credits_total: 0, quickstart_credits_remaining: 0, diff --git a/frontend-modern/src/components/Settings/AISettingsDialogs.tsx b/frontend-modern/src/components/Settings/AISettingsDialogs.tsx index 69378b5a0..78c6331b0 100644 --- a/frontend-modern/src/components/Settings/AISettingsDialogs.tsx +++ b/frontend-modern/src/components/Settings/AISettingsDialogs.tsx @@ -3,7 +3,6 @@ import { Dialog } from '@/components/shared/Dialog'; import { SelectionCardGroup } from '@/components/shared/SelectionCardGroup'; import { getAISessionDiffStatusPresentation } from '@/utils/aiSessionDiffPresentation'; import { getAISettingsSetupDialogPresentation } from '@/utils/aiSettingsPresentation'; -import { RELAY_ONBOARDING_TRIAL_STARTING_LABEL } from '@/utils/relayPresentation'; import type { FileChange } from '@/api/aiChat'; import type { AIProvider } from '@/types/ai'; import { @@ -122,11 +121,12 @@ export const AISettingsDialogs: Component = (props) => {

- Start a Pro trial to unlock Patrol quickstart + Use hosted quickstart for Patrol

- Use the hosted quickstart path first, or skip it and connect your own model - provider below. + Open the hosted quickstart handoff, or skip it and connect your own model + provider below. Hosted quickstart routes prompts through Pulse + infrastructure; BYOK providers go direct.

diff --git a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx index 3fbb538d7..93604f6cc 100644 --- a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx @@ -210,16 +210,16 @@ export const GeneralSettingsPanel: Component = (props
- Disable local-only upgrade events + Disable local-only commercial events

- Records local-only upgrade events like "paywall viewed" and "trial started" to help - debug and improve upgrade flows. These events stay on this Pulse instance and are not - exported to third parties. + Records local-only commercial handoff events, such as plan-link views or activation + handoff attempts, to debug voluntary paid flows. These events stay on this Pulse + instance and are not exported to third parties.

{ quickstart_credits_remaining: 0, quickstart_credits_available: false, quickstart_blocked_reason: - 'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.', + 'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.', }); renderComponent(); @@ -518,10 +518,10 @@ describe('AISettings quickstart enablement flow', () => { ).toBeInTheDocument(); expect( screen.getAllByText( - 'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.', + 'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.', ), ).toHaveLength(2); - expect(screen.getByRole('button', { name: /start trial/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /open hosted handoff/i })).toBeInTheDocument(); }); it('hides trial setup guidance when self-hosted upgrade prompts are disabled', async () => { @@ -534,7 +534,7 @@ describe('AISettings quickstart enablement flow', () => { quickstart_credits_remaining: 0, quickstart_credits_available: false, quickstart_blocked_reason: - 'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.', + 'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.', }); renderComponent(); @@ -550,7 +550,7 @@ describe('AISettings quickstart enablement flow', () => { expect( screen.getByText('Connect a provider to power Pulse Assistant and Patrol.'), ).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /start trial/i })).not.toBeInTheDocument(); - expect(screen.queryByText(/start a trial to use Patrol quickstart/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /open hosted handoff/i })).not.toBeInTheDocument(); + expect(screen.queryByText(/Hosted quickstart requires an activated entitlement/i)).not.toBeInTheDocument(); }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.guardrails.test.ts b/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.guardrails.test.ts index a37ff7f1d..013cd6c19 100644 --- a/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.guardrails.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.guardrails.test.ts @@ -13,7 +13,7 @@ describe('GeneralSettingsPanel guardrails', () => { it('keeps telemetry preview and reset controls in the general settings panel', () => { expect(generalSettingsPanelSource).toContain('Usage data and privacy'); - expect(generalSettingsPanelSource).toContain('Disable local-only upgrade events'); + expect(generalSettingsPanelSource).toContain('Disable local-only commercial events'); expect(generalSettingsPanelSource).toContain('Preview payload'); expect(generalSettingsPanelSource).toContain('Reset ID'); expect(generalSettingsPanelSource).toContain('Telemetry payload preview'); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 24b99a344..ac9144b66 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import settingsSource from '../Settings.tsx?raw'; import settingsDialogsSource from '../SettingsDialogs.tsx?raw'; import settingsPageShellSource from '../SettingsPageShell.tsx?raw'; +import aiSettingsDialogsSource from '../AISettingsDialogs.tsx?raw'; +import generalSettingsPanelSource from '../GeneralSettingsPanel.tsx?raw'; import settingsHeaderMetaSource from '../settingsHeaderMeta.ts?raw'; import settingsNavCatalogSource from '../settingsNavCatalog.ts?raw'; import settingsNavigationHookSource from '../useSettingsNavigation.ts?raw'; @@ -25,6 +27,7 @@ import diagnosticsResultsPanelSource from '../DiagnosticsResultsPanel.tsx?raw'; import diagnosticsModelSource from '../diagnosticsModel.ts?raw'; import infrastructureOnboardingPresentationSource from '../../../utils/infrastructureOnboardingPresentation.ts?raw'; import selfHostedBillingPresentationSource from '../selfHostedBillingPresentation.ts?raw'; +import systemSettingsPresentationSource from '../../../utils/systemSettingsPresentation.ts?raw'; describe('settings architecture guardrails', () => { it('keeps Settings on the canonical page shell boundary', () => { @@ -122,6 +125,20 @@ describe('settings architecture guardrails', () => { ); }); + it('keeps default self-hosted commercial copy opt-in from shared settings primitives', () => { + expect(aiSettingsDialogsSource).toContain('Open hosted handoff'); + expect(aiSettingsDialogsSource).not.toContain('Start Trial'); + expect(aiSettingsDialogsSource).not.toContain('RELAY_ONBOARDING_TRIAL_STARTING_LABEL'); + expect(generalSettingsPanelSource).toContain('Disable local-only commercial events'); + expect(generalSettingsPanelSource).not.toContain('Disable local-only upgrade events'); + expect(systemSettingsPresentationSource).toContain( + 'Unable to update local-only commercial events.', + ); + expect(systemSettingsPresentationSource).toContain( + 'Unable to update commercial prompt preferences.', + ); + }); + it('keeps infrastructure on a source-manager landing with route-backed dialogs', () => { expect(infrastructureWorkspaceSource).toContain( "import { ConnectionEditor } from './ConnectionEditor/ConnectionEditor';", diff --git a/frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts b/frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts index 843a3140e..afb3f3a0c 100644 --- a/frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts +++ b/frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts @@ -27,10 +27,6 @@ export interface SelfHostedProBillingPresentation { purchaseExpiredActionLabel: string; purchaseFailedActionLabel: string; purchaseUnavailableActionLabel: string; - trialStartTitle: string; - trialStartBody: string; - trialStartIdleActionLabel: string; - trialStartPendingActionLabel: string; recoverySectionTitle: string; recoverySectionDescription: string; } @@ -67,14 +63,9 @@ export const SELF_HOSTED_PRO_BILLING_PRESENTATION: SelfHostedProBillingPresentat planSelectionPromptActionLabel: 'Compare plans', purchaseActivatedPlanActionLabel: 'Review plan', purchaseCancelledActionLabel: 'Compare plans', - purchaseExpiredActionLabel: 'Restart upgrade', + purchaseExpiredActionLabel: 'Compare plans', purchaseFailedActionLabel: 'Open recovery', purchaseUnavailableActionLabel: 'Try again', - trialStartTitle: 'Try Pro for free', - trialStartBody: - 'Start a 14-day Pro trial for root-cause analysis, safe auto-fix, and 90-day history on this organization.', - trialStartIdleActionLabel: 'Start 14-day Pro Trial', - trialStartPendingActionLabel: 'Starting...', recoverySectionTitle: 'Activation & Recovery', recoverySectionDescription: 'Activate a purchased key, recover a previous self-hosted purchase, or clear a local key from this instance.', diff --git a/frontend-modern/src/components/Settings/useSystemSettingsState.ts b/frontend-modern/src/components/Settings/useSystemSettingsState.ts index 3f25b63d4..41ca3428e 100644 --- a/frontend-modern/src/components/Settings/useSystemSettingsState.ts +++ b/frontend-modern/src/components/Settings/useSystemSettingsState.ts @@ -339,9 +339,12 @@ export function useSystemSettingsState({ try { await SettingsAPI.updateSystemSettings({ reduceProUpsellNoise: enabled }); updateReduceProUpsellNoiseSetting(enabled); - notificationStore.success(enabled ? 'Pro prompts reduced' : 'Pro prompts restored', 2000); + notificationStore.success( + enabled ? 'Commercial prompts reduced' : 'Commercial prompt preference restored', + 2000, + ); } catch (error) { - logger.error('Failed to update reduce upsell noise setting', error); + logger.error('Failed to update commercial prompt preference', error); notificationStore.error( getReduceUpsellNoiseUpdateErrorMessage(error instanceof Error ? error.message : undefined), ); @@ -364,11 +367,11 @@ export function useSystemSettingsState({ await SettingsAPI.updateSystemSettings({ disableLocalUpgradeMetrics: disabled }); updateDisableLocalUpgradeMetricsSetting(disabled); notificationStore.success( - disabled ? 'Local-only upgrade events disabled' : 'Local-only upgrade events enabled', + disabled ? 'Local-only commercial events disabled' : 'Local-only commercial events enabled', 2000, ); } catch (error) { - logger.error('Failed to update local-only upgrade events setting', error); + logger.error('Failed to update local-only commercial events setting', error); notificationStore.error( getLocalUpgradeMetricsUpdateErrorMessage( error instanceof Error ? error.message : undefined, diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index e949989b5..5ea9f08c4 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -840,7 +840,7 @@ describe('AIIntelligence entitlement gating', () => { quickstart_credits_total: 25, quickstart_credits_remaining: 0, blocked_reason: - 'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.', + 'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.', }), ); @@ -850,7 +850,7 @@ describe('AIIntelligence entitlement gating', () => { expect(getPatrolStatusMock).toHaveBeenCalled(); expect( screen.getAllByText( - 'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.', + 'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.', ).length, ).toBeGreaterThan(0); }); diff --git a/frontend-modern/src/stores/__tests__/systemSettings.test.ts b/frontend-modern/src/stores/__tests__/systemSettings.test.ts index 1ba75d48b..e560f372f 100644 --- a/frontend-modern/src/stores/__tests__/systemSettings.test.ts +++ b/frontend-modern/src/stores/__tests__/systemSettings.test.ts @@ -68,6 +68,8 @@ describe('systemSettings store', () => { const privacyDoc = readFileSync(path.join(repoRoot, 'docs', 'PRIVACY.md'), 'utf8'); expect(privacyDoc).toContain('## Usage Data'); + expect(privacyDoc).toContain('Local-only commercial handoff events'); + expect(privacyDoc).not.toContain('Local-only upgrade events'); expect(privacyDoc).toContain('Every field is listed below with the reason it exists'); expect(privacyDoc).toContain('rows older than **90 days** are purged automatically'); expect(privacyDoc).toContain('uses client IP addresses transiently for abuse/rate limiting'); diff --git a/frontend-modern/src/stores/licenseCommercial.ts b/frontend-modern/src/stores/licenseCommercial.ts index e819b25d9..a112d2515 100644 --- a/frontend-modern/src/stores/licenseCommercial.ts +++ b/frontend-modern/src/stores/licenseCommercial.ts @@ -137,7 +137,7 @@ export async function loadCommercialPosture(force = false): Promise { } /** - * Start a Pro trial for the current org, then refresh both runtime and commercial state. + * Start the explicit hosted trial handoff for the current org, then refresh runtime and commercial state. */ export async function startProTrial(): Promise { if ( @@ -212,8 +212,8 @@ export const isPro = createRoot(() => ); /** - * Used by non-billing upgrade surfaces that can render a trial CTA unless the - * current posture explicitly denies trial eligibility. + * Used by explicit commercial surfaces that can render a trial CTA unless the + * presentation policy or current posture denies trial eligibility. */ export function canOfferCommercialTrial(): boolean { if (presentationPolicyHidesUpgradePrompts()) return false; @@ -221,8 +221,8 @@ export function canOfferCommercialTrial(): boolean { } /** - * Used by surfaces that should only offer trial start when the current - * commercial posture is loaded and not already active/trial-backed. + * Used by explicit commercial surfaces that should only offer trial start when + * the current commercial posture is loaded and not already active/trial-backed. */ export function canStartCommercialTrial(): boolean { if (presentationPolicyHidesUpgradePrompts()) return false; diff --git a/frontend-modern/src/stores/systemSettings.ts b/frontend-modern/src/stores/systemSettings.ts index 0e72a353c..91ddd8af6 100644 --- a/frontend-modern/src/stores/systemSettings.ts +++ b/frontend-modern/src/stores/systemSettings.ts @@ -12,9 +12,9 @@ import type { SystemConfig } from '@/types/config'; // Server-side setting to hide Docker update buttons while still detecting updates const [disableDockerUpdateActions, setDisableDockerUpdateActions] = createSignal(false); -// Server-side setting to reduce proactive Pro prompts (paywalls still appear when accessing gated features) +// Server-side compatibility setting for proactive commercial prompts const [reduceProUpsellNoise, setReduceProUpsellNoise] = createSignal(false); -// Server-side setting to disable local-only upgrade UX metrics collection +// Server-side setting to disable local-only commercial handoff event collection const [disableLocalUpgradeMetrics, setDisableLocalUpgradeMetrics] = createSignal(false); // Track if settings have been loaded diff --git a/frontend-modern/src/types/config.ts b/frontend-modern/src/types/config.ts index 623b482ec..92c2d3ceb 100644 --- a/frontend-modern/src/types/config.ts +++ b/frontend-modern/src/types/config.ts @@ -49,8 +49,8 @@ export interface SystemConfig { hideLocalLogin?: boolean; // Hide local login form (username/password) publicURL?: string; // Public URL for email notifications (e.g., http://198.51.100.100:8080) disableDockerUpdateActions?: boolean; // Hide Docker update buttons while still detecting updates (server-wide) - reduceProUpsellNoise?: boolean; // Hide proactive Pro prompts; paywalls still appear when accessing gated features - disableLocalUpgradeMetrics?: boolean; // Disable local-only upgrade UX metrics collection + reduceProUpsellNoise?: boolean; // Legacy compatibility preference for proactive commercial prompts + disableLocalUpgradeMetrics?: boolean; // Disable local-only commercial handoff event collection telemetryEnabled?: boolean; // Opt-in anonymous usage telemetry } diff --git a/frontend-modern/src/utils/__tests__/aiSettingsPresentation.test.ts b/frontend-modern/src/utils/__tests__/aiSettingsPresentation.test.ts index a182de59a..54bcfd2eb 100644 --- a/frontend-modern/src/utils/__tests__/aiSettingsPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/aiSettingsPresentation.test.ts @@ -53,7 +53,7 @@ describe('aiSettingsPresentation', () => { ariaLabel: 'Activate quickstart or connect a provider', title: 'Activate quickstart or connect a provider', description: - 'Start a trial to unlock Patrol quickstart, or connect your own provider for Pulse Assistant and Patrol.', + 'Use an eligible hosted quickstart entitlement, or connect your own provider for Pulse Assistant and Patrol.', submitLabel: 'Enable Assistant & Patrol', }); }); @@ -104,13 +104,13 @@ describe('aiSettingsPresentation', () => { quickstartCreditsRemaining: 0, quickstartCreditsTotal: 0, quickstartBlockedReason: - 'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.', + 'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.', }), ).toEqual({ containerClassName: 'bg-amber-50 dark:bg-amber-900 text-amber-800 dark:text-amber-200', dotClassName: 'bg-amber-400', summary: - 'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.', + 'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.', }); }); diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts index be7153dca..ccf3ba897 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts @@ -101,14 +101,9 @@ describe('licensePresentation', () => { planSelectionPromptActionLabel: 'Compare plans', purchaseActivatedPlanActionLabel: 'Review plan', purchaseCancelledActionLabel: 'Compare plans', - purchaseExpiredActionLabel: 'Restart upgrade', + purchaseExpiredActionLabel: 'Compare plans', purchaseFailedActionLabel: 'Open recovery', purchaseUnavailableActionLabel: 'Try again', - trialStartTitle: 'Try Pro for free', - trialStartBody: - 'Start a 14-day Pro trial for root-cause analysis, safe auto-fix, and 90-day history on this organization.', - trialStartIdleActionLabel: 'Start 14-day Pro Trial', - trialStartPendingActionLabel: 'Starting...', recoverySectionTitle: 'Activation & Recovery', recoverySectionDescription: 'Activate a purchased key, recover a previous self-hosted purchase, or clear a local key from this instance.', diff --git a/frontend-modern/src/utils/__tests__/relayPresentation.test.ts b/frontend-modern/src/utils/__tests__/relayPresentation.test.ts index 98b2a6948..84025f6f1 100644 --- a/frontend-modern/src/utils/__tests__/relayPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/relayPresentation.test.ts @@ -13,10 +13,6 @@ import { RELAY_ONBOARDING_DISCONNECTED_LABEL, RELAY_ONBOARDING_SETUP_LABEL, RELAY_ONBOARDING_TITLE, - RELAY_ONBOARDING_TRIAL_LABEL, - RELAY_ONBOARDING_TRIAL_STARTING_LABEL, - RELAY_ONBOARDING_SETUP_WIZARD_TRIAL_LABEL, - RELAY_ONBOARDING_TRIAL_HINT, RELAY_ONBOARDING_UPGRADE_LABEL, RELAY_PAIRING_AVAILABILITY_MESSAGE, RELAY_PAIRING_AVAILABILITY_TITLE, @@ -84,10 +80,6 @@ describe('relayPresentation', () => { expect(RELAY_ONBOARDING_TITLE).toBe('Pair Your Mobile Device'); expect(RELAY_ONBOARDING_DESCRIPTION).toContain('remote monitoring'); expect(RELAY_ONBOARDING_UPGRADE_LABEL).toBe('Get Relay — $39/yr'); - expect(RELAY_ONBOARDING_TRIAL_LABEL).toBe('or start a Pro trial'); - expect(RELAY_ONBOARDING_TRIAL_STARTING_LABEL).toBe('Starting trial...'); - expect(RELAY_ONBOARDING_SETUP_WIZARD_TRIAL_LABEL).toBe('Start Free Trial & Set Up Mobile'); - expect(RELAY_ONBOARDING_TRIAL_HINT).toBe('14-DAY PRO TRIAL · NO CREDIT CARD REQUIRED'); expect(RELAY_ONBOARDING_SETUP_LABEL).toBe('Set Up Relay'); expect(RELAY_ONBOARDING_DISCONNECTED_LABEL).toBe('Relay is currently disconnected.'); }); diff --git a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts index 89d1e5614..dde38d15e 100644 --- a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts +++ b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts @@ -58,8 +58,7 @@ describe('selfHostedPlans', () => { includedLabel: 'Included', freeLabel: 'Free', buyRelayLabel: 'Buy Relay', - upgradeToProLabel: 'Upgrade to Pro', - startTrialLabel: 'Start Free 14-day Trial', + upgradeToProLabel: 'Choose Pro', featureComparisonHeading: 'Feature Comparison', footerLinks: [ { @@ -135,7 +134,7 @@ describe('selfHostedPlans', () => { expect.arrayContaining([ 'Unlimited self-hosted monitoring', 'Pulse Patrol (BYOK)', - 'Patrol quickstart after activation or trial: 25 runs, no API key', + 'Hosted Patrol quickstart with activated entitlement: 25 runs, no API key', ]), ); expect(SELF_HOSTED_PLAN_BY_TIER.relay.highlights).toContain('14-day metric history'); diff --git a/frontend-modern/src/utils/__tests__/systemSettingsPresentation.test.ts b/frontend-modern/src/utils/__tests__/systemSettingsPresentation.test.ts index 7b191f9c7..41baf6fe6 100644 --- a/frontend-modern/src/utils/__tests__/systemSettingsPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/systemSettingsPresentation.test.ts @@ -83,10 +83,10 @@ describe('systemSettingsPresentation', () => { 'Unable to update container update actions.', ); expect(getReduceUpsellNoiseUpdateErrorMessage()).toBe( - 'Unable to update upgrade guidance preferences.', + 'Unable to update commercial prompt preferences.', ); expect(getLocalUpgradeMetricsUpdateErrorMessage()).toBe( - 'Unable to update local-only upgrade events.', + 'Unable to update local-only commercial events.', ); expect(getTelemetryUpdateErrorMessage()).toBe('Unable to update anonymous telemetry.'); expect(getTemperatureMonitoringUpdateErrorMessage()).toBe( diff --git a/frontend-modern/src/utils/aiQuickstartContract.ts b/frontend-modern/src/utils/aiQuickstartContract.ts index 2e0b2cb0c..0c65b8bbf 100644 --- a/frontend-modern/src/utils/aiQuickstartContract.ts +++ b/frontend-modern/src/utils/aiQuickstartContract.ts @@ -2,7 +2,7 @@ export const AI_QUICKSTART_EXHAUSTED_REASON = 'Quickstart credits exhausted. Connect your API key to continue using Patrol.'; export const AI_QUICKSTART_ACTIVATION_REQUIRED_REASON = - 'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.'; + 'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.'; export const AI_QUICKSTART_UNAVAILABLE_REASON = 'Quickstart credits require internet access. Connect your API key to use Patrol offline.'; diff --git a/frontend-modern/src/utils/aiSettingsPresentation.ts b/frontend-modern/src/utils/aiSettingsPresentation.ts index 22ab7b334..3c814749e 100644 --- a/frontend-modern/src/utils/aiSettingsPresentation.ts +++ b/frontend-modern/src/utils/aiSettingsPresentation.ts @@ -69,7 +69,7 @@ export function getAISettingsSetupDialogPresentation( ariaLabel: 'Activate quickstart or connect a provider', title: 'Activate quickstart or connect a provider', description: - 'Start a trial to unlock Patrol quickstart, or connect your own provider for Pulse Assistant and Patrol.', + 'Use an eligible hosted quickstart entitlement, or connect your own provider for Pulse Assistant and Patrol.', submitLabel: 'Enable Assistant & Patrol', }; case 'provider-required': diff --git a/frontend-modern/src/utils/relayPresentation.ts b/frontend-modern/src/utils/relayPresentation.ts index a1fbd35a4..c3e23b2d9 100644 --- a/frontend-modern/src/utils/relayPresentation.ts +++ b/frontend-modern/src/utils/relayPresentation.ts @@ -39,12 +39,8 @@ export const RELAY_ONBOARDING_TITLE = 'Pair Your Mobile Device'; export const RELAY_ONBOARDING_DESCRIPTION = 'Pulse Relay lets your phone securely connect to this Pulse instance for remote monitoring.'; export const RELAY_ONBOARDING_UPGRADE_LABEL = 'Get Relay — $39/yr'; -export const RELAY_ONBOARDING_TRIAL_LABEL = 'or start a Pro trial'; -export const RELAY_ONBOARDING_TRIAL_STARTING_LABEL = 'Starting trial...'; export const RELAY_ONBOARDING_SETUP_LABEL = 'Set Up Relay'; export const RELAY_ONBOARDING_DISCONNECTED_LABEL = 'Relay is currently disconnected.'; -export const RELAY_ONBOARDING_SETUP_WIZARD_TRIAL_LABEL = 'Start Free Trial & Set Up Mobile'; -export const RELAY_ONBOARDING_TRIAL_HINT = '14-DAY PRO TRIAL · NO CREDIT CARD REQUIRED'; export function getRelayDiagnosticClass(severity: 'warning' | 'error'): string { return severity === 'error' diff --git a/frontend-modern/src/utils/selfHostedPlans.ts b/frontend-modern/src/utils/selfHostedPlans.ts index 91076fdc3..44845df36 100644 --- a/frontend-modern/src/utils/selfHostedPlans.ts +++ b/frontend-modern/src/utils/selfHostedPlans.ts @@ -42,7 +42,6 @@ export interface SelfHostedCommercialPresentation { freeLabel: string; buyRelayLabel: string; upgradeToProLabel: string; - startTrialLabel: string; featureComparisonHeading: string; footerLinks: readonly SelfHostedLinkCTA[]; } @@ -99,7 +98,7 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] = 'Unlimited self-hosted monitoring', '7-day metric history', 'Pulse Patrol (BYOK)', - 'Patrol quickstart after activation or trial: 25 runs, no API key', + 'Hosted Patrol quickstart with activated entitlement: 25 runs, no API key', 'Update alerts', 'Basic SSO (OIDC)', 'Community support', @@ -183,8 +182,7 @@ export const SELF_HOSTED_COMMERCIAL_PRESENTATION: SelfHostedCommercialPresentati includedLabel: 'Included', freeLabel: 'Free', buyRelayLabel: 'Buy Relay', - upgradeToProLabel: 'Upgrade to Pro', - startTrialLabel: 'Start Free 14-day Trial', + upgradeToProLabel: 'Choose Pro', featureComparisonHeading: 'Feature Comparison', footerLinks: [ { diff --git a/frontend-modern/src/utils/systemSettingsPresentation.ts b/frontend-modern/src/utils/systemSettingsPresentation.ts index cdf39133e..a73244c27 100644 --- a/frontend-modern/src/utils/systemSettingsPresentation.ts +++ b/frontend-modern/src/utils/systemSettingsPresentation.ts @@ -77,11 +77,11 @@ export function getDockerUpdateActionsUpdateErrorMessage(message?: string): stri } export function getReduceUpsellNoiseUpdateErrorMessage(message?: string): string { - return message || 'Unable to update upgrade guidance preferences.'; + return message || 'Unable to update commercial prompt preferences.'; } export function getLocalUpgradeMetricsUpdateErrorMessage(message?: string): string { - return message || 'Unable to update local-only upgrade events.'; + return message || 'Unable to update local-only commercial events.'; } export function getTelemetryUpdateErrorMessage(message?: string): string { diff --git a/internal/ai/patrol.go b/internal/ai/patrol.go index 0443ae5e3..913e3c37f 100644 --- a/internal/ai/patrol.go +++ b/internal/ai/patrol.go @@ -90,7 +90,7 @@ const ( const ( patrolQuickstartCreditsExhaustedReason = "Quickstart credits exhausted. Connect your API key to continue using AI Patrol." - patrolQuickstartActivationRequiredReason = "Activate this install or start a trial to use AI Patrol quickstart. Otherwise connect your API key." + patrolQuickstartActivationRequiredReason = "Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement." patrolQuickstartUnavailableReason = "Quickstart credits require internet access. Connect your API key for offline AI Patrol." ) diff --git a/internal/ai/quickstart_test.go b/internal/ai/quickstart_test.go index 85f6ea8fe..b559bfcd1 100644 --- a/internal/ai/quickstart_test.go +++ b/internal/ai/quickstart_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/ed25519" "encoding/base64" + "strings" "testing" "time" @@ -145,6 +146,9 @@ func TestQuickstartReasonExportsRemainCanonical(t *testing.T) { if got := QuickstartActivationRequiredReason(); got != patrolQuickstartActivationRequiredReason { t.Fatalf("QuickstartActivationRequiredReason() = %q, want %q", got, patrolQuickstartActivationRequiredReason) } + if strings.Contains(strings.ToLower(QuickstartActivationRequiredReason()), "trial") { + t.Fatalf("activation-required quickstart reason must stay BYOK-first, got %q", QuickstartActivationRequiredReason()) + } } func TestPersistentQuickstartCreditManager_LoadsPersistedTokenWithoutBootstrap(t *testing.T) { diff --git a/internal/api/router_routes_licensing.go b/internal/api/router_routes_licensing.go index a097f41f6..9ebd60084 100644 --- a/internal/api/router_routes_licensing.go +++ b/internal/api/router_routes_licensing.go @@ -45,8 +45,8 @@ func (r *Router) registerOrgLicenseRoutesGroup(orgHandlers *OrgHandlers, rbacHan r.mux.HandleFunc("POST /api/license/monitored-system-ledger/preview", RequireAuth(r.config, r.handleMonitoredSystemLedgerPreview)) r.mux.HandleFunc("POST /api/license/trial/start", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.licenseHandlers.HandleStartTrial))) - // Local-only upgrade events (legacy route names retain "upgrade-metrics" for compatibility). - // These are local-only signals used to improve in-app upgrade flows; no external export. + // Local-only commercial handoff events (legacy route names retain "upgrade-metrics" for compatibility). + // These are local-only signals used to debug explicit paid-plan handoffs; no external export. r.mux.HandleFunc("POST /api/upgrade-metrics/events", RequireAuth(r.config, conversionHandlers.HandleRecordEvent)) r.mux.HandleFunc("GET /api/upgrade-metrics/stats", RequireAuth(r.config, conversionHandlers.HandleGetStats)) r.mux.HandleFunc("GET /api/upgrade-metrics/health", RequireAuth(r.config, conversionHandlers.HandleGetHealth)) diff --git a/internal/config/config.go b/internal/config/config.go index 4cdd3967e..3233601d3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1049,7 +1049,7 @@ func load(initLogging bool) (*Config, error) { cfg.DisableLocalUpgradeMetrics = disabled cfg.EnvOverrides["PULSE_DISABLE_LOCAL_UPGRADE_METRICS"] = true cfg.EnvOverrides["disableLocalUpgradeMetrics"] = true - log.Info().Bool("disabled", disabled).Msg("Overriding local-only upgrade events setting from environment") + log.Info().Bool("disabled", disabled).Msg("Overriding local-only commercial events setting from environment") } else { log.Warn().Str("value", disableLocalUpgradeMetricsStr).Msg("Invalid PULSE_DISABLE_LOCAL_UPGRADE_METRICS value, ignoring") } diff --git a/internal/config/config_load_test.go b/internal/config/config_load_test.go index 26d8c5b50..3514b3e7e 100644 --- a/internal/config/config_load_test.go +++ b/internal/config/config_load_test.go @@ -62,6 +62,7 @@ func TestLoad_EnvOverrides(t *testing.T) { t.Setenv("PULSE_DATA_DIR", tempDir) t.Setenv("HTTPS_ENABLED", "true") t.Setenv("PULSE_AUTH_USER", "admin") + t.Setenv("PULSE_DISABLE_LOCAL_UPGRADE_METRICS", "true") cfg, err := Load() require.NoError(t, err) @@ -70,6 +71,9 @@ func TestLoad_EnvOverrides(t *testing.T) { assert.Equal(t, tempDir, cfg.DataPath) assert.True(t, cfg.HTTPSEnabled) assert.Equal(t, "admin", cfg.AuthUser) + assert.True(t, cfg.DisableLocalUpgradeMetrics) + assert.True(t, cfg.EnvOverrides["PULSE_DISABLE_LOCAL_UPGRADE_METRICS"]) + assert.True(t, cfg.EnvOverrides["disableLocalUpgradeMetrics"]) } func TestLoad_DotEnv(t *testing.T) { diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 281314486..9d192892b 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -1372,8 +1372,8 @@ type SystemSettings struct { DisableDockerUpdateActions bool `json:"disableDockerUpdateActions"` // Hide update buttons while still detecting updates // UX + privacy knobs (server-wide) - ReduceProUpsellNoise bool `json:"reduceProUpsellNoise,omitempty"` // Hide proactive Pro prompts; paywalls still appear when accessing gated features - DisableLocalUpgradeMetrics bool `json:"disableLocalUpgradeMetrics,omitempty"` // Disable local-only upgrade UX metrics collection (no network calls) + ReduceProUpsellNoise bool `json:"reduceProUpsellNoise,omitempty"` // Legacy compatibility preference for proactive commercial prompts + DisableLocalUpgradeMetrics bool `json:"disableLocalUpgradeMetrics,omitempty"` // Disable local-only commercial handoff metrics collection (no network calls) // Telemetry (enabled by default, opt-out) TelemetryEnabled *bool `json:"telemetryEnabled,omitempty"` // Send anonymous usage telemetry (install ID, version, resource counts, feature flags — no PII) diff --git a/tests/integration/tests/51-quickstart-cross-surface.spec.ts b/tests/integration/tests/51-quickstart-cross-surface.spec.ts index 807fe9f4f..b56de00bb 100644 --- a/tests/integration/tests/51-quickstart-cross-surface.spec.ts +++ b/tests/integration/tests/51-quickstart-cross-surface.spec.ts @@ -5,7 +5,7 @@ import { ensureAuthenticated } from "./helpers"; const QUICKSTART_EXHAUSTED_REASON = "Quickstart credits exhausted. Connect your API key to continue using Patrol."; const QUICKSTART_ACTIVATION_REQUIRED_REASON = - "Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key."; + "Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement."; const QUICKSTART_OFFLINE_REASON = "Quickstart credits require internet access. Connect your API key to use Patrol offline."; diff --git a/tests/integration/tests/58-self-hosted-trial-rate-limit-ui.spec.ts b/tests/integration/tests/58-self-hosted-trial-rate-limit-ui.spec.ts index 3e160335f..62316dd6c 100644 --- a/tests/integration/tests/58-self-hosted-trial-rate-limit-ui.spec.ts +++ b/tests/integration/tests/58-self-hosted-trial-rate-limit-ui.spec.ts @@ -98,5 +98,8 @@ test.describe.serial('Self-hosted paid prompt visibility', () => { await expect(page.getByRole('button', { name: 'Plans & Activation' })).toHaveCount(0); 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); + await expect(page.getByText(/free 14-day trial/i)).toHaveCount(0); + await expect(page.getByText(/open hosted handoff/i)).toHaveCount(0); }); });