diff --git a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md index 48df90f4d..31a4a5d17 100644 --- a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md +++ b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md @@ -1,6 +1,6 @@ # Pulse Account Portal Spec -Last updated: 2026-03-29 +Last updated: 2026-04-07 Status: ACTIVE ## Purpose @@ -429,6 +429,8 @@ Transition rule: 3. in-product self-hosted upgrade CTAs should hand off into `Pulse Account` billing first, with `Pulse Account` owning self-hosted plan comparison and checkout before returning through Pulse's activation callback + via a signed instance-bound handoff token rather than loose return query + parameters 4. utility pages should shrink toward redirects or lightweight recovery handoffs once equivalent Pulse Account areas exist @@ -468,6 +470,10 @@ Accepted as sufficient for RC and GA: Pulse accepting a signed instance-bound return token and returning either the originating billing tab or the current tab fallback to the owned billing route automatically +5. `Pulse Account` must not render a second manual `Activate in Pulse Pro` + step after hosted checkout success; the portal must resolve a verified + Pulse return template first and let checkout success flow straight into + Pulse's activation bridge 5. commercial surfaces are functional but still fragmented outside the owned checkout-return path diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 34baf146a..575bd84a1 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -190,6 +190,16 @@ management, and fleet control surfaces. prereleases and build-metadata versions must fail closed so first-host install, repair, and fleet continuity do not depend on unpublished or branch-local installer URLs. +13. Keep self-hosted purchase handoff state on the adjacent commercial/auth + boundary. When shared `internal/api/router.go`, + `internal/api/router_routes_cloud.go`, `internal/api/licensing_handlers.go`, + or `internal/api/demo_mode_commercial.go` evolve public + `/auth/license-purchase-start`, `/auth/license-purchase-handoff`, or + `/auth/license-purchase-activate`, lifecycle-adjacent setup and fleet + surfaces may rely on that public-route wiring but must not reinterpret + purchase-return tokens, activation-bridge form state, or demo-hidden + commercial route policy as installer credentials, registration state, or + fleet enrollment authority. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 56471a275..09f1c4511 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -501,21 +501,26 @@ hosted-only accounts do not render self-hosted license, refund, privacy, or self-hosted escalation paths by default, and self-hosted-only accounts do not front-load an empty hosted-billing block before the real self-hosted jobs. That same runtime handoff contract now also covers product-originated -self-hosted upgrade arrivals: `/portal?service=upgrade&feature=...` may open a -portal-owned upgrade job inside `Billing`, but it must not fabricate broader -self-hosted commercial history or reveal retrieve/refund/privacy panels for a -hosted-only account that only arrived through an upgrade CTA. +self-hosted upgrade arrivals: `/portal?service=upgrade&purchase_return_token=...` +may open a portal-owned upgrade job inside `Billing`, but it must not +fabricate broader self-hosted commercial history or reveal +retrieve/refund/privacy panels for a hosted-only account that only arrived +through an upgrade CTA. That same commercial contract now also includes the self-hosted purchase return path. Product-originated upgrade handoffs must include a canonical -`return_url` that points back to Pulse's public `POST /auth/license-purchase-activate` -callback plus a signed `purchase_return_token` bound to the originating Pulse -instance, and that callback must redeem the completed checkout through the -shared license/commercial API before returning the browser to the owned -billing plan route. When the upgrade flow was opened in a secondary tab, the -callback may refresh the originating billing tab and close itself; when no -opener is available, the callback must still return the current tab to the -owned billing route automatically instead of leaving the operator on a dead -success page. +signed `purchase_return_token` bound to the originating Pulse instance. The +portal runtime must derive the originating Pulse origin from the browser +referrer, resolve a verified `activation_url_template` through Pulse-owned +`GET /auth/license-purchase-handoff`, and use that verified template as the +checkout success target instead of trusting loose `feature` or `return_url` +query parameters. Pulse's public `GET /auth/license-purchase-activate` +callback then serves an auto-submitting bridge into the owned POST activation +path, which redeems the completed checkout through the shared +license/commercial API before returning the browser to the owned billing plan +route. When the upgrade flow was opened in a secondary tab, the callback may +refresh the originating billing tab and close itself; when no opener is +available, the callback must still return the current tab to the owned billing +route automatically instead of leaving the operator on a dead success page. That same typed bootstrap/runtime contract must also derive the default signed- in shell section from account shape: hosted accounts open on `Workspaces`, self-hosted-only accounts open on `Billing`, and the signed-in shell keeps diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 4e77b6034..70295b62e 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -425,10 +425,17 @@ public pricing surface inside the runtime, and the authenticated portal shell must not collapse back into a public-site-only waystation for new-purchase depth. That handoff now starts through Pulse-owned `GET /auth/license-purchase-start`, which mints a signed `purchase_return_token` for the local instance before the -browser leaves for `Pulse Account`. The owned activation callback must accept -that signed state, redeem the completed checkout, and return the operator to -the canonical billing plan route automatically whether checkout completed in a -secondary tab or in the current tab fallback path. +browser leaves for `Pulse Account`. `Pulse Account` must resolve that signed +state through Pulse-owned `GET /auth/license-purchase-handoff` before it +starts checkout, so the portal never trusts loose `feature` or `return_url` +query parameters for self-hosted purchase completion. Stripe success now lands +on Pulse's public `GET /auth/license-purchase-activate` bridge, which +auto-submits into the owned POST activation path; the portal must not render a +second manual `Activate in Pulse Pro` step after checkout. The owned +activation callback must accept that signed state, redeem the completed +checkout, and return the operator to the canonical billing plan route +automatically whether checkout completed in a secondary tab or in the current +tab fallback path. That destination split is canonical commercial truth, but navigation semantics are not owned here. `frontend-modern/src/utils/pricingHandoff.ts` and `frontend-modern/src/stores/license.ts` decide which href each commercial 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 eea2f5615..46d0c5b6b 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -183,6 +183,16 @@ regression protection. preview/pinned wash via `data-summary-group-member-active` rather than per-surface outlines, secondary buttons, or full-strength row fills. 28. Keep summary-card hover emphasis on one bounded rendering budget: when a summary row is active, shared sparkline and density-map primitives must promote the selected series and demote background series through the same active-series ID rather than layering a second page-local highlight pass, so zoom-range and hover scrubbing stay visually coherent without reintroducing multi-series overdraw on the hot summary cards. Density maps on that hot path must stay overview-first under focus: preserve the multi-entity heatmap rows, layer focused-entity detail inside the card, and avoid swapping transient hover into a separate single-series chart path. +29. Keep public self-hosted checkout handoff endpoints on the adjacent + commercial/router boundary, not the summary-chart hot path. When + `internal/api/router.go`, `internal/api/router_routes_cloud.go`, or + `internal/api/licensing_handlers.go` evolve + `/auth/license-purchase-start`, `/auth/license-purchase-handoff`, or + `/auth/license-purchase-activate`, performance work may keep those routes + cheap and redirect-safe, but it must not treat purchase-return callbacks as + chart-transport hot paths, fold summary-card caching into commercial + callback behavior, or reuse those public auth endpoints as a justification + for relaxing the protected history payload budgets that belong elsewhere. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 00b3ae76d..8b03db773 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -159,6 +159,16 @@ querying, and the operator-facing storage health presentation layer. commercial compatibility handoffs like `/pricing` must stay separate thin route exits rather than borrowing storage/recovery preview framing, first-session copy, or page-state assumptions. +36. Keep public self-hosted purchase handoff and activation routes on the + adjacent commercial/auth boundary. When `internal/api/router.go`, + `internal/api/router_routes_cloud.go`, `internal/api/licensing_handlers.go`, + or `internal/api/demo_mode_commercial.go` evolve + `/auth/license-purchase-start`, `/auth/license-purchase-handoff`, or + `/auth/license-purchase-activate`, storage and recovery may coexist with + those shared public-route helpers but must not reuse purchase-return tokens, + activation-bridge callbacks, or demo-hidden commercial route policy as + recovery identity, restore proof, preview framing, or backup/recovery-local + transport. ## Forbidden Paths diff --git a/frontend-modern/vite.config.ts b/frontend-modern/vite.config.ts index bd911df6d..a0c0ee66b 100644 --- a/frontend-modern/vite.config.ts +++ b/frontend-modern/vite.config.ts @@ -200,6 +200,18 @@ export default defineConfig({ changeOrigin: true, cookieDomainRewrite: '', }, + '/auth/license-purchase-start': { + target: backendUrl, + changeOrigin: true, + }, + '/auth/license-purchase-handoff': { + target: backendUrl, + changeOrigin: true, + }, + '/auth/license-purchase-activate': { + target: backendUrl, + changeOrigin: true, + }, '/install-container-agent.sh': { target: backendUrl, changeOrigin: true, diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 360d921d4..64f5435b2 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -4835,6 +4835,7 @@ func TestContract_DemoModeCommercialSurfacePolicy(t *testing.T) { {method: http.MethodGet, path: "/api/upgrade-metrics/stats"}, {method: http.MethodPost, path: "/api/upgrade-metrics/events"}, {method: http.MethodGet, path: licensePurchaseStartPath}, + {method: http.MethodGet, path: licensePurchaseHandoffPath}, {method: http.MethodGet, path: "/auth/trial-activate"}, } @@ -4883,6 +4884,54 @@ func TestContract_DemoModeCommercialSurfacePolicy(t *testing.T) { }) } +func TestContract_SelfHostedPurchaseHandoffJSONSnapshot(t *testing.T) { + handler := createTestHandler(t) + handler.SetConfig(&config.Config{PublicURL: "https://pulse.example.com"}) + + returnToken := issuePurchaseReturnToken(t, handler, "default", "max_monitored_systems") + req := httptest.NewRequest( + http.MethodGet, + "https://pulse.example.com"+licensePurchaseHandoffPath+ + "?purchase_return_token="+url.QueryEscape(returnToken), + nil, + ) + rec := httptest.NewRecorder() + + handler.HandleCheckoutHandoff(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Fatalf("allow-origin=%q, want *", got) + } + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("cache-control=%q, want no-store", got) + } + + var payload struct { + Feature string `json:"feature"` + ActivationURLTemplate string `json:"activation_url_template"` + } + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + + wantTemplate, err := licensePurchaseActivationTemplateURL( + "https://pulse.example.com"+licensePurchaseActivationPath, + returnToken, + ) + if err != nil { + t.Fatalf("licensePurchaseActivationTemplateURL: %v", err) + } + if payload.Feature != "max_monitored_systems" { + t.Fatalf("feature=%q, want max_monitored_systems", payload.Feature) + } + if payload.ActivationURLTemplate != wantTemplate { + t.Fatalf("activation_url_template=%q, want %q", payload.ActivationURLTemplate, wantTemplate) + } +} + func TestContract_HandoffExchangeJSONSnapshot(t *testing.T) { key := []byte("test-handoff-key") configDir := t.TempDir() @@ -6021,6 +6070,21 @@ func TestContract_SecurityStatusIncludesSessionCapabilitiesDemoMode(t *testing.T if got, _ := sessionCapabilities["demoMode"].(bool); !got { t.Fatalf("sessionCapabilities.demoMode = %v, want true", sessionCapabilities["demoMode"]) } + + presentationPolicy, ok := payload["presentationPolicy"].(map[string]any) + if !ok { + t.Fatalf("presentationPolicy = %#v, want object", payload["presentationPolicy"]) + } + for key, want := range map[string]bool{ + "demoMode": true, + "readOnly": true, + "hideCommercial": true, + "hideUpgrade": true, + } { + if got, _ := presentationPolicy[key].(bool); got != want { + t.Fatalf("presentationPolicy.%s = %v, want %v", key, presentationPolicy[key], want) + } + } } func TestContract_SetupScriptURLRejectsNonCanonicalRequestJSON(t *testing.T) { diff --git a/internal/api/demo_middleware_test.go b/internal/api/demo_middleware_test.go index c2f770b67..c96a942d2 100644 --- a/internal/api/demo_middleware_test.go +++ b/internal/api/demo_middleware_test.go @@ -58,6 +58,7 @@ func TestDemoModeMiddleware(t *testing.T) { {"demo on hidden commercial posture", true, http.MethodGet, "/api/license/commercial-posture", "", false, http.StatusNotFound, true}, {"demo on hidden license entitlements", true, http.MethodGet, "/api/license/entitlements", "", false, http.StatusNotFound, true}, {"demo on hidden checkout start", true, http.MethodGet, "/auth/license-purchase-start", "", false, http.StatusNotFound, true}, + {"demo on hidden checkout handoff", true, http.MethodGet, "/auth/license-purchase-handoff", "", false, http.StatusNotFound, true}, {"demo on hidden license activate", true, http.MethodPost, "/api/license/activate", "", false, http.StatusNotFound, true}, {"demo on hidden purchase start", true, http.MethodGet, licensePurchaseStartPath, "", false, http.StatusNotFound, true}, {"demo on hidden trial activation", true, http.MethodGet, "/auth/trial-activate", "", false, http.StatusNotFound, true}, diff --git a/internal/api/demo_mode_commercial.go b/internal/api/demo_mode_commercial.go index 76ceac334..dda0a1287 100644 --- a/internal/api/demo_mode_commercial.go +++ b/internal/api/demo_mode_commercial.go @@ -38,6 +38,11 @@ var publicDemoCommercialPolicies = []publicDemoCommercialRoutePolicy{ exposure: publicDemoCommercialExposureHidden, matches: exactDemoCommercialMethodPath(http.MethodGet, "/auth/license-purchase-start"), }, + { + route: licensePurchaseHandoffPath, + exposure: publicDemoCommercialExposureHidden, + matches: exactDemoCommercialMethodPath(http.MethodGet, licensePurchaseHandoffPath), + }, { route: "/api/license/activate", exposure: publicDemoCommercialExposureHidden, diff --git a/internal/api/demo_mode_commercial_test.go b/internal/api/demo_mode_commercial_test.go index 4bf24c8b1..25200fc10 100644 --- a/internal/api/demo_mode_commercial_test.go +++ b/internal/api/demo_mode_commercial_test.go @@ -69,6 +69,10 @@ func routeBelongsToPublicDemoCommercialBoundary(route string) bool { return true case route == "GET "+licensePurchaseStartPath: return true + case route == licensePurchaseHandoffPath: + return true + case route == "GET "+licensePurchaseHandoffPath: + return true case route == "GET /api/license/runtime-capabilities": return false case strings.HasPrefix(route, "/api/license/"): diff --git a/internal/api/license_handlers_test.go b/internal/api/license_handlers_test.go index c948de3f7..4dbe7cd24 100644 --- a/internal/api/license_handlers_test.go +++ b/internal/api/license_handlers_test.go @@ -720,14 +720,14 @@ func TestHandleCheckoutStart_RedirectsToPulseAccountWithSignedReturnState(t *tes if redirectURL.Scheme != "https" || redirectURL.Host != "cloud.pulserelay.pro" || redirectURL.Path != "/portal" { t.Fatalf("redirect location = %q, want Pulse Account portal", location) } - if got := redirectURL.Query().Get("feature"); got != "relay" { - t.Fatalf("feature = %q, want relay", got) + if got := redirectURL.Query().Get("feature"); got != "" { + t.Fatalf("feature = %q, want omitted portal query", got) } if got := redirectURL.Query().Get("service"); got != "upgrade" { t.Fatalf("service = %q, want upgrade", got) } - if got := redirectURL.Query().Get("return_url"); got != "https://pulse.example.com/auth/license-purchase-activate" { - t.Fatalf("return_url = %q, want canonical purchase callback", got) + if got := redirectURL.Query().Get("return_url"); got != "" { + t.Fatalf("return_url = %q, want omitted portal query", got) } if got := redirectURL.Query().Get("utm_content"); got != "legacy-bookmark" { t.Fatalf("utm_content = %q, want preserved query value", got) @@ -752,6 +752,86 @@ func TestHandleCheckoutStart_RedirectsToPulseAccountWithSignedReturnState(t *tes } } +func TestHandleCheckoutHandoff_ReturnsVerifiedActivationTemplate(t *testing.T) { + handler := createTestHandler(t) + handler.SetConfig(&config.Config{PublicURL: "https://pulse.example.com"}) + + returnToken := issuePurchaseReturnToken(t, handler, "default", "max_monitored_systems") + req := httptest.NewRequest( + http.MethodGet, + "https://pulse.example.com"+licensePurchaseHandoffPath+"?purchase_return_token="+url.QueryEscape(returnToken), + nil, + ) + rec := httptest.NewRecorder() + + handler.HandleCheckoutHandoff(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Feature string `json:"feature"` + ActivationURLTemplate string `json:"activation_url_template"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if resp.Feature != "max_monitored_systems" { + t.Fatalf("feature = %q, want max_monitored_systems", resp.Feature) + } + + expectedTemplate, err := licensePurchaseActivationTemplateURL( + "https://pulse.example.com"+licensePurchaseActivationPath, + returnToken, + ) + if err != nil { + t.Fatalf("licensePurchaseActivationTemplateURL: %v", err) + } + if resp.ActivationURLTemplate != expectedTemplate { + t.Fatalf("activation_url_template = %q, want %q", resp.ActivationURLTemplate, expectedTemplate) + } +} + +func TestHandleCheckoutActivation_GETRendersAutoSubmitBridge(t *testing.T) { + handler := createTestHandler(t) + handler.SetConfig(&config.Config{PublicURL: "https://pulse.example.com"}) + + returnToken := issuePurchaseReturnToken(t, handler, "default", "max_monitored_systems") + req := httptest.NewRequest( + http.MethodGet, + "https://pulse.example.com"+licensePurchaseActivationPath+ + "?session_id=cs_success&purchase_return_token="+url.QueryEscape(returnToken), + nil, + ) + rec := httptest.NewRecorder() + + handler.HandleCheckoutActivation(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, "Finalizing Pulse Pro upgrade") { + t.Fatalf("body = %q, want activation bridge title", body) + } + if !strings.Contains(body, `method="POST" action="`+licensePurchaseActivationPath+`"`) { + t.Fatalf("body = %q, want POST activation form", body) + } + if !strings.Contains(body, `name="session_id" value="cs_success"`) { + t.Fatalf("body = %q, want session_id hidden field", body) + } + if !strings.Contains(body, `name="purchase_return_token" value="`+returnToken+`"`) { + t.Fatalf("body = %q, want purchase_return_token hidden field", body) + } + if !strings.Contains(body, `name="feature" value="max_monitored_systems"`) { + t.Fatalf("body = %q, want feature hidden field derived from claims", body) + } + if !strings.Contains(body, "form.submit()") { + t.Fatalf("body = %q, want auto-submit bridge", body) + } +} + func TestHandleCheckoutActivation_RedeemsCompletedCheckoutAndWritesSuccessBridge(t *testing.T) { t.Setenv("PULSE_LICENSE_DEV_MODE", "false") @@ -818,7 +898,7 @@ func TestHandleCheckoutActivation_RedeemsCompletedCheckoutAndWritesSuccessBridge req := httptest.NewRequest( http.MethodPost, "https://pulse.example.com"+licensePurchaseActivationPath, - strings.NewReader("session_id=cs_success&feature=max_monitored_systems&purchase_return_token="+url.QueryEscape(returnToken)), + strings.NewReader("session_id=cs_success&purchase_return_token="+url.QueryEscape(returnToken)), ) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() @@ -886,7 +966,7 @@ func TestHandleCheckoutActivation_RendersFailurePageWhenCheckoutIsNotFulfilled(t req := httptest.NewRequest( http.MethodPost, "https://pulse.example.com"+licensePurchaseActivationPath, - strings.NewReader("session_id=cs_pending&feature=max_monitored_systems&purchase_return_token="+url.QueryEscape(returnToken)), + strings.NewReader("session_id=cs_pending&purchase_return_token="+url.QueryEscape(returnToken)), ) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() diff --git a/internal/api/licensing_handlers.go b/internal/api/licensing_handlers.go index 3da1738bb..6d6717f42 100644 --- a/internal/api/licensing_handlers.go +++ b/internal/api/licensing_handlers.go @@ -25,13 +25,14 @@ import ( const ( trialStartRateLimitBurst = 6 trialStartRateLimitWindow = 15 * time.Minute + licensePurchaseHandoffPath = "/auth/license-purchase-handoff" licensePurchaseStartPath = "/auth/license-purchase-start" licensePurchaseActivationPath = "/auth/license-purchase-activate" + licensePurchaseSessionIDField = "session_id" licensePurchaseReturnTokenField = "purchase_return_token" pulseAccountUpgradeService = "upgrade" pulseAccountPortalFeatureQueryParam = "feature" pulseAccountPortalServiceQueryParam = "service" - pulseAccountPortalReturnURLQueryParam = "return_url" pulseAccountPortalReturnTokenQueryParam = "purchase_return_token" purchaseReturnKeyPurpose = "pulse-license-purchase-return" ) @@ -263,7 +264,7 @@ func (h *LicenseHandlers) purchaseReturnSigningKey() ([]byte, error) { return signingKey, nil } -func pulseAccountUpgradeURLForRequest(feature, returnURL, returnToken string, query url.Values) (string, error) { +func pulseAccountUpgradeURLForRequest(returnToken string, query url.Values) (string, error) { portalURL := strings.TrimSpace(pulseAccountPortalURLFromLicensing("")) if portalURL == "" { return "", fmt.Errorf("pulse account portal url is unavailable") @@ -279,7 +280,6 @@ func pulseAccountUpgradeURLForRequest(feature, returnURL, returnToken string, qu switch key { case pulseAccountPortalFeatureQueryParam, pulseAccountPortalServiceQueryParam, - pulseAccountPortalReturnURLQueryParam, pulseAccountPortalReturnTokenQueryParam, "checkout", "session_id": @@ -294,17 +294,24 @@ func pulseAccountUpgradeURLForRequest(feature, returnURL, returnToken string, qu } } - normalizedFeature := strings.TrimSpace(feature) - if normalizedFeature != "" { - params.Set(pulseAccountPortalFeatureQueryParam, normalizedFeature) - } params.Set(pulseAccountPortalServiceQueryParam, pulseAccountUpgradeService) - params.Set(pulseAccountPortalReturnURLQueryParam, strings.TrimSpace(returnURL)) params.Set(pulseAccountPortalReturnTokenQueryParam, strings.TrimSpace(returnToken)) parsed.RawQuery = params.Encode() return parsed.String(), nil } +func licensePurchaseActivationTemplateURL(returnURL, returnToken string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(returnURL)) + if err != nil || parsed == nil { + return "", fmt.Errorf("parse purchase activation return url: %w", err) + } + query := parsed.Query() + query.Set(licensePurchaseReturnTokenField, strings.TrimSpace(returnToken)) + query.Set(licensePurchaseSessionIDField, "{CHECKOUT_SESSION_ID}") + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} + func licensePurchaseActivationRedirectPath(feature string) string { normalizedFeature := strings.TrimSpace(feature) switch normalizedFeature { @@ -348,6 +355,55 @@ func writeLicensePurchaseActivationSuccessPage(w http.ResponseWriter, feature st ) } +func writeLicensePurchaseActivationContinuePage( + w http.ResponseWriter, + sessionID string, + feature string, + returnToken string, +) { + escapedFeature := html.EscapeString(feature) + escapedReturnToken := html.EscapeString(returnToken) + escapedSessionID := html.EscapeString(sessionID) + featureInput := "" + if strings.TrimSpace(feature) != "" { + featureInput = fmt.Sprintf("", escapedFeature) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintf( + w, + "Finalizing Pulse Pro upgrade

