Keep the provider portal up when the MSP licence lapses

A provider-hosted control plane refused to start once its licence was past
expiry and the 7-day grace, because startup validated it with
ValidateLicense. So at the next restart after a 60-day evaluation ran out,
or after a paying provider's subscription lapsed, the portal went down, and
the portal's Plan tab is the only place a provider can buy or renew.
Reproduced on the walkthrough lab.

Startup now accepts an authentic, key-bound licence even when lapsed
(ValidateLicenseAllowingLapse), preferring a current licence and otherwise
the one that expired later, and logs the lapse. It unlocks nothing: client
runtimes still verify the provider licence in every lease with
ValidateLicense and drop MSP capabilities, new clients are refused with
provider_msp_license_lapsed, and the refresher still refuses a lapsed
licence. The Plan panel says the evaluation or plan ended and offers the
plans, and provider-msp status reports license_lapsed without failing so a
lapsed install can still upgrade.

The same run showed the portal printing control plane error codes such as
provider_msp_license_lapsed instead of their message. The portal now
prefers an error's message over its code, which also fixes
already_subscribed and checkout_unavailable in the Plan tab.
This commit is contained in:
rcourtman 2026-09-24 10:19:40 +01:00
parent 8c6ee59325
commit 951424d4ff
21 changed files with 455 additions and 44 deletions

View file

@ -436,8 +436,9 @@ func (rt *providerMSPProofRuntime) createProviderMSPProofWorkspace(ctx context.C
rt.registry,
rt.provisioner,
account.WorkspaceLimitPolicy{
ProviderHostedMSP: true,
ProviderMSPPlanVersion: providerMSPProofPlanVersion(rt.cfg),
ProviderHostedMSP: true,
ProviderMSPPlanVersion: providerMSPProofPlanVersion(rt.cfg),
ProviderMSPLicenseLapsed: func() bool { return rt.cfg.ProviderMSPLicenseLapsed(time.Now()) },
},
)
mux := http.NewServeMux()

View file

