Documentation
¶
Overview ¶
Package checkout: admin API scaffolding for /_/commerce/*.
This file defines the minimum type/method surface the router expects (AdminStore interface, AdminAPI struct). The concrete handlers return HTTP 501 until the hanzo/base-backed store is wired — see STATUS.md.
Security posture when endpoints go live:
- Every mutation derives the tenant from the IAM session claims (never from the request body).
- Credentials never flow through commerce JSON; they stream directly to KMS at commerce/{tenant}/{provider}/{field}.
- Every mutation appends to commerce_admin_audit with 7-year retention.
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 Mount(app *zip.App, r Resolver, fwd Forwarder)
- func MountAdmin(group zip.Router, r *StaticResolver, adminStore AdminStore)
- func MountPublic(group zip.Router, r Resolver, fwd Forwarder)
- func MountPublicFromStore(group zip.Router, s *store.Store, fwd Forwarder)
- func MountSPA(app *zip.App)
- func MountTenantAdmin(group zip.Router, s *store.Store)
- func SPAHandler(prefix string) zip.Handler
- func TenantJSON(r Resolver) zip.Handler
- func TenantJSONFromStore(s *store.Store) zip.Handler
- func UISub() fs.FS
- func WebhookIntake(r Resolver) zip.Handler
- type AdminAPI
- func (a *AdminAPI) AuditLog(c *zip.Ctx) error
- func (a *AdminAPI) ConfigureMethod(c *zip.Ctx) error
- func (a *AdminAPI) DisableProvider(c *zip.Ctx) error
- func (a *AdminAPI) EnableProvider(c *zip.Ctx) error
- func (a *AdminAPI) GetIAM(c *zip.Ctx) error
- func (a *AdminAPI) GetIDV(c *zip.Ctx) error
- func (a *AdminAPI) ListMethods(c *zip.Ctx) error
- func (a *AdminAPI) ListProviders(c *zip.Ctx) error
- func (a *AdminAPI) RotateCredentials(c *zip.Ctx) error
- func (a *AdminAPI) SetIAM(c *zip.Ctx) error
- func (a *AdminAPI) SetIDV(c *zip.Ctx) error
- func (a *AdminAPI) TestProvider(c *zip.Ctx) error
- func (a *AdminAPI) UploadCredentials(c *zip.Ctx) error
- type AdminStore
- 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 StaticResolver
- type StoreResolver
- 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 Mount ¶
Mount is the convenience entrypoint used by commerce.go setupRoutes. It wires the public checkout routes onto the /v1/commerce API group (on the caller's zip app) and registers the least-specific SPA fallback. Admin routes are attached separately by the superadmin router once the admin API is complete.
func MountAdmin ¶
func MountAdmin(group zip.Router, r *StaticResolver, adminStore AdminStore)
MountAdmin registers the /_/commerce/* admin endpoints onto a router group the caller has already wrapped with IAM + admin-role guard. These endpoints are tenant-scoped: every mutation derives the tenant from the session, never from the request body.
func MountPublic ¶
MountPublic registers the /v1/commerce/* public endpoints onto an already-authed zip.Router group. The caller (commerce.go setupRoutes) owns middleware — IAM auth, org resolution, request context, cache headers — so this package stays focused on tenant routing and upstream forwarding.
The API group is passed in so we never register a duplicate /v1 prefix, and so admin/IAM middleware composed on the group applies to every handler here.
func MountPublicFromStore ¶
MountPublicFromStore mirrors MountPublic but reads tenant config from the hanzo/base-backed commerce/store rather than the legacy StaticResolver. New callers should prefer this; the legacy variant remains for tests and deployments that have not yet constructed a *store.Store.
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 older MountAdmin path (StaticResolver-driven) until they migrate over to the store seam. Both groups can coexist on the same /_/commerce prefix because their handler paths don't overlap.
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 TenantJSONFromStore ¶
TenantJSONFromStore is the store-backed variant of TenantJSON. The legacy TenantJSON(Resolver) remains in tenant.go for callers that still hold a StaticResolver; new callers should use this one. Once every deployment is on base, TenantJSON becomes a thin wrapper around this function and StaticResolver is deleted.
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 AdminAPI ¶
type AdminAPI struct {
Resolver *StaticResolver
Store AdminStore
}
AdminAPI is the handler set bound to /_/commerce/*. Constructed by MountAdmin; holds the Resolver (for tenant config lookups) and the AdminStore (for persistence).
type AdminStore ¶
type AdminStore interface {
// ListProviders returns the tenant's payment providers with credential
// presence flags — never the actual credential values.
ListProviders(tenantID string) ([]Provider, error)
// SetProviderEnabled toggles the enabled flag for a single provider
// on the given tenant. Writes an audit entry.
SetProviderEnabled(tenantID, name string, enabled bool, actor string) error
// AuditAppend records an admin action. Never stores secrets.
AuditAppend(tenantID, actor, action string, meta map[string]any) error
}
AdminStore is the persistence surface the admin handlers require. The production implementation wraps hanzo/base collections:
- tenants → read/write Tenant records
- commerce_admin_audit → append-only audit log
- providers → per-tenant provider enable/config
The interface stays in this package so the admin code depends on the abstraction, not on base directly; this keeps the package unit-testable with in-memory stubs.
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 a StaticResolver driven by a hostname→Tenant map; 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 StaticResolver ¶
type StaticResolver struct {
// contains filtered or unexported fields
}
StaticResolver is an in-memory Resolver used for tests and bootstrap. Hostname keys are lowercased and assumed to have no port suffix; the Resolver handles normalization before lookup.
func NewStaticResolver ¶
func NewStaticResolver(hosts map[string]Tenant) *StaticResolver
NewStaticResolver copies the input map and lowercases keys so the caller need not normalize ahead of time.
func (*StaticResolver) Resolve ¶
func (r *StaticResolver) Resolve(host string) (Tenant, error)
Resolve returns the Tenant for host, or ErrUnknownTenant. Exact-match only: suffix spoofing ("pay.example.com.evil.com") does not match.
func (*StaticResolver) Set ¶
func (r *StaticResolver) Set(host string, t Tenant)
Set replaces the tenant for a host (or inserts). Host is normalized. Intended for runtime reconfig (admin API) — thread-safe.
type StoreResolver ¶ added in v1.42.6
StoreResolver implements Resolver by reading from a commerce/store *store.Store. Lookups go through store.Tenants.FindByHostname which applies the same normalization rule as StaticResolver.Resolve.
func NewStoreResolver ¶ added in v1.42.6
func NewStoreResolver(s *store.Store) *StoreResolver
NewStoreResolver returns a Resolver that resolves via s.
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 new hanzo/base-backed store. This is distinct from the legacy AdminAPI in admin.go — that one still speaks to the old Resolver; this one speaks to store.Store. Both coexist during migration.
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.