Documentation
¶
Overview ¶
Package account mounts the signed-in caller's OWN account self-service surface natively in the unified cloud binary — the Go port of the console's two NON-proxy Next server routes (app/keys + app/onboard) plus the money/store data bridges the statically-exported console needs (task #41, "True 1-binary FE"). It replaces the retired /v1/console/* namespace: "console" is just the cloud FE name, so there is NO /v1/console API domain — every route lives on its REAL domain.
WHY THESE ROUTES (and not the pure passthrough proxies). The console's PURE BFF reverse-proxies — app/cloud, app/ai — vanish in the one-binary model: the SPA calls the canonical /v1/* on its own origin and the already-mounted subsystems answer. The routes ported HERE do REAL server work a static SPA cannot: keys/onboard run privileged IAM logic as the confidential `hanzo-console` client; embed-status/topup do server-side verification; and the billing/commerce bridges inject the commerce SERVICE token and pin the caller's own subject SERVER-SIDE (a passthrough would leak cross-tenant ledgers). Each has no pure-proxy equivalent, so it must be ported.
SURFACE — each route on its REAL domain (every one requires a VALIDATED principal — a gateway-minted, IAM-verified X-User-Id; a client-forged X-Org-Id on the bearer-less path is refused):
GET /v1/keys — the caller's keys: { keys: [{ type, prefix, createdAt }] }; no secret.
POST /v1/keys — create/rotate a key of { type: publishable | secret }; returns it ONCE.
DELETE /v1/keys — revoke the key of that type.
… /v1/iam/keys — DEPRECATED aliases of the three above (same handlers).
POST /v1/iam/onboard — create the caller's org (+ move them in on first run).
GET /v1/csrf — mint the anti-CSRF token the SPA echoes on money writes (csrf.go).
GET /v1/embed-status — brand-app embed entitlement + reachability probe (embed.go).
POST /v1/commerce/topup/wallet — HUSD on-chain verify → commerce credit (topup.go).
GET /v1/billing/* — per-tenant billing read, SCOPED to the validated caller (billing.go).
… /v1/commerce/* — per-tenant STORE CRUD, SCOPED to the validated caller's org (commerce.go).
TWO SUBSYSTEM REGISTRATIONS FROM ONE PACKAGE. A route-ordering constraint forces the split (Fiber matches by registration order — the earliest-mounted route wins):
- `account` (order 48) mounts the SPECIFIC self-service routes. keys/onboard MUST win over clients/iam's /v1/iam/* WILDCARD (order 50), and topup MUST win over the commerce embed (order 100) + the /v1/commerce/* bridge — so they mount EARLY.
- `account-bridge` (order 122) mounts the CATCH-ALL data bridges. /v1/billing/* must sit AFTER clients/billing's specific routes (order 121) and /v1/commerce/* after the commerce embed (order 100) — so they mount LATE.
Both share one state shape + the process-wide CSRF key (csrf.go), so a token minted at /v1/csrf verifies on the /v1/billing|commerce writes.
TENANCY. The caller is resolved from the VALIDATED identity headers ONLY (principal.Validated / c.Org() / c.User()), the same trust boundary every mutating subsystem uses. The IAM id targeted is DERIVED as `<owner>/<name>` from those validated claims — never taken from the request body/query — so a caller can only ever mint/revoke their OWN key and onboard THEMSELVES; there is no path to name a third-party subject. When the confidential client is unwired the surface is honestly "not configured" (501), never a fabricated key or org.
billing.go — the per-tenant billing DATA bridge, the Go port of console's app/billing/v1/[...path]/route.ts (task #41, the BFF catch-all sweep). It lets the statically-exported console reach its own money surface at the CANONICAL same-origin /v1/billing/* (nothing before /v1/): GET|POST /v1/billing/<path> forwards to commerce's /v1/billing/<path> with the admin COMMERCE_SERVICE_TOKEN, SCOPING every request to the VALIDATED caller's own billing subject — so a tenant can only ever read/act on its OWN ledger (balance / usage / invoices / subscriptions / payment-methods / spend-alerts / …), never another's.
TWO INDEPENDENT BOUNDS, because the token makes this a privileged forwarder:
- WHICH ENDPOINT — billingForwardable, the per-method allowlist below. It is the authorization gate: an unlisted path is 404'd before the token is ever attached, so no money-MINT route (deposit/credit/refund/…) can be reached through this bridge.
- WHOSE DATA — the subject-pinning below. It aims a permitted call at the caller's own ledger. It is an IDOR control and NOT an authority control: on a mint route it would have pinned the CREDIT to the attacker's own account. (1) is what stops that.
WHY A SERVER HANDLER (not a same-origin passthrough). Commerce's billing surface is service-token-gated and filters DIFFERENT endpoints on DIFFERENT subject params — subscriptions on ?userId, payment-methods on ?customerId, usage on ?user. Pinning only ONE leaves the others UNFILTERED, so a request with no (or a forged) param returns every subject's rows in the namespace. This handler pins ALL of them to the server-resolved subject (and drops ?org), on the query AND the write body — exactly mirroring console's billing-scope.ts and commerce's own edge-auth billingSubjectKeys.
IDOR-safe: the subject is derived from the VALIDATED identity (resolveCaller → principal.Validated / c.Org() / c.User()), NEVER a client-supplied userId/org. A bearer-less request with a forged X-Org-Id has no validated principal and is refused.
billing_coresident.go — PinBillingSubject, the subject-pinning middleware that lets commerce's OWN billing READ handlers serve co-resident in the unified cloud binary.
WHY IT EXISTS. Co-resident, commerce is EMBEDDED (apps/commerce.go mountCommerce → commerce.Embed on the shared zip app), and in prod there is NO standalone commerce backend — the in-cluster `commerce` Service selects the cloud pods themselves. So the /v1/billing/* bridge (billing.go), which forwards to COMMERCE_URL, has nowhere to send a read but back into cloud: the default base (the public api.hanzo.ai edge) re-enters the same bridge in an unbounded self-dispatch loop that surfaces as a 502 ("billing upstream unreachable: Get https://api.hanzo.ai/v1/billing/<path>"). The fix is to serve those reads co-resident from the embedded commerce — the same co-resident move mountCommerce already makes for GET /v1/billing/plans — so the specific route shadows the bridge wildcard (order 100 < 122) and never leaves the process.
WHAT IT GUARANTEES. commerce's read handlers scope to the org by NAMESPACE (from the gateway-validated X-Org-Id via iammiddleware) but filter finer scope (the billing subject) only from a query param — an UNPINNED ListInvoices returns every user's rows in the org namespace. The /v1/billing/* bridge is what pins that subject today; this middleware carries the SAME pin onto the co-resident route so the isolation is byte-for-byte the shipped behavior. It is the ONE subject rule (account.Payer, the same function the ai spend-gate and the top-up resolve), fed the account the credential NAMES — so a read scopes to exactly the account the gate debits, never wider.
commerce.go — the per-tenant STORE data bridge, the Go port of console's app/commerce/[...path]/route.ts (task #41, the BFF catch-all sweep; the store twin of billing.go). It lets the statically-exported console reach its merchant store at the CANONICAL same-origin /v1/commerce/* (nothing before /v1/): GET|POST|PUT|PATCH| DELETE /v1/commerce/<path> forwards to commerce's bare store surface /v1/<path> with the admin COMMERCE_SERVICE_TOKEN, SCOPING every request to the VALIDATED caller's own org — so a merchant only ever reads/writes its OWN org's catalog (products / orders / customers / variants / collections / discounts / storefront), never another's.
WHY /v1/commerce/<x> → commerce /v1/<x> (the `commerce` segment is DROPPED, not preserved like billing's /v1/billing/<x> → /v1/billing/<x>). The DEPLOYED commerce binary (hanzoai/commerce cmd/commerced) mounts its whole REST surface with `api.Route(router.Group("/v1"))`: the store models live at BARE /v1/<kind> (/v1/product, /v1/order, /v1/user, …) while money lives at /v1/billing/*. The console namespaces the store under /v1/commerce/* only to keep the generic store heads (product/order/user/store) from colliding with the rest of the /v1 surface; this bridge strips that console-side namespace and forwards to commerce's real bare head — EXACTLY the mapping console's next.config rewrite already proved live (`/v1/commerce/:path*` → `/commerce/v1/:path*` → commerce.svc/v1/:path*).
WHY A SERVER HANDLER (not a same-origin passthrough). Commerce's store is service-token-gated: its EdgeAuth resolves the org from the X-Org-Id header ONLY after it verifies the bearer is the COMMERCE_SERVICE_TOKEN, then scopes every store row to that org. A browser passthrough would have to carry that admin token (a cross-tenant skeleton key) or a per-tenant selector the browser could forge — either leaks another org's store. This handler injects the token SERVER-SIDE and pins the org to the caller's own, so tenancy can never be crossed from the browser.
IDOR-safe: the org is derived from the VALIDATED identity (resolveCaller → principal.Validated / c.Org() / c.User()), NEVER a client-supplied value. A bearer-less request with a forged X-Org-Id has no validated principal and is refused (403) BEFORE any commerce call — the exact off-gateway forge principal.Validated closes. Least privilege on the path: only the merchant store heads are reachable, so this bridge can NOT tunnel to /v1/billing (its own subject-scoped bridge), /v1/checkout (the money path), or /v1/_/commerce/tenants (tenant admin) — mirroring console's proxy-allow.ts allowCommerceSurface.
embed.go ports console's app/embed-status/route.ts into the unified binary at GET /v1/embed-status (task #41). It answers ONE question for the console's data-product modules (Content Studio / ERP / Help Center): is this brand's shared embedded app provisioned and reachable, so the module can decide embed-vs-provision panel? A cross-origin browser can't read another origin's status (SOP + CORS), so this server route probes it once and returns an honest verdict.
TWO real jobs (why it is a handler, not a vanishing proxy):
ENTITLEMENT (server-authoritative). cms/erp/help are each a SINGLE shared per-BRAND instance, so only a member of the owning brand org — or a SuperAdmin — may frame them; a customer org gets the honest provision panel, never a cross-tenant frame. The caller's org is the VALIDATED X-Org-Id (never a browser claim); the owning org is the deployment brand.
SSRF SAFETY. The probe target is ALWAYS `<app>.<brand-domain>` where the brand is the deployment's OWN brand (deps.Brand, fixed at deploy) and app ∈ {cms,erp,help}. There is NO client-controlled host in the target at all — a forged Host header can never steer this into probing an arbitrary origin (strictly tighter than route.ts, which clamped a client Host).
iam.go is the ONE HTTP path from the console subsystem to Hanzo IAM, acting as the confidential first-party `hanzo-console` client (client_secret_basic). It ports the privileged IAM primitives that console's server-only src/lib/server/identity.ts drove — mint/revoke/get the per-user Cloud API key and create/read/update an organization — so those standalone Next server routes can be retired and console statically exported (task #41, "True 1-binary FE").
WHY A CONFIDENTIAL CLIENT (and not the caller's own token). These ops are privileged: `mint-user-keys` writes a user's AccessKey, `add-organization` creates a tenant and moves the user in. IAM authorizes them for an app that is allow-listed (IAM_KEY_MINT_ALLOWED_APPS / IAM_ORG_ADMIN_APPS / IAM_USER_ADMIN_APPS) — the `hanzo-console` client — NOT for an arbitrary user bearer. So this client authenticates as that app (Basic id:secret) and always targets the ALREADY-VALIDATED caller (the handler resolves the principal from the gateway-minted X-User-Id/X-Org-Id before calling here); the caller can only ever act on their OWN id, never a third party's.
CREDENTIALS come from server-only env (IAM_MINT_CLIENT_ID / IAM_MINT_CLIENT_SECRET, sourced from KMS by the deployment), never a NEXT_PUBLIC value and never the browser. When they are unset the subsystem is honestly "not configured" (501), exactly as identity.ts's mintConfigured() gate behaved — no fabricated key/org.
onboarding.go — PURE org-naming + reserved-name policy, no transport/IAM. A faithful Go port of console's src/lib/server/onboarding.ts, decomplected from the handler so the naming rules are one testable thing (the route does the IAM calls; this decides the slug). Two concerns:
- NAMING: turn a human org name (or a username) into a valid IAM org slug — lowercase, [a-z0-9-], collapsed, trimmed, bounded.
- RESERVED: refuse names that must never become a customer org — IAM system owners (admin/built-in/app) and the brand/staff orgs (hanzo/lux/zoo/pars), which the OrgGate routes to the admin host. Creating one would collide with a staff tenant or a system principal.
topup.go is the verify-and-record seam for a crypto wallet top-up: the browser sends a USD-pegged ERC-20 transfer to our treasury and posts the tx hash here; this handler reads the receipt from that chain, confirms it is a mined, successful Transfer(from → treasury, value), derives USD cents from the on-chain value using the TOKEN'S OWN decimals, records it to commerce, and returns the credited amount plus the new balance.
A rail is one accepted (chain, token, treasury) triple, configured as data in TOPUP_RAILS and discoverable at GET /v1/commerce/topup/rails. This replaced a single hardcoded HUSD-on-Hanzo-Mainnet pair: HUSD is not deployed, so the surface was permanently 501 — complete, correct and unable to take a cent. Customers already hold USDC on Base/Ethereum/Polygon, so accepting the assets they have is what makes this earn.
THE CREDITED AMOUNT IS THE ON-CHAIN VALUE, never a client number — which is exactly why this MUST be a server handler and cannot collapse to a same-origin call. Three properties worth keeping:
- IDOR-safe: the credit lands on the VALIDATED caller's own org/user (the gateway-verified X-Org-Id/X-User-Id), never a client-supplied `userId`.
- S2S to commerce: recorded with the admin COMMERCE_SERVICE_TOKEN + the caller's X-Org-Id (the same service-to-service pattern clients/admin reads balances on), not by forwarding a browser cookie.
- Per-rail decimals: cents come from 10^(decimals-2), so a 6-decimal USDC and an 18-decimal token cannot be priced with one another's divisor.
The EVM receipt is read over plain JSON-RPC (eth_getTransactionReceipt) — one well-known call + one well-known event, so the stdlib is sufficient and no EVM client dependency is pulled in. That also means a new chain costs no new code.
Honest failure (no fabricated credit, ever): no rail configured → 501; an unknown rail, or a missing/failed/non-matching tx → 400; the chain or commerce unreachable → 502.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func IsServiceToken ¶
IsServiceToken is the exported view of s2sBillingCall — whether the request is a trusted in-proc S2S caller bearing the verified COMMERCE_SERVICE_TOKEN. Used by co-resident route gates (e.g. the spend-alert admin gate) that must admit the metering cap-gate and the SuperAdmin cap-oversight Forward alongside org admins, while refusing a plain member.
func MountAccount ¶
MountAccount wires the SPECIFIC self-service routes (order 48) — the ones that must win over the IAM /v1/iam/* wildcard (50) and the commerce embed (100).
func MountBridge ¶
MountBridge wires the CATCH-ALL data bridges (order 122) — the /v1/billing/* and /v1/commerce/* proxies that must sit AFTER clients/billing (121) + the commerce embed.
func PinBillingSubject ¶
PinBillingSubject pins every billing subject key in the request query to the VALIDATED caller's own subject (dropping ?org), so a co-resident commerce read handler downstream can only ever return the caller's OWN rows. It is the co-resident twin of billingData's subject-pinning, reusing the SAME resolveCaller → account.Payer rule and the SAME scopedBillingSearch, so the two paths scope identically.
Three cases, mirroring billingData exactly:
- Browser customer (a validated principal with an org): OVERWRITE the subject keys with the caller's own subject and drop ?org. The client cannot widen scope.
- Trusted in-proc S2S (the verified COMMERCE_SERVICE_TOKEN bearer, carrying its own X-Org-Id): pass the query through VERBATIM — it legitimately names its own subject, scoped by the EdgeAuth-controlled org.
- 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 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 RequireCSRF ¶
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 commerce transport 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.
Types ¶
This section is empty.