@ -23,15 +23,18 @@ type providerMSPStatusOptions struct {
}
type providerMSPStatusReport struct {
OK bool
Environment string
ControlMode string
BaseURL string
PlanVersion string
PlanSource string
LicenseID string
LicenseEmail string
WorkspaceLimit int
OK bool
Environment string
ControlMode string
BaseURL string
PlanVersion string
PlanSource string
LicenseID string
LicenseEmail string
WorkspaceLimit int
// LicenseLapsed is informational, not a failure: a lapsed install must
// still upgrade and keep its portal up so the provider can renew there.
LicenseLapsed bool
RegistryReady bool
TotalTenants int
HealthyTenants int
@ -117,6 +120,7 @@ func runProviderMSPStatusWithDependencies(ctx context.Context, cfg *cloudcp.CPCo
LicenseID: strings.TrimSpace(cfg.ProviderMSPLicenseID),
LicenseEmail: strings.ToLower(strings.TrimSpace(cfg.ProviderMSPLicenseEmail)),
WorkspaceLimit: workspaceLimit,
LicenseLapsed: cfg.ProviderMSPLicenseLapsed(deps.Now()),
CountsByState: map[registry.TenantState]int{},
}
addFailure := func(format string, args ...any) {
@ -399,6 +403,7 @@ func printProviderMSPStatusReport(report *providerMSPStatusReport) {
fmt.Printf("license_id=%s\n", report.LicenseID)
fmt.Printf("license_email=%s\n", report.LicenseEmail)
fmt.Printf("workspace_limit=%d\n", report.WorkspaceLimit)
fmt.Printf("license_lapsed=%t\n", report.LicenseLapsed)
fmt.Printf("registry_ready=%t\n", report.RegistryReady)
fmt.Printf("total_tenants=%d\n", report.TotalTenants)
fmt.Printf("healthy_tenants=%d\n", report.HealthyTenants)

View file

@ -73,6 +73,30 @@ func TestProviderMSPStatusReportsHealthyOperatorSurface(t *testing.T) {
}
}
// A lapsed licence is reported, not failed: upgrade.sh gates on status, and a
// lapsed install must still upgrade and keep its portal up to renew.
func TestProviderMSPStatusReportsALapsedLicenceWithoutFailing(t *testing.T) {
cfg := testProviderMSPPreflightConfig(t, cloudcp.ProviderMSPPlanSourceLicenseFile)
now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC)
cfg.ProviderMSPLicenseExpiresAt = now.Add(-30 * 24 * time.Hour)
report, err := runProviderMSPStatusWithDependencies(context.Background(), cfg, providerMSPStatusOptions{}, providerMSPStatusDependencies{
RunPreflight: func(context.Context, *cloudcp.CPConfig, providerMSPPreflightOptions) (*providerMSPPreflightReport, error) {
return healthyProviderMSPStatusPreflightReport(), nil
},
NewDocker: healthyProviderMSPStatusDocker(map[string]bool{}),
CheckBackup: func(context.Context, *cloudcp.CPConfig) (*providerMSPBackupStatus, error) {
return healthyProviderMSPBackupStatus(now), nil
},
Now: func() time.Time { return now },
})
if err != nil {
t.Fatalf("runProviderMSPStatusWithDependencies: %v", err)
}
if !report.OK || !report.LicenseLapsed {
t.Fatalf("report OK=%v LicenseLapsed=%v failures=%v, want OK and lapsed", report.OK, report.LicenseLapsed, report.Failures)
}
}
func TestProviderMSPStatusFailsOnFailedUnhealthyAndStuckWorkspaces(t *testing.T) {
cfg := testProviderMSPPreflightConfig(t, cloudcp.ProviderMSPPlanSourceLicenseFile)
now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC)

View file

@ -3455,6 +3455,40 @@ Regression coverage:
`TestEnsureTenantNetworkRejectsUnownedExistingNetwork` in
`internal/cloudcp/docker/manager_test.go`.
### A provider control plane keeps its portal up on a lapsed licence
A provider-hosted control plane used to refuse to start once its licence was
past expiry and the 7-day grace: `ValidateLicense` rejected it, so `LoadConfig`
failed and the portal went down at the next restart. That is exactly when the
provider needs the portal, because Plan is the only place to buy or renew;
the same happened to a paying provider whose subscription lapsed. Startup now
resolves an authentic, key-bound licence with
`pkglicensing.ValidateLicenseAllowingLapse`, preferring a current licence
(renewed first) and otherwise the lapsed one that expired later, and logs the
lapse. Nothing is unlocked by this: client runtimes still verify the provider
licence carried in each lease with `ValidateLicense` and drop MSP capabilities
once it lapses, workspace creation refuses with `provider_msp_license_lapsed`,
and the refresher still refuses to adopt a lapsed licence from the licence
server. The Plan panel reads `lapsed` from the plan state, says the evaluation
or plan ended and what that means for clients, and offers the plans, including
the same plan again after a paid plan ends; `provider-msp status` prints
`license_lapsed` without failing, so a lapsed install can still upgrade. The
portal also shows a control plane error's `message` in preference to its
machine `error` code, which had been surfacing codes such as
`provider_msp_license_lapsed` and `already_subscribed` as the whole message.
Verified on 2026-09-24 against the walkthrough lab: the control plane started
on an evaluation that lapsed 20 days earlier, the Plan panel offered Solo and
Starter, adding a client showed the lapse sentence, and with a live
subscription the refresher restored the paid licence on its own. Regression
coverage: `TestValidateLicenseAllowingLapseReturnsLapsedButStillAuthenticLicence`
in `pkg/licensing/service_lapsed_test.go`,
`TestLoadConfig_ProviderHostedMSPStartsOnALapsedLicence` in
`internal/cloudcp/config_test.go`,
`TestCreateWorkspace_ProviderHostedMSPRefusesNewClientsOnALapsedLicence` in
`internal/cloudcp/account/tenant_handlers_lapsed_test.go`, and
`TestProviderMSPLicenseRefreshRefusesALapsedLicence` in
`internal/cloudcp/provider_msp_license_refresh_test.go`.
### Provider-hosted MSP platforms buy and renew their own licence
A provider-hosted control plane now buys and renews its licence through the

View file

@ -2483,6 +2483,20 @@ unchanged. Regression coverage:
`TestProviderMSPInstallProofCleansUpWorkspacesFromAPartialProof` in
`cmd/pulse-control-plane/provider_msp_install_proof_test.go`.
### A lapsed provider MSP install still starts and upgrades
A provider-hosted install whose licence is past expiry and grace (an
evaluation that ran out, or a paid plan that was not renewed) now starts its
control plane instead of crash-looping on licence validation, so the portal
and its Plan tab stay reachable to buy or renew. `provider-msp status`, which
`upgrade.sh` gates on, prints `license_lapsed=true` as information rather than
a failure, so such an install can still be upgraded. Client runtimes keep
enforcing the lapse themselves and new clients are refused until a current
licence is in place. Verified on 2026-09-24 on the walkthrough lab with an
evaluation that lapsed 20 days earlier. Regression coverage:
`TestProviderMSPStatusReportsALapsedLicenceWithoutFailing` in
`cmd/pulse-control-plane/provider_msp_status_test.go`.
### Provider MSP setup points buyers at the portal
`deploy/provider-msp/setup.sh` no longer tells an evaluating provider to

View file

@ -36,6 +36,10 @@ type ownerAwareWorkspaceProvisioner interface {
type WorkspaceLimitPolicy struct {
ProviderHostedMSP bool
ProviderMSPPlanVersion string
// ProviderMSPLicenseLapsed reports whether the provider's licence is past
// its expiry and grace period. The portal keeps running then, so the
// provider can buy or renew, but no new client workspace may be created.
ProviderMSPLicenseLapsed func() bool
}
// HandleListTenants lists all tenants for an account.
@ -271,6 +275,13 @@ func enforceWorkspaceLimit(reg *registry.TenantRegistry, account *registry.Accou
usingProviderHostedPlan := false
if sa == nil {
if policy.ProviderHostedMSP && account != nil && account.Kind == registry.AccountKindMSP {
if policy.ProviderMSPLicenseLapsed != nil && policy.ProviderMSPLicenseLapsed() {
return &workspaceLimitError{
reason: "provider_msp_license_lapsed",
message: "Your Pulse MSP plan has ended, so no new clients can be added. Buy a plan from Plan in the portal to add clients again.",
statusCode: http.StatusForbidden,
}
}
planVersion = pkglicensing.CanonicalizePlanVersion(policy.ProviderMSPPlanVersion)
usingProviderHostedPlan = true
if strings.TrimSpace(planVersion) == "" {

View file

@ -698,3 +698,42 @@ func TestCreateWorkspace_BlockedWhenSubscriptionCanceled(t *testing.T) {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusForbidden, rec.Body.String())
}
}
// A provider control plane keeps running on a lapsed licence so its portal can
// sell the renewal, but it must not add clients until the provider buys.
func TestCreateWorkspace_ProviderHostedMSPRefusesNewClientsOnALapsedLicence(t *testing.T) {
reg := newTestRegistry(t)
lapsed := true
mux, _ := newTestTenantMuxWithWorkspaceLimitPolicy(
t,
reg,
t.TempDir(),
WorkspaceLimitPolicy{ProviderHostedMSP: true, ProviderMSPPlanVersion: "msp_eval", ProviderMSPLicenseLapsed: func() bool { return lapsed }},
cpstripe.WithDefaultMSPPlanVersion("msp_eval"),
)
accountID, err := registry.GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&registry.Account{ID: accountID, Kind: registry.AccountKindMSP, DisplayName: "Provider MSP"}); err != nil {
t.Fatal(err)
}
create := func() *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/accounts/"+accountID+"/tenants", bytes.NewBufferString(`{"display_name":"Acme Dental"}`))
return doRequest(t, mux, req)
}
rec := create()
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Buy a plan from Plan") {
t.Fatalf("create on a lapsed licence = %d %q, want 403 pointing to Plan", rec.Code, rec.Body.String())
}
if count, _ := reg.CountActiveByAccountID(accountID); count != 0 {
t.Fatalf("lapsed licence created %d workspaces", count)
}
// Once the provider buys and the licence is current again, clients can be added.
lapsed = false
if rec := create(); rec.Code != http.StatusCreated {
t.Fatalf("create after renewal = %d %q, want 201", rec.Code, rec.Body.String())
}
}

View file

@ -234,6 +234,13 @@ func LoadConfig() (*CPConfig, error) {
providerMSPLicenseEmail = resolved.LicenseEmail
providerMSPLicenseKey = resolved.LicenseKey
providerMSPLeaseSigningPublicKey = resolved.LeaseSigningPublicKey
if resolved.lapsed(time.Now()) {
log.Warn().
Str("license_id", resolved.LicenseID).
Str("plan_version", resolved.PlanVersion).
Time("expired_at", resolved.ExpiresAt).
Msg("Provider MSP license has lapsed: the portal stays up to sell the renewal, no new clients can be added, and client workspaces drop MSP capabilities")
}
}
cfg := &CPConfig{
@ -626,7 +633,14 @@ type providerMSPLicenseResolution struct {
ExpiresAt time.Time
}
// resolveProviderMSPPlanFromLicenseFile validates a licence the control plane
// is about to adopt, such as one the licence server just returned. It refuses
// a lapsed licence.
func resolveProviderMSPPlanFromLicenseFile(path string) (*providerMSPLicenseResolution, error) {
return resolveProviderMSPLicenseFile(path, false)
}
func resolveProviderMSPLicenseFile(path string, allowLapsed bool) (*providerMSPLicenseResolution, error) {
path = strings.TrimSpace(path)
if path == "" {
return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE is required")
@ -636,7 +650,11 @@ func resolveProviderMSPPlanFromLicenseFile(path string) (*providerMSPLicenseReso
return nil, err
}
pkglicensing.InitEmbeddedPublicKey()
license, err := pkglicensing.ValidateLicense(licenseKey)
validate := pkglicensing.ValidateLicense
if allowLapsed {
validate = pkglicensing.ValidateLicenseAllowingLapse
}
license, err := validate(licenseKey)
if err != nil {
return nil, fmt.Errorf("validate CP_PROVIDER_MSP_LICENSE_FILE: %w", err)
}
@ -689,26 +707,62 @@ func ProviderMSPRenewedLicensePath(dataDir string) string {
return filepath.Join(dataDir, "control-plane", "provider-msp-license.jwt")
}
// resolveProviderMSPLicense prefers a renewed licence that still validates
// and falls back to CP_PROVIDER_MSP_LICENSE_FILE. The renewed licence is
// tried first because the host file is usually the self-issued evaluation,
// which expires; a paying provider must keep starting after it does. The
// lease signing key check in validate still ties either licence to this
// control plane's private key.
// lapsed reports whether the licence is past its expiry and grace period, the
// point at which client runtimes stop accepting leases chained to it.
func (r *providerMSPLicenseResolution) lapsed(now time.Time) bool {
return providerMSPLicenseLapsed(r.ExpiresAt, now)
}
func providerMSPLicenseLapsed(expiresAt, now time.Time) bool {
return !expiresAt.IsZero() && now.After(expiresAt.Add(pkglicensing.DefaultGracePeriod))
}
// ProviderMSPLicenseLapsed reports whether this platform's licence is past its
// expiry and grace period. The control plane keeps running so its portal can
// sell the renewal, but it adds no clients, and client runtimes drop MSP
// capabilities on their own when they verify the lapsed licence.
func (c *CPConfig) ProviderMSPLicenseLapsed(now time.Time) bool {
if c == nil {
return false
}
return providerMSPLicenseLapsed(c.ProviderMSPLicenseExpiresAt, now)
}
// resolveProviderMSPLicense picks the licence a provider-hosted control plane
// starts on: a current renewed licence first, then a current
// CP_PROVIDER_MSP_LICENSE_FILE. The renewed licence wins because the host file
// is usually the self-issued evaluation, which expires; a paying provider must
// keep starting after it does. When neither is current it still starts, on
// whichever lapsed licence expires later, because refusing to start would take
// down the portal, which is the only place a provider can buy or renew. Both
// candidates must be authentic Pulse licences, and validate still ties the
// chosen one to this control plane's lease signing key.
func resolveProviderMSPLicense(hostFile, dataDir string) (*providerMSPLicenseResolution, string, error) {
now := time.Now()
var renewed *providerMSPLicenseResolution
renewedPath := ProviderMSPRenewedLicensePath(dataDir)
if _, err := os.Stat(renewedPath); err == nil {
renewed, err := resolveProviderMSPPlanFromLicenseFile(renewedPath)
if err == nil {
candidate, err := resolveProviderMSPLicenseFile(renewedPath, true)
if err != nil {
log.Warn().Err(err).Str("path", renewedPath).Msg("Renewed provider MSP license is unusable; falling back to CP_PROVIDER_MSP_LICENSE_FILE")
} else {
renewed = candidate
}
}
if renewed != nil && !renewed.lapsed(now) {
return renewed, ProviderMSPPlanSourceRenewedLicense, nil
}
host, err := resolveProviderMSPLicenseFile(hostFile, true)
if err != nil {
if renewed != nil {
return renewed, ProviderMSPPlanSourceRenewedLicense, nil
}
log.Warn().Err(err).Str("path", renewedPath).Msg("Renewed provider MSP license is unusable; falling back to CP_PROVIDER_MSP_LICENSE_FILE")
}
resolved, err := resolveProviderMSPPlanFromLicenseFile(hostFile)
if err != nil {
return nil, "", err
}
return resolved, ProviderMSPPlanSourceLicenseFile, nil
if !host.lapsed(now) || renewed == nil || !renewed.ExpiresAt.After(host.ExpiresAt) {
return host, ProviderMSPPlanSourceLicenseFile, nil
}
return renewed, ProviderMSPPlanSourceRenewedLicense, nil
}
func readProviderMSPLicenseFile(path string) (string, error) {

View file

@ -991,6 +991,51 @@ func TestLoadConfig_SessionTTLProviderHostedDefault(t *testing.T) {
}
}
// Refusing to start on a lapsed licence took the provider's portal down at the
// moment the provider needed it to buy. The control plane now starts, reports
// the licence as lapsed, and when both licences have lapsed it starts on the
// one that expired later, so a lapsed paid plan reads as that plan.
func TestLoadConfig_ProviderHostedMSPStartsOnALapsedLicence(t *testing.T) {
setProviderHostedMSPEnv(t)
t.Setenv("CP_ENV", "production")
dataDir := t.TempDir()
t.Setenv("CP_DATA_DIR", dataDir)
issuer := newProviderMSPTestIssuer(t)
key := trialSigningEnvPublicKey(t)
hostFile := writeProviderMSPTestFile(t, filepath.Join(t.TempDir(), "eval.jwt"),
issuer.sign(t, "lic_msp_eval", pkglicensing.PlanVersionMSPEval, time.Now().Add(-60*24*time.Hour), key))
t.Setenv("CP_PROVIDER_MSP_LICENSE_FILE", hostFile)
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig with only a lapsed evaluation: %v", err)
}
if cfg.ProviderMSPPlanVersion != pkglicensing.PlanVersionMSPEval || cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceLicenseFile {
t.Fatalf("lapsed evaluation resolved plan=%q source=%q", cfg.ProviderMSPPlanVersion, cfg.ProviderMSPPlanSource)
}
if !cfg.ProviderMSPLicenseLapsed(time.Now()) {
t.Fatal("ProviderMSPLicenseLapsed = false for an evaluation that ended 60 days ago")
}
writeProviderMSPTestFile(t, ProviderMSPRenewedLicensePath(dataDir),
issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(-20*24*time.Hour), key))
cfg, err = LoadConfig()
if err != nil {
t.Fatalf("LoadConfig with both licences lapsed: %v", err)
}
if cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceRenewedLicense || cfg.ProviderMSPPlanVersion != "msp_solo" || !cfg.ProviderMSPLicenseLapsed(time.Now()) {
t.Fatalf("both lapsed resolved plan=%q source=%q lapsed=%v, want the later-expiring paid licence",
cfg.ProviderMSPPlanVersion, cfg.ProviderMSPPlanSource, cfg.ProviderMSPLicenseLapsed(time.Now()))
}
// Inside the 7-day grace the licence is expired but not yet lapsed.
writeProviderMSPTestFile(t, ProviderMSPRenewedLicensePath(dataDir),
issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(-2*24*time.Hour), key))
if cfg, err = LoadConfig(); err != nil || cfg.ProviderMSPLicenseLapsed(time.Now()) {
t.Fatalf("licence 2 days past expiry: err=%v lapsed=%v, want running and not lapsed", err, cfg != nil && cfg.ProviderMSPLicenseLapsed(time.Now()))
}
}
func TestLoadConfig_SessionTTLOverride(t *testing.T) {
setProviderHostedMSPEnv(t)
t.Setenv("CP_SESSION_TTL", "36h")

View file

@ -1,5 +1,5 @@
{
"source_hash": "33ddecf9ec76e0a22e0facf6971b8f0825600e56007db5e7f1f4e2cbe1dd2621",
"source_hash": "5307f9f4c72602a934c10d38c5a20b212bddae474198b278648fc751df932267",
"build_inputs": [
"package.json",
"tsconfig.json",

View file

@ -1034,14 +1034,14 @@
}
function messageFromPayload(payload, fallback) {
if (payload && typeof payload === "object") {
var errorMessage = payload.error;
if (typeof errorMessage === "string" && errorMessage.trim()) {
return errorMessage;
}
var message = payload.message;
if (typeof message === "string" && message.trim()) {
return message;
}
var errorMessage = payload.error;
if (typeof errorMessage === "string" && errorMessage.trim()) {
return errorMessage;
}
}
if (typeof payload === "string" && payload.trim()) {
return payload;
@ -1517,7 +1517,7 @@
if (body.error !== "workspace_limit_reached") return "";
var entity = clientLanguage ? "client workspaces" : "workspaces";
var counts = typeof body.current === "number" && typeof body.limit === "number" && body.limit > 0 ? " (" + body.current + " of " + body.limit + " in use)" : "";
return "Your license limit for " + entity + " is reached" + counts + ". Remove a " + (clientLanguage ? "client" : "workspace") + " or upgrade your license to add more.";
return "Your plan limit for " + entity + " is reached" + counts + ". Remove a " + (clientLanguage ? "client" : "workspace") + " or move to a bigger plan to add more.";
}
var createWorkspace = async function(accountID) {
var nameEl = getElement("ws-name-" + accountID);
@ -2728,15 +2728,30 @@
var expiresAt = new Date(plan.expires_at).getTime();
return !isNaN(expiresAt) && expiresAt - now <= PAID_GRACE_MS;
}
function planEnded(plan, now) {
if (plan.lapsed) return true;
if (!plan.expires_at) return false;
var expiresAt = new Date(plan.expires_at).getTime();
return !isNaN(expiresAt) && now >= expiresAt;
}
function renderCurrentPlan(plan, canManage, busy, inUse, now) {
var expires = formatDate(plan.expires_at);
var title = providerPlanName(plan.plan_version);
var description;
var meta = [inUse >= 0 ? String(inUse) + " of " + clientWorkspaces(plan.workspace_limit) + " in use" : clientWorkspaces(plan.workspace_limit)];
var cta = "";
if (plan.evaluation) {
var ended = planEnded(plan, now);
var lostFeatures = plan.lapsed ? " Client workspaces keep running with core monitoring but have lost their MSP features, and no new clients can be added." : "";
if (plan.evaluation && ended) {
description = "Your evaluation ended" + (expires ? " on " + expires : "") + "." + lostFeatures + " Buy a plan to keep your clients monitored and to add more.";
} else if (plan.evaluation) {
description = expires ? "Your evaluation covers " + clientWorkspaces(plan.workspace_limit) + " until " + expires + ". Buy a plan to keep your clients monitored after that and to add more." : "Your evaluation covers " + clientWorkspaces(plan.workspace_limit) + ". Buy a plan to add more.";
if (expires) meta.push("Expires " + expires);
} else if (ended) {
description = "Your " + title + " plan ended" + (expires ? " on " + expires : "") + "." + lostFeatures + " Renew from Manage billing or buy a plan below.";
if (canManage) {
cta = '<button class="btn-secondary billing-action-button" type="button" data-provider-plan-action="manage-billing"' + (busy ? " disabled" : "") + ">" + (busy === "manage-billing" ? "Opening\u2026" : "Manage billing") + "</button>";
}
} else {
description = paidLicenceInGrace(plan, now) ? "Your subscription has not renewed. Your clients keep this plan until " + expires + ". Open Manage billing to renew or update your payment method." : "Up to " + clientWorkspaces(plan.workspace_limit) + ". Renews automatically while your subscription is active.";
if (canManage) {
@ -2774,7 +2789,8 @@
var plan = view.plan;
if (!plan) return parts.join("");
parts.push(renderCurrentPlan(plan, canManage, view.busy, inUse, now));
if (plan.evaluation) {
var ended = planEnded(plan, now);
if (plan.evaluation || ended) {
if (plan.plans_error) {
parts.push('<p class="billing-action-meta" role="alert">' + escapeText(plan.plans_error) + "</p>");
} else if (plan.purchase_available) {
@ -2783,7 +2799,7 @@
});
var cycle = hasAnnual ? view.cycle : "monthly";
var offers = plan.plans.filter(function(option) {
return option.billing_cycle === cycle && option.workspace_limit > plan.workspace_limit;
return option.billing_cycle === cycle && (ended && !plan.evaluation ? option.workspace_limit >= plan.workspace_limit : option.workspace_limit > plan.workspace_limit);
});
parts.push('<div class="billing-section-intro"><h3>Choose a plan</h3><p>You pay per client workspace, never per monitored system. Every client workspace is full Pulse.</p></div>');
parts.push(renderCycleToggle(cycle, hasAnnual));

View file

@ -188,8 +188,8 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
var counts = typeof body.current === 'number' && typeof body.limit === 'number' && body.limit > 0
? ' (' + body.current + ' of ' + body.limit + ' in use)'
: '';
return 'Your license limit for ' + entity + ' is reached' + counts + '. Remove a ' +
(clientLanguage ? 'client' : 'workspace') + ' or upgrade your license to add more.';
return 'Your plan limit for ' + entity + ' is reached' + counts + '. Remove a ' +
(clientLanguage ? 'client' : 'workspace') + ' or move to a bigger plan to add more.';
}
var createWorkspace = async function(accountID: string): Promise<void> {

View file

@ -73,6 +73,31 @@ describe('portal api', function() {
});
});
// A code-plus-sentence payload must show the sentence: the provider saw
// "provider_msp_license_lapsed" instead of what to do about it.
it('prefers the human message over the machine error code', async function() {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 403,
headers: new Headers({ 'content-type': 'application/json' }),
json: async function() {
return { error: 'provider_msp_license_lapsed', message: 'Your Pulse MSP plan has ended, so no new clients can be added.' };
},
}));
var api = createPortalAPI({
getBootstrap: function() {
return bootstrap;
},
});
await expect(api.createWorkspace('acct_1', { display_name: 'Acme' })).rejects.toMatchObject({
name: 'PortalAPIError',
status: 403,
message: 'Your Pulse MSP plan has ended, so no new clients can be added.',
});
});
it('keeps task-specific fallback copy on network failures', async function() {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up')));

View file

@ -109,16 +109,19 @@ export function createPortalAPI(context: PortalAPIContext): PortalAPI {
return null;
}
// Control plane errors carry a machine code in "error" and, when there is
// something to tell the person, a sentence in "message". Show the sentence;
// older handlers put their only human text in "error", so fall back to it.
function messageFromPayload(payload: unknown, fallback: string): string {
if (payload && typeof payload === 'object') {
var errorMessage = (payload as { error?: unknown }).error;
if (typeof errorMessage === 'string' && errorMessage.trim()) {
return errorMessage;
}
var message = (payload as { message?: unknown }).message;
if (typeof message === 'string' && message.trim()) {
return message;
}
var errorMessage = (payload as { error?: unknown }).error;
if (typeof errorMessage === 'string' && errorMessage.trim()) {
return errorMessage;
}
}
if (typeof payload === 'string' && payload.trim()) {
return payload;

View file

@ -96,6 +96,31 @@ describe('renderProviderPlanHTML', () => {
expect(lapsed).not.toContain('Renews automatically');
});
// The control plane keeps serving the portal on a lapsed licence precisely
// so the provider can buy here; the panel must say so and offer the plans.
it('tells a provider whose evaluation ended what happened and how to buy', () => {
const now = Date.parse('2026-12-10T00:00:00Z');
const html = renderProviderPlanHTML(view({
plan: evaluationPlan({ expires_at: '2026-11-22T20:49:54Z', lapsed: true }),
}), true, 2, now);
expect(html).toContain('Your evaluation ended on');
expect(html).toContain('lost their MSP features, and no new clients can be added');
expect(html).toContain('data-provider-plan-action="buy" data-provider-plan-version="msp_solo"');
expect(html).not.toContain('Your evaluation covers');
});
it('offers the same plan again after a paid plan lapses', () => {
const now = Date.parse('2026-12-10T00:00:00Z');
const html = renderProviderPlanHTML(view({
plan: evaluationPlan({ plan_version: 'msp_solo', evaluation: false, workspace_limit: 3, expires_at: '2026-11-20T00:00:00Z', lapsed: true }),
}), true, 3, now);
expect(html).toContain('Your Solo plan ended on');
expect(html).toContain('data-provider-plan-action="buy" data-provider-plan-version="msp_solo"');
expect(html).toContain('data-provider-plan-action="buy" data-provider-plan-version="msp_starter"');
expect(html).toContain('data-provider-plan-action="manage-billing"');
expect(html).not.toContain('Renews automatically');
});
it('keeps the evaluation visible when plans cannot be loaded', () => {
const html = renderProviderPlanHTML(view({ plan: evaluationPlan({ plans: [], purchase_available: false, plans_error: 'Plans are unavailable right now.' }) }), true);
expect(html).toContain('Free evaluation');

View file

@ -20,6 +20,9 @@ export interface ProviderPlanState {
evaluation: boolean;
license_id?: string;
expires_at?: string;
// Past expiry and grace: client workspaces have lost MSP features and no
// new clients can be added until the provider buys or renews.
lapsed?: boolean;
workspace_limit: number;
purchase_available: boolean;
plans: ProviderPlanOption[];
@ -80,17 +83,38 @@ function paidLicenceInGrace(plan: ProviderPlanState, now: number): boolean {
return !isNaN(expiresAt) && expiresAt - now <= PAID_GRACE_MS;
}
// The licence has run out: the evaluation or paid period is over. The control
// plane keeps serving this portal so the provider can buy or renew here.
function planEnded(plan: ProviderPlanState, now: number): boolean {
if (plan.lapsed) return true;
if (!plan.expires_at) return false;
var expiresAt = new Date(plan.expires_at).getTime();
return !isNaN(expiresAt) && now >= expiresAt;
}
function renderCurrentPlan(plan: ProviderPlanState, canManage: boolean, busy: string, inUse: number, now: number): string {
var expires = formatDate(plan.expires_at);
var title = providerPlanName(plan.plan_version);
var description: string;
var meta: string[] = [inUse >= 0 ? String(inUse) + ' of ' + clientWorkspaces(plan.workspace_limit) + ' in use' : clientWorkspaces(plan.workspace_limit)];
var cta = '';
if (plan.evaluation) {
var ended = planEnded(plan, now);
var lostFeatures = plan.lapsed
? ' Client workspaces keep running with core monitoring but have lost their MSP features, and no new clients can be added.'
: '';
if (plan.evaluation && ended) {
description = 'Your evaluation ended' + (expires ? ' on ' + expires : '') + '.' + lostFeatures + ' Buy a plan to keep your clients monitored and to add more.';
} else if (plan.evaluation) {
description = expires
? 'Your evaluation covers ' + clientWorkspaces(plan.workspace_limit) + ' until ' + expires + '. Buy a plan to keep your clients monitored after that and to add more.'
: 'Your evaluation covers ' + clientWorkspaces(plan.workspace_limit) + '. Buy a plan to add more.';
if (expires) meta.push('Expires ' + expires);
} else if (ended) {
description = 'Your ' + title + ' plan ended' + (expires ? ' on ' + expires : '') + '.' + lostFeatures + ' Renew from Manage billing or buy a plan below.';
if (canManage) {
cta = '<button class="btn-secondary billing-action-button" type="button" data-provider-plan-action="manage-billing"' +
(busy ? ' disabled' : '') + '>' + (busy === 'manage-billing' ? 'Opening…' : 'Manage billing') + '</button>';
}
} else {
// The renewal date lives in Manage billing; the licence date only
// matters, and only shows, once the subscription has stopped renewing.
@ -162,14 +186,17 @@ export function renderProviderPlanHTML(view: ProviderPlanView, canManage: boolea
if (!plan) return parts.join('');
parts.push(renderCurrentPlan(plan, canManage, view.busy, inUse, now));
if (plan.evaluation) {
var ended = planEnded(plan, now);
if (plan.evaluation || ended) {
if (plan.plans_error) {
parts.push('<p class="billing-action-meta" role="alert">' + escapeText(plan.plans_error) + '</p>');
} else if (plan.purchase_available) {
var hasAnnual = plan.plans.some(function(option) { return option.billing_cycle === 'annual'; });
var cycle = hasAnnual ? view.cycle : 'monthly';
// After a paid plan ends, buying the same plan again is a valid choice.
var offers = plan.plans.filter(function(option) {
return option.billing_cycle === cycle && option.workspace_limit > plan!.workspace_limit;
return option.billing_cycle === cycle &&
(ended && !plan!.evaluation ? option.workspace_limit >= plan!.workspace_limit : option.workspace_limit > plan!.workspace_limit);
});
parts.push('<div class="billing-section-intro"><h3>Choose a plan</h3><p>You pay per client workspace, never per monitored system. Every client workspace is full Pulse.</p></div>');
parts.push(renderCycleToggle(cycle, hasAnnual));

View file

@ -389,6 +389,7 @@ type ProviderMSPPlanState struct {
Evaluation bool `json:"evaluation"`
LicenseID string `json:"license_id,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Lapsed bool `json:"lapsed"`
WorkspaceLimit int `json:"workspace_limit"`
PurchaseAvailable bool `json:"purchase_available"`
Plans []ProviderMSPPurchasablePlan `json:"plans"`
@ -409,6 +410,7 @@ func providerMSPPlanState(ctx context.Context, cfg *CPConfig, refresher *Provide
if !cfg.ProviderMSPLicenseExpiresAt.IsZero() {
state.ExpiresAt = cfg.ProviderMSPLicenseExpiresAt.Format(time.RFC3339)
}
state.Lapsed = cfg.ProviderMSPLicenseLapsed(time.Now())
if refresher == nil || state.LicenseID == "" {
return state
}

View file

@ -244,6 +244,26 @@ func TestProviderMSPLicenseRefreshAppliesShorterPaidPeriod(t *testing.T) {
}
}
// Startup tolerates a lapsed licence so the portal stays up, but the refresher
// must never adopt one the licence server returns.
func TestProviderMSPLicenseRefreshRefusesALapsedLicence(t *testing.T) {
issuer := newProviderMSPTestIssuer(t)
fake := &fakeProviderMSPLicenseServer{t: t, currentStatus: http.StatusOK}
server := httptest.NewServer(fake)
defer server.Close()
cfg := newProviderMSPRefreshTestConfig(t, server.URL)
fake.currentLicense = issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(-30*24*time.Hour), trialSigningEnvPublicKey(t))
refresher := NewProviderMSPLicenseRefresher(cfg)
refresher.SetRestart(func() { t.Fatal("a lapsed licence must not restart the control plane") })
if _, err := refresher.Refresh(context.Background()); err == nil || !strings.Contains(err.Error(), "unusable") {
t.Fatalf("Refresh with a lapsed licence err = %v, want refusal", err)
}
if _, err := os.Stat(ProviderMSPRenewedLicensePath(cfg.DataDir)); !os.IsNotExist(err) {
t.Fatalf("lapsed licence was installed: %v", err)
}
}
func TestProviderMSPPortalRoutesRelayToTheLicenceServer(t *testing.T) {
fake := &fakeProviderMSPLicenseServer{
t: t,

View file

@ -250,6 +250,7 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) {
if deps.Config.IsMSPControlPlane() {
workspaceLimitPolicy.ProviderHostedMSP = true
workspaceLimitPolicy.ProviderMSPPlanVersion = providerMSPPlanVersion(deps.Config)
workspaceLimitPolicy.ProviderMSPLicenseLapsed = func() bool { return deps.Config.ProviderMSPLicenseLapsed(time.Now()) }
}
createTenant := account.HandleCreateTenantWithWorkspaceLimitPolicy(deps.Registry, provisioner, workspaceLimitPolicy)
updateTenant := account.HandleUpdateTenant(deps.Registry)

View file

@ -1,6 +1,14 @@
package licensing
import "testing"
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"testing"
"time"
)
func TestNormalizeBillingStatePreservesMissingPlanVersionAndScrubsRetiredMonitoringLimit(t *testing.T) {
state := &BillingState{
@ -93,3 +101,43 @@ func TestCloudClaimsMissingPlanVersionDoesNotReintroduceMonitoringLimit(t *testi
t.Fatalf("EffectiveLimits retained retired max_monitored_systems: %v", claims.EffectiveLimits())
}
}
// A provider control plane must keep starting on a lapsed licence so its
// portal can sell the renewal, but the licence must still be authentic, and
// client runtimes must still refuse to take MSP capabilities from it.
func TestValidateLicenseAllowingLapseReturnsLapsedButStillAuthenticLicence(t *testing.T) {
setupTestPublicKey(t)
providerPub, providerPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate provider key pair: %v", err)
}
lapsed := mintTestProviderMSPLicense(t, TierMSP, providerPub, -30*24*time.Hour)
if _, err := ValidateLicense(lapsed); !errors.Is(err, ErrExpiredLicense) {
t.Fatalf("ValidateLicense(lapsed) error = %v, want ErrExpiredLicense", err)
}
license, err := ValidateLicenseAllowingLapse(lapsed)
if err != nil || license == nil {
t.Fatalf("ValidateLicenseAllowingLapse(lapsed) = %v, %v; want the licence", license, err)
}
if !license.IsExpired() || license.GracePeriodEnd == nil || !license.GracePeriodEnd.Before(time.Now()) {
t.Fatalf("lapsed licence expired=%v graceEnd=%v; want expired with a grace end in the past", license.IsExpired(), license.GracePeriodEnd)
}
// Authenticity is not relaxed: a licence not signed by the Pulse root is
// still refused.
claims := Claims{LicenseID: "lic_forged", Email: "attacker@example.com", Tier: TierMSP, IssuedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(-30 * 24 * time.Hour).Unix(), PlanVersion: "msp_starter",
EntitlementSigningPublicKey: base64.StdEncoding.EncodeToString(providerPub)}
payload, _ := json.Marshal(claims)
if _, err := ValidateLicenseAllowingLapse(signTestJWT(t, payload, providerPriv)); err == nil {
t.Fatal("ValidateLicenseAllowingLapse accepted a licence not signed by the Pulse root")
}
// Enforcement stays with the runtime: a lease chained to the lapsed
// licence does not verify.
token := signTestProviderLease(t, providerPriv, lapsed, []string{FeatureWhiteLabel, FeatureMultiTenant})
if _, err := VerifyEntitlementLeaseToken(token, testPublicKey, "t-acme.pulse.example-msp.com", time.Now()); err == nil {
t.Fatal("a lease chained to a lapsed provider licence must not verify")
}
}

View file

@ -935,6 +935,21 @@ func cloneClaims(in Claims) Claims {
// ValidateLicense validates a license key and returns the license if valid.
func ValidateLicense(licenseKey string) (*License, error) {
return validateLicense(licenseKey, false)
}
// ValidateLicenseAllowingLapse applies every ValidateLicense check (format,
// Pulse signature, required claims) but returns a licence past its expiry and
// grace period instead of rejecting it, with GracePeriodEnd set. The caller
// owns enforcement. A provider-hosted MSP control plane uses it to keep
// starting on a lapsed licence so its portal can sell the renewal; the client
// runtimes it serves still verify the licence with ValidateLicense and drop
// MSP capabilities once it lapses.
func ValidateLicenseAllowingLapse(licenseKey string) (*License, error) {
return validateLicense(licenseKey, true)
}
func validateLicense(licenseKey string, allowLapsed bool) (*License, error) {
// Trim whitespace
licenseKey = strings.TrimSpace(licenseKey)
if licenseKey == "" {
@ -1028,6 +1043,8 @@ func ValidateLicense(licenseKey string) (*License, error) {
// Within grace period - allow activation but mark as in grace period
license.GracePeriodEnd = &gracePeriodEnd
// License is still valid during grace period
} else if allowLapsed {
license.GracePeriodEnd = &gracePeriodEnd
} else {
// Past grace period - reject
return nil, fmt.Errorf("%w: expired on %s (grace period ended %s)",