diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 71face61b..f0611eb94 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -260,6 +260,12 @@ before any API-only token fallback or optional-auth anonymous fallback so operators can mint relay-mobile credentials and continue onboarding from the hosted runtime itself even after that tenant has already minted managed API tokens. +That same lifecycle-adjacent hosted setup path must also survive legacy tenant +runtime env drift. When Pulse Account hands an operator into a hosted workspace, +`internal/api/cloud_handoff_handlers.go` must still recover the canonical +tenant context from hosted runtime state if `PULSE_TENANT_ID` is missing, so +lifecycle entry into onboarding, setup, and mobile-pairing surfaces does not +die before the first authenticated page load. That same lifecycle-adjacent hosted setup path also depends on AI bootstrap staying canonical before the first settings write. Hosted operators may land in Chat, Patrol-backed setup hints, or AI-dependent remediation surfaces diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 606b29716..c4c5bcd42 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1465,6 +1465,13 @@ must also project the effective default-org hosted lease when the tenant-local billing file has not been materialized yet, so admin billing-state payloads stay coherent with the tenant's active entitlement payload instead of briefly regressing to local trial/default state. +That same hosted handoff boundary also owns tenant-context recovery inside the +tenant runtime itself. When older hosted containers are missing explicit +`PULSE_TENANT_ID`, `internal/api/cloud_handoff_handlers.go` must recover the +JWT audience/tenant context from hosted-only runtime inputs like +`PULSE_PUBLIC_URL` or the hosted tenant request host before it mints the +browser session, rather than failing a valid control-plane handoff with a +generic `500 internal error`. Canonical missing-resource lookups in governed frontend API clients must now also route `404 => null` response handling through shared response helpers in `frontend-modern/src/api/responseUtils.ts` rather than open-coding local diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 008eb45b7..113178889 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1052,6 +1052,11 @@ surfaces may run without local auth configured, but a valid tenant the anonymous optional-auth fallback so hosted recovery, onboarding, and support flows do not silently degrade into unauthenticated state or bearer- token-only mode after cloud handoff. +That same hosted recovery boundary must also preserve tenant-context recovery at +the handoff exchange itself. When older hosted tenant containers are missing +`PULSE_TENANT_ID`, the shared handoff handler must still recover the tenant +from hosted runtime context before it mints session cookies, so recovery/support +entry from Pulse Account does not fail closed with a generic internal error. That same shared `internal/api/` boundary also owns hosted AI bootstrap continuity. Storage- and recovery-adjacent hosted flows may surface Patrol- backed investigation or AI-assisted recovery guidance before an operator has diff --git a/internal/api/cloud_handoff_handlers.go b/internal/api/cloud_handoff_handlers.go index 1459f007e..559a06d9e 100644 --- a/internal/api/cloud_handoff_handlers.go +++ b/internal/api/cloud_handoff_handlers.go @@ -183,6 +183,14 @@ func tenantIDFromRequest(r *http.Request) string { } return "" } + if hostedModeEnabledFromEnv() { + if v := tenantIDFromPublicURL(strings.TrimSpace(os.Getenv("PULSE_PUBLIC_URL"))); v != "" { + return v + } + } + if hostedRuntimeTenantID := tenantIDFromHostedProxyRequest(r); hostedRuntimeTenantID != "" { + return hostedRuntimeTenantID + } if r == nil { return "" } @@ -220,6 +228,46 @@ func tenantIDFromRequest(r *http.Request) string { return tenantID } +func tenantIDFromHostedProxyRequest(r *http.Request) string { + if r == nil || !hostedModeEnabledFromEnv() { + return "" + } + if v := tenantIDFromPublicURL(strings.TrimSpace(r.Host)); v != "" { + return v + } + return "" +} + +func tenantIDFromPublicURL(publicURL string) string { + if publicURL == "" { + return "" + } + if !strings.Contains(publicURL, "://") { + publicURL = "https://" + publicURL + } + parsed, err := url.Parse(publicURL) + if err != nil { + return "" + } + host := strings.TrimSpace(parsed.Hostname()) + if host == "" { + return "" + } + // Hosted tenant URLs follow "." and must have a + // distinct tenant label ahead of the shared cloud domain. + if strings.Count(host, ".") < 3 { + return "" + } + tenantID := host + if i := strings.IndexByte(host, '.'); i > 0 { + tenantID = host[:i] + } + if !isValidOrganizationID(tenantID) { + return "" + } + return tenantID +} + func normalizeHandoffEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) } diff --git a/internal/api/cloud_handoff_handlers_test.go b/internal/api/cloud_handoff_handlers_test.go index be8b18965..6c4cc779b 100644 --- a/internal/api/cloud_handoff_handlers_test.go +++ b/internal/api/cloud_handoff_handlers_test.go @@ -58,9 +58,11 @@ func TestIsSQLiteUniqueViolation(t *testing.T) { func TestTenantIDFromRequest(t *testing.T) { t.Run("uses env var when present", func(t *testing.T) { + t.Setenv("PULSE_HOSTED_MODE", "") t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "") resetTrustedProxyConfig() t.Setenv("PULSE_TENANT_ID", "env-tenant") + t.Setenv("PULSE_PUBLIC_URL", "") req := httptest.NewRequest(http.MethodGet, "/", nil) req.Host = "host-tenant.example.com" req.RemoteAddr = "198.51.100.10:4567" @@ -71,9 +73,11 @@ func TestTenantIDFromRequest(t *testing.T) { }) t.Run("extracts subdomain from loopback host", func(t *testing.T) { + t.Setenv("PULSE_HOSTED_MODE", "") t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "") resetTrustedProxyConfig() t.Setenv("PULSE_TENANT_ID", "") + t.Setenv("PULSE_PUBLIC_URL", "") req := httptest.NewRequest(http.MethodGet, "/", nil) req.Host = "tenant.example.com:8443" req.RemoteAddr = "127.0.0.1:8080" @@ -84,9 +88,11 @@ func TestTenantIDFromRequest(t *testing.T) { }) t.Run("returns full loopback host when no dot exists", func(t *testing.T) { + t.Setenv("PULSE_HOSTED_MODE", "") t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "") resetTrustedProxyConfig() t.Setenv("PULSE_TENANT_ID", "") + t.Setenv("PULSE_PUBLIC_URL", "") req := httptest.NewRequest(http.MethodGet, "/", nil) req.Host = "localhost" req.RemoteAddr = "127.0.0.1:8080" @@ -97,9 +103,11 @@ func TestTenantIDFromRequest(t *testing.T) { }) t.Run("extracts tenant from trusted proxy forwarded host", func(t *testing.T) { + t.Setenv("PULSE_HOSTED_MODE", "") t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "203.0.113.0/24") resetTrustedProxyConfig() t.Setenv("PULSE_TENANT_ID", "") + t.Setenv("PULSE_PUBLIC_URL", "") req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "203.0.113.10:9443" req.Host = "ignored.example.com" @@ -110,10 +118,42 @@ func TestTenantIDFromRequest(t *testing.T) { } }) - t.Run("ignores untrusted remote host header", func(t *testing.T) { + t.Run("falls back to hosted public url when tenant env is missing", func(t *testing.T) { + t.Setenv("PULSE_HOSTED_MODE", "true") t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "") resetTrustedProxyConfig() t.Setenv("PULSE_TENANT_ID", "") + t.Setenv("PULSE_PUBLIC_URL", "https://tenant-from-public.cloud.pulserelay.pro") + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "198.51.100.20:1234" + req.Host = "untrusted.example.com" + + if got := tenantIDFromRequest(req); got != "tenant-from-public" { + t.Fatalf("tenantIDFromRequest() = %q, want %q", got, "tenant-from-public") + } + }) + + t.Run("uses hosted request host when tenant env and public url are missing", func(t *testing.T) { + t.Setenv("PULSE_HOSTED_MODE", "true") + t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "") + resetTrustedProxyConfig() + t.Setenv("PULSE_TENANT_ID", "") + t.Setenv("PULSE_PUBLIC_URL", "") + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "198.51.100.20:1234" + req.Host = "tenant-from-host.cloud.pulserelay.pro" + + if got := tenantIDFromRequest(req); got != "tenant-from-host" { + t.Fatalf("tenantIDFromRequest() = %q, want %q", got, "tenant-from-host") + } + }) + + t.Run("ignores untrusted remote host header", func(t *testing.T) { + t.Setenv("PULSE_HOSTED_MODE", "") + t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "") + resetTrustedProxyConfig() + t.Setenv("PULSE_TENANT_ID", "") + t.Setenv("PULSE_PUBLIC_URL", "") req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "198.51.100.20:1234" req.Host = "tenant.example.com" @@ -124,6 +164,23 @@ func TestTenantIDFromRequest(t *testing.T) { }) } +func TestTenantIDFromPublicURL(t *testing.T) { + t.Run("extracts tenant from hosted public url", func(t *testing.T) { + if got := tenantIDFromPublicURL("https://tenant-a.cloud.pulserelay.pro"); got != "tenant-a" { + t.Fatalf("tenantIDFromPublicURL() = %q, want %q", got, "tenant-a") + } + }) + + t.Run("rejects invalid public url", func(t *testing.T) { + if got := tenantIDFromPublicURL("://not-a-url"); got != "" { + t.Fatalf("tenantIDFromPublicURL() = %q, want empty", got) + } + if got := tenantIDFromPublicURL("https://cloud.pulserelay.pro"); got != "" { + t.Fatalf("tenantIDFromPublicURL() = %q, want empty", got) + } + }) +} + func TestJTIReplayStoreCheckAndStore(t *testing.T) { store := &jtiReplayStore{configDir: t.TempDir()} expires := time.Now().Add(time.Hour) @@ -218,6 +275,12 @@ func TestJTIReplayStoreSecuresPermissionModes(t *testing.T) { func TestHandleHandoffExchange(t *testing.T) { key := []byte("test-handoff-key") configDir := t.TempDir() + resetSessionStoreForTests() + t.Cleanup(resetSessionStoreForTests) + resetCSRFStoreForTests() + t.Cleanup(resetCSRFStoreForTests) + InitSessionStore(configDir) + InitCSRFStore(configDir) secretsDir := filepath.Join(configDir, "secrets") if err := os.MkdirAll(secretsDir, 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) @@ -370,6 +433,12 @@ func TestHandleHandoffExchange(t *testing.T) { func TestHandleHandoffExchangeBrowserFlowSetsSessionCookies(t *testing.T) { key := []byte("test-handoff-key") configDir := t.TempDir() + resetSessionStoreForTests() + t.Cleanup(resetSessionStoreForTests) + resetCSRFStoreForTests() + t.Cleanup(resetCSRFStoreForTests) + InitSessionStore(configDir) + InitCSRFStore(configDir) secretsDir := filepath.Join(configDir, "secrets") if err := os.MkdirAll(secretsDir, 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 0acf027b3..a582b12dd 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -2484,6 +2484,12 @@ func TestContract_HostedBillingStateFallbackJSONSnapshot(t *testing.T) { func TestContract_HandoffExchangeJSONSnapshot(t *testing.T) { key := []byte("test-handoff-key") configDir := t.TempDir() + resetSessionStoreForTests() + t.Cleanup(resetSessionStoreForTests) + resetCSRFStoreForTests() + t.Cleanup(resetCSRFStoreForTests) + InitSessionStore(configDir) + InitCSRFStore(configDir) secretsDir := filepath.Join(configDir, "secrets") if err := os.MkdirAll(secretsDir, 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) @@ -2494,7 +2500,9 @@ func TestContract_HandoffExchangeJSONSnapshot(t *testing.T) { handler := HandleHandoffExchange(configDir) tenantID := "tenant-contract" + t.Setenv("PULSE_HOSTED_MODE", "true") t.Setenv("PULSE_TENANT_ID", "") + t.Setenv("PULSE_PUBLIC_URL", "") token := signHandoffToken(t, key, cloudHandoffClaims{ AccountID: "acct-contract", Email: "Operator.Owner+Mixed@PulseRelay.Pro", @@ -2511,9 +2519,7 @@ func TestContract_HandoffExchangeJSONSnapshot(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/cloud/handoff/exchange?token="+token+"&format=json", nil) req.Host = tenantID + ".cloud.pulserelay.pro" req.Header.Set("Accept", "application/json") - req.Header.Set("X-Forwarded-Host", req.Host) - req.Header.Set("X-Forwarded-For", "127.0.0.1") - req.RemoteAddr = "127.0.0.1:1234" + req.RemoteAddr = "198.51.100.20:1234" rec := httptest.NewRecorder() handler(rec, req)