mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
Four gaps between "Circle says completed" and "the customer's balance moved". A remittance Circle reports as completed is marked settled and then credited. If the ledger is down at that moment the row is settled and not landed, and the reconciler never looked at settled rows again — the payment was final at Circle and credited nowhere, forever. Settled rows that have not landed are now swept, and later() reschedules them, so the only write left (the idempotent ledger entry) is retried until it succeeds. Pinned by TestCompletedPaymentRetriesCreditAfterLedgerOutage. Production offers only the USDC contracts Circle itself issues, keyed by CAIP-2 and compared against what /v1/x402/supported reports. A testnet pair, a same-symbol substitute or a network nobody reviewed is dropped rather than offered, so a signature can never be for something that is not Circle USDC. Pinned by TestProductionRefusesTestnetAndSubstituteUSDC. Circle renders base-unit amounts with leading zeroes, and a string compare of "00010000" against "10000" failed to match a payment the payer had already made — the remittance stayed open and was never settled. Amounts now compare as unsigned integers; signs, decimals and exponents do not parse. Pinned by TestAmountsCompareAsUnsignedBaseUnits. An unconfigured Circle Gateway no longer stops commerce from starting: the error is logged and the rail stays closed, while card, wire and the custody deposit rail keep serving. Billing going down because one optional processor has no credential is a bigger outage than the rail being unavailable. The rig signs against Base mainnet now, matching production.
1388 lines
54 KiB
Go
1388 lines
54 KiB
Go
// Package commerce provides the main application framework for Hanzo Commerce.
|
|
//
|
|
// Commerce is a multi-org e-commerce platform that runs as a standalone
|
|
// binary with embedded SQLite for per-user/org data and optional analytics
|
|
// via Datastore.
|
|
//
|
|
// Architecture:
|
|
//
|
|
// ┌─────────────────────────────────────────────────────────────┐
|
|
// │ Commerce App │
|
|
// ├─────────────────────────────────────────────────────────────┤
|
|
// │ HTTP Server (Gin) │ Hooks System │ Background Tasks │
|
|
// ├─────────────────────────────────────────────────────────────┤
|
|
// │ User SQLite │ Org SQLite │ Analytics (CH) │
|
|
// │ + sqlite-vec │ + sqlite-vec │ (parallel queries) │
|
|
// └─────────────────────────────────────────────────────────────┘
|
|
package commerce
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
fiber "github.com/zap-proto/fiber/v3"
|
|
"github.com/zap-proto/zip"
|
|
zipmw "github.com/zap-proto/zip/middleware"
|
|
|
|
commerceApi "github.com/hanzoai/commerce/api"
|
|
billingPkg "github.com/hanzoai/commerce/api/billing"
|
|
catalogapi "github.com/hanzoai/commerce/api/catalog"
|
|
currencyapi "github.com/hanzoai/commerce/api/currency"
|
|
rateapi "github.com/hanzoai/commerce/api/rate"
|
|
uploadApi "github.com/hanzoai/commerce/api/upload"
|
|
"github.com/hanzoai/commerce/auth"
|
|
billingUI "github.com/hanzoai/commerce/billing"
|
|
"github.com/hanzoai/commerce/billing/depositledger"
|
|
"github.com/hanzoai/commerce/billing/gateway"
|
|
"github.com/hanzoai/commerce/billing/husdledger"
|
|
"github.com/hanzoai/commerce/checkout"
|
|
commerceDatastore "github.com/hanzoai/commerce/datastore"
|
|
commerceQuery "github.com/hanzoai/commerce/datastore/query"
|
|
"github.com/hanzoai/commerce/db"
|
|
"github.com/hanzoai/commerce/delay"
|
|
"github.com/hanzoai/commerce/events"
|
|
"github.com/hanzoai/commerce/hooks"
|
|
"github.com/hanzoai/commerce/infra"
|
|
"github.com/hanzoai/commerce/middleware"
|
|
"github.com/hanzoai/commerce/middleware/iammiddleware"
|
|
"github.com/hanzoai/commerce/models/catalogentry"
|
|
currencymodel "github.com/hanzoai/commerce/models/currency"
|
|
orgModel "github.com/hanzoai/commerce/models/organization"
|
|
planModel "github.com/hanzoai/commerce/models/plan"
|
|
"github.com/hanzoai/commerce/models/sbomrecord"
|
|
"github.com/hanzoai/commerce/models/types/currency"
|
|
"github.com/hanzoai/commerce/payment/providers/circle"
|
|
commercestore "github.com/hanzoai/commerce/store"
|
|
"github.com/hanzoai/commerce/treasury"
|
|
"github.com/hanzoai/commerce/types"
|
|
"github.com/hanzoai/commerce/ui"
|
|
"github.com/hanzoai/commerce/util/husd"
|
|
)
|
|
|
|
// Version, GitCommit, and BuildTime are set via -ldflags at build time.
|
|
// Version's default is the source-of-record release; CI overrides it with
|
|
// the immutable image tag (-X github.com/hanzoai/commerce.Version=<tag>) so
|
|
// the running binary's /healthz version always equals its deployed tag.
|
|
var (
|
|
Version = "1.49.43"
|
|
GitCommit = "dev"
|
|
BuildTime = "unknown"
|
|
)
|
|
|
|
// Config holds application configuration
|
|
type Config struct {
|
|
// DataDir is the base directory for all data
|
|
DataDir string
|
|
|
|
// MasterKey is the 32-byte at-rest encryption master key supplied by an
|
|
// EMBEDDER (EmbedConfig.MasterKey). nil ⇒ COMMERCE_KMS_MASTER_KEY decides,
|
|
// which is the standalone path.
|
|
MasterKey []byte
|
|
|
|
// Dev enables development mode
|
|
Dev bool
|
|
|
|
// RequireIdentity makes the identity boundary (auth.Gin) reject any
|
|
// request that arrives without X-Org-Id/X-User-Id. Sourced from
|
|
// COMMERCED_REQUIRE_IDENTITY. It is OFF by default and MUST stay off
|
|
// wherever the cloud-api -> commerce per-org billing path runs: that
|
|
// path authenticates with a Bearer service token + X-Org-Id and
|
|
// carries NO X-Org-Id, so a require-identity gate would 401 the money
|
|
// path. The anti-spoofing boundary is EdgeAuth (always mounted), not
|
|
// this gate.
|
|
RequireIdentity bool
|
|
|
|
// Secret for encryption and sessions
|
|
Secret string
|
|
|
|
// HTTP server address
|
|
HTTPAddr string
|
|
|
|
// HTTPS server address (optional)
|
|
HTTPSAddr string
|
|
|
|
// TLS certificate paths
|
|
TLSCert string
|
|
TLSKey string
|
|
|
|
// CORS allowed origins
|
|
AllowedOrigins []string
|
|
|
|
// SharedApp, when non-nil, is the host binary's zip app — the NATIVE
|
|
// co-residence contract (HIP-0106): Bootstrap registers commerce's routes
|
|
// directly on it (one router, one specificity space) and setupRoutes
|
|
// skips the standalone-only surfaces (/healthz, legacy /admin SPA, the
|
|
// checkout SPA catch-all). Serve refuses — the host listens.
|
|
SharedApp *zip.App
|
|
|
|
// Database configuration
|
|
Database db.Config
|
|
|
|
// Analytics collector endpoint (optional)
|
|
AnalyticsEndpoint string
|
|
|
|
// Analytics DSN (optional, for direct Datastore queries)
|
|
DatastoreDSN string
|
|
|
|
// Infrastructure configuration
|
|
Infra infra.Config
|
|
|
|
// Query timeout
|
|
QueryTimeout time.Duration
|
|
|
|
// IAM configuration for hanzo.id JWT validation
|
|
IAM struct {
|
|
Enabled bool `json:"enabled"`
|
|
Issuer string `json:"issuer"`
|
|
ClientID string `json:"clientId"`
|
|
ClientSecret string `json:"clientSecret"`
|
|
AcceptedAudiences []string `json:"acceptedAudiences"`
|
|
AcceptedIssuers []string `json:"acceptedIssuers"`
|
|
JwksURI string `json:"jwksUri"`
|
|
} `json:"iam"`
|
|
}
|
|
|
|
// DefaultConfig returns the default configuration
|
|
func DefaultConfig() *Config {
|
|
cfg := &Config{
|
|
DataDir: getEnv("COMMERCE_DIR", "./commerce_data"),
|
|
Dev: getEnv("COMMERCE_DEV", "false") == "true",
|
|
RequireIdentity: getEnv("COMMERCED_REQUIRE_IDENTITY", "false") == "true",
|
|
Secret: getEnv("COMMERCE_SECRET", "change-me-in-production"),
|
|
HTTPAddr: getEnv("COMMERCE_HTTP", "127.0.0.1:8090"),
|
|
AllowedOrigins: []string{"*"},
|
|
AnalyticsEndpoint: getEnv("ANALYTICS_ENDPOINT", ""),
|
|
DatastoreDSN: getEnv("DATASTORE_URL", ""),
|
|
Infra: *infraConfigFromEnv(),
|
|
QueryTimeout: 30 * time.Second,
|
|
}
|
|
|
|
cfg.IAM.Enabled = getEnv("IAM_ENABLED", "true") == "true"
|
|
cfg.IAM.Issuer = getEnv("IAM_ISSUER", "https://hanzo.id")
|
|
cfg.IAM.ClientID = getEnv("IAM_CLIENT_ID", "")
|
|
cfg.IAM.ClientSecret = getEnv("IAM_CLIENT_SECRET", "")
|
|
if accepted := getEnv("IAM_ACCEPTED_AUDIENCES", ""); accepted != "" {
|
|
cfg.IAM.AcceptedAudiences = strings.Split(accepted, ",")
|
|
}
|
|
if issuers := getEnv("IAM_ACCEPTED_ISSUERS", ""); issuers != "" {
|
|
cfg.IAM.AcceptedIssuers = strings.Split(issuers, ",")
|
|
}
|
|
cfg.IAM.JwksURI = getEnv("IAM_JWKS_URI", "")
|
|
|
|
return cfg
|
|
}
|
|
|
|
// infraConfigFromEnv loads infrastructure config from environment.
|
|
//
|
|
// Env vars (generic, no implementation leakage):
|
|
//
|
|
// KV_PREFIX = optional key namespace (cache is hanzo/base, embedded)
|
|
// S3_URL = s3://key:secret@host:9000/bucket
|
|
// S3_ENDPOINT = host:9000 (with S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET)
|
|
// DATASTORE_URL = datastore://host:9000/db
|
|
// SQL_URL = postgresql://user:pass@host:5432/db
|
|
// VECTOR_URL = qdrant://host:6333
|
|
// SEARCH_URL = http://host:7700
|
|
// PUBSUB_URL = nats://host:4222
|
|
// TASKS_URL = temporal://host:7233/namespace
|
|
func infraConfigFromEnv() *infra.Config {
|
|
cfg := infra.DefaultConfig()
|
|
|
|
// KV (hanzo/base, per-org/user SQLite). The cache is always available —
|
|
// base is an embedded store, not an external service. DataDir/DataDSN are
|
|
// resolved against the commerce store at bootstrap (commerce.go wires the
|
|
// shared store), so env wiring here only sets an optional key namespace.
|
|
cfg.KV.Enabled = true
|
|
cfg.KV.KeyPrefix = getEnv("KV_PREFIX", "")
|
|
|
|
// Vector (Qdrant)
|
|
if vectorURL := getEnv("VECTOR_URL", ""); vectorURL != "" {
|
|
if parsed, err := url.Parse(vectorURL); err == nil {
|
|
cfg.Vector.Enabled = true
|
|
host := parsed.Hostname()
|
|
cfg.Vector.Host = host
|
|
if p := parsed.Port(); p != "" {
|
|
if port, err := strconv.Atoi(p); err == nil {
|
|
cfg.Vector.Port = port
|
|
}
|
|
}
|
|
if parsed.User != nil {
|
|
cfg.Vector.APIKey = parsed.User.Username()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Storage (S3-compatible)
|
|
if s3URL := getEnv("S3_URL", ""); s3URL != "" {
|
|
if parsed, err := url.Parse(s3URL); err == nil {
|
|
cfg.Storage.Enabled = true
|
|
cfg.Storage.Endpoint = parsed.Host
|
|
if parsed.User != nil {
|
|
cfg.Storage.AccessKey = parsed.User.Username()
|
|
if pwd, ok := parsed.User.Password(); ok {
|
|
cfg.Storage.SecretKey = pwd
|
|
}
|
|
}
|
|
if bucket := strings.TrimPrefix(parsed.Path, "/"); bucket != "" {
|
|
cfg.Storage.Bucket = bucket
|
|
}
|
|
cfg.Storage.UseSSL = parsed.Scheme == "s3s" || parsed.Query().Get("ssl") == "true"
|
|
cfg.Storage.PublicBaseURL = getEnv("S3_PUBLIC_URL", "")
|
|
}
|
|
} else if endpoint := getEnv("S3_ENDPOINT", ""); endpoint != "" {
|
|
cfg.Storage.Enabled = true
|
|
cfg.Storage.Endpoint = endpoint
|
|
cfg.Storage.AccessKey = getEnv("S3_ACCESS_KEY", "")
|
|
cfg.Storage.SecretKey = getEnv("S3_SECRET_KEY", "")
|
|
cfg.Storage.Bucket = getEnv("S3_BUCKET", "commerce")
|
|
cfg.Storage.UseSSL = getEnv("S3_USE_SSL", "false") == "true"
|
|
cfg.Storage.PublicBaseURL = getEnv("S3_PUBLIC_URL", "")
|
|
}
|
|
|
|
// Search (Meilisearch)
|
|
if searchURL := getEnv("SEARCH_URL", ""); searchURL != "" {
|
|
cfg.Search.Enabled = true
|
|
cfg.Search.Host = searchURL
|
|
if parsed, err := url.Parse(searchURL); err == nil {
|
|
if parsed.User != nil {
|
|
cfg.Search.APIKey = parsed.User.Username()
|
|
}
|
|
}
|
|
}
|
|
|
|
// PubSub (NATS)
|
|
if pubsubURL := getEnv("PUBSUB_URL", ""); pubsubURL != "" {
|
|
cfg.PubSub.Enabled = true
|
|
cfg.PubSub.URL = pubsubURL
|
|
if parsed, err := url.Parse(pubsubURL); err == nil {
|
|
if parsed.User != nil {
|
|
cfg.PubSub.Token = parsed.User.Username()
|
|
}
|
|
}
|
|
cfg.PubSub.EnableJetStream = getEnv("PUBSUB_JETSTREAM", "true") == "true"
|
|
}
|
|
|
|
// Tasks
|
|
if tasksURL := getEnv("TASKS_URL", ""); tasksURL != "" {
|
|
if parsed, err := url.Parse(tasksURL); err == nil {
|
|
cfg.Tasks.Enabled = true
|
|
cfg.Tasks.Address = parsed.Host
|
|
if ns := strings.TrimPrefix(parsed.Path, "/"); ns != "" {
|
|
cfg.Tasks.Namespace = ns
|
|
}
|
|
}
|
|
}
|
|
|
|
return cfg
|
|
}
|
|
|
|
// App is the main Commerce application
|
|
type App struct {
|
|
config *Config
|
|
|
|
// Root command
|
|
RootCmd *cobra.Command
|
|
|
|
// Database manager
|
|
DB *db.Manager
|
|
|
|
// Infrastructure manager
|
|
Infra *infra.Manager
|
|
|
|
// Hook system
|
|
Hooks *hooks.Registry
|
|
|
|
// Events client (sends to analytics-collector via HTTP)
|
|
Events *events.Client
|
|
|
|
// Publisher sends commerce events to NATS/JetStream
|
|
Publisher *events.Publisher
|
|
|
|
// ZAP node for inter-service vector operations
|
|
ZAP *infra.ZAPNode
|
|
|
|
// HTTP router — the native zip app (zap-proto/fiber underneath).
|
|
Router *zip.App
|
|
|
|
// CommerceStore is the hanzo/base-backed persistence seam. When set it
|
|
// provides the authoritative orgs + hostname-claims collections;
|
|
// handlers that have migrated off the legacy resolver use this directly.
|
|
// Initialized in Bootstrap from COMMERCE_DATA_DIR / COMMERCE_BASE_URL.
|
|
CommerceStore *commercestore.Store
|
|
|
|
// Shutdown handling
|
|
shutdownOnce sync.Once
|
|
shutdownCh chan struct{}
|
|
|
|
// State
|
|
bootstrapped bool
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// New creates a new Commerce application with default configuration
|
|
func New() *App {
|
|
return NewWithConfig(DefaultConfig())
|
|
}
|
|
|
|
// NewWithConfig creates a new Commerce application with the given configuration
|
|
func NewWithConfig(config *Config) *App {
|
|
app := &App{
|
|
config: config,
|
|
Hooks: hooks.NewRegistry(),
|
|
shutdownCh: make(chan struct{}),
|
|
}
|
|
|
|
// Set Gin mode
|
|
|
|
// Initialize CLI
|
|
app.initCLI()
|
|
|
|
return app
|
|
}
|
|
|
|
// initCLI sets up the cobra command structure
|
|
func (app *App) initCLI() {
|
|
app.RootCmd = &cobra.Command{
|
|
Use: "commerce",
|
|
Short: "Hanzo Commerce - Multi-org e-commerce platform",
|
|
Version: Version,
|
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
|
// Skip bootstrap for help/version
|
|
if cmd.Name() == "help" || cmd.Name() == "version" {
|
|
return nil
|
|
}
|
|
return app.Bootstrap()
|
|
},
|
|
}
|
|
|
|
// Global flags
|
|
app.RootCmd.PersistentFlags().StringVar(&app.config.DataDir, "dir", app.config.DataDir, "Data directory")
|
|
app.RootCmd.PersistentFlags().BoolVar(&app.config.Dev, "dev", app.config.Dev, "Enable development mode")
|
|
app.RootCmd.PersistentFlags().StringVar(&app.config.Secret, "secret", app.config.Secret, "Encryption secret")
|
|
|
|
// Add commands
|
|
app.RootCmd.AddCommand(app.newServeCmd())
|
|
app.RootCmd.AddCommand(app.newAdminCmd())
|
|
app.RootCmd.AddCommand(app.newSeedCmd())
|
|
}
|
|
|
|
// newServeCmd creates the serve command
|
|
func (app *App) newServeCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "serve [address]",
|
|
Short: "Start the Commerce server",
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if len(args) > 0 {
|
|
app.config.HTTPAddr = args[0]
|
|
}
|
|
return app.Serve()
|
|
},
|
|
}
|
|
|
|
cmd.Flags().StringSliceVar(&app.config.AllowedOrigins, "origins", app.config.AllowedOrigins, "CORS allowed origins")
|
|
cmd.Flags().StringVar(&app.config.HTTPSAddr, "https", "", "HTTPS address")
|
|
cmd.Flags().StringVar(&app.config.TLSCert, "cert", "", "TLS certificate path")
|
|
cmd.Flags().StringVar(&app.config.TLSKey, "key", "", "TLS key path")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// newAdminCmd creates the admin command
|
|
func (app *App) newAdminCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "admin",
|
|
Short: "Admin user management",
|
|
}
|
|
|
|
cmd.AddCommand(&cobra.Command{
|
|
Use: "create [email]",
|
|
Short: "Create an admin user",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
email := args[0]
|
|
fmt.Printf("Creating admin user: %s\n", email)
|
|
// TODO: Implement admin creation
|
|
return nil
|
|
},
|
|
})
|
|
|
|
return cmd
|
|
}
|
|
|
|
// newSeedCmd creates the seed command for bootstrapping organizations and plans
|
|
func (app *App) newSeedCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "seed [org-name]",
|
|
Short: "Seed organization and plans for a service",
|
|
Long: "Bootstrap an organization with API tokens and subscription plans.\nDefault org: bootnode",
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
orgName := "bootnode"
|
|
if len(args) > 0 {
|
|
orgName = args[0]
|
|
}
|
|
return app.seedOrganization(orgName)
|
|
},
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
func (app *App) seedOrganization(orgName string) error {
|
|
ctx := context.Background()
|
|
ds := commerceDatastore.New(ctx)
|
|
|
|
// Create or get organization
|
|
org := orgModel.New(ds)
|
|
org.Name = orgName
|
|
org.GetOrCreate("Name=", org.Name)
|
|
org.FullName = orgName + " Platform"
|
|
org.Enabled = true
|
|
org.AddDefaultTokens()
|
|
|
|
org.MustPut()
|
|
|
|
// Get the test-secret-key token for API access
|
|
tok, err := org.GetTokenByName("test-secret-key")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get API token: %w", err)
|
|
}
|
|
|
|
fmt.Printf("Organization: %s (ID: %s)\n", org.Name, org.Id())
|
|
fmt.Printf("API Key (test): %s\n", tok.String)
|
|
|
|
liveTok, err := org.GetTokenByName("live-secret-key")
|
|
if err == nil {
|
|
fmt.Printf("API Key (live): %s\n", liveTok.String)
|
|
}
|
|
|
|
// Create plans in the org's namespace
|
|
nsDs := commerceDatastore.New(ctx)
|
|
nsDs.SetNamespace(org.Namespace())
|
|
|
|
plans := []struct {
|
|
slug string
|
|
name string
|
|
price int64
|
|
interval string
|
|
description string
|
|
}{
|
|
{"bootnode-free", "Bootnode Free", 0, "month", "Free tier: 30M CU/mo, 25 req/s"},
|
|
{"bootnode-payg", "Bootnode Pay-As-You-Go", 0, "month", "PAYG: $0.40/M CU, 300 req/s"},
|
|
{"bootnode-growth", "Bootnode Growth", 4900, "month", "Growth: 100M CU included, $0.35/M overage, 500 req/s"},
|
|
{"bootnode-enterprise", "Bootnode Enterprise", 0, "month", "Enterprise: custom pricing, 1000+ req/s"},
|
|
}
|
|
|
|
for _, p := range plans {
|
|
pln := planModel.New(nsDs)
|
|
pln.Slug = p.slug
|
|
pln.GetOrCreate("Slug=", pln.Slug)
|
|
pln.Name = p.name
|
|
pln.Price = currency.Cents(p.price)
|
|
pln.Currency = currency.USD
|
|
pln.Interval = types.Monthly
|
|
pln.IntervalCount = 1
|
|
pln.Description = p.description
|
|
pln.MustPut()
|
|
fmt.Printf("Plan: %s (%s) - $%.2f/mo\n", pln.Name, pln.Slug, float64(pln.Price)/100)
|
|
}
|
|
|
|
fmt.Println("\nSeed complete.")
|
|
return nil
|
|
}
|
|
|
|
// Start runs the application
|
|
func (app *App) Start() error {
|
|
return app.RootCmd.Execute()
|
|
}
|
|
|
|
// Bootstrap initializes the application
|
|
func (app *App) Bootstrap() error {
|
|
app.mu.Lock()
|
|
defer app.mu.Unlock()
|
|
|
|
if app.bootstrapped {
|
|
return nil
|
|
}
|
|
|
|
// Trigger OnBootstrap hooks
|
|
if err := app.Hooks.TriggerBootstrap(app); err != nil {
|
|
return fmt.Errorf("bootstrap hook error: %w", err)
|
|
}
|
|
|
|
// Ensure data directory exists
|
|
if err := os.MkdirAll(app.config.DataDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create data directory: %w", err)
|
|
}
|
|
|
|
// Initialize database manager. Start from db.DefaultConfig() so the per-org
|
|
// SQLite stores inherit the concurrency-safe SQLite settings (WAL journal +
|
|
// 10s busy_timeout + bounded conn pools). The previous literal omitted
|
|
// .SQLite entirely, leaving BusyTimeout=0 / JournalMode="" — which made any
|
|
// concurrent write (deposit credit, usage debit, periodic DB tasks) fail
|
|
// immediately with "database is locked" on the SQLite fallback path.
|
|
dbConfig := db.DefaultConfig()
|
|
dbConfig.DataDir = app.config.DataDir
|
|
dbConfig.DatastoreDSN = app.config.DatastoreDSN
|
|
dbConfig.EnableDatastore = app.config.DatastoreDSN != ""
|
|
dbConfig.VectorDimensions = 1536
|
|
dbConfig.IsDev = app.config.Dev
|
|
dbConfig.MasterKey = app.config.MasterKey
|
|
|
|
var err error
|
|
app.DB, err = db.NewManager(dbConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize database: %w", err)
|
|
}
|
|
|
|
// Wire the primary data store. Prefer PostgreSQL (SQL_URL) in production;
|
|
// fall back to per-org SQLite for local/dev environments.
|
|
var systemDB db.DB
|
|
if sqlURL := getEnv("SQL_URL", ""); sqlURL != "" {
|
|
pdb, pgErr := db.NewPostgresDB(&db.PostgresDBConfig{
|
|
DSN: sqlURL,
|
|
MaxOpenConns: 4,
|
|
MaxIdleConns: 2,
|
|
ConnMaxLifetime: 30 * time.Minute,
|
|
ConnMaxIdleTime: 5 * time.Minute,
|
|
QueryTimeout: 30 * time.Second,
|
|
TenantID: "system",
|
|
TenantType: "org",
|
|
EnableVectorSearch: true,
|
|
VectorDimensions: 1536,
|
|
})
|
|
if pgErr != nil {
|
|
return fmt.Errorf("failed to connect to PostgreSQL (SQL_URL): %w", pgErr)
|
|
}
|
|
systemDB = pdb
|
|
fmt.Println("Commerce: using PostgreSQL as primary data store")
|
|
} else {
|
|
// Dev/local fallback: per-org SQLite in DataDir.
|
|
fmt.Fprintln(os.Stderr, "Commerce: SQL_URL not set, falling back to SQLite (not for production)")
|
|
var dbErr error
|
|
systemDB, dbErr = app.DB.Org("system")
|
|
if dbErr != nil {
|
|
return fmt.Errorf("failed to initialize system database: %w", dbErr)
|
|
}
|
|
}
|
|
commerceDatastore.SetDefaultDB(systemDB)
|
|
commerceQuery.SetDefaultDB(systemDB)
|
|
|
|
// Route the generic REST merchant datastore (product/order/store/customer/
|
|
// collection/discount/variant/…) to per-org SQLite via db.Manager.Org(<caller
|
|
// org>). systemDB above remains the store for global kinds (organization/user/
|
|
// token, which are DefaultNamespace) and for the billing/checkout subsystems
|
|
// that call datastore.New directly. Merchant models are thus physically
|
|
// isolated per org — no request can read/list/mutate another org's merchant
|
|
// data on the /v1 REST surface, regardless of whether systemDB is Postgres or
|
|
// SQLite. Decomplects the single-org Postgres out of the merchant path.
|
|
if app.DB == nil {
|
|
return fmt.Errorf("commerce: database manager not initialized — cannot install the per-org money resolver")
|
|
}
|
|
commerceDatastore.SetOrgDBResolver(app.DB.Org)
|
|
|
|
// Fail closed: a production binary must NOT run a money path without the
|
|
// per-org resolver installed. Without it every NewNamespaced money handler
|
|
// (gift cards, orders, checkout, transactions, wire, b2b) would silently fall
|
|
// back to the shared systemDB and cross orgs (Red CRIT-2). Assert here at
|
|
// Bootstrap rather than degrade on the hot path.
|
|
if !commerceDatastore.HasOrgDBResolver() {
|
|
return fmt.Errorf("commerce: per-org DB resolver not installed after SetOrgDBResolver — refusing to start (money paths would use the shared store)")
|
|
}
|
|
|
|
// Chain-backed credit ledger (HUSD on the Hanzo EVM). Build the mint+index
|
|
// service from the KMS-injected HUSD config + the org-derivation master seed
|
|
// (all in-memory only). The SEED is the ledger's sole intent signal: with a
|
|
// seed AND token+key the ledger is ENABLED; without a seed the service is
|
|
// DISABLED and the billing money-in handlers keep their existing DB-mint
|
|
// behavior — so a deploy with HUSD token+key but no seed (e.g. the shared
|
|
// config the OSS contributor payout path uses) is a no-op for the ledger, and
|
|
// the migration (Step 6) flips it on deliberately by provisioning the seed.
|
|
// Wired AFTER the DB resolver so its per-org + system stores resolve. Fail
|
|
// closed ONLY on an incoherent ledger config (seed set but token/key missing)
|
|
// — see husdledger.ValidateConfig.
|
|
husdCfg := husd.Config{}
|
|
husdCfg.LoadFromEnv()
|
|
var husdSeed []byte
|
|
if raw := os.Getenv("HUSD_ORG_DERIVATION_SEED"); raw != "" {
|
|
s, seedErr := treasury.SeedFromHex(raw)
|
|
if seedErr != nil {
|
|
return fmt.Errorf("commerce: invalid HUSD_ORG_DERIVATION_SEED: %w", seedErr)
|
|
}
|
|
husdSeed = s
|
|
}
|
|
if err := husdledger.ValidateConfig(husdCfg, husdSeed); err != nil {
|
|
return fmt.Errorf("commerce: %w", err)
|
|
}
|
|
husdSvc := husdledger.New(husdCfg, husdSeed)
|
|
husdledger.SetDefault(husdSvc)
|
|
if husdSvc.Enabled() {
|
|
fmt.Fprintf(os.Stderr, "Commerce: chain-backed credit ledger ENABLED (HUSD chainId=%d token=%s)\n", husdCfg.ChainID, husdCfg.TokenAddress)
|
|
} else {
|
|
fmt.Fprintln(os.Stderr, "Commerce: chain-backed credit ledger disabled (HUSD not configured — using DB credit path)")
|
|
}
|
|
|
|
// Crypto deposit watcher: the half of the crypto top-up rail that turns money
|
|
// arriving at a minted custody address into spendable balance. CONSTRUCTED
|
|
// here, after the DB resolver — and deliberately NOT started.
|
|
//
|
|
// Bootstrap builds the app; it does not run it. This was the one background
|
|
// schedule that broke that rule, and the breakage was not academic: every
|
|
// caller of Bootstrap got a chain scanner that writes ledger credits, whether
|
|
// or not it was ever going to serve a request. cmd/grant and
|
|
// cmd/backfill-events are one-shot tools that call Bootstrap for the DB
|
|
// wiring, never call Shutdown, and so started a money-moving pass and exited
|
|
// in the middle of it. Starting is the serving lifecycle's job — see Serve
|
|
// and Embed, which are the two entry points that mean "commerce is now
|
|
// answering requests".
|
|
//
|
|
// Nothing is lost by moving it: the in-process schedule is still the point
|
|
// (the alternative in this repo is an admin endpoint waiting for a CronJob
|
|
// defined in another repository, which is exactly why the HUSD indexer has
|
|
// never run), and forgetting to start it can no longer take money — the mint
|
|
// gate consults Running, not Enabled, so a serving process with a dead
|
|
// schedule refuses to hand out addresses rather than handing out addresses
|
|
// nothing watches.
|
|
//
|
|
// Fail closed on an incoherent watch table (a token with no endpoint, a token
|
|
// with no USD peg): an operator who configured a deposit rail must not get a
|
|
// process that watches less than they asked for, because the deposits it does
|
|
// not watch are money received and never credited. An EMPTY table is not an
|
|
// error — it is the inert state, identical to before this existed.
|
|
depositSvc, depositErr := depositledger.New(depositledger.Env())
|
|
if depositErr != nil {
|
|
return fmt.Errorf("commerce: crypto deposit watcher: %w", depositErr)
|
|
}
|
|
depositledger.SetDefault(depositSvc)
|
|
|
|
// Circle Gateway top-up rail. Armed by CIRCLE_GATEWAY_TREASURY (plain, not
|
|
// secret) with CIRCLE_ENVIRONMENT naming the network; unset is the inert
|
|
// state. Fail closed on an incoherent config — a treasury that does not
|
|
// read as an address, or no stated network — because the failure mode is
|
|
// payments made out to an address this process misread, or credited from a
|
|
// network it guessed. Like the deposit watcher, its reconciler is started
|
|
// by the serving entry points, never here.
|
|
gatewayCfg, gatewayErr := circle.FromEnv(os.Environ())
|
|
if gatewayErr != nil {
|
|
slog.Error("commerce: circle gateway disabled; other billing rails remain available", "err", gatewayErr)
|
|
gatewayCfg = circle.Config{}
|
|
}
|
|
gateway.SetDefault(gateway.New(gatewayCfg, circle.New(gatewayCfg.Network.Base())))
|
|
|
|
// Hanzo/base-backed commerce store: the commerce_kv cache collection that
|
|
// replaced the former Redis/Valkey KV. The org registry that once lived
|
|
// here is gone — the IAM org is the org, resolved host→brand→org with no
|
|
// row to seed or drift. Built before the infra manager so its KV store can
|
|
// be attached to the manager (no second base app on the same SQLite
|
|
// files). Bootstrap is idempotent; a failure here is fatal because the KV
|
|
// cache backs live request paths.
|
|
storeCfg := commercestore.FromEnv()
|
|
if storeCfg.DataDir == "" || storeCfg.DataDir == "./commerce_data" {
|
|
// Align with the app-level DataDir so all commerce persistence lives
|
|
// under one tree.
|
|
storeCfg.DataDir = filepath.Join(app.config.DataDir, "base")
|
|
}
|
|
cStore, storeErr := commercestore.New(storeCfg)
|
|
if storeErr != nil {
|
|
return fmt.Errorf("failed to initialize commerce store: %w", storeErr)
|
|
}
|
|
app.CommerceStore = cStore
|
|
|
|
// Initialize infrastructure manager. Attach the base-backed KV client
|
|
// (sharing the commerce store) before Connect so Connect reuses it.
|
|
app.Infra = infra.New(&app.config.Infra)
|
|
if app.config.Infra.KV.Enabled {
|
|
// KV_URL selects an external Hanzo KV instance (the on-demand add-on);
|
|
// unset ⇒ the Base-backed commerce_kv (default — no external datastore).
|
|
// A malformed KV_URL fails closed rather than silently splitting cache +
|
|
// lock state across two backends.
|
|
var kvc *infra.KVClient
|
|
if kvURL := getEnv("KV_URL", ""); kvURL != "" {
|
|
c, kvErr := infra.NewKVClientFromURL(&app.config.Infra.KV, kvURL)
|
|
if kvErr != nil {
|
|
return fmt.Errorf("commerce: invalid KV_URL: %w", kvErr)
|
|
}
|
|
kvc = c
|
|
fmt.Fprintln(os.Stderr, "Commerce: using external Hanzo KV (KV_URL) for cache + locks")
|
|
} else {
|
|
kvc = infra.NewKVClientFromStore(&app.config.Infra.KV, cStore)
|
|
}
|
|
app.Infra.SetKV(kvc)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), app.config.Infra.ConnectTimeout)
|
|
defer cancel()
|
|
|
|
if err := app.Infra.Connect(ctx); err != nil {
|
|
// Log but don't fail - infrastructure services are optional
|
|
fmt.Fprintf(os.Stderr, "Warning: some infrastructure services unavailable: %v\n", err)
|
|
}
|
|
|
|
// Wire the object store into the admin upload surface (POST /v1/upload).
|
|
// Absent S3 config ⇒ storage nil ⇒ uploads 503 (never a silent drop).
|
|
if storageClient, err := app.Infra.Storage(); err == nil {
|
|
uploadApi.SetStorage(storageClient)
|
|
}
|
|
|
|
// Initialize ZAP node for inter-service vector operations
|
|
if vector, err := app.Infra.Vector(); err == nil {
|
|
zapPort := 9090
|
|
if p := getEnv("COMMERCE_ZAP_PORT", ""); p != "" {
|
|
if parsed, convErr := strconv.Atoi(p); convErr == nil {
|
|
zapPort = parsed
|
|
}
|
|
}
|
|
zapCfg := &infra.ZAPConfig{
|
|
Enabled: true,
|
|
NodeID: getEnv("COMMERCE_ZAP_NODE_ID", "commerce-0"),
|
|
Port: zapPort,
|
|
}
|
|
zapNode, zapErr := infra.NewZAPNode(zapCfg, vector, slog.Default())
|
|
if zapErr != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: ZAP node failed to start: %v\n", zapErr)
|
|
} else {
|
|
app.ZAP = zapNode
|
|
// Wire the ZAP-native SBOM ingest path (0x20). Stores via the SAME
|
|
// sbomrecord.Ingest used by the HTTP handler, in the global "system"
|
|
// namespace where SBOMs + OSS accruals live.
|
|
zapNode.RegisterSBOMIngest(func(ctx context.Context, p infra.SBOMIngestPayload) error {
|
|
db := commerceDatastore.New(ctx)
|
|
db.SetNamespace("system")
|
|
comps := make([]sbomrecord.Component, 0, len(p.Components))
|
|
for _, comp := range p.Components {
|
|
scope := comp.Scope
|
|
if scope != "direct" {
|
|
scope = "transitive"
|
|
}
|
|
comps = append(comps, sbomrecord.Component{
|
|
PURL: comp.PURL, Name: comp.Name, Ecosystem: comp.Ecosystem,
|
|
Version: comp.Version, Scope: scope,
|
|
})
|
|
}
|
|
in := sbomrecord.New(db)
|
|
in.ImageRef = p.ImageRef
|
|
in.ImageDigest = p.ImageDigest
|
|
in.Service = p.Service
|
|
in.Format = p.Format
|
|
in.Tool = p.Tool
|
|
in.Components = comps
|
|
_, err := sbomrecord.Ingest(db, in)
|
|
return err
|
|
})
|
|
fmt.Printf("ZAP node started on :%d (vector ops: 0x10=upsert, 0x11=search, 0x12=delete; sbom: 0x20=ingest)\n", zapPort)
|
|
}
|
|
}
|
|
|
|
// Initialize analytics client (sends events to analytics-collector via HTTP)
|
|
if app.config.AnalyticsEndpoint != "" {
|
|
app.Events = events.NewClient(app.config.AnalyticsEndpoint)
|
|
}
|
|
|
|
// Initialize NATS/JetStream publisher for commerce events
|
|
if pubsub, err := app.Infra.PubSub(); err == nil {
|
|
if err := events.Bootstrap(ctx, pubsub); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to bootstrap commerce stream: %v\n", err)
|
|
}
|
|
app.Publisher = events.NewPublisher(pubsub)
|
|
fmt.Println("Commerce event publisher initialized (NATS/JetStream)")
|
|
}
|
|
|
|
// Initialize router — native zip (zap-proto/fiber): zero net/http
|
|
// adaptation. Co-resident mode registers on the host's shared app; the
|
|
// host owns Recover/logging for its whole surface.
|
|
if app.config.SharedApp != nil {
|
|
app.Router = app.config.SharedApp
|
|
} else {
|
|
app.Router = zip.New(zip.Config{AppName: "commerce", DisableStartupMessage: true})
|
|
app.Router.Use(zipmw.Recover())
|
|
if app.config.Dev {
|
|
app.Router.Use(middleware.Logger())
|
|
}
|
|
}
|
|
|
|
// Initialize IAM middleware for hanzo.id JWT validation
|
|
if app.config.IAM.Enabled && app.config.IAM.Issuer != "" && app.config.IAM.ClientID != "" {
|
|
iamCfg := &auth.IAMConfig{
|
|
Issuer: app.config.IAM.Issuer,
|
|
ClientID: app.config.IAM.ClientID,
|
|
ClientSecret: app.config.IAM.ClientSecret,
|
|
AcceptedAudiences: app.config.IAM.AcceptedAudiences,
|
|
AcceptedIssuers: app.config.IAM.AcceptedIssuers,
|
|
JwksURI: app.config.IAM.JwksURI,
|
|
}
|
|
if err := iammiddleware.Init(iamCfg); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: IAM middleware initialization failed: %v\n", err)
|
|
app.config.IAM.Enabled = false
|
|
}
|
|
} else if app.config.IAM.ClientID == "" {
|
|
app.config.IAM.Enabled = false
|
|
}
|
|
|
|
// Platform product catalog seed — populate the CMS source-of-truth for
|
|
// Hanzo's own products (the list docs/console/pricing derive from) on first
|
|
// boot. SeedIfEmpty is a cheap count-gated no-op once populated, so CMS
|
|
// edits are authoritative thereafter. Set COMMERCE_CATALOG_SEED=false to skip.
|
|
// The address on each of those rows is corrected on EVERY boot, because it is
|
|
// the one field on them the fleet owns rather than a human — see
|
|
// catalogentry.Correct. Same gate: the seed and its correction are one
|
|
// decision about where the catalog's contents come from.
|
|
if getEnv("COMMERCE_CATALOG_SEED", "true") != "false" {
|
|
app.runCatalogSeed()
|
|
app.runInfraCatalogSeed()
|
|
app.runEnsoCatalogSeed()
|
|
}
|
|
|
|
// Currency reference seed — populate the global (default-namespace) currency
|
|
// table the store/settings + product/price pickers read. SeedIfEmpty is a
|
|
// cheap count-gated no-op once populated, so admin edits stay authoritative.
|
|
// COMMERCE_CURRENCY_SEED=false to skip.
|
|
if getEnv("COMMERCE_CURRENCY_SEED", "true") != "false" {
|
|
app.runCurrencySeed()
|
|
}
|
|
|
|
// Subscription/DNS plan authority seed — reconcile models/plan (the editable
|
|
// pricing source of truth that GET /v1/billing/plans and resolveSubscriptionPlan
|
|
// read) to the embedded @hanzo/plans catalog on EVERY boot.
|
|
//
|
|
// THIS CAN CHANGE WHAT A PLAN CHARGES. That is the point, and it is a reversal
|
|
// of what this comment used to claim ("seed values EQUAL the embed, so it
|
|
// changes NO charge"), which stopped being true the moment the seed could
|
|
// correct rows it had written itself. Shipping a new @hanzo/plans reprices the
|
|
// live catalog on the next boot, and retires what the catalog stopped
|
|
// publishing by archiving it. An admin edit (plan.AdminEdited) is left as the
|
|
// admin set it until the catalog publishes the same values, when the row is
|
|
// the catalog's again; no row is ever deleted.
|
|
//
|
|
// So a catalog change is a DEPLOY, with the same review a deploy gets — not a
|
|
// script someone runs against production holding a SuperAdmin bearer.
|
|
// COMMERCE_PLANS_SEED=false to skip.
|
|
if getEnv("COMMERCE_PLANS_SEED", "true") != "false" {
|
|
app.runPlansSeed()
|
|
// The meter authority the same way, under the SAME decision: seeding an
|
|
// authority at boot is one policy, not two, and a second env var would
|
|
// let a deployment hold half of it. What it seeds is the platform's own
|
|
// metered work — storage, translation, screening — each of which is
|
|
// otherwise a compiled constant with an env override and no history.
|
|
rateapi.LogSeed(context.Background())
|
|
}
|
|
|
|
// Anti-spoofing boundary — MUST be installed before any route group so it
|
|
// wraps EVERY route. zip applies Use() middleware only to routes
|
|
// registered AFTER the Use() call, so this runs ahead of setupRoutes (and
|
|
// ahead of the /v1 api.Route() bundle the cmd/* binaries register
|
|
// post-Bootstrap). EdgeAuth strips client-supplied identity and re-mints it
|
|
// from a verified IAM JWT. See server.go installEdgeAuth.
|
|
installEdgeAuth(app)
|
|
|
|
// Bind the analytics events client into every request's locals ROOT-WIDE, so
|
|
// the post-Bootstrap /v1 api.Route bundle (billing, checkout, usage) inherits
|
|
// it — not just the /v1/commerce group. This is what lets the
|
|
// customer-activity spine (subscription/invoice/usage) actually reach the
|
|
// collector (commerce.events). Best-effort; no-op without a collector.
|
|
installEventsLocal(app)
|
|
|
|
// Setup routes (registers /healthz + the /v1/commerce, /_/commerce groups).
|
|
app.setupRoutes()
|
|
|
|
// Require-identity gate (auth.Gin) — mounted AFTER setupRoutes so /healthz
|
|
// (k8s probes) and the setupRoutes groups keep their existing exemption,
|
|
// while the admin SPA and the post-Bootstrap /v1 bundle inherit it exactly
|
|
// as before. See server.go installRequireGate.
|
|
installRequireGate(app, app.config.RequireIdentity)
|
|
|
|
app.bootstrapped = true
|
|
return nil
|
|
}
|
|
|
|
func (app *App) runCatalogSeed() {
|
|
db := catalogentry.SystemDB(context.Background())
|
|
created, err := catalogentry.SeedIfEmpty(db)
|
|
if err != nil {
|
|
slog.Error("catalog seed failed", "err", err)
|
|
return
|
|
}
|
|
if created > 0 {
|
|
slog.Info("catalog seeded", "products", created)
|
|
}
|
|
|
|
// A row left in a category the taxonomy has retired is not projected at all,
|
|
// so this runs on every boot and ahead of everything that reads the catalog.
|
|
// It moves such rows to the category that replaced the old one — the only
|
|
// home they can have — and touches nothing that already sits in a canonical
|
|
// category, so an admin's choice of category is never overwritten.
|
|
moved, err := catalogentry.Rename(db)
|
|
if err != nil {
|
|
slog.Error("catalog category rename failed", "err", err)
|
|
return
|
|
}
|
|
if moved > 0 {
|
|
slog.Info("catalog categories renamed", "products", moved)
|
|
}
|
|
|
|
// A product's API address is the fleet's fact, not the CMS's, so it is read
|
|
// back from the snapshot on every boot instead of only at birth. Logged
|
|
// because a boot that quietly moves where a customer is told to call is a
|
|
// boot nobody can audit afterwards.
|
|
corrected, err := catalogentry.Correct(db)
|
|
if err != nil {
|
|
slog.Error("catalog address correction failed", "err", err)
|
|
return
|
|
}
|
|
if corrected > 0 {
|
|
slog.Info("catalog addresses corrected", "products", corrected)
|
|
}
|
|
}
|
|
|
|
// runInfraCatalogSeed populates the infra-tier catalog rows (cloud/gpu/datastore
|
|
// pricing tiers) on first boot from the embedded snapshot. Gated SEPARATELY from
|
|
// runCatalogSeed (SeedInfraIfEmpty counts only infra-category rows), so the tiers
|
|
// seed independently of the hanzo product snapshot. Cheap count-gated no-op once
|
|
// populated; failures are logged, never fatal.
|
|
func (app *App) runInfraCatalogSeed() {
|
|
db := catalogentry.SystemDB(context.Background())
|
|
created, err := catalogentry.SeedInfraIfEmpty(db)
|
|
if err != nil {
|
|
slog.Error("infra catalog seed failed", "err", err)
|
|
return
|
|
}
|
|
if created > 0 {
|
|
slog.Info("infra catalog seeded", "tiers", created)
|
|
}
|
|
}
|
|
|
|
// runEnsoCatalogSeed populates the Enso family's model rows on first boot from
|
|
// the embedded snapshot, at the price the model API bills. Gated SEPARATELY
|
|
// (SeedEnsoModelsIfEmpty counts only enso-category rows) so the family seeds
|
|
// independently of the product and infra snapshots — per-family isolation, the
|
|
// same rule sync.go applies so one family's failure cannot touch another's.
|
|
// Cheap count-gated no-op once populated; failures are logged, never fatal.
|
|
//
|
|
// Every boot then reads the snapshot's prices back over the rows it names
|
|
// (catalogentry.Reprice): an Enso price is set in the model API's code and billed
|
|
// from there, so a row stating another is a price nobody is charged. Logged,
|
|
// because a boot that moves a published price is a boot someone has to be able
|
|
// to audit.
|
|
func (app *App) runEnsoCatalogSeed() {
|
|
db := catalogentry.SystemDB(context.Background())
|
|
created, err := catalogentry.SeedEnsoModelsIfEmpty(db)
|
|
if err != nil {
|
|
slog.Error("enso catalog seed failed", "err", err)
|
|
return
|
|
}
|
|
if created > 0 {
|
|
slog.Info("enso catalog seeded", "models", created)
|
|
}
|
|
repriced, err := catalogentry.Reprice(db)
|
|
if err != nil {
|
|
slog.Error("enso catalog reprice failed", "err", err)
|
|
return
|
|
}
|
|
if repriced > 0 {
|
|
slog.Info("enso catalog repriced", "models", repriced)
|
|
}
|
|
}
|
|
|
|
// runCurrencySeed populates the global currency reference table (DEFAULT
|
|
// namespace, like token/user/organization) on first boot from the embedded
|
|
// common-currency list. Cheap count-gated no-op once populated; failures are
|
|
// logged, never fatal — the currency list simply returns empty until seeded.
|
|
func (app *App) runCurrencySeed() {
|
|
db := commerceDatastore.New(context.Background())
|
|
created, err := currencymodel.SeedIfEmpty(db)
|
|
if err != nil {
|
|
slog.Error("currency seed failed", "err", err)
|
|
return
|
|
}
|
|
if created > 0 {
|
|
slog.Info("currency seeded", "currencies", created)
|
|
}
|
|
}
|
|
|
|
// runPlansSeed reconciles the subscription/DNS plan authority (models/plan,
|
|
// "system" ns) to the embedded @hanzo/plans catalog on EVERY boot. It logs the
|
|
// counts because a boot that silently reprices the catalog is a boot nobody can
|
|
// audit afterwards — the SAME
|
|
func (app *App) runPlansSeed() {
|
|
created, corrected, err := billingPkg.SeedPlans(context.Background())
|
|
if err != nil {
|
|
slog.Error("plan authority seed failed", "err", err)
|
|
return
|
|
}
|
|
if created > 0 || corrected > 0 {
|
|
slog.Info("plan authority seeded", "created", created, "corrected", corrected)
|
|
}
|
|
}
|
|
|
|
// setupRoutes configures HTTP routes
|
|
func (app *App) setupRoutes() {
|
|
// Health check
|
|
embedded := app.config.SharedApp != nil
|
|
if !embedded {
|
|
app.Router.Raw(http.MethodGet, "/healthz", func(c *zip.Ctx) error {
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"status": "ok",
|
|
"service": "commerce",
|
|
"version": Version,
|
|
"commit": GitCommit,
|
|
"built": BuildTime,
|
|
})
|
|
})
|
|
}
|
|
|
|
// Embedded admin surface — two go:embed'd bundles sharing /admin/*:
|
|
// /admin/billing/* → hanzoai/billing bundle (IAM-gated inside the handler:
|
|
// admin/billing_admin/owner/superadmin; 404 otherwise)
|
|
// /admin/* → commerce admin SPA (ui.FS; auth enforced by the app
|
|
// itself via IAM redirect)
|
|
// The old manual dispatch is gone: zip routes by SPECIFICITY, so the
|
|
// /admin/billing subtree wins over /admin/* declaratively.
|
|
if !embedded {
|
|
billingUIMount := "/admin/billing"
|
|
app.Router.Raw(http.MethodGet, billingUIMount+"/*", billingUI.UIHandler(billingUIMount, iammiddleware.Client()))
|
|
adminSPA := zip.Static(ui.FS(), zip.WithIndex("index.html"), zip.WithFallback("index.html"))
|
|
app.Router.Raw(http.MethodGet, "/admin", adminSPA)
|
|
app.Router.Raw(http.MethodGet, "/admin/*", adminSPA)
|
|
}
|
|
|
|
// API routes
|
|
api := app.Router.Group("/v1/commerce")
|
|
{
|
|
// Core middleware required by Commerce API handlers
|
|
api.Use(middleware.AddHost())
|
|
api.Use(middleware.RequestContext())
|
|
api.Use(middleware.DetectOverrides())
|
|
api.Use(middleware.ErrorHandlerJSON())
|
|
api.Use(middleware.AccessControl("*"))
|
|
|
|
// IAM JWT validation middleware (falls through to legacy auth if not IAM token).
|
|
// EdgeAuth (strip client identity + mint from verified JWT) is installed by
|
|
// Bootstrap (server.go installIdentityBoundary) BEFORE this group is
|
|
// registered, so it already covers this group.
|
|
if app.config.IAM.Enabled {
|
|
api.Use(iammiddleware.IAMTokenRequired())
|
|
}
|
|
|
|
// Default cache policy: private, no-store for all API routes.
|
|
// Individual route groups or handlers may override with CachePublic().
|
|
api.Use(middleware.CachePrivate())
|
|
|
|
// Inject Events, Publisher, and KV into request locals for handlers.
|
|
api.Use(zip.H(func(c *zip.Ctx) error {
|
|
if app.Events != nil {
|
|
c.Locals("events", app.Events)
|
|
}
|
|
if app.Publisher != nil {
|
|
c.Locals("publisher", app.Publisher)
|
|
}
|
|
if kv, err := app.Infra.KV(); err == nil {
|
|
c.Locals("kv", kv)
|
|
}
|
|
return c.Next()
|
|
}))
|
|
|
|
// Trigger OnRouteSetup hooks to let extensions add routes
|
|
app.Hooks.TriggerRouteSetup(api)
|
|
|
|
// Owner=="admin" margin projection: GET /v1/commerce/admin/catalog. On the
|
|
// IAM-gated api group so claims are present; the handler ALSO enforces
|
|
// IsSuperAdmin(), so cost + margin never reach an org-level admin.
|
|
catalogapi.AdminCatalogRoute(api)
|
|
}
|
|
|
|
// Hosted multi-org checkout. Mounts:
|
|
// GET /v1/commerce/org — public org JSON (branding + enabled methods)
|
|
// POST /v1/commerce/deposits — proxied to org Backend.URL (e.g. a broker-dealer backend)
|
|
// GET /* — embedded Vite SPA with SPA fallback
|
|
//
|
|
// Must be registered LAST: the SPA handler is the least-specific catch-all,
|
|
// and everything above this line owns its own route prefix.
|
|
// Org-as-org resolution (ONE way): the IAM org IS the org. host→brand→
|
|
// org slug (checkout.OrgResolver) is the single source of truth for
|
|
// /v1/commerce/org, deposits, and webhooks — no separate commerce-org
|
|
// registry to seed or drift. The public org JSON carries the org's public
|
|
// Square config (resolved by the same authority as the charge path), 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, no 404.
|
|
//
|
|
// The Square public config is resolved from the org ROW, because live-vs-sandbox
|
|
// is org state (TestMode() == !Live) and nothing else can answer it. With the
|
|
// nil loader this used to be a synthetic org, which is never Live, so the public
|
|
// org JSON advertised the SANDBOX application permanently — flipping the org
|
|
// live changed the record and nothing else (measured 2026-07-30:
|
|
// POST /v1/billing/mode returned {"live":true,"testMode":false} while the
|
|
// org kept serving sandbox-sq0idb-…). The card iframe then tokenizes against
|
|
// sandbox while the charge path uses the live org, and a sandbox nonce cannot be
|
|
// charged by a production account — checkout fails closed and nobody can pay.
|
|
//
|
|
// The read is wrapped in the two properties checkout.OrgLoader REQUIRES, since
|
|
// this is a PUBLIC, unauthenticated endpoint the pay SPA hits on every boot and
|
|
// an unbounded per-request query is what exhausted the pool at 1.42.44:
|
|
// a 60s cache (so a boot storm costs one read, and a flip still lands within a
|
|
// minute with no restart) and a 2s deadline (so a wedged datastore costs one
|
|
// request, not the pool). Every failure path returns a MISS, which degrades to
|
|
// exactly the previous synthetic-org behavior — sandbox. An outage can never
|
|
// promote a org onto production rails.
|
|
orgResolver := checkout.NewOrgResolver(checkout.NewCachedOrgLoader(
|
|
func(ctx context.Context, slug string) (*orgModel.Organization, error) {
|
|
org := orgModel.New(commerceDatastore.New(ctx))
|
|
if _, err := org.Query().Filter("Name=", slug).Get(); err != nil {
|
|
return nil, err
|
|
}
|
|
return org, nil
|
|
},
|
|
60*time.Second, 2*time.Second,
|
|
).Load)
|
|
|
|
// forwardedHostMiddleware lifts the original customer-facing host from
|
|
// X-Forwarded-Host (set by a trusted upstream) since the ingress overwrites
|
|
// req.Host. brandForHost is exact-suffix, so a spoofed host still only maps
|
|
// to a real brand's org.
|
|
public := app.Router.Group("/v1/commerce")
|
|
public.Use(forwardedHostMiddleware())
|
|
// Stated HERE rather than lifted from the handler, because OrgJSON is a
|
|
// FACTORY: it takes the resolver and returns the closure, and a doc comment
|
|
// on the factory is not a doc comment on the route zipdoc walks. So the
|
|
// handler's own prose — which is good, and stays where it is — reaches
|
|
// nobody, and cloud's describe gate refused the operation as saying nothing
|
|
// about itself. Its message names this remedy exactly. `/catalog` on the
|
|
// next line needs none: it registers a plain function, so the lift works.
|
|
//
|
|
// This sentence is the one the caller reads — in the spec, the MCP tool
|
|
// list, every generated SDK and the CLI's help — so it says what the
|
|
// endpoint is FOR, not how it is built.
|
|
zip.Describe("GET /v1/commerce/org", zip.Doc{
|
|
Description: "The public configuration for the org that owns this hostname: " +
|
|
"its branding, its checkout options and the return URLs it will accept. " +
|
|
"Unauthenticated and cached briefly, because a storefront asks for it " +
|
|
"before anyone has signed in. An unknown host is a plain 404 that does " +
|
|
"not echo the host back, so it cannot be used to discover which orgs exist.",
|
|
})
|
|
public.Raw(http.MethodGet, "/org", checkout.OrgJSON(orgResolver))
|
|
// The one payment-provider webhook route, in both modes: a standalone commerce
|
|
// and the cloud binary that embeds it serve the same address from this one
|
|
// declaration. Sessionless — the provider's signature over the raw body is the
|
|
// authentication — and the address is part of what Square signs, so it moves
|
|
// only with the processor dashboards (payment/orgsetup.go).
|
|
public.Raw(http.MethodPost, "/webhooks/:provider", middleware.RequestContext(), billingPkg.HandleProviderWebhook)
|
|
// Public platform product catalog projection (the CMS SOT other surfaces —
|
|
// docs, console sidebar, pricing — consume). Public + brand-scoped (?brand).
|
|
public.Raw(http.MethodGet, "/catalog", catalogapi.Public)
|
|
// Public currency reference list (the store/settings + product/price pickers
|
|
// read this instead of a hardcoded array). Global default-namespace set.
|
|
currencyapi.PublicRoute(public)
|
|
// The deposit-intent proxy is GONE. It forwarded /v1/commerce/deposits to a
|
|
// org-supplied broker-dealer backend, and no org here runs one — so
|
|
// every request answered 503 "org backend not configured", a true
|
|
// sentence about orgs that reached a customer buying credit with a
|
|
// wallet. Deposits are commerce's own: a tokenized method charges through
|
|
// /v1/billing/topup/token, wire reads /v1/billing/wire, crypto mints an
|
|
// address at /v1/billing/crypto/deposit. A rail nobody operates is not a
|
|
// fallback, and leaving it as the last branch of the client meant anything
|
|
// unhandled by name silently went there.
|
|
// The provider-webhook forwarder went with it, and for the same reason: it
|
|
// relayed Square's callbacks to that same absent backend. The REAL receiver
|
|
// is commerce's own POST /v1/commerce/webhooks/:provider, which verifies the
|
|
// signature and settles the payment — the address registered in the Square
|
|
// dashboard.
|
|
|
|
// The tenant's own admin read, under commerce's root. The registry CRUD that
|
|
// once lived beside it is gone — the IAM org is the org — so what remains is
|
|
// read-only runtime state.
|
|
//
|
|
// It answers on the `api` group rather than a second one at /_: an address
|
|
// outside /v1 is one no generated client, tool list or CLI can name, and the
|
|
// audience never justified it — a superadmin read is still a read of this
|
|
// capability. The group already carries the chain this needs (IAMTokenRequired
|
|
// when IAM is on, which is what populates the claims below), and a second
|
|
// group at one prefix would run that chain twice per request.
|
|
//
|
|
// The ingress used to be the gate. It is not one now and never was a good
|
|
// one: DepositWatcherStatus refuses anonymously (401) and refuses a
|
|
// non-superadmin (403) on its own, so the answer does not depend on which
|
|
// route the request came through.
|
|
if app.CommerceStore != nil {
|
|
// The crypto deposit rail's runtime state. Read-only and superadmin
|
|
// only — see api/billing.DepositWatcherStatus for why arming an asset
|
|
// stays a CRYPTO_DEPOSIT_* act and never a button.
|
|
api.Raw(http.MethodGet, "/deposits", billingPkg.DepositWatcherStatus)
|
|
// The Circle Gateway treasury's position. Read-only and superadmin
|
|
// only, for the same reason — and STANDALONE only: a co-resident host
|
|
// counts every untyped route the module binds on its app against a
|
|
// ratchet that may only shrink, so in cloud the treasury is read
|
|
// through gateway.Default().Treasury from a typed op of its own.
|
|
if !embedded {
|
|
api.Raw(http.MethodGet, "/treasury", billingPkg.GatewayTreasury)
|
|
}
|
|
}
|
|
|
|
// Standalone-only routes: the full /v1 API bundle and the SPA catch-all.
|
|
// In the co-resident cloud binary, the host binary owns its own root surface
|
|
// and routes /v1/billing through its own controllers.
|
|
if !embedded {
|
|
// Wire the full Commerce API routes (billing, checkout, store, …)
|
|
// on /v1 ahead of the SPA catch-all. zip/fiber evaluates routes in
|
|
// registration order; MountSPA's wildcard /* must be registered LAST
|
|
// so concrete API routes are matched first.
|
|
apiGroup := app.Router.Group("/v1")
|
|
apiGroup.Use(iammiddleware.IAMTokenRequired())
|
|
commerceApi.Route(apiGroup)
|
|
|
|
checkout.MountSPA(app.Router)
|
|
}
|
|
}
|
|
|
|
// Serve starts the HTTP listener(s) — zip.Listen on the native transport
|
|
// (zero net/http). The legacy standalone-TLS path uses the documented fiber
|
|
// escape hatch; production terminates TLS at the gateway.
|
|
func (app *App) Serve() error {
|
|
if app.config.SharedApp != nil {
|
|
return fmt.Errorf("commerce: co-resident (SharedApp set) — the host binary listens")
|
|
}
|
|
// The crypto deposit schedule starts HERE and in Embed — the two entry points
|
|
// that mean "commerce is now answering requests" — and not in Bootstrap,
|
|
// which the one-shot cmd/ tools also call. Idempotent, and a no-op when no
|
|
// asset is configured. Shutdown stops it.
|
|
depositledger.Default().Start()
|
|
gateway.Default().Start()
|
|
|
|
// Trigger OnServe hooks
|
|
if err := app.Hooks.TriggerServe(app); err != nil {
|
|
return fmt.Errorf("serve hook error: %w", err)
|
|
}
|
|
|
|
fmt.Printf("Commerce %s starting on %s\n", Version, app.config.HTTPAddr)
|
|
if app.config.Dev {
|
|
fmt.Println("Running in DEVELOPMENT mode")
|
|
}
|
|
|
|
if app.config.HTTPSAddr != "" && app.config.TLSCert != "" && app.config.TLSKey != "" {
|
|
go func() {
|
|
if err := app.Router.Fiber().Listen(app.config.HTTPSAddr, fiber.ListenConfig{
|
|
CertFile: app.config.TLSCert,
|
|
CertKeyFile: app.config.TLSKey,
|
|
DisableStartupMessage: true,
|
|
}); err != nil {
|
|
fmt.Fprintf(os.Stderr, "HTTPS error: %v\n", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
return app.Router.Listen("http://" + app.config.HTTPAddr)
|
|
}
|
|
|
|
// Shutdown gracefully shuts down the application
|
|
func (app *App) Shutdown() error {
|
|
var err error
|
|
app.shutdownOnce.Do(func() {
|
|
close(app.shutdownCh)
|
|
|
|
// Trigger OnTerminate hooks
|
|
if hookErr := app.Hooks.TriggerTerminate(app); hookErr != nil {
|
|
err = hookErr
|
|
}
|
|
|
|
// Shutdown HTTP server (drains in-flight requests, runs zip teardown hooks)
|
|
if app.Router != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if shutdownErr := app.Router.ShutdownWithContext(ctx); shutdownErr != nil {
|
|
err = shutdownErr
|
|
}
|
|
}
|
|
|
|
// Drain the background queue. The listener is down, so nothing new is
|
|
// being queued; what is left are the webhook emits and counter shards
|
|
// the last requests spawned, and they read the database. Waiting for
|
|
// them here is what makes closing it below safe — otherwise they come
|
|
// back to "db: namespaces closed".
|
|
drain, cancelDrain := context.WithTimeout(context.Background(), 30*time.Second)
|
|
if drainErr := delay.Drain(drain); drainErr != nil {
|
|
slog.Warn("background tasks still running at shutdown", "err", drainErr)
|
|
}
|
|
cancelDrain()
|
|
|
|
// Stop the crypto deposit watcher before the database closes: a pass in
|
|
// flight is holding the ledger open, and a half-written credit is the one
|
|
// thing this rail must never produce.
|
|
depositledger.Default().Stop()
|
|
// And the Gateway reconciler, for the same reason: a pass in flight may
|
|
// be between Gateway's answer and the credit it carries.
|
|
gateway.Default().Stop()
|
|
|
|
// Stop ZAP node
|
|
if app.ZAP != nil {
|
|
app.ZAP.Stop()
|
|
}
|
|
|
|
// Close events emitter (flush remaining events)
|
|
if app.Events != nil {
|
|
if eventsErr := app.Events.Close(); eventsErr != nil {
|
|
err = eventsErr
|
|
}
|
|
}
|
|
|
|
// Close infrastructure
|
|
if app.Infra != nil {
|
|
if infraErr := app.Infra.Close(); infraErr != nil {
|
|
err = infraErr
|
|
}
|
|
}
|
|
|
|
// Close database
|
|
if app.DB != nil {
|
|
if dbErr := app.DB.Close(); dbErr != nil {
|
|
err = dbErr
|
|
}
|
|
}
|
|
})
|
|
|
|
return err
|
|
}
|
|
|
|
// Config returns the current configuration
|
|
func (app *App) Config() *Config {
|
|
return app.config
|
|
}
|
|
|
|
// DataPath returns the full path within the data directory
|
|
func (app *App) DataPath(subpath string) string {
|
|
return filepath.Join(app.config.DataDir, subpath)
|
|
}
|
|
|
|
// IsDev returns true if running in development mode
|
|
func (app *App) IsDev() bool {
|
|
return app.config.Dev
|
|
}
|
|
|
|
// getEnv returns environment variable or default
|
|
func getEnv(key, defaultVal string) string {
|
|
if val := os.Getenv(key); val != "" {
|
|
return val
|
|
}
|
|
return defaultVal
|
|
}
|
|
|
|
// forwardedHostMiddleware lifts the X-Forwarded-Host header into
|
|
// req.Host so downstream org resolution sees the original customer
|
|
// hostname (e.g. world.hanzo.ai) instead of the ingress hostname
|
|
// (e.g. commerce.hanzo.ai). Org resolution is exact-match, so a
|
|
// spoofed header still must point at an existing org row to do
|
|
// anything — there is no probe oracle. Empty header is a no-op.
|
|
func forwardedHostMiddleware() zip.Handler {
|
|
return func(c *zip.Ctx) error {
|
|
if xfh := c.Header("X-Forwarded-Host"); xfh != "" {
|
|
c.Fiber().Request().Header.SetHost(xfh)
|
|
}
|
|
return c.Next()
|
|
}
|
|
}
|