Files
zeekay 1943f1c093 Hanzo Commerce
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans,
usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0.

Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
2026-09-23 21:52:16 -07:00

287 lines
12 KiB
Go

// Package checkout is the hosted multi-org checkout SPA embedded into
// commerce. The Vite build lives under ui/ and ships into ui/dist via the
// Dockerfile's pay-dist stage; embed.go exposes it to the Go binary.
//
// Security posture:
// - Org 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 org JSON endpoint (GET /v1/commerce/org) 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 org; cross-org mutations are
// handled at the API layer (see deposits.go + admin/org handlers)
// by cross-checking the IAM claim against the resolved org name.
package checkout
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/zap-proto/zip"
)
// ErrUnknownOrg is returned when the incoming Host header does not map
// to a configured org. 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 ErrUnknownOrg = errors.New("checkout: unknown org")
// Org is the full org config. Only the fields tagged `json:"..."`
// (no `-` suffix) flow to the public GET /v1/commerce/org endpoint via
// the PublicView projection — everything else (secrets, backend creds) is
// dropped before serialization.
type Org struct {
// Name is the stable org 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 org's back end, not here).
IDV IDVConfig `json:"idv"`
// Providers is the per-org 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 org this resolves to BD; other orgs supply
// their own URL. Kind is an opaque free-form label ("bd", "custom").
Backend BackendConfig `json:"-"`
}
// 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 SquarePublic struct {
ApplicationID string `json:"applicationId"`
LocationID string `json:"locationId"`
Environment string `json:"environment"`
}
// Brand controls visible white-label surface.
type Brand struct {
DisplayName string `json:"displayName"`
LogoURL string `json:"logoUrl"`
PrimaryColor string `json:"primaryColor"`
// The entity the buyer is contracting with, and the policies it publishes.
// A checkout is a contract; the footer is where it says whose. All three are
// omitted when unset so a brand that has not stated them renders nothing
// rather than a wrong name or a 404.
LegalName string `json:"legalName,omitempty"`
TermsURL string `json:"termsUrl,omitempty"`
PrivacyURL string `json:"privacyUrl,omitempty"`
}
// 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 IAMConfig struct {
Issuer string `json:"issuer"`
ClientID string `json:"clientId"`
ClientSecret string `json:"-"`
AdminSecret string `json:"-"`
}
// 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 org requires from the IDV provider.
type IDVConfig struct {
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
RequiredFields []string `json:"requiredFields,omitempty"`
}
// Provider is a payment provider the org has enabled. All credential
// fields are `json:"-"` so json.Marshal drops them — the PublicView
// projection relies on this.
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:"-"`
}
// BackendConfig describes where the checkout API forwards deposit
// intents. Backend.Kind is org-specific.
// For generic orgs, Kind="custom" and URL is the org's own endpoint.
type BackendConfig struct {
Kind string
URL string
}
// ─── Resolver ────────────────────────────────────────────────────────────
// Resolver resolves a Host header to a Org. The default implementation
// is an in-memory map driven by hostname→Org (tests only); in production the
// resolver is backed by the commerce organization model (hosts stored on
// the organization record).
type Resolver interface {
Resolve(host string) (Org, error)
}
// 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 org"} 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 RequestHost(c *zip.Ctx) string {
if h := normalizeHost(c.Host()); h != "" {
return h
}
if h := normalizeHost(firstForwarded(c.Header("X-Forwarded-Host"))); h != "" {
return h
}
return normalizeHost(c.Header("Host"))
}
// firstForwarded takes the LEFT-MOST entry of a comma-separated forwarding
// header: each proxy appends, so the left-most is the original client-facing
// value and every later one is an internal hop.
func firstForwarded(v string) string {
if i := strings.IndexByte(v, ','); i >= 0 {
v = v[:i]
}
return strings.TrimSpace(v)
}
func normalizeHost(host string) string {
if host == "" {
return ""
}
for i := 0; i < len(host); i++ {
b := host[i]
if b <= 0x20 || b == 0x7f {
return "" // whitespace / DEL / control byte → reject
}
}
// Strip :port. IPv6 literals start with '[' and are not supported as
// org keys; they'd fall through to ErrUnknownOrg anyway.
if i := strings.IndexByte(host, ':'); i >= 0 && !strings.HasPrefix(host, "[") {
host = host[:i]
}
return strings.ToLower(host)
}
// ─── Public org JSON ──────────────────────────────────────────────────
// publicView is the JSON shape served at GET /v1/commerce/org. It's a
// deliberate projection — anything not explicitly listed here cannot leak.
type publicView struct {
Name string `json:"name"`
Brand Brand `json:"brand"`
IAM publicIAM `json:"iam"`
IDV IDVConfig `json:"idv"`
Providers []publicProvider `json:"providers"`
ReturnURLAllowlist []string `json:"returnUrlAllowlist"`
Square SquarePublic `json:"square"`
}
type publicIAM struct {
Issuer string `json:"issuer"`
ClientID string `json:"clientId"`
}
type publicProvider struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
}
// toPublicView drops every field that is not safe to expose to an
// anonymous client. This is the single source of truth for "what does the
// SPA get to see" — org_test.go pins this contract.
func toPublicView(t Org) publicView {
enabled := make([]publicProvider, 0, len(t.Providers))
for _, p := range t.Providers {
if !p.Enabled {
continue
}
enabled = append(enabled, publicProvider{Name: p.Name, Enabled: true})
}
return publicView{
Name: t.Name,
Brand: t.Brand,
IAM: publicIAM{Issuer: t.IAM.Issuer, ClientID: t.IAM.ClientID},
IDV: t.IDV,
Providers: enabled,
// Always a non-nil slice so the JSON is `[]` not `null`. A nil
// allowlist otherwise serializes as null, and the pay SPA iterates it
// (firstOrigin/isAllowedReturnUrl) → "e is not iterable", which breaks
// the entire /onboard page ("Could not load org configuration").
ReturnURLAllowlist: append([]string{}, t.ReturnURLAllowlist...),
Square: t.Square,
}
}
// OrgJSON returns a zip.Handler for GET /v1/commerce/org. The
// handler:
// 1. Extracts and normalizes the Host header.
// 2. Resolves to a Org (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. Org config is not user-specific.
func OrgJSON(r Resolver) zip.Handler {
return func(c *zip.Ctx) error {
t, err := r.Resolve(RequestHost(c))
if err != nil {
// Do NOT include the Host in the 404 body. Attackers probing
// for org existence should see a constant response.
c.SetHeader("Cache-Control", "no-store")
return c.Bytes(http.StatusNotFound, []byte(`{"error":"unknown org"}`))
}
c.SetHeader("Content-Type", "application/json; charset=utf-8")
c.SetHeader("Cache-Control", "public, max-age=60")
b, _ := json.Marshal(toPublicView(t))
return c.Bytes(http.StatusOK, b)
}
}