fix(cloud): recover hosted tenant handoff context

Recover hosted tenant context during cloud handoff exchange when older tenant containers are missing PULSE_TENANT_ID, and govern the hosted handoff contract in the owning API/lifecycle/storage subsystem docs.
This commit is contained in:
rcourtman 2026-03-26 11:30:23 +00:00
parent 8ecea8dbdf
commit 8b91ab7c04
6 changed files with 145 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 "<tenant-id>.<base-domain>" 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))
}

View file

@ -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)

View file

@ -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)