diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index be93adc24..7ff93c68e 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -592,7 +592,13 @@ that same `portal_handoff_id` into Pulse's activation callback, and Pulse must compare both `portal_handoff_id` and `purchase_return_jti` against the commercial checkout-session result before redeeming the activation key, so browser form/query state and Stripe metadata alone never become the source of -truth for a completed self-hosted upgrade. That same owned contract also +truth for a completed self-hosted upgrade. Once that commercial binding +verifies, Pulse's owned callback must persist a dedicated local +purchase-return redemption record keyed by `portal_handoff_id` plus +`purchase_return_jti`, use explicit local redemption state +(`started`, `activated`, `failed`) instead of a generic replay tombstone, and +allow retry only from owned failed state rather than by deleting the local +binding outright. That same owned contract also retires the old compatibility bootstrap surfaces: Pulse must not expose a separate public `GET /auth/license-purchase-handoff` resolver, and the commercial server must diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 5e0537e5a..d9e623547 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -497,6 +497,13 @@ resolved, started, and completed. Stripe success must carry that same cross-check both `portal_handoff_id` and `purchase_return_jti` against the commercial session result before local activation so the browser no longer trusts Stripe metadata or local form state alone for return integrity. +Once that commercial binding verifies, Pulse must not fall back to a generic +JTI replay tombstone. The local activation callback must persist a dedicated +purchase-return redemption record keyed by `portal_handoff_id` plus +`purchase_return_jti`, stamp explicit local redemption state +(`started`, `activated`, `failed`), and use that owned record to make +completed returns idempotent while still allowing retry after transient local +activation failures. Stripe success now lands on Pulse's public `frontend-modern/src/utils/pricingHandoff.ts` and `frontend-modern/src/pages/PricingHandoff.tsx` may only hand operators into diff --git a/internal/api/license_handlers_test.go b/internal/api/license_handlers_test.go index dcc43a921..b8584005f 100644 --- a/internal/api/license_handlers_test.go +++ b/internal/api/license_handlers_test.go @@ -61,6 +61,27 @@ func purchaseReturnJTIFromToken(t *testing.T, handler *LicenseHandlers, token st return strings.TrimSpace(claims.ID) } +func issueCheckoutActivationGrant(t *testing.T) string { + t.Helper() + + grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ + LicenseID: "lic_checkout_success", + Tier: "pro_plus", + State: "active", + Features: []string{"relay", "ai_alerts"}, + MaxMonitoredSystems: 50, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + Email: "buyer@example.com", + }) + if err != nil { + t.Fatalf("generate grant jwt: %v", err) + } + license.SetPublicKey(grantPublicKey) + t.Cleanup(func() { license.SetPublicKey(nil) }) + return grantJWT +} + type licenseFeaturesResponseDTO struct { LicenseStatus string `json:"license_status"` Features map[string]bool `json:"features"` @@ -866,21 +887,7 @@ func TestHandleCheckoutActivation_RedeemsCompletedCheckoutAndWritesSuccessBridge returnToken := issuePurchaseReturnToken(t, handler, "default", "max_monitored_systems") purchaseReturnJTI := purchaseReturnJTIFromToken(t, handler, returnToken) - grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ - LicenseID: "lic_checkout_success", - Tier: "pro_plus", - State: "active", - Features: []string{"relay", "ai_alerts"}, - MaxMonitoredSystems: 50, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - Email: "buyer@example.com", - }) - if err != nil { - t.Fatalf("generate grant jwt: %v", err) - } - license.SetPublicKey(grantPublicKey) - t.Cleanup(func() { license.SetPublicKey(nil) }) + grantJWT := issueCheckoutActivationGrant(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -957,6 +964,162 @@ func TestHandleCheckoutActivation_RedeemsCompletedCheckoutAndWritesSuccessBridge } } +func TestHandleCheckoutActivation_BlocksDuplicateRedemptionAfterSuccess(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "false") + + handler := createTestHandler(t) + returnToken := issuePurchaseReturnToken(t, handler, "default", "max_monitored_systems") + purchaseReturnJTI := purchaseReturnJTIFromToken(t, handler, returnToken) + grantJWT := issueCheckoutActivationGrant(t) + + activateCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/checkout/session": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"fulfilled","portal_handoff_id":"cph_success","purchase_return_jti":"` + purchaseReturnJTI + `","license_id":"lic_checkout_success","activation_key_prefix":"ppk_live","activation_key":"ppk_live_checkout_activation"}`)) + case "/v1/activate": + activateCalls++ + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "license": map[string]any{ + "license_id": "lic_checkout_success", + "state": "active", + "tier": "pro_plus", + "features": []string{"relay", "ai_alerts"}, + "max_monitored_systems": 50, + }, + "installation": map[string]any{ + "installation_id": "inst_checkout_success", + "installation_token": "pit_live_checkout_success", + "status": "active", + }, + "grant": map[string]any{ + "jwt": grantJWT, + "jti": "grant_checkout_success", + "expires_at": time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339), + }, + }) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer server.Close() + t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL) + + makeRequest := func() *httptest.ResponseRecorder { + req := httptest.NewRequest( + http.MethodPost, + "https://pulse.example.com"+licensePurchaseActivationPath, + strings.NewReader("session_id=cs_success&portal_handoff_id=cph_success&purchase_return_token="+url.QueryEscape(returnToken)), + ) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + handler.HandleCheckoutActivation(rec, req) + return rec + } + + first := makeRequest() + if first.Code != http.StatusOK { + t.Fatalf("first status = %d, want %d (body=%q)", first.Code, http.StatusOK, first.Body.String()) + } + + second := makeRequest() + if second.Code != http.StatusConflict { + t.Fatalf("second status = %d, want %d (body=%q)", second.Code, http.StatusConflict, second.Body.String()) + } + if !strings.Contains(second.Body.String(), "already returned to Pulse Pro") { + t.Fatalf("second body = %q, want duplicate redemption message", second.Body.String()) + } + if activateCalls != 1 { + t.Fatalf("activateCalls = %d, want 1", activateCalls) + } +} + +func TestHandleCheckoutActivation_AllowsRetryAfterActivationFailure(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "false") + + handler := createTestHandler(t) + returnToken := issuePurchaseReturnToken(t, handler, "default", "max_monitored_systems") + purchaseReturnJTI := purchaseReturnJTIFromToken(t, handler, returnToken) + grantJWT := issueCheckoutActivationGrant(t) + + activateCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/checkout/session": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"fulfilled","portal_handoff_id":"cph_success","purchase_return_jti":"` + purchaseReturnJTI + `","license_id":"lic_checkout_success","activation_key_prefix":"ppk_live","activation_key":"ppk_live_checkout_activation"}`)) + case "/v1/activate": + activateCalls++ + if activateCalls == 1 { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": "invalid_activation", + "message": "Activation key could not be redeemed", + "retryable": false, + }) + return + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "license": map[string]any{ + "license_id": "lic_checkout_success", + "state": "active", + "tier": "pro_plus", + "features": []string{"relay", "ai_alerts"}, + "max_monitored_systems": 50, + }, + "installation": map[string]any{ + "installation_id": "inst_checkout_success", + "installation_token": "pit_live_checkout_success", + "status": "active", + }, + "grant": map[string]any{ + "jwt": grantJWT, + "jti": "grant_checkout_success", + "expires_at": time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339), + }, + }) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer server.Close() + t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL) + + makeRequest := func() *httptest.ResponseRecorder { + req := httptest.NewRequest( + http.MethodPost, + "https://pulse.example.com"+licensePurchaseActivationPath, + strings.NewReader("session_id=cs_success&portal_handoff_id=cph_success&purchase_return_token="+url.QueryEscape(returnToken)), + ) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + handler.HandleCheckoutActivation(rec, req) + return rec + } + + first := makeRequest() + if first.Code != http.StatusBadRequest { + t.Fatalf("first status = %d, want %d (body=%q)", first.Code, http.StatusBadRequest, first.Body.String()) + } + if !strings.Contains(first.Body.String(), "Activation key could not be redeemed") { + t.Fatalf("first body = %q, want activation failure message", first.Body.String()) + } + + second := makeRequest() + if second.Code != http.StatusOK { + t.Fatalf("second status = %d, want %d (body=%q)", second.Code, http.StatusOK, second.Body.String()) + } + if !strings.Contains(second.Body.String(), "Pulse Pro activated") { + t.Fatalf("second body = %q, want activation success bridge", second.Body.String()) + } + if activateCalls != 2 { + t.Fatalf("activateCalls = %d, want 2", activateCalls) + } +} + func TestHandleCheckoutActivation_RejectsMissingPurchaseReturnToken(t *testing.T) { handler := createTestHandler(t) handler.SetConfig(&config.Config{PublicURL: "https://pulse.example.com"}) diff --git a/internal/api/licensing_handlers.go b/internal/api/licensing_handlers.go index 9c3d48c37..cf3b5be23 100644 --- a/internal/api/licensing_handlers.go +++ b/internal/api/licensing_handlers.go @@ -57,6 +57,7 @@ type LicenseHandlers struct { services sync.Map // map[string]*licenseService trialLimiter *RateLimiter trialReplay *jtiReplayStore + purchaseReturnRedemptions *purchaseReturnRedemptionStore trialInitiations *trialSignupInitiationStore trialRedeemer func(token string) (*hostedTrialRedemptionResponse, error) monitor *monitoring.Monitor @@ -76,19 +77,22 @@ func NewLicenseHandlers(mtp *config.MultiTenantPersistence, hostedMode bool, cfg } var trialReplay *jtiReplayStore + var purchaseReturnRedemptions *purchaseReturnRedemptionStore var trialInitiations *trialSignupInitiationStore if mtp != nil { trialReplay = &jtiReplayStore{configDir: mtp.BaseDataDir()} + purchaseReturnRedemptions = &purchaseReturnRedemptionStore{configDir: mtp.BaseDataDir()} trialInitiations = &trialSignupInitiationStore{configDir: mtp.BaseDataDir()} } return &LicenseHandlers{ - mtPersistence: mtp, - hostedMode: hostedMode, - cfg: cfg, - trialLimiter: NewRateLimiter(trialStartRateLimitBurst, trialStartRateLimitWindow), - trialReplay: trialReplay, - trialInitiations: trialInitiations, + mtPersistence: mtp, + hostedMode: hostedMode, + cfg: cfg, + trialLimiter: NewRateLimiter(trialStartRateLimitBurst, trialStartRateLimitWindow), + trialReplay: trialReplay, + purchaseReturnRedemptions: purchaseReturnRedemptions, + trialInitiations: trialInitiations, } } @@ -277,6 +281,20 @@ func (h *LicenseHandlers) purchaseReturnSigningKey() ([]byte, error) { return signingKey, nil } +func (h *LicenseHandlers) purchaseReturnRedemptionStore() *purchaseReturnRedemptionStore { + if h == nil { + return nil + } + if h.purchaseReturnRedemptions != nil { + return h.purchaseReturnRedemptions + } + dataDir := h.purchaseReturnDataDir() + if strings.TrimSpace(dataDir) == "" { + return nil + } + return &purchaseReturnRedemptionStore{configDir: dataDir} +} + func pulseAccountUpgradeURLForRequest(portalHandoffID string, query url.Values) (string, error) { portalURL := strings.TrimSpace(pulseAccountPortalURLFromLicensing("")) if portalURL == "" { @@ -1553,54 +1571,8 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt return } - replayStore := h.trialReplay - if replayStore == nil { - dataDir := h.purchaseReturnDataDir() - if strings.TrimSpace(dataDir) != "" { - replayStore = &jtiReplayStore{configDir: dataDir} - } - } - replaySubject := "" - if claims != nil { - replaySubject = strings.TrimSpace(claims.Subject) - if replaySubject == "" { - replaySubject = strings.TrimSpace(claims.ID) - } - } - if replayStore == nil || replaySubject == "" { - writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse could not verify the purchase activation state for this checkout.") - return - } - - replayID := "purchase_activate:" + replaySubject - expiresAt := time.Now().UTC().Add(2 * time.Hour) - if claims != nil && claims.ExpiresAt != nil { - expiresAt = claims.ExpiresAt.Time - } - stored, err := replayStore.checkAndStore(replayID, expiresAt) - if err != nil { - log.Error().Err(err).Msg("Purchase activation replay-store failure") - writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse could not verify the purchase activation state for this checkout.") - return - } - if !stored { - log.Warn().Str("replay_id_prefix", replayID[:24]).Msg("Purchase activation replay blocked") - writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseActivated, "This completed purchase was already returned to Pulse Pro. Reopen billing if you need to confirm the current entitlement.") - return - } - clearReplay := func(reason string, replayErr error) { - if deleteErr := replayStore.delete(replayID); deleteErr != nil { - log.Warn().Err(deleteErr).Str("replay_id_prefix", replayID[:24]).Str("reason", reason).Msg("Purchase activation replay-store cleanup failed") - return - } - if replayErr != nil { - log.Warn().Err(replayErr).Str("replay_id_prefix", replayID[:24]).Str("reason", reason).Msg("Cleared purchase activation replay marker after activation failure") - } - } - lsClient := newLicenseServerClientFromLicensing("") if lsClient == nil { - clearReplay("license_server_unavailable", nil) writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse could not contact the commercial activation service.") return } @@ -1612,7 +1584,6 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt checkoutResult, err := lsClient.GetCheckoutSessionResult(ctx, sessionID) if err != nil { - clearReplay("checkout_lookup_failed", err) log.Warn().Err(err).Str("checkout_session_id", sessionID).Msg("Failed to resolve checkout session during purchase activation") writeLicensePurchaseActivationFailurePage(w, http.StatusBadGateway, feature, selfHostedBillingPurchaseFailed, "Pulse could not confirm the completed checkout yet. Please try again in a moment.") return @@ -1623,7 +1594,6 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt if checkoutResult != nil && strings.TrimSpace(checkoutResult.Message) != "" { message = strings.TrimSpace(checkoutResult.Message) } - clearReplay("checkout_not_fulfilled", nil) writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseFailed, message) return } @@ -1641,7 +1611,6 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt } if portalHandoffID != "" { if resolvedPortalHandoffID == "" || resolvedPortalHandoffID != portalHandoffID { - clearReplay("portal_handoff_id_mismatch", nil) log.Warn(). Str("checkout_session_id", sessionID). Str("expected_portal_handoff_id", portalHandoffID). @@ -1650,14 +1619,17 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseExpired, "Purchase activation state did not match the completed upgrade flow. Reopen the upgrade flow from Pulse Pro billing.") return } - } else if resolvedPortalHandoffID != "" { + } else { + portalHandoffID = resolvedPortalHandoffID + } + if portalHandoffID == "" { log.Warn(). Str("checkout_session_id", sessionID). - Str("resolved_portal_handoff_id", resolvedPortalHandoffID). - Msg("Purchase activation continued without browser portal_handoff_id state") + Msg("Rejected checkout activation without canonical portal handoff binding") + writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseExpired, "Purchase activation state did not match the completed upgrade flow. Reopen the upgrade flow from Pulse Pro billing.") + return } if expectedPurchaseReturnJTI == "" || resolvedPurchaseReturnJTI == "" || expectedPurchaseReturnJTI != resolvedPurchaseReturnJTI { - clearReplay("purchase_return_jti_mismatch", nil) log.Warn(). Str("checkout_session_id", sessionID). Str("expected_purchase_return_jti", expectedPurchaseReturnJTI). @@ -1669,17 +1641,82 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt activationKey := strings.TrimSpace(checkoutResult.ActivationKey) if activationKey == "" { - clearReplay("activation_key_missing", nil) writeLicensePurchaseActivationFailurePage(w, http.StatusBadGateway, feature, selfHostedBillingPurchaseFailed, "The completed checkout did not return an activation key.") return } + redemptionStore := h.purchaseReturnRedemptionStore() + if redemptionStore == nil { + writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse could not verify the purchase activation state for this checkout.") + return + } + expiresAt := time.Now().UTC().Add(2 * time.Hour) + if claims != nil && claims.ExpiresAt != nil { + expiresAt = claims.ExpiresAt.Time + } + redemptionDecision, _, err := redemptionStore.begin(purchaseReturnRedemptionAttempt{ + PortalHandoffID: portalHandoffID, + PurchaseReturnJTI: expectedPurchaseReturnJTI, + CheckoutSessionID: sessionID, + LicenseID: strings.TrimSpace(checkoutResult.LicenseID), + ActivationKeyPrefix: strings.TrimSpace(checkoutResult.ActivationKeyPrefix), + ExpiresAt: expiresAt, + }) + if err != nil { + log.Error().Err(err).Str("portal_handoff_id", portalHandoffID).Msg("Purchase activation redemption-store failure") + writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse could not verify the purchase activation state for this checkout.") + return + } + switch redemptionDecision { + case purchaseReturnRedemptionDecisionAlreadyActivated: + writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseActivated, "This completed purchase was already returned to Pulse Pro. Reopen billing if you need to confirm the current entitlement.") + return + case purchaseReturnRedemptionDecisionInProgress: + writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseFailed, "Pulse is already finalizing this completed purchase. Reopen billing in a moment if this tab does not close automatically.") + return + case purchaseReturnRedemptionDecisionConflict: + log.Warn(). + Str("checkout_session_id", sessionID). + Str("portal_handoff_id", portalHandoffID). + Msg("Rejected checkout activation due to local redemption binding conflict") + writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseExpired, "Purchase activation state did not match the completed upgrade flow. Reopen the upgrade flow from Pulse Pro billing.") + return + } + recordFailure := func(reason string, activationErr error) { + message := "" + if activationErr != nil { + message = activationErr.Error() + } + if markErr := redemptionStore.markFailed(portalHandoffID, expectedPurchaseReturnJTI, reason, message); markErr != nil { + log.Warn(). + Err(markErr). + Str("portal_handoff_id", portalHandoffID). + Str("purchase_return_jti", expectedPurchaseReturnJTI). + Str("reason", reason). + Msg("Failed to update purchase activation redemption record") + return + } + if activationErr != nil { + log.Warn(). + Err(activationErr). + Str("portal_handoff_id", portalHandoffID). + Str("purchase_return_jti", expectedPurchaseReturnJTI). + Str("reason", reason). + Msg("Marked purchase activation redemption as failed") + } + } + if _, err := h.activateLicenseKey(ctx, activationKey); err != nil { - clearReplay("license_activation_failed", err) + recordFailure("license_activation_failed", err) log.Warn().Err(err).Str("checkout_session_id", sessionID).Msg("Failed to activate completed checkout locally") writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, feature, selfHostedBillingPurchaseFailed, userFriendlyActivationError(err)) return } + if err := redemptionStore.markActivated(portalHandoffID, expectedPurchaseReturnJTI, strings.TrimSpace(checkoutResult.LicenseID), strings.TrimSpace(checkoutResult.ActivationKeyPrefix)); err != nil { + log.Error().Err(err).Str("portal_handoff_id", portalHandoffID).Str("checkout_session_id", sessionID).Msg("Failed to finalize purchase activation redemption record") + writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse activated the license but could not finalize the local purchase record. Reopen billing to confirm the current entitlement.") + return + } writeLicensePurchaseActivationSuccessPage(w, feature) } diff --git a/internal/api/purchase_return_redemptions.go b/internal/api/purchase_return_redemptions.go new file mode 100644 index 000000000..036837e65 --- /dev/null +++ b/internal/api/purchase_return_redemptions.go @@ -0,0 +1,523 @@ +package api + +import ( + "database/sql" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + purchaseReturnRedemptionStateStarted = "started" + purchaseReturnRedemptionStateActivated = "activated" + purchaseReturnRedemptionStateFailed = "failed" + + purchaseReturnRedemptionDecisionStarted = "started" + purchaseReturnRedemptionDecisionAlreadyActivated = "already_activated" + purchaseReturnRedemptionDecisionInProgress = "in_progress" + purchaseReturnRedemptionDecisionConflict = "conflict" + + purchaseReturnRedemptionStaleAfter = 30 * time.Second +) + +type purchaseReturnRedemptionStore struct { + once sync.Once + db *sql.DB + mu sync.Mutex + + configDir string + initErr error +} + +type purchaseReturnRedemptionAttempt struct { + PortalHandoffID string + PurchaseReturnJTI string + CheckoutSessionID string + LicenseID string + ActivationKeyPrefix string + ExpiresAt time.Time +} + +type purchaseReturnRedemptionRecord struct { + PortalHandoffID string + PurchaseReturnJTI string + CheckoutSessionID string + LicenseID string + ActivationKeyPrefix string + Status string + FailureReason string + FailureMessage string + CreatedAt time.Time + UpdatedAt time.Time + ExpiresAt time.Time + RedeemedAt *time.Time +} + +func (s *purchaseReturnRedemptionStore) init() { + s.once.Do(func() { + dir := filepath.Clean(s.configDir) + if strings.TrimSpace(dir) == "" { + s.initErr = fmt.Errorf("configDir is required") + return + } + secretsDir := filepath.Join(dir, "secrets") + if err := os.MkdirAll(secretsDir, handoffPrivateDirPerm); err != nil { + s.initErr = fmt.Errorf("create purchase return secrets dir: %w", err) + return + } + if err := os.Chmod(secretsDir, handoffPrivateDirPerm); err != nil { + s.initErr = fmt.Errorf("chmod purchase return secrets dir: %w", err) + return + } + + dbPath := filepath.Join(secretsDir, "purchase_return_redemptions.db") + dsn := dbPath + "?" + url.Values{ + "_pragma": []string{ + "busy_timeout(30000)", + "journal_mode(WAL)", + "synchronous(NORMAL)", + }, + }.Encode() + + db, err := sql.Open("sqlite", dsn) + if err != nil { + s.initErr = fmt.Errorf("open purchase return redemption db: %w", err) + return + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(0) + + schema := ` + CREATE TABLE IF NOT EXISTS purchase_return_redemptions ( + portal_handoff_id TEXT NOT NULL, + purchase_return_jti TEXT NOT NULL, + checkout_session_id TEXT NOT NULL, + license_id TEXT NOT NULL DEFAULT '', + activation_key_prefix TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + failure_reason TEXT NOT NULL DEFAULT '', + failure_message TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + redeemed_at INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (portal_handoff_id, purchase_return_jti) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_purchase_return_redemptions_portal_handoff + ON purchase_return_redemptions(portal_handoff_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_purchase_return_redemptions_checkout_session + ON purchase_return_redemptions(checkout_session_id); + CREATE INDEX IF NOT EXISTS idx_purchase_return_redemptions_status + ON purchase_return_redemptions(status); + ` + if _, err := db.Exec(schema); err != nil { + _ = db.Close() + s.initErr = fmt.Errorf("init purchase return redemption schema: %w", err) + return + } + for _, path := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} { + if err := hardenPrivateFile(path, handoffPrivateFilePerm); err != nil { + _ = db.Close() + s.initErr = fmt.Errorf("harden purchase return redemption file permissions: %w", err) + return + } + } + + s.db = db + }) +} + +func (s *purchaseReturnRedemptionStore) begin(attempt purchaseReturnRedemptionAttempt) (string, *purchaseReturnRedemptionRecord, error) { + s.init() + if s.initErr != nil { + return "", nil, s.initErr + } + if s.db == nil { + return "", nil, fmt.Errorf("purchase return redemption store not initialized") + } + + attempt.PortalHandoffID = strings.TrimSpace(attempt.PortalHandoffID) + attempt.PurchaseReturnJTI = strings.TrimSpace(attempt.PurchaseReturnJTI) + attempt.CheckoutSessionID = strings.TrimSpace(attempt.CheckoutSessionID) + attempt.LicenseID = strings.TrimSpace(attempt.LicenseID) + attempt.ActivationKeyPrefix = strings.TrimSpace(attempt.ActivationKeyPrefix) + if attempt.PortalHandoffID == "" { + return "", nil, fmt.Errorf("portal handoff id is required") + } + if attempt.PurchaseReturnJTI == "" { + return "", nil, fmt.Errorf("purchase return jti is required") + } + if attempt.CheckoutSessionID == "" { + return "", nil, fmt.Errorf("checkout session id is required") + } + if attempt.ExpiresAt.IsZero() { + return "", nil, fmt.Errorf("expires at is required") + } + attempt.ExpiresAt = attempt.ExpiresAt.UTC() + + s.mu.Lock() + defer s.mu.Unlock() + + tx, err := s.db.Begin() + if err != nil { + return "", nil, fmt.Errorf("begin purchase return redemption tx: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + + now := time.Now().UTC() + + existing, err := loadPurchaseReturnRedemptionExactTx(tx, attempt.PortalHandoffID, attempt.PurchaseReturnJTI) + if err != nil { + return "", nil, err + } + if existing != nil { + if existing.CheckoutSessionID != attempt.CheckoutSessionID { + return purchaseReturnRedemptionDecisionConflict, existing, nil + } + switch existing.Status { + case purchaseReturnRedemptionStateActivated: + committed = true + if err := tx.Commit(); err != nil { + return "", nil, fmt.Errorf("commit activated purchase redemption lookup: %w", err) + } + return purchaseReturnRedemptionDecisionAlreadyActivated, existing, nil + case purchaseReturnRedemptionStateStarted: + if now.Sub(existing.UpdatedAt) < purchaseReturnRedemptionStaleAfter { + committed = true + if err := tx.Commit(); err != nil { + return "", nil, fmt.Errorf("commit in-progress purchase redemption lookup: %w", err) + } + return purchaseReturnRedemptionDecisionInProgress, existing, nil + } + case purchaseReturnRedemptionStateFailed: + default: + return "", nil, fmt.Errorf("unexpected purchase return redemption status %q", existing.Status) + } + if err := updatePurchaseReturnRedemptionStartedTx(tx, attempt, now); err != nil { + return "", nil, err + } + record, err := loadPurchaseReturnRedemptionExactTx(tx, attempt.PortalHandoffID, attempt.PurchaseReturnJTI) + if err != nil { + return "", nil, err + } + if record == nil { + return "", nil, fmt.Errorf("purchase return redemption row disappeared after restart") + } + if err := tx.Commit(); err != nil { + return "", nil, fmt.Errorf("commit restarted purchase return redemption: %w", err) + } + committed = true + return purchaseReturnRedemptionDecisionStarted, record, nil + } + + byPortalHandoffID, err := loadPurchaseReturnRedemptionByPortalHandoffTx(tx, attempt.PortalHandoffID) + if err != nil { + return "", nil, err + } + if byPortalHandoffID != nil { + committed = true + if err := tx.Commit(); err != nil { + return "", nil, fmt.Errorf("commit portal handoff conflict lookup: %w", err) + } + return purchaseReturnRedemptionDecisionConflict, byPortalHandoffID, nil + } + + bySessionID, err := loadPurchaseReturnRedemptionBySessionTx(tx, attempt.CheckoutSessionID) + if err != nil { + return "", nil, err + } + if bySessionID != nil { + committed = true + if err := tx.Commit(); err != nil { + return "", nil, fmt.Errorf("commit checkout session conflict lookup: %w", err) + } + return purchaseReturnRedemptionDecisionConflict, bySessionID, nil + } + + if err := insertPurchaseReturnRedemptionStartedTx(tx, attempt, now); err != nil { + return "", nil, err + } + record, err := loadPurchaseReturnRedemptionExactTx(tx, attempt.PortalHandoffID, attempt.PurchaseReturnJTI) + if err != nil { + return "", nil, err + } + if record == nil { + return "", nil, fmt.Errorf("purchase return redemption row missing after insert") + } + + if err := tx.Commit(); err != nil { + return "", nil, fmt.Errorf("commit purchase return redemption insert: %w", err) + } + committed = true + return purchaseReturnRedemptionDecisionStarted, record, nil +} + +func (s *purchaseReturnRedemptionStore) markFailed(portalHandoffID, purchaseReturnJTI, reason, message string) error { + return s.update(portalHandoffID, purchaseReturnJTI, func(tx *sql.Tx, now time.Time, existing *purchaseReturnRedemptionRecord) error { + if existing.Status == purchaseReturnRedemptionStateActivated { + return nil + } + if _, err := tx.Exec( + `UPDATE purchase_return_redemptions + SET status = ?, failure_reason = ?, failure_message = ?, updated_at = ? + WHERE portal_handoff_id = ? AND purchase_return_jti = ?`, + purchaseReturnRedemptionStateFailed, + strings.TrimSpace(reason), + strings.TrimSpace(message), + now.Unix(), + strings.TrimSpace(portalHandoffID), + strings.TrimSpace(purchaseReturnJTI), + ); err != nil { + return fmt.Errorf("mark purchase return redemption failed: %w", err) + } + return nil + }) +} + +func (s *purchaseReturnRedemptionStore) markActivated(portalHandoffID, purchaseReturnJTI, licenseID, activationKeyPrefix string) error { + return s.update(portalHandoffID, purchaseReturnJTI, func(tx *sql.Tx, now time.Time, existing *purchaseReturnRedemptionRecord) error { + if existing.Status == purchaseReturnRedemptionStateActivated { + return nil + } + if _, err := tx.Exec( + `UPDATE purchase_return_redemptions + SET status = ?, failure_reason = '', failure_message = '', license_id = ?, activation_key_prefix = ?, updated_at = ?, redeemed_at = ? + WHERE portal_handoff_id = ? AND purchase_return_jti = ?`, + purchaseReturnRedemptionStateActivated, + strings.TrimSpace(licenseID), + strings.TrimSpace(activationKeyPrefix), + now.Unix(), + now.Unix(), + strings.TrimSpace(portalHandoffID), + strings.TrimSpace(purchaseReturnJTI), + ); err != nil { + return fmt.Errorf("mark purchase return redemption activated: %w", err) + } + return nil + }) +} + +func (s *purchaseReturnRedemptionStore) get(portalHandoffID, purchaseReturnJTI string) (*purchaseReturnRedemptionRecord, error) { + s.init() + if s.initErr != nil { + return nil, s.initErr + } + if s.db == nil { + return nil, fmt.Errorf("purchase return redemption store not initialized") + } + + s.mu.Lock() + defer s.mu.Unlock() + + return loadPurchaseReturnRedemptionExactTx(nil, strings.TrimSpace(portalHandoffID), strings.TrimSpace(purchaseReturnJTI), s.db) +} + +func (s *purchaseReturnRedemptionStore) update(portalHandoffID, purchaseReturnJTI string, apply func(tx *sql.Tx, now time.Time, existing *purchaseReturnRedemptionRecord) error) error { + s.init() + if s.initErr != nil { + return s.initErr + } + if s.db == nil { + return fmt.Errorf("purchase return redemption store not initialized") + } + portalHandoffID = strings.TrimSpace(portalHandoffID) + purchaseReturnJTI = strings.TrimSpace(purchaseReturnJTI) + if portalHandoffID == "" { + return fmt.Errorf("portal handoff id is required") + } + if purchaseReturnJTI == "" { + return fmt.Errorf("purchase return jti is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("begin purchase return redemption update tx: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + + existing, err := loadPurchaseReturnRedemptionExactTx(tx, portalHandoffID, purchaseReturnJTI) + if err != nil { + return err + } + if existing == nil { + return fmt.Errorf("purchase return redemption not found") + } + if err := apply(tx, time.Now().UTC(), existing); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit purchase return redemption update: %w", err) + } + committed = true + return nil +} + +func insertPurchaseReturnRedemptionStartedTx(tx *sql.Tx, attempt purchaseReturnRedemptionAttempt, now time.Time) error { + _, err := tx.Exec( + `INSERT INTO purchase_return_redemptions ( + portal_handoff_id, purchase_return_jti, checkout_session_id, license_id, activation_key_prefix, + status, failure_reason, failure_message, created_at, updated_at, expires_at, redeemed_at + ) VALUES (?, ?, ?, ?, ?, ?, '', '', ?, ?, ?, 0)`, + attempt.PortalHandoffID, + attempt.PurchaseReturnJTI, + attempt.CheckoutSessionID, + attempt.LicenseID, + attempt.ActivationKeyPrefix, + purchaseReturnRedemptionStateStarted, + now.Unix(), + now.Unix(), + attempt.ExpiresAt.Unix(), + ) + if err != nil { + if isSQLiteUniqueViolation(err) { + return fmt.Errorf("purchase return redemption binding already exists: %w", err) + } + return fmt.Errorf("insert purchase return redemption: %w", err) + } + return nil +} + +func updatePurchaseReturnRedemptionStartedTx(tx *sql.Tx, attempt purchaseReturnRedemptionAttempt, now time.Time) error { + _, err := tx.Exec( + `UPDATE purchase_return_redemptions + SET checkout_session_id = ?, license_id = ?, activation_key_prefix = ?, status = ?, failure_reason = '', failure_message = '', updated_at = ?, expires_at = ?, redeemed_at = 0 + WHERE portal_handoff_id = ? AND purchase_return_jti = ?`, + attempt.CheckoutSessionID, + attempt.LicenseID, + attempt.ActivationKeyPrefix, + purchaseReturnRedemptionStateStarted, + now.Unix(), + attempt.ExpiresAt.Unix(), + attempt.PortalHandoffID, + attempt.PurchaseReturnJTI, + ) + if err != nil { + return fmt.Errorf("restart purchase return redemption: %w", err) + } + return nil +} + +func loadPurchaseReturnRedemptionExactTx(tx *sql.Tx, portalHandoffID, purchaseReturnJTI string, dbs ...*sql.DB) (*purchaseReturnRedemptionRecord, error) { + const query = `SELECT + portal_handoff_id, + purchase_return_jti, + checkout_session_id, + license_id, + activation_key_prefix, + status, + failure_reason, + failure_message, + created_at, + updated_at, + expires_at, + redeemed_at + FROM purchase_return_redemptions + WHERE portal_handoff_id = ? AND purchase_return_jti = ?` + + var row *sql.Row + switch { + case tx != nil: + row = tx.QueryRow(query, portalHandoffID, purchaseReturnJTI) + case len(dbs) > 0 && dbs[0] != nil: + row = dbs[0].QueryRow(query, portalHandoffID, purchaseReturnJTI) + default: + return nil, fmt.Errorf("database handle is required") + } + return scanPurchaseReturnRedemptionRow(row) +} + +func loadPurchaseReturnRedemptionByPortalHandoffTx(tx *sql.Tx, portalHandoffID string) (*purchaseReturnRedemptionRecord, error) { + return scanPurchaseReturnRedemptionRow(tx.QueryRow( + `SELECT + portal_handoff_id, + purchase_return_jti, + checkout_session_id, + license_id, + activation_key_prefix, + status, + failure_reason, + failure_message, + created_at, + updated_at, + expires_at, + redeemed_at + FROM purchase_return_redemptions + WHERE portal_handoff_id = ?`, + portalHandoffID, + )) +} + +func loadPurchaseReturnRedemptionBySessionTx(tx *sql.Tx, checkoutSessionID string) (*purchaseReturnRedemptionRecord, error) { + return scanPurchaseReturnRedemptionRow(tx.QueryRow( + `SELECT + portal_handoff_id, + purchase_return_jti, + checkout_session_id, + license_id, + activation_key_prefix, + status, + failure_reason, + failure_message, + created_at, + updated_at, + expires_at, + redeemed_at + FROM purchase_return_redemptions + WHERE checkout_session_id = ?`, + checkoutSessionID, + )) +} + +func scanPurchaseReturnRedemptionRow(row *sql.Row) (*purchaseReturnRedemptionRecord, error) { + if row == nil { + return nil, fmt.Errorf("purchase return redemption row is required") + } + record := purchaseReturnRedemptionRecord{} + var createdAt, updatedAt, expiresAt, redeemedAt int64 + err := row.Scan( + &record.PortalHandoffID, + &record.PurchaseReturnJTI, + &record.CheckoutSessionID, + &record.LicenseID, + &record.ActivationKeyPrefix, + &record.Status, + &record.FailureReason, + &record.FailureMessage, + &createdAt, + &updatedAt, + &expiresAt, + &redeemedAt, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("scan purchase return redemption: %w", err) + } + record.CreatedAt = time.Unix(createdAt, 0).UTC() + record.UpdatedAt = time.Unix(updatedAt, 0).UTC() + record.ExpiresAt = time.Unix(expiresAt, 0).UTC() + if redeemedAt > 0 { + redeemedAtTime := time.Unix(redeemedAt, 0).UTC() + record.RedeemedAt = &redeemedAtTime + } + return &record, nil +} diff --git a/internal/api/purchase_return_redemptions_test.go b/internal/api/purchase_return_redemptions_test.go new file mode 100644 index 000000000..d45a82f16 --- /dev/null +++ b/internal/api/purchase_return_redemptions_test.go @@ -0,0 +1,115 @@ +package api + +import ( + "testing" + "time" +) + +func TestPurchaseReturnRedemptionStore_RetriesFailedRedemptionAndBlocksActivatedReplay(t *testing.T) { + store := &purchaseReturnRedemptionStore{configDir: t.TempDir()} + attempt := purchaseReturnRedemptionAttempt{ + PortalHandoffID: "cph_test_upgrade", + PurchaseReturnJTI: "prt_test_jti", + CheckoutSessionID: "cs_test_upgrade", + LicenseID: "lic_test_upgrade", + ActivationKeyPrefix: "ppk_live", + ExpiresAt: time.Now().UTC().Add(2 * time.Hour), + } + + decision, record, err := store.begin(attempt) + if err != nil { + t.Fatalf("begin: %v", err) + } + if decision != purchaseReturnRedemptionDecisionStarted { + t.Fatalf("decision = %q, want %q", decision, purchaseReturnRedemptionDecisionStarted) + } + if record == nil || record.Status != purchaseReturnRedemptionStateStarted { + t.Fatalf("record status = %#v, want started", record) + } + + if err := store.markFailed(attempt.PortalHandoffID, attempt.PurchaseReturnJTI, "license_activation_failed", "boom"); err != nil { + t.Fatalf("markFailed: %v", err) + } + record, err = store.get(attempt.PortalHandoffID, attempt.PurchaseReturnJTI) + if err != nil { + t.Fatalf("get after failure: %v", err) + } + if record == nil || record.Status != purchaseReturnRedemptionStateFailed { + t.Fatalf("record status after failure = %#v, want failed", record) + } + if record.FailureReason != "license_activation_failed" { + t.Fatalf("failure_reason = %q, want license_activation_failed", record.FailureReason) + } + + decision, record, err = store.begin(attempt) + if err != nil { + t.Fatalf("begin retry: %v", err) + } + if decision != purchaseReturnRedemptionDecisionStarted { + t.Fatalf("retry decision = %q, want %q", decision, purchaseReturnRedemptionDecisionStarted) + } + if record == nil || record.Status != purchaseReturnRedemptionStateStarted { + t.Fatalf("retry record status = %#v, want started", record) + } + if record.FailureReason != "" || record.FailureMessage != "" { + t.Fatalf("retry record failure fields = %#v, want cleared", record) + } + + if err := store.markActivated(attempt.PortalHandoffID, attempt.PurchaseReturnJTI, attempt.LicenseID, attempt.ActivationKeyPrefix); err != nil { + t.Fatalf("markActivated: %v", err) + } + record, err = store.get(attempt.PortalHandoffID, attempt.PurchaseReturnJTI) + if err != nil { + t.Fatalf("get after activation: %v", err) + } + if record == nil || record.Status != purchaseReturnRedemptionStateActivated { + t.Fatalf("record status after activation = %#v, want activated", record) + } + if record.RedeemedAt == nil { + t.Fatal("redeemed_at was not recorded") + } + + decision, _, err = store.begin(attempt) + if err != nil { + t.Fatalf("begin after activation: %v", err) + } + if decision != purchaseReturnRedemptionDecisionAlreadyActivated { + t.Fatalf("decision after activation = %q, want %q", decision, purchaseReturnRedemptionDecisionAlreadyActivated) + } +} + +func TestPurchaseReturnRedemptionStore_RejectsConflictingBindings(t *testing.T) { + store := &purchaseReturnRedemptionStore{configDir: t.TempDir()} + attempt := purchaseReturnRedemptionAttempt{ + PortalHandoffID: "cph_test_upgrade", + PurchaseReturnJTI: "prt_test_jti", + CheckoutSessionID: "cs_test_upgrade", + LicenseID: "lic_test_upgrade", + ActivationKeyPrefix: "ppk_live", + ExpiresAt: time.Now().UTC().Add(2 * time.Hour), + } + + if decision, _, err := store.begin(attempt); err != nil { + t.Fatalf("begin: %v", err) + } else if decision != purchaseReturnRedemptionDecisionStarted { + t.Fatalf("decision = %q, want %q", decision, purchaseReturnRedemptionDecisionStarted) + } + + conflictingSession := attempt + conflictingSession.CheckoutSessionID = "cs_conflicting_upgrade" + if decision, _, err := store.begin(conflictingSession); err != nil { + t.Fatalf("begin conflicting session: %v", err) + } else if decision != purchaseReturnRedemptionDecisionConflict { + t.Fatalf("decision = %q, want %q", decision, purchaseReturnRedemptionDecisionConflict) + } + + conflictingHandoff := attempt + conflictingHandoff.PortalHandoffID = "cph_other_upgrade" + conflictingHandoff.CheckoutSessionID = attempt.CheckoutSessionID + conflictingHandoff.PurchaseReturnJTI = "prt_other_jti" + if decision, _, err := store.begin(conflictingHandoff); err != nil { + t.Fatalf("begin conflicting handoff: %v", err) + } else if decision != purchaseReturnRedemptionDecisionConflict { + t.Fatalf("decision = %q, want %q", decision, purchaseReturnRedemptionDecisionConflict) + } +}