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/iam/keys — whether the caller has an `hk-` key (+ prefix/mtime); no secret.
POST /v1/iam/keys — mint/rotate the key; returns { accessKey } ONCE.
DELETE /v1/iam/keys — revoke the key.
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.
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.
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 `hk-` 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 ports console's app/billing/v1/topup/wallet/route.ts into the unified binary at POST /v1/commerce/topup/wallet (task #41). It is the verify-and-record seam for an HUSD wallet top-up: the browser sends an HUSD ERC-20 transfer to the treasury and posts the tx hash here; this handler reads the receipt from the Hanzo EVM, confirms it is a mined, successful HUSD Transfer(from → treasury, value), derives USD cents from the (18-decimal, USD-pegged) on-chain value, records it to commerce as an `husd` crypto payment, and returns the credited amount + balance.
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. Two hardenings over the Node route:
- 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.
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.
Honest failure (no fabricated credit, ever): HUSD/treasury unconfigured (greenfield — HUSD not yet deployed) → 501; a missing/failed/non-HUSD-to-treasury tx → 400; the chain or commerce unreachable → 502.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
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).
Types ¶
This section is empty.