preflight admits the four headers the console actually sends

X-Actor-Id, X-Act-As-Project, X-Act-As-Org and X-CSRF-Token are stamped on every
signed-in console call (client.ts baseHeaders + applyCsrfToInit) and none of them
were in corsAllowHeaders. A header absent from that list fails PREFLIGHT: the
browser reports an opaque "TypeError: Failed to fetch" and the request never
reaches a server log, so there is nothing to find on our side. Measured from a
signed-in console.hanzo.ai page: adding any ONE of the four blocked the call,
the identical request without it returned 200.

Naming a header only lets the browser SEND it. Each stays exactly as trustworthy
as before — SanitizeIdentity still strips and re-mints client-supplied identity,
so an intent is validated, never believed.

Taken as a one-hunk cherry-pick rather than by merging PR #385: that branch is
443 files and 49,500 insertions off an ancient base, and merging it would drop
~17 commerce prefixes from manifest/apps.go and revert usage_coresident.go's
FloorMinor back to RoundMinor.

NOTE: this constant's own doc says it mirrors hanzoai/gateway routes.go
corsPreflightMiddleware so the browser contract is byte-identical through either
path. The gateway needs the same four or the mirror is broken.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
2026-08-06 01:27:29 -07:00
parent bd4115d323
commit 702ecbf7a6
+20 -1
View File
@@ -56,9 +56,28 @@ import (
// allowed brand host works and every other origin gets no CORS headers.
const (
corsAllowMethods = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
// The list must name EVERY header a browser client actually sends: a header
// absent here fails PREFLIGHT, and the browser reports it as an opaque
// "TypeError: Failed to fetch" with no server-side log — the request never
// arrives. Measured against production before this line changed: from a
// signed-in console.hanzo.ai page, adding any one of X-Actor-Id,
// X-Act-As-Project, X-Act-As-Org or X-CSRF-Token blocked the call, while the
// identical request without it returned 200.
//
// Those four are what the console stamps on a signed-in call (console
// src/lib/api/client.ts baseHeaders + applyCsrfToInit):
// X-Actor-Id — the signed-in user, on EVERY authenticated request
// X-Act-As-Project — project sub-scope INTENT (a request, never a claim)
// X-Act-As-Org — org-switch INTENT, same shape
// X-CSRF-Token — echoed on every mutating write the ambient-cookie path makes
//
// Naming a header here only lets the browser SEND it; each stays exactly as
// trustworthy as before — SanitizeIdentity still strips and re-mints
// client-supplied identity, so an intent is still validated, never believed.
corsAllowHeaders = "Content-Type, Authorization, X-User-Id, X-Org-Id, " +
"X-Project-Id, X-Environment, X-Roles, X-User-Email, X-Request-ID, " +
"X-Client-ID, X-Requested-With, Accept, Accept-Language"
"X-Client-ID, X-Requested-With, Accept, Accept-Language, " +
"X-Actor-Id, X-Act-As-Project, X-Act-As-Org, X-CSRF-Token"
corsMaxAge = "86400"
)