diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 376f77340..399bee1c1 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -134,7 +134,10 @@ an add-only capacity posture. the signed entitlement lease already carried in canonical billing state. Lifecycle flows must not reintroduce anonymous bootstrap identity, tenant-local commercial-owner surrogates, or fake activation records when - they traverse those shared handlers. + they traverse those shared handlers. They also must not infer tenant + creation, email issuance, or public-route availability from + `/api/public/signup` response codes or payload fields just because that + commercial route lives under the shared `internal/api/` tree. That same shared quickstart boundary is vendor-neutral at the lifecycle edge too: lifecycle-adjacent consumers may observe the stable `quickstart:pulse-hosted` alias in AI settings payloads, but they must not diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 4d50cefad..924b15b45 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -138,6 +138,11 @@ Own canonical runtime payload shapes between backend and frontend. 42. `internal/api/org_lifecycle_handlers.go` shared with `organization-settings`: organization lifecycle handlers are both an organization settings control surface and a canonical API payload contract boundary. 43. `internal/api/payments_webhook_handlers.go` shared with `cloud-paid`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership. 44. `internal/api/public_signup_handlers.go` shared with `cloud-paid`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership. + That same shared boundary also owns public hosted-signup response privacy: + syntactically valid `/api/public/signup` requests must return one generic + `202 Accepted` Pulse Account message whether provisioning/email side effects + ran or were suppressed by the owner-email limiter, while invalid bodies and + true server failures remain explicit. 45. `internal/api/relay_mobile_capability.go` shared with `relay-runtime`: the backend-owned Pulse Mobile relay capability inventory is both a relay runtime boundary and a canonical API payload contract surface. 46. `internal/api/resources.go` shared with `unified-resources`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary. 47. `internal/api/security.go` shared with `security-privacy`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary. @@ -2777,6 +2782,11 @@ boundary: `internal/api/public_signup_handlers.go` owns request/response and magic-link payload semantics, while `internal/hosted/provisioner.go` owns the shared org bootstrap and rollback mechanics that the hosted signup handler invokes. +That shared public-signup response contract is now intentionally uniform for +syntactically valid requests: the route returns `202 Accepted` with one generic +Pulse Account message whether provisioning/email side effects ran or were +suppressed by the owner-email rate limiter, while invalid request bodies and +true server failures remain explicit. The API token settings surface now also follows the same explicit ownership rule. Changes to `frontend-modern/src/components/Settings/APITokenManager.tsx`, `frontend-modern/src/components/Settings/apiTokenManagerModel.ts`, and diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index af5e36122..a99257a93 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -116,6 +116,10 @@ agreement, and cloud-specific enforcement rules. zero-delta and removal-only TrueNAS or VMware previews as non-consuming or capacity-freeing changes rather than warning users that a disabled connection still grows monitored-system usage. + That same shared signup boundary also owns the public privacy floor: + syntactically valid `/api/public/signup` requests resolve to one uniform + `202 Accepted` Pulse Account response whether provisioning/email side + effects ran or were suppressed by owner-email throttling. The real `pulse-pro` license-server legacy checkout issuance, recurring renewals, manual issue, and legacy exchange flows are part of that same @@ -1480,6 +1484,11 @@ That duplicate-owner-email rule is fail-closed and server-owned. Repeated hosted signup attempts for an email that already owns a tenant must resolve to the existing org identity instead of minting a second tenant and relying on later auth or billing surfaces to untangle the collision. +That same public signup boundary must also stay privacy-safe at the browser +edge: syntactically valid signup requests return one uniform `202 Accepted` +Pulse Account message whether the backend provisioned/sent email or suppressed +side effects due to the owner-email rate limit, so the route does not reveal +prior email usage through `201` versus `429` drift. Hosted billing-state normalization now follows the same rule: a missing `plan_version` must remain missing instead of being synthesized from `subscription_state`, while explicit trial defaults remain explicit. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 37cc9dc77..bb42659a1 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -146,6 +146,10 @@ querying, and the operator-facing storage health presentation layer. any adjacent recovery-rollup query, so hidden workload-route selectors do not hydrate storage/recovery transport on the protected hot path. 6. Preserve API-owned node identity continuity in shared `internal/api/` helpers so storage and recovery transport attachments do not fork by hostname-versus-IP drift across the same runtime. + That same adjacent `internal/api/` boundary also keeps public hosted signup + commercial-only: storage and recovery surfaces must not infer tenant + existence, email issuance, or readiness from `/api/public/signup` response + codes or payload fields when shared backend API helpers change nearby. 7. Preserve fail-closed API assignment and lookup behavior in shared `internal/api/` helpers so storage and recovery surfaces do not inherit orphaned profile or resource references from unrelated transport mutations. 8. Preserve canonical configured public endpoint selection in shared `internal/api/` helpers so recovery and storage links do not inherit loopback-local scheme drift from admin-originated setup/install flows. 9. Preserve trailing-slash normalization in those shared install-command helpers so recovery-adjacent transport and link surfaces do not inherit double-slash installer paths or slash-suffixed public endpoint drift from canonical backend install payloads. diff --git a/frontend-modern/src/api/__tests__/hostedSignup.test.ts b/frontend-modern/src/api/__tests__/hostedSignup.test.ts index b1b59741e..20c9a7a2f 100644 --- a/frontend-modern/src/api/__tests__/hostedSignup.test.ts +++ b/frontend-modern/src/api/__tests__/hostedSignup.test.ts @@ -17,11 +17,9 @@ describe('HostedSignupAPI', () => { vi.mocked(apiClient.fetch).mockResolvedValueOnce( new Response( JSON.stringify({ - org_id: 'org-1', - user_id: 'owner@example.com', - message: 'Check your email.', + message: "If that email can finish signup, you'll receive a Pulse Account sign-in link shortly.", }), - { status: 201 }, + { status: 202 }, ), ); @@ -47,11 +45,9 @@ describe('HostedSignupAPI', () => { }); expect(result).toEqual({ ok: true, - status: 201, + status: 202, data: { - org_id: 'org-1', - user_id: 'owner@example.com', - message: 'Check your email.', + message: "If that email can finish signup, you'll receive a Pulse Account sign-in link shortly.", }, }); }); diff --git a/frontend-modern/src/pages/HostedSignup.tsx b/frontend-modern/src/pages/HostedSignup.tsx index 396f19eb6..f06c96fb1 100644 --- a/frontend-modern/src/pages/HostedSignup.tsx +++ b/frontend-modern/src/pages/HostedSignup.tsx @@ -93,7 +93,7 @@ export default function HostedSignup() { return; } setStatus('success'); - setMessage(result.data.message || 'Check your email for a Pulse Account sign-in link.'); + setMessage(result.data.message || "If that email can finish signup, you'll receive a Pulse Account sign-in link shortly."); return; } diff --git a/frontend-modern/src/pages/__tests__/HostedSignup.test.tsx b/frontend-modern/src/pages/__tests__/HostedSignup.test.tsx index 7e393a91f..309db6ed1 100644 --- a/frontend-modern/src/pages/__tests__/HostedSignup.test.tsx +++ b/frontend-modern/src/pages/__tests__/HostedSignup.test.tsx @@ -31,9 +31,9 @@ describe('HostedSignup', () => { requestMagicLinkMock.mockReset(); signupMock.mockResolvedValue({ ok: true, - status: 201, + status: 202, data: { - message: 'Check your email for a Pulse Account sign-in link.', + message: "If that email can finish signup, you'll receive a Pulse Account sign-in link shortly.", }, }); requestMagicLinkMock.mockResolvedValue({ diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 0ac875f3a..6eff529e8 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -2319,9 +2319,7 @@ func TestContract_ApprovalListResponseJSONSnapshot(t *testing.T) { func TestContract_HostedSignupResponseJSONSnapshot(t *testing.T) { payload := hostedSignupResponse{ - OrgID: "org-123", - UserID: "owner@example.com", - Message: "Check your email for a magic link to finish signing in.", + Message: hostedSignupAcceptedMessage, } got, err := json.Marshal(payload) @@ -2330,9 +2328,7 @@ func TestContract_HostedSignupResponseJSONSnapshot(t *testing.T) { } const want = `{ - "org_id":"org-123", - "user_id":"owner@example.com", - "message":"Check your email for a magic link to finish signing in." + "message":"If that email can finish signup, you'll receive a Pulse Account sign-in link shortly." }` assertJSONSnapshot(t, got, want) diff --git a/internal/api/hosted_lifecycle_integration_test.go b/internal/api/hosted_lifecycle_integration_test.go index eff82acf7..0eea9adc5 100644 --- a/internal/api/hosted_lifecycle_integration_test.go +++ b/internal/api/hosted_lifecycle_integration_test.go @@ -38,27 +38,26 @@ func issueHostedLifecycleTrialInitiationToken(t *testing.T, h *LicenseHandlers, func TestHostedLifecycle(t *testing.T) { t.Run("Signup_SeedsTrialBillingState_ForBillingAdmin", func(t *testing.T) { - router, _, _, _, baseDir := newHostedSignupTestRouter(t, true) + router, persistence, _, _, baseDir := newHostedSignupTestRouter(t, true) rec := doHostedSignupRequest(router, `{"email":"owner@example.com","org_name":"My Organization"}`) - if rec.Code != http.StatusCreated { - t.Fatalf("signup status=%d, want %d: %s", rec.Code, http.StatusCreated, rec.Body.String()) + if rec.Code != http.StatusAccepted { + t.Fatalf("signup status=%d, want %d: %s", rec.Code, http.StatusAccepted, rec.Body.String()) } - var signupResp struct { - OrgID string `json:"org_id"` - } + var signupResp hostedSignupResponse if err := json.NewDecoder(rec.Body).Decode(&signupResp); err != nil { t.Fatalf("decode signup response: %v", err) } - if signupResp.OrgID == "" { - t.Fatal("expected signup response org_id to be populated") + if signupResp.OrgID != "" || signupResp.UserID != "" { + t.Fatalf("expected signup response to omit identifiers, got %+v", signupResp) } + orgID := requireHostedSignupProvisionedOrgID(t, persistence, "owner@example.com") billingStore := config.NewFileBillingStore(baseDir) - stored, err := billingStore.GetBillingState(signupResp.OrgID) + stored, err := billingStore.GetBillingState(orgID) if err != nil { - t.Fatalf("GetBillingState(%s) failed: %v", signupResp.OrgID, err) + t.Fatalf("GetBillingState(%s) failed: %v", orgID, err) } if stored == nil { t.Fatal("expected seeded billing state after hosted signup") @@ -91,8 +90,8 @@ func TestHostedLifecycle(t *testing.T) { } billingHandlers := NewBillingStateHandlers(billingStore, true) - req := httptest.NewRequest(http.MethodGet, "/api/admin/orgs/"+signupResp.OrgID+"/billing-state", nil) - req.SetPathValue("id", signupResp.OrgID) + req := httptest.NewRequest(http.MethodGet, "/api/admin/orgs/"+orgID+"/billing-state", nil) + req.SetPathValue("id", orgID) rec2 := httptest.NewRecorder() billingHandlers.HandleGetBillingState(rec2, req) if rec2.Code != http.StatusOK { @@ -120,34 +119,33 @@ func TestHostedLifecycle(t *testing.T) { router, mtp, _, _, baseDir := newHostedSignupTestRouter(t, true) rec := doHostedSignupRequest(router, `{"email":"owner@example.com","org_name":"My Organization"}`) - if rec.Code != http.StatusCreated { - t.Fatalf("signup status=%d, want %d: %s", rec.Code, http.StatusCreated, rec.Body.String()) + if rec.Code != http.StatusAccepted { + t.Fatalf("signup status=%d, want %d: %s", rec.Code, http.StatusAccepted, rec.Body.String()) } - var signupResp struct { - OrgID string `json:"org_id"` - } + var signupResp hostedSignupResponse if err := json.NewDecoder(rec.Body).Decode(&signupResp); err != nil { t.Fatalf("decode signup response: %v", err) } - if signupResp.OrgID == "" { - t.Fatal("expected signup response org_id to be populated") + if signupResp.OrgID != "" || signupResp.UserID != "" { + t.Fatalf("expected signup response to omit identifiers, got %+v", signupResp) } + orgID := requireHostedSignupProvisionedOrgID(t, mtp, "owner@example.com") // Write Pro billing state via the billing store. billingStore := config.NewFileBillingStore(baseDir) - if err := billingStore.SaveBillingState(signupResp.OrgID, &entitlements.BillingState{ + if err := billingStore.SaveBillingState(orgID, &entitlements.BillingState{ Capabilities: append([]string(nil), license.TierFeatures[license.TierPro]...), Limits: map[string]int64{}, MetersEnabled: []string{}, PlanVersion: string(license.TierPro), SubscriptionState: entitlements.SubStateActive, }); err != nil { - t.Fatalf("SaveBillingState(%s) failed: %v", signupResp.OrgID, err) + t.Fatalf("SaveBillingState(%s) failed: %v", orgID, err) } handlers := NewLicenseHandlers(mtp, true) - ctx := context.WithValue(context.Background(), OrgIDContextKey, signupResp.OrgID) + ctx := context.WithValue(context.Background(), OrgIDContextKey, orgID) // GET entitlements and verify capabilities match billing state. entReq := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil).WithContext(ctx) diff --git a/internal/api/hosted_signup_handlers_test.go b/internal/api/hosted_signup_handlers_test.go index 01a88bca8..39032c566 100644 --- a/internal/api/hosted_signup_handlers_test.go +++ b/internal/api/hosted_signup_handlers_test.go @@ -19,29 +19,26 @@ func TestHostedSignupSuccess(t *testing.T) { router, persistence, rbacProvider, emailer, _ := newHostedSignupTestRouter(t, true) rec := doHostedSignupRequest(router, `{"email":"owner@example.com","org_name":"My Organization"}`) - if rec.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusAccepted { + t.Fatalf("expected 202, got %d: %s", rec.Code, rec.Body.String()) } - var response struct { - OrgID string `json:"org_id"` - UserID string `json:"user_id"` - Message string `json:"message"` - } + var response hostedSignupResponse if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { t.Fatalf("decode response: %v", err) } - if response.OrgID == "" { - t.Fatal("expected org_id to be set") + if response.OrgID != "" { + t.Fatalf("expected org_id to be omitted, got %q", response.OrgID) } - if response.UserID != "owner@example.com" { - t.Fatalf("expected user_id owner@example.com, got %q", response.UserID) + if response.UserID != "" { + t.Fatalf("expected user_id to be omitted, got %q", response.UserID) } - if response.Message != "Check your email for a magic link to finish signing in." { + if response.Message != hostedSignupAcceptedMessage { t.Fatalf("unexpected message: %q", response.Message) } - org, err := persistence.LoadOrganization(response.OrgID) + orgID := requireHostedSignupProvisionedOrgID(t, persistence, "owner@example.com") + org, err := persistence.LoadOrganization(orgID) if err != nil { t.Fatalf("load org: %v", err) } @@ -52,7 +49,7 @@ func TestHostedSignupSuccess(t *testing.T) { t.Fatalf("expected owner owner@example.com, got %q", org.OwnerUserID) } - manager, err := rbacProvider.GetManager(response.OrgID) + manager, err := rbacProvider.GetManager(orgID) if err != nil { t.Fatalf("get rbac manager: %v", err) } @@ -155,8 +152,8 @@ func TestHostedSignupRateLimit(t *testing.T) { i, ) rec := doHostedSignupRequest(router, body) - if i <= 5 && rec.Code != http.StatusCreated { - t.Fatalf("request %d expected 201, got %d: %s", i, rec.Code, rec.Body.String()) + if i <= 5 && rec.Code != http.StatusAccepted { + t.Fatalf("request %d expected 202, got %d: %s", i, rec.Code, rec.Body.String()) } if i == 6 && rec.Code != http.StatusTooManyRequests { t.Fatalf("request %d expected 429, got %d: %s", i, rec.Code, rec.Body.String()) @@ -182,8 +179,8 @@ func TestHostedSignupRateLimit_NoProvisioningSideEffects(t *testing.T) { ) first := doHostedSignupRequest(router, `{"email":"owner@example.com","org_name":"Org One"}`) - if first.Code != http.StatusCreated { - t.Fatalf("first request expected 201, got %d: %s", first.Code, first.Body.String()) + if first.Code != http.StatusAccepted { + t.Fatalf("first request expected 202, got %d: %s", first.Code, first.Body.String()) } orgsBefore, err := persistence.ListOrganizations() @@ -193,8 +190,20 @@ func TestHostedSignupRateLimit_NoProvisioningSideEffects(t *testing.T) { beforeCount := len(orgsBefore) second := doHostedSignupRequest(router, `{"email":"owner@example.com","org_name":"Org Two"}`) - if second.Code != http.StatusTooManyRequests { - t.Fatalf("second request expected 429, got %d: %s", second.Code, second.Body.String()) + if second.Code != http.StatusAccepted { + t.Fatalf("second request expected 202, got %d: %s", second.Code, second.Body.String()) + } + + var firstResp hostedSignupResponse + if err := json.NewDecoder(first.Body).Decode(&firstResp); err != nil { + t.Fatalf("decode first response: %v", err) + } + var secondResp hostedSignupResponse + if err := json.NewDecoder(second.Body).Decode(&secondResp); err != nil { + t.Fatalf("decode second response: %v", err) + } + if firstResp != secondResp { + t.Fatalf("expected uniform accepted responses, got first=%+v second=%+v", firstResp, secondResp) } orgsAfter, err := persistence.ListOrganizations() @@ -339,6 +348,25 @@ func doHostedSignupRequest(router *Router, body string) *httptest.ResponseRecord return rec } +func requireHostedSignupProvisionedOrgID(t *testing.T, persistence *config.MultiTenantPersistence, ownerEmail string) string { + t.Helper() + + orgs, err := persistence.ListOrganizations() + if err != nil { + t.Fatalf("list orgs: %v", err) + } + for _, org := range orgs { + if org == nil || org.ID == "default" { + continue + } + if strings.EqualFold(org.OwnerUserID, ownerEmail) { + return org.ID + } + } + t.Fatalf("expected provisioned org for owner %q", ownerEmail) + return "" +} + func containsRoleID(items []string, target string) bool { for _, item := range items { if item == target { diff --git a/internal/api/public_signup_handlers.go b/internal/api/public_signup_handlers.go index 6a26b145a..9e6722e2f 100644 --- a/internal/api/public_signup_handlers.go +++ b/internal/api/public_signup_handlers.go @@ -15,6 +15,8 @@ import ( const hostedSignupRequestBodyLimit = 64 * 1024 +const hostedSignupAcceptedMessage = "If that email can finish signup, you'll receive a Pulse Account sign-in link shortly." + type HostedRBACProvider interface { GetManager(orgID string) (auth.ExtendedManager, error) RemoveTenant(orgID string) error @@ -35,8 +37,8 @@ type hostedSignupRequest struct { } type hostedSignupResponse struct { - OrgID string `json:"org_id"` - UserID string `json:"user_id"` + OrgID string `json:"org_id,omitempty"` + UserID string `json:"user_id,omitempty"` Message string `json:"message"` } @@ -103,7 +105,9 @@ func (h *HostedSignupHandlers) HandlePublicSignup(w http.ResponseWriter, r *http // Enforce per-email rate limit before any provisioning side effects. if !h.magicLinks.AllowRequest(req.Email) { - writeErrorResponse(w, http.StatusTooManyRequests, "rate_limited", "Too many magic link requests. Please wait and try again.", nil) + writeJSON(w, http.StatusAccepted, hostedSignupResponse{ + Message: hostedSignupAcceptedMessage, + }) return } @@ -177,10 +181,8 @@ func (h *HostedSignupHandlers) HandlePublicSignup(w http.ResponseWriter, r *http hosted.GetHostedMetrics().RecordSignup() hosted.GetHostedMetrics().RecordProvisionStatus(hosted.ProvisionMetricStatusSuccess) - writeJSON(w, http.StatusCreated, hostedSignupResponse{ - OrgID: orgID, - UserID: userID, - Message: "Check your email for a magic link to finish signing in.", + writeJSON(w, http.StatusAccepted, hostedSignupResponse{ + Message: hostedSignupAcceptedMessage, }) }