diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index f58ddff01..cebdf8317 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -151,7 +151,12 @@ Agent-facing operations-loop status wiring in `internal/api/router.go` and shares agent route infrastructure. Other handlers in `internal/api/` such as the AI settings handler (`ai_handlers.go`) carry AI provider configuration (for example per-provider base URL overrides) that is ai-runtime config-surface -and is not agent enrollment, liveness, or lifecycle state. Workflow starter counts on that endpoint, +and is not agent enrollment, liveness, or lifecycle state. The Pro update +credential wiring in `internal/api/router.go` (feeding the activation's +installation token and instance fingerprint to the server updater's +download-broker path) is likewise server self-update plumbing: agent +enrollment, agent update liveness, and fleet-control semantics must not key +off it. Workflow starter counts on that endpoint, contextual Assistant/external-agent collaboration counts inside the Assistant step, the content-free Patrol control starter split, and Patrol control completed-loop, resolved-loop, or `patrolControlValueState` proof mirrored to diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index d71b00742..b44ee55ba 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1585,6 +1585,15 @@ payload shape change when the portal presents compact client rows. the requested target version through the shared update-target validation path, recompute readiness from live backend state, and reject `blocked` verdicts before update execution starts. + On the compiled Pro edition the same check and apply endpoints are served + by the license server download broker instead of the public GitHub + releases: response shapes are unchanged, but `downloadUrl` is a broker + intent URL for the activation's license server (target version carried in + the query so the shared update-target validation still applies), and apply + re-resolves fresh signed artifact URLs server-side. Update payloads must + never expose broker credentials (installation token, fingerprint) or the + short-lived signed URLs, and handlers must not fork payload shape by + edition. The platform-connections API contract also owns inactive monitored-system candidate semantics end to end. `enabled=false` on TrueNAS or VMware preview, test, add, and update payloads must serialize through the shared ledger client diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 4992516d8..9f65f0547 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -249,6 +249,14 @@ avoids a cloud-control-plane report data path across clients. preserve the raw runtime build and expose a normalized `pro`, `community`, or `unknown` status so paid-runtime support triage does not depend on interpreting Docker tags, public release names, or customer screenshots. + The same activation state is also the credential source for the compiled + Pro binary's in-app self-update through the license-gated download broker: + that consumer is read-only over the activation snapshot (installation + token, instance fingerprint, license server URL) and must not mutate + licensing state, extend entitlements, or act as an alternate activation + path. Keeping the Pro runtime updatable in place is part of the + paid-runtime posture; the community self-update flow must never be the + default answer for an installed Pro runtime. That same shared licensing boundary also owns paid-migration degradation visibility and recovery. A persisted v5 license that exists but cannot be read or decrypted must publish a terminal `commercial_migration` state diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index d12e1fe2d..b2200b2d2 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -593,20 +593,29 @@ TLS floor in the dynamic config. archive filenames through `--archive` so direct Linux and Proxmox LXC users can keep the normal service setup while installing the private Pulse Pro runtime. - The in-app updater must refuse to apply on the compiled Pulse Pro binary: - `internal/updates` `ApplyUpdate` blocks when the running edition is Pro - (recorded by `pkg/edition`, flipped to `pro` in `enterpriseruntime.Initialize` - alongside `coreaudit.SetLogger`/`server.SetBusinessHooks`, and keyed off the - compiled binary — never license-active state) and directs the operator to + The in-app updater must never install a public community build on the + compiled Pulse Pro binary: when the running edition is Pro (recorded by + `pkg/edition`, flipped to `pro` in `enterpriseruntime.Initialize` alongside + `coreaudit.SetLogger`/`server.SetBusinessHooks`, and keyed off the compiled + binary — never license-active state), `internal/updates` checks and applies + updates exclusively through the license server download broker + (`GET /v1/downloads/pulse-pro` with the installation token and instance + fingerprint, per `internal/updates/pro_update.go`), verifying the private + archive against the same pinned `pulse-installer` SSHSIG key plus the + broker manifest sha256, and refusing GitHub-shaped download URLs outright. + An unactivated Pro binary refuses to apply and directs the operator to `https://pulserelay.pro/download.html` and the `install.sh --archive` path. - This is required because the community self-update flow (both the in-app - updater and `install.sh` default to the public `rcourtman/Pulse` community - assets) would replace the Pro binary and silently strip Audit, RBAC, - Reporting, and SSO from a paying customer. A community binary with an active - paid license is still community and must keep its normal self-update; the - `frontend-modern` update banner mirrors the same distinction by hiding the - in-app apply affordance for the Pro runtime identity and surfacing the portal - path instead. + This is required because the community self-update flow (the in-app GitHub + path, `install.sh` defaults, and the unattended + `scripts/pulse-auto-update.sh` timer — which must skip when the installed + binary reports `Pulse Pro`) targets the public `rcourtman/Pulse` community + assets and would replace the Pro binary and silently strip Audit, RBAC, + Reporting, and SSO from a paying customer. A community binary with an + active paid license is still community and must keep its normal + self-update; the `frontend-modern` update banner keeps the in-app apply + affordance for auto-updatable Pro deployments (the broker path preserves + the Pro runtime) and surfaces the portal path for deployments the updater + cannot drive, such as Docker. Customer-facing private Pro RC/GA promotion is part of that same boundary: for every non-draft v6 public release, `create-release.yml` must call the private `rcourtman/pulse-enterprise` `Build Pro Release` workflow after diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index fa98af7ba..2a4d0f8c3 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -279,6 +279,12 @@ regression protection. monitor config into the unified-resource adapter, but it must not add per-request polling, registry rescans, persistence walks, or tenant-wide refreshes to decide whether Proxmox/PBS/PMG resources are stale. + Pro update credential wiring in `internal/api/router.go` follows the same + bounded rule: the credential-source closure reads the already-held + activation snapshot only when the updater checks or applies, and the + download-broker fetch stays on the existing update-check cadence (the + check cache plus one fresh resolve per apply). It must not add polling + loops, background license-server traffic, or per-request broker calls. Global resource timeline routing follows the same protected-request hot-path rule: `/api/resources/timeline` registration may wire the authenticated handler, but router setup and auth gating must not execute resource-change diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 3906e2f10..4cab48700 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -321,6 +321,13 @@ the `white_label` branding entitlement. metadata only. They must not disclose credentials, command output, raw provider payloads, tenant-crossing config, or any new resource-policy bypass through monitoring-readable API responses. + The Pro update credential source in router glue hands the activation's + installation token, instance fingerprint, and license server URL to the + server updater only. The token travels solely as an Authorization header + to the activation's normalized license-server base URL; it must never be + logged, echoed through update payloads, status, or history surfaces, or + sent to any other host, and the broker's short-lived signed artifact URLs + are transport only and must not be persisted or exposed. Assistant session rename routing through `PATCH /api/ai/sessions/{id}` stays on that same auth/scope boundary: the route may accept only a user-visible title mutation, must not expose transcript contents, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index bce94262d..75d974e7c 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -540,6 +540,12 @@ recovery scope, or a storage/recovery-owned secret source. transport with storage/recovery. Storage and recovery consumers must preserve the API-owned Docker / Podman module or host wording for management responses and must not introduce recovery-local container-runtime labels. + Pro update credential wiring and update-broker transport in + `internal/api/router.go` and the update handlers are server self-update + plumbing, not storage or recovery surface: storage and recovery must not + consume them, and the pre-update backup and rollback machinery in + `internal/updates` stays identical for community and private Pro archives + so recovery semantics never fork by edition. Proxmox-side LXC Docker inventory wiring may also pass through `internal/api/router.go` and Proxmox agent install-command generation, but storage and recovery may consume the resulting app-container/resource diff --git a/frontend-modern/src/components/UpdateBanner.test.tsx b/frontend-modern/src/components/UpdateBanner.test.tsx index cc50f119c..8d9f89cd0 100644 --- a/frontend-modern/src/components/UpdateBanner.test.tsx +++ b/frontend-modern/src/components/UpdateBanner.test.tsx @@ -15,7 +15,7 @@ const { mock } = vi.hoisted(() => ({ })); // runtime.build is the binary-edition signal (business-hooks presence), which -// is what UpdateBanner keys the Pro path off of. +// is what UpdateBanner keys the Pro paths off of. vi.mock('@/stores/license', () => ({ runtimeCapabilities: () => ({ runtime: { build: mock.runtimeBuild } }), })); @@ -60,24 +60,49 @@ afterEach(() => { mock.plan = { canAutoUpdate: true, requiresRoot: false, rollbackSupport: false, instructions: [] }; }); -describe('UpdateBanner Pro edition guard', () => { - it('suppresses in-app apply and routes the Pro binary to the portal', () => { +describe('UpdateBanner Pro edition update paths', () => { + it('keeps in-app apply for the Pro binary on auto-updatable deployments', async () => { mock.runtimeBuild = 'pro'; render(() => ); - // The in-app apply affordance must never render for the Pro binary, even - // though the plan reports canAutoUpdate=true (systemd). - expect(screen.queryByRole('button', { name: /Apply Update/ })).not.toBeInTheDocument(); - - const portalLink = screen.getByRole('link', { name: /Private Release Access/ }); - expect(portalLink).toHaveAttribute('href', PORTAL_URL); + // The Pro binary updates from the license server download broker, so the + // in-app apply affordance must render once the plan resolves. + expect(await screen.findByRole('button', { name: 'Apply Update' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /Private Release Access/ })).not.toBeInTheDocument(); }); - it('shows the portal steps (archive + .sshsig) when the Pro banner is expanded', () => { + it('explains the private release source when the Pro banner is expanded', async () => { mock.runtimeBuild = 'pro'; render(() => ); + await screen.findByRole('button', { name: 'Apply Update' }); + fireEvent.click(screen.getByTitle('Show more')); + + expect(screen.getByText(/private Pulse Pro build from the license server/)).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Apply Update Automatically' }), + ).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /Private Release Access/ })).not.toBeInTheDocument(); + }); + + it('routes non-auto-updatable Pro deployments to the portal', async () => { + mock.runtimeBuild = 'pro'; + mock.plan = { canAutoUpdate: false, requiresRoot: false, rollbackSupport: false, instructions: [] }; + + render(() => ); + + const portalLink = await screen.findByRole('link', { name: /Private Release Access/ }); + expect(portalLink).toHaveAttribute('href', PORTAL_URL); + expect(screen.queryByRole('button', { name: /Apply Update/ })).not.toBeInTheDocument(); + }); + + it('shows the portal steps (archive + .sshsig) for manual Pro deployments when expanded', async () => { + mock.runtimeBuild = 'pro'; + mock.plan = { canAutoUpdate: false, requiresRoot: false, rollbackSupport: false, instructions: [] }; + + render(() => ); + await screen.findByRole('link', { name: /Private Release Access/ }); fireEvent.click(screen.getByTitle('Show more')); expect(screen.getByText('Pulse Pro update')).toBeInTheDocument(); diff --git a/frontend-modern/src/components/UpdateBanner.tsx b/frontend-modern/src/components/UpdateBanner.tsx index fb0561d4e..c89562a3d 100644 --- a/frontend-modern/src/components/UpdateBanner.tsx +++ b/frontend-modern/src/components/UpdateBanner.tsx @@ -7,9 +7,10 @@ import { copyToClipboard } from '@/utils/clipboard'; import { logger } from '@/utils/logger'; import { buildReleaseNotesUrl } from '@/components/updateVersion'; -// Self-hosted Pulse Pro updates come from the Private Release Access portal, not -// the in-app updater (which tracks the public community build and would strip -// Pro features). See the ApplyUpdate edition gate in internal/updates/manager.go. +// The Pro binary self-updates from the license server download broker (see +// internal/updates/pro_update.go), so in-app apply keeps the Pro runtime. +// The portal is the manual path for deployments the updater cannot drive +// (e.g. Docker), where the community pull/instructions would strip Pro. const PRO_RELEASE_ACCESS_URL = 'https://pulserelay.pro/download.html'; export function UpdateBanner() { @@ -54,12 +55,12 @@ export function UpdateBanner() { buildReleaseNotesUrl(updateStore.updateInfo()?.latestVersion), ); - // The compiled Pro binary must never self-update off the community build, so - // suppress in-app apply and point the customer at the portal instead. This - // keys off the binary's runtime identity (business hooks presence), NOT the - // license tier: a community binary with an active Pro license still - // self-updates normally. The backend ApplyUpdate gate is the hard guarantee; - // this is the UX layer. + // The compiled Pro binary self-updates from the license server download + // broker, so in-app apply is safe and keeps the Pro runtime; only the + // manual instructions differ (community pull/console steps would install + // the community build). This keys off the binary's runtime identity + // (business hooks presence), NOT the license tier: a community binary with + // an active Pro license follows the normal community paths. const isProEdition = () => runtimeCapabilities()?.runtime?.build === 'pro'; const handleApplyUpdate = () => { @@ -161,8 +162,9 @@ export function UpdateBanner() { - {/* Apply Update Button (automated community deployments) */} - + {/* Apply Update Button (automated deployments; Pro applies + the private build from the license server broker) */} +