Finalizing Pulse Pro upgrade

Pulse is securely finalizing the completed checkout.

%s
", + licensePurchaseActivationPath, + licensePurchaseSessionIDField, + escapedSessionID, + licensePurchaseReturnTokenField, + escapedReturnToken, + featureInput, + ) +} + +func writePublicPurchaseHandoffHeaders(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Cache-Control", "no-store") +} + +func (h *LicenseHandlers) verifiedPurchaseReturnClaims( + r *http.Request, + returnToken string, +) (*purchaseReturnClaimsModel, error) { + expectedHost := purchaseReturnExpectedHost(r, h.cfg) + if expectedHost == "" { + return nil, fmt.Errorf("purchase return expected host is unavailable") + } + signingKey, err := h.purchaseReturnSigningKey() + if err != nil { + return nil, err + } + return verifyPurchaseReturnTokenFromLicensing(returnToken, signingKey, expectedHost, time.Now().UTC()) +} + func normalizeHostForTrial(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { @@ -1301,7 +1357,7 @@ func (h *LicenseHandlers) HandleCheckoutStart(w http.ResponseWriter, r *http.Req return } - destination, err := pulseAccountUpgradeURLForRequest(feature, returnURL, returnToken, r.URL.Query()) + destination, err := pulseAccountUpgradeURLForRequest(returnToken, r.URL.Query()) if err != nil { log.Error().Err(err).Str("feature", feature).Msg("Failed to build Pulse Account upgrade destination") http.Error(w, "Pulse Account handoff unavailable", http.StatusServiceUnavailable) @@ -1311,10 +1367,70 @@ func (h *LicenseHandlers) HandleCheckoutStart(w http.ResponseWriter, r *http.Req http.Redirect(w, r, destination, http.StatusSeeOther) } -// HandleCheckoutActivation handles POST /auth/license-purchase-activate. -// It redeems a completed commercial checkout session into a local activation -// and returns the operator to the canonical billing route. +// HandleCheckoutHandoff verifies the signed checkout handoff and returns the +// canonical Pulse activation URL template for Pulse Account checkout success. +func (h *LicenseHandlers) HandleCheckoutHandoff(w http.ResponseWriter, r *http.Request) { + writePublicPurchaseHandoffHeaders(w) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + returnToken := strings.TrimSpace(r.URL.Query().Get(licensePurchaseReturnTokenField)) + if returnToken == "" { + http.Error(w, "purchase return token is required", http.StatusBadRequest) + return + } + claims, err := h.verifiedPurchaseReturnClaims(r, returnToken) + if err != nil { + log.Warn().Err(err).Msg("Purchase handoff token verification failed") + http.Error(w, "purchase handoff is invalid", http.StatusBadRequest) + return + } + + activationURLTemplate, err := licensePurchaseActivationTemplateURL(claims.ReturnURL, returnToken) + if err != nil { + log.Warn().Err(err).Msg("Purchase handoff activation template build failed") + http.Error(w, "purchase handoff is invalid", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "activation_url_template": activationURLTemplate, + "feature": strings.TrimSpace(claims.Feature), + }) +} + +// HandleCheckoutActivation handles the local checkout return at +// /auth/license-purchase-activate. GET renders the auto-submitting bridge for +// a completed Stripe redirect, while POST redeems the completed session into a +// local activation and returns the operator to the canonical billing route. func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + sessionID := strings.TrimSpace(r.URL.Query().Get(licensePurchaseSessionIDField)) + feature := strings.TrimSpace(r.URL.Query().Get("feature")) + returnToken := strings.TrimSpace(r.URL.Query().Get(licensePurchaseReturnTokenField)) + if returnToken == "" || sessionID == "" { + writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, feature, "Purchase activation link expired or is missing required state. Reopen the upgrade flow from Pulse Pro billing.") + return + } + claims, err := h.verifiedPurchaseReturnClaims(r, returnToken) + if err != nil { + log.Warn().Err(err).Msg("Purchase return token verification failed during GET bridge") + writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, feature, "Purchase activation link expired or is invalid. Reopen the upgrade flow from Pulse Pro billing.") + return + } + if claims != nil && strings.TrimSpace(claims.Feature) != "" { + feature = strings.TrimSpace(claims.Feature) + } + writeLicensePurchaseActivationContinuePage(w, sessionID, feature, returnToken) + return + } if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return @@ -1324,7 +1440,7 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt return } - sessionID := strings.TrimSpace(r.FormValue("session_id")) + sessionID := strings.TrimSpace(r.FormValue(licensePurchaseSessionIDField)) feature := strings.TrimSpace(r.FormValue("feature")) returnToken := strings.TrimSpace(r.FormValue(licensePurchaseReturnTokenField)) if returnToken == "" { @@ -1332,18 +1448,7 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt return } - expectedHost := purchaseReturnExpectedHost(r, h.cfg) - if expectedHost == "" { - writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, "Pulse could not verify the commercial return target for this instance.") - return - } - signingKey, err := h.purchaseReturnSigningKey() - if err != nil { - log.Error().Err(err).Msg("Purchase return signing key unavailable during activation") - writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, "Pulse could not verify the commercial return state for this instance.") - return - } - claims, err := verifyPurchaseReturnTokenFromLicensing(returnToken, signingKey, expectedHost, time.Now().UTC()) + claims, err := h.verifiedPurchaseReturnClaims(r, returnToken) if err != nil { log.Warn().Err(err).Msg("Purchase return token verification failed") writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, feature, "Purchase activation link expired or is invalid. Reopen the upgrade flow from Pulse Pro billing.") diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index fdb593355..b99ef16dc 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -278,6 +278,7 @@ var publicRouteAllowlist = []string{ "/api/auto-register", "/auth/cloud-handoff", "/auth/trial-activate", + licensePurchaseHandoffPath, licensePurchaseActivationPath, "/install.sh", "/install.ps1", @@ -337,6 +338,7 @@ var bareRouteAllowlist = []string{ "/api/clusters/", "/auth/cloud-handoff", "/auth/trial-activate", + licensePurchaseHandoffPath, licensePurchaseActivationPath, "/ws", "/api/saml/", @@ -456,6 +458,7 @@ var allRouteAllowlist = []string{ "PUT /api/upgrade-metrics/config", "GET /api/admin/upgrade-metrics-funnel", "GET /auth/license-purchase-start", + licensePurchaseHandoffPath, "GET /api/orgs", "POST /api/orgs", "GET /api/orgs/{id}", diff --git a/internal/api/router.go b/internal/api/router.go index 6e92fc6d8..c2f34b7d6 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -3481,6 +3481,7 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { "/api/ai/oauth/callback", // OAuth callback from Anthropic for Claude subscription auth "/auth/cloud-handoff", // Cloud control plane handoff (token-authenticated) "/auth/trial-activate", // Hosted trial signup callback (token-authenticated) + "/auth/license-purchase-handoff", // Self-hosted checkout handoff resolution for Pulse Account "/auth/license-purchase-activate", // Self-hosted checkout return (session-authenticated via commercial backend) } diff --git a/internal/api/router_routes_cloud.go b/internal/api/router_routes_cloud.go index b8ca7445f..3c03ab55d 100644 --- a/internal/api/router_routes_cloud.go +++ b/internal/api/router_routes_cloud.go @@ -74,6 +74,8 @@ func (r *Router) registerHostedRoutes(hostedSignupHandlers *HostedSignupHandlers if r.licenseHandlers != nil { // Self-hosted commercial checkout handoff: mint a signed return token and redirect into Pulse Account. r.mux.HandleFunc("GET /auth/license-purchase-start", RequireAuth(routerConfig, r.licenseHandlers.HandleCheckoutStart)) + // Public cross-origin handoff resolver: Pulse Account uses the signed token to recover the verified local activation URL. + r.mux.HandleFunc("/auth/license-purchase-handoff", r.licenseHandlers.HandleCheckoutHandoff) // Hosted trial signup callback: signed token activation flow for self-hosted Pulse Pro trials. r.mux.HandleFunc("/auth/trial-activate", r.licenseHandlers.HandleTrialActivation) // Self-hosted commercial checkout return: complete purchase activation without manual key entry. diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 0db13e5c4..513793d4e 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "c7889cf007edcd132e614ad8c8629d58ec665987b748ea62e4e6589b7e5e4080", + "source_hash": "a9eb5e65c406a16d4470b9296fbd6f2ca7bd54d9bbf37606ed6a6d4b7f6fc1c4", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 92daf51f3..5c3374ee3 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -831,12 +831,13 @@ return { openBillingPanelID: "", upgradeFeatureKey: "", - upgradeReturnURL: "", - upgradeCheckoutSessionID: "", + upgradeInstanceOrigin: "", + upgradePurchaseReturnToken: "", + upgradeActivationURLTemplate: "", + upgradeHandoff: createQueryState(null), upgradeCheckoutStatus: "", upgradePricing: createQueryState(null), upgradeCheckout: createMutationState(), - upgradeCheckoutResult: createQueryState(null), flows: { manage: newVerificationFlowState(), retrieve: newVerificationFlowState(), @@ -1399,12 +1400,6 @@ el.textContent = status.message; el.className = "billing-status visible" + (status.error ? " error" : " success"); } - function formatCheckoutDate(value) { - if (!value) return ""; - var parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) return ""; - return parsed.toLocaleDateString(void 0, { month: "short", day: "numeric", year: "numeric" }); - } function renderUpgradePlansHTML(billingState) { var pricing = billingState.upgradePricing.data; if (!pricing || !Array.isArray(pricing.plans)) { @@ -1418,6 +1413,7 @@ if (plans.length === 0) { return ""; } + var checkoutDisabled = billingState.upgradeCheckout.pending || billingState.upgradeHandoff.status === "loading" || !String(billingState.upgradeActivationURLTemplate || "").trim(); return '
' + plans.map(function(plan) { var buttons = Array.isArray(plan.buttons) ? plan.buttons : []; var checkoutButtons = buttons.filter(function(button) { @@ -1426,19 +1422,18 @@ return '
' + (plan.badge ? '
' + escapeText(plan.badge) + "
" : "") + '
' + escapeText(plan.tierKicker) + "

" + escapeText(plan.title) + '

' + escapeText(plan.price) + '
' + escapeText(plan.period) + '

' + escapeText(plan.blurb) + '

" + (plan.note ? '
' + escapeText(plan.note) + "
" : "") + '
' + checkoutButtons.map(function(button) { - return '"; + return '"; }).join("") + "
"; }).join("") + "
"; } - function renderUpgradePanel(billingState, bootstrap) { + function renderUpgradePanel(billingState, _bootstrap) { var root = getElement3("upgrade-billing-root"); if (!root) return; var featureKey = String(billingState.upgradeFeatureKey || "").trim(); var pricingState = billingState.upgradePricing; - var checkoutResultState = billingState.upgradeCheckoutResult; - var result = checkoutResultState.data; - var returnURL = String(billingState.upgradeReturnURL || "").trim(); - var sessionID = String(billingState.upgradeCheckoutSessionID || "").trim(); + var handoffState = billingState.upgradeHandoff; + var activationURLTemplate = String(billingState.upgradeActivationURLTemplate || "").trim(); + var returnToken = String(billingState.upgradePurchaseReturnToken || "").trim(); var explainer = pricingState.data && pricingState.data.explainer ? pricingState.data.explainer : ""; var summaryItems = []; if (billingState.upgradeCheckoutStatus === "cancelled") { @@ -1450,6 +1445,17 @@ if (billingState.upgradeCheckout.error) { summaryItems.push('
' + escapeText(billingState.upgradeCheckout.error) + "
"); } + if (!returnToken) { + summaryItems.push( + '
Open this upgrade from Pulse Pro billing so Pulse Account can verify the return path before checkout.
' + ); + } else if (handoffState.status === "loading" && !activationURLTemplate) { + summaryItems.push('
Verifying the secure Pulse Pro return path...
'); + } else if (handoffState.status === "error") { + summaryItems.push('
' + escapeText(handoffState.error || "Failed to verify the secure Pulse Pro return path.") + "
"); + } else if (handoffState.status === "ready" && activationURLTemplate) { + summaryItems.push('
Pulse Account will return completed checkout directly to Pulse Pro billing.
'); + } if (pricingState.status === "loading" && !pricingState.data) { summaryItems.push("

Loading self-hosted plan options...

"); } @@ -1461,19 +1467,7 @@ if (explainer) { summaryItems.push('
' + explainer + "
"); } - if (checkoutResultState.status === "loading") { - summaryItems.push('
Finalizing the completed checkout...
'); - } else if (checkoutResultState.status === "error") { - summaryItems.push('
' + escapeText(checkoutResultState.error || "Could not confirm the completed checkout.") + "
"); - } else if (result && result.status === "fulfilled") { - var activationForm = returnURL && sessionID ? '
' : '
Return to Pulse Pro billing to refresh the upgraded entitlement. If automatic activation is unavailable, Pulse Account can still retrieve the latest active license.
'; - summaryItems.push( - '

Checkout complete

Plan
' + escapeText(result.tier || result.plan_key || "Purchased") + '
Purchase email
' + escapeText(result.owner_email || bootstrap.email || "") + '
Activation key
' + escapeText(result.activation_key_prefix || "Issued") + '
Monitored systems
' + escapeText(typeof result.max_monitored_systems === "number" && result.max_monitored_systems > 0 ? String(result.max_monitored_systems) : "Included") + "
" + activationForm + "
" - ); - } else if (result && result.message) { - summaryItems.push('
' + escapeText(result.message) + "
"); - } - root.innerHTML = '
' + summaryItems.join("") + renderUpgradePlansHTML(billingState) + (pricingState.status === "ready" && pricingState.data && pricingState.data.description ? '
' + escapeText(pricingState.data.description) + "
" : "") + (result && result.status === "fulfilled" && formatCheckoutDate(result.current_period_end) ? '
Next renewal: ' + escapeText(formatCheckoutDate(result.current_period_end)) + "
" : "") + "
"; + root.innerHTML = '
' + summaryItems.join("") + renderUpgradePlansHTML(billingState) + (pricingState.status === "ready" && pricingState.data && pricingState.data.description ? '
' + escapeText(pricingState.data.description) + "
" : "") + '
' + (featureKey === "max_monitored_systems" ? "Pulse Account compares self-hosted tiers and sends completed monitored-system upgrades straight back to Pulse Pro billing." : "Pulse Account compares self-hosted tiers and sends completed checkout straight back to Pulse Pro billing.") + "
"; } function renderButton(id, disabled, label) { if (!id || !label) return; @@ -1692,12 +1686,13 @@ var nextState = createPortalBillingState(); billingState.openBillingPanelID = nextState.openBillingPanelID; billingState.upgradeFeatureKey = nextState.upgradeFeatureKey; - billingState.upgradeReturnURL = nextState.upgradeReturnURL; - billingState.upgradeCheckoutSessionID = nextState.upgradeCheckoutSessionID; + billingState.upgradeInstanceOrigin = nextState.upgradeInstanceOrigin; + billingState.upgradePurchaseReturnToken = nextState.upgradePurchaseReturnToken; + billingState.upgradeActivationURLTemplate = nextState.upgradeActivationURLTemplate; + billingState.upgradeHandoff = nextState.upgradeHandoff; billingState.upgradeCheckoutStatus = nextState.upgradeCheckoutStatus; billingState.upgradePricing = nextState.upgradePricing; billingState.upgradeCheckout = nextState.upgradeCheckout; - billingState.upgradeCheckoutResult = nextState.upgradeCheckoutResult; billingState.flows = nextState.flows; billingState.refund = nextState.refund; } @@ -1772,23 +1767,17 @@ return String(store.getBootstrap().portal_path || "/portal"); } } - function buildUpgradeCheckoutReturnURL(status) { + function buildUpgradeCheckoutCancelURL() { var url = new URL(currentPortalBaseURL()); var billingState = getBillingState(); if (billingState.flows.manage.emailValue) { url.searchParams.set("email", billingState.flows.manage.emailValue); } - if (billingState.upgradeFeatureKey) { - url.searchParams.set("feature", billingState.upgradeFeatureKey); - } - if (billingState.upgradeReturnURL) { - url.searchParams.set("return_url", billingState.upgradeReturnURL); + if (billingState.upgradePurchaseReturnToken) { + url.searchParams.set("purchase_return_token", billingState.upgradePurchaseReturnToken); } url.searchParams.set("service", "upgrade"); - url.searchParams.set("checkout", status); - if (status === "success") { - url.searchParams.set("session_id", "{CHECKOUT_SESSION_ID}"); - } + url.searchParams.set("checkout", "cancelled"); return url.toString(); } async function loadUpgradePricing(force) { @@ -1814,39 +1803,78 @@ }); } } - async function resolveCompletedCheckout(force) { + async function resolveUpgradeHandoff(force) { var billingState = getBillingState(); - var sessionID = billingState.upgradeCheckoutSessionID; - if (!sessionID) return; - if (!force && (billingState.upgradeCheckoutResult.status === "loading" || billingState.upgradeCheckoutResult.status === "ready")) { + var returnToken = billingState.upgradePurchaseReturnToken; + if (!returnToken) return; + if (!force && (billingState.upgradeHandoff.status === "loading" || billingState.upgradeHandoff.status === "ready")) { + return; + } + if (!billingState.upgradeInstanceOrigin) { + updateBillingState(function(nextBillingState) { + failQueryState( + nextBillingState.upgradeHandoff, + null, + "Reopen this upgrade from Pulse Pro billing so Pulse Account can verify the return path." + ); + nextBillingState.upgradeActivationURLTemplate = ""; + }); return; } updateBillingState(function(nextBillingState) { - beginQueryState(nextBillingState.upgradeCheckoutResult, null); + beginQueryState(nextBillingState.upgradeHandoff, null); }); try { - var result = await api.getCommercialJSON( - "/v1/checkout/session?session_id=" + encodeURIComponent(sessionID) - ); + var handoffURL = new URL("/auth/license-purchase-handoff", billingState.upgradeInstanceOrigin); + handoffURL.searchParams.set("purchase_return_token", returnToken); + var response = await fetch(handoffURL.toString(), { + headers: { Accept: "application/json" } + }); + if (!response.ok) { + var errorText = ""; + try { + errorText = (await response.text()).trim(); + } catch { + errorText = ""; + } + throw new Error(errorText || "Failed to verify the Pulse Pro upgrade return path."); + } + var result = await response.json(); + var resolvedFeature = String(result.feature || "").trim(); + var activationURLTemplate = String(result.activation_url_template || "").trim(); + if (!activationURLTemplate) { + throw new Error("Pulse Account could not verify the Pulse Pro checkout return path."); + } updateBillingState(function(nextBillingState) { - resolveQueryState(nextBillingState.upgradeCheckoutResult, result); + resolveQueryState(nextBillingState.upgradeHandoff, result); + nextBillingState.upgradeFeatureKey = resolvedFeature; + nextBillingState.upgradeActivationURLTemplate = activationURLTemplate; }); } catch (err) { updateBillingState(function(nextBillingState) { failQueryState( - nextBillingState.upgradeCheckoutResult, + nextBillingState.upgradeHandoff, null, - err instanceof Error ? err.message : "Failed to confirm the completed checkout." + err instanceof Error ? err.message : "Failed to verify the Pulse Pro upgrade return path." ); + nextBillingState.upgradeActivationURLTemplate = ""; }); } } async function startUpgradeCheckout(planKey, tier, billingCycle) { if (!planKey || !tier || !billingCycle) return; + var activationURLTemplate = String(getBillingState().upgradeActivationURLTemplate || "").trim(); + if (!activationURLTemplate) { + updateBillingState(function(nextBillingState) { + failMutationState( + nextBillingState.upgradeCheckout, + "Pulse Account could not verify the secure return path. Reopen the upgrade flow from Pulse Pro billing." + ); + }); + return; + } updateBillingState(function(nextBillingState) { beginMutationState(nextBillingState.upgradeCheckout); - nextBillingState.upgradeCheckoutResult = createPortalBillingState().upgradeCheckoutResult; - nextBillingState.upgradeCheckoutSessionID = ""; nextBillingState.upgradeCheckoutStatus = ""; }); try { @@ -1854,8 +1882,8 @@ plan_key: planKey, tier, billing_cycle: billingCycle, - success_url: buildUpgradeCheckoutReturnURL("success"), - cancel_url: buildUpgradeCheckoutReturnURL("cancelled") + success_url: activationURLTemplate, + cancel_url: buildUpgradeCheckoutCancelURL() }); if (!data || !data.url) { throw new Error("Checkout URL was not returned."); @@ -2153,11 +2181,9 @@ } function renderBillingRuntime() { var billingState = getBillingState(); - if (billingState.openBillingPanelID === "upgrade-billing-panel" || !!billingState.upgradeFeatureKey || billingState.upgradeCheckoutStatus === "success") { + if (billingState.openBillingPanelID === "upgrade-billing-panel" || !!billingState.upgradeFeatureKey || !!billingState.upgradePurchaseReturnToken) { void loadUpgradePricing(false); - } - if (billingState.upgradeCheckoutStatus === "success" && billingState.upgradeCheckoutSessionID) { - void resolveCompletedCheckout(false); + void resolveUpgradeHandoff(false); } renderOpenBillingPanels(getBillingState().openBillingPanelID); renderAllFlows(); @@ -2278,7 +2304,7 @@ } function renderSelfHostedUpgradeBillingPanel(context) { var featureKey = normalizeUpgradeFeatureKey(context.billingState.upgradeFeatureKey); - var helperCopy = featureKey === "max_monitored_systems" ? "Choose the self-hosted tier that matches the monitored-system allowance you need, then send the completed purchase back to Pulse Pro for activation." : "Choose the self-hosted tier that fits this upgrade, then send the completed purchase back to Pulse Pro for activation."; + var helperCopy = featureKey === "max_monitored_systems" ? "Choose the self-hosted tier that matches the monitored-system allowance you need. Pulse Account will send completed checkout directly back to Pulse Pro billing." : "Choose the self-hosted tier that fits this upgrade. Pulse Account will send completed checkout directly back to Pulse Pro billing."; return renderBillingTaskPanel( selfHostedUpgradeActionTitle(featureKey), "Pulse Account owns self-hosted plan selection and checkout for Pulse Pro upgrades.", @@ -2936,6 +2962,15 @@ function normalizeHandoffEmail(value) { return String(value || "").trim(); } + function normalizeHandoffInstanceOrigin(value) { + var trimmed = String(value || "").trim(); + if (!trimmed) return ""; + try { + return new URL(trimmed).origin; + } catch { + return ""; + } + } function normalizeHandoffBillingPanel(value) { switch (String(value || "").trim()) { case "upgrade": @@ -2955,10 +2990,7 @@ function normalizeUpgradeFeatureKey2(value) { return String(value || "").trim(); } - function normalizeUpgradeReturnURL(value) { - return String(value || "").trim(); - } - function normalizeUpgradeCheckoutSessionID(value) { + function normalizeUpgradePurchaseReturnToken(value) { return String(value || "").trim(); } function normalizeUpgradeCheckoutStatus(value) { @@ -2971,24 +3003,24 @@ return ""; } } - function readPortalRuntimeHandoff(locationHref = window.location.href) { + function readPortalRuntimeHandoff(locationHref = window.location.href, referrerHref = typeof document !== "undefined" ? document.referrer : "") { try { var params = new URL(locationHref).searchParams; return { email: normalizeHandoffEmail(params.get("email")), openBillingPanelID: normalizeHandoffBillingPanel(params.get("service")), - upgradeFeatureKey: normalizeUpgradeFeatureKey2(params.get("feature")), - upgradeReturnURL: normalizeUpgradeReturnURL(params.get("return_url")), - upgradeCheckoutSessionID: normalizeUpgradeCheckoutSessionID(params.get("session_id")), + upgradeInstanceOrigin: normalizeHandoffInstanceOrigin(referrerHref), + upgradeFeatureKey: normalizeUpgradeFeatureKey2(params.get("purchase_return_token") ? "" : params.get("feature")), + upgradePurchaseReturnToken: normalizeUpgradePurchaseReturnToken(params.get("purchase_return_token")), upgradeCheckoutStatus: normalizeUpgradeCheckoutStatus(params.get("checkout")) }; } catch { return { email: "", openBillingPanelID: "", + upgradeInstanceOrigin: "", upgradeFeatureKey: "", - upgradeReturnURL: "", - upgradeCheckoutSessionID: "", + upgradePurchaseReturnToken: "", upgradeCheckoutStatus: "" }; } @@ -3028,17 +3060,19 @@ store.setActiveShellSection("billing"); store.updateBillingState(function(billingState) { billingState.openBillingPanelID = handoff.openBillingPanelID; + billingState.upgradeInstanceOrigin = handoff.upgradeInstanceOrigin; billingState.upgradeFeatureKey = handoff.upgradeFeatureKey; - billingState.upgradeReturnURL = handoff.upgradeReturnURL; - billingState.upgradeCheckoutSessionID = handoff.upgradeCheckoutSessionID; + billingState.upgradePurchaseReturnToken = handoff.upgradePurchaseReturnToken; + billingState.upgradeActivationURLTemplate = ""; billingState.upgradeCheckoutStatus = handoff.upgradeCheckoutStatus; }, { notify: false }); - } else if (handoff.upgradeFeatureKey) { + } else if (handoff.upgradeFeatureKey || handoff.upgradePurchaseReturnToken) { store.setActiveShellSection("billing"); store.updateBillingState(function(billingState) { + billingState.upgradeInstanceOrigin = handoff.upgradeInstanceOrigin; billingState.upgradeFeatureKey = handoff.upgradeFeatureKey; - billingState.upgradeReturnURL = handoff.upgradeReturnURL; - billingState.upgradeCheckoutSessionID = handoff.upgradeCheckoutSessionID; + billingState.upgradePurchaseReturnToken = handoff.upgradePurchaseReturnToken; + billingState.upgradeActivationURLTemplate = ""; billingState.upgradeCheckoutStatus = handoff.upgradeCheckoutStatus; }, { notify: false }); } diff --git a/internal/cloudcp/portal/frontend/src/app.test.ts b/internal/cloudcp/portal/frontend/src/app.test.ts index f3b5f3911..e8bdeac28 100644 --- a/internal/cloudcp/portal/frontend/src/app.test.ts +++ b/internal/cloudcp/portal/frontend/src/app.test.ts @@ -150,10 +150,9 @@ describe('portal app', function() { { email: 'buyer@example.com', openBillingPanelID: 'retrieve-billing-panel', + upgradeInstanceOrigin: '', upgradeFeatureKey: '', - upgradeReturnURL: '', upgradePurchaseReturnToken: '', - upgradeCheckoutSessionID: '', upgradeCheckoutStatus: '', } ); diff --git a/internal/cloudcp/portal/frontend/src/billing.ts b/internal/cloudcp/portal/frontend/src/billing.ts index 14e40df83..21157e56b 100644 --- a/internal/cloudcp/portal/frontend/src/billing.ts +++ b/internal/cloudcp/portal/frontend/src/billing.ts @@ -41,7 +41,7 @@ import type { PortalBillingFlowID, PortalBillingState, PortalCheckoutSessionCreateResponse, - PortalCheckoutSessionResult, + PortalUpgradeHandoffModel, PortalUpgradePricingModel, VerificationFlowState, } from './types'; @@ -88,13 +88,13 @@ export function installBillingRuntime(deps: BillingRuntimeDeps): void { var nextState = createPortalBillingState(); billingState.openBillingPanelID = nextState.openBillingPanelID; billingState.upgradeFeatureKey = nextState.upgradeFeatureKey; - billingState.upgradeReturnURL = nextState.upgradeReturnURL; + billingState.upgradeInstanceOrigin = nextState.upgradeInstanceOrigin; billingState.upgradePurchaseReturnToken = nextState.upgradePurchaseReturnToken; - billingState.upgradeCheckoutSessionID = nextState.upgradeCheckoutSessionID; + billingState.upgradeActivationURLTemplate = nextState.upgradeActivationURLTemplate; + billingState.upgradeHandoff = nextState.upgradeHandoff; billingState.upgradeCheckoutStatus = nextState.upgradeCheckoutStatus; billingState.upgradePricing = nextState.upgradePricing; billingState.upgradeCheckout = nextState.upgradeCheckout; - billingState.upgradeCheckoutResult = nextState.upgradeCheckoutResult; billingState.flows = nextState.flows; billingState.refund = nextState.refund; } @@ -180,26 +180,17 @@ export function installBillingRuntime(deps: BillingRuntimeDeps): void { } } - function buildUpgradeCheckoutReturnURL(status: 'success' | 'cancelled'): string { + function buildUpgradeCheckoutCancelURL(): string { var url = new URL(currentPortalBaseURL()); var billingState = getBillingState(); if (billingState.flows.manage.emailValue) { url.searchParams.set('email', billingState.flows.manage.emailValue); } - if (billingState.upgradeFeatureKey) { - url.searchParams.set('feature', billingState.upgradeFeatureKey); - } - if (billingState.upgradeReturnURL) { - url.searchParams.set('return_url', billingState.upgradeReturnURL); - } if (billingState.upgradePurchaseReturnToken) { url.searchParams.set('purchase_return_token', billingState.upgradePurchaseReturnToken); } url.searchParams.set('service', 'upgrade'); - url.searchParams.set('checkout', status); - if (status === 'success') { - url.searchParams.set('session_id', '{CHECKOUT_SESSION_ID}'); - } + url.searchParams.set('checkout', 'cancelled'); return url.toString(); } @@ -227,40 +218,79 @@ export function installBillingRuntime(deps: BillingRuntimeDeps): void { } } - async function resolveCompletedCheckout(force: boolean) { + async function resolveUpgradeHandoff(force: boolean) { var billingState = getBillingState(); - var sessionID = billingState.upgradeCheckoutSessionID; - if (!sessionID) return; - if (!force && (billingState.upgradeCheckoutResult.status === 'loading' || billingState.upgradeCheckoutResult.status === 'ready')) { + var returnToken = billingState.upgradePurchaseReturnToken; + if (!returnToken) return; + if (!force && (billingState.upgradeHandoff.status === 'loading' || billingState.upgradeHandoff.status === 'ready')) { + return; + } + if (!billingState.upgradeInstanceOrigin) { + updateBillingState(function(nextBillingState) { + failQueryState( + nextBillingState.upgradeHandoff, + null, + 'Reopen this upgrade from Pulse Pro billing so Pulse Account can verify the return path.', + ); + nextBillingState.upgradeActivationURLTemplate = ''; + }); return; } updateBillingState(function(nextBillingState) { - beginQueryState(nextBillingState.upgradeCheckoutResult, null); + beginQueryState(nextBillingState.upgradeHandoff, null); }); try { - var result = await api.getCommercialJSON( - '/v1/checkout/session?session_id=' + encodeURIComponent(sessionID), - ); + var handoffURL = new URL('/auth/license-purchase-handoff', billingState.upgradeInstanceOrigin); + handoffURL.searchParams.set('purchase_return_token', returnToken); + var response = await fetch(handoffURL.toString(), { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + var errorText = ''; + try { + errorText = (await response.text()).trim(); + } catch { + errorText = ''; + } + throw new Error(errorText || 'Failed to verify the Pulse Pro upgrade return path.'); + } + var result = await response.json() as PortalUpgradeHandoffModel; + var resolvedFeature = String(result.feature || '').trim(); + var activationURLTemplate = String(result.activation_url_template || '').trim(); + if (!activationURLTemplate) { + throw new Error('Pulse Account could not verify the Pulse Pro checkout return path.'); + } updateBillingState(function(nextBillingState) { - resolveQueryState(nextBillingState.upgradeCheckoutResult, result); + resolveQueryState(nextBillingState.upgradeHandoff, result); + nextBillingState.upgradeFeatureKey = resolvedFeature; + nextBillingState.upgradeActivationURLTemplate = activationURLTemplate; }); } catch (err) { updateBillingState(function(nextBillingState) { failQueryState( - nextBillingState.upgradeCheckoutResult, + nextBillingState.upgradeHandoff, null, - err instanceof Error ? err.message : 'Failed to confirm the completed checkout.', + err instanceof Error ? err.message : 'Failed to verify the Pulse Pro upgrade return path.', ); + nextBillingState.upgradeActivationURLTemplate = ''; }); } } async function startUpgradeCheckout(planKey: string, tier: string, billingCycle: string) { if (!planKey || !tier || !billingCycle) return; + var activationURLTemplate = String(getBillingState().upgradeActivationURLTemplate || '').trim(); + if (!activationURLTemplate) { + updateBillingState(function(nextBillingState) { + failMutationState( + nextBillingState.upgradeCheckout, + 'Pulse Account could not verify the secure return path. Reopen the upgrade flow from Pulse Pro billing.', + ); + }); + return; + } updateBillingState(function(nextBillingState) { beginMutationState(nextBillingState.upgradeCheckout); - nextBillingState.upgradeCheckoutResult = createPortalBillingState().upgradeCheckoutResult; - nextBillingState.upgradeCheckoutSessionID = ''; nextBillingState.upgradeCheckoutStatus = ''; }); try { @@ -268,8 +298,8 @@ export function installBillingRuntime(deps: BillingRuntimeDeps): void { plan_key: planKey, tier: tier, billing_cycle: billingCycle, - success_url: buildUpgradeCheckoutReturnURL('success'), - cancel_url: buildUpgradeCheckoutReturnURL('cancelled'), + success_url: activationURLTemplate, + cancel_url: buildUpgradeCheckoutCancelURL(), }); if (!data || !data.url) { throw new Error('Checkout URL was not returned.'); @@ -576,12 +606,10 @@ export function installBillingRuntime(deps: BillingRuntimeDeps): void { if ( billingState.openBillingPanelID === 'upgrade-billing-panel' || !!billingState.upgradeFeatureKey || - billingState.upgradeCheckoutStatus === 'success' + !!billingState.upgradePurchaseReturnToken ) { void loadUpgradePricing(false); - } - if (billingState.upgradeCheckoutStatus === 'success' && billingState.upgradeCheckoutSessionID) { - void resolveCompletedCheckout(false); + void resolveUpgradeHandoff(false); } renderOpenBillingPanels(getBillingState().openBillingPanelID); renderAllFlows(); diff --git a/internal/cloudcp/portal/frontend/src/billing_view.test.ts b/internal/cloudcp/portal/frontend/src/billing_view.test.ts index 269c77437..8f5deb328 100644 --- a/internal/cloudcp/portal/frontend/src/billing_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/billing_view.test.ts @@ -223,14 +223,18 @@ describe('services view', function() { expect(document.getElementById('manage-inline-status')?.textContent).toBe('Code sent.'); }); - it('renders upgrade panel with canonical plans and a return-to-product activation form', function() { + it('renders upgrade panel with verified direct-return checkout actions', function() { document.body.innerHTML = '
'; var billingState = createPortalBillingState(); billingState.upgradeFeatureKey = 'max_monitored_systems'; - billingState.upgradeReturnURL = 'https://pulse.example.com/auth/license-purchase-activate'; billingState.upgradePurchaseReturnToken = 'prt_signed'; - billingState.upgradeCheckoutSessionID = 'cs_success'; + billingState.upgradeActivationURLTemplate = 'https://pulse.example.com/auth/license-purchase-activate?purchase_return_token=prt_signed&session_id={CHECKOUT_SESSION_ID}'; + billingState.upgradeHandoff.status = 'ready'; + billingState.upgradeHandoff.data = { + feature: 'max_monitored_systems', + activation_url_template: billingState.upgradeActivationURLTemplate, + }; billingState.upgradePricing.status = 'ready'; billingState.upgradePricing.data = { title: 'Pricing', @@ -257,45 +261,60 @@ describe('services view', function() { }, ], }; - billingState.upgradeCheckoutResult.status = 'ready'; - billingState.upgradeCheckoutResult.data = { - status: 'fulfilled', - owner_email: 'buyer@example.com', - tier: 'pro_plus', - activation_key_prefix: 'ppk_live_preview', - max_monitored_systems: 50, - }; renderUpgradePanel(billingState, createBootstrap()); expect(document.getElementById('upgrade-billing-root')?.innerHTML).toContain('Buy Annual'); - expect(document.getElementById('upgrade-billing-root')?.innerHTML).toContain('Activate in Pulse Pro'); - expect(document.getElementById('upgrade-billing-root')?.innerHTML).toContain('session_id'); expect(document.getElementById('upgrade-billing-root')?.innerHTML).toContain( - 'purchase_return_token', + 'Pulse Account will return completed checkout directly to Pulse Pro billing.', ); - expect(document.getElementById('upgrade-billing-root')?.innerHTML).toContain('ppk_live_preview'); + expect(document.getElementById('upgrade-billing-root')?.innerHTML).not.toContain('Activate in Pulse Pro'); + expect(document.getElementById('upgrade-billing-root')?.innerHTML).not.toContain('ppk_live_preview'); + expect( + (document.querySelector('[data-account-billing-action="upgrade-start-checkout"]') as HTMLButtonElement).disabled, + ).toBe(false); }); - it('renders a product-refresh fallback when a completed checkout has no return URL', function() { + it('renders a blocked checkout state until the Pulse Pro handoff is verified', function() { document.body.innerHTML = '
'; var billingState = createPortalBillingState(); - billingState.upgradeFeatureKey = 'max_monitored_systems'; - billingState.upgradeCheckoutSessionID = 'cs_success'; - billingState.upgradeCheckoutResult.status = 'ready'; - billingState.upgradeCheckoutResult.data = { - status: 'fulfilled', - owner_email: 'buyer@example.com', - tier: 'pro_plus', - activation_key_prefix: 'ppk_live_preview', - max_monitored_systems: 50, + billingState.upgradePurchaseReturnToken = 'prt_signed'; + billingState.upgradeHandoff.status = 'error'; + billingState.upgradeHandoff.error = 'Pulse Account could not verify the secure return path.'; + billingState.upgradePricing.status = 'ready'; + billingState.upgradePricing.data = { + title: 'Pricing', + description: 'Canonical pricing model', + plans: [ + { + tierKicker: 'Relay', + title: 'Relay', + price: '$4.99', + period: '$39/year available too', + blurb: 'Secure remote access and mobile access.', + features: [{ tone: 'check', html: 'Up to 8 monitored systems' }], + buttons: [ + { + kind: 'checkout', + className: 'btn btn-primary', + tier: 'relay', + planKey: 'price_relay_annual', + billingCycle: 'annual', + label: 'Buy Annual', + }, + ], + }, + ], }; renderUpgradePanel(billingState, createBootstrap()); expect(document.getElementById('upgrade-billing-root')?.innerHTML).toContain( - 'Return to Pulse Pro billing and reopen the upgrade flow', + 'Pulse Account could not verify the secure return path.', ); + expect( + (document.querySelector('[data-account-billing-action="upgrade-start-checkout"]') as HTMLButtonElement).disabled, + ).toBe(true); }); }); diff --git a/internal/cloudcp/portal/frontend/src/billing_view.ts b/internal/cloudcp/portal/frontend/src/billing_view.ts index ef4711b42..40761f8a6 100644 --- a/internal/cloudcp/portal/frontend/src/billing_view.ts +++ b/internal/cloudcp/portal/frontend/src/billing_view.ts @@ -73,13 +73,6 @@ export function renderBillingStatus(id: string, status: BillingStatus): void { el.className = 'billing-status visible' + (status.error ? ' error' : ' success'); } -function formatCheckoutDate(value: string | undefined): string { - if (!value) return ''; - var parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) return ''; - return parsed.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); -} - function renderUpgradePlansHTML(billingState: PortalBillingState): string { var pricing = billingState.upgradePricing.data; if (!pricing || !Array.isArray(pricing.plans)) { @@ -95,6 +88,10 @@ function renderUpgradePlansHTML(billingState: PortalBillingState): string { return ''; } + var checkoutDisabled = billingState.upgradeCheckout.pending || + billingState.upgradeHandoff.status === 'loading' || + !String(billingState.upgradeActivationURLTemplate || '').trim(); + return '
' + plans.map(function(plan) { var buttons = Array.isArray(plan.buttons) ? plan.buttons : []; var checkoutButtons = buttons.filter(function(button) { @@ -125,7 +122,7 @@ function renderUpgradePlansHTML(billingState: PortalBillingState): string { ' data-upgrade-plan-key="' + escapeAttribute(button.planKey || '') + '"' + ' data-upgrade-tier="' + escapeAttribute(button.tier || '') + '"' + ' data-upgrade-billing-cycle="' + escapeAttribute(button.billingCycle || '') + '"' + - (billingState.upgradeCheckout.pending ? ' disabled' : '') + + (checkoutDisabled ? ' disabled' : '') + '>' + escapeText(button.label) + '' @@ -136,17 +133,15 @@ function renderUpgradePlansHTML(billingState: PortalBillingState): string { }).join('') + '
'; } -export function renderUpgradePanel(billingState: PortalBillingState, bootstrap: PortalBootstrapData): void { +export function renderUpgradePanel(billingState: PortalBillingState, _bootstrap: PortalBootstrapData): void { var root = getElement('upgrade-billing-root'); if (!root) return; var featureKey = String(billingState.upgradeFeatureKey || '').trim(); var pricingState = billingState.upgradePricing; - var checkoutResultState = billingState.upgradeCheckoutResult; - var result = checkoutResultState.data; - var returnURL = String(billingState.upgradeReturnURL || '').trim(); + var handoffState = billingState.upgradeHandoff; + var activationURLTemplate = String(billingState.upgradeActivationURLTemplate || '').trim(); var returnToken = String(billingState.upgradePurchaseReturnToken || '').trim(); - var sessionID = String(billingState.upgradeCheckoutSessionID || '').trim(); var explainer = pricingState.data && pricingState.data.explainer ? pricingState.data.explainer : ''; var summaryItems = [] as string[]; @@ -159,6 +154,17 @@ export function renderUpgradePanel(billingState: PortalBillingState, bootstrap: if (billingState.upgradeCheckout.error) { summaryItems.push('
' + escapeText(billingState.upgradeCheckout.error) + '
'); } + if (!returnToken) { + summaryItems.push( + '
Open this upgrade from Pulse Pro billing so Pulse Account can verify the return path before checkout.
', + ); + } else if (handoffState.status === 'loading' && !activationURLTemplate) { + summaryItems.push('
Verifying the secure Pulse Pro return path...
'); + } else if (handoffState.status === 'error') { + summaryItems.push('
' + escapeText(handoffState.error || 'Failed to verify the secure Pulse Pro return path.') + '
'); + } else if (handoffState.status === 'ready' && activationURLTemplate) { + summaryItems.push('
Pulse Account will return completed checkout directly to Pulse Pro billing.
'); + } if (pricingState.status === 'loading' && !pricingState.data) { summaryItems.push('

Loading self-hosted plan options...

'); } @@ -172,39 +178,6 @@ export function renderUpgradePanel(billingState: PortalBillingState, bootstrap: summaryItems.push('
' + explainer + '
'); } - if (checkoutResultState.status === 'loading') { - summaryItems.push('
Finalizing the completed checkout...
'); - } else if (checkoutResultState.status === 'error') { - summaryItems.push('
' + escapeText(checkoutResultState.error || 'Could not confirm the completed checkout.') + '
'); - } else if (result && result.status === 'fulfilled') { - var activationForm = returnURL && sessionID && returnToken - ? ( - '
' + - '' + - '' + - '' + - '
' + - '' + - '
' + - '
' - ) - : '
Return to Pulse Pro billing and reopen the upgrade flow so Pulse can securely finalize this completed checkout. If automatic return is unavailable, Pulse Account can still retrieve the latest active license.
'; - summaryItems.push( - '
' + - '

Checkout complete

' + - '
' + - '
Plan
' + escapeText(result.tier || result.plan_key || 'Purchased') + '
' + - '
Purchase email
' + escapeText(result.owner_email || bootstrap.email || '') + '
' + - '
Activation key
' + escapeText(result.activation_key_prefix || 'Issued') + '
' + - '
Monitored systems
' + escapeText(typeof result.max_monitored_systems === 'number' && result.max_monitored_systems > 0 ? String(result.max_monitored_systems) : 'Included') + '
' + - '
' + - activationForm + - '
', - ); - } else if (result && result.message) { - summaryItems.push('
' + escapeText(result.message) + '
'); - } - root.innerHTML = '
' + summaryItems.join('') + @@ -212,9 +185,11 @@ export function renderUpgradePanel(billingState: PortalBillingState, bootstrap: (pricingState.status === 'ready' && pricingState.data && pricingState.data.description ? '
' + escapeText(pricingState.data.description) + '
' : '') + - (result && result.status === 'fulfilled' && formatCheckoutDate(result.current_period_end) - ? '
Next renewal: ' + escapeText(formatCheckoutDate(result.current_period_end)) + '
' - : '') + + '
' + + (featureKey === 'max_monitored_systems' + ? 'Pulse Account compares self-hosted tiers and sends completed monitored-system upgrades straight back to Pulse Pro billing.' + : 'Pulse Account compares self-hosted tiers and sends completed checkout straight back to Pulse Pro billing.') + + '
' + '
'; } diff --git a/internal/cloudcp/portal/frontend/src/runtime.test.ts b/internal/cloudcp/portal/frontend/src/runtime.test.ts index cf5343bc5..fdb42295e 100644 --- a/internal/cloudcp/portal/frontend/src/runtime.test.ts +++ b/internal/cloudcp/portal/frontend/src/runtime.test.ts @@ -63,15 +63,17 @@ describe('portal runtime', function() { }); it('derives canonical email and billing handoff from the portal URL', function() { - var handoff = readPortalRuntimeHandoff('https://cloud.pulserelay.pro/portal?email=buyer%40example.com&service=upgrade&feature=max_monitored_systems&return_url=https%3A%2F%2Fpulse.example.com%2Fauth%2Flicense-purchase-activate&purchase_return_token=prt_signed&checkout=success&session_id=cs_success'); + var handoff = readPortalRuntimeHandoff( + 'https://cloud.pulserelay.pro/portal?email=buyer%40example.com&service=upgrade&purchase_return_token=prt_signed&checkout=cancelled', + 'https://pulse.example.com/auth/license-purchase-start?feature=max_monitored_systems', + ); expect(handoff.email).toBe('buyer@example.com'); expect(handoff.openBillingPanelID).toBe('upgrade-billing-panel'); - expect(handoff.upgradeFeatureKey).toBe('max_monitored_systems'); - expect(handoff.upgradeReturnURL).toBe('https://pulse.example.com/auth/license-purchase-activate'); + expect(handoff.upgradeInstanceOrigin).toBe('https://pulse.example.com'); + expect(handoff.upgradeFeatureKey).toBe(''); expect(handoff.upgradePurchaseReturnToken).toBe('prt_signed'); - expect(handoff.upgradeCheckoutStatus).toBe('success'); - expect(handoff.upgradeCheckoutSessionID).toBe('cs_success'); + expect(handoff.upgradeCheckoutStatus).toBe('cancelled'); }); it('applies email and billing handoff to the initial portal store', function() { @@ -85,10 +87,9 @@ describe('portal runtime', function() { { email: 'buyer@example.com', openBillingPanelID: 'refund-billing-panel', + upgradeInstanceOrigin: '', upgradeFeatureKey: '', - upgradeReturnURL: '', upgradePurchaseReturnToken: '', - upgradeCheckoutSessionID: '', upgradeCheckoutStatus: '', } ); @@ -110,22 +111,18 @@ describe('portal runtime', function() { { email: '', openBillingPanelID: 'upgrade-billing-panel', - upgradeFeatureKey: 'max_monitored_systems', - upgradeReturnURL: 'https://pulse.example.com/auth/license-purchase-activate', + upgradeInstanceOrigin: 'https://pulse.example.com', + upgradeFeatureKey: '', upgradePurchaseReturnToken: 'prt_signed', - upgradeCheckoutSessionID: 'cs_success', - upgradeCheckoutStatus: 'success', + upgradeCheckoutStatus: 'cancelled', } ); expect(runtime.store.getShellState().activeSection).toBe('billing'); expect(runtime.store.getBillingState().openBillingPanelID).toBe('upgrade-billing-panel'); - expect(runtime.store.getBillingState().upgradeFeatureKey).toBe('max_monitored_systems'); - expect(runtime.store.getBillingState().upgradeReturnURL).toBe( - 'https://pulse.example.com/auth/license-purchase-activate', - ); + expect(runtime.store.getBillingState().upgradeInstanceOrigin).toBe('https://pulse.example.com'); + expect(runtime.store.getBillingState().upgradeFeatureKey).toBe(''); expect(runtime.store.getBillingState().upgradePurchaseReturnToken).toBe('prt_signed'); - expect(runtime.store.getBillingState().upgradeCheckoutSessionID).toBe('cs_success'); - expect(runtime.store.getBillingState().upgradeCheckoutStatus).toBe('success'); + expect(runtime.store.getBillingState().upgradeCheckoutStatus).toBe('cancelled'); }); }); diff --git a/internal/cloudcp/portal/frontend/src/runtime.ts b/internal/cloudcp/portal/frontend/src/runtime.ts index c60d3554f..18c582f3f 100644 --- a/internal/cloudcp/portal/frontend/src/runtime.ts +++ b/internal/cloudcp/portal/frontend/src/runtime.ts @@ -5,10 +5,9 @@ import type { PortalBootstrapData } from './types'; export interface PortalRuntimeHandoff { email: string; openBillingPanelID: string; + upgradeInstanceOrigin: string; upgradeFeatureKey: string; - upgradeReturnURL: string; upgradePurchaseReturnToken: string; - upgradeCheckoutSessionID: string; upgradeCheckoutStatus: '' | 'success' | 'cancelled'; } @@ -35,6 +34,18 @@ function normalizeHandoffEmail(value: string | null): string { return String(value || '').trim(); } +function normalizeHandoffInstanceOrigin( + value: string | null | undefined, +): string { + var trimmed = String(value || '').trim(); + if (!trimmed) return ''; + try { + return new URL(trimmed).origin; + } catch { + return ''; + } +} + function normalizeHandoffBillingPanel(value: string | null): string { switch (String(value || '').trim()) { case 'upgrade': @@ -56,18 +67,10 @@ function normalizeUpgradeFeatureKey(value: string | null): string { return String(value || '').trim(); } -function normalizeUpgradeReturnURL(value: string | null): string { - return String(value || '').trim(); -} - function normalizeUpgradePurchaseReturnToken(value: string | null): string { return String(value || '').trim(); } -function normalizeUpgradeCheckoutSessionID(value: string | null): string { - return String(value || '').trim(); -} - function normalizeUpgradeCheckoutStatus(value: string | null): '' | 'success' | 'cancelled' { switch (String(value || '').trim()) { case 'success': @@ -79,26 +82,27 @@ function normalizeUpgradeCheckoutStatus(value: string | null): '' | 'success' | } } -export function readPortalRuntimeHandoff(locationHref: string | undefined = window.location.href): PortalRuntimeHandoff { +export function readPortalRuntimeHandoff( + locationHref: string | undefined = window.location.href, + referrerHref: string | undefined = typeof document !== 'undefined' ? document.referrer : '', +): PortalRuntimeHandoff { try { var params = new URL(locationHref).searchParams; return { email: normalizeHandoffEmail(params.get('email')), openBillingPanelID: normalizeHandoffBillingPanel(params.get('service')), - upgradeFeatureKey: normalizeUpgradeFeatureKey(params.get('feature')), - upgradeReturnURL: normalizeUpgradeReturnURL(params.get('return_url')), + upgradeInstanceOrigin: normalizeHandoffInstanceOrigin(referrerHref), + upgradeFeatureKey: normalizeUpgradeFeatureKey(params.get('purchase_return_token') ? '' : params.get('feature')), upgradePurchaseReturnToken: normalizeUpgradePurchaseReturnToken(params.get('purchase_return_token')), - upgradeCheckoutSessionID: normalizeUpgradeCheckoutSessionID(params.get('session_id')), upgradeCheckoutStatus: normalizeUpgradeCheckoutStatus(params.get('checkout')), }; } catch { return { email: '', openBillingPanelID: '', + upgradeInstanceOrigin: '', upgradeFeatureKey: '', - upgradeReturnURL: '', upgradePurchaseReturnToken: '', - upgradeCheckoutSessionID: '', upgradeCheckoutStatus: '', }; } @@ -143,25 +147,25 @@ export function createPortalRuntime( billingState.refund.emailValue = handoff.email; }, { notify: false }); } - if (handoff.openBillingPanelID) { - store.setActiveShellSection('billing'); - store.updateBillingState(function(billingState) { - billingState.openBillingPanelID = handoff.openBillingPanelID; - billingState.upgradeFeatureKey = handoff.upgradeFeatureKey; - billingState.upgradeReturnURL = handoff.upgradeReturnURL; - billingState.upgradePurchaseReturnToken = handoff.upgradePurchaseReturnToken; - billingState.upgradeCheckoutSessionID = handoff.upgradeCheckoutSessionID; - billingState.upgradeCheckoutStatus = handoff.upgradeCheckoutStatus; - }, { notify: false }); - } else if (handoff.upgradeFeatureKey) { - store.setActiveShellSection('billing'); - store.updateBillingState(function(billingState) { - billingState.upgradeFeatureKey = handoff.upgradeFeatureKey; - billingState.upgradeReturnURL = handoff.upgradeReturnURL; - billingState.upgradePurchaseReturnToken = handoff.upgradePurchaseReturnToken; - billingState.upgradeCheckoutSessionID = handoff.upgradeCheckoutSessionID; - billingState.upgradeCheckoutStatus = handoff.upgradeCheckoutStatus; - }, { notify: false }); + if (handoff.openBillingPanelID) { + store.setActiveShellSection('billing'); + store.updateBillingState(function(billingState) { + billingState.openBillingPanelID = handoff.openBillingPanelID; + billingState.upgradeInstanceOrigin = handoff.upgradeInstanceOrigin; + billingState.upgradeFeatureKey = handoff.upgradeFeatureKey; + billingState.upgradePurchaseReturnToken = handoff.upgradePurchaseReturnToken; + billingState.upgradeActivationURLTemplate = ''; + billingState.upgradeCheckoutStatus = handoff.upgradeCheckoutStatus; + }, { notify: false }); + } else if (handoff.upgradeFeatureKey || handoff.upgradePurchaseReturnToken) { + store.setActiveShellSection('billing'); + store.updateBillingState(function(billingState) { + billingState.upgradeInstanceOrigin = handoff.upgradeInstanceOrigin; + billingState.upgradeFeatureKey = handoff.upgradeFeatureKey; + billingState.upgradePurchaseReturnToken = handoff.upgradePurchaseReturnToken; + billingState.upgradeActivationURLTemplate = ''; + billingState.upgradeCheckoutStatus = handoff.upgradeCheckoutStatus; + }, { notify: false }); } return { bootstrapDefaults: bootstrapDefaults, diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 4bcddd343..e8067949b 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -168,8 +168,8 @@ function renderSelfHostedUpgradeActionRow(context: ShellViewContext): string { function renderSelfHostedUpgradeBillingPanel(context: ShellViewContext): string { var featureKey = normalizeUpgradeFeatureKey(context.billingState.upgradeFeatureKey); var helperCopy = featureKey === 'max_monitored_systems' - ? 'Choose the self-hosted tier that matches the monitored-system allowance you need, then send the completed purchase back to Pulse Pro for activation.' - : 'Choose the self-hosted tier that fits this upgrade, then send the completed purchase back to Pulse Pro for activation.'; + ? 'Choose the self-hosted tier that matches the monitored-system allowance you need. Pulse Account will send completed checkout directly back to Pulse Pro billing.' + : 'Choose the self-hosted tier that fits this upgrade. Pulse Account will send completed checkout directly back to Pulse Pro billing.'; return renderBillingTaskPanel( selfHostedUpgradeActionTitle(featureKey), 'Pulse Account owns self-hosted plan selection and checkout for Pulse Pro upgrades.', diff --git a/internal/cloudcp/portal/frontend/src/state.test.ts b/internal/cloudcp/portal/frontend/src/state.test.ts index bc15bc4e1..0c3b0bed4 100644 --- a/internal/cloudcp/portal/frontend/src/state.test.ts +++ b/internal/cloudcp/portal/frontend/src/state.test.ts @@ -35,12 +35,12 @@ describe('portal state', function() { expect(billingState.flows.manage.confirm.pending).toBe(false); expect(billingState.refund.submit.pending).toBe(false); expect(billingState.upgradeFeatureKey).toBe(''); - expect(billingState.upgradeReturnURL).toBe(''); + expect(billingState.upgradeInstanceOrigin).toBe(''); expect(billingState.upgradePurchaseReturnToken).toBe(''); - expect(billingState.upgradeCheckoutSessionID).toBe(''); + expect(billingState.upgradeActivationURLTemplate).toBe(''); + expect(billingState.upgradeHandoff.status).toBe('idle'); expect(billingState.upgradeCheckoutStatus).toBe(''); expect(billingState.upgradePricing.status).toBe('idle'); - expect(billingState.upgradeCheckoutResult.status).toBe('idle'); billingState.flows.manage.emailValue = 'override@example.com'; syncBillingStateBootstrapEmail(billingState, 'owner@example.com'); diff --git a/internal/cloudcp/portal/frontend/src/state.ts b/internal/cloudcp/portal/frontend/src/state.ts index 81242534e..f45de9acf 100644 --- a/internal/cloudcp/portal/frontend/src/state.ts +++ b/internal/cloudcp/portal/frontend/src/state.ts @@ -118,13 +118,13 @@ export function createPortalBillingState(): PortalBillingState { return { openBillingPanelID: '', upgradeFeatureKey: '', - upgradeReturnURL: '', + upgradeInstanceOrigin: '', upgradePurchaseReturnToken: '', - upgradeCheckoutSessionID: '', + upgradeActivationURLTemplate: '', + upgradeHandoff: createQueryState(null), upgradeCheckoutStatus: '', upgradePricing: createQueryState(null), upgradeCheckout: createMutationState(), - upgradeCheckoutResult: createQueryState(null), flows: { manage: newVerificationFlowState(), retrieve: newVerificationFlowState(), diff --git a/internal/cloudcp/portal/frontend/src/types.ts b/internal/cloudcp/portal/frontend/src/types.ts index 703bbd688..d9e7608c4 100644 --- a/internal/cloudcp/portal/frontend/src/types.ts +++ b/internal/cloudcp/portal/frontend/src/types.ts @@ -79,6 +79,11 @@ export interface PortalUpgradePricingModel { plans: PortalUpgradePricingPlan[]; } +export interface PortalUpgradeHandoffModel { + feature?: string; + activation_url_template?: string; +} + export type PortalUpgradeCheckoutStatus = '' | 'success' | 'cancelled'; export interface PortalCheckoutSessionCreateResponse { @@ -88,19 +93,6 @@ export interface PortalCheckoutSessionCreateResponse { billing_cycle?: string; } -export interface PortalCheckoutSessionResult { - status?: string; - message?: string; - checkout_status?: string; - payment_status?: string; - owner_email?: string; - tier?: string; - plan_key?: string; - activation_key_prefix?: string; - max_monitored_systems?: number; - current_period_end?: string; -} - export interface PortalLoginState { emailValue: string; request: PortalMutationState; @@ -173,13 +165,13 @@ export interface RefundState { export interface PortalBillingState { openBillingPanelID: string; upgradeFeatureKey: string; - upgradeReturnURL: string; + upgradeInstanceOrigin: string; upgradePurchaseReturnToken: string; - upgradeCheckoutSessionID: string; + upgradeActivationURLTemplate: string; + upgradeHandoff: PortalQueryState; upgradeCheckoutStatus: PortalUpgradeCheckoutStatus; upgradePricing: PortalQueryState; upgradeCheckout: PortalMutationState; - upgradeCheckoutResult: PortalQueryState; flows: Record; refund: RefundState; } diff --git a/tests/integration/tests/55-self-hosted-upgrade-return.spec.ts b/tests/integration/tests/55-self-hosted-upgrade-return.spec.ts index 946dc7fa2..bd40010de 100644 --- a/tests/integration/tests/55-self-hosted-upgrade-return.spec.ts +++ b/tests/integration/tests/55-self-hosted-upgrade-return.spec.ts @@ -168,8 +168,7 @@ test.describe('Self-hosted upgrade return flow', () => { status: 303, headers: { location: - `${PULSE_ACCOUNT_PORTAL_URL}?feature=max_monitored_systems&service=upgrade` + - `&return_url=${encodeURIComponent(PURCHASE_RETURN_URL)}` + + `${PULSE_ACCOUNT_PORTAL_URL}?service=upgrade` + `&purchase_return_token=${encodeURIComponent(PURCHASE_RETURN_TOKEN)}`, }, body: '', @@ -178,9 +177,9 @@ test.describe('Self-hosted upgrade return flow', () => { await context.route(`${PULSE_ACCOUNT_PORTAL_URL}**`, async (route) => { const requestUrl = new URL(route.request().url()); - expect(requestUrl.searchParams.get('feature')).toBe('max_monitored_systems'); expect(requestUrl.searchParams.get('service')).toBe('upgrade'); - expect(requestUrl.searchParams.get('return_url')).toBe(PURCHASE_RETURN_URL); + expect(requestUrl.searchParams.get('feature')).toBeNull(); + expect(requestUrl.searchParams.get('return_url')).toBeNull(); expect(requestUrl.searchParams.get('purchase_return_token')).toBe(PURCHASE_RETURN_TOKEN); await route.fulfill({ @@ -189,22 +188,39 @@ test.describe('Self-hosted upgrade return flow', () => { body: '' + '

Pulse Account

' + - '

Checkout complete.

' + - `
` + - '' + - '' + - `` + - '' + - '
' + + '

Checkout complete. Returning to Pulse Pro.

' + + `` + '', }); }); - await context.route(PURCHASE_RETURN_URL, async (route) => { - expect(route.request().method()).toBe('POST'); - const formData = new URLSearchParams(route.request().postData() || ''); + await context.route(`${PURCHASE_RETURN_URL}**`, async (route) => { + const request = route.request(); + if (request.method() === 'GET') { + const requestUrl = new URL(request.url()); + expect(requestUrl.searchParams.get('session_id')).toBe('cs_upgrade_return'); + expect(requestUrl.searchParams.get('purchase_return_token')).toBe(PURCHASE_RETURN_TOKEN); + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: + '' + + '

Finalizing Pulse Pro upgrade

' + + `
` + + '' + + `` + + '
' + + '' + + '', + }); + return; + } + + expect(request.method()).toBe('POST'); + const formData = new URLSearchParams(request.postData() || ''); expect(formData.get('session_id')).toBe('cs_upgrade_return'); - expect(formData.get('feature')).toBe('max_monitored_systems'); expect(formData.get('purchase_return_token')).toBe(PURCHASE_RETURN_TOKEN); await route.fulfill({ status: 200, @@ -232,22 +248,14 @@ test.describe('Self-hosted upgrade return flow', () => { ); await page.goto( - `${PULSE_ACCOUNT_PORTAL_URL}?feature=max_monitored_systems&service=upgrade` + - `&return_url=${encodeURIComponent(PURCHASE_RETURN_URL)}` + - `&purchase_return_token=${encodeURIComponent(PURCHASE_RETURN_TOKEN)}`, + `${PULSE_ACCOUNT_PORTAL_URL}?service=upgrade&purchase_return_token=${encodeURIComponent(PURCHASE_RETURN_TOKEN)}`, { waitUntil: 'domcontentloaded', }, ); - await expect(page).toHaveURL( - new RegExp( - `${PULSE_ACCOUNT_PORTAL_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*purchase_return_token=${PURCHASE_RETURN_TOKEN}`, - ), - ); await expect(page.getByRole('heading', { name: 'Pulse Account' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Activate in Pulse Pro' })).toBeVisible(); - - await page.getByRole('button', { name: 'Activate in Pulse Pro' }).click(); + await expect(page.getByText('Checkout complete. Returning to Pulse Pro.')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Activate in Pulse Pro' })).toHaveCount(0); await expect(page).toHaveURL(/\/settings\/system\/billing\/plan\?intent=max_monitored_systems$/); }); });