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 (IsAdmin claim = true) 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 served via NoRoute fallback.
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 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.satschel.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 DepositConfirm(r Resolver, fwd Forwarder) http.Handler
- func DepositStatus(r Resolver, fwd Forwarder) http.Handler
- func Deposits(r Resolver, fwd Forwarder) http.Handler
- func Mount(router *gin.Engine, r Resolver, fwd Forwarder)
- func MountAdmin(group *gin.RouterGroup, r *StaticResolver, adminStore AdminStore)
- func MountPublic(group *gin.RouterGroup, r Resolver, fwd Forwarder)
- func MountPublicFromStore(group *gin.RouterGroup, s *store.Store, fwd Forwarder)
- func MountSPA(router *gin.Engine)
- func MountTenantAdmin(group *gin.RouterGroup, s *store.Store)
- func SPAHandler(prefix string) http.Handler
- func TenantJSON(r Resolver) http.Handler
- func TenantJSONFromStore(s *store.Store) http.Handler
- func UISub() fs.FS
- func WebhookIntake(r Resolver) http.Handler
- type AdminAPI
- func (a *AdminAPI) AuditLog(c *gin.Context)
- func (a *AdminAPI) ConfigureMethod(c *gin.Context)
- func (a *AdminAPI) DisableProvider(c *gin.Context)
- func (a *AdminAPI) EnableProvider(c *gin.Context)
- func (a *AdminAPI) GetIAM(c *gin.Context)
- func (a *AdminAPI) GetIDV(c *gin.Context)
- func (a *AdminAPI) ListMethods(c *gin.Context)
- func (a *AdminAPI) ListProviders(c *gin.Context)
- func (a *AdminAPI) RotateCredentials(c *gin.Context)
- func (a *AdminAPI) SetIAM(c *gin.Context)
- func (a *AdminAPI) SetIDV(c *gin.Context)
- func (a *AdminAPI) TestProvider(c *gin.Context)
- func (a *AdminAPI) UploadCredentials(c *gin.Context)
- type AdminStore
- type BackendConfig
- type Brand
- type Forwarder
- type ForwarderFunc
- type HTTPForwarder
- type IAMConfig
- type IDVConfig
- type Provider
- type Resolver
- type StaticResolver
- 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 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 (using the caller's existing gin.Engine) and registers the NoRoute SPA fallback. Admin routes are attached separately by the superadmin router once the admin API is complete.
func MountAdmin ¶
func MountAdmin(group *gin.RouterGroup, 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 ¶
func MountPublic(group *gin.RouterGroup, r Resolver, fwd Forwarder)
MountPublic registers the /v1/commerce/* public endpoints onto an already-authed gin.RouterGroup. 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 ¶
func MountPublicFromStore(group *gin.RouterGroup, s *store.Store, fwd Forwarder)
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 NoRoute catch-all that serves the embedded Vite SPA at /. Must be called AFTER every API/admin group is attached to the engine so those routes win path resolution.
func MountTenantAdmin ¶
func MountTenantAdmin(group *gin.RouterGroup, s *store.Store)
MountTenantAdmin registers the base-store-backed admin endpoints onto an already-authed /_/commerce/* router group. Called from commerce.go setupRoutes AFTER IAM middleware so claims are populated.
func SPAHandler ¶
SPAHandler returns an http.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 an http.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).
func (*AdminAPI) ConfigureMethod ¶
func (*AdminAPI) DisableProvider ¶
func (*AdminAPI) EnableProvider ¶
func (*AdminAPI) ListMethods ¶
func (*AdminAPI) ListProviders ¶
func (*AdminAPI) RotateCredentials ¶
func (*AdminAPI) TestProvider ¶
func (*AdminAPI) UploadCredentials ¶
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. For Liquidity, Kind="bd" and URL=https://bd.{env}.satschel.com. 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 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 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.satschel.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 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"`
// Backend tells the checkout API how to proxy deposit intents. For
// the Liquidity 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 *gin.Context)
CreateTenant creates a new tenant row. Only superadmins (IAM `isAdmin` claim = true) may call this. Tenant-admins get 403; unauthenticated callers get 401.
func (*TenantAdminAPI) ListProviders ¶
func (a *TenantAdminAPI) ListProviders(c *gin.Context)
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.