Files
commerce/checkout/org_resolver.go
zeekay 73b7290e7f square: one webhook route and one verifier; secrets come from the host's KMS
Square keeps an event's created_at across every retry, and the receiver
refused anything older than five minutes, so every retry of an event
first sent while cloud was down (Recreate, ten rollouts a day) was refused
until Square gave up. The verifier now lives in payment/providers/square
and needs only the key and the URL; thirdparty/square is the API client
and no longer registers itself or verifies anything, so every registry
holds one Square type.

Replay is stopped by the event id instead: each verified event is
recorded once for the whole deployment, keyed on the verifying processor
and the event id with an insert-if-absent swap, so a repeat is a
duplicate under any path or org. The path names the one processor that
verifies, and only one that holds its webhook secret may: an unknown name,
or a verifier that needs no secret, is refused rather than tried.
The books an event lands in are the verifying account's, else the
deployment's brand — never a header or session, which on a replay are the
replayer's. An effect that did not land (a store or the ledger did not
answer) leaves the delivery open with a 503, so the provider redelivers;
it used to be acknowledged and never retried. A settlement reads the
payment index before the org's books, because an in-session charge's
receipt is in the payer's org.

POST /v1/commerce/webhooks/:provider is declared once, in setupRoutes, so
standalone and embedded commerce serve the same address; the
/v1/billing/webhooks spelling is gone.

thirdparty/kms read /v1/kms/orgs/<org>/secrets/..., which cloud no longer
serves, and was off in production anyway, so per-org payment credentials
never loaded. It is deleted. secrets.Org reads orgs/<org>/<name> through
the host's reader, the caller re-pointed at that org with zip.ActingAs so
the KMS tenancy check admits it, and payment.Hydrate loads an org's
credentials where the org receives the money: storefront checkout,
storefront subscriptions and wire, and the brand's bank for the billing
wire rail. Hanzo's own billing no longer reads the paying org's
credentials; it charges the deployment's account, as it did. The
standalone binary reads through hanzoai/kms/sdk/go.
2026-10-19 09:12:56 -07:00

409 lines
17 KiB
Go

