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 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 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.