diff --git a/SANGRIA_BUY_SKILL.md b/SANGRIA_BUY_SKILL.md index 9ef625b..5520361 100644 --- a/SANGRIA_BUY_SKILL.md +++ b/SANGRIA_BUY_SKILL.md @@ -69,7 +69,7 @@ The Sangria buy flow is split across **two skills** — `sangria-discover` (step POST /v1/buy { "discovery_id": "", "sku": "" } ``` -Both fields required. `Authorization: Bearer $SANGRIA_API_KEY`, JSON. The buy is **strict** (unlike discovery): it validates your operator profile — email + phone + full shipping address — and the merchant's service area before charging. It then charges the chosen match's `price_microunits`, calls the merchant, and on success debits your credit balance. The ledger debit lands only **after** the merchant confirms, so a merchant failure leaves your balance untouched. Only your operator email + phone + shipping address and the chosen item (`sku` + quantity) are sent to the merchant — never your discovery `intent`/`reasoning`/`context`. +Both fields required. `Authorization: Bearer $SANGRIA_API_KEY`, JSON. The buy is **strict** (unlike discovery): it validates your **account email** (sourced from your sign-in identity) plus the **operator's phone + full shipping address**, and the merchant's service area, before charging. It then charges the chosen match's `price_microunits`, calls the merchant, and on success debits your credit balance. The ledger debit lands only **after** the merchant confirms, so a merchant failure leaves your balance untouched. Only your account email, the operator's phone + shipping address, and the chosen item (`sku` + quantity) are sent to the merchant — never your discovery `intent`/`reasoning`/`context`. ## Response shape @@ -163,7 +163,7 @@ Error responses use `{"error": "", ...}`. **Branch on the `error` value, n | Status | `error` code | Meaning | What to do | | ------ | ------------------------------------------------------ | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `400` | `invalid_request` | Body didn't parse, **or** an operator profile field is missing (see `missing_field`) | If `missing_field` is set, tell the user to set that field (email / phone / shipping address) at https://getsangria.com/dashboard. | +| `400` | `invalid_request` | Body didn't parse, **or** a profile field is missing (see `missing_field`) | If `missing_field` is `email`, have the user sign out and back in (it's sourced from their account at sign-in). If it's `phone` or one of `address.shipping.*`, set the field at https://getsangria.com/dashboard. | | `400` | `invalid_discovery_id` | `discovery_id` empty | Provide the `discovery_id` from `/v1/discover`. | | `404` | `invalid_discovery_id` | `discovery_id` not found or not owned by your key | Don't retry. Re-discover if needed. | | `400` | `invalid_sku` | `sku` empty, or not one of the discovery's matches | Use a `sku` from the discovery's `matches[]`; if matches was empty, re-discover. | diff --git a/SANGRIA_DISCOVER_SKILL.md b/SANGRIA_DISCOVER_SKILL.md index c82486b..3e3d615 100644 --- a/SANGRIA_DISCOVER_SKILL.md +++ b/SANGRIA_DISCOVER_SKILL.md @@ -95,7 +95,7 @@ Discovery is **synchronous** and typically completes in under a second (it fetch | `why` | Purpose behind the purchase. | `"client meeting at 10am"` | | `how` | Delivery / format preferences. | `"pickup preferred"` | -Other keys are fine — `context` is stored as-is (any valid JSON; an object is recommended but not enforced). It is **not** scored and **not** sent to the merchant. **Don't put secrets, credentials, or sensitive PII in `reasoning` or `context`** — both are persisted in Sangria's database and may be logged. (Only your operator profile's email + phone + shipping address reach the merchant, and only at `/v1/buy` time.) +Other keys are fine — `context` is stored as-is (any valid JSON; an object is recommended but not enforced). It is **not** scored and **not** sent to the merchant. **Don't put secrets, credentials, or sensitive PII in `reasoning` or `context`** — both are persisted in Sangria's database and may be logged. (Only your account email + the operator's phone + shipping address reach the merchant, and only at `/v1/buy` time.) ## Endpoints diff --git a/backend/.env.example b/backend/.env.example index 29b53c7..2f165f2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -92,4 +92,10 @@ STRIPE_WEBHOOK_SECRET= # (SameSite=Lax, non-secure). Anything other than "development" — including # unset — is treated as production. NODE_ENV is read as a legacy fallback; # prefer APP_ENV. -APP_ENV=development \ No newline at end of file +APP_ENV=development +# Outbound shared secret Sangria sends as `Authorization: Bearer ` on +# merchant /buy requests. Opaque (no format constraint) — generate with e.g. +# `openssl rand -hex 32`, or use the `sg_internal_` convention. +# Must match the value configured on every merchant proxy. Required; +# backend exits at startup if missing. +SANGRIA_INTERNAL_KEY=sg_internal_REPLACE_ME diff --git a/backend/adminHandlers/merchants.go b/backend/adminHandlers/merchants.go index 47d81d6..4082702 100644 --- a/backend/adminHandlers/merchants.go +++ b/backend/adminHandlers/merchants.go @@ -42,7 +42,7 @@ func CreateMerchantAPIKey(pool *pgxpool.Pool) fiber.Handler { if user.FirstName != "" && user.LastName != "" { owner = user.FirstName + " " + user.LastName } - if _, err := dbengine.UpsertUser(c.Context(), pool, owner, user.ID); err != nil { + if _, err := dbengine.UpsertUser(c.Context(), pool, owner, user.ID, user.Email); err != nil { slog.Error("upsert user", "user_id", user.ID, "error", err) return c.Status(500).JSON(fiber.Map{"error": "failed to create user"}) } diff --git a/backend/auth/workos.go b/backend/auth/workos.go index a071cf2..7ffc8f1 100644 --- a/backend/auth/workos.go +++ b/backend/auth/workos.go @@ -164,14 +164,14 @@ func CreateUser(pool *pgxpool.Pool) fiber.Handler { defer tx.Rollback(c.Context()) // Step 1: Create/update user within transaction - u, err := dbengine.UpsertUserTx(c.Context(), tx, owner, user.ID) + u, err := dbengine.UpsertUserTx(c.Context(), tx, owner, user.ID, user.Email) if err != nil { slog.Error("upsert user", "error", err) return c.Status(500).JSON(fiber.Map{"error": "failed to create user"}) } // Step 2: Ensure user has a personal organization within the same transaction - err = dbengine.EnsurePersonalOrganizationTx(c.Context(), tx, user.ID, owner, user.Email) + err = dbengine.EnsurePersonalOrganizationTx(c.Context(), tx, user.ID, owner) if err != nil { slog.Error("ensure personal organization", "error", err) return c.Status(500).JSON(fiber.Map{"error": "failed to setup personal organization"}) @@ -279,7 +279,7 @@ func CreateOrganization(pool *pgxpool.Pool) fiber.Handler { } // Create organization with user as admin - orgID, err := dbengine.CreateOrganization(c.Context(), pool, user.ID, req.Name, user.Email) + orgID, err := dbengine.CreateOrganization(c.Context(), pool, user.ID, req.Name) if err != nil { slog.Error("create organization", "user_id", user.ID, "name", req.Name, "error", err) return c.Status(500).JSON(fiber.Map{"error": "failed to create organization"}) diff --git a/backend/buyHandlers/buy.go b/backend/buyHandlers/buy.go index db9b07d..46452ec 100644 --- a/backend/buyHandlers/buy.go +++ b/backend/buyHandlers/buy.go @@ -62,9 +62,16 @@ func Buy(pool *pgxpool.Pool) fiber.Handler { } // 2. Validate the operator profile — the merchant POST body needs - // email + phone + shipping address. First-missing wins for a stable - // sub-reason across retries. - if operator.Email == nil || strings.TrimSpace(*operator.Email) == "" { + // email + phone + shipping address. The email is sourced from the + // user's identity (users.email, written by UpsertUserTx at login) + // rather than the operator row; phone + shipping stay per-operator. + // First-missing wins for a stable sub-reason across retries. + userEmail, getEmailErr := dbengine.GetUserEmailForOperator(ctx, pool, operator.ID) + if getEmailErr != nil { + slog.Error("get user email for operator", "operator_id", operator.ID, "error", getEmailErr) + return c.Status(fiber.StatusInternalServerError).JSON(errorJSON("internal_error")) + } + if strings.TrimSpace(userEmail) == "" { return c.Status(fiber.StatusBadRequest).JSON(errorJSONWithField("invalid_request", "email")) } if operator.Phone == nil || strings.TrimSpace(*operator.Phone) == "" { @@ -148,7 +155,7 @@ func Buy(pool *pgxpool.Pool) fiber.Handler { defer buyCancel() result, merchErr := merchantClient.Buy(buyCtx, buyURL, sangriamerchant.BuyRequest{ Items: []sangriamerchant.BuyItem{{SKU: match.SKU, Quantity: match.Quantity}}, - Email: *operator.Email, + Email: userEmail, Phone: *operator.Phone, Address: sangriamerchant.BuyAddress{ Line1: shipping.Line1, diff --git a/backend/buyHandlers/common.go b/backend/buyHandlers/common.go index f9e639b..81267c2 100644 --- a/backend/buyHandlers/common.go +++ b/backend/buyHandlers/common.go @@ -16,6 +16,7 @@ import ( "github.com/gofiber/fiber/v3" + "sangria/backend/config" dbengine "sangria/backend/dbEngine" "sangria/backend/sangriamerchant" ) @@ -35,10 +36,16 @@ const ( // tuning becomes a need. const quoteTTL = 60 * time.Second -// merchantClient is the package-level merchant HTTP client. Constructed once -// at package init; stateless except for the underlying http.Client's -// connection pool, so a single instance serves every handler call. -var merchantClient = sangriamerchant.New() +// merchantClient is the package-level merchant HTTP client. Populated by +// InitMerchantClient at startup (off package-init because the constructor +// needs the outbound-auth key from config). Stateless; one per process. +var merchantClient *sangriamerchant.Client + +// InitMerchantClient constructs the merchant client with the configured +// outbound-auth key. Call from main.go after config.LoadSangriaInternalConfig. +func InitMerchantClient() { + merchantClient = sangriamerchant.New(config.SangriaInternal.Key) +} // --------------------------------------------------------------------------- // Microunits conversion diff --git a/backend/buyHandlers/discover.go b/backend/buyHandlers/discover.go index f9d250a..bda3f84 100644 --- a/backend/buyHandlers/discover.go +++ b/backend/buyHandlers/discover.go @@ -100,14 +100,21 @@ func Discover(pool *pgxpool.Pool) fiber.Handler { top := disco.Top3(intent, candidates) // Build the matches. Untrusted catalog money is validated via - // toMicrounitsSafe; a product with NaN/Inf/negative/overflow price is - // skipped rather than minting a bad quote. + // toMicrounitsSafe; a product with null / NaN/Inf/negative/overflow + // price is skipped rather than minting a bad quote (a null priceUsd + // would otherwise collapse to 0.0 → quote for delivery-fee-only, + // which is a real money bug — explicit nil-check defends against it). matches := make([]dbengine.DiscoveryMatch, 0, len(top)) for _, cand := range top { - subtotal, ok := toMicrounitsSafe(cand.Product.PriceUSD) + if cand.Product.PriceUSD == nil { + slog.Warn("merchant product has invalid price; skipping candidate", + "sku", cand.Product.SKU, "price_usd", "null") + continue + } + subtotal, ok := toMicrounitsSafe(*cand.Product.PriceUSD) if !ok { slog.Warn("merchant product has invalid price; skipping candidate", - "sku", cand.Product.SKU, "price_usd", cand.Product.PriceUSD) + "sku", cand.Product.SKU, "price_usd", *cand.Product.PriceUSD) continue } matches = append(matches, dbengine.DiscoveryMatch{ diff --git a/backend/clientHandlers/apiKeys.go b/backend/clientHandlers/apiKeys.go index 4d02264..f2f1ad4 100644 --- a/backend/clientHandlers/apiKeys.go +++ b/backend/clientHandlers/apiKeys.go @@ -80,6 +80,26 @@ func resolveConfirmThreshold(v *int64) (int64, error) { return *v, nil } +// validateCapHierarchy rejects configurations where a tighter-window cap +// exceeds a looser-window one. A single payment can't exceed the day's +// budget (the agent would over-run daily on its very first call); daily +// can't exceed monthly (same reasoning). unlimitedSentinel is treated as +// +∞ and short-circuits the comparison — an unlimited per-call paired +// with a capped daily is a valid config (single payments are unbounded by +// themselves, daily total still gates spend). +func validateCapHierarchy(perCall, daily, monthly int64) error { + if perCall != unlimitedSentinel && daily != unlimitedSentinel && perCall > daily { + return errors.New("maxPerCallMicrounits cannot exceed dailyCapMicrounits") + } + if perCall != unlimitedSentinel && monthly != unlimitedSentinel && perCall > monthly { + return errors.New("maxPerCallMicrounits cannot exceed monthlyCapMicrounits") + } + if daily != unlimitedSentinel && monthly != unlimitedSentinel && daily > monthly { + return errors.New("dailyCapMicrounits cannot exceed monthlyCapMicrounits") + } + return nil +} + // CreateAPIKey handles POST /internal/client/agent/keys. Mints a new // `sg_agents_…` key for the operator, stores its bcrypt hash, and returns // the full secret exactly once. The full secret is held only in a local @@ -156,6 +176,10 @@ func CreateAPIKey(pool *pgxpool.Pool) fiber.Handler { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } + if err := validateCapHierarchy(maxPerCall, dailyCap, monthlyCap); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + // fullKey is the only piece of secret material in this handler. It is // passed to HashAPIKey, written to the response once, and never assigned // to any other named slot. Matches the parseAPIKey reference pattern. @@ -298,6 +322,10 @@ func UpdateAPIKeySettings(pool *pgxpool.Pool) fiber.Handler { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } + if err := validateCapHierarchy(maxPerCall, dailyCap, monthlyCap); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + rowsAffected, err := dbengine.UpdateAgentAPIKeyCaps( c.Context(), pool, keyID, user.ID, dbengine.UpdateAgentAPIKeyCapsParams{ diff --git a/backend/clientHandlers/context.go b/backend/clientHandlers/context.go index 46f54fa..5113d04 100644 --- a/backend/clientHandlers/context.go +++ b/backend/clientHandlers/context.go @@ -59,7 +59,6 @@ func resolveOperator(c fiber.Ctx, pool *pgxpool.Pool) (*operatorContext, bool) { operator, err := dbengine.CreateAgentOperator( c.Context(), pool, orgResult.OrganizationID, dbengine.DefaultSignupGrantMicrounits, - user.Email, ) if err != nil { slog.Error("ensure agent operator", diff --git a/backend/config/sangriaInternal.go b/backend/config/sangriaInternal.go new file mode 100644 index 0000000..5601c94 --- /dev/null +++ b/backend/config/sangriaInternal.go @@ -0,0 +1,23 @@ +package config + +// SangriaInternal is the shared secret sent as `Authorization: Bearer ` +// on outbound Sangria → merchant Buy calls. Outbound-only — unrelated to the +// inbound API-key validators in backend/auth/merchantKeys.go. +var SangriaInternal SangriaInternalConfig + +// SangriaInternalConfig holds the outbound-auth shared secret. Own struct +// so future additions (rotation, per-merchant keys) land here. +type SangriaInternalConfig struct { + Key string +} + +// LoadSangriaInternalConfig reads the required SANGRIA_INTERNAL_KEY env var. +// Fails closed at startup — silent absence would unauth outbound merchant calls. +func LoadSangriaInternalConfig() error { + key, err := requireEnv("SANGRIA_INTERNAL_KEY") + if err != nil { + return err + } + SangriaInternal.Key = key + return nil +} diff --git a/backend/dbEngine/agentOperators.go b/backend/dbEngine/agentOperators.go index 95f18d6..871d112 100644 --- a/backend/dbEngine/agentOperators.go +++ b/backend/dbEngine/agentOperators.go @@ -20,7 +20,7 @@ const DefaultSignupGrantMicrounits int64 = 1_000_000 // agentOperatorColumns is the canonical SELECT / RETURNING column list for // agent_operators rows. Keeps the Scan() target order in sync everywhere. -const agentOperatorColumns = `id, organization_id, stripe_customer_id, address, email, phone, kyc_status, created_at` +const agentOperatorColumns = `id, organization_id, stripe_customer_id, address, phone, kyc_status, created_at` // scanAgentOperator scans a row produced by SELECT agentOperatorColumns into // an AgentOperator struct. @@ -28,7 +28,7 @@ func scanAgentOperator(row pgx.Row) (AgentOperator, error) { var o AgentOperator err := row.Scan( &o.ID, &o.OrganizationID, - &o.StripeCustomerID, &o.Address, &o.Email, &o.Phone, &o.KYCStatus, &o.CreatedAt, + &o.StripeCustomerID, &o.Address, &o.Phone, &o.KYCStatus, &o.CreatedAt, ) return o, err } @@ -62,14 +62,14 @@ func GetAgentOperatorByID(ctx context.Context, pool *pgxpool.Pool, operatorID st // Pool-owning entry point. Callers that need to compose operator creation // with other inserts inside an outer atomic envelope should call // CreateAgentOperatorTx directly with their existing tx. -func CreateAgentOperator(ctx context.Context, pool *pgxpool.Pool, orgID string, signupGrantAmount int64, email string) (AgentOperator, error) { +func CreateAgentOperator(ctx context.Context, pool *pgxpool.Pool, orgID string, signupGrantAmount int64) (AgentOperator, error) { tx, err := pool.Begin(ctx) if err != nil { return AgentOperator{}, fmt.Errorf("begin transaction: %w", err) } defer tx.Rollback(ctx) // safe no-op once Commit fires - op, err := CreateAgentOperatorTx(ctx, tx, orgID, signupGrantAmount, email) + op, err := CreateAgentOperatorTx(ctx, tx, orgID, signupGrantAmount) if err != nil { return AgentOperator{}, err } @@ -87,7 +87,7 @@ func CreateAgentOperator(ctx context.Context, pool *pgxpool.Pool, orgID string, // (EnsurePersonalOrganizationTx, dbengine.CreateOrganization) that need // operator creation to commit atomically with the surrounding org + member // inserts. -func CreateAgentOperatorTx(ctx context.Context, tx pgx.Tx, orgID string, signupGrantAmount int64, email string) (AgentOperator, error) { +func CreateAgentOperatorTx(ctx context.Context, tx pgx.Tx, orgID string, signupGrantAmount int64) (AgentOperator, error) { if strings.TrimSpace(orgID) == "" { return AgentOperator{}, fmt.Errorf("organization ID must not be empty") } @@ -95,26 +95,16 @@ func CreateAgentOperatorTx(ctx context.Context, tx pgx.Tx, orgID string, signupG return AgentOperator{}, fmt.Errorf("signup grant amount must be non-negative, got %d", signupGrantAmount) } - // Normalize the email per the repo convention (lowercase + trimmed) and - // drop empty-string callers to NULL. Empty is the safe default for paths - // where the caller doesn't know one yet; the operator can be backfilled - // or set explicitly later (see the backfill branch below). - var emailArg any - normalizedEmail := strings.TrimSpace(strings.ToLower(email)) - if normalizedEmail != "" { - emailArg = normalizedEmail - } - // 1. Insert the operator row. The UNIQUE on organization_id serializes // concurrent creates; on conflict we read the existing row and keep going // (the credit-accounts step below is idempotent and worth re-ensuring as // cheap insurance against a partial-state operator from a prior bug). row := tx.QueryRow(ctx, - `INSERT INTO agent_operators (organization_id, email, kyc_status) - VALUES ($1, $2, 'unverified') + `INSERT INTO agent_operators (organization_id, kyc_status) + VALUES ($1, 'unverified') ON CONFLICT (organization_id) DO NOTHING RETURNING `+agentOperatorColumns, - orgID, emailArg, + orgID, ) op, err := scanAgentOperator(row) operatorIsNew := true @@ -135,21 +125,6 @@ func CreateAgentOperatorTx(ctx context.Context, tx pgx.Tx, orgID string, signupG "caller_requested_signup_grant_microunits", signupGrantAmount, ) } - // Backfill the email if the existing row has NULL and the caller supplied - // one. Only fills NULLs — never overwrites an explicit value (e.g. a - // future profile-edit endpoint could set a custom shipping email and we - // don't want a routine dashboard request to clobber it back to the - // WorkOS identity email). Caps the auto-populate to "first known - // email wins." - if emailArg != nil && op.Email == nil { - if _, err := tx.Exec(ctx, - `UPDATE agent_operators SET email = $1 WHERE id = $2 AND email IS NULL`, - normalizedEmail, op.ID, - ); err != nil { - return AgentOperator{}, fmt.Errorf("backfill operator email: %w", err) - } - op.Email = &normalizedEmail - } } else if err != nil { return AgentOperator{}, fmt.Errorf("insert agent operator: %w", err) } diff --git a/backend/dbEngine/discoveries.go b/backend/dbEngine/discoveries.go index 5240e63..0bb034d 100644 --- a/backend/dbEngine/discoveries.go +++ b/backend/dbEngine/discoveries.go @@ -139,6 +139,9 @@ func validateDiscoveryMatches(matches []DiscoveryMatch) error { if strings.TrimSpace(m.SKU) == "" { return fmt.Errorf("%w: match %d has empty sku", ErrInvalidMatchShape, i) } + if strings.TrimSpace(m.Name) == "" { + return fmt.Errorf("%w: match %d (%s) has empty name", ErrInvalidMatchShape, i, m.SKU) + } if m.PriceMicrounits < 0 { return fmt.Errorf("%w: match %d (%s) has negative price %d", ErrInvalidMatchShape, i, m.SKU, m.PriceMicrounits) } diff --git a/backend/dbEngine/models.go b/backend/dbEngine/models.go index 31425cb..a1fc357 100644 --- a/backend/dbEngine/models.go +++ b/backend/dbEngine/models.go @@ -52,6 +52,11 @@ type Account struct { type User struct { WorkosID string `json:"workos_id"` Owner string `json:"owner"` + // Email is the identity email sourced from WorkOS, refreshed on every + // login via UpsertUserTx. Pointer-typed so an unset/NULL stays + // distinguishable from an empty string. Forwarded to merchants on + // POST /v1/buy via GetUserEmailForOperator. + Email *string `json:"email,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -387,11 +392,11 @@ type AgentOperator struct { StripeCustomerID *string `json:"stripe_customer_id"` KYCStatus AgentKYCStatus `json:"kyc_status"` Address json.RawMessage `json:"address,omitempty"` - // Email + Phone are forwarded to merchants on /v1/buy. - // Both nullable in the DB and pointer-typed here so absent values - // stay distinguishable from empty strings. The buy handler validates - // non-empty before calling the merchant. - Email *string `json:"email,omitempty"` + // Phone is forwarded to merchants on /v1/buy. Nullable in the DB and + // pointer-typed here so absent values stay distinguishable from empty + // strings. The buy handler validates non-empty before calling the + // merchant. Email lives on users (see User.Email) — the buy handler + // fetches it via GetUserEmailForOperator at call time. Phone *string `json:"phone,omitempty"` CreatedAt time.Time `json:"created_at"` } @@ -572,16 +577,20 @@ type Discovery struct { // merchant catalog at discovery time so an agent/UX layer can render candidates // without re-fetching the catalog. type DiscoveryMatch struct { - SKU string `json:"sku"` - Name string `json:"name"` - Category string `json:"category"` - PriceMicrounits int64 `json:"price_microunits"` - Quantity int `json:"quantity"` - Score float64 `json:"score"` - ImageURL string `json:"image_url"` - ProductURL string `json:"product_url"` - Rating float64 `json:"rating"` - NumReviews int `json:"num_reviews"` + SKU string `json:"sku"` + Name string `json:"name"` + Category string `json:"category"` + PriceMicrounits int64 `json:"price_microunits"` + Quantity int `json:"quantity"` + Score float64 `json:"score"` + // Display metadata. Pointer-typed so a merchant that didn't supply a + // value surfaces to the agent as JSON null rather than fabricating + // zero/empty — preserves the "merchant didn't supply this" signal + // end-to-end. Persisted verbatim into the matches jsonb column. + ImageURL *string `json:"image_url"` + ProductURL *string `json:"product_url"` + Rating *float64 `json:"rating"` + NumReviews *int `json:"num_reviews"` } type OrderStatus string diff --git a/backend/dbEngine/organizations.go b/backend/dbEngine/organizations.go index ba98b88..9eb7c90 100644 --- a/backend/dbEngine/organizations.go +++ b/backend/dbEngine/organizations.go @@ -30,7 +30,7 @@ var ( // receives the signup credit. This caps each WorkOS user to exactly one // signup grant across their org graph, eliminating the "create N orgs, // harvest N grants" abuse vector. -func CreateOrganization(ctx context.Context, pool *pgxpool.Pool, creatorUserID, orgName, creatorEmail string) (string, error) { +func CreateOrganization(ctx context.Context, pool *pgxpool.Pool, creatorUserID, orgName string) (string, error) { tx, err := pool.Begin(ctx) if err != nil { return "", fmt.Errorf("failed to begin transaction: %w", err) @@ -59,7 +59,7 @@ func CreateOrganization(ctx context.Context, pool *pgxpool.Pool, creatorUserID, // signupGrantAmount=0: only personal orgs get the grant; user-created // additional orgs start at zero balance (see policy note in the docstring // above). - if _, err = CreateAgentOperatorTx(ctx, tx, orgID, 0, creatorEmail); err != nil { + if _, err = CreateAgentOperatorTx(ctx, tx, orgID, 0); err != nil { return "", fmt.Errorf("failed to create agent operator for org: %w", err) } diff --git a/backend/dbEngine/users.go b/backend/dbEngine/users.go index 59feb97..933a54d 100644 --- a/backend/dbEngine/users.go +++ b/backend/dbEngine/users.go @@ -4,60 +4,77 @@ import ( "context" "errors" "fmt" + "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) -// UpsertUser creates or updates a user (WorkOS identity) and returns the full row. -func UpsertUser(ctx context.Context, pool *pgxpool.Pool, owner, workosID string) (User, error) { +// userColumns is the canonical SELECT / RETURNING column list for users rows. +// Keeps the Scan() target order in lockstep across every queryer. +const userColumns = `workos_id, owner, email, created_at, updated_at` + +// scanUser scans a row produced by SELECT userColumns into a User struct. +func scanUser(row pgx.Row) (User, error) { var u User - err := pool.QueryRow(ctx, - `INSERT INTO users (workos_id, owner) - VALUES ($1, $2) - ON CONFLICT (workos_id) DO UPDATE - SET owner = EXCLUDED.owner, - updated_at = NOW() - RETURNING workos_id, owner, created_at, updated_at`, - workosID, owner, - ).Scan(&u.WorkosID, &u.Owner, &u.CreatedAt, &u.UpdatedAt) + err := row.Scan(&u.WorkosID, &u.Owner, &u.Email, &u.CreatedAt, &u.UpdatedAt) return u, err } -// UpsertUserTx creates or updates a user (WorkOS identity) within a transaction and returns the full row. -func UpsertUserTx(ctx context.Context, tx pgx.Tx, owner, workosID string) (User, error) { - var u User - err := tx.QueryRow(ctx, - `INSERT INTO users (workos_id, owner) - VALUES ($1, $2) - ON CONFLICT (workos_id) DO UPDATE - SET owner = EXCLUDED.owner, - updated_at = NOW() - RETURNING workos_id, owner, created_at, updated_at`, - workosID, owner, - ).Scan(&u.WorkosID, &u.Owner, &u.CreatedAt, &u.UpdatedAt) - return u, err +// emailArgForInsert normalizes (lowercase + trim) per repo convention and +// returns nil for empty input so pgx writes SQL NULL rather than an empty +// string. The upsert SQL pairs this with `COALESCE(NULLIF(EXCLUDED.email, ''), +// users.email)` so a momentary empty Email from WorkOS never nulls the +// column out. +func emailArgForInsert(email string) any { + if normalized := strings.TrimSpace(strings.ToLower(email)); normalized != "" { + return normalized + } + return nil +} + +// upsertUserSQL is shared between UpsertUser and UpsertUserTx. The ON CONFLICT +// branch's `COALESCE(NULLIF(EXCLUDED.email, ''), users.email)` is load-bearing: +// it lets a login that supplies an email refresh the column, while a login +// that supplies an empty email (WorkOS hiccup, future caller that doesn't +// know one) leaves the existing value intact instead of nulling it. +const upsertUserSQL = `INSERT INTO users (workos_id, owner, email) + VALUES ($1, $2, $3) + ON CONFLICT (workos_id) DO UPDATE + SET owner = EXCLUDED.owner, + email = COALESCE(NULLIF(EXCLUDED.email, ''), users.email), + updated_at = NOW() + RETURNING ` + userColumns + +// UpsertUser creates or updates a user (WorkOS identity) and returns the full +// row. The email is normalized + written through the COALESCE-safe upsert +// pattern (see upsertUserSQL). +func UpsertUser(ctx context.Context, pool *pgxpool.Pool, owner, workosID, email string) (User, error) { + row := pool.QueryRow(ctx, upsertUserSQL, workosID, owner, emailArgForInsert(email)) + return scanUser(row) +} + +// UpsertUserTx is UpsertUser within an existing transaction. +func UpsertUserTx(ctx context.Context, tx pgx.Tx, owner, workosID, email string) (User, error) { + row := tx.QueryRow(ctx, upsertUserSQL, workosID, owner, emailArgForInsert(email)) + return scanUser(row) } // GetUserByWorkosID returns a user by their WorkOS ID. func GetUserByWorkosID(ctx context.Context, pool *pgxpool.Pool, workosID string) (User, error) { - var u User - err := pool.QueryRow(ctx, - `SELECT workos_id, owner, created_at, updated_at - FROM users WHERE workos_id = $1`, + row := pool.QueryRow(ctx, + `SELECT `+userColumns+` FROM users WHERE workos_id = $1`, workosID, - ).Scan(&u.WorkosID, &u.Owner, &u.CreatedAt, &u.UpdatedAt) - return u, err + ) + return scanUser(row) } - - -// queryRowInterface defines the interface for executing a single-row query +// queryRowInterface defines the interface for executing a single-row query. type queryRowInterface interface { QueryRow(ctx context.Context, sql string, args ...any) pgx.Row } -// getUserPersonalOrgIDQuery is a helper that executes the personal org lookup query +// getUserPersonalOrgIDQuery is a helper that executes the personal org lookup query. func getUserPersonalOrgIDQuery(ctx context.Context, q queryRowInterface, userID string) (string, error) { var orgID string err := q.QueryRow(ctx, @@ -81,7 +98,6 @@ func GetUserPersonalOrgIDTx(ctx context.Context, tx pgx.Tx, userID string) (stri return getUserPersonalOrgIDQuery(ctx, tx, userID) } - // IsAdmin returns true if the given WorkOS user ID has an entry in the admins table. func IsAdmin(ctx context.Context, pool *pgxpool.Pool, workosID string) (bool, error) { var exists bool @@ -99,7 +115,11 @@ func IsAdmin(ctx context.Context, pool *pgxpool.Pool, workosID string) (bool, er // missing operators on existing orgs. Must be called within an existing // transaction. Acquires a row lock on the user to serialize concurrent signup // flows. -func EnsurePersonalOrganizationTx(ctx context.Context, tx pgx.Tx, userWorkosID, userName, email string) error { +// +// Note: the operator's email used to live on agent_operators.email and was +// populated here. The canonical identity email now lives on users.email +// (written by UpsertUserTx); this function no longer touches email at all. +func EnsurePersonalOrganizationTx(ctx context.Context, tx pgx.Tx, userWorkosID, userName string) error { // Acquire a row lock on the user to serialize concurrent flows var lockCheck bool err := tx.QueryRow(ctx, @@ -142,10 +162,49 @@ func EnsurePersonalOrganizationTx(ctx context.Context, tx pgx.Tx, userWorkosID, // Create the org's agent_operator atomically with the org + member inserts, // granting the per-user signup credit. - if _, err := CreateAgentOperatorTx(ctx, tx, personalOrgID, DefaultSignupGrantMicrounits, email); err != nil { + if _, err := CreateAgentOperatorTx(ctx, tx, personalOrgID, DefaultSignupGrantMicrounits); err != nil { return fmt.Errorf("failed to create agent operator: %w", err) } return nil } +// GetUserEmailForOperator returns the canonical purchase-contact email for an +// operator by joining through the operator's org to the earliest-joined admin +// member, then to that user's email column. Returns empty string + nil error +// when the operator exists but no admin email is set yet (handler maps this +// to the missing_field invalid_request response, same shape as the old +// operator.Email-was-NULL path). Returns empty string + nil error when the +// operator itself doesn't exist — handlers MUST have validated the operator +// before calling this; the API-key middleware guarantees that for /v1/buy. +// +// "Earliest-joined admin" is the canonical rule: a personal org has exactly +// one admin (the creator) so this is unambiguous; an additional org has the +// creator as the original admin, so creator-email-wins is the natural default +// and matches the operator's owner story. +func GetUserEmailForOperator(ctx context.Context, pool *pgxpool.Pool, operatorID string) (string, error) { + if strings.TrimSpace(operatorID) == "" { + return "", fmt.Errorf("operator ID must not be empty") + } + var email *string + err := pool.QueryRow(ctx, ` + SELECT u.email + FROM agent_operators ao + JOIN organization_members om + ON om.organization_id = ao.organization_id AND om.is_admin = true + JOIN users u ON u.workos_id = om.user_id + WHERE ao.id = $1 + ORDER BY om.joined_at ASC + LIMIT 1 + `, operatorID).Scan(&email) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("get user email for operator: %w", err) + } + if email == nil { + return "", nil + } + return *email, nil +} diff --git a/backend/main.go b/backend/main.go index a194ea4..6c032ad 100644 --- a/backend/main.go +++ b/backend/main.go @@ -11,6 +11,7 @@ import ( "sangria/backend/adminHandlers" "sangria/backend/auth" + "sangria/backend/buyHandlers" "sangria/backend/clientHandlers" "sangria/backend/config" dbengine "sangria/backend/dbEngine" @@ -82,6 +83,13 @@ func main() { } slog.Info("merchant config loaded", "catalog_path", config.RedactedURL(config.Merchant.CatalogURL)) + if err := config.LoadSangriaInternalConfig(); err != nil { + slog.Error("failed to load sangria internal config", "error", err) + os.Exit(1) + } + buyHandlers.InitMerchantClient() + slog.Info("sangria internal config loaded") + if err := config.LoadStripeConfig(); err != nil { slog.Error("failed to load stripe config", "error", err) os.Exit(1) diff --git a/backend/sangriamerchant/client.go b/backend/sangriamerchant/client.go index 7cbf948..dd7ff34 100644 --- a/backend/sangriamerchant/client.go +++ b/backend/sangriamerchant/client.go @@ -32,6 +32,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "net/http" "net/url" "path" @@ -114,19 +115,26 @@ type BuyEndpoint struct { Auth string `json:"auth"` // "sangria" currently; "x402" deferred } -// Product is one item the merchant sells. PriceUSD is the decimal price -// without delivery; total cost = PriceUSD + Store.Delivery.Fee. Display -// fields (ImageURL, Rating, etc.) are passed through to the agent in the -// /v1/buy response but not persisted on the order. +// Product is one item the merchant sells. Total cost (when PriceUSD is set) +// is *PriceUSD + Store.Delivery.Fee, converted to int64 microunits at the +// wire boundary by toMicrounitsSafe — no float64 escapes the merchant client. +// +// Pointer-typed nullable fields preserve the "merchant didn't supply this +// value" signal as a JSON `null` rather than collapsing to zero/empty: +// - PriceUSD nil → discover.go rejects the candidate (no quote without +// a price; same skip-and-warn branch as NaN/Inf/negative). +// - Rating / NumReviews / ImageURL / ProductURL nil → passed through to +// DiscoveryMatch as nil so the agent JSON shows explicit null and a +// downstream renderer can say "unknown" instead of "0 stars" or "". type Product struct { - SKU string `json:"sku"` - Name string `json:"name"` - PriceUSD float64 `json:"priceUsd"` - Rating float64 `json:"rating"` - NumReviews int `json:"numReviews"` - ImageURL string `json:"imageUrl"` - ProductURL string `json:"productUrl"` - Category string `json:"category"` // slash-delimited path, e.g. "grocery-and-gourmet-food/beverages/coffee" + SKU string `json:"sku"` + Name string `json:"name"` + PriceUSD *float64 `json:"priceUsd"` + Rating *float64 `json:"rating"` + NumReviews *int `json:"numReviews"` + ImageURL *string `json:"imageUrl"` + ProductURL *string `json:"productUrl"` + Category string `json:"category"` // slash-delimited path, e.g. "grocery-and-gourmet-food/beverages/coffee" } // --------------------------------------------------------------------------- @@ -196,17 +204,19 @@ const ( // Client wraps an *http.Client. Stateless beyond the connection pool — same // instance serves any merchant URL passed to FetchCatalog / Buy. type Client struct { - httpClient *http.Client + httpClient *http.Client + internalKey string } -// New constructs a Client with a 60s backstop timeout on the underlying -// http.Client. Per-call budgets are the caller's responsibility via -// context.WithTimeout (10s for catalog, 30s for buy per the plan). -func New() *Client { +// New constructs a Client with a 60s http.Client backstop (per-call budgets +// via context.WithTimeout). internalKey is sent as `Authorization: Bearer +// ` on Buy() only — FetchCatalog stays unauthed; empty key skips for tests. +func New(internalKey string) *Client { return &Client{ httpClient: &http.Client{ Timeout: 60 * time.Second, }, + internalKey: internalKey, } } @@ -262,6 +272,19 @@ func (c *Client) Buy(ctx context.Context, buyURL string, body BuyRequest) (BuyRe } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") + // Outbound auth. Skip on non-HTTPS — don't leak the shared key over the + // wire (a local-playground http:// merchant is a legitimate target); log + // the suppression so an operator notices rather than a silent reject. + if c.internalKey != "" { + if req.URL.Scheme == "https" { + req.Header.Set("Authorization", "Bearer "+c.internalKey) + } else { + slog.Warn("suppressing SANGRIA_INTERNAL_KEY: refusing to send shared secret over non-HTTPS merchant Buy", + "scheme", req.URL.Scheme, + "buy_path", redactURL(buyURL), + ) + } + } resp, err := c.httpClient.Do(req) if err != nil { diff --git a/backend/x402Handlers/facilitator.go b/backend/x402Handlers/facilitator.go index 5ae018d..d26acbb 100644 --- a/backend/x402Handlers/facilitator.go +++ b/backend/x402Handlers/facilitator.go @@ -142,7 +142,28 @@ func UptoFacilitatorAddress(caip2Network string) string { // facilitator URL is the Coinbase CDP API (which requires auth). // The testnet facilitator at x402.org does not need auth. func addCDPAuth(req *http.Request, facilitatorURL, path string) error { - if !strings.Contains(facilitatorURL, "api.cdp.coinbase.com") { + // Exact-hostname gate: only the production Coinbase CDP API requires + // auth. Substring matching here would leak the JWT to any URL whose + // host or path *contained* the substring — e.g. + // `https://api.cdp.coinbase.com.attacker.com/…` (subdomain hijack) or + // `https://evil.com/api.cdp.coinbase.com/…` (path injection) would + // both have passed. We compare req.URL.Hostname() (the host + // http.Client.Do will actually dial — already parsed by + // NewRequestWithContext upstream, so no re-parse here) using + // strings.EqualFold to honour DNS case-insensitivity per RFC 1035. + if !strings.EqualFold(req.URL.Hostname(), "api.cdp.coinbase.com") { + return nil + } + // Belt-and-suspenders: the host check above implies HTTPS in practice + // (api.cdp.coinbase.com is HTTPS-only), but X402_FACILITATOR_URL is + // validated as http-or-https at config-load (config/x402.go), so an + // explicit `X402_FACILITATOR_URL=http://api.cdp.coinbase.com/...` value + // would technically slip through. Refuse to send the JWT over plaintext. + if req.URL.Scheme != "https" { + slog.Warn("suppressing CDP JWT: refusing to send credentials over non-HTTPS facilitator request", + "scheme", req.URL.Scheme, + "host", req.URL.Host, + ) return nil } diff --git a/dbSchema/schema.ts b/dbSchema/schema.ts index b1430c5..6e63cf7 100644 --- a/dbSchema/schema.ts +++ b/dbSchema/schema.ts @@ -53,6 +53,13 @@ export const organizations = pgTable( export const users = pgTable("users", { workosId: text("workos_id").primaryKey(), owner: text().notNull(), + // User's identity email (sourced from WorkOS). Refreshed on every login via + // UpsertUserTx. This is the canonical "contact email" Sangria has on file + // for the user; the buy flow forwards it to merchants. Per-operator + // overrides intentionally not supported — see EMAIL_ON_USERS_REFACTOR_PLAN.md. + // Nullable for backwards compatibility during the migration window; tighten + // to NOT NULL once SELECT COUNT(*) WHERE email IS NULL returns 0. + email: varchar({ length: 320 }), // RFC 5321 max createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -473,11 +480,11 @@ export const agentOperators = pgTable( // } address: jsonb("address"), - // Operator contact info forwarded to the merchant on POST /v1/buy. Both - // nullable — operator can sign up without setting them; /v1/buy requires - // them at call time (returns 400 invalid_request with a missing_field if - // either is absent). No format CHECKs — the receiving merchant validates. - email: varchar({ length: 320 }), // RFC 5321 max + // Operator contact phone forwarded to the merchant on POST /v1/buy. + // Nullable — operator can sign up without setting it; /v1/buy requires it + // at call time (returns 400 invalid_request with missing_field:"phone" if + // absent). The contact email lives on users.email — the user identity is + // the canonical email source, not the per-org operator row. phone: varchar({ length: 32 }), // generous for international formats kycStatus: agentKycStatusEnum("kyc_status").notNull().default("unverified"), diff --git a/frontend/app/(client)/client/settings/ClientSettingsContent.tsx b/frontend/app/(client)/client/settings/ClientSettingsContent.tsx index b6276ad..12d1a64 100644 --- a/frontend/app/(client)/client/settings/ClientSettingsContent.tsx +++ b/frontend/app/(client)/client/settings/ClientSettingsContent.tsx @@ -123,6 +123,10 @@ export default function ClientSettingsContent() { if (!dailyUnlimited && daily === null) return "Daily limit must be greater than $0."; if (!monthlyUnlimited && monthly === null) return "Monthly limit must be greater than $0."; if (!perRunUnlimited && perRun === null) return "Per-run cap must be greater than $0."; + if (perRun !== null && daily !== null && perRun > daily) + return "Per-run cap cannot exceed daily limit."; + if (perRun !== null && monthly !== null && perRun > monthly) + return "Per-run cap cannot exceed monthly limit."; if (daily !== null && monthly !== null && daily > monthly) return "Daily limit cannot exceed monthly limit."; return null; diff --git a/frontend/components/CardSettingsModal.tsx b/frontend/components/CardSettingsModal.tsx index d34721b..f89312e 100644 --- a/frontend/components/CardSettingsModal.tsx +++ b/frontend/components/CardSettingsModal.tsx @@ -125,6 +125,10 @@ function CardSettingsContent({ return "Daily limit must be greater than $0."; if (!monthlyUnlimited && m === null) return "Monthly limit must be greater than $0."; + if (pc !== null && d !== null && pc > d) + return "Per-call cap cannot exceed daily limit."; + if (pc !== null && m !== null && pc > m) + return "Per-call cap cannot exceed monthly limit."; if (d !== null && m !== null && d > m) return "Daily limit cannot exceed monthly limit."; return null; diff --git a/frontend/components/CreateAgentKeyModal.tsx b/frontend/components/CreateAgentKeyModal.tsx index 14beec0..4b33937 100644 --- a/frontend/components/CreateAgentKeyModal.tsx +++ b/frontend/components/CreateAgentKeyModal.tsx @@ -117,6 +117,10 @@ function CreateAgentKeyContent({ return "Daily limit must be greater than $0."; if (!monthlyUnlimited && m === null) return "Monthly limit must be greater than $0."; + if (pc !== null && d !== null && pc > d) + return "Per-call cap cannot exceed daily limit."; + if (pc !== null && m !== null && pc > m) + return "Per-call cap cannot exceed monthly limit."; if (d !== null && m !== null && d > m) return "Daily limit cannot exceed monthly limit."; return null;