console

package
v1.786.112 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 6, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

Documentation

Overview

billing.go — the per-tenant billing DATA bridge, the Go port of console2'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 console2'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 console2'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 console2'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 console2's proxy-allow.ts allowCommerceSurface.

Package console mounts the console's OWN standalone server surface natively in the unified cloud binary at /v1/console/* (HIP-0106). It is the Go port of the two console2 Next server routes that hold NO backend-proxy role but DO privileged IAM work on the signed-in user's behalf — app/keys/route.ts and app/onboard/route.ts — so console2 can drop those Node server handlers and be statically exported (task #41, "True 1-binary FE": the console SPA is go:embed'd and every dynamic call terminates at THIS binary's /v1, no separate Node origin).

WHY THESE (and not the pure passthrough proxies). console2's PURE BFF reverse- proxies — app/cloud, app/ai, app/commerce, whose only server work is minting a user Bearer — 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 are the ones that do REAL server work a static SPA cannot: `keys`/`onboard` run privileged IAM logic as the confidential `hanzo-console` client; `waitlist`/ `embed-status`/`topup` do server-side verification; and the /v1/billing/* bridge (billing.go) injects the commerce SERVICE token and pins the caller's own billing subject SERVER-SIDE (a passthrough would leak cross-tenant ledgers). Each has no pure-proxy equivalent, so it must be ported for the static export to be complete.

SURFACE (every route 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/console/keys      — whether the caller has an `hk-` key (+ prefix/mtime); no secret.
POST   /v1/console/keys      — mint/rotate the key; returns { accessKey } ONCE.
DELETE /v1/console/keys      — revoke the key.
POST   /v1/console/onboard   — create the caller's org (+ move them in on first run).
GET    /v1/console/health    — real IAM-configured probe (fail-closed when unwired).
GET    /v1/billing/*         — per-tenant billing read (balance/usage/invoices/…),
POST   /v1/billing/*           forwarded to commerce with the service token, SCOPED
                              to the validated caller's own subject (billing.go).
GET    /v1/commerce/*        — per-tenant STORE (products/orders/customers/…),
…      /v1/commerce/*          forwarded to commerce's bare /v1/<kind> with the
                              service token, SCOPED to the validated caller's own
                              org; full CRUD, store-heads only (commerce.go).

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.

embed.go ports console2's app/embed-status/route.ts into the unified binary at GET /v1/console/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 global admin — 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 console2'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 console2 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 console2'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 console2's app/billing/v1/topup/wallet/route.ts into the unified binary at POST /v1/console/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.

waitlist.go ports console2's app/waitlist/route.ts — the "Join waitlist" CTA on coming-soon products — into the unified binary at POST /v1/console/waitlist (task #41). It is NOT a backend that owns the list: it is the session-gated, email-binding seam in front of the Hanzo Base waitlist plugin (the hanzoai/ waitlist pattern, a per-product SQLite-backed list at POST /v1/waitlist/join).

WHY IT IS A HANDLER (not a vanishing proxy). It does real work the SPA must not: it BINDS the recorded email to the VALIDATED session (the gateway-verified X-User-Email) so a signed-in user cannot enroll a third party (victim@othercorp) or forge "org X wants ERP" — the client-supplied email is only a fallback for an account whose token carries no email. The backend URL is server-only env (WAITLIST_URL, sourced from KMS by the deployment, never NEXT_PUBLIC); when it is unset the route is honestly 501 ("not open on this deployment yet"), never a fabricated confirmation.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Mount

func Mount(app *zip.App, deps cloud.Deps) error

Mount wires the /v1/console surface onto app per HIP-0106.

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL