mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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.
409 lines
17 KiB
Go
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
|
|
}
|
|
}
|