Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions SANGRIA_BUY_SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ The Sangria buy flow is split across **two skills** — `sangria-discover` (step
POST /v1/buy { "discovery_id": "<from /v1/discover>", "sku": "<one of that discovery's matches>" }
```

Both fields required. `Authorization: Bearer $SANGRIA_API_KEY`, JSON. The buy is **strict** (unlike discovery): it validates your operator profile — email + phone + full shipping addressand 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

Expand Down Expand Up @@ -163,7 +163,7 @@ Error responses use `{"error": "<code>", ...}`. **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. |
Expand Down
2 changes: 1 addition & 1 deletion SANGRIA_DISCOVER_SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
APP_ENV=development
# Outbound shared secret Sangria sends as `Authorization: Bearer <key>` on
# merchant /buy requests. Opaque (no format constraint) — generate with e.g.
# `openssl rand -hex 32`, or use the `sg_internal_<random>` convention.
# Must match the value configured on every merchant proxy. Required;
# backend exits at startup if missing.
SANGRIA_INTERNAL_KEY=sg_internal_REPLACE_ME
2 changes: 1 addition & 1 deletion backend/adminHandlers/merchants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
}
Expand Down
6 changes: 3 additions & 3 deletions backend/auth/workos.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down Expand Up @@ -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"})
Expand Down
15 changes: 11 additions & 4 deletions backend/buyHandlers/buy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) == "" {
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 11 additions & 4 deletions backend/buyHandlers/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

"github.com/gofiber/fiber/v3"

"sangria/backend/config"
dbengine "sangria/backend/dbEngine"
"sangria/backend/sangriamerchant"
)
Expand All @@ -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
Expand Down
15 changes: 11 additions & 4 deletions backend/buyHandlers/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
28 changes: 28 additions & 0 deletions backend/clientHandlers/apiKeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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{
Expand Down
1 change: 0 additions & 1 deletion backend/clientHandlers/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions backend/config/sangriaInternal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package config

// SangriaInternal is the shared secret sent as `Authorization: Bearer <key>`
// 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
}
41 changes: 8 additions & 33 deletions backend/dbEngine/agentOperators.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ 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.
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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -87,34 +87,24 @@ 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")
}
if signupGrantAmount < 0 {
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
Expand All @@ -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)
}
Expand Down
3 changes: 3 additions & 0 deletions backend/dbEngine/discoveries.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading