store

package
v1.42.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 6, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package store — hanzo/base seam entry point.

Construction:

s, err := store.New(store.Config{DataDir: "/var/lib/commerce"})

Postgres override (multi-instance deployments):

s, err := store.New(store.Config{
    DataDir: "/var/lib/commerce",   // still used for base's aux
    DataDSN: "postgres://...",      // takes precedence for the main db
})

The Store is the single construction point for collection-backed repositories. New() runs Bootstrap (DB connections, settings load, system migrations) and then RunAllMigrations (commerce-defined collections). Callers hold the resulting *Store for the process lifetime.

Package store is the hanzo/base-backed persistence seam for commerce.

This package replaces the scattered hanzoai/datastore-go + bespoke model packages with a single, typed repository facade over a base.Collection set. The first collection migrated is `commerce_tenants` — other collections (orders, products, etc.) follow the same shape: Go model struct + typed repo + base migration. The legacy `commerce/datastore` package coexists during migration and is removed collection-by-collection.

Security posture:

  • Every repo method is scoped by the caller (handler derives tenant from IAM session claims — never from the body). The store itself does not enforce tenancy; it is the authoritative backing store and the handler layer is the trust boundary.
  • JSON columns (brand/iam/idv/providers/return_url_allowlist) are validated at the handler boundary against the canonical Go types in this file. Malformed JSON lands as a 400 at the handler, not a 500 deep inside the store.
  • Secret fields on Provider (access_token, webhook_signature_key, etc.) flow to KMS out-of-band; they never live in this store. The Provider struct here intentionally has no credential fields.

Index

Constants

This section is empty.

Variables

View Source
var ErrDuplicateTenant = errors.New("store: tenant with that name already exists")

ErrDuplicateTenant is returned when a Create would violate the unique-by- name index. Handlers translate to 409 Conflict.

View Source
var ErrHostnameClaimed = errors.New("store: hostname already claimed by another tenant")

ErrHostnameClaimed is returned when a Create or UpdateHostnames would cross-assign a hostname already owned by a different tenant. Handlers translate to 409 Conflict with a body that does NOT reveal the colliding tenant's identity — that would be a fingerprinting oracle.

View Source
var ErrInvalidHostname = errors.New("store: invalid hostname")

ErrInvalidHostname is returned when a hostname fails normalization or contains characters that are illegal in a Host header value. Handlers translate to 400.

View Source
var ErrTenantNotFound = errors.New("store: tenant not found")

ErrTenantNotFound is returned by every lookup that fails. Handlers MUST translate this to HTTP 404 (never 500) and MUST NOT echo the lookup key in the response body — that would be a free fingerprinting oracle.

Functions

This section is empty.

Types

type BrandConfig

type BrandConfig struct {
	DisplayName  string `json:"display_name"`
	LogoURL      string `json:"logo_url"`
	PrimaryColor string `json:"primary_color"`
}

BrandConfig is the SPA-rendered visible surface.

type Config

type Config struct {
	// DataDir is the base filesystem path for SQLite DB files. If empty,
	// defaults to ./commerce_data (matches commerced's default).
	DataDir string

	// DataDSN is an optional PostgreSQL DSN ("postgres://user:pass@host/db").
	// When set, overrides the main data DB; aux still lives under DataDir.
	DataDSN string

	// AuxDSN is an optional PostgreSQL DSN for the auxiliary DB. When empty,
	// aux falls back to DataDir/auxiliary.db.
	AuxDSN string

	// QueryTimeout is applied to all store-issued queries. Defaults to 30s
	// when zero; never unlimited — a hung query is a DoS vector.
	QueryTimeout time.Duration
}

Config controls how the store connects to its backing database. Zero value is valid and selects the file-path SQLite default under DataDir.

func FromEnv

func FromEnv() Config

FromEnv builds a Config from conventional environment variables. Precedence matches hanzo/base: DSN set → Postgres; otherwise SQLite.

COMMERCE_DATA_DIR   data dir (default "./commerce_data")
COMMERCE_BASE_URL   main data DSN (Postgres) — takes precedence
COMMERCE_BASE_AUX   aux DSN (Postgres)

type IAMConfig

type IAMConfig struct {
	Issuer   string `json:"issuer"`
	ClientID string `json:"client_id"`
}

IAMConfig points the SPA at the tenant's Hanzo IAM app. Only Issuer and ClientID are safe to surface publicly; they already ship in the OIDC well-known doc. No client secret — commerce never needs it; the confidential client flow runs in the tenant's own BD, not in commerce.

type IDVConfig

type IDVConfig struct {
	Provider       string   `json:"provider"`
	Endpoint       string   `json:"endpoint"`
	RequiredFields []string `json:"required_fields,omitempty"`
}

IDVConfig is opaque to commerce; the SPA renders the redirect.

type Provider

type Provider struct {
	Name    string `json:"name"`
	Enabled bool   `json:"enabled"`
	KMSPath string `json:"kms_path,omitempty"`
}

Provider is a payment provider configured for the tenant. Credentials are stored in KMS under commerce/{tenant}/{provider}/{field} — this struct holds only the enable flag + a KMS reference (not the secret itself).

type Store

type Store struct {
	App     core.App
	Tenants *TenantRepo
}

Store is the seam between commerce's HTTP layer and hanzo/base. Add a new collection by (a) writing a migration under store/migrations, (b) adding a typed repo, (c) wiring it onto this struct.

func New

func New(cfg Config) (*Store, error)

New bootstraps a base app from cfg and returns a store ready to serve. The caller owns lifecycle: Close() releases DB handles and stops the base cron ticker.

func (*Store) Close

func (s *Store) Close(_ context.Context) error

Close releases DB handles. Safe to call once per Store.

type Tenant

type Tenant struct {
	ID                 string      `json:"id"`
	Name               string      `json:"name"`
	Hostnames          []string    `json:"hostnames"`
	Brand              BrandConfig `json:"brand"`
	IAM                IAMConfig   `json:"iam"`
	IDV                IDVConfig   `json:"idv"`
	Providers          []Provider  `json:"providers"`
	BDEndpoint         string      `json:"bd_endpoint"`
	ReturnURLAllowlist []string    `json:"return_url_allowlist"`
	Created            time.Time   `json:"created"`
	Updated            time.Time   `json:"updated"`
}

Tenant is the canonical in-memory shape backed by the `commerce_tenants` collection. Hostnames is exact-match only after normalization (lowercase, trailing dot stripped, port stripped) — suffix-match spoofing is rejected by design. See checkout/tenant.go normalizeHost for the rule.

type TenantRepo

type TenantRepo struct {
	// contains filtered or unexported fields
}

TenantRepo is the typed persistence API over the commerce_tenants collection. It intentionally does not do tenant scoping — that is the handler layer's responsibility. Repo methods trust their caller.

func NewTenantRepo

func NewTenantRepo(app core.App) *TenantRepo

NewTenantRepo wraps a base app. The collection must already exist (the migration under store/migrations creates it on Bootstrap).

func (*TenantRepo) Create

func (r *TenantRepo) Create(t *Tenant) error

Create persists a new tenant row atomically with its hostname claims.

Atomicity guarantee (P8-C1 fix): tenant row save + hostname inserts run inside a single transaction. A collision on hostname (unique index on commerce_tenant_hostnames.hostname) rolls back the tenant row as well, so a failed create leaves no dangling state and no "dormant" row.

Uniqueness:

  • `name` — enforced by idx_commerce_tenants_name (unique). Duplicate returns ErrDuplicateTenant.
  • each hostname — enforced by idx_commerce_tenant_hostnames_unique (unique). Cross-tenant collision returns ErrHostnameClaimed.

The caller MUST have already checked superadmin privilege; Create does not verify identity.

func (*TenantRepo) FindByHostname

func (r *TenantRepo) FindByHostname(host string) (*Tenant, error)

FindByHostname resolves a hostname to its owning tenant via the commerce_tenant_hostnames join table. The input is normalized (lowercase, trailing-dot stripped, port stripped) before lookup; malformed inputs return ErrInvalidHostname. Exact-match only — suffix spoofing ("pay.satschel.com.evil.com") is rejected because the row-per-hostname store does a point lookup on the unique index, never a prefix/LIKE scan.

func (*TenantRepo) FindByID

func (r *TenantRepo) FindByID(id string) (*Tenant, error)

FindByID returns the tenant with the given id, or ErrTenantNotFound.

func (*TenantRepo) List

func (r *TenantRepo) List(limit, offset int) ([]*Tenant, error)

List returns tenants ordered by name ascending for admin dashboards. limit is clamped to [1, 500]; offset is clamped to [0, ∞). A zero limit is treated as 50 to avoid accidental full-table scans.

func (*TenantRepo) UpdateHostnames

func (r *TenantRepo) UpdateHostnames(id string, hostnames []string) error

UpdateHostnames replaces the tenant's hostname claim set atomically. All of the tenant's existing commerce_tenant_hostnames rows are deleted and the canonicalized new set is inserted under the same transaction; the unique index on commerce_tenant_hostnames.hostname rejects any incoming hostname already owned by a different tenant (ErrHostnameClaimed).

Concurrency semantics: serializable via the SQL engine's unique-index contention. Two transactions racing to claim "pay.shared.test" — at most one commits; the other returns ErrHostnameClaimed. No application-layer mutex is required or sufficient across replicas.

func (*TenantRepo) UpdateProviders

func (r *TenantRepo) UpdateProviders(id string, providers []Provider) error

UpdateProviders replaces the tenant's providers list atomically. Concurrency model: last-write-wins. If two admins PUT at the same time, the later save overwrites the earlier one. The audit log (logged at the handler layer) records both attempts so operators can reconcile. A future slice may add optimistic-locking via a row version column — that is out of scope here.

Directories

Path Synopsis
Package migrations — commerce-owned base migrations.
Package migrations — commerce-owned base migrations.
Package seed — one-shot seed helpers for local dev.
Package seed — one-shot seed helpers for local dev.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL