From bfe256ae8506f4b41d63bb3986f282ec5ec2762c Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Tue, 11 Aug 2026 10:23:04 -0700 Subject: [PATCH 1/7] Money races cannot double-spend Codex hardening, first slice. The daily order budget is reserved atomically: count and insert serialize under an advisory lock, so several verified approvals racing to submit cannot spend the last slot twice. Blank or junk cap variables now mean the defaults instead of a zero-dollar ceiling (Number of an empty string is 0, the template trap). An approval whose code email fails to send voids itself while still counting toward the rate caps, since excluding voided rows would let re-requests reset the meter. And cancel reopens the cart only when the void actually happened; a consumed approval answers 409. --- .../doordash/approvals/[id]/cancel/route.ts | 12 ++-- lib/rendi/doordash-approval.test.ts | 61 +++++++++++++++-- lib/rendi/doordash-approval.ts | 67 +++++++++++++++++-- trigger/tools/doordash-request-approval.ts | 31 ++++++--- trigger/tools/doordash-submit.ts | 30 +++++---- 5 files changed, 161 insertions(+), 40 deletions(-) diff --git a/app/api/doordash/approvals/[id]/cancel/route.ts b/app/api/doordash/approvals/[id]/cancel/route.ts index 456028f..880a0ad 100644 --- a/app/api/doordash/approvals/[id]/cancel/route.ts +++ b/app/api/doordash/approvals/[id]/cancel/route.ts @@ -17,18 +17,18 @@ export async function POST( return Response.json({ error: "bad approval" }, { status: 400 }); } const [row] = await getDb() - .select({ - cartUuid: ddApprovals.cartUuid, - consumedAt: ddApprovals.consumedAt, - }) + .select({ cartUuid: ddApprovals.cartUuid }) .from(ddApprovals) .where(eq(ddApprovals.id, approvalId)); if (!row) return Response.json({ error: "no such approval" }, { status: 404 }); - if (row.consumedAt) { + // The void itself is the arbiter: if submission consumed the row + // between any read and now, nothing voids and the cart must not + // reopen under an order that is already being placed. + const voided = await voidApproval(approvalId); + if (!voided) { return Response.json({ error: "already used" }, { status: 409 }); } - await voidApproval(approvalId); await setCartStatus(row.cartUuid, "open"); return Response.json({ ok: true }); } diff --git a/lib/rendi/doordash-approval.test.ts b/lib/rendi/doordash-approval.test.ts index 16faeb2..859fd77 100644 --- a/lib/rendi/doordash-approval.test.ts +++ b/lib/rendi/doordash-approval.test.ts @@ -7,6 +7,8 @@ import { consumeApproval, createApproval, hashCart, + maxOrderCents, + reserveOrderSlot, totalWithTip, verifyApproval, voidApproval, @@ -201,12 +203,61 @@ describe("the approval lifecycle", () => { }); }); - it("void never rewinds a consumed approval", async () => { + it("void never rewinds a consumed approval, and says so", async () => { const approval = await mintApproval(); - await verifyApproval(approval.id, approval.code); - await consumeApproval(approval.id); - await voidApproval(approval.id); - expect((await row(approval.id)).voidedAt).toBeNull(); + expect(await voidApproval(approval.id)).toBe(true); + const second = await mintApproval(); + await verifyApproval(second.id, second.code); + await consumeApproval(second.id); + expect(await voidApproval(second.id)).toBe(false); + expect((await row(second.id)).voidedAt).toBeNull(); + }); +}); + +describe("cap parsing", () => { + it("blank and junk env values mean the defaults", () => { + const original = process.env.DD_MAX_ORDER_CENTS; + try { + process.env.DD_MAX_ORDER_CENTS = ""; + expect(maxOrderCents()).toBe(10_000); + process.env.DD_MAX_ORDER_CENTS = "junk"; + expect(maxOrderCents()).toBe(10_000); + process.env.DD_MAX_ORDER_CENTS = "2500"; + expect(maxOrderCents()).toBe(2500); + } finally { + if (original === undefined) delete process.env.DD_MAX_ORDER_CENTS; + else process.env.DD_MAX_ORDER_CENTS = original; + } + }); +}); + +describe("reserveOrderSlot", () => { + it("the last daily slot cannot be spent twice", async () => { + const original = process.env.DD_MAX_ORDERS_PER_DAY; + process.env.DD_MAX_ORDERS_PER_DAY = "1"; + try { + const first = await mintApproval({ cartUuid: "cart-r1" }); + const second = await mintApproval({ cartUuid: "cart-r2" }); + const win = await reserveOrderSlot({ + approvalId: first.id, + conversationId: "conv-1", + cartUuid: "cart-r1", + storeName: "Toomie's Thai", + totalCents: 3317, + }); + expect("orderId" in win).toBe(true); + const lose = await reserveOrderSlot({ + approvalId: second.id, + conversationId: "conv-1", + cartUuid: "cart-r2", + storeName: "Toomie's Thai", + totalCents: 3317, + }); + expect("denied" in lose && lose.denied).toContain("daily cap"); + } finally { + if (original === undefined) delete process.env.DD_MAX_ORDERS_PER_DAY; + else process.env.DD_MAX_ORDERS_PER_DAY = original; + } }); }); diff --git a/lib/rendi/doordash-approval.ts b/lib/rendi/doordash-approval.ts index 3701e0c..0ddf2e5 100644 --- a/lib/rendi/doordash-approval.ts +++ b/lib/rendi/doordash-approval.ts @@ -12,12 +12,21 @@ import type { DdCartLine, DdQuoteSnapshot } from "./doordash-cart.ts"; const CODE_TTL_MS = 15 * 60 * 1000; const MAX_ATTEMPTS = 5; +// Number("") is 0 and Number("junk") is NaN; a copied template with +// blank values must mean the defaults, never a zero-dollar ceiling. +function envInt(name: string, fallback: number): number { + const raw = process.env[name]?.trim(); + if (!raw) return fallback; + const value = Number(raw); + return Number.isFinite(value) ? value : fallback; +} + export function maxOrderCents(): number { - return Number(process.env.DD_MAX_ORDER_CENTS ?? 10_000); + return envInt("DD_MAX_ORDER_CENTS", 10_000); } export function maxOrdersPerDay(): number { - return Number(process.env.DD_MAX_ORDERS_PER_DAY ?? 3); + return envInt("DD_MAX_ORDERS_PER_DAY", 3); } // Twenty strangers with gate codes must not be able to bomb the owner's @@ -26,7 +35,7 @@ export function maxOrdersPerDay(): number { const APPROVALS_PER_CONVERSATION_HOUR = 3; export function maxApprovalsPerDay(): number { - return Number(process.env.DD_MAX_APPROVALS_PER_DAY ?? 15); + return envInt("DD_MAX_APPROVALS_PER_DAY", 15); } export function hashCart(input: { @@ -240,9 +249,55 @@ export async function consumeApproval( }); } -export async function voidApproval(approvalId: number): Promise { - await getDb() +// True when a row actually voided; a consumed approval never rewinds, +// and callers that reopen carts must know the difference. +export async function voidApproval(approvalId: number): Promise { + const rows = await getDb() .update(ddApprovals) .set({ voidedAt: new Date() }) - .where(and(eq(ddApprovals.id, approvalId), isNull(ddApprovals.consumedAt))); + .where(and(eq(ddApprovals.id, approvalId), isNull(ddApprovals.consumedAt))) + .returning({ id: ddApprovals.id }); + return rows.length > 0; +} + +export type OrderSlot = { orderId: number } | { denied: string }; + +// The daily budget must hold even when several verified approvals race +// to submit: the count and the insert serialize under one advisory +// lock, so overlapping submits cannot double-spend the last slot. +export async function reserveOrderSlot(input: { + approvalId: number; + conversationId: string; + cartUuid: string; + storeName: string; + totalCents: number; +}): Promise { + const db = getDb(); + return db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext('dd_orders_daily'))`, + ); + const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const [orders] = await tx + .select({ n: count() }) + .from(ddOrders) + .where(gt(ddOrders.createdAt, dayAgo)); + if ((orders?.n ?? 0) >= maxOrdersPerDay()) { + return { + denied: `the daily cap of ${maxOrdersPerDay()} orders is spent`, + }; + } + const [row] = await tx + .insert(ddOrders) + .values({ + approvalId: input.approvalId, + conversationId: input.conversationId, + cartUuid: input.cartUuid, + storeName: input.storeName, + totalCents: input.totalCents, + status: "pending", + }) + .returning({ id: ddOrders.id }); + return { orderId: row.id }; + }); } diff --git a/trigger/tools/doordash-request-approval.ts b/trigger/tools/doordash-request-approval.ts index 8264cb0..98c594c 100644 --- a/trigger/tools/doordash-request-approval.ts +++ b/trigger/tools/doordash-request-approval.ts @@ -8,6 +8,7 @@ import { createApproval, hashCart, totalWithTip, + voidApproval, } from "@/lib/rendi/doordash-approval"; import { setCartStatus } from "@/lib/rendi/doordash-db"; import { sendApprovalEmail } from "@/lib/rendi/doordash-email"; @@ -74,17 +75,25 @@ export const doordashRequestApproval = tool({ .select({ title: conversations.title }) .from(conversations) .where(eq(conversations.id, conversationId)); - await sendApprovalEmail({ - code: approval.code, - storeName: priced.storeName, - itemsCount: priced.items.reduce((sum, line) => sum + line.quantity, 0), - totalDisplay: `$${(totalCents / 100).toFixed(2)}`, - destination: - priced.fulfillment === "pickup" ? "pickup" : "the saved address", - conversationTitle: conversation?.title ?? "a conversation", - minutes: 15, - approvalId: approval.id, - }); + try { + await sendApprovalEmail({ + code: approval.code, + storeName: priced.storeName, + itemsCount: priced.items.reduce((sum, line) => sum + line.quantity, 0), + totalDisplay: `$${(totalCents / 100).toFixed(2)}`, + destination: + priced.fulfillment === "pickup" ? "pickup" : "the saved address", + conversationTitle: conversation?.title ?? "a conversation", + minutes: 15, + approvalId: approval.id, + }); + } catch { + // An approval whose code nobody received must not stay live. It + // still counts toward the rate caps: excluding voided rows would + // let re-requests reset the meter, which is the attack. + await voidApproval(approval.id); + return { denied: "the code email would not send; nothing is pending" }; + } await setCartStatus(cartUuid, "awaiting_code"); return { approvalId: approval.id, diff --git a/trigger/tools/doordash-submit.ts b/trigger/tools/doordash-submit.ts index 92f6083..fbf0895 100644 --- a/trigger/tools/doordash-submit.ts +++ b/trigger/tools/doordash-submit.ts @@ -7,6 +7,7 @@ import * as dd from "@/lib/rendi/doordash"; import { consumeApproval, hashCart, + reserveOrderSlot, totalWithTip, voidApproval, } from "@/lib/rendi/doordash-approval"; @@ -127,18 +128,23 @@ export const doordashSubmit = tool({ } // The order row lands BEFORE the charge, so a crash mid-submit - // leaves an honest pending record instead of a silent maybe. - const [orderRow] = await getDb() - .insert(ddOrders) - .values({ - approvalId, - conversationId: approval.conversationId, - cartUuid: approval.cartUuid, - storeName: snapshot.storeName, - totalCents: approval.totalCents, - status: "pending", - }) - .returning({ id: ddOrders.id }); + // leaves an honest pending record instead of a silent maybe; the + // reservation is atomic so racing approvals cannot double-spend + // the last daily slot. + const slot = await reserveOrderSlot({ + approvalId, + conversationId: approval.conversationId, + cartUuid: approval.cartUuid, + storeName: snapshot.storeName, + totalCents: approval.totalCents, + }); + if ("denied" in slot) { + await setCartStatus(approval.cartUuid, "open"); + return { + refused: `${slot.denied}; this approval is spent either way`, + }; + } + const orderRow = { id: slot.orderId }; await setCartStatus(approval.cartUuid, "placing"); let orderUuid: string | null = null; From bd6e047380c48d1508f606de54437d2993e8e8e3 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Tue, 11 Aug 2026 10:33:02 -0700 Subject: [PATCH 2/7] The cart seals the moment its approval is spent Codex hardening, second slice. Submission now seals the cart right after consuming the approval, and every mutating op (add, quantity, remove, tip, delete, fulfillment flip) refuses sealed carts with a plain answer, so nothing can drift between the final revalidation and the charge. A snapshot refresh in flight during that window can no longer stomp the seal back to open. Shrinking a line now demands the menu id BEFORE removing, so a grocery line without one decrements safely instead of vanishing. And pickup approvals check pickup availability the way delivery always did; the preview schema carried the flag, normalization just dropped it. --- lib/rendi/doordash-cart.test.ts | 24 ++++ lib/rendi/doordash-cart.ts | 4 + lib/rendi/doordash-ops.test.ts | 148 +++++++++++++++++++++ lib/rendi/doordash-ops.ts | 32 ++++- trigger/tools/doordash-request-approval.ts | 9 ++ trigger/tools/doordash-submit.ts | 5 +- 6 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 lib/rendi/doordash-ops.test.ts diff --git a/lib/rendi/doordash-cart.test.ts b/lib/rendi/doordash-cart.test.ts index ab39a56..0929760 100644 --- a/lib/rendi/doordash-cart.test.ts +++ b/lib/rendi/doordash-cart.test.ts @@ -69,4 +69,28 @@ describe("quote normalization", () => { }); expect(normalizeQuote(closed)?.asapAvailable).toBe(false); }); + + it("carries pickup availability only when DoorDash states it", () => { + const noPickup = ddPreviewResult.parse({ + ...previewFixture, + quote: { + ...previewFixture.quote, + delivery_availability: { + ...previewFixture.quote.delivery_availability, + asap_pickup_available: false, + }, + }, + }); + expect(normalizeQuote(noPickup)?.pickupAvailable).toBe(false); + const { asap_pickup_available: _dropped, ...withoutPickup } = + previewFixture.quote.delivery_availability; + const absent = ddPreviewResult.parse({ + ...previewFixture, + quote: { + ...previewFixture.quote, + delivery_availability: withoutPickup, + }, + }); + expect(normalizeQuote(absent)?.pickupAvailable).toBeUndefined(); + }); }); diff --git a/lib/rendi/doordash-cart.ts b/lib/rendi/doordash-cart.ts index b3235f4..1064434 100644 --- a/lib/rendi/doordash-cart.ts +++ b/lib/rendi/doordash-cart.ts @@ -30,6 +30,7 @@ export type DdQuoteSnapshot = { totalBeforeTipCents: number | null; currency: string; asapAvailable: boolean; + pickupAvailable?: boolean; etaRange?: string | null; deliveryOptions: { type: string; title: string; etaRange?: string | null }[]; }; @@ -89,6 +90,9 @@ export function normalizeQuote( totalBeforeTipCents: quote.total_before_tip?.unit_amount ?? null, currency: quote.currency ?? "USD", asapAvailable: availability?.asap_available ?? false, + // Absent means unknown, not unavailable; only an explicit false + // should stop a pickup approval. + pickupAvailable: availability?.asap_pickup_available, etaRange: availability?.asap_minutes_range_string ?? null, deliveryOptions: (availability?.delivery_options ?? []).map((option) => ({ type: option.delivery_option_type, diff --git a/lib/rendi/doordash-ops.test.ts b/lib/rendi/doordash-ops.test.ts new file mode 100644 index 0000000..ab52c6e --- /dev/null +++ b/lib/rendi/doordash-ops.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DdCartRow } from "../db/schema.ts"; +import * as dd from "./doordash.ts"; +import * as db from "./doordash-db.ts"; +import { + opDeleteCart, + opPreview, + opSetQuantity, + opSetTip, +} from "./doordash-ops.ts"; + +vi.mock("./doordash.ts", () => ({ + cartShow: vi.fn(), + cartAddItems: vi.fn(), + cartRemoveItem: vi.fn(), + cartDelete: vi.fn(), + orderPreview: vi.fn(), +})); + +vi.mock("./doordash-db.ts", () => ({ + getCartSnapshot: vi.fn(), + upsertCartSnapshot: vi.fn(), + setCartStatus: vi.fn(), + setCartTip: vi.fn(), +})); + +function snapshot(status: DdCartRow["status"]): DdCartRow { + return { + cartUuid: "cart-1", + conversationId: "conv-1", + storeId: "store-1", + storeName: "Toomie's Thai", + storeImageUrl: null, + items: [], + quote: null, + fulfillment: "delivery", + scheduledTime: null, + tipCents: 440, + status, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +const bareLine = (menuId: string | null) => ({ + cart: { + items: [ + { + id: "line-1", + item_id: "item-1", + menu_id: menuId, + name: "Taco", + quantity: 3, + price: 4.78, + nested_options: [], + }, + ], + }, +}); + +beforeEach(() => { + vi.mocked(db.getCartSnapshot).mockReset(); + vi.mocked(db.upsertCartSnapshot).mockReset(); + vi.mocked(dd.cartShow).mockReset(); + vi.mocked(dd.cartRemoveItem).mockReset(); + vi.mocked(dd.cartAddItems).mockReset(); + vi.mocked(dd.cartDelete).mockReset(); + vi.mocked(dd.orderPreview).mockReset(); +}); + +describe("the seal", () => { + it("refuses every mutation while the order is being placed", async () => { + vi.mocked(db.getCartSnapshot).mockResolvedValue(snapshot("placing")); + await expect( + opSetTip({ cartUuid: "cart-1", tipCents: 880 }), + ).rejects.toThrow("sealed"); + await expect( + opDeleteCart({ goal: "g", cartUuid: "cart-1" }), + ).rejects.toThrow("sealed"); + await expect( + opSetQuantity({ + conversationId: "conv-1", + goal: "g", + cartUuid: "cart-1", + lineId: "line-1", + quantity: 2, + }), + ).rejects.toThrow("sealed"); + expect(dd.cartDelete).not.toHaveBeenCalled(); + expect(dd.cartShow).not.toHaveBeenCalled(); + }); + + it("a read-only refresh never unseals a placing cart", async () => { + vi.mocked(db.getCartSnapshot).mockResolvedValue(snapshot("placing")); + vi.mocked(dd.cartShow).mockResolvedValue( + bareLine("menu-1") as unknown as Awaited>, + ); + vi.mocked(dd.orderPreview).mockResolvedValue({} as never); + await opPreview({ + conversationId: "conv-1", + goal: "g", + cartUuid: "cart-1", + }); + expect(db.upsertCartSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ status: "placing" }), + ); + }); +}); + +describe("opSetQuantity shrink", () => { + it("demands a menu id before removing, so the line cannot vanish", async () => { + vi.mocked(db.getCartSnapshot).mockResolvedValue(snapshot("open")); + vi.mocked(dd.cartShow).mockResolvedValue( + bareLine(null) as unknown as Awaited>, + ); + await expect( + opSetQuantity({ + conversationId: "conv-1", + goal: "g", + cartUuid: "cart-1", + lineId: "line-1", + quantity: 2, + }), + ).rejects.toThrow("menu id"); + expect(dd.cartRemoveItem).not.toHaveBeenCalled(); + }); + + it("shrinking to zero is a plain remove and needs no menu id", async () => { + vi.mocked(db.getCartSnapshot).mockResolvedValue(snapshot("open")); + vi.mocked(dd.cartShow) + .mockResolvedValueOnce( + bareLine(null) as unknown as Awaited>, + ) + .mockResolvedValue({ cart: { items: [] } } as unknown as Awaited< + ReturnType + >); + vi.mocked(dd.cartRemoveItem).mockResolvedValue({} as never); + await opSetQuantity({ + conversationId: "conv-1", + goal: "g", + cartUuid: "cart-1", + lineId: "line-1", + quantity: 0, + }); + expect(dd.cartRemoveItem).toHaveBeenCalledOnce(); + expect(dd.cartAddItems).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/rendi/doordash-ops.ts b/lib/rendi/doordash-ops.ts index c299b91..b277ac3 100644 --- a/lib/rendi/doordash-ops.ts +++ b/lib/rendi/doordash-ops.ts @@ -50,6 +50,15 @@ function lineToNewItem(line: DdCartLine, quantity: number): dd.DdNewCartItem { }; } +// While an approved order is being placed, both hands are off: a +// mutation racing the final revalidation could change what actually +// gets charged. Sealed carts refuse edits with a plain answer. +function assertUnsealed(status: string | undefined): void { + if (status === "placing" || status === "placed") { + throw new Error("the order is being placed; the cart is sealed"); + } +} + async function refreshedSnapshot(input: { cartUuid: string; conversationId: string; @@ -100,7 +109,12 @@ async function refreshedSnapshot(input: { quote, tipCents, fulfillment, - status: "open", + // A refresh must never unseal a cart that entered submission while + // the CLI calls above were in flight. + status: + existing?.status === "placing" || existing?.status === "placed" + ? existing.status + : "open", }); return { cartUuid: input.cartUuid, @@ -126,6 +140,9 @@ export async function opAddItems(input: { cartUuid?: string; fulfillment?: "delivery" | "pickup"; }): Promise { + if (input.cartUuid) { + assertUnsealed((await getCartSnapshot(input.cartUuid))?.status); + } const envelope = await dd.cartAddItems( { storeId: input.storeId, @@ -161,6 +178,7 @@ export async function opSetQuantity(input: { }): Promise { const snapshot = await getCartSnapshot(input.cartUuid); if (!snapshot) throw new Error("unknown cart"); + assertUnsealed(snapshot.status); const shown = await dd.cartShow(input.cartUuid, input.goal); const lines = normalizeCartLines(shown.cart ?? null); const line = lines.find((candidate) => candidate.lineId === input.lineId); @@ -169,7 +187,11 @@ export async function opSetQuantity(input: { const delta = target - line.quantity; if (target === 0 || delta < 0) { // The backend has no target-quantity; shrinking means remove, then - // re-add at the target with the same modifiers. + // re-add at the target with the same modifiers. The re-add needs a + // menu id, so demand it BEFORE removing or the line would vanish. + if (target > 0 && !line.menuId) { + throw new Error("line carries no menu id to add against"); + } await dd.cartRemoveItem( { cartUuid: input.cartUuid, cartItemId: line.lineId }, input.goal, @@ -215,6 +237,7 @@ export async function opRemoveLine(input: { }): Promise { const snapshot = await getCartSnapshot(input.cartUuid); if (!snapshot) throw new Error("unknown cart"); + assertUnsealed(snapshot.status); const envelope = await dd.cartRemoveItem( { cartUuid: input.cartUuid, cartItemId: input.lineId }, input.goal, @@ -238,6 +261,9 @@ export async function opPreview(input: { }): Promise { const snapshot = await getCartSnapshot(input.cartUuid); if (!snapshot) throw new Error("unknown cart"); + // Previewing with a fulfillment argument MUTATES the cart at DoorDash + // (their law); a bare re-price is read-only and stays allowed. + if (input.fulfillment) assertUnsealed(snapshot.status); return refreshedSnapshot({ cartUuid: input.cartUuid, conversationId: input.conversationId, @@ -253,6 +279,7 @@ export async function opSetTip(input: { cartUuid: string; tipCents: number; }): Promise<{ cartUuid: string; tipCents: number }> { + assertUnsealed((await getCartSnapshot(input.cartUuid))?.status); // Tip is a submit-time argument at DoorDash; until then it is ours. const tipCents = Math.max(0, Math.round(input.tipCents)); await setCartTip(input.cartUuid, tipCents); @@ -263,6 +290,7 @@ export async function opDeleteCart(input: { goal: string; cartUuid: string; }): Promise<{ deleted: true }> { + assertUnsealed((await getCartSnapshot(input.cartUuid))?.status); await dd.cartDelete(input.cartUuid, input.goal); await setCartStatus(input.cartUuid, "abandoned"); return { deleted: true }; diff --git a/trigger/tools/doordash-request-approval.ts b/trigger/tools/doordash-request-approval.ts index 98c594c..ca082ec 100644 --- a/trigger/tools/doordash-request-approval.ts +++ b/trigger/tools/doordash-request-approval.ts @@ -52,6 +52,15 @@ export const doordashRequestApproval = tool({ "the store is not delivering right now; approval waits until it is", }; } + if ( + priced.fulfillment === "pickup" && + priced.quote.pickupAvailable === false + ) { + return { + denied: + "the store is not taking pickup orders right now; approval waits until it is", + }; + } const totalCents = totalWithTip(priced.quote, priced.tipCents); if (totalCents == null) { return { denied: "the cart would not price; it cannot be approved" }; diff --git a/trigger/tools/doordash-submit.ts b/trigger/tools/doordash-submit.ts index fbf0895..56f5125 100644 --- a/trigger/tools/doordash-submit.ts +++ b/trigger/tools/doordash-submit.ts @@ -87,6 +87,10 @@ export const doordashSubmit = tool({ : { refused: "the approval was already used" }; } const approval = consumed.consumed; + // Seal the cart the moment the approval is spent: from here to the + // outcome, both hands are off, and the ops layer refuses mutations + // against placing carts so nothing can drift mid-submission. + await setCartStatus(approval.cartUuid, "placing"); const snapshot = await getCartSnapshot(approval.cartUuid); if (!snapshot) return { refused: "the cart is gone" }; @@ -145,7 +149,6 @@ export const doordashSubmit = tool({ }; } const orderRow = { id: slot.orderId }; - await setCartStatus(approval.cartUuid, "placing"); let orderUuid: string | null = null; try { From 380fec5d34642df038a84f3e6746ee365afbfdbd Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Tue, 11 Aug 2026 10:39:49 -0700 Subject: [PATCH 3/7] Approval routes demand the card's own capability Codex hardening, third slice. Approval ids are sequential, and on a shared-gate instance that made them another guest's lever: five wrong guesses could lock a stranger's approval, cancel could void it. The request tool now returns an HMAC capability bound to the approval id; verify, cancel, and status all demand it before touching the row. The inbox code remains the only key that moves money. The wake is durable too: the bell rings up to three times, the card says plainly when nobody answered, and the agent knows a manual submit is safe because consumption refuses anything unverified. --- .../doordash/approvals/[id]/cancel/route.ts | 15 ++++++- app/api/doordash/approvals/[id]/route.ts | 7 +++- .../doordash/approvals/[id]/verify/route.ts | 42 +++++++++++++++---- components/doordash/approval-card.stories.tsx | 21 ++++++---- components/doordash/approval-card.tsx | 40 +++++++++++++----- lib/rendi/doordash-approval.test.ts | 21 ++++++++++ lib/rendi/doordash-approval.ts | 31 +++++++++++++- trigger/rendi/agent.md | 5 ++- trigger/tools/doordash-request-approval.ts | 2 + 9 files changed, 152 insertions(+), 32 deletions(-) diff --git a/app/api/doordash/approvals/[id]/cancel/route.ts b/app/api/doordash/approvals/[id]/cancel/route.ts index 880a0ad..9fac88a 100644 --- a/app/api/doordash/approvals/[id]/cancel/route.ts +++ b/app/api/doordash/approvals/[id]/cancel/route.ts @@ -1,14 +1,21 @@ import { eq } from "drizzle-orm"; +import { z } from "zod"; import { getDb } from "@/lib/db/index"; import { ddApprovals } from "@/lib/db/schema"; -import { voidApproval } from "@/lib/rendi/doordash-approval"; +import { + verifyApprovalToken, + voidApproval, +} from "@/lib/rendi/doordash-approval"; import { setCartStatus } from "@/lib/rendi/doordash-db"; // The card's cancel button: voids an unconsumed approval and reopens // the cart for editing. A consumed approval already became an order; // cancelling here must never rewind that cart. + +const bodySchema = z.object({ token: z.string().min(1).max(64) }); + export async function POST( - _request: Request, + request: Request, { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; @@ -16,6 +23,10 @@ export async function POST( if (!Number.isInteger(approvalId)) { return Response.json({ error: "bad approval" }, { status: 400 }); } + const parsed = bodySchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success || !verifyApprovalToken(approvalId, parsed.data.token)) { + return Response.json({ error: "not yours" }, { status: 403 }); + } const [row] = await getDb() .select({ cartUuid: ddApprovals.cartUuid }) .from(ddApprovals) diff --git a/app/api/doordash/approvals/[id]/route.ts b/app/api/doordash/approvals/[id]/route.ts index 75cbb80..e94825e 100644 --- a/app/api/doordash/approvals/[id]/route.ts +++ b/app/api/doordash/approvals/[id]/route.ts @@ -1,6 +1,7 @@ import { eq } from "drizzle-orm"; import { getDb } from "@/lib/db/index"; import { ddApprovals } from "@/lib/db/schema"; +import { verifyApprovalToken } from "@/lib/rendi/doordash-approval"; export type ApprovalStatus = | "waiting" @@ -12,7 +13,7 @@ export type ApprovalStatus = // The card re-renders from the transcript on every reload, so it asks // the row where things actually stand before offering a code input. export async function GET( - _request: Request, + request: Request, { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; @@ -20,6 +21,10 @@ export async function GET( if (!Number.isInteger(approvalId)) { return Response.json({ error: "bad approval" }, { status: 400 }); } + const token = new URL(request.url).searchParams.get("token"); + if (!verifyApprovalToken(approvalId, token)) { + return Response.json({ error: "not yours" }, { status: 403 }); + } const [row] = await getDb() .select({ consumedAt: ddApprovals.consumedAt, diff --git a/app/api/doordash/approvals/[id]/verify/route.ts b/app/api/doordash/approvals/[id]/verify/route.ts index e3bcec4..01415cd 100644 --- a/app/api/doordash/approvals/[id]/verify/route.ts +++ b/app/api/doordash/approvals/[id]/verify/route.ts @@ -2,14 +2,22 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; import { getDb } from "@/lib/db/index"; import { ddApprovals } from "@/lib/db/schema"; -import { verifyApproval } from "@/lib/rendi/doordash-approval"; +import { + verifyApproval, + verifyApprovalToken, +} from "@/lib/rendi/doordash-approval"; import { sendSessionText } from "@/lib/rendi/nudge"; // The code's only door. Deterministic verification, no model anywhere // in the path; on success the session inbox wakes the agent, the -// three-writers pattern doing what it was born for. +// three-writers pattern doing what it was born for. The capability +// token proves the caller holds this conversation's card, so another +// gate holder cannot burn attempts on someone else's approval. -const bodySchema = z.object({ code: z.string().min(1).max(12) }); +const bodySchema = z.object({ + code: z.string().min(1).max(12), + token: z.string().min(1).max(64), +}); export async function POST( request: Request, @@ -22,7 +30,10 @@ export async function POST( } const parsed = bodySchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { - return Response.json({ error: "bad code" }, { status: 400 }); + return Response.json({ error: "bad request" }, { status: 400 }); + } + if (!verifyApprovalToken(approvalId, parsed.data.token)) { + return Response.json({ error: "not yours" }, { status: 403 }); } const result = await verifyApproval(approvalId, parsed.data.code); if (!result.ok) { @@ -32,11 +43,24 @@ export async function POST( .select({ conversationId: ddApprovals.conversationId }) .from(ddApprovals) .where(eq(ddApprovals.id, approvalId)); + // The row is already verified; losing the wake must not lose the + // approval, so the bell rings up to three times and the card is told + // honestly when nobody answered. + let woke = false; if (row) { - await sendSessionText( - row.conversationId, - `[order approved, approval ${approvalId}] The owner entered the code. Place the order with doordash-submit.`, - ); + for (let attempt = 0; attempt < 3 && !woke; attempt++) { + try { + await sendSessionText( + row.conversationId, + `[order approved, approval ${approvalId}] The owner entered the code. Place the order with doordash-submit.`, + ); + woke = true; + } catch { + await new Promise((resolve) => + setTimeout(resolve, 300 * (attempt + 1)), + ); + } + } } - return Response.json({ ok: true }); + return Response.json({ ok: true, woke }); } diff --git a/components/doordash/approval-card.stories.tsx b/components/doordash/approval-card.stories.tsx index c42aeda..a62f1cb 100644 --- a/components/doordash/approval-card.stories.tsx +++ b/components/doordash/approval-card.stories.tsx @@ -4,6 +4,7 @@ import { ApprovalCard } from "./approval-card"; const awaiting = { approvalId: 41, + token: "test-token", expiresAt: new Date(Date.now() + 14 * 60 * 1000).toISOString(), totalCents: 3317, tipCents: 440, @@ -19,9 +20,11 @@ const meta = { args: { state: "output-available", output: awaiting, - verify: fn(async (_id: number, _code: string) => ({ ok: true })), - cancel: fn(async (_id: number) => {}), - fetchStatus: fn(async (_id: number) => "waiting"), + verify: fn(async (_id: number, _code: string, _token: string) => ({ + ok: true, + })), + cancel: fn(async (_id: number, _token: string) => {}), + fetchStatus: fn(async (_id: number, _token: string) => "waiting"), }, decorators: [ (Story) => ( @@ -43,7 +46,9 @@ export const CodeEntry: Story = { await expect(approve).toBeDisabled(); await userEvent.type(input, "482913"); await userEvent.click(approve); - await waitFor(() => expect(args.verify).toHaveBeenCalledWith(41, "482913")); + await waitFor(() => + expect(args.verify).toHaveBeenCalledWith(41, "482913", "test-token"), + ); await expect( canvas.getByText("approved; rendi is placing the order"), ).toBeVisible(); @@ -52,7 +57,7 @@ export const CodeEntry: Story = { export const WrongCode: Story = { args: { - verify: fn(async (_id: number, _code: string) => ({ + verify: fn(async (_id: number, _code: string, _token: string) => ({ ok: false, reason: "wrong", attemptsLeft: 4, @@ -75,7 +80,9 @@ export const Cancelled: Story = { play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button", { name: "Cancel" })); - await waitFor(() => expect(args.cancel).toHaveBeenCalledWith(41)); + await waitFor(() => + expect(args.cancel).toHaveBeenCalledWith(41, "test-token"), + ); await expect( canvas.getByText("approval cancelled; the cart is open again"), ).toBeVisible(); @@ -83,7 +90,7 @@ export const Cancelled: Story = { }; export const AlreadyApproved: Story = { - args: { fetchStatus: fn(async (_id: number) => "consumed") }, + args: { fetchStatus: fn(async (_id: number, _token: string) => "consumed") }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); // A reload after the code was entered never re-offers the input. diff --git a/components/doordash/approval-card.tsx b/components/doordash/approval-card.tsx index 01e7a80..3047dbc 100644 --- a/components/doordash/approval-card.tsx +++ b/components/doordash/approval-card.tsx @@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input"; type ApprovalOutput = { approvalId?: number; + token?: string; expiresAt?: string; totalCents?: number; storeName?: string; @@ -19,6 +20,7 @@ type ApprovalOutput = { type VerifyResponse = { ok?: boolean; + woke?: boolean; reason?: string; attemptsLeft?: number; }; @@ -26,23 +28,28 @@ type VerifyResponse = { async function postCode( approvalId: number, code: string, + token: string, ): Promise { const response = await fetch(`/api/doordash/approvals/${approvalId}/verify`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ code }), + body: JSON.stringify({ code, token }), }); return response.json(); } -async function postCancel(approvalId: number): Promise { +async function postCancel(approvalId: number, token: string): Promise { await fetch(`/api/doordash/approvals/${approvalId}/cancel`, { method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token }), }); } -async function getStatus(approvalId: number): Promise { - const response = await fetch(`/api/doordash/approvals/${approvalId}`); +async function getStatus(approvalId: number, token: string): Promise { + const response = await fetch( + `/api/doordash/approvals/${approvalId}?token=${encodeURIComponent(token)}`, + ); if (!response.ok) return "voided"; const body = (await response.json()) as { status?: string }; return body.status ?? "waiting"; @@ -90,12 +97,13 @@ export function ApprovalCard({ const [seconds, setSeconds] = useState(null); const approvalId = output?.approvalId; + const token = output?.token ?? ""; const expiresAt = output?.expiresAt; useEffect(() => { if (!approvalId) return; let alive = true; - fetchStatus(approvalId).then((status) => { + fetchStatus(approvalId, token).then((status) => { if (!alive) return; if (status === "verified" || status === "consumed") { setPhase("approved"); @@ -108,7 +116,7 @@ export function ApprovalCard({ return () => { alive = false; }; - }, [approvalId, fetchStatus]); + }, [approvalId, token, fetchStatus]); useEffect(() => { if (phase !== "waiting" || !expiresAt) return; @@ -125,9 +133,12 @@ export function ApprovalCard({ if (!approvalId || code.trim().length < 6) return; setPhase("checking"); setNote(null); - const result = await verify(approvalId, code.trim()); + const result = await verify(approvalId, code.trim(), token); if (result.ok) { setPhase("approved"); + if (result.woke === false) { + setNote("rendi did not hear the bell; say anything in the chat"); + } return; } setCode(""); @@ -173,9 +184,16 @@ export function ApprovalCard({

) : approvalId ? ( phase === "approved" ? ( -

- approved; rendi is placing the order -

+
+

+ approved; rendi is placing the order +

+ {note ? ( +

+ {note} +

+ ) : null} +
) : phase === "cancelled" ? (

approval cancelled; the cart is open again @@ -218,7 +236,7 @@ export function ApprovalCard({ variant="ghost" disabled={phase === "checking"} onClick={async () => { - await cancel(approvalId); + await cancel(approvalId, token); setPhase("cancelled"); }} > diff --git a/lib/rendi/doordash-approval.test.ts b/lib/rendi/doordash-approval.test.ts index 859fd77..cd165c0 100644 --- a/lib/rendi/doordash-approval.test.ts +++ b/lib/rendi/doordash-approval.test.ts @@ -3,6 +3,7 @@ import type { drizzle } from "drizzle-orm/pglite"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ddApprovals, ddOrders } from "../db/schema.ts"; import { + approvalToken, checkCaps, consumeApproval, createApproval, @@ -11,6 +12,7 @@ import { reserveOrderSlot, totalWithTip, verifyApproval, + verifyApprovalToken, voidApproval, } from "./doordash-approval.ts"; import type { DdCartLine, DdQuoteSnapshot } from "./doordash-cart.ts"; @@ -214,6 +216,25 @@ describe("the approval lifecycle", () => { }); }); +describe("approvalToken", () => { + it("binds to the approval id and never to guesswork", () => { + const original = process.env.RENDER_TOKEN_SECRET; + process.env.RENDER_TOKEN_SECRET = "test-secret"; + try { + const token = approvalToken(41); + expect(token).toMatch(/^[0-9a-f]{32}$/); + expect(approvalToken(41)).toBe(token); + expect(verifyApprovalToken(41, token)).toBe(true); + expect(verifyApprovalToken(42, token)).toBe(false); + expect(verifyApprovalToken(41, "0".repeat(32))).toBe(false); + expect(verifyApprovalToken(41, undefined)).toBe(false); + } finally { + if (original === undefined) delete process.env.RENDER_TOKEN_SECRET; + else process.env.RENDER_TOKEN_SECRET = original; + } + }); +}); + describe("cap parsing", () => { it("blank and junk env values mean the defaults", () => { const original = process.env.DD_MAX_ORDER_CENTS; diff --git a/lib/rendi/doordash-approval.ts b/lib/rendi/doordash-approval.ts index 0ddf2e5..860dad2 100644 --- a/lib/rendi/doordash-approval.ts +++ b/lib/rendi/doordash-approval.ts @@ -1,4 +1,9 @@ -import { createHash, randomInt, timingSafeEqual } from "node:crypto"; +import { + createHash, + createHmac, + randomInt, + timingSafeEqual, +} from "node:crypto"; import { and, count, eq, gt, isNull, sql } from "drizzle-orm"; import { getDb } from "../db/index.ts"; import { type DdApprovalRow, ddApprovals, ddOrders } from "../db/schema.ts"; @@ -72,6 +77,30 @@ function hashCode(code: string, approvalId: number): string { return createHash("sha256").update(`${code}:${approvalId}`).digest("hex"); } +// A capability, not a code: it rides in the tool output so only this +// conversation's card can speak to the approval routes, keeping +// sequential ids from being another gate holder's lever. The inbox +// code remains the only key that moves money. +export function approvalToken(approvalId: number): string { + const secret = process.env.RENDER_TOKEN_SECRET; + if (!secret) throw new Error("RENDER_TOKEN_SECRET is not set"); + return createHmac("sha256", secret) + .update(`dd-approval:${approvalId}`) + .digest("hex") + .slice(0, 32); +} + +export function verifyApprovalToken( + approvalId: number, + token: string | null | undefined, +): boolean { + const expected = Buffer.from(approvalToken(approvalId)); + const provided = Buffer.from(token ?? ""); + return ( + expected.length === provided.length && timingSafeEqual(expected, provided) + ); +} + export type ApprovalDenied = { denied: string }; export async function checkCaps( diff --git a/trigger/rendi/agent.md b/trigger/rendi/agent.md index 3ad4ec2..b0d5639 100644 --- a/trigger/rendi/agent.md +++ b/trigger/rendi/agent.md @@ -181,7 +181,10 @@ exactly that id and narrate the outcome honestly, including action_required and failed, where the answer is never to resubmit. An unknown outcome means the response was lost, not that the order failed: wait a minute, check order-history, and reconcile honestly -before saying anything definitive. +before saying anything definitive. If the owner says they entered the +code but no [order approved] message ever arrived, calling +doordash-submit with the approval id from your own request is safe; +the machine refuses unless the code was truly verified. Spend caps are enforced below you; when a request is denied for caps, say so plainly. Anything changed after the code was sent voids it, and a fresh approval is one call away. diff --git a/trigger/tools/doordash-request-approval.ts b/trigger/tools/doordash-request-approval.ts index ca082ec..1001f23 100644 --- a/trigger/tools/doordash-request-approval.ts +++ b/trigger/tools/doordash-request-approval.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { getDb } from "@/lib/db/index"; import { conversations } from "@/lib/db/schema"; import { + approvalToken, checkCaps, createApproval, hashCart, @@ -106,6 +107,7 @@ export const doordashRequestApproval = tool({ await setCartStatus(cartUuid, "awaiting_code"); return { approvalId: approval.id, + token: approvalToken(approval.id), expiresAt: approval.expiresAt.toISOString(), totalCents, tipCents: priced.tipCents, From b5ad606102dcf12a1c705b213bacc820599c22e6 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Tue, 11 Aug 2026 10:44:22 -0700 Subject: [PATCH 4/7] Cart cards adopt the durable truth Codex hardening, fourth slice. Transcripts freeze tool output forever, but the cart keeps living: cards now hydrate from a snapshot GET on mount, so reloads and older copies of the same cart converge on what is actually true, and a sealed cart renders sealed, controls frozen, header saying why. The ops route cancels its run when the deadline fires, so a timed-out edit can no longer mutate the cart after the card was told it failed. And a submit retry that lands on an already-used approval refreshes pending or unknown outcomes against live order status instead of repeating stale news. --- app/api/doordash/carts/[uuid]/ops/route.ts | 3 ++ app/api/doordash/carts/[uuid]/route.ts | 25 ++++++++++ components/doordash/cart-card.stories.tsx | 38 ++++++++++++++- components/doordash/cart-card.tsx | 56 ++++++++++++++++++---- trigger/tools/doordash-submit.ts | 40 +++++++++++++--- 5 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 app/api/doordash/carts/[uuid]/route.ts diff --git a/app/api/doordash/carts/[uuid]/ops/route.ts b/app/api/doordash/carts/[uuid]/ops/route.ts index 12652f3..a5c4b92 100644 --- a/app/api/doordash/carts/[uuid]/ops/route.ts +++ b/app/api/doordash/carts/[uuid]/ops/route.ts @@ -68,5 +68,8 @@ export async function POST( } await new Promise((resolve) => setTimeout(resolve, POLL_MS)); } + // A timeout answer must also be true: cancel the run so a queued edit + // cannot mutate the cart after the card was told it failed. + await runs.cancel(handle.id).catch(() => {}); return Response.json({ error: "edit timed out" }, { status: 504 }); } diff --git a/app/api/doordash/carts/[uuid]/route.ts b/app/api/doordash/carts/[uuid]/route.ts new file mode 100644 index 0000000..429dcdf --- /dev/null +++ b/app/api/doordash/carts/[uuid]/route.ts @@ -0,0 +1,25 @@ +import { getCartSnapshot } from "@/lib/rendi/doordash-db"; + +// The durable snapshot, for cards to hydrate against: transcripts +// freeze tool output forever, but the cart kept living. The uuid is +// its own capability; there is nothing guessable here. +export async function GET( + _request: Request, + { params }: { params: Promise<{ uuid: string }> }, +) { + const { uuid } = await params; + const snapshot = await getCartSnapshot(uuid); + if (!snapshot) { + return Response.json({ error: "no such cart" }, { status: 404 }); + } + return Response.json({ + cartUuid: snapshot.cartUuid, + storeName: snapshot.storeName, + storeImageUrl: snapshot.storeImageUrl, + items: snapshot.items, + quote: snapshot.quote, + tipCents: snapshot.tipCents, + fulfillment: snapshot.fulfillment, + status: snapshot.status, + }); +} diff --git a/components/doordash/cart-card.stories.tsx b/components/doordash/cart-card.stories.tsx index daedfac..22bcc85 100644 --- a/components/doordash/cart-card.stories.tsx +++ b/components/doordash/cart-card.stories.tsx @@ -3,7 +3,7 @@ import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { cartShowFixture, previewFixture } from "@/lib/rendi/doordash.fixtures"; import { normalizeCartLines, normalizeQuote } from "@/lib/rendi/doordash-cart"; import { ddCartEnvelope, ddPreviewResult } from "@/lib/rendi/doordash-schemas"; -import { CartCard, type CartCardData } from "./cart-card"; +import { CartCard, type CartCardData, type DurableCart } from "./cart-card"; const lines = normalizeCartLines(ddCartEnvelope.parse(cartShowFixture).cart); const quote = normalizeQuote(ddPreviewResult.parse(previewFixture)); @@ -25,6 +25,7 @@ const meta = { args: { data: building, exec: fn(async () => ({ ok: true, cart: null })), + hydrate: fn(async (): Promise => null), }, decorators: [ (Story) => ( @@ -97,3 +98,38 @@ export const Empty: Story = { await expect(canvas.getByText("The cart is empty.")).toBeVisible(); }, }; + +export const Sealed: Story = { + args: { + hydrate: fn(async () => ({ status: "placing" })), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // The durable snapshot says the order is being placed: every + // control freezes and the header says why. + await expect(await canvas.findByText("placing the order")).toBeVisible(); + await waitFor(() => + expect( + canvas.getByRole("button", { name: "One more Pad See Ew" }), + ).toBeDisabled(), + ); + await expect(canvas.getByRole("button", { name: "10%" })).toBeDisabled(); + }, +}; + +export const HydratesFromDurableTruth: Story = { + args: { + hydrate: fn(async () => ({ status: "open", tipCents: 880 })), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // The transcript froze a 10 percent tip; the durable cart moved on + // to 20 percent, and the card adopts the truth on mount. + await waitFor(async () => + expect(canvas.getByRole("button", { name: "20%" })).toHaveAttribute( + "aria-pressed", + "true", + ), + ); + }, +}; diff --git a/components/doordash/cart-card.tsx b/components/doordash/cart-card.tsx index 12177fd..6120a8c 100644 --- a/components/doordash/cart-card.tsx +++ b/components/doordash/cart-card.tsx @@ -1,7 +1,7 @@ "use client"; import { ShoppingBag } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Shimmer } from "@/components/ai-elements/shimmer"; import { CartLine } from "@/components/doordash/cart-line"; import { ZoomableImage } from "@/components/doordash/zoomable-image"; @@ -43,6 +43,14 @@ async function execOp( return response.json(); } +export type DurableCart = Partial & { status?: string }; + +async function fetchSnapshot(cartUuid: string): Promise { + const response = await fetch(`/api/doordash/carts/${cartUuid}`); + if (!response.ok) return null; + return response.json(); +} + const TIP_PRESETS = [10, 15, 20]; function dollars(cents: number): string { @@ -51,18 +59,46 @@ function dollars(cents: number): string { // The cart is an instrument: live truth rendered as a card, steered by // touch without the model, and every change lands where the agent reads -// it back. exec is injectable so stories run on fixtures. +// it back. exec and hydrate are injectable so stories run on fixtures. export function CartCard({ data, exec = execOp, + hydrate = fetchSnapshot, }: { data: CartCardData; exec?: typeof execOp; + hydrate?: typeof fetchSnapshot; }) { const [cart, setCart] = useState(data); + const [status, setStatus] = useState("open"); const [busy, setBusy] = useState(false); const [failed, setFailed] = useState(null); + // The transcript froze this card's numbers at tool time; the cart + // kept living. Adopt the durable snapshot so a reload, or an older + // copy of the same cart, converges on the truth. + useEffect(() => { + let alive = true; + hydrate(data.cartUuid).then((durable) => { + if (!alive || !durable) return; + setStatus(durable.status ?? "open"); + setCart((current) => ({ + ...current, + ...(durable.items ? { items: durable.items } : {}), + ...(durable.quote !== undefined ? { quote: durable.quote } : {}), + ...(durable.tipCents !== undefined + ? { tipCents: durable.tipCents } + : {}), + ...(durable.fulfillment ? { fulfillment: durable.fulfillment } : {}), + })); + }); + return () => { + alive = false; + }; + }, [data.cartUuid, hydrate]); + + const sealed = status === "placing" || status === "placed"; + const frozen = busy || sealed; const subtotal = cart.quote?.ladder.find( (line) => line.chargeId === "SUBTOTAL", )?.cents; @@ -116,9 +152,13 @@ export function CartCard({ {cart.storeName}

- {cart.quote?.asapAvailable - ? (cart.quote?.etaRange ?? "") - : "closed right now"} + {sealed + ? status === "placed" + ? "ordered" + : "placing the order" + : cart.quote?.asapAvailable + ? (cart.quote?.etaRange ?? "") + : "closed right now"}

@@ -126,7 +166,7 @@ export function CartCard({