Compare commits

...
Author SHA1 Message Date
antje becc8e7e39 fix(commerce+o11y): co-resident top-up money path + gen_ai trace resource; bump ai v1.831.1, o11y v1.5.30
- commerce: co-resident billing-account top-up resolves the subject wallet
  in-proc (billing_coresident + csrf + commerceinproc self-dispatch)
- cli: gpu memory reporting (gpu.go + gpu_mem_test.go)
- clients/o11y: trace resource provider (deployment.environment + service attrs)
- go.mod/go.sum: ai v1.830.0 -> v1.831.1 (gen_ai spans nest under the request
  span + always stamp org_id/X-Session-Id), o11y v1.5.28 -> v1.5.30 (querier
  resolve $N trace_id before the trace-summary short-circuit)
2026-07-23 02:42:33 -07:00
10 changed files with 478 additions and 11 deletions
+93
View File
@@ -314,6 +314,99 @@ func mountCommerce(app *zip.App, deps cloud.Deps) error {
commercebilling.DeleteSpendAlert,
)
// POST /v1/billing/topup/token — the INLINE Square card top-up (the console's
// "Billing → Credits → add credits": the Square Web Payments SDK tokenizes the card
// IN THE BROWSER → a single-use nonce → this endpoint charges it and credits the
// caller's balance). commerce's api.Route() billing bundle is NOT compiled into the
// co-resident embed, so — exactly like plans/invoices/spend-alerts above — without
// this registration the POST fell through to the account bridge's /v1/billing/*
// wildcard (order 122). That wildcard is service-token-forwardable for topup/token
// (billing.go billingForwardable), so billingData re-forwarded it to COMMERCE_URL
// (default the public api.hanzo.ai edge = THIS binary) over commerceinproc's
// self-routing transport, re-entering the SAME wildcard until the depth-8 guard
// refused → the "commerceinproc: in-process dispatch depth 8 exceeded" 502 that broke
// top-up outright. Registering commerce's real TopupWithToken co-resident here
// (order 100 < 122) shadows the wildcard and serves the charge in-process at depth 1
// — no HTTP hop, no self-dispatch. topup/token STAYS in billingForwardable as the
// split-deploy fallback (a standalone commerce still serves it); co-residence just
// wins first.
//
// Chain — the browser money-WRITE posture, byte-for-byte what the bridge applied:
// RequireCSRF — the ambient-cookie anti-CSRF gate the bridge's requireCSRF
// wrapped POST /v1/billing/* with (a Bearer/gateway caller is
// not CSRF-able; an ambient-cookie write needs the token).
// RequestContext — the gated request context commerce's handler + ledger read.
// IAMTokenRequired — resolves the org from the gateway-validated X-Org-Id into
// Locals("organization"), which TopupWithToken.GetOrganization
// + topupDestination read as the org billing key.
// PinBillingSubject — pins ?user= to the caller's OWN account.Payer subject (the
// SAME rule the ai spend-gate debits and billingData pins), so
// the credit lands on the caller's subject (person=org/name) and
// can never be widened; fail-closed for an unvalidated caller —
// the IDOR boundary stays exactly where billingData put it.
// The card PAN never touches this binary: TopupWithToken charges the Square nonce only,
// and the settled charge itself is the mint authority (mintauth.WithAuthorized).
app.Post("/v1/billing/topup/token",
accountclient.RequireCSRF(),
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercebilling.TopupWithToken,
)
// The remaining console billing WRITES that share topup/token's self-dispatch loop
// class — each is a POST the console makes (billingForwardable in billing.go), each had
// NO co-resident handler, so each fell through to the account bridge's /v1/billing/*
// wildcard (order 122) and re-entered it over commerceinproc until the depth-8 guard
// refused (the same "in-process dispatch depth 8 exceeded" 502 that broke top-up). Each
// commerce handler exists in the vendored module (v1.49.13); registering them co-resident
// (order 100 < 122) shadows the wildcard and serves the write in-process at depth 1. They
// STAY in billingForwardable as the split-deploy fallback (same precedent as topup/token
// + spend-alerts). Chain matches the bridge's write posture byte-for-byte:
//
// - RequireCSRF — the ambient-cookie anti-CSRF gate the bridge wrapped POST
// /v1/billing/* with (Bearer/gateway callers are not CSRF-able).
// - RequestContext — the gated request context commerce's handlers read.
// - IAMTokenRequired — resolves the org from the gateway-validated X-Org-Id into
// Locals("organization") — the namespace GetOrganization reads.
// - PinBillingSubject — pins the caller's OWN account.Payer subject into BOTH query and
// body AND fail-closes an unvalidated caller. It is the auth gate
// on every one, and the IDOR control on the subject-scoped one.
//
// payment-methods (save a card-on-file / vault a Square nonce) is SUBJECT-scoped: commerce's
// CreatePaymentMethod reads `customerId` from the BODY, so PinBillingSubject's body-pin is
// load-bearing here — a member can only vault a card for their OWN subject, exactly the
// boundary billingData's scopedBillingBody enforced. The Square nonce goes to Square; the
// PAN never touches this binary.
app.Post("/v1/billing/payment-methods",
accountclient.RequireCSRF(),
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercebilling.CreatePaymentMethod,
)
// subscriptions/:id/{cancel,reactivate} are org-NAMESPACE-scoped: commerce's handlers
// resolve the subscription by `:id` WITHIN the caller's org namespace (a foreign org's id
// is a 404 miss), so tenancy is the namespace IAMTokenRequired resolves and PinBillingSubject
// is the fail-closed-anon auth gate — its pinned subject params are ignored by these
// handlers (the SAME role it plays for the org-scoped payment-config read). The bridge's
// subject-pin was likewise a no-op for these, so nothing is dropped.
app.Post("/v1/billing/subscriptions/:id/cancel",
accountclient.RequireCSRF(),
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercebilling.CancelBillingSubscription,
)
app.Post("/v1/billing/subscriptions/:id/reactivate",
accountclient.RequireCSRF(),
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercebilling.ReactivateBillingSubscription,
)
// In-process seams:
// - commerceinproc routes the S2S billing byte-stream into the co-resident
// app (the metering debit path) instead of a socket to a standalone pod.
+72 -6
View File
@@ -312,6 +312,11 @@ func detectGPUs() []gpuInfo {
}
// detectNvidiaGPUs reports NVIDIA accelerators via nvidia-smi (name + total VRAM).
// A discrete card returns a real "<N> MiB" for memory.total; a unified-memory board
// (GB10 "spark" — Grace Blackwell, LPDDR5X) has no discrete VRAM BAR, so memory.total
// reports "[N/A]". In that case fall back to the machine's nominal unified RAM —
// exactly the treatment detectAppleGPU gives Apple Silicon — rounded to the installed
// figure so spark shows a clean "128 GiB", not "[N/A]".
func detectNvidiaGPUs() []gpuInfo {
out, err := exec.Command("nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader").Output()
if err != nil {
@@ -325,24 +330,63 @@ func detectNvidiaGPUs() []gpuInfo {
continue
}
name, mem, _ := strings.Cut(line, ",")
gpus = append(gpus, gpuInfo{Name: strings.TrimSpace(name), MemoryTotal: strings.TrimSpace(mem)})
info := gpuInfo{Name: strings.TrimSpace(name)}
if mib := leadingMiB(mem); mib > 0 { // real discrete VRAM: keep the reported total
info.MemoryTotal = fmt.Sprintf("%d MiB", mib)
} else if ram := detectMemTotal(); ram > 0 { // "[N/A]" unified board → nominal system RAM
info.MemoryTotal = fmt.Sprintf("%d MiB", nominalMemMiB(ram/(1024*1024)))
}
gpus = append(gpus, info)
}
return gpus
}
// leadingMiB extracts the leading integer MiB from an nvidia-smi memory.total token
// ("24576 MiB"), or 0 when it is non-numeric ("[N/A]", empty) — the unified-memory
// signal that routes to the system-RAM fallback.
func leadingMiB(token string) int64 {
var mib int64
if _, err := fmt.Sscan(strings.TrimSpace(token), &mib); err == nil && mib > 0 {
return mib
}
return 0
}
// nominalMemMiB rounds a unified-memory total (in MiB) UP to the nearest nominal
// installed-DIMM capacity, so a kernel MemTotal that reads a few GiB under a round
// figure (128 GiB installed reports ~125 GiB; an amdgpu carveout reads ~118 GiB) is
// reported as the headline the operator expects ("128 GiB", not "125"/"118"). ONLY
// unified-memory paths (Apple / GB10 / Strix Halo) call this — discrete VRAM carries
// its exact reported total. Totals above the known DIMM steps round up to the next
// multiple of 64 GiB.
func nominalMemMiB(rawMiB int64) int64 {
if rawMiB <= 0 {
return 0
}
const gib = int64(1024) // 1 GiB expressed in MiB
for _, n := range []int64{16, 24, 32, 48, 64, 96, 128, 192, 256} {
if rawMiB <= n*gib {
return n * gib
}
}
steps := (rawMiB + 64*gib - 1) / (64 * gib)
return steps * 64 * gib
}
// detectAmdGPUs reports AMD accelerators — discrete Radeon cards and gfx APUs alike
// (e.g. evo's gfx1151 Radeon 8060S on the RYZEN AI MAX+ 395). Resolution order:
// rocm-smi (marketing name + gfx target), then the kfd topology under /sys (gfx
// target from gfx_target_version, GPU nodes only), then a vulkaninfo summary. VRAM
// is filled best-effort from the amdgpu sysfs mem_info_vram_total, positionally.
func detectAmdGPUs() []gpuInfo {
sysRAMMiB := detectMemTotal() / (1024 * 1024)
if out, err := exec.Command("rocm-smi", "--showproductname", "--csv").Output(); err == nil {
if gpus := parseRocmSmiCSV(out); len(gpus) > 0 {
return fillAmdVRAM(gpus, amdVRAMTotals(sysfsDRM))
return fillAmdVRAM(gpus, amdVRAMTotals(sysfsDRM), sysRAMMiB)
}
}
if gpus := parseKfdTopology(sysfsKfdNodes); len(gpus) > 0 {
return fillAmdVRAM(gpus, amdVRAMTotals(sysfsDRM))
return fillAmdVRAM(gpus, amdVRAMTotals(sysfsDRM), sysRAMMiB)
}
if out, err := exec.Command("vulkaninfo", "--summary").Output(); err == nil {
if gpus := parseVulkaninfoSummary(out); len(gpus) > 0 {
@@ -487,18 +531,40 @@ func amdVRAMTotals(drmDir string) []int64 {
return mems
}
// fillAmdVRAM attaches VRAM totals to the GPU list positionally when the counts
// match (the common single-GPU case always does); otherwise the names stand alone.
func fillAmdVRAM(gpus []gpuInfo, memsMiB []int64) []gpuInfo {
// fillAmdVRAM attaches each GPU's headline memory positionally when the counts match
// (the common single-GPU case always does); otherwise the names stand alone. A
// discrete Radeon carries its exact amdgpu VRAM carveout (mem_info_vram_total); a
// unified-memory APU (Strix Halo / "Ryzen AI Max" / gfx1151, e.g. evo) instead reports
// the machine's nominal installed RAM — the carveout undercounts the shared LPDDR5X
// (evo reads ~118 GiB of a 128 GiB box), so the nominal system total is the right
// headline, the same unified-memory treatment as Apple/GB10.
func fillAmdVRAM(gpus []gpuInfo, memsMiB []int64, sysRAMMiB int64) []gpuInfo {
if len(memsMiB) != len(gpus) {
return gpus
}
for i := range gpus {
if unifiedAPU(gpus[i].Name, memsMiB[i], sysRAMMiB) {
gpus[i].MemoryTotal = fmt.Sprintf("%d MiB", nominalMemMiB(sysRAMMiB))
continue
}
gpus[i].MemoryTotal = fmt.Sprintf("%d MiB", memsMiB[i])
}
return gpus
}
// unifiedAPU reports whether an AMD GPU is a unified-memory APU — a Strix Halo /
// "Ryzen AI Max" / gfx1151 part whose real memory is the machine's installed RAM, not
// the amdgpu VRAM carveout. Detected by marketing/gfx name OR by the carveout sitting
// close to (>=85% of) system RAM; a discrete card's dedicated VRAM never does either,
// so it keeps its exact reported total.
func unifiedAPU(name string, carveoutMiB, sysRAMMiB int64) bool {
l := strings.ToLower(name)
if strings.Contains(l, "gfx1151") || strings.Contains(l, "ryzen ai max") || strings.Contains(l, "strix halo") {
return true
}
return sysRAMMiB > 0 && carveoutMiB > 0 && carveoutMiB*100 >= sysRAMMiB*85
}
// parseVulkaninfoSummary is the last-resort AMD path: it scrapes GPU device names
// from `vulkaninfo --summary` (the "deviceName = ..." lines), keeping AMD/Radeon
// devices only so it never double-counts an NVIDIA card already handled upstream.
+96
View File
@@ -0,0 +1,96 @@
package cli
import "testing"
const gib = int64(1024) // 1 GiB expressed in MiB, matching nominalMemMiB
// TestNominalMemMiB — the unified-memory headline rounds a kernel/carveout total UP
// to the nominal installed-DIMM figure, so spark (GB10) and evo (Strix Halo) show a
// clean 128 GiB instead of the ~125/~118 GiB the probes actually read. Rounding is
// scoped to the unified path only; the discrete-VRAM cases here confirm nominal DIMM
// steps are preserved, never inflated past the next step.
func TestNominalMemMiB(t *testing.T) {
for _, c := range []struct {
name string
rawMiB int64
wantMiB int64
}{
{"amd carveout ~118GiB → 128", 118 * gib, 128 * gib}, // evo Strix Halo carveout
{"kernel MemTotal ~125GiB → 128", 125 * gib, 128 * gib}, // GB10 / 128GiB box under-read
{"fractional 15.5GiB → 16", 15872, 16 * gib}, // 15.5 GiB in MiB
{"exact 128GiB stays 128", 128 * gib, 128 * gib},
{"nominal 24GiB stays 24", 24 * gib, 24 * gib}, // a 24GB step is itself nominal
{"64GiB stays 64", 64 * gib, 64 * gib},
{"200GiB → 256 step", 200 * gib, 256 * gib},
{"above steps rounds to 64 multiple", 300 * gib, 320 * gib},
{"zero stays zero", 0, 0},
} {
if got := nominalMemMiB(c.rawMiB); got != c.wantMiB {
t.Errorf("%s: nominalMemMiB(%d) = %d, want %d", c.name, c.rawMiB, got, c.wantMiB)
}
}
}
// TestLeadingMiB — nvidia-smi's memory.total is numeric "<N> MiB" on a discrete card
// and "[N/A]" on a unified GB10; leadingMiB is the guard that routes only the latter
// to the system-RAM fallback.
func TestLeadingMiB(t *testing.T) {
for _, c := range []struct {
token string
want int64
}{
{"24576 MiB", 24576}, // discrete card, with units
{"81920 MiB", 81920}, // H100-class
{"[N/A]", 0}, // GB10 unified — no VRAM BAR
{"N/A", 0},
{"", 0},
{" ", 0},
} {
if got := leadingMiB(c.token); got != c.want {
t.Errorf("leadingMiB(%q) = %d, want %d", c.token, got, c.want)
}
}
}
// TestUnifiedAPU — a Strix Halo / gfx1151 APU (by name, or by its carveout hugging
// system RAM) reports installed RAM; a discrete Radeon with dedicated VRAM does not.
func TestUnifiedAPU(t *testing.T) {
for _, c := range []struct {
name string
gpuName string
carveoutMiB int64
sysRAMMiB int64
want bool
}{
{"evo by gfx name", "Radeon 8060S Graphics (gfx1151)", 118 * gib, 125 * gib, true},
{"kfd gfx name", "AMD GPU (gfx1151)", 0, 125 * gib, true},
{"ryzen ai max marketing", "AMD Ryzen AI Max+ 395", 118 * gib, 125 * gib, true},
{"carveout hugs sysram", "Some APU", 118 * gib, 125 * gib, true},
{"discrete 24GB in 128GB box", "Radeon RX 7900 XTX (gfx1100)", 24 * gib, 128 * gib, false},
{"discrete no sysram", "Radeon RX 7900 XTX (gfx1100)", 24 * gib, 0, false},
} {
if got := unifiedAPU(c.gpuName, c.carveoutMiB, c.sysRAMMiB); got != c.want {
t.Errorf("%s: unifiedAPU(%q, %d, %d) = %v, want %v", c.name, c.gpuName, c.carveoutMiB, c.sysRAMMiB, got, c.want)
}
}
}
// TestFillAmdVRAM — the discrete card keeps its exact carveout; the gfx1151 APU's
// headline becomes the nominal installed RAM (118 GiB carveout → 128 GiB).
func TestFillAmdVRAM(t *testing.T) {
// Unified APU: carveout undercounts, headline is nominal system RAM.
apu := fillAmdVRAM([]gpuInfo{{Name: "Radeon 8060S Graphics (gfx1151)"}}, []int64{118 * gib}, 125*gib)
if got, want := apu[0].MemoryTotal, "131072 MiB"; got != want { // 128 GiB
t.Errorf("APU headline = %q, want %q", got, want)
}
// Discrete card: exact carveout preserved, never rounded.
dgpu := fillAmdVRAM([]gpuInfo{{Name: "Radeon RX 7900 XTX (gfx1100)"}}, []int64{24 * gib}, 128*gib)
if got, want := dgpu[0].MemoryTotal, "24576 MiB"; got != want {
t.Errorf("discrete headline = %q, want %q", got, want)
}
// Count mismatch: names stand alone (no memory attached).
mismatch := fillAmdVRAM([]gpuInfo{{Name: "A"}, {Name: "B"}}, []int64{4096}, 128*gib)
if mismatch[0].MemoryTotal != "" || mismatch[1].MemoryTotal != "" {
t.Errorf("count mismatch should leave memory empty, got %+v", mismatch)
}
}
+16 -2
View File
@@ -46,8 +46,12 @@ import (
// - Neither: refuse. A bearer-less request with a forged X-Org-Id has no validated
// principal and is fail-closed here, before the read handler runs.
//
// The pin rewrites the request URI's query string in place; fasthttp's SetQueryString
// resets the parsed-args cache, so the handler's later c.Query() reads the pinned values.
// The pin rewrites the request URI's query string AND (on a write) the JSON body in place;
// fasthttp's SetQueryString resets the parsed-args cache and SetBody replaces the body
// bytes, so the handler's later c.Query() / c.Bind() read the pinned values. Pinning the
// body is what keeps a co-resident WRITE handler that reads its subject from the body
// (commerce's CreatePaymentMethod reads customerId from the JSON body) IDOR-safe — query-only
// pinning would leave a client-named customerId/userId in the body untouched.
func PinBillingSubject() zip.Handler {
return func(c *zip.Ctx) error {
inQuery, _ := url.ParseQuery(string(c.Fiber().Request().URI().QueryString()))
@@ -69,7 +73,17 @@ func PinBillingSubject() zip.Handler {
Account: principal.BillingAccount(c),
}).Subject()
// Pin the subject on BOTH the query AND the write body — the SAME two-helper
// scoping billingData applies (scopedBillingSearch + scopedBillingBody). A
// co-resident WRITE handler that reads its subject from the body (commerce's
// CreatePaymentMethod → customerId) is only IDOR-safe if the body is pinned too.
// scopedBillingBody overwrites the subject keys and preserves every other field
// (card, type, sourceId, …); a non-JSON / empty body is returned unchanged, so a
// GET read carries no body and is unaffected.
c.Fiber().Request().URI().SetQueryString(scopedBillingSearch(inQuery, subject).Encode())
if len(c.Body()) > 0 {
c.Fiber().Request().SetBody(scopedBillingBody(c.Body(), subject))
}
return c.Next()
}
}
@@ -29,6 +29,17 @@ func echoQuery(c *zip.Ctx) error {
})
}
// echoBody is the downstream stand-in for a commerce WRITE handler (e.g. CreatePaymentMethod)
// that reads its subject from the JSON BODY: it reports the body it observes AFTER the pin, so
// a test can assert the subject the handler would persist and that non-subject fields survive.
func echoBody(c *zip.Ctx) error {
var got map[string]any
if len(c.Body()) > 0 {
_ = json.Unmarshal(c.Body(), &got)
}
return c.JSON(200, got)
}
func pinApp(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
@@ -38,6 +49,7 @@ func pinApp(t *testing.T) *zip.App {
t.Fatalf("MountAccount: %v", err)
}
app.Get("/probe", PinBillingSubject(), echoQuery)
app.Post("/probe", PinBillingSubject(), echoBody)
return app
}
@@ -67,6 +79,33 @@ func TestPinBillingSubject_PinsCallerAndDropsOrg(t *testing.T) {
}
}
// TestPinBillingSubject_PinsBodyForWrites — a POST whose JSON body names a FOREIGN subject
// (customerId/userId/user) has every subject key overwritten with the caller's OWN subject
// before the handler binds it, while non-subject fields survive. This is the boundary
// commerce's CreatePaymentMethod (which reads customerId from the body) relies on to be
// IDOR-safe co-resident — byte-identical to billingData's scopedBillingBody on the bridge.
func TestPinBillingSubject_PinsBodyForWrites(t *testing.T) {
app := pinApp(t)
code, body := callH(t, app, http.MethodPost, "/probe", alice,
`{"customerId":"victim","userId":"victim","user":"victim","card":{"last4":"4242"}}`)
if code != http.StatusOK {
t.Fatalf("want 200, got %d (%s)", code, body)
}
var got map[string]any
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("bad body: %s", body)
}
for _, k := range billingSubjectKeys {
if got[k] != "acme" { // alice/acme resolves to the org subject "acme"
t.Fatalf("handler must see body %s=acme (caller's own subject), got %v", k, got[k])
}
}
card, ok := got["card"].(map[string]any)
if !ok || card["last4"] != "4242" {
t.Fatalf("non-subject body field must survive the pin, got card=%v", got["card"])
}
}
// TestPinBillingSubject_RefusesUnvalidated — a forged X-Org-Id with NO validated
// X-User-Id (and no service token) is refused before the read handler runs: no
// cross-tenant billing read is possible.
+17
View File
@@ -173,6 +173,23 @@ func requireCSRF(s *cloud.Service[state], next zip.Handler) zip.Handler {
}
}
// RequireCSRF exposes the ambient-cookie anti-CSRF gate as a STANDALONE middleware for a
// co-resident money-WRITE route registered OUTSIDE this package — specifically
// apps/commerce.go's POST /v1/billing/topup/token, which shadows the account-bridge's
// POST /v1/billing/* wildcard (order 100 < 122) that would otherwise have wrapped the
// write in requireCSRF. Moving the route co-resident to break the commerceinproc
// self-dispatch loop must NOT silently drop that anti-CSRF gate, so the identical
// enforcement rides along as its own handler. It binds to the SAME process-wide key
// (sharedCSRFKey) the GET /v1/csrf issuer and the bridge verifier use, so a token minted
// at /v1/csrf verifies here byte-identically. Enforces ONLY on the ambient-cookie path (a
// Bearer/gateway/API caller is not CSRF-able); on success it c.Next()s into the rest of
// the chain. The minimal Service carries only the shared key — requireCSRF/verifyCSRF
// read nothing else off it.
func RequireCSRF() zip.Handler {
s := &cloud.Service[state]{State: state{csrfKey: sharedCSRFKey(nil)}}
return requireCSRF(s, func(c *zip.Ctx) error { return c.Next() })
}
// issueCSRFToken serves GET /v1/csrf: for a VALIDATED caller, a fresh token
// bound to their identity. no-store so it is never cached by a shared proxy. This is
// the same-origin endpoint the embedded SPA reads (its response body is unreadable to
+121
View File
@@ -186,3 +186,124 @@ func TestSpendAlertsAuthorizeShadowsBridge(t *testing.T) {
}
})
}
// assertPostShadowsBridge is the shared body of every "co-resident POST billing WRITE shadows
// the self-proxying /v1/billing/* bridge wildcard" regression — the whole class the P0 that
// broke console Billing → Credits belonged to. specificPattern is the route as registered
// co-resident (may carry a :param); requestPath is the concrete path the browser POSTs.
//
// It models both arrangements on the POST transport this seam owns:
// - build(false) registers ONLY the account bridge's self-proxying POST /v1/billing/*
// wildcard (like MountBridge at order 122): billingData → commerceDo re-dials COMMERCE_URL
// (the public edge = this binary) BY PATH through THIS transport, re-entering the wildcard
// until the depth-8 guard refuses — the exact user-facing 502.
// - build(true) ALSO registers the specific commerce route FIRST (like mountCommerce at
// order 100), so it shadows the wildcard and the write runs once at depth 1 — no loop.
func assertPostShadowsBridge(t *testing.T, specificPattern, requestPath string) {
t.Helper()
const okBody = `{"status":"ok"}`
newReq := func() *http.Request {
req, _ := http.NewRequest(http.MethodPost, BaseURL("")+requestPath, strings.NewReader(`{"sourceId":"cnon:card-nonce-ok"}`))
req.Header.Set("Authorization", "Bearer service-token")
req.Header.Set("X-Org-Id", "hanzo")
return req
}
build := func(withSpecific bool) (app *zip.App, specificHits, wildcardHits *int32) {
var sHits, wHits int32
app = zip.New(zip.Config{})
if withSpecific {
app.Post(specificPattern, func(c *zip.Ctx) error {
atomic.AddInt32(&sHits, 1)
return c.Bytes(http.StatusOK, []byte(okBody))
})
}
app.Post("/v1/billing/*", func(c *zip.Ctx) error {
atomic.AddInt32(&wHits, 1)
resp, err := Client(5 * time.Second).Do(newReq())
if err != nil { // the depth guard's refusal surfaces here — the 502 the user saw.
return c.Bytes(http.StatusBadGateway, []byte(err.Error()))
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return c.Bytes(resp.StatusCode, body)
})
return app, &sHits, &wHits
}
do := func() (int, string) {
resp, err := Client(5 * time.Second).Do(newReq())
if err != nil {
t.Fatalf("dispatch: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(body)
}
// BUG (no co-resident handler): the wildcard self-loops until the depth guard refuses, so
// it is entered exactly maxDepth times and the outer request surfaces the depth-exceeded
// 502 carrying the EXACT re-entrancy refusal for THIS route.
t.Run("bug_wildcard_self_loops", func(t *testing.T) {
t.Cleanup(func() { SetHandler(nil) })
app, specificHits, wildcardHits := build(false)
SetApp(app)
status, body := do()
t.Logf("no-shadow %s => status=%d wildcardHits=%d body=%s", requestPath, status, atomic.LoadInt32(wildcardHits), body)
if got := atomic.LoadInt32(specificHits); got != 0 {
t.Fatalf("specific hits = %d, want 0 (no specific route registered)", got)
}
if got := atomic.LoadInt32(wildcardHits); got != maxDepth {
t.Fatalf("wildcard entered %d times, want maxDepth=%d (the self-dispatch loop)", got, maxDepth)
}
if status != http.StatusBadGateway ||
!strings.Contains(body, "dispatch depth") ||
!strings.Contains(body, "POST "+requestPath) {
t.Fatalf("outer status=%d body=%q, want 502 carrying the depth-exceeded refusal for POST %s", status, body, requestPath)
}
})
// FIX (co-resident specific route registered ahead of the wildcard): it shadows the
// wildcard, so the handler runs once at depth 1, the wildcard's self-proxy NEVER fires,
// and the write returns 200 — no loop, no depth-8 502.
t.Run("fix_specific_route_shadows_wildcard", func(t *testing.T) {
t.Cleanup(func() { SetHandler(nil) })
app, specificHits, wildcardHits := build(true)
SetApp(app)
status, body := do()
t.Logf("shadowed %s => status=%d specificHits=%d wildcardHits=%d body=%s",
requestPath, status, atomic.LoadInt32(specificHits), atomic.LoadInt32(wildcardHits), body)
if got := atomic.LoadInt32(specificHits); got != 1 {
t.Fatalf("specific route entered %d times, want exactly 1 (served in-process at depth 1)", got)
}
if got := atomic.LoadInt32(wildcardHits); got != 0 {
t.Fatalf("wildcard self-dispatch fired %d times, want 0 — the specific route must shadow it", got)
}
if status != http.StatusOK {
t.Fatalf("outer status = %d, want 200 (the co-resident write, no self-dispatch 502)", status)
}
})
}
// TestTopupTokenShadowsBridge — the reported P0: POST /v1/billing/topup/token (inline Square
// card top-up) self-dispatched to the depth-8 502 until commerce's TopupWithToken was
// registered co-resident (apps/commerce.go mountCommerce).
func TestTopupTokenShadowsBridge(t *testing.T) {
assertPostShadowsBridge(t, "/v1/billing/topup/token", "/v1/billing/topup/token")
}
// TestPaymentMethodsShadowsBridge — the save-card write (POST /v1/billing/payment-methods →
// commerce CreatePaymentMethod), same self-dispatch class, now shadowed co-resident.
func TestPaymentMethodsShadowsBridge(t *testing.T) {
assertPostShadowsBridge(t, "/v1/billing/payment-methods", "/v1/billing/payment-methods")
}
// TestSubscriptionCancelShadowsBridge — POST /v1/billing/subscriptions/:id/cancel →
// commerce CancelBillingSubscription, same class, now shadowed co-resident.
func TestSubscriptionCancelShadowsBridge(t *testing.T) {
assertPostShadowsBridge(t, "/v1/billing/subscriptions/:id/cancel", "/v1/billing/subscriptions/sub_1/cancel")
}
// TestSubscriptionReactivateShadowsBridge — POST /v1/billing/subscriptions/:id/reactivate →
// commerce ReactivateBillingSubscription, same class, now shadowed co-resident.
func TestSubscriptionReactivateShadowsBridge(t *testing.T) {
assertPostShadowsBridge(t, "/v1/billing/subscriptions/:id/reactivate", "/v1/billing/subscriptions/sub_1/reactivate")
}
+18 -1
View File
@@ -98,7 +98,13 @@ func installTraceProvider(ctx context.Context, serviceName string) func(context.
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithResource(resource.NewSchemaless(attribute.String("service.name", serviceName))),
sdktrace.WithResource(resource.NewSchemaless(
attribute.String("service.name", serviceName),
// deployment.environment on the resource so o11y's Environment column
// resolves instead of defaulting to "default". Every span this provider
// exports (cloud's own + the adopted ai gen_ai spans) inherits it.
attribute.String("deployment.environment", deploymentEnvironment()),
)),
)
otel.SetTracerProvider(tp)
@@ -141,3 +147,14 @@ func firstNonEmptyEnv(keys ...string) string {
}
return ""
}
// deploymentEnvironment resolves the process deployment environment for the OTel
// resource. Env-overridable (DEPLOYMENT_ENVIRONMENT / OTEL_DEPLOYMENT_ENVIRONMENT /
// ENVIRONMENT); defaults to "production" because this provider installs only when a
// sink/wire is configured — i.e. a real deployment.
func deploymentEnvironment() string {
if v := firstNonEmptyEnv("DEPLOYMENT_ENVIRONMENT", "OTEL_DEPLOYMENT_ENVIRONMENT", "ENVIRONMENT"); v != "" {
return v
}
return "production"
}
+2 -2
View File
@@ -756,12 +756,12 @@ require (
github.com/hanzo-ds/go v1.0.1
github.com/hanzo-ds/native v0.72.0 // indirect
github.com/hanzoai/agent v0.1.3
github.com/hanzoai/ai v1.830.0
github.com/hanzoai/ai v1.831.1
github.com/hanzoai/authz v1.10.7
github.com/hanzoai/base v1.5.7
github.com/hanzoai/licensing v0.1.5
github.com/hanzoai/metrics v1.110.2
github.com/hanzoai/o11y v1.5.28
github.com/hanzoai/o11y v1.5.30
github.com/hanzoai/thinking v0.1.1 // indirect
github.com/hanzoai/vfs v0.6.4
github.com/hanzoai/zen v1.4.2
+4
View File
@@ -1061,6 +1061,8 @@ github.com/hanzoai/agent v0.1.3 h1:zzV4t8kN/m/wTLrqzEy0fxxONSZbx3XSVH7TIR9gZNU=
github.com/hanzoai/agent v0.1.3/go.mod h1:Z3hCBdSeN/nGV4o+3F4psQ2bbFk17+tMP5l+G2ssNNA=
github.com/hanzoai/ai v1.830.0 h1:l7bZwuEPuql+z3aE+nmn/Ei24+MqXoJXL32Z3W+vueQ=
github.com/hanzoai/ai v1.830.0/go.mod h1:BeIlJNoqJ38l77meu6pgYfZqnxdebqcDJlZNgvwDJ8w=
github.com/hanzoai/ai v1.831.1 h1:Ik9RYAOYgjsSsEfhgj4/ggJ0+OyYUBV9uYLB7tIWGgA=
github.com/hanzoai/ai v1.831.1/go.mod h1:iZMupp7r/NsFt8kb3BXCj/0bpu0E82fIalK2UyEsKfw=
github.com/hanzoai/authz v1.10.7 h1:JrHljH29mbmVi8u6/6EVG7R0NiFhIYYm2WUBBuBmFq0=
github.com/hanzoai/authz v1.10.7/go.mod h1:9wf6n6BvrvxRULUtL3yc+vmprwwvAGZLGJoMHhiQSK4=
github.com/hanzoai/base v1.5.7 h1:490temFA2Bz4/QD5lWRKWFWJp+k7FfIJCOi3FXMA80w=
@@ -1117,6 +1119,8 @@ github.com/hanzoai/notify v1.6.18 h1:YLIKheJSMhGqRuo7NRsMicHjAWSVFV6j6ZGqm2H+IBM
github.com/hanzoai/notify v1.6.18/go.mod h1:O8OZj1cfUAIY39ROTPpiaVH8jv947VNfAGor2AZ/ebQ=
github.com/hanzoai/o11y v1.5.28 h1:kOqV7vdDdezB+q9YEvrVOq3kviraVs1Pst7IsD0LbI0=
github.com/hanzoai/o11y v1.5.28/go.mod h1:0N4ISLvuFsN/Dw74uWwNH3yBTsqBYB+BAgqfBJToyZ0=
github.com/hanzoai/o11y v1.5.30 h1:jh0BSAijR98eT5lvhq5/o3sRTU+YsVHtrGVym+a7cKo=
github.com/hanzoai/o11y v1.5.30/go.mod h1:lI8yn6GRGJ4ZK2CEfuduZ9yGrr7QFo3AZr7FrFwg0M8=
github.com/hanzoai/orm v0.6.1 h1:PELYVy+kTVuA7hqn1y3IQqR1Q5cTk008Wh4CLn9Isok=
github.com/hanzoai/orm v0.6.1/go.mod h1:7tXULhLKymkAwlC+jASS66tlLEzU2sdCXX1sRFPoAFs=
github.com/hanzoai/otel-collector v1.2.0 h1:lBDL5lKotq89JaqchcM+/oxEnjrjazEI2JJfDhotudc=