Reconcile provider-hosted MSP copy

This commit is contained in:
rcourtman 2026-06-02 19:14:18 +01:00
parent d6964832a0
commit 68e3a015c0
13 changed files with 177 additions and 115 deletions

View file

@ -60,12 +60,12 @@ All backend code lives under `cmd/`, `internal/`, and `pkg/`. The binary is asse
- **Polymorphic monitors**: Each Proxmox VE/PBS/PMG node runs in its own goroutine, polling via the platform REST API.
- **Agent receivers** (`internal/api`): Docker, Host, and Kubernetes agents push metrics via HTTP POST to `/api/agents/{type}/report`.
- **TrueNAS provider** (`internal/truenas`): Polls TrueNAS REST API for system info, pools, datasets, disks, alerts, ZFS snapshots, and replication tasks.
- Multi-tenant aware: when `PULSE_MULTI_TENANT_ENABLED=true`, each organisation gets an isolated monitor instance with its own configuration.
- Enterprise/internal multi-org aware: when `PULSE_MULTI_TENANT_ENABLED=true`, each organisation gets a separate shared-process monitor namespace with its own configuration.
4. **WebSocket Hub (`internal/websocket`)**
- Manages active browser connections with per-message compression (deflate).
- Broadcasts state diffs to all subscribed clients; supports per-tenant broadcasts for multi-org setups.
- Enforces origin validation, org-level authorization, and multi-tenant license gating.
- Broadcasts state diffs to all subscribed clients; supports per-organization broadcasts for internal multi-org setups.
- Enforces origin validation, organization-level authorization, and Enterprise/internal multi-org license gating.
5. **Decomposed API Router (`internal/api`)**
- The router is split into focused registration files for maintainability:
@ -92,15 +92,21 @@ All backend code lives under `cmd/`, `internal/`, and `pkg/`. The binary is asse
8. **Entitlements & Licensing (`internal/license`)**
- Capability-key based gating: `ai_autofix`, `rbac`, `multi_tenant`, `relay`, `agent_profiles`, `kubernetes_ai`, `ai_alerts`, etc.
- Three tiers: **Community** (free), **Pro** (license key), **Cloud** (subscription).
- Core tiers include **Community** (free), **Relay**, **Pro**, hosted **Cloud**, request-assisted **MSP**, and Enterprise/custom entitlements.
- Trial lifecycle with activation, renewal, and expiry. All state exposed via `/api/license/*`.
9. **Recovery Engine (`internal/recovery`)**
9. **Provider-hosted MSP control plane (`internal/cloudcp`)**
- A Stripe-free provider control plane can run one isolated Pulse runtime/container per client workspace.
- A signed MSP license sets the provider plan and client workspace cap.
- Pulse Account handles the provider workspace roster, sign-in-once handoff, client-bound agent install paths, and provider access surfaces.
- Each client runtime remains a normal Pulse instance for monitoring data, alerts, webhooks, reports, users, and audit history. Shared-process organizations are for Enterprise/internal multi-org, not the default MSP boundary.
10. **Recovery Engine (`internal/recovery`)**
- Aggregates backup data (PBS snapshots, ZFS snapshots, replication tasks) into a unified recovery point timeline.
- Provides faceted queries: filter by type, source, status, and source health.
- API: `/api/recovery/points`, `/api/recovery/series`, `/api/recovery/facets`, `/api/recovery/rollups`.
10. **Audit Logging (`pkg/audit`)**
11. **Audit Logging (`pkg/audit`)**
- Defence-in-depth: every mutation is logged to SQLite, optionally signed with per-tenant encryption keys.
- Async logging mode (`PULSE_AUDIT_ASYNC`) for high-throughput environments.
- Tenant-aware logger manager for isolated per-org audit trails.

View file

@ -22,7 +22,7 @@ normal contribution flow for this repository. See [CONTRIBUTING.md](CONTRIBUTING
Pulse is a modern, unified monitoring workspace for your **infrastructure** across Proxmox, Docker, Kubernetes, and TrueNAS. It consolidates metrics, alerts, and AI-powered insights from all your systems into a single, beautiful interface.
Designed for homelabs, sysadmins, and MSPs who need a "single pane of glass" without the complexity of enterprise monitoring stacks.
Designed for homelabs, sysadmins, internal IT teams, and providers who need a clear monitoring view without the complexity of enterprise monitoring stacks. MSP access is a separate, request-assisted provider path and is not part of ordinary self-hosted setup.
![Pulse Infrastructure](docs/images/01-dashboard.jpg)
@ -150,7 +150,7 @@ and Recovery desktop history-table layout are all aligned.
- **[API Reference](docs/API.md)**: Integrate Pulse with your own tools.
- **[Architecture](ARCHITECTURE.md)**: High-level system design and data flow.
- **[AI Features](docs/AI.md)**: Pulse Assistant (Chat) and Pulse Patrol documentation.
- **[Multi-Tenant](docs/MULTI_TENANT.md)**: Enterprise multi-tenant setup and configuration.
- **[Multi-Tenant](docs/MULTI_TENANT.md)**: Enterprise/internal multi-organization setup and configuration.
- **[Troubleshooting](docs/TROUBLESHOOTING.md)**: Solutions to common issues.
- **[Agent Security](docs/AGENT_SECURITY.md)**: Agent privilege model, Proxmox API-only choices, and checksum/signature verification.
- **[Docker Monitoring](docs/DOCKER.md)**: Setup and management of Docker agents.
@ -166,8 +166,9 @@ Community-maintained integrations and addons:
Pulse is full-featured for core monitoring in every self-hosted tier. Self-hosted
pricing no longer sells more room for monitoring volume; paid value comes from
convenience, history, AI operations, and advanced administration. Cloud and MSP
pricing are unchanged.
convenience, history, AI operations, and advanced administration. Cloud remains
the hosted Pulse path. MSP is request-assisted provider hosting, with one
isolated Pulse runtime per client.
Self-hosted tiers:

View file

@ -1,6 +1,8 @@
# Pulse Cloud (Hosted)
Pulse Cloud is the hosted version of Pulse — a fully managed monitoring instance that runs in the cloud so you don't have to self-host.
Pulse Cloud is the hosted version of Pulse. It is a fully managed monitoring instance that runs in the cloud so you don't have to self-host.
Pulse Cloud is for a hosted Pulse instance. Pulse MSP is a separate provider path: the MSP normally runs a Stripe-free provider-hosted control plane with one isolated Pulse runtime per client. Pulse-hosted MSP is available only as a request-assisted option where Pulse operates that provider stack for the MSP.
## How It Works
@ -25,7 +27,7 @@ Pulse Cloud includes everything in the **Pro** plan, plus:
### Cloud Enterprise (Add-On)
For organisations that need multi-tenant management:
For organisations that need internal multi-organization management under one owner:
| Feature | Capability Key |
|---|---|
@ -35,6 +37,8 @@ For organisations that need multi-tenant management:
See [Plans & Entitlements](PULSE_PRO.md) for the full feature matrix.
Cloud Enterprise shared-process organizations are for one owner separating internal sites, departments, or environments. They are not the default MSP model for unrelated customer businesses.
## Getting Started
### 1. Create Your Account
@ -117,4 +121,4 @@ Yes. Use the export/import workflow described above. Your monitoring configurati
- [Plans & Entitlements](PULSE_PRO.md) — feature comparison across Community, Relay, Pro, legacy Pro+, and Cloud
- [Installation (Self-Hosted)](INSTALL.md) — self-hosted installation guide
- [Relay / Mobile Access](RELAY.md) — relay setup and mobile rollout status (pre-configured on Cloud)
- [Multi-Tenant](MULTI_TENANT.md) — multi-tenant mode (Cloud Enterprise)
- [Multi-Tenant](MULTI_TENANT.md), Enterprise/internal multi-organization mode

View file

@ -10,6 +10,7 @@ Pulse supports multiple deployment models. This page clarifies what differs betw
| systemd (bare metal / VM) | Traditional Linux hosts | `/etc/pulse` | In-app updates supported |
| Docker | Quick evaluation and container stacks | `/data` (bind mount / volume) | Image pull + restart |
| Kubernetes (Helm) | Cluster operators | `/data` (PVC) | Helm upgrade |
| Provider-hosted MSP | Managed service providers, request-assisted | Provider control plane data plus one tenant data directory per client runtime | Provider control plane rollout plus per-client runtime rollout |
## Common Ports
@ -53,7 +54,7 @@ Pulse uses a split config model:
- **Recovery tokens**: `recovery_tokens.json`
- **Update history**: `update-history.jsonl`
- **Metrics history**: `metrics.db` (SQLite)
- **Organization metadata**: `org.json` (multi-tenant)
- **Organization metadata**: `org.json` (Enterprise/internal multi-org)
- **TrueNAS connections**: `truenas.enc` (encrypted)
- **Relay config**: `relay.enc` (encrypted, Relay and above)
- **RBAC roles**: `rbac_roles.json` (Pro/legacy Pro+/Cloud)
@ -63,11 +64,18 @@ Path mapping:
- systemd/LXC: `/etc/pulse/*`
- Docker/Helm: `/data/*`
Multi-tenant layout:
Enterprise/internal multi-org layout:
- Default org uses the root data dir for backward compatibility.
- Non-default orgs use `/orgs/<org-id>/`.
- Migration may create `/orgs/default/` and symlinks in the root data dir.
Provider-hosted MSP layout:
- The MSP runs a Stripe-free provider control plane.
- A signed MSP license is the activation source and sets the provider plan plus client workspace cap.
- Each client workspace runs as its own isolated Pulse runtime/container with its own data, metrics, alerts, webhooks, report settings, users, and audit history.
- Pulse Account is the provider control plane for creating client workspaces and handing operators into the correct tenant-local Pulse runtime.
- Ordinary self-hosted Pulse deployments do not use this model unless the operator deliberately enters the MSP path.
## Updates by Model
### systemd and Proxmox LXC
@ -94,3 +102,9 @@ Upgrade the chart:
helm repo update
helm upgrade pulse pulse/pulse -n pulse
```
### Provider-hosted MSP
Provider-hosted MSP is not the same as enabling shared-process organizations in a normal Pulse install. The provider-hosted path runs a control plane that creates an isolated Pulse runtime for each client workspace. Alerts, webhook destinations, branded report settings, users, audit history, and metrics stay inside the client runtime. Duplicate hostnames across clients do not collide because they never share the same runtime namespace.
Access is request-assisted while MSP is staged for rollout. The deployable model is license-backed by a signed MSP license, not by Stripe checkout or environment-only plan selection.

View file

@ -1,6 +1,8 @@
# Multi-Tenant Organizations (Cloud Enterprise)
# Multi-Tenant Organizations (Enterprise/Internal)
Pulse supports isolated, multi-tenant organizations for MSPs, homelabs with multiple environments, and multi-datacenter deployments. Each organization gets its own infrastructure, resources, alerts, and audit log — fully isolated from other organizations on the same Pulse instance.
Pulse supports shared-process organizations for Enterprise and internal multi-organization deployments. Each organization gets its own infrastructure, resources, alerts, and audit log namespace on the same Pulse process.
This is not the canonical Pulse MSP model for separate customer businesses. MSP crosses legal and security ownership boundaries, so the canonical MSP route is provider-hosted: a Stripe-free provider control plane runs one isolated Pulse runtime per client workspace. Use shared-process organizations when one owner is deliberately separating internal sites, teams, departments, or environments.
## Requirements
@ -23,7 +25,7 @@ Without these, all API calls return `501 Not Implemented` (flag off) or `402 Pay
### Organizations
An organization is a fully isolated monitoring environment:
An organization is a separate monitoring namespace inside the same Pulse runtime:
- Its own set of monitored nodes and resources.
- Its own alerts, thresholds, and notifications.
@ -118,21 +120,23 @@ curl http://localhost:7655/api/orgs/{orgId}/shares/incoming \
-H "Authorization: Bearer $TOKEN"
```
## Monitoring Multiple Clients (MSP Setup)
## Monitoring Multiple Internal Estates
A common managed service provider pattern is to run one central Pulse server and keep each client in its own organization, so dashboards, alerts, notifications, and audit logs never mix between clients. The same default node names (`pve`, `pve1`) in different client organizations do not collide, because each organization is a separate namespace.
An Enterprise deployment can run one central Pulse server and keep each internal estate in its own organization, so dashboards, alerts, notifications, and audit logs are scoped by organization. The same default node names (`pve`, `pve1`) in different organizations do not collide, because each organization is a separate namespace.
To onboard a client:
Use this for one company operating many internal sites, teams, departments, or environments. Do not use this as the default MSP model for unrelated customer businesses; MSP client isolation belongs to the provider-hosted client-workspace model with one isolated Pulse runtime per client.
1. **Create an organization for the client** (see [Creating an Organization](#creating-an-organization)).
2. **Create an org-bound API token** with the `agent:report` scope, bound to that client's organization (`orgId`). A token bound to a single organization automatically routes every agent that uses it into that organization, with no extra header required.
3. **Install the client's agents** (Proxmox host, Docker, Kubernetes) using that token. Their telemetry lands in the client's organization, isolated from every other client.
4. **(Optional) Alias node names per client.** If two clients both use the default `pve` hostname and you want them visually distinct, set `--hostname` (or the `PULSE_HOSTNAME` environment variable) on the agent, for example `--hostname "acme-pve1"`. See [UNIFIED_AGENT.md](UNIFIED_AGENT.md).
5. **(Optional) Isolate agent check-in on its own port.** When client nodes reach the central server across the internet, enable [Split-Port Agent Ingest](CONFIGURATION.md#split-port-agent-ingest-network-isolation) so agents connect on a dedicated, firewalled port that exposes only `/api/agents/*` and never the web UI or management API.
To onboard an internal estate:
Route each client's alerts into your ticketing system (ConnectWise and others) with per-organization webhooks or the org-scoped alerts API. See the Multi-tenant / MSP section of [WEBHOOKS.md](WEBHOOKS.md).
1. **Create an organization for the estate** (see [Creating an Organization](#creating-an-organization)).
2. **Create an org-bound API token** with the `agent:report` scope, bound to that estate's organization (`orgId`). A token bound to a single organization automatically routes every agent that uses it into that organization, with no extra header required.
3. **Install the estate's agents** (Proxmox host, Docker, Kubernetes) using that token. Their telemetry lands in the selected organization.
4. **(Optional) Alias node names per estate.** If two estates both use the default `pve` hostname and you want them visually distinct, set `--hostname` (or the `PULSE_HOSTNAME` environment variable) on the agent, for example `--hostname "acme-pve1"`. See [UNIFIED_AGENT.md](UNIFIED_AGENT.md).
5. **(Optional) Isolate agent check-in on its own port.** When remote nodes reach the central server across the internet, enable [Split-Port Agent Ingest](CONFIGURATION.md#split-port-agent-ingest-network-isolation) so agents connect on a dedicated, firewalled port that exposes only `/api/agents/*` and never the web UI or management API.
**Licensing:** a single multi-tenant instance covers every client organization on that server. You do not need a separate Pro license per client. Self-hosted multi-tenant requires an Enterprise license with the `multi_tenant` capability (see [Requirements](#requirements)).
Route each estate's alerts into the right internal system with per-organization webhooks or the org-scoped alerts API. See the multi-tenant section of [WEBHOOKS.md](WEBHOOKS.md).
**Licensing:** self-hosted multi-tenant requires an Enterprise license with the `multi_tenant` capability (see [Requirements](#requirements)). MSP licensing is separate and is based on a signed provider MSP license that sets the client workspace cap for isolated client runtimes, not shared-process organizations.
## Settings Panels
@ -202,6 +206,6 @@ Activate an Enterprise license with the `multi_tenant` capability in **Settings
## See Also
- [Plans & Entitlements](PULSE_PRO.md) multi-tenant availability by plan
- [Pulse Cloud](CLOUD.md) — hosted multi-tenant environment
- [Security](../SECURITY.md) authentication and authorization model
- [Plans & Entitlements](PULSE_PRO.md), multi-tenant availability by plan
- [Pulse Cloud](CLOUD.md), hosted Pulse environment
- [Security](../SECURITY.md), authentication and authorization model

View file

@ -1,4 +1,4 @@
# Pulse Plans and Entitlements (Community / Relay / Pro / Cloud)
# Pulse Plans and Entitlements (Community / Relay / Pro / Cloud / MSP)
This document explains Pulse's user-facing plan structure, the locked self-hosted commercial model, and how those plans map to runtime feature gates.
@ -13,13 +13,15 @@ User-facing plans map to internal tiers as follows:
- **Community**: `free`
- **Relay**: `relay`
- **Pro**: `pro`, `pro_annual`, `lifetime`
- **Cloud**: `msp` or `enterprise`
- **Cloud**: `cloud` for hosted Pro-level instances, with `enterprise` for internal multi-organization add-ons
- **MSP**: signed provider MSP license using `msp_*` plan versions, with Enterprise/custom terms for higher client counts or white-label report branding
Notes:
- `lifetime` keeps the same runtime feature set as Pro, and lifetime plus grandfathered recurring legacy entitlements are not metered by self-hosted monitoring or child-resource volume under the current v6 policy. Other migrated legacy paid installs can still carry cohort continuity metadata for support and audit, but self-hosted monitoring volume is no longer the paid gate.
- `pro_plus` remains a legacy compatibility tier for existing holders. It is not a current public self-hosted plan because monitored-system volume is no longer the paid boundary.
- Items marked **Cloud*** require the `enterprise` tier rather than the base `msp` tier.
- Items marked **Enterprise*** require an Enterprise/custom entitlement rather than the base hosted or MSP tier.
- If you are self-hosting, you can use capability keys and `GET /api/license/features` to discover exactly what is active in your instance.
- Ordinary self-hosted Pulse stays free-first. MSP and Enterprise paths are explicit commercial paths and should not appear in normal self-hosted monitoring flows.
## Self-Hosted Commercial Model
@ -130,7 +132,7 @@ surface upgrade prompts unless the user deliberately enters a commercial path.
Legend:
- Included: `Y` / `N`
- `Y*`: Cloud Enterprise only (`enterprise` tier)
- `Y*`: Enterprise/custom only (`enterprise` tier or explicit entitlement)
This matrix is derived from the canonical table in `docs/architecture/ENTITLEMENT_MATRIX.md` plus runtime history/limit semantics exposed through entitlements.
@ -149,9 +151,10 @@ This matrix is derived from the canonical table in `docs/architecture/ENTITLEMEN
| `FeatureAuditLogging` | `audit_logging` | Audit Logging | N | N | Y | Y | API route gating for audit query, verify, and export endpoints. |
| `FeatureAdvancedReporting` | `advanced_reporting` | PDF/CSV Reporting | N | N | Y | Y | API route gating via `RequireLicenseFeature(..., advanced_reporting, ...)`. |
| `FeatureLongTermMetrics` | `long_term_metrics` | Extended Metric History | N | Y | Y | Y | Runtime history limits are tier-aware through `max_history_days`: Community `7`, Relay `14`, Pro `90`. |
| `FeatureMultiUser` | `multi_user` | Multi-User Mode | N | N | N | Y* | Cloud Enterprise only. |
| `FeatureMultiUser` | `multi_user` | Multi-User Mode | N | N | N | Y* | Enterprise/custom only. |
| `FeatureMultiTenant` | `multi_tenant` | Multi-Tenant Mode | N | N | N | Y* | Requires both `PULSE_MULTI_TENANT_ENABLED=true` and the `multi_tenant` capability for non-default orgs. |
| `FeatureUnlimited` | `unlimited` | Hosted Capacity Policy | N | N | N | Y | Hosted/enterprise capacity policy only; not a self-hosted core monitoring gate. |
| `FeatureWhiteLabel` | `white_label` | White-Label Report Branding | N | N | N | Y* | Gates custom report branding. Provider defaults and per-client overrides render only when this entitlement is active. |
## Autonomy Levels (AI Safety)
@ -191,7 +194,12 @@ Patrol and the Assistant support tiered autonomy:
### Cloud
- Hosted Pulse with Pro-level capabilities and hosted lifecycle management.
- Cloud Enterprise adds multi-tenant orgs and multi-user mode.
- Cloud Enterprise adds internal multi-organization mode and multi-user mode.
### MSP
- Provider-hosted MSP is request-assisted and license-backed. The MSP runs a Stripe-free provider control plane that creates one isolated Pulse runtime per client workspace.
- Each client runtime keeps its own data, alerts, webhooks, users, audit history, report settings, and branded PDF reports when `white_label` is granted.
- Pulse-hosted MSP is an optional request-assisted path where Pulse operates the provider stack.
## License Activation and Introspection

View file

@ -2,7 +2,7 @@
RBAC lets you define custom roles with granular permissions and assign them to users. This restricts what each user can see and do in Pulse.
**Requires:** Pro, legacy Pro+, or Cloud license with the `rbac` capability.
**Requires:** Pro, legacy Pro+, Cloud, MSP, or Enterprise/custom license with the `rbac` capability.
For plan details, see [PULSE_PRO.md](PULSE_PRO.md). For API endpoints, see [API Reference](API.md#-rbac--role-management-pro).
@ -31,7 +31,7 @@ When using OIDC/SSO, roles can be automatically assigned based on group membersh
## Quick Start
1. Activate a Pro, grandfathered Pro+, or Cloud license in **Settings → Plans**.
1. Activate a Pro, grandfathered Pro+, Cloud, MSP, or Enterprise/custom license in **Settings → Plans**.
2. Go to **Settings → Security → Access Control**.
3. Create roles with the permissions you need.
4. Assign roles to users.
@ -151,9 +151,9 @@ See [OIDC documentation](OIDC.md#group-to-role-mapping-pro) for full configurati
---
## Organization Roles (Multi-Tenant)
## Organization Roles (Enterprise/Internal Multi-Org)
In multi-tenant deployments (Cloud Enterprise), each organization has its own role hierarchy:
In Enterprise/internal multi-organization deployments, each organization has its own role hierarchy:
| Role | Permissions |
|------|------------|
@ -162,7 +162,9 @@ In multi-tenant deployments (Cloud Enterprise), each organization has its own ro
| **Editor** | Read/write access to org resources. Cannot manage members. |
| **Viewer** | Read-only access to all org data. |
These organization roles are separate from the RBAC custom roles described above. Organization roles control access within a specific tenant, while RBAC roles control access to Pulse features globally.
These organization roles are separate from the RBAC custom roles described above. Organization roles control access within a specific internal organization, while RBAC roles control access to Pulse features globally.
Provider-hosted MSP uses a different boundary: each client workspace is its own isolated Pulse runtime. RBAC inside that runtime controls access for that client, and the provider control plane handles account-level staff access and handoff.
See [Multi-Tenant Organizations](MULTI_TENANT.md) for details.

View file

@ -75,24 +75,39 @@ Pro, legacy Pro+, and Cloud support dedicated audit webhooks for security event
### Security
Audit webhooks are dispatched asynchronously. The payload includes a `signature` field which can be verified using the per-instance HMAC key stored (encrypted) at `.audit-signing.key` in the Pulse data directory. There is no `PULSE_AUDIT_SIGNING_KEY` override.
## 🏢 Multi-tenant / MSP and PSA integration
## 🏢 Provider-hosted MSP webhooks
In multi-tenant mode (Pulse Cloud, or self-hosted with `PULSE_MULTI_TENANT_ENABLED=true` and an Enterprise license with the `multi_tenant` capability) alerts and notification destinations are isolated **per organization**. Every alert and webhook request resolves an organization and operates only on that org's own alert state and webhook config, so one client's destinations and templates never leak into another's.
Provider-hosted MSP runs one isolated Pulse runtime per client workspace. That means alert routes and webhook destinations are configured inside the client runtime, not in one shared cross-client alert table. A webhook for Client A only sees Client A alerts because Client A has its own Pulse runtime, data, tokens, and notification config.
Built-in webhook templates include Gotify, PagerDuty, Slack, and Generic. Use the Generic webhook for systems that accept custom inbound payloads, including ConnectWise and similar PSA or ITSM tools. This is webhook routing, not a bespoke PSA integration.
Typical MSP setup:
1. Open the client workspace from Pulse Account.
2. Add that client's notification destinations in **Alerts → Notification Destinations**.
3. Use Gotify, PagerDuty, Slack, or Generic depending on where the client or provider team wants alerts to land.
4. Keep each destination scoped to the client runtime so alert payloads and resolved events never cross into another client's workflow.
## 🏢 Multi-tenant organization integrations
In shared-process multi-tenant mode (self-hosted with `PULSE_MULTI_TENANT_ENABLED=true` and an Enterprise license with the `multi_tenant` capability) alerts and notification destinations are isolated **per organization**. Every alert and webhook request resolves an organization and operates only on that org's own alert state and webhook config.
Use this for one owner separating internal sites, departments, teams, or environments. It is not the canonical Pulse MSP model for separate customer businesses; MSP uses isolated client workspaces with their own runtime boundaries.
The organization for a request is resolved in this order:
1. `X-Pulse-Org-ID: <orgID>` header (the way API clients and PSA middleware should target a specific tenant).
1. `X-Pulse-Org-ID: <orgID>` header (the way API clients or internal middleware should target a specific organization).
2. `pulse_org_id` session cookie (browser sessions).
3. An org-bound API token (a token scoped to a single org needs no header).
4. Fallback: the `default` org.
Suspended or pending-deletion organizations return `403`, and an unknown org ID returns `400`.
### Wiring tenant alerts into ConnectWise / a PSA
### Wiring organization alerts into external systems
There are two integration models. Most MSPs use the push model so tickets open and close automatically.
There are two integration models. The push model is usually the right fit when tickets or incidents should open and close automatically.
**Push (recommended): one outbound webhook per tenant org.** Create a **Generic** webhook for each client org and point it at your PSA's inbound endpoint (ConnectWise inbound webhook, an email connector, or a middleware that opens service tickets). Shape the JSON with a [custom template](#-custom-templates) so it matches your PSA's expected schema — every template variable listed above is available. Pulse fires on both `alert` and `resolved` events (`{{.Event}}` is `"alert"` or `"resolved"`), so the PSA can open a ticket on alert and auto-resolve it on recovery. Add PSA authentication as a custom header (e.g. `Authorization: Bearer ...`).
**Push (recommended): one outbound webhook per organization.** Create a **Generic** webhook for each organization and point it at your external system's inbound endpoint (an ITSM/PSA inbound webhook, an email connector, or middleware that opens service tickets). Shape the JSON with a [custom template](#-custom-templates) so it matches the receiving system's expected schema: every template variable listed above is available. Pulse fires on both `alert` and `resolved` events (`{{.Event}}` is `"alert"` or `"resolved"`), so the receiving system can open a ticket on alert and auto-resolve it on recovery. Add authentication as a custom header (e.g. `Authorization: Bearer ...`).
Configure it from the UI (**Alerts → Notification Destinations → Add Webhook**) per org, or programmatically with an org-bound admin token:
@ -113,9 +128,9 @@ Content-Type: application/json
}
```
The exact ticket fields differ by PSA (ConnectWise, Autotask, Halo, and others each expect their own inbound shape), so map the template to your platform's contract. The `alertId` round-trips through `{{.ID}}`, which lets the PSA correlate the later `resolved` event to the ticket it opened.
The exact ticket fields differ by platform (ConnectWise, Autotask, Halo, and others each expect their own inbound shape), so map the template to your platform's contract. The `alertId` round-trips through `{{.ID}}`, which lets the receiving system correlate the later `resolved` event to the ticket it opened.
**Pull (poll): org-scoped read API.** Issue a `monitoring:read` token bound to each client org and poll that org's alerts. Send `X-Pulse-Org-ID` (or rely on the org-bound token) so you get only that tenant's data:
**Pull (poll): org-scoped read API.** Issue a `monitoring:read` token bound to each organization and poll that org's alerts. Send `X-Pulse-Org-ID` (or rely on the org-bound token) so you get only that organization's data:
- `GET /api/alerts/active` — currently firing alerts for the org.
- `GET /api/alerts/history` — historical alerts for the org.
@ -131,4 +146,4 @@ To acknowledge or clear from the PSA side, use a `monitoring:write` token: `POST
| Read active / historical alerts | `GET /api/alerts/active`, `GET /api/alerts/history` | `monitoring:read` |
| Acknowledge / clear alerts | `POST /api/alerts/acknowledge`, `POST /api/alerts/clear` | `monitoring:write` |
Target a tenant with the `X-Pulse-Org-ID: <orgID>` header or an org-bound API token. See [API.md](API.md) for the full endpoint and token reference.
Target an organization with the `X-Pulse-Org-ID: <orgID>` header or an org-bound API token. See [API.md](API.md) for the full endpoint and token reference.

View file

@ -224,51 +224,55 @@ Cloud launches alongside v6 (not behind a waitlist).
---
## MSP Tiers (Multi-Tenant — separate page)
## MSP Tiers (Provider-hosted, request-assisted)
All MSP tiers include everything in Pro + multi-tenant management UI + port separation
(agent vs web UI) + webhook templates. Annual pricing is 2 months free (~17% savings).
Pulse MSP is not the shared-process organization model. The default MSP route is provider-hosted: the MSP runs a Stripe-free control plane that creates one isolated Pulse runtime/container per client workspace. A signed MSP license sets the plan version and client workspace cap. Pulse-hosted MSP is an optional request-assisted path where Pulse operates that provider stack.
MSP is built and staged for assisted rollout, but it is not a public self-serve checkout path yet. Pricing, availability, and launch wording need owner review before publication.
### MSP Starter — $149/month or $1,490/year
| Element | Value |
|---|---|
| Clients | Up to **10** |
| Host pool | **50** |
| Multi-tenant UI | Yes |
| Port separation | Yes |
| Webhook templates | Yes |
| White-label | Future |
| Client workspaces | Up to **5** |
| Provider control plane | Yes, provider-hosted and Stripe-free |
| Client runtime isolation | One Pulse runtime/container per client |
| Portal handoff | Yes |
| Webhook templates | Gotify, PagerDuty, Slack, and Generic |
| White-label report branding | Requires `white_label` entitlement |
| Access motion | Request-assisted |
### MSP Growth — $249/month or $2,490/year
| Element | Value |
|---|---|
| Clients | Up to **25** |
| Host pool | **150** |
| Multi-tenant UI | Yes |
| Port separation | Yes |
| Webhook templates | Yes |
| White-label | Future |
| Client workspaces | Up to **15** |
| Provider control plane | Yes, provider-hosted and Stripe-free |
| Client runtime isolation | One Pulse runtime/container per client |
| Portal handoff | Yes |
| Webhook templates | Gotify, PagerDuty, Slack, and Generic |
| White-label report branding | Requires `white_label` entitlement |
| Access motion | Request-assisted |
### MSP Scale — $399/month or $3,990/year
| Element | Value |
|---|---|
| Clients | Up to **50** |
| Host pool | **400** |
| Multi-tenant UI | Yes |
| Port separation | Yes |
| Webhook templates | Yes |
| White-label | Future |
| Client workspaces | Up to **40** |
| Provider control plane | Yes, provider-hosted and Stripe-free |
| Client runtime isolation | One Pulse runtime/container per client |
| Portal handoff | Yes |
| Webhook templates | Gotify, PagerDuty, Slack, and Generic |
| White-label report branding | Requires `white_label` entitlement |
| Access motion | Request-assisted |
### MSP Enterprise — Custom
| Element | Value |
|---|---|
| Clients | **50+** |
| Host pool | Custom |
| White-label | **Yes** |
| Client workspaces | Custom |
| Hosting shape | Provider-hosted by default, Pulse-hosted optional by agreement |
| White-label report branding | **Yes**, when granted through `white_label` |
| Pricing | Negotiated |
---
@ -398,7 +402,7 @@ Below the table:
```
Starter Growth Scale Enterprise
$149/mo $249/mo $399/mo Custom
10 clients 25 clients 50 clients 50+
5 clients 15 clients 40 clients Custom
```
---
@ -457,6 +461,8 @@ explain monitored-system identity:
- Cloud Max Annual: `price_1T5kg5BrHBocJIGH5AIJ4nVc` ($699/yr)
### MSP (created 2026-02-28)
> Owner review required before publication: these price IDs exist, but MSP is request-assisted and must not be presented as live self-serve checkout until launch is approved.
- MSP Starter Monthly: `price_1T5kgTBrHBocJIGHjOs15LI2` ($149/mo)
- MSP Starter Annual: `price_1T5kgUBrHBocJIGHT6PiOn6x` ($1,490/yr)
- MSP Growth Monthly: `price_1T5kgVBrHBocJIGHulNsCTb1` ($249/mo)
@ -496,6 +502,7 @@ explain monitored-system identity:
### Cloud / MSP
- [ ] Keep current Cloud/MSP list pricing unchanged for now unless a separate decision explicitly revises it
- [ ] Keep MSP request-assisted until owner approval makes the public launch motion explicit
- [ ] Continue differentiating Cloud / MSP limits via plan-specific license claims rather than self-hosted static bands
---
@ -522,6 +529,7 @@ explain monitored-system identity:
| Date | Change | Author |
|---|---|---|
| 2026-06-02 | Reconciled MSP pricing evidence with the provider-hosted model: signed MSP license, Stripe-free provider control plane, isolated Pulse runtime per client, 5/15/40 client workspace caps, and request-assisted access until launch approval. | Richard |
| 2026-04-29 | Replaced stale capacity-style monitoring phrasing with core-monitoring-included language across active v6 docs and upgrade-return copy so Community does not read like a former capacity upsell. | Codex |
| 2026-04-23 | Removed stale self-hosted monitored-system capacity and Pro+ public-checkout language. Reaffirmed Community / Relay / Pro as current public self-hosted tiers, with Pro+ as continuity only and Pro value centered on operations, history, and admin controls. | Codex |
| 2026-03-17 | Re-locked the self-hosted commercial model around monitored systems rather than installed agents. New self-hosted public pricing: Relay $4.99/$39, Pro $8.99/$79, Pro+ $14.99/$129. Added free-tier grace policy and marked the monitored-system counting migration as still required in code. | Codex + Richard |

View file

@ -172,9 +172,9 @@ func defaultSetupTemplatesForAccount(account portalPageAccount) []BootstrapSetup
{
ID: "standard-client-onboarding",
Title: "Standard client onboarding",
AgentNaming: "Keep the client workspace as the identity boundary; repeated hostnames are expected across clients.",
AlertRouting: "Create at least one enabled alert route inside each client workspace.",
Reporting: "Schedule at least one client performance report before the workspace is marked ready.",
AgentNaming: "Each client workspace opens an isolated Pulse runtime; repeated hostnames are expected across clients.",
AlertRouting: "Create at least one enabled alert route inside each client runtime.",
Reporting: "Schedule at least one client performance report, with client branding if white_label is granted, before the workspace is marked ready.",
Access: "Invite provider staff from Access and keep client users on the smallest useful role.",
},
}

View file

@ -15,7 +15,7 @@ import (
const canonicalPublicMSPSignupPath = "/cloud/msp/signup"
// mspTier represents a hosted MSP plan tier for public signup. The three tiers
// mspTier represents an MSP plan tier for public signup. The three tiers
// mirror the canonical MSP plan-version ladder in pkg/licensing (msp_starter /
// msp_growth / msp_scale), each capped at a different number of client
// workspaces.
@ -113,7 +113,7 @@ var publicMSPSignupPageTemplate = template.Must(template.New("public-msp-signup-
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Start Pulse MSP Starter</title>
<title>Request Pulse MSP Access</title>
<style nonce="{{.Nonce}}">
:root { color-scheme: light; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: linear-gradient(140deg, #f8fafc, #e2e8f0); color: #0f172a; }
@ -138,18 +138,18 @@ var publicMSPSignupPageTemplate = template.Must(template.New("public-msp-signup-
<body>
<div class="wrap">
<div class="card">
<h1>Start Pulse MSP Starter</h1>
<p>Run Pulse for multiple clients from one hosted operator account. Each client gets an isolated workspace; you manage them all from Pulse Account. Starter is the self-serve checkout path for small providers. Growth and Scale have published prices, but access is request-assisted so onboarding, reporting, and support expectations can be sized with you.</p>
<h1>Request Pulse MSP Access</h1>
<p>Run Pulse for multiple clients from one provider account. The provider-hosted model runs a Stripe-free control plane and one isolated Pulse runtime per client workspace. MSP is an explicit provider path, so ordinary self-hosted Pulse stays out of it unless you choose this model. Starter, Growth, and Scale have published prices, but access is request-assisted while rollout, reporting, hosting, and support expectations are sized with you.</p>
{{if .ErrorMessage}}<div class="error">{{.ErrorMessage}}</div>{{end}}
{{if .Cancelled}}<div class="note">Checkout was cancelled. You can start again below.</div>{{end}}
{{if .Cancelled}}<div class="note">The previous request was cancelled. You can start again below.</div>{{end}}
{{if .Available}}
<form method="POST" action="{{.FormAction}}">
<input type="hidden" name="tier" value="{{.Tier}}">
<div class="tier-group">
<div class="tier-option"><strong>Starter</strong> &mdash; up to 5 client workspaces, $149/mo</div>
<div class="tier-option"><strong>Growth</strong> &mdash; up to 15 client workspaces, $249/mo, request-assisted access</div>
<div class="tier-option"><strong>Scale</strong> &mdash; up to 40 client workspaces, $399/mo, request-assisted access</div>
<div class="tier-option"><strong>Starter</strong>, up to 5 client workspaces, $149/mo, request-assisted access</div>
<div class="tier-option"><strong>Growth</strong>, up to 15 client workspaces, $249/mo, request-assisted access</div>
<div class="tier-option"><strong>Scale</strong>, up to 40 client workspaces, $399/mo, request-assisted access</div>
</div>
<label for="email">Work Email</label>
@ -158,17 +158,17 @@ var publicMSPSignupPageTemplate = template.Must(template.New("public-msp-signup-
<label for="org_name">Company Name</label>
<input id="org_name" name="org_name" type="text" value="{{.OrgName}}" autocomplete="organization" required>
<button class="cta" type="submit">Continue To Secure Checkout</button>
<button class="cta" type="submit">Request Assisted Access</button>
</form>
<p class="fine">After checkout, we will email a Pulse Account sign-in link so you can create client workspaces and continue setup. For Growth or Scale, email support@pulserelay.pro and include the tier you want and your expected client workspace count.</p>
<p class="fine">MSP is not open for public self-serve purchase yet. I will confirm the right tier, license-backed provider activation, and whether provider-hosted or Pulse-hosted operation fits before client data starts flowing. For Growth or Scale, email support@pulserelay.pro and include the tier you want and your expected client workspace count.</p>
<ol>
<li>Stripe securely starts your MSP Starter subscription.</li>
<li>Pulse provisions your MSP operator account after checkout completes.</li>
<li>The email link opens Pulse Account, where you add client workspaces and continue setup.</li>
<li>Confirm the expected client workspace count and hosting model.</li>
<li>Issue or verify the signed MSP license that sets the provider plan and client cap.</li>
<li>Set up the provider account so you can add client workspaces and hand off into each isolated Pulse runtime.</li>
</ol>
{{else}}
<div class="note">Pulse MSP Starter self-serve signup is not open yet. Email support@pulserelay.pro and we will get you set up.</div>
<div class="note">Pulse MSP is not open for public self-serve purchase yet. Email support@pulserelay.pro and I will get you set up.</div>
{{end}}
</div>
</div>
@ -181,7 +181,7 @@ var publicMSPSignupCompleteTemplate = template.Must(template.New("public-msp-sig
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pulse MSP Starter Checkout Complete</title>
<title>Pulse MSP Access Request Complete</title>
<style nonce="{{.Nonce}}">
:root { color-scheme: light; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f8fafc; color: #0f172a; }
@ -194,9 +194,9 @@ var publicMSPSignupCompleteTemplate = template.Must(template.New("public-msp-sig
<body>
<div class="wrap">
<div class="card">
<h1>Checkout complete</h1>
<p>Your Pulse MSP Starter checkout completed. Pulse is provisioning your MSP operator account.</p>
<p>Watch your inbox for a Pulse Account sign-in link. That link lands in Pulse Account, where you can add client workspaces and continue setup.</p>
<h1>Request received</h1>
<p>Your Pulse MSP access request has been received.</p>
<p>Watch your inbox for the next setup step. MSP access is request-assisted, and setup confirms the signed provider license, client workspace cap, and hosting boundary before rollout.</p>
</div>
</div>
</body>
@ -211,11 +211,11 @@ type publicMSPSignupPageData struct {
ErrorMessage string
Cancelled bool
Nonce string
Available bool // true if MSP Starter self-serve checkout is configured
Available bool // true if the gated MSP request path is configured
}
// newMSPSignupPageData seeds page data from the current public MSP buying
// motion: Starter is self-serve; larger plans are assisted.
// motion: all MSP plans are request-assisted while public launch is gated.
func (h *PublicCloudSignupHandlers) newMSPSignupPageData() publicMSPSignupPageData {
_, starterConfigured := h.selfServeMSPPriceIDForTier(mspTierStarter)
data := publicMSPSignupPageData{
@ -274,18 +274,18 @@ func (h *PublicCloudSignupHandlers) HandleMSPSignupPage(w http.ResponseWriter, r
return
}
if !isSelfServeMSPTier(tier) {
renderErr(http.StatusBadRequest, "MSP Growth and Scale are request-based. Email support@pulserelay.pro with your client workspace count.")
renderErr(http.StatusBadRequest, "MSP Growth and Scale are request-assisted. Email support@pulserelay.pro with your client workspace count.")
return
}
if _, avail := h.selfServeMSPPriceIDForTier(tier); !avail {
renderErr(http.StatusBadRequest, "MSP Starter self-serve checkout is not currently available.")
renderErr(http.StatusBadRequest, "MSP Starter access is not currently available.")
return
}
checkoutURL, err := h.createMSPCheckout(email, orgName, tier)
if err != nil {
log.Warn().Err(err).Str("email", email).Str("tier", string(tier)).Msg("public msp signup checkout creation failed")
renderErr(http.StatusBadGateway, "Unable to create checkout session. Please try again.")
renderErr(http.StatusBadGateway, "Unable to start MSP access request. Please try again.")
return
}
http.Redirect(w, r, checkoutURL, http.StatusSeeOther)
@ -311,7 +311,7 @@ func (h *PublicCloudSignupHandlers) HandleMSPPublicSignup(w http.ResponseWriter,
h.servePublicSignupCheckout(w, r,
"Invalid plan tier. Must be one of: starter, growth, scale",
"public msp signup API checkout creation failed",
"Checkout session created. Continue in Stripe to start your Pulse MSP Starter subscription and provision your operator account.",
"MSP access request created. Continue with the assisted setup instructions.",
func(tierRaw string) (bool, bool, func(email, orgName string) (string, error)) {
tier, ok := parseMSPTier(tierRaw)
if !ok {
@ -331,7 +331,7 @@ func (h *PublicCloudSignupHandlers) createMSPCheckout(email, orgName string, tie
}
priceID, ok := h.selfServeMSPPriceIDForTier(tier)
if !ok || priceID == "" {
return "", fmt.Errorf("self-serve checkout is not configured for msp tier %q", tier)
return "", fmt.Errorf("checkout is not configured for msp tier %q", tier)
}
if err := validatePublicMSPSignupPriceID(tier, priceID); err != nil {
return "", err

View file

@ -86,7 +86,7 @@ func TestMSPSignupPageRendersUnavailableWhenNoTierConfigured(t *testing.T) {
if strings.Contains(body, "<form") {
t.Fatal("expected no signup form when no MSP tier is configured")
}
if !strings.Contains(body, "Starter self-serve signup is not open yet") {
if !strings.Contains(body, "not open for public self-serve purchase yet") {
t.Fatalf("expected unavailable notice, got %q", body)
}
}
@ -107,8 +107,8 @@ func TestMSPSignupPageRendersFormWhenTierConfigured(t *testing.T) {
if !strings.Contains(body, "<form") {
t.Fatal("expected signup form when an MSP tier is configured")
}
if !strings.Contains(body, "Start Pulse MSP Starter") {
t.Fatal("expected MSP paid signup heading")
if !strings.Contains(body, "Request Pulse MSP Access") {
t.Fatal("expected MSP access request heading")
}
if strings.Contains(strings.ToLower(body), "trial") {
t.Fatal("MSP signup page should not advertise a trial")
@ -138,7 +138,7 @@ func TestMSPSignupPageKeepsStarterSelfServeWhenMultipleTiersConfigured(t *testin
body := rec.Body.String()
if strings.Contains(body, `type="radio"`) {
t.Fatal("expected no tier radios because only MSP Starter is self-serve")
t.Fatal("expected no tier radios because only MSP Starter request path is exposed")
}
if !strings.Contains(body, `type="hidden" name="tier" value="starter"`) {
t.Fatal("expected hidden starter tier input")
@ -263,7 +263,7 @@ func TestMSPSignupPostAssistedTierReturns400EvenWhenConfigured(t *testing.T) {
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "request-based") {
if !strings.Contains(rec.Body.String(), "request-assisted") {
t.Fatalf("expected assisted-tier message, got %q", rec.Body.String())
}
}
@ -283,7 +283,7 @@ func TestMSPSignupPostUnconfiguredStarterReturns400(t *testing.T) {
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Starter self-serve checkout is not currently available") {
if !strings.Contains(rec.Body.String(), "Starter access is not currently available") {
t.Fatalf("expected tier-unavailable message, got %q", rec.Body.String())
}
}
@ -401,14 +401,14 @@ func TestMSPSignupCompleteRendersHandoff(t *testing.T) {
t.Fatalf("status=%d, want %d", rec.Code, http.StatusOK)
}
body := rec.Body.String()
if !strings.Contains(body, "Checkout complete") {
if !strings.Contains(body, "Request received") {
t.Fatal("expected completion heading")
}
if strings.Contains(strings.ToLower(body), "trial") {
t.Fatal("MSP checkout completion should not advertise a trial")
t.Fatal("MSP access completion should not advertise a trial")
}
if !strings.Contains(body, "Pulse Account") || !strings.Contains(body, "add client workspaces") {
t.Fatal("expected Pulse Account handoff copy")
if !strings.Contains(body, "signed provider license") || !strings.Contains(body, "client workspace cap") {
t.Fatal("expected assisted access setup copy")
}
}

View file

@ -315,7 +315,7 @@ func TestRegisterRoutes_PublicMSPSignupRoutes(t *testing.T) {
if mspPageRec.Code != http.StatusOK {
t.Fatalf("GET /cloud/msp/signup status=%d, want %d", mspPageRec.Code, http.StatusOK)
}
if !strings.Contains(mspPageRec.Body.String(), "Start Pulse MSP Starter") {
if !strings.Contains(mspPageRec.Body.String(), "Request Pulse MSP Access") {
t.Fatalf("expected public MSP signup page body")
}