mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
The owner's call, 2026-10-30: USDC through Circle Gateway is Circle's own SDK (@circle-fin/x402-batching) in Hanzo's x402 gateway in front of cloud, a deposit into Gateway once and a payment per request. The Go rail reimplemented that protocol, so it goes now rather than later: billing/gateway, payment/providers/circle, models/remittance, the three /v1/billing/crypto/gateway routes, the commerce treasury route, and the gateway networks in /v1/billing/crypto/options. Crypto top-ups by authorization pause until the gateway takes them; the deposit-address rail is unchanged. Hashid kinds 292 and 293 stay taken. Testing: go build ./... and vet clean; api/billing's crypto tests pass (TestPlanAtInterval and TestSubscribeTrial_NoChargeCardVaultedTrialing fail on main without this change too). Risk: removes a live payment route; cloud must drop its plane ops in the release that pins this. How to revert: revert this commit.
1360 lines
52 KiB
Go
1360 lines
52 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/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"
|
|
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)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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()
|
|
|
|
// 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()
|
|
|
|
// 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()
|
|
}
|
|
}
|