Documentation
¶
Overview ¶
Package checkout — admin tenant handlers backed by the hanzo/base store.
Two handlers:
POST /_/commerce/tenants SuperAdmin-only create (owner=="admin"
— NOT org-level isAdmin)
GET /_/commerce/providers tenant-admin list current tenant's providers
Security invariants (Red-1 H-1 precedent):
- Cross-tenant probes MUST return a 404 with a byte-identical body to the "tenant you belong to doesn't exist" case. No existence oracle.
- Tenant scope derives from the session's IAM claim (`owner` — the org name). It is NEVER read from the request body or query string; if the handler ever does, that is a trust-boundary collapse.
- Every mutation logs an admin_mutation audit entry. This slice logs to stdout JSON via slog; a later slice moves it to a durable commerce_admin_audit collection with 7-year retention.
Package checkout: this file embeds the Vite-built SPA into the commerce binary. The source lives under ui/ and builds into ui/dist via the checkout-build Dockerfile stage. Local Go test runs only need the .gitkeep in ui/dist/ so the go:embed directive resolves.
SPA handler for the hosted checkout. Serves the embedded Vite bundle at "/". Follows the admin/embed.go pattern:
- path with file extension that exists in the embed → serve with long-cache immutable headers (hashed filenames from Vite)
- anything else → serve index.html (client-side router takes over), no-cache so a deploy rolls out without stale page fragments
Package checkout mounts the hosted multi-tenant checkout into the commerce router. Public paths live under /v1/commerce/*; admin paths live under /_/commerce/*; the Vite SPA is the least-specific catch-all.
Path convention (canonical, per platform rules):
GET /v1/commerce/tenant public tenant config (branding) POST /v1/commerce/deposits create intent → proxy to tenant BD POST /v1/commerce/deposits/:id/confirm submit provider token GET /v1/commerce/deposits/:id/status poll settlement POST /v1/commerce/webhooks/:provider provider-hosted webhook intake GET /_/commerce/providers list (redacted) POST /_/commerce/providers/:name/enable toggle enabled=true POST /_/commerce/providers/:name/disable toggle enabled=false POST /_/commerce/providers/:name/credentials stream creds → KMS DELETE /_/commerce/providers/:name/credentials clear KMS version POST /_/commerce/providers/:name/test sandbox $0.01 charge GET /_/commerce/methods derived live methods POST /_/commerce/methods/:method/configure per-method config GET /_/commerce/idv IDV provider + config PUT /_/commerce/idv set IDV provider GET /_/commerce/iam IAM app config PUT /_/commerce/iam set IAM app config GET /_/commerce/audit admin action audit log
Package checkout — OrgResolver is the canonical tenant resolver: the IAM org IS the tenant. There is no separate commerce-tenant registry to seed or drift.
Resolution is host → brand → IAM org slug → Organization → public Tenant:
- brandForHost maps a request host to its brand + IAM app (pay.hanzo.ai → hanzo, pay.lux.network → lux, …). Unknown hosts fall back to the deployment's default org (COMMERCE_DEFAULT_TENANT, default "hanzo").
- the org's public Square config (application id + location + environment) is resolved by the SAME authority as the charge path (payment.SquarePublicConfig) and projected into the tenant JSON, so the pay SPA's card iframe initializes with the exact application commerce will charge — no build-time VITE_* env, no per-host seed row.
Resolution never 404s for a well-formed host: a missing org row degrades to the brand defaults + env Square config, so "add credits" always renders.
Package checkout is the hosted multi-tenant checkout SPA embedded into commerce. The Vite build lives under ui/ and ships into ui/dist via the Dockerfile's checkout-build stage; embed.go exposes it to the Go binary.
Security posture:
- Tenant resolution is exact-match on the Host header after port/case normalization. Suffix-match tricks ("pay.example.com.evil.com") are rejected by design.
- The public tenant JSON endpoint (GET /v1/commerce/tenant) exposes ONLY branding, public IAM client ID + issuer, return-URL allowlist, and the NAMES of enabled payment providers. No secrets, no KMS paths, no client secrets, no webhook keys.
- Writes are scoped to the resolved tenant; cross-tenant mutations are handled at the API layer (see deposits.go + admin/tenant handlers) by cross-checking the IAM claim against the resolved tenant name.
Index ¶
- Variables
- func AllowedCheckoutRedirect(rawURL, orgName string, websiteURLs []string, requestHost string) bool
- func DepositConfirm(r Resolver, fwd Forwarder) zip.Handler
- func DepositStatus(r Resolver, fwd Forwarder) zip.Handler
- func Deposits(r Resolver, fwd Forwarder) zip.Handler
- func MountSPA(app *zip.App)
- func MountTenantAdmin(group zip.Router, s *store.Store)
- func RequestHost(c *zip.Ctx) string
- func SPAHandler(prefix string) zip.Handler
- func TenantJSON(r Resolver) zip.Handler
- func UISub() fs.FS
- func WebhookIntake(r Resolver) zip.Handler
- type BackendConfig
- type Brand
- type CachedOrgLoader
- type Forwarder
- type ForwarderFunc
- type HTTPForwarder
- type IAMConfig
- type IDVConfig
- type OrgLoader
- type OrgResolver
- type Provider
- type Resolver
- type SquarePublic
- type Tenant
- type TenantAdminAPI
Constants ¶
This section is empty.
Variables ¶
var ErrUnknownTenant = errors.New("checkout: unknown tenant")
ErrUnknownTenant is returned when the incoming Host header does not map to a configured tenant. Callers should respond with 404 (never 500) and MUST NOT echo the Host back in the response body — that would be a free fingerprinting primitive for attackers.
var UIFS embed.FS
UIFS is the embedded checkout SPA bundle. Mirror of admin/embed.go.
Functions ¶
func AllowedCheckoutRedirect ¶ added in v1.46.4
AllowedCheckoutRedirect reports whether rawURL is a well-formed http(s) URL whose host is one the org legitimately controls, and is therefore a safe success/cancel redirect to bake into a minted hosted-checkout (Square) link.
This is the ONE server-side redirect allowlist for the checkout money path. Without it, POST /v1/checkout/sessions would happily point a REAL payment link's post-payment redirect at any attacker host — an open-redirect / phishing pivot on a link that carries the org's brand and a real charge.
A host is allowed when it is:
- one of the org's OWN registered website hosts (websiteURLs), OR
- a first-party host of the org's brand (returnHostsFor), OR
- equal to, or a subdomain of, one of that brand's registrable domains.
orgName picks a brand org's (hanzo/lux/zoo/pars) own brand; any other (custom) org inherits the brand this commerce deployment serves, resolved from requestHost. Matching is exact host or registrable-domain suffix, so a spoof host like "hanzo.ai.evil.com" never matches "hanzo.ai".
func DepositConfirm ¶
DepositConfirm handles POST /v1/commerce/deposits/:id/confirm. The SPA posts the provider-minted token (e.g. Square nonce) back here so BD can complete the pre-auth → capture flow. We never touch the provider directly from commerce — BD owns that call path and the audit record.
func DepositStatus ¶
DepositStatus handles GET /v1/commerce/deposits/:id/status. Returns the BD-owned state machine (pending, processing, settled, failed). The SPA polls this until terminal or timeout.
func Deposits ¶
Deposits handles POST /v1/commerce/deposits. Preconditions:
- Host resolves to a known tenant (404 if not).
- Authorization header is present (401 if not — IAM middleware at the commerce router will re-validate the JWT; we only enforce presence here to fail fast before forwarding anywhere).
- Tenant.Backend.URL is configured (503 if not — fail closed, never fall back to a default).
On success the upstream response is streamed back to the client verbatim so the SPA can consume { id, provider, clientToken, ... }.
func MountSPA ¶
MountSPA registers the least-specific catch-all that serves the embedded Vite SPA at /. zip routes by specificity, so every concrete API route wins over this wildcard regardless of registration order.
func MountTenantAdmin ¶
MountTenantAdmin registers the store-backed admin surface onto a router group the caller has already wrapped with IAM auth + admin-role checks. Only the create-tenant + list-providers handlers live here today; future per-tenant config endpoints (idv, iam, etc.) hang off the same TenantAdminAPI struct so they share the store-backed instance and the audit-mutation log.
The legacy provider-credentials / methods / audit endpoints stay on the tenant-admin path until every deployment migrates over to the store seam. Both groups can coexist on the same /_/commerce prefix because their handler paths don't overlap.
func RequestHost ¶ added in v1.49.32
normalizeHost strips :port and lowercases. Any malformed input — embedded whitespace, control bytes, empty string — is rejected (returns ""). We deliberately do NOT trim: a well-formed Host header has none, and silently repairing input turns a bug into an attack surface. RequestHost is the ONE way commerce learns the customer-facing hostname.
fiber parses the request URI ONCE, so a middleware that rewrites the Host HEADER afterwards does not change what Host() returns. That is why lifting X-Forwarded-Host into the header (forwardedHostMiddleware) was a silent no-op behind the ingress: Host() stayed empty, normalizeHost returned "", and Resolve's only error path fired — 404 {"error":"unknown tenant"} on EVERY well-formed host. Measured live 2026-07-30 on pay.hanzo.ai and api.hanzo.ai, with the sibling /v1/commerce/catalog on the same group answering 200 (it reads ?brand=, never the host, so it could not see this).
Order: the parsed host, then the forwarded host set by the trusted ingress, then the raw Host header. brandForHost is exact-suffix and an unknown host falls back to the deployment default, so a spoofed value can only ever select a brand's ALREADY-PUBLIC config (brand chrome, IAM client id, the Square PUBLIC application id). There is no probe oracle and nothing private behind it — the 404 body still never echoes the host.
func SPAHandler ¶
SPAHandler returns the zip.Handler that serves the embedded checkout SPA. prefix is typically "" (mounted at root) or "/pay" if the SPA needs to live at a subpath. Unknown extension-less paths fall through to index.html so TanStack Router can render them.
The handler is Host-agnostic — tenant branding is fetched at runtime by the SPA via GET /checkout/v1/tenant. This keeps the embed identical across all tenants and the binary itself reproducible.
func TenantJSON ¶
TenantJSON returns a zip.Handler for GET /v1/commerce/tenant. The handler:
- Extracts and normalizes the Host header.
- Resolves to a Tenant (or 404 with no Host echo on failure).
- Projects through toPublicView and JSON-encodes.
Cache policy: short public cache (60s) to absorb SPA boot storms without leaking per-user state. Tenant config is not user-specific.
func WebhookIntake ¶
WebhookIntake handles POST /v1/commerce/webhooks/:provider. The provider (Square, Braintree, etc.) posts settlement/dispute events here. We DO NOT verify the provider's signature in commerce — signature keys live in BD + the tenant-scoped KMS secret that BD already owns, so we forward the payload + original signature headers verbatim so BD can verify with its own tenant-scoped key.
Why not verify here: key rotation races. If commerce cached a stale signing key it would reject live webhooks. BD is the only source of truth for provider keys; having commerce also hold them would be two places to rotate, and two places to forget.
Types ¶
type BackendConfig ¶
BackendConfig describes where the checkout API forwards deposit intents. Backend.Kind is tenant-specific. For generic tenants, Kind="custom" and URL is the tenant's own endpoint.
type Brand ¶
type Brand struct {
DisplayName string `json:"displayName"`
LogoURL string `json:"logoUrl"`
PrimaryColor string `json:"primaryColor"`
}
Brand controls visible white-label surface.
type CachedOrgLoader ¶ added in v1.49.35
type CachedOrgLoader struct {
// contains filtered or unexported fields
}
CachedOrgLoader wraps a slow, fallible org read in the two properties OrgLoader's contract REQUIRES of any real loader: a cache, and a deadline.
Resolve runs on GET /v1/commerce/tenant — public, unauthenticated, and hit by the pay SPA on every boot. A naive per-request org query under an unbounded context blocks and exhausts the DB pool; that is the 1.42.44 regression, and it is why the loader defaulted to nil. nil is safe but it is not free: with no loader every host resolves to a SYNTHETIC org, a synthetic org is never Live, and the public tenant JSON therefore advertises the SANDBOX Square application forever — even after the org record is flipped Live (measured 2026-07-30: POST /v1/billing/test-mode returned {"live":true} and the tenant kept serving sandbox-sq0idb-…, so the card iframe tokenized against sandbox while the charge path used the live org — a nonce the production account cannot charge).
So the org row must be read, and reading it must not be able to hurt the endpoint. This type is that bargain, and nothing else:
- ttl bounds staleness. A hit costs a mutex, never I/O. Flipping an org live takes effect within ttl with no restart.
- timeout bounds the miss. The read runs under its own deadline, so a slow or wedged datastore costs one request that many rather than a pool.
- a failed or timed-out read returns (nil,false), which degrades to exactly the synthetic-org behavior that shipped before — SANDBOX. The fallback on every error path is the fail-closed one, so an outage can never promote a tenant onto production rails.
Negative results are cached too, and deliberately: a brand with no org row is the common case for the non-default brands, and re-querying for a row that does not exist on every SPA boot is the same stampede in a different costume.
func NewCachedOrgLoader ¶ added in v1.49.35
func NewCachedOrgLoader( read func(ctx context.Context, slug string) (*organization.Organization, error), ttl, timeout time.Duration, ) *CachedOrgLoader
NewCachedOrgLoader builds a loader over read. Zero or negative ttl/timeout take the defaults (60s / 2s). read must be READ-ONLY and must honor its ctx.
func (*CachedOrgLoader) Load ¶ added in v1.49.35
func (c *CachedOrgLoader) Load(slug string) (*organization.Organization, bool)
Load is the OrgLoader. A nil receiver or nil read reports a miss, so wiring one in is never a nil-deref hazard.
type Forwarder ¶
Forwarder wraps "given this request and this tenant, produce a response". The production Forwarder is a net/http.Client wired to the tenant's Backend.URL; tests substitute a ForwarderFunc.
type ForwarderFunc ¶
ForwarderFunc adapts a function to the Forwarder interface.
type HTTPForwarder ¶
type HTTPForwarder struct {
// contains filtered or unexported fields
}
HTTPForwarder is the production Forwarder. It uses a shared *http.Client with sane timeouts so a slow backend cannot exhaust commerce's goroutine budget. TLS is enforced (tenant Backend.URL must be https://) — an http backend is a misconfiguration and requests to it will be rejected by Go's transport anyway.
func NewHTTPForwarder ¶
func NewHTTPForwarder() *HTTPForwarder
NewHTTPForwarder builds a forwarder with 15s connect timeout, 30s request timeout, 20s TLS handshake. These numbers are tuned for BD: BD's deposit-intent creation path is typically sub-second.
type IAMConfig ¶
type IAMConfig struct {
Issuer string `json:"issuer"`
ClientID string `json:"clientId"`
ClientSecret string `json:"-"`
AdminSecret string `json:"-"`
}
IAMConfig: Issuer + ClientID are OIDC-public (they already ship in the well-known discovery doc). ClientSecret and AdminSecret are server-side and MUST NOT project to PublicView.
type IDVConfig ¶
type IDVConfig struct {
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
RequiredFields []string `json:"requiredFields,omitempty"`
}
IDVConfig: opaque to commerce. Provider is a label the SPA switches on; Endpoint is the URL the SPA opens for the IDV flow. RequiredFields is a whitelist of claims the tenant requires from the IDV provider.
type OrgLoader ¶ added in v1.42.44
type OrgLoader func(slug string) (*organization.Organization, bool)
OrgLoader loads an Organization by its IAM slug (read-only). It is injected by the binary so this package stays decoupled from datastore wiring; a nil loader (or a miss) degrades to a brand-default synthetic org whose Square config resolves from the deployment's per-brand env (the cloud-org path).
WARNING: Resolve runs on /v1/commerce/tenant — a PUBLIC, unauthenticated endpoint the pay SPA hits on every boot. Any real loader wired here MUST be cached AND deadline-bounded. A naive per-request DB query under an unbounded context blocks and can exhaust the connection pool (regression at 1.42.44). nil is the correct default: pure host→brand→env, no I/O, no hang.
type OrgResolver ¶ added in v1.42.44
type OrgResolver struct {
// contains filtered or unexported fields
}
OrgResolver implements Resolver by mapping the Host header to an IAM org and projecting that org into a public Tenant.
func NewOrgResolver ¶ added in v1.42.44
func NewOrgResolver(load OrgLoader) *OrgResolver
NewOrgResolver returns a Resolver backed by the IAM org model. load may be nil (every host resolves to a brand-default synthetic org + env Square).
func (*OrgResolver) Resolve ¶ added in v1.42.44
func (r *OrgResolver) Resolve(host string) (Tenant, error)
Resolve maps host → brand → org and projects a public Tenant. It returns ErrUnknownTenant only for a malformed Host (empty / control bytes); every well-formed host resolves (known brand or the deployment default).
type Provider ¶
type Provider struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
// The following are server-side only. KMSPath is the KMS folder that
// holds this provider's credentials; AccessToken et al are optional
// fallbacks for bootstrap / local dev.
KMSPath string `json:"-"`
ApplicationID string `json:"-"`
AccessToken string `json:"-"`
PrivateKey string `json:"-"`
WebhookSignatureKey string `json:"-"`
}
Provider is a payment provider the tenant has enabled. All credential fields are `json:"-"` so json.Marshal drops them — the PublicView projection relies on this.
type Resolver ¶
Resolver resolves a Host header to a Tenant. The default implementation is an in-memory map driven by hostname→Tenant (tests only); in production the resolver is backed by the commerce organization model (hosts stored on the organization record).
type SquarePublic ¶ added in v1.42.44
type SquarePublic struct {
ApplicationID string `json:"applicationId"`
LocationID string `json:"locationId"`
Environment string `json:"environment"`
}
SquarePublic is the public Square config surfaced to the checkout SPA. It mirrors payment.SquarePublic minus the ledger `live` flag (the SPA drives its sandbox-vs-prod script off Environment). All fields are public.
type Tenant ¶
type Tenant struct {
// Name is the stable tenant identifier (also the Hanzo IAM org name /
// commerce organization.Name). Used to scope KMS paths, DB queries,
// and IAM owner-claim comparisons.
Name string `json:"name"`
// Brand controls what the SPA renders.
Brand Brand `json:"brand"`
// IAM points the SPA at the correct identity provider and app. Only
// the public fields (Issuer, ClientID) project to PublicView; the rest
// stay server-side and are used by the checkout API handlers.
IAM IAMConfig `json:"iam"`
// IDV (identity verification) is opaque to the server: the SPA just
// reads it, renders a redirect/prompt, and trusts the IDV provider's
// completion webhook (handled by the tenant's back end, not here).
IDV IDVConfig `json:"idv"`
// Providers is the per-tenant enable/disable list for payment
// providers. The PublicView projection strips all credential fields
// before emission.
Providers []Provider `json:"providers"`
// ReturnURLAllowlist bounds the ?return= query param the SPA may
// bounce to. Prevents open-redirect phishing pivots.
ReturnURLAllowlist []string `json:"returnUrlAllowlist"`
// Square carries the PUBLIC Square Web Payments config (application id +
// location id + environment) the SPA's card iframe needs. Resolved from the
// IAM org via the single test-mode authority (see payment.SquarePublicConfig)
// so the browser tokenizes with the exact application commerce will charge.
// Every field is public — no secret ever crosses this boundary.
Square SquarePublic `json:"square"`
// Backend tells the checkout API how to proxy deposit intents. For
// the example tenant this resolves to BD; other tenants supply
// their own URL. Kind is an opaque free-form label ("bd", "custom").
Backend BackendConfig `json:"-"`
}
Tenant is the full tenant config. Only the fields tagged `json:"..."` (no `-` suffix) flow to the public GET /v1/commerce/tenant endpoint via the PublicView projection — everything else (secrets, backend creds) is dropped before serialization.
type TenantAdminAPI ¶
TenantAdminAPI wires the /_/commerce/* endpoints that drive the tenant record in the hanzo/base-backed store. It is the ONLY admin surface: the legacy AdminAPI it used to coexist with spoke to a Resolver, implemented nothing but notImplemented, and was reachable from no route — deleted.
func NewTenantAdminAPI ¶
func NewTenantAdminAPI(s *store.Store) *TenantAdminAPI
NewTenantAdminAPI constructs the handler set.
func (*TenantAdminAPI) CreateTenant ¶
func (a *TenantAdminAPI) CreateTenant(c *zip.Ctx) error
CreateTenant creates a new tenant row. Only PLATFORM (global) admins — IsSuperAdmin(): the HOME owner=="admin" (reserved admin org) — may call this. Org owners (org-level isAdmin) and tenant-admins get 403; unauthenticated callers get 401.
func (*TenantAdminAPI) ListProviders ¶
func (a *TenantAdminAPI) ListProviders(c *zip.Ctx) error
ListProviders returns the current tenant's provider list. The tenant is derived from the IAM `owner` claim — never from the body or query. If the authenticated user has no tenant row, the response is a byte- identical 404 to the cross-tenant-probe case — same status, same body.