checkout

package
v1.42.3 Latest Latest
Warning

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

Go to latest
Published: May 6, 2026 License: MIT Imports: 21 Imported by: 0

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

Constants

This section is empty.

Variables

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

View Source
var UIFS embed.FS

UIFS is the embedded checkout SPA bundle. Mirror of admin/embed.go.

Functions

func DepositConfirm

func DepositConfirm(r Resolver, fwd Forwarder) http.Handler

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

func DepositStatus(r Resolver, fwd Forwarder) http.Handler

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

func Deposits(r Resolver, fwd Forwarder) http.Handler

Deposits handles POST /v1/commerce/deposits. Preconditions:

  1. Host resolves to a known tenant (404 if not).
  2. 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).
  3. 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

func Mount(router *gin.Engine, r Resolver, fwd Forwarder)

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

func MountSPA(router *gin.Engine)

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

func SPAHandler(prefix string) http.Handler

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

func TenantJSON(r Resolver) http.Handler

TenantJSON returns an http.Handler for GET /v1/commerce/tenant. The handler:

  1. Extracts and normalizes the Host header.
  2. Resolves to a Tenant (or 404 with no Host echo on failure).
  3. 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

func TenantJSONFromStore(s *store.Store) http.Handler

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 UISub

func UISub() fs.FS

UISub returns the dist/ subtree as an fs.FS — ready for http.FS.

func WebhookIntake

func WebhookIntake(r Resolver) http.Handler

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) AuditLog

func (a *AdminAPI) AuditLog(c *gin.Context)

func (*AdminAPI) ConfigureMethod

func (a *AdminAPI) ConfigureMethod(c *gin.Context)

func (*AdminAPI) DisableProvider

func (a *AdminAPI) DisableProvider(c *gin.Context)

func (*AdminAPI) EnableProvider

func (a *AdminAPI) EnableProvider(c *gin.Context)

func (*AdminAPI) GetIAM

func (a *AdminAPI) GetIAM(c *gin.Context)

func (*AdminAPI) GetIDV

func (a *AdminAPI) GetIDV(c *gin.Context)

func (*AdminAPI) ListMethods

func (a *AdminAPI) ListMethods(c *gin.Context)

func (*AdminAPI) ListProviders

func (a *AdminAPI) ListProviders(c *gin.Context)

func (*AdminAPI) RotateCredentials

func (a *AdminAPI) RotateCredentials(c *gin.Context)

func (*AdminAPI) SetIAM

func (a *AdminAPI) SetIAM(c *gin.Context)

func (*AdminAPI) SetIDV

func (a *AdminAPI) SetIDV(c *gin.Context)

func (*AdminAPI) TestProvider

func (a *AdminAPI) TestProvider(c *gin.Context)

func (*AdminAPI) UploadCredentials

func (a *AdminAPI) UploadCredentials(c *gin.Context)

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

type BackendConfig struct {
	Kind string
	URL  string
}

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

type Forwarder interface {
	Forward(req *http.Request, tenant Tenant) (*http.Response, error)
}

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

type ForwarderFunc func(req *http.Request, tenant Tenant) (*http.Response, error)

ForwarderFunc adapts a function to the Forwarder interface.

func (ForwarderFunc) Forward

func (f ForwarderFunc) Forward(req *http.Request, tenant Tenant) (*http.Response, error)

Forward calls f.

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.

func (*HTTPForwarder) Forward

func (h *HTTPForwarder) Forward(req *http.Request, tenant Tenant) (*http.Response, error)

Forward sends req upstream. tenant is accepted so future per-tenant policy (custom timeouts, mTLS client certs) can be layered without changing the interface.

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

type Resolver interface {
	Resolve(host string) (Tenant, error)
}

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

type TenantAdminAPI struct {
	Store *store.Store
}

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.

Jump to

Keyboard shortcuts

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