diff --git a/e2e/w11_anon_internal_url_e2e_test.go b/e2e/w11_anon_internal_url_e2e_test.go new file mode 100644 index 0000000..9506096 --- /dev/null +++ b/e2e/w11_anon_internal_url_e2e_test.go @@ -0,0 +1,61 @@ +//go:build e2e + +package e2e + +// w11_anon_internal_url_e2e_test.go — black-box coverage for W11 Fix 1 +// (anon internal_url scrub, 2026-05-14). +// +// Contract: POST //new from an unclaimed (anonymous) caller MUST +// NOT carry `internal_url` in the response body. The cluster-internal +// proxy FQDN leaks infra topology and serves no purpose for anon callers +// — they can't run /deploy/new workloads against the proxy without a +// claimed team. Companion unit coverage lives in +// internal/handlers/internal_url_test.go::TestSetInternalURL. +// +// Target endpoint: /cache/new because redis is the most reliably-enabled +// service in dev (db can skip on 503, nosql can skip on mongo absence). +// The handler returns internal_url via the same setInternalURL helper +// that all four provisioning endpoints share, so a single endpoint +// exercises the contract for the whole family. + +import ( + "encoding/json" + "net/http" + "testing" +) + +// TestE2E_W11_AnonProvision_NoInternalURL pins the anon-internal_url +// scrub contract at the HTTP boundary. The response body MUST NOT +// contain an `internal_url` field for an unclaimed POST /cache/new. +func TestE2E_W11_AnonProvision_NoInternalURL(t *testing.T) { + ip := uniqueIP(t) + resp := post(t, "/cache/new", nil, "X-Forwarded-For", ip) + + if resp.StatusCode == http.StatusServiceUnavailable { + readBody(t, resp) + t.Skip("/cache/new service not enabled") + } + if resp.StatusCode != http.StatusCreated { + t.Fatalf("POST /cache/new: want 201, got %d\n%s", resp.StatusCode, readBody(t, resp)) + } + + body := readBody(t, resp) + + // Parse to a free-form map so we can assert on field presence rather + // than on a typed struct (which would silently swallow the field). + var raw map[string]any + if err := json.Unmarshal([]byte(body), &raw); err != nil { + t.Fatalf("decode /cache/new body: %v\n%s", err, body) + } + + if tier, _ := raw["tier"].(string); tier != "anonymous" { + t.Fatalf("expected tier=anonymous, got %q (full body: %s)", tier, body) + } + if _, present := raw["internal_url"]; present { + t.Errorf("anonymous /cache/new MUST NOT include internal_url; got body:\n%s", body) + } + // Sanity: connection_url is still there (we scrubbed internal_url, not the public URL). + if cu, _ := raw["connection_url"].(string); cu == "" { + t.Errorf("connection_url must remain populated for anon callers; got body:\n%s", body) + } +} diff --git a/e2e/w11_idempotency_e2e_test.go b/e2e/w11_idempotency_e2e_test.go new file mode 100644 index 0000000..50904b2 --- /dev/null +++ b/e2e/w11_idempotency_e2e_test.go @@ -0,0 +1,171 @@ +//go:build e2e + +package e2e + +// w11_idempotency_e2e_test.go — black-box coverage for W11 Fix 2 +// (X-Idempotent-Replay header + idempotency-vs-fingerprint-dedup +// precedence, 2026-05-14). +// +// Contracts under test: +// +// 1. Same Idempotency-Key + same body from the same fingerprint: +// second response MUST carry `X-Idempotent-Replay: true` AND return +// the cached body (including the same token). The first response +// MUST NOT carry the header. +// +// 2. Same Idempotency-Key + DIFFERENT body: 409 with structured error +// `idempotency_key_conflict`. Already covered by the middleware unit +// test; re-asserted here at the HTTP boundary so a per-route wiring +// misconfig (e.g. middleware accidentally moved AFTER the handler) +// would fail loudly. +// +// 3. NO Idempotency-Key + same fingerprint: handler's per-fingerprint +// dedup still works, but X-Idempotent-Replay is NEVER set. This is +// the precedence inverse — the header is reserved exclusively for +// the idempotency middleware so upstream agents can branch on it. +// +// Target endpoint: /cache/new (most reliably enabled). Idempotency +// middleware wiring is identical across all provisioning endpoints — +// see internal/router/router.go. + +import ( + "net/http" + "strings" + "testing" + + "github.com/google/uuid" +) + +// TestE2E_W11_Idempotency_ReplaysWithHeader drives the core replay flow: +// two POST /cache/new calls from the same fingerprint with the same +// Idempotency-Key + same body MUST yield the SAME token AND the second +// response MUST carry `X-Idempotent-Replay: true`. +// +// Precedence: even if fingerprint dedup would return the same token +// (it's the same /24), the cached entry replays verbatim — including +// the header — which fingerprint dedup alone cannot produce. The header +// is the differentiator an upstream agent can branch on. +func TestE2E_W11_Idempotency_ReplaysWithHeader(t *testing.T) { + ip := uniqueIP(t) + idemKey := "w11-replay-" + uuid.NewString() + body := map[string]any{"name": "w11-idem-test"} + + // First call: fresh provision, no replay header. + resp1 := post(t, "/cache/new", body, + "X-Forwarded-For", ip, + "Idempotency-Key", idemKey, + ) + if resp1.StatusCode == http.StatusServiceUnavailable { + readBody(t, resp1) + t.Skip("/cache/new service not enabled") + } + if resp1.StatusCode != http.StatusCreated { + t.Fatalf("call 1: want 201, got %d\n%s", resp1.StatusCode, readBody(t, resp1)) + } + if r := resp1.Header.Get("X-Idempotent-Replay"); r != "" { + t.Errorf("call 1 MUST NOT set X-Idempotent-Replay; got %q", r) + } + var first provisionNewResponse + decodeJSON(t, resp1, &first) + if first.Token == "" { + t.Fatalf("call 1: token missing\n%v", first) + } + + // Second call: same key + same body. Middleware short-circuits with + // the cached response and `X-Idempotent-Replay: true`. + resp2 := post(t, "/cache/new", body, + "X-Forwarded-For", ip, + "Idempotency-Key", idemKey, + ) + defer resp2.Body.Close() + + if resp2.StatusCode != http.StatusCreated { + t.Fatalf("call 2: want 201 (cached replay), got %d", resp2.StatusCode) + } + if r := resp2.Header.Get("X-Idempotent-Replay"); r != "true" { + t.Errorf("call 2 MUST set X-Idempotent-Replay: true; got %q", r) + } + var second provisionNewResponse + decodeJSON(t, resp2, &second) + if second.Token != first.Token { + t.Errorf("replay MUST return the same token; got %q want %q", + second.Token, first.Token) + } +} + +// TestE2E_W11_Idempotency_DifferentBody_Returns409 pins the +// "same key, different body" → 409 contract at the HTTP boundary. +// Without this guard an agent could silently mutate a payload on retry +// and get a totally different resource under the same key — a class of +// "race condition with myself" bug that's hard to debug. +func TestE2E_W11_Idempotency_DifferentBody_Returns409(t *testing.T) { + ip := uniqueIP(t) + idemKey := "w11-conflict-" + uuid.NewString() + + // First body + resp1 := post(t, "/cache/new", map[string]any{"name": "first"}, + "X-Forwarded-For", ip, + "Idempotency-Key", idemKey, + ) + if resp1.StatusCode == http.StatusServiceUnavailable { + readBody(t, resp1) + t.Skip("/cache/new service not enabled") + } + if resp1.StatusCode != http.StatusCreated { + t.Fatalf("call 1: want 201, got %d\n%s", resp1.StatusCode, readBody(t, resp1)) + } + readBody(t, resp1) + + // Same key, different body → 409. + resp2 := post(t, "/cache/new", map[string]any{"name": "second-different-payload"}, + "X-Forwarded-For", ip, + "Idempotency-Key", idemKey, + ) + body2 := readBody(t, resp2) + if resp2.StatusCode != http.StatusConflict { + t.Fatalf("call 2 (different body): want 409, got %d\n%s", resp2.StatusCode, body2) + } + if !strings.Contains(body2, "idempotency_key_conflict") { + t.Errorf("409 body must carry structured error 'idempotency_key_conflict'; got\n%s", body2) + } +} + +// TestE2E_W11_FingerprintDedup_NoIdempotencyKey_StillWorks pins the +// inverse direction: when NO Idempotency-Key is sent, the handler's +// per-fingerprint dedup branch is still the authoritative path. Two +// sequential calls from the same /24 may return the same token +// (fingerprint dedup) but MUST NOT set X-Idempotent-Replay — that header +// is reserved for the idempotency middleware's cache hits, not for +// fingerprint dedup. Locks the "no key ⇒ fingerprint dedup; key ⇒ +// idempotency" precedence contract from both sides. +func TestE2E_W11_FingerprintDedup_NoIdempotencyKey_StillWorks(t *testing.T) { + ip := uniqueIP(t) + + resp1 := post(t, "/cache/new", nil, "X-Forwarded-For", ip) + if resp1.StatusCode == http.StatusServiceUnavailable { + readBody(t, resp1) + t.Skip("/cache/new service not enabled") + } + if resp1.StatusCode != http.StatusCreated { + t.Fatalf("call 1: want 201, got %d\n%s", resp1.StatusCode, readBody(t, resp1)) + } + if r := resp1.Header.Get("X-Idempotent-Replay"); r != "" { + t.Errorf("call 1 (no idem key) MUST NOT set X-Idempotent-Replay; got %q", r) + } + var first provisionNewResponse + decodeJSON(t, resp1, &first) + + // Call 2 from the same /24 — fingerprint dedup may return the same + // resource depending on cluster state. The contract under test is + // that the header stays absent. + resp2 := post(t, "/cache/new", nil, "X-Forwarded-For", ip) + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusCreated && resp2.StatusCode != http.StatusOK { + // Anonymous dedup path returns 200, fresh provision returns 201. + // Either is acceptable here — the assertion is on the header. + t.Logf("call 2: status=%d (informational; either 200 or 201 is acceptable)", resp2.StatusCode) + } + if r := resp2.Header.Get("X-Idempotent-Replay"); r != "" { + t.Errorf("call 2 (no idem key, fingerprint dedup) MUST NOT set X-Idempotent-Replay; got %q", r) + } +} diff --git a/internal/handlers/cache.go b/internal/handlers/cache.go index 2b6618b..d31cce1 100644 --- a/internal/handlers/cache.go +++ b/internal/handlers/cache.go @@ -130,13 +130,14 @@ func (h *CacheHandler) NewCache(c *fiber.Ctx) error { connectionURL := h.decryptConnectionURL(existing.ConnectionURL.String, requestID) if connectionURL != "" { metrics.FingerprintAbuseBlocked.Inc() + // internal_url omitted via setInternalURL on the anon dedup + // path — see internal_url.go for the W11 scrub rationale. dedupResp := fiber.Map{ "ok": true, "id": existing.ID.String(), "token": existing.Token.String(), "name": existing.Name.String, "connection_url": connectionURL, - "internal_url": proxiedInternalURL(connectionURL, "redis"), "tier": existing.Tier, "env": existing.Env, "limits": cacheAnonymousLimits(), @@ -144,6 +145,7 @@ func (h *CacheHandler) NewCache(c *fiber.Ctx) error { "upgrade": upgradeURL, "upgrade_jwt": jwtToken, } + setInternalURL(dedupResp, existing.Tier, connectionURL, "redis") if existing.KeyPrefix.String != "" { dedupResp["key_prefix"] = existing.KeyPrefix.String } @@ -266,13 +268,13 @@ func (h *CacheHandler) NewCache(c *fiber.Ctx) error { cacheStorageLimitMB := h.plans.StorageLimitMB("anonymous", "redis") _, cacheStorageExceeded, _ := quota.CheckStorageQuota(ctx, h.db, resource.ID, cacheStorageLimitMB) + // internal_url omitted on the anonymous path — see internal_url.go. resp := fiber.Map{ "ok": true, "id": resource.ID.String(), "token": tokenStr, "name": resource.Name.String, "connection_url": creds.URL, - "internal_url": proxiedInternalURL(creds.URL, "redis"), "tier": "anonymous", "env": resource.Env, "limits": cacheAnonymousLimits(), @@ -411,7 +413,6 @@ func (h *CacheHandler) newCacheAuthenticated( "token": tokenStr, "name": resource.Name.String, "connection_url": creds.URL, - "internal_url": proxiedInternalURL(creds.URL, "redis"), "tier": tier, "env": resource.Env, "dedicated": dedicated, @@ -419,6 +420,7 @@ func (h *CacheHandler) newCacheAuthenticated( "memory_mb": cacheAuthStorageLimitMB, }, } + setInternalURL(authResp, tier, creds.URL, "redis") if creds.KeyPrefix != "" { authResp["key_prefix"] = creds.KeyPrefix } @@ -475,7 +477,6 @@ func (h *CacheHandler) ProvisionForTwin(c *fiber.Ctx, in ProvisionForTwinInput) "token": res.Token, "name": res.Name, "connection_url": res.ConnectionURL, - "internal_url": res.InternalURL, "tier": res.Tier, "env": res.Env, "family_root_id": res.FamilyRootID, @@ -484,6 +485,11 @@ func (h *CacheHandler) ProvisionForTwin(c *fiber.Ctx, in ProvisionForTwinInput) "memory_mb": res.Limits.StorageMB, }, } + // Twin pipeline requires an authenticated team — res.Tier is never + // anonymous in practice. Defensive guard preserves the W11 invariant. + if res.Tier != tierAnonymous && res.InternalURL != "" { + resp[internalURLResponseKey] = res.InternalURL + } if res.StorageExceeded { resp["warning"] = "Storage limit reached. Upgrade to continue." c.Set("X-Instant-Notice", "storage_limit_reached") diff --git a/internal/handlers/db.go b/internal/handlers/db.go index e8613bb..f16aa58 100644 --- a/internal/handlers/db.go +++ b/internal/handlers/db.go @@ -143,20 +143,24 @@ func (h *DBHandler) NewDB(c *fiber.Ctx) error { connectionURL := h.decryptConnectionURL(existing.ConnectionURL.String, requestID) if connectionURL != "" { metrics.FingerprintAbuseBlocked.Inc() - return c.JSON(fiber.Map{ + // internal_url omitted via setInternalURL: existing.Tier is + // "anonymous" on the fingerprint-dedup path (never crosses into + // authenticated territory — that's a separate code branch). + dedupResp := fiber.Map{ "ok": true, "id": existing.ID.String(), "token": existing.Token.String(), "name": existing.Name.String, "connection_url": connectionURL, - "internal_url": proxiedInternalURL(connectionURL, "postgres"), "tier": existing.Tier, "env": existing.Env, "limits": dbAnonymousLimits(), "note": limitExceededNote(upgradeURL, existing.ExpiresAt.Time), "upgrade": upgradeURL, "upgrade_jwt": jwtToken, - }) + } + setInternalURL(dedupResp, existing.Tier, connectionURL, "postgres") + return c.JSON(dedupResp) } // Empty connection_url means provisioning failed mid-flight on the existing // resource. Fall through to provision a fresh one rather than returning @@ -276,13 +280,16 @@ func (h *DBHandler) NewDB(c *fiber.Ctx) error { storageLimitMB := h.plans.StorageLimitMB("anonymous", "postgres") _, storageExceeded, _ := quota.CheckStorageQuota(ctx, h.db, resource.ID, storageLimitMB) + // internal_url intentionally omitted on the anonymous path — see + // setInternalURL doc comment in internal_url.go. Anon callers can't run + // in-cluster workloads (POST /deploy/new requires a claimed team), so + // internal_url has zero utility for them and leaks infra topology. resp := fiber.Map{ "ok": true, "id": resource.ID.String(), "token": tokenStr, "name": resource.Name.String, "connection_url": creds.URL, - "internal_url": proxiedInternalURL(creds.URL, "postgres"), "tier": "anonymous", "env": resource.Env, "limits": dbAnonymousLimits(), @@ -412,7 +419,6 @@ func (h *DBHandler) newDBAuthenticated( "token": tokenStr, "name": resource.Name.String, "connection_url": creds.URL, - "internal_url": proxiedInternalURL(creds.URL, "postgres"), "tier": tier, "env": resource.Env, "dedicated": dedicated, @@ -421,6 +427,7 @@ func (h *DBHandler) newDBAuthenticated( "connections": h.plans.ConnectionsLimit(tier, "postgres"), }, } + setInternalURL(authResp, tier, creds.URL, "postgres") if authStorageExceeded { authResp["warning"] = "Storage limit reached. Upgrade to continue." c.Set("X-Instant-Notice", "storage_limit_reached") @@ -484,7 +491,6 @@ func (h *DBHandler) ProvisionForTwin(c *fiber.Ctx, in ProvisionForTwinInput) err "token": res.Token, "name": res.Name, "connection_url": res.ConnectionURL, - "internal_url": res.InternalURL, "tier": res.Tier, "env": res.Env, "family_root_id": res.FamilyRootID, @@ -493,6 +499,15 @@ func (h *DBHandler) ProvisionForTwin(c *fiber.Ctx, in ProvisionForTwinInput) err "connections": res.Limits.Connections, }, } + // Twin requires an authenticated team (see TwinHandler.ProvisionTwin) + // so res.Tier is never "anonymous" in practice. Defensive guard + // preserves the W11 anon-internal_url-scrub invariant if a future + // callpath ever invokes the twin pipeline against an anon resource. + // res.InternalURL is already pre-computed (proxiedInternalURL ran + // upstream in ProvisionForTwinCore), so don't re-transform. + if res.Tier != tierAnonymous && res.InternalURL != "" { + resp[internalURLResponseKey] = res.InternalURL + } if res.StorageExceeded { resp["warning"] = "Storage limit reached. Upgrade to continue." c.Set("X-Instant-Notice", "storage_limit_reached") diff --git a/internal/handlers/internal_url.go b/internal/handlers/internal_url.go index ec803c0..c18c6ea 100644 --- a/internal/handlers/internal_url.go +++ b/internal/handlers/internal_url.go @@ -3,9 +3,60 @@ package handlers import ( "net/url" + "github.com/gofiber/fiber/v2" + "instant.dev/internal/urls" ) +// internalURLResponseKey is the JSON key that carries the cluster-internal +// proxy address back to in-cluster callers. Centralized here so both the +// helper and any future tests reference a single named constant — never +// scatter raw "internal_url" string literals in handler code. +const internalURLResponseKey = "internal_url" + +// tierAnonymous is the tier identifier for anonymous (unclaimed) resources. +// Centralized here so the anon-internal_url guard can be grep-audited. +const tierAnonymous = "anonymous" + +// setInternalURL conditionally writes `internal_url` into resp. +// +// Contract (W11 hardening, 2026-05-14): +// - Anonymous-tier responses MUST NOT include internal_url. The +// cluster-internal proxy FQDN (e.g. instant-pg-proxy.instant.svc.cluster.local) +// leaks infra topology to any unauthenticated curl. Anon callers +// legitimately use only the public connection_url; they can't deploy +// in-cluster workloads (POST /deploy/new requires a claimed team), so +// internal_url has zero utility for them. +// - Claimed/authenticated responses (paid tiers — hobby, pro, growth, +// team) DO include internal_url. Pro users running /deploy/new +// workloads alongside their DB need it because DOKS doesn't hairpin +// traffic back through the public LB. +// +// Why a helper and not a guard at every callsite: there are ~12 callsites +// across db.go, cache.go, nosql.go, queue.go, vector.go (storage.go and +// webhook.go don't carry internal_url). Centralizing the "anon → omit" +// rule here means a future tier addition (e.g. "free_signed_in") only +// has to update this one function, and a grep for "internal_url" in +// handlers stays clean. +// +// Returns resp unchanged so callsites can chain idiomatically. +// +// connectionURL: the customer-facing public URL we'll rewrite via +// proxiedInternalURL. Empty input yields no internal_url field even +// for paid tiers (we never emit a half-formed value). +// kind: "postgres", "redis", "mongodb", "queue" — passed through to +// proxiedInternalURL for the per-protocol host substitution. +func setInternalURL(resp fiber.Map, tier, connectionURL, kind string) fiber.Map { + if tier == tierAnonymous { + return resp + } + if connectionURL == "" { + return resp + } + resp[internalURLResponseKey] = proxiedInternalURL(connectionURL, kind) + return resp +} + // proxiedInternalURL rewrites a customer-facing public URL to the cluster-internal // address of the per-protocol proxy. Workloads deployed inside the same cluster // (e.g. /deploy/new apps in their own namespace) cannot reach the public LB IP diff --git a/internal/handlers/internal_url_test.go b/internal/handlers/internal_url_test.go index b7d82b5..b93c3f2 100644 --- a/internal/handlers/internal_url_test.go +++ b/internal/handlers/internal_url_test.go @@ -1,6 +1,113 @@ package handlers -import "testing" +import ( + "testing" + + "github.com/gofiber/fiber/v2" +) + +// TestSetInternalURL pins the W11 "scrub internal_url for anonymous" contract. +// The helper centralises the omit-on-anon rule; these cases drive every axis +// that handler responses route through it. +func TestSetInternalURL(t *testing.T) { + const pgURL = "postgres://usr_x:pass@pg.instanode.dev:5432/db_x?sslmode=disable" + const wantPgInternal = "postgres://usr_x:pass@instant-pg-proxy.instant.svc.cluster.local:5432/db_x?sslmode=disable" + + cases := []struct { + name string + tier string + connURL string + kind string + wantInternal string // empty string ⇒ key absent + }{ + { + name: "anonymous tier MUST NOT emit internal_url", + tier: "anonymous", + connURL: pgURL, + kind: "postgres", + wantInternal: "", + }, + { + name: "hobby tier emits internal_url", + tier: "hobby", + connURL: pgURL, + kind: "postgres", + wantInternal: wantPgInternal, + }, + { + name: "pro tier emits internal_url", + tier: "pro", + connURL: pgURL, + kind: "postgres", + wantInternal: wantPgInternal, + }, + { + name: "team tier emits internal_url", + tier: "team", + connURL: pgURL, + kind: "postgres", + wantInternal: wantPgInternal, + }, + { + name: "growth tier emits internal_url", + tier: "growth", + connURL: pgURL, + kind: "postgres", + wantInternal: wantPgInternal, + }, + { + name: "empty connection URL on paid tier does NOT emit internal_url", + tier: "pro", + connURL: "", + kind: "postgres", + wantInternal: "", + }, + { + name: "empty connection URL on anon tier does NOT emit internal_url", + tier: "anonymous", + connURL: "", + kind: "postgres", + wantInternal: "", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + resp := fiber.Map{"ok": true, "connection_url": c.connURL} + setInternalURL(resp, c.tier, c.connURL, c.kind) + got, present := resp[internalURLResponseKey] + if c.wantInternal == "" { + if present { + t.Errorf("internal_url MUST be omitted for tier=%q connURL=%q; got %v", + c.tier, c.connURL, got) + } + return + } + if !present { + t.Fatalf("internal_url missing for tier=%q; expected %q", c.tier, c.wantInternal) + } + gotStr, ok := got.(string) + if !ok { + t.Fatalf("internal_url is not a string: %T %v", got, got) + } + if gotStr != c.wantInternal { + t.Errorf("internal_url mismatch:\n got = %q\n want = %q", gotStr, c.wantInternal) + } + }) + } +} + +// TestSetInternalURL_ReturnsSameMap pins the chaining contract: callers can +// rely on the returned map being the same instance they passed in (allows +// "return setInternalURL(resp, ...)" patterns in handler code if ever needed). +func TestSetInternalURL_ReturnsSameMap(t *testing.T) { + resp := fiber.Map{"ok": true} + out := setInternalURL(resp, "pro", "postgres://x@y/z", "postgres") + // Same backing map — mutating one reflects in the other. + out["sentinel"] = "v" + if resp["sentinel"] != "v" { + t.Fatalf("setInternalURL must return the same map instance") + } +} func TestProxiedInternalURL(t *testing.T) { cases := []struct { diff --git a/internal/handlers/nosql.go b/internal/handlers/nosql.go index 13fcba7..7e3f3fe 100644 --- a/internal/handlers/nosql.go +++ b/internal/handlers/nosql.go @@ -129,20 +129,23 @@ func (h *NoSQLHandler) NewNoSQL(c *fiber.Ctx) error { connectionURL := h.decryptConnectionURL(existing.ConnectionURL.String, requestID) if connectionURL != "" { metrics.FingerprintAbuseBlocked.Inc() - return c.JSON(fiber.Map{ + // internal_url omitted on the anonymous dedup path — see + // internal_url.go (W11 scrub). + dedupResp := fiber.Map{ "ok": true, "id": existing.ID.String(), "token": existing.Token.String(), "name": existing.Name.String, "connection_url": connectionURL, - "internal_url": proxiedInternalURL(connectionURL, "mongodb"), "tier": existing.Tier, "env": existing.Env, "limits": nosqlAnonymousLimits(), "note": limitExceededNote(upgradeURL, existing.ExpiresAt.Time), "upgrade": upgradeURL, "upgrade_jwt": jwtToken, - }) + } + setInternalURL(dedupResp, existing.Tier, connectionURL, "mongodb") + return c.JSON(dedupResp) } // Empty connection_url means provisioning failed mid-flight on the existing // resource. Fall through to provision a fresh one rather than returning @@ -254,13 +257,13 @@ func (h *NoSQLHandler) NewNoSQL(c *fiber.Ctx) error { nosqlStorageLimitMB := h.plans.StorageLimitMB("anonymous", "mongodb") _, nosqlStorageExceeded, _ := quota.CheckStorageQuota(ctx, h.db, resource.ID, nosqlStorageLimitMB) + // internal_url omitted on the anonymous path — see internal_url.go. nosqlResp := fiber.Map{ "ok": true, "id": resource.ID.String(), "token": tokenStr, "name": resource.Name.String, "connection_url": creds.URL, - "internal_url": proxiedInternalURL(creds.URL, "mongodb"), "tier": "anonymous", "env": resource.Env, "limits": nosqlAnonymousLimits(), @@ -388,7 +391,6 @@ func (h *NoSQLHandler) newNoSQLAuthenticated( "token": resource.Token.String(), "name": resource.Name.String, "connection_url": creds.URL, - "internal_url": proxiedInternalURL(creds.URL, "mongodb"), "tier": tier, "env": resource.Env, "limits": fiber.Map{ @@ -396,6 +398,7 @@ func (h *NoSQLHandler) newNoSQLAuthenticated( "connections": h.plans.ConnectionsLimit(tier, "mongodb"), }, } + setInternalURL(nosqlAuthResp, tier, creds.URL, "mongodb") if nosqlAuthStorageExceeded { nosqlAuthResp["warning"] = "Storage limit reached. Upgrade to continue." c.Set("X-Instant-Notice", "storage_limit_reached") @@ -450,7 +453,6 @@ func (h *NoSQLHandler) ProvisionForTwin(c *fiber.Ctx, in ProvisionForTwinInput) "token": res.Token, "name": res.Name, "connection_url": res.ConnectionURL, - "internal_url": res.InternalURL, "tier": res.Tier, "env": res.Env, "family_root_id": res.FamilyRootID, @@ -459,6 +461,11 @@ func (h *NoSQLHandler) ProvisionForTwin(c *fiber.Ctx, in ProvisionForTwinInput) "connections": res.Limits.Connections, }, } + // Twin pipeline requires an authenticated team — res.Tier is never + // anonymous in practice. Defensive guard preserves the W11 invariant. + if res.Tier != tierAnonymous && res.InternalURL != "" { + resp[internalURLResponseKey] = res.InternalURL + } if res.StorageExceeded { resp["warning"] = "Storage limit reached. Upgrade to continue." c.Set("X-Instant-Notice", "storage_limit_reached") diff --git a/internal/handlers/queue.go b/internal/handlers/queue.go index 160683b..d7e66b9 100644 --- a/internal/handlers/queue.go +++ b/internal/handlers/queue.go @@ -139,20 +139,23 @@ func (h *QueueHandler) NewQueue(c *fiber.Ctx) error { connectionURL := h.decryptConnectionURL(existing.ConnectionURL.String, requestID) if connectionURL != "" { metrics.FingerprintAbuseBlocked.Inc() - return c.JSON(fiber.Map{ + // internal_url omitted on the anonymous dedup path — see + // internal_url.go (W11 scrub). + dedupResp := fiber.Map{ "ok": true, "id": existing.ID.String(), "token": existing.Token.String(), "name": existing.Name.String, "connection_url": connectionURL, - "internal_url": proxiedInternalURL(connectionURL, "queue"), "tier": existing.Tier, "env": existing.Env, "limits": queueAnonymousLimits(), "note": limitExceededNote(upgradeURL, existing.ExpiresAt.Time), "upgrade": upgradeURL, "upgrade_jwt": jwtToken, - }) + } + setInternalURL(dedupResp, existing.Tier, connectionURL, "queue") + return c.JSON(dedupResp) } // Empty connection_url means provisioning failed mid-flight on the existing // resource. Fall through to provision a fresh one rather than returning @@ -254,13 +257,13 @@ func (h *QueueHandler) NewQueue(c *fiber.Ctx) error { metrics.RedisErrors.WithLabelValues("recycle_mark").Inc() } + // internal_url omitted on the anonymous path — see internal_url.go. return c.Status(fiber.StatusCreated).JSON(fiber.Map{ "ok": true, "id": resource.ID.String(), "token": tokenStr, "name": resource.Name.String, "connection_url": creds.URL, - "internal_url": proxiedInternalURL(creds.URL, "queue"), "subject_prefix": creds.SubjectPrefix, "tier": "anonymous", "env": resource.Env, @@ -376,7 +379,6 @@ func (h *QueueHandler) newQueueAuthenticated( "token": resource.Token.String(), "name": resource.Name.String, "connection_url": creds.URL, - "internal_url": proxiedInternalURL(creds.URL, "queue"), "subject_prefix": creds.SubjectPrefix, "tier": tier, "env": resource.Env, @@ -385,6 +387,7 @@ func (h *QueueHandler) newQueueAuthenticated( "storage_mb": h.plans.StorageLimitMB(tier, "queue"), }, } + setInternalURL(resp, tier, creds.URL, "queue") return c.Status(fiber.StatusCreated).JSON(resp) } diff --git a/internal/handlers/vector.go b/internal/handlers/vector.go index d8ec2be..038377c 100644 --- a/internal/handlers/vector.go +++ b/internal/handlers/vector.go @@ -267,13 +267,14 @@ func (h *VectorHandler) NewVector(c *fiber.Ctx) error { connectionURL := h.decryptConnectionURL(existing.ConnectionURL.String, requestID) if connectionURL != "" { metrics.FingerprintAbuseBlocked.Inc() - return c.JSON(fiber.Map{ + // internal_url omitted on the anonymous dedup path — see + // internal_url.go (W11 scrub). + dedupResp := fiber.Map{ "ok": true, "id": existing.ID.String(), "token": existing.Token.String(), "name": existing.Name.String, "connection_url": connectionURL, - "internal_url": proxiedInternalURL(connectionURL, "postgres"), "tier": existing.Tier, "env": existing.Env, "extension": "pgvector", @@ -282,7 +283,9 @@ func (h *VectorHandler) NewVector(c *fiber.Ctx) error { "note": limitExceededNote(upgradeURL, existing.ExpiresAt.Time), "upgrade": upgradeURL, "upgrade_jwt": jwtToken, - }) + } + setInternalURL(dedupResp, existing.Tier, connectionURL, "postgres") + return c.JSON(dedupResp) } slog.Warn("vector.new.dedup_empty_url — provisioning fresh", "token", existing.Token, "request_id", requestID) @@ -390,13 +393,13 @@ func (h *VectorHandler) NewVector(c *fiber.Ctx) error { storageLimitMB := h.plans.StorageLimitMB("anonymous", models.ResourceTypeVector) _, storageExceeded, _ := quota.CheckStorageQuota(ctx, h.db, resource.ID, storageLimitMB) + // internal_url omitted on the anonymous path — see internal_url.go. resp := fiber.Map{ "ok": true, "id": resource.ID.String(), "token": tokenStr, "name": resource.Name.String, "connection_url": creds.URL, - "internal_url": proxiedInternalURL(creds.URL, "postgres"), "tier": "anonymous", "env": resource.Env, "extension": "pgvector", @@ -523,7 +526,6 @@ func (h *VectorHandler) newVectorAuthenticated( "token": tokenStr, "name": resource.Name.String, "connection_url": creds.URL, - "internal_url": proxiedInternalURL(creds.URL, "postgres"), "tier": tier, "env": resource.Env, "dedicated": dedicated, @@ -534,6 +536,7 @@ func (h *VectorHandler) newVectorAuthenticated( "connections": h.plans.ConnectionsLimit(tier, models.ResourceTypeVector), }, } + setInternalURL(authResp, tier, creds.URL, "postgres") if authStorageExceeded { authResp["warning"] = "Storage limit reached. Upgrade to continue." c.Set("X-Instant-Notice", "storage_limit_reached") diff --git a/internal/middleware/idempotency.go b/internal/middleware/idempotency.go index f7626b0..ad0aaa2 100644 --- a/internal/middleware/idempotency.go +++ b/internal/middleware/idempotency.go @@ -54,6 +54,25 @@ import ( // does NOT match the current request body, return 409 conflict (the agent // reused a key for a different request — almost certainly a bug). // +// Precedence vs handler-internal fingerprint dedup (W11, 2026-05-14): the +// middleware sits BEFORE the handler in the per-route chain (see +// internal/router/router.go), so a cached idempotency hit short-circuits +// before the handler's fingerprint-dedup branch ever runs. This is the +// load-bearing ordering for the W11 contract that Idempotency-Key wins +// against fingerprint dedup: +// - With Idempotency-Key + cached: replay the cached token (whatever it +// was on the first call), even if fingerprint dedup would now hand out +// a different existing resource. X-Idempotent-Replay: true. +// - With Idempotency-Key + no cache: handler runs; its fingerprint-dedup +// branch may apply on the first call. The response is then cached so +// subsequent same-key calls replay the same token. +// - Without Idempotency-Key: handler's fingerprint-dedup is the only +// dedup layer. X-Idempotent-Replay is NEVER set on this path — that +// header is reserved exclusively for the idempotency middleware so +// upstream agents can branch reliably on "this was a replay vs a +// fingerprint dedup hit vs a fresh provision". +// E2E coverage: e2e/w11_hardening_e2e_test.go pins all three branches. +// // 5xx responses are NOT cached so retries trigger fresh attempts; 2xx and // 4xx ARE cached (a 402 quota_exceeded should replay so the agent sees // the same upgrade prompt rather than retry-storming the wall).