// Package checkout — OrgResolver is the canonical org resolver: the IAM org
// IS the org. There is no separate commerce-org registry to seed or drift.
//
// Resolution is host → brand → IAM org slug → Organization → public Org:
// - 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_ORG, 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 org 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
import (
"cmp"
"os"
"strings"
"sync"
"github.com/hanzoai/commerce/models/organization"
"github.com/hanzoai/commerce/payment"
"github.com/hanzoai/commerce/payment/processor"
)
// 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/org — 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 OrgLoader func(slug string) (*organization.Organization, bool)
// OrgResolver implements Resolver by mapping the Host header to an IAM org and
// projecting that org into a public Org.
type OrgResolver struct {
load OrgLoader
}
// 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 NewOrgResolver(load OrgLoader) *OrgResolver {
return &OrgResolver{load: load}
}
// Resolve maps host → brand → org and projects a public Org. It returns
// ErrUnknownOrg only for a malformed Host (empty / control bytes); every
// well-formed host resolves (known brand or the deployment default).
func (r *OrgResolver) Resolve(host string) (Org, error) {
h := normalizeHost(host)
if h == "" {
return Org{}, ErrUnknownOrg
}
b := brandForHost(h)
var org *organization.Organization
if r.load != nil {
if o, ok := r.load(b.slug); ok && o != nil {
org = o
}
}
if org == nil {
// No org row yet — the brand's env-backed Square config still resolves.
org = &organization.Organization{}
org.Name = b.slug
}
// The deployment's Square credentials are its default brand's account. Any
// other brand takes cards only with an account of its own; resolved onto the
// default's, its buyers would pay that merchant under this brand's name.
card := b.slug == defaultBrand().slug || payment.SquareOwned(org)
var sq payment.SquarePublic
if card {
sq = payment.SquarePublicConfig(org)
}
return Org{
Name: b.slug,
Brand: Brand{
DisplayName: b.displayName,
LogoURL: b.logoURL,
PrimaryColor: b.primaryColor,
LegalName: cmp.Or(b.legalName, b.displayName),
TermsURL: b.termsURL,
PrivacyURL: b.privacyURL,
},
IAM: IAMConfig{
Issuer: b.iamIssuer,
ClientID: b.iamClientID,
},
Providers: enabledProviders(card),
ReturnURLAllowlist: returnHostsFor(b.slug),
Square: SquarePublic{
ApplicationID: sq.ApplicationID,
LocationID: sq.LocationID,
Environment: sq.Environment,
},
}, nil
}
// returnHostsFor is the per-brand allowlist of first-party app hosts the pay
// SPA may bounce ?return= back to after checkout/onboarding. The pay matcher
// (isAllowedReturnUrl) requires an EXACT https hostname match, so these are
// full origins, not wildcards — every entry is a brand-controlled app host.
// Bounding ?return= to them keeps open-redirect phishing off the table while
// letting a user return to the app that sent them here (e.g. the playground
// flow: playground.hanzo.bot → pay.hanzo.ai/onboard → back to playground).
//
// This is intentionally a static first-party list, not org-model data: the
// resolver runs nil-loader on the PUBLIC /v1/commerce/org path (no
// per-request DB read — see the OrgLoader warning above), and these hosts are
// deploy-stable. Add a host here when a new first-party app must round-trip
// through pay.
func returnHostsFor(slug string) []string {
switch slug {
case "hanzo":
return []string{
"https://playground.hanzo.bot",
"https://hanzo.bot",
"https://console.hanzo.ai",
"https://cloud.hanzo.ai",
// hanzo.app — the builder. Its /billing and /pricing pages send the
// customer to pay.hanzo.ai for a card top-up or a plan and expect to
// get them back on /billing afterwards. Without this entry that
// returnUrl fails the allowlist and the buyer is stranded on pay.
"https://hanzo.app",
"https://chat.hanzo.ai",
"https://hanzo.chat",
"https://analytics.hanzo.ai",
"https://platform.hanzo.ai",
"https://bot.hanzo.ai",
"https://billing.hanzo.ai",
"https://s3.hanzo.ai",
"https://hanzo.ai",
"https://www.hanzo.ai",
// hanzo.id — sign-up onboarding. Its plan step sends a new customer
// here and completes only when checkout returns them to /onboarding
// paid.
"https://hanzo.id",
// hanzo.agency — the agency marketing/onboarding site. Its
// onboarding checkout mints a real Square link via the authed
// storefront BFF and bounces the buyer to
// https://hanzo.agency/onboarding-success on success. A distinct
// registrable domain, so it belongs in the brand's first-party
// host list (returnHostsFor), not brandDomains.
"https://hanzo.agency",
"https://www.hanzo.agency",
}
case "lux":
return []string{
"https://lux.network",
"https://lux.id",
"https://lux.finance",
"https://console.lux.network",
// Lux Tel — its site and console send the customer to pay.lux.tel
// for a top-up or a plan and expect them back afterwards.
"https://lux.tel",
"https://www.lux.tel",
"https://console.lux.tel",
// lux.cloud — the Lux console (hanzoai/console brand lux, home
// lux.cloud). Its top-ups and plans send the customer to
// pay.lux.cloud with returnUrl = the console page they left.
"https://lux.cloud",
"https://console.lux.cloud",
"https://cloud.lux.cloud",
}
case "zoo":
return []string{
"https://zoo.ngo",
"https://zoo.network",
// zoolabs.id — Zoo's identity origin (brandZoo.iamIssuer), where
// sign-up sends a new customer here and expects them back.
"https://zoolabs.id",
// zoo.cloud — the Zoo console (hanzoai/console brand zoo, home
// zoo.cloud), which returns its buyers the same way.
"https://zoo.cloud",
"https://console.zoo.cloud",
}
case "pars":
return []string{
"https://pars.network",
"https://pars.id",
"https://pars.vote",
}
default:
return nil
}
}
// enabledProviders is the payment-method surface the pay SPA renders, honoring
// the deploy-wide disabled policy. Square is the fiat card processor, surfaced
// where the brand has a card account (`card`) and the policy allows it; crypto
// (MPC) and bank wire are always available. Stripe is NEVER surfaced for new
// charges.
func enabledProviders(card bool) []Provider {
out := make([]Provider, 0, 3)
if card && !processor.DisabledByPolicy(processor.Square) {
out = append(out, Provider{Name: "square", Enabled: true})
}
out = append(out, Provider{Name: "crypto", Enabled: true})
out = append(out, Provider{Name: "wire", Enabled: true})
return out
}
// ─── Brand map (ONE place) ───────────────────────────────────────────────────
// brand is the per-brand org defaults: the IAM org slug, display branding,
// and the IAM app the SPA signs in against. These mirror the commerce
// deployment's IAM_ISSUER / IAM_ACCEPTED_ISSUERS / IAM_ACCEPTED_AUDIENCES.
type brand struct {
slug string
displayName string
logoURL string
primaryColor string
iamIssuer string
iamClientID string
// legalName is the entity that takes the money — the name a receipt and a
// footer must carry, which is not the same string as the product's. "Hanzo"
// is a brand; "Hanzo Industries Inc." is who the customer is contracting
// with, and a checkout that never says so is a checkout that never says who
// it is. Empty falls back to displayName.
legalName string
// termsURL and privacyURL are the policies THIS brand publishes. Empty
// renders no link rather than a guess: a 404 in a footer is worse than an
// absence, and inventing a path on a brand's domain is how that happens.
termsURL string
privacyURL string
}
// brandDomains maps a registrable brand domain to its brand. Matching is
// exact-suffix (host == domain OR host endsWith "."+domain) so a spoof host
// like "pay.hanzo.ai.evil.com" can never match "hanzo.ai".
type domainBrand struct {
domain string
brand brand
}
var brandDomains = []domainBrand{
{"hanzo.ai", brandHanzo},
{"hanzo.id", brandHanzo},
{"hanzo.network", brandHanzo},
{"hanzo.chat", brandHanzo},
{"hanzo.computer", brandHanzo},
{"lux.network", brandLux},
{"lux.id", brandLux},
{"lux.finance", brandLux},
// The .cloud hosts are where these brands TAKE MONEY (pay.lux.cloud,
// pay.zoo.cloud). Absent here they fell to defaultBrand() — hanzo — so a Lux
// customer met Hanzo's brand, IAM app and Square merchant. The fallback is
// right for an unknown host; the bug was that these were unknown.
{"lux.cloud", brandLux},
// lux.tel is the same case one domain later: pay.lux.tel answered with Hanzo's
// name, Hanzo's logo and Hanzo's IAM app, because it was unknown here and the
// fallback is hanzo. Every host a brand TAKES MONEY on has to be listed, and
// the list is the only thing that knows.
{"lux.tel", brandLux},
{"zoo.ngo", brandZoo},
{"zoo.network", brandZoo},
{"zoolabs.id", brandZoo},
{"zoo.cloud", brandZoo},
{"pars.network", brandPars},
{"pars.id", brandPars},
{"pars.vote", brandPars},
}
// A brand carries a logoURL only when it HAS one. The checkout header already
// renders the displayName as a wordmark when this is empty, so a brand without
// a mark reads as itself rather than as a broken image — and, more to the point,
// never as somebody else. Filling these in by pattern would be the way a Hanzo
// logo ends up on a Lux page.
//
// Each was fetched before it was written here, and re-fetched 2026-08-11.
// Hanzo and Lux both answer with an actual image (200, image/svg+xml) and are
// different files — Hanzo's is the 67x67 square mark, Lux's the 63x17 wordmark —
// so the Lux entry is Lux's own asset and not Hanzo's served from a Lux host,
// which is the failure worth checking for rather than assuming. cdn.lux.network
// 404'd when this comment was first written; the asset was published since, so
// re-measure before trusting a "none exists" note.
//
// Zoo and Pars still have none: cdn.zoo.ngo and cdn.pars.network answer 522, and
// cdn.zoo.network / cdn.zoo.cloud / cdn.zoolabs.org do not resolve at all. They
// stay empty, which renders the displayName as a wordmark. Publishing those two
// assets is the remaining work, and it is a content task, not a config one.
//
// lux.network/logo-white.svg looks like a hit at 200 until you read the type —
// it is 112KB of text/html, the SPA's catch-all serving index.html, which the
// browser would take for an image and fail to draw.
var (
// #ffffff, like every other brand here. It was #808000 — CSS "olive" — and
// it painted the one control on the checkout that matters: pay renders the
// primary CTA from this value (accent() accepts it because olive does clear
// 3:1 on black), so "Continue to payment" shipped as a mustard slab and the
// selected plan wore a mustard edge. Hanzo's design system is monochrome by
// construction — "one hue rendered through an opacity ladder", with the only
// permitted hues being error/success state — so a brand hue here was never a
// value this system has. Lux and Zoo were already #ffffff; Hanzo was the odd
// one out, which is the shape of a placeholder that shipped.
brandHanzo = brand{slug: "hanzo", displayName: "Hanzo", logoURL: "https://cdn.hanzo.ai/img/logo-white.svg", primaryColor: "#ffffff", iamIssuer: "https://hanzo.id", iamClientID: "hanzo-app", legalName: "Hanzo Industries Inc.", termsURL: "https://hanzo.ai/terms", privacyURL: "https://hanzo.ai/privacy"}
brandLux = brand{slug: "lux", displayName: "Lux", logoURL: "https://cdn.lux.network/img/logo-white.svg", primaryColor: "#ffffff", iamIssuer: "https://lux.id", iamClientID: "lux-app"}
brandZoo = brand{slug: "zoo", displayName: "Zoo", primaryColor: "#ffffff", iamIssuer: "https://zoolabs.id", iamClientID: "zoo-app"}
brandPars = brand{slug: "pars", displayName: "Pars", primaryColor: "#ffffff", iamIssuer: "https://pars.id", iamClientID: "pars-app"}
)
// brandForHost resolves a normalized host to its brand. Unknown hosts fall back
// to the deployment default org (COMMERCE_DEFAULT_ORG, default "hanzo") so a
// same-cluster host that isn't a public brand domain still resolves to the org
// this commerce serves.
func brandForHost(host string) brand {
for _, bd := range configuredDomains() {
if host == bd.domain || strings.HasSuffix(host, "."+bd.domain) {
return bd.brand
}
}
return defaultBrand()
}
// COMMERCE_BRAND_DOMAINS adds paying hosts WITHOUT a release.
//
// The table above is the only thing that knows which hosts a brand takes money on,
// and it is compiled in — so every new one cost a module tag, a dependency bump in
// the host binary, an image build and a pin, to add one line. Meanwhile the host
// answered with the fallback brand, which is somebody else's name and mark on a
// checkout. That price is why lux.cloud and then lux.tel each shipped wrong first.
//
// The form is `domain=brand`, comma or space separated:
//
// COMMERCE_BRAND_DOMAINS="lux.tel=lux, pay.example.coop=zoo"
//
// Entries are prepended, so configuration BEATS the compiled default and a
// mistake in the table is correctable without a release too. An unknown brand is
// skipped rather than guessed — naming a brand that does not exist should not
// silently mint one.
//
// Read once. This runs on a PUBLIC unauthenticated path (see OrgLoader's warning),
// so it must never do I/O or take a lock per request; parsing an env var at first
// use does neither, and the value cannot change without a restart anyway.
var configuredDomains = sync.OnceValue(func() []domainBrand {
return append(parseBrandDomains(os.Getenv("COMMERCE_BRAND_DOMAINS")), brandDomains...)
})
// parseBrandDomains reads the `domain=brand` list. Split out so it is testable:
// configuredDomains reads the env exactly once by design, so a test cannot set it.
func parseBrandDomains(raw string) []domainBrand {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
known := map[string]brand{
brandHanzo.slug: brandHanzo,
brandLux.slug: brandLux,
brandZoo.slug: brandZoo,
brandPars.slug: brandPars,
}
extra := make([]domainBrand, 0, 4)
for _, pair := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' }) {
d, b, ok := strings.Cut(pair, "=")
if !ok {
continue
}
d = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(d, ".")))
br, isKnown := known[strings.ToLower(strings.TrimSpace(b))]
if d == "" || !isKnown {
continue
}
extra = append(extra, domainBrand{d, br})
}
return extra
}
// BrandSlugForHost resolves a customer-facing host to the serving brand's org
// slug — the RECEIVING side of a deposit (whose bank account, whose KMS wire
// path, whose custody vault) and the SELLING side of the plan catalog.
// Exported for the /v1/billing surface, which shares this one host→brand table
// with the org endpoint rather than growing a second.
//
// It takes the host as a request carries it: port and case are dropped exactly
// as Resolve drops them, so "pay.lux.cloud:443" is Lux here as it is on the org
// endpoint, rather than an unknown host that falls to the default brand.
func BrandSlugForHost(host string) string {
return brandForHost(normalizeHost(host)).slug
}
// DefaultBrandSlug is the deployment's own brand: the org its own processor
// accounts belong to.
func DefaultBrandSlug() string { return defaultBrand().slug }
// defaultBrand is the deployment's fallback org. It is brandHanzo unless
// COMMERCE_DEFAULT_ORG names another known brand (so a lux/zoo/pars-only
// commerce deploy can flip its default without code changes).
func defaultBrand() brand {
switch strings.ToLower(strings.TrimSpace(os.Getenv("COMMERCE_DEFAULT_ORG"))) {
case "lux":
return brandLux
case "zoo":
return brandZoo
case "pars":
return brandPars
default:
return brandHanzo
}
}