Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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
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
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
15 changes: 10 additions & 5 deletions backend/dbEngine/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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"`
}
Expand Down
4 changes: 2 additions & 2 deletions backend/dbEngine/organizations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}

Expand Down
Loading