From efff3eaabb8ef7255b919c26eb85ff7ec2e97444 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:51:53 +0000 Subject: [PATCH 01/13] Add doordash.com get_store page tool --- domains/doordash.com/README.md | 172 ++++++++++++-- domains/doordash.com/get_store.js | 267 +++++++++++++++++++++ registry.json | 13 ++ tests/doordash-get-store.test.mjs | 373 ++++++++++++++++++++++++++++++ 4 files changed, 811 insertions(+), 14 deletions(-) create mode 100644 domains/doordash.com/get_store.js create mode 100644 tests/doordash-get-store.test.mjs diff --git a/domains/doordash.com/README.md b/domains/doordash.com/README.md index 6890d852..0da04bc6 100644 --- a/domains/doordash.com/README.md +++ b/domains/doordash.com/README.md @@ -5,18 +5,25 @@ Tools for DoorDash (https://www.doordash.com). | tool | file | status | notes | |------|------|--------|-------| | login | [login.js](login.js) | qa_passed | Email + password sign-in with a two-step guided flow; existing sessions verified against the profile page; `otp` mode for the 2-Step Verification modal | +| get_store | [get_store.js](get_store.js) | draft | Page tool: store hours, service modes, delivery and pickup availability with estimates and fee strings, and menu ids, with the delivery basis (selected address, none selected, unknown) stated explicitly and unreliable delivery figures withheld; never changes the address | ## Native WebMCP tools Checked on 2026-09-25 in a stealth browser with a signed-in profile. DoorDash ships its own tools through a `[WebMCP Polyfill]` that it installs on `navigator.modelContext` (`registerTool`, `unregisterTool`, `callTool`, `listTools`, plus a `_registeredTools` map). -The polyfill is page JavaScript, not the browser's `document.modelContext` API, so -`webmcp.listTools()` in the Kernel REPL returns **no native tools** on any page; read them with -`await navigator.modelContext.listTools()` from page context instead. The polyfill can take a few -seconds to load after `load`; poll for `navigator.modelContext` before reading. Tool names are -therefore not visible to Kernel's WebMCP client, but the custom `login` name still does not -collide with any of them. +On that date the polyfill was page JavaScript that `webmcp.listTools()` in the Kernel REPL did not +list, so the tools had to be read with `await navigator.modelContext.listTools()` from page context. +**That has changed (rechecked 2026-09-29, see below).** The polyfill can take a few seconds to load +after `load`. The custom tool names in this namespace do not collide with any native name. + +Rechecked 2026-09-29 on the Kernel runtime, signed out, on `/store/29417/`: `kernel browsers webmcp +list` and the REPL's `webmcp.listTools()` list the page's native tools (`add_to_cart`, +`get_item_details`, `get_page_context`, `get_restaurant_menu`, `navigate_to_page`) from about 3 to +5 seconds after the page loads (none at 3 s in the first poll, five at 5 s), and `document.modelContext` +exposes `registerTool`, `getTools` and `executeTool`. Rechecked the same day through +`navigator.modelContext.listTools()`, the lists per page type in the table were unchanged. The earlier "not visible" reading was either a timing artifact or a runtime difference; it +is not the case now. | page | URL checked | native tools | |------|-------------|--------------| @@ -43,10 +50,10 @@ Schemas as read from the polyfill (inputs only; the polyfill exposes no output s - `add_to_cart({ storeId, menuId, itemId, itemName, quantity, unitPrice, specialInstructions?, cartId?, options?, purchaseTypeOptions? })` with nested option selections; `unitPrice` is in cents. -None of the native tools sign in, so `login` is custom. Search and cart are already covered -natively; the next phase should evaluate those tools before writing custom ones, and it must -call them through `navigator.modelContext.callTool` (or register the site-provided definitions) -because Kernel's WebMCP client does not see the polyfill. +None of the native tools sign in, so `login` is custom. Search, item options and adding to the cart +are covered natively. The native tools give no store details (hours, address, availability, fees, +estimates) and no cart read or edit, address, or order tools; `get_store` below covers the first +gap only. ## Entry path and flow @@ -94,16 +101,17 @@ challenge appeared in any run. ## Loading ```javascript -const source = (await import("node:fs")).readFileSync("domains/doordash.com/login.js", "utf8"); -await webmcp.addCustomTools({ namespace: "doordash.com", tools: eval(source), forceOverwriteNamespace: true }); +const fs = await import("node:fs"); +const tools = ["login", "get_store"].map(name => eval(fs.readFileSync(`domains/doordash.com/${name}.js`, "utf8"))[0]); +await webmcp.addCustomTools({ namespace: "doordash.com", tools, forceOverwriteNamespace: true }); const tool = (await webmcp.listTools()).find(t => t.source.custom?.namespace === "doordash.com" && t.tool.name === "login"); const result = await webmcp.invokeTool(tool.tool_ref, { email: "qa@example.invalid", password: "fabricated" }, { timeoutSec: 120 }); // Second step, once the code arrives: // await webmcp.invokeTool(tool.tool_ref, { email: "qa@example.invalid", otp: "123456" }, { timeoutSec: 120 }); ``` -`forceOverwriteNamespace: true` replaces every tool in `doordash.com`; register search or cart tools -together with `login` as one array. Take a fresh `tool_ref` after navigation. Use `timeoutSec: 120` +`forceOverwriteNamespace: true` replaces every tool in `doordash.com`, so register `login` and `get_store` +together as one array. Take a fresh `tool_ref` after navigation. Use `timeoutSec: 120` (the WebMCP maximum); the post-submit observation window is 45 seconds and a full search-entry run took about 15 seconds. The runtime only lists a CDP tool on pages matching its `url_patterns`, so `login` is invocable from any `doordash.com` page (including `help.` and `identity.`) but not from @@ -256,6 +264,142 @@ the branch touch only this README and the registry. mailbox; the tool does not click it, so a caller that cannot receive SMS should click it in the browser before invoking `otp` mode. +## get_store + +`get_store({ store_id })` reads one DoorDash store and reports the things the native tools do not: +open status and hours, the services the store offers, whether delivery and pickup can be ordered +**right now**, an estimate range only when the site marks the mode available, the site's own +delivery-fee strings, and the store's menus, together with the delivery basis those figures rest on. It never returns menu items +(use the native `get_restaurant_menu` on the store page), reviews, or a fee quote. `store_id` is the +digits from `search_restaurants` or from a `doordash.com/store/-/` URL. The result also +carries `menu.menu_id`, the `menuId` the native `add_to_cart` requires. + +Name: `get_store` follows `ubereats.com/get_store`, and a DoorDash store can be a restaurant or a +convenience store, so the restaurant-only names were not used. Convenience and grocery stores return +their header details with `store_type: "convenience"` and `menu: null`; their catalog is a different +surface (native `get_convenience_store`, `search_retail_products`). + +### Delivery context + +DoorDash computes delivery availability, estimate and distance for the delivery address selected in +the session, not for the store alone. With no address selected it does **not** use the egress IP: on +2026-09-29 two guest browsers, one whose `getGeoByIP` answered with a point in the middle of the +United States and one behind a New York residential proxy whose `getGeoByIP` answered with the Bronx, +got the identical distance to the same New York store (5385 mi) and "Too far away" for it. Those figures +describe a placeholder location, not the customer, and they read "Too far away" for every store. The +output says which case applies in `delivery_context.basis`: + +- `selected_address`: the header address control shows an address; delivery `available`, its reason, + `estimate_minutes`, `distance` and `too_far_from_consumer` are DoorDash's answer for that address. +- `no_address_selected`: the header shows the site's "Your Address" placeholder. Delivery `available`, + `unavailable_reason`, `unavailable_message`, `estimate_minutes`, `distance` and `too_far_from_consumer` + are `null` (withheld), because reporting `available: false` there would say the store does not deliver + when nothing is known. Store details, hours, pickup and the fee strings are still returned. +- `unknown`: the page has no header address control (the signed-out landing page and `/home`); the + figures are passed through unverified. Invoke from a store, search, DashPass or grocery page for a + definite basis. + +Other fields: + +- `delivery_context.address_selected` is the boolean behind `basis` (`data-testid="addressTextButton"` + text other than "Your Address" is `true`). The tool never returns the address text and never opens, + changes or selects an address. +- `delivery_context.signed_in` is from the `dd_cx_logged_in=true` cookie, a hint only; fees and + promotions can differ by account (first-order and DashPass messages). +- `delivery.estimate_minutes` and `pickup.estimate_minutes` are `null` unless that mode is available, + because the site sends a range even for a mode it reports unavailable. +- `delivery.fee_display` returns the site's `title`, `subtitle` and `text` unparsed; the strings differ + by store type ("$0 delivery fee over $12" on a restaurant, "$0.00" plus "delivery fee on $12+" on a + convenience store) and are not a quote. They are returned in every basis. + +### Why a page tool + +The tool makes one same-origin `fetch` from the registering document and returns; it does not +navigate, so it is `kind: "page"` per the authoring skill and `spec/cdp-tools.md` §2.1. It is invocable +from any `www.doordash.com` page (verified from a search page, the home page, a store page, a DashPass +page and a grocery tab) without leaving it. Registered by the Kernel API, a page tool's input is **not** +checked against `inputSchema` (observed 2026-09-29: malformed input reached `execute`), so `execute` +validates `store_id` itself and returns `invalid_input` before any request. + +### Mechanism (observed 2026-09-29) + +The store page calls `POST /graphql/storepageFeed?operation=storepageFeed` from its own JavaScript, with +headers `x-csrftoken` (the `csrf_token` cookie when signed in, an empty string signed out), +`x-channel-id: marketplace` and `x-experience-id: doordash`, and a 22 KB query. The tool sends a +trimmed query for the same operation that selects only the fields listed in the output. The trimmed +query was checked live from `/home` and from other store, search, DashPass and grocery pages, +returning HTTP 200 and about 3.4 KB per store, in about 0.6 to 0.9 s. Unknown ids return HTTP 200 with a +GraphQL `NOT_FOUND` error and a null feed; some ids (for example a one-digit id) return a header with an +empty name, which the tool also reports as `not_found`. The server-rendered store HTML carries the same +header, but inside a Next.js payload in which a leading `$` in a string is escaped as `$$` (for example +`"$$0 delivery fee"`); it was compared field by field with the GraphQL response and rejected as the +source because of that encoding and its 2.5 to 3 MB size. + +### Errors + +Every failure is `{ status: "error", error_kind, error_message }`: `invalid_input`, `wrong_page` +(not on `www.doordash.com`; nothing was requested), `not_found`, `login_required` (HTTP 401 or an +unauthenticated GraphQL error), `challenge` (a non-JSON bot-check page), `rate_limited` (HTTP 429), +`site_changed` (the query was rejected as invalid, the header is missing, or another store came back) +and `site_error` (anything else, including timeout after 25 s). A permission, challenge or rate-limit +response is never turned into an empty success, and nothing is retried. + +### Live evidence for get_store + +Kernel runtime (`kernel` CLI 0.41.0), 2026-09-29, one profile-less stealth browser signed out with a +default egress far from the sampled stores, registering the whole `doordash.com` namespace +(`login` and `get_store`) in one `force_overwrite_namespace` batch: + +| case | result | +|---|---| +| store 29417 from a search page, `/home`, its own store page, a DashPass page | `ok`, 0.6 to 0.8 s, no navigation (a marker on `window` survived), output valid under Ajv against the schema the runtime lists | +| store 36030759 (convenience) from a grocery tab | `store_type: convenience`, `menu: null`, `pickup.available: false` with `estimate_minutes: null` | +| unknown id 99999999999 and empty-header id 1 | `not_found` | +| eight malformed inputs (`abc`, `{}`, 13 digits, a number, an extra field, a space, `-1`, `""`) | `invalid_input`; CDP network events showed no `storepageFeed` request, while one valid call showed exactly one | +| help., merchants., identity.doordash.com | `get_store` is not listed (only `login`); the apex redirects to `www` where it is listed | +| signed out, no address, central-US egress and New York residential egress | identical: `basis: no_address_selected` on search, store, DashPass and grocery pages, delivery figures `null`, pickup and store data returned; on `/home` (no header control) `basis: unknown` and DoorDash's own "Too far away" passed through. The same store read 5385 mi from both egress locations before the figures were withheld | + +The same calls in the approved signed-in browser (read only; the custom tools were registered for the +check and removed afterwards, and no address text or account data was recorded): a store in range of +the account's address returned `basis: selected_address`, `signed_in: true`, and delivery and pickup +available with estimate ranges; a store outside that range returned `too_far_from_consumer: true` with +pickup still available; a convenience store in range returned delivery available and pickup +unavailable. Store ids and distances from this account are deliberately not recorded. + +### Known limitations of get_store + +- Draft. No independent QA has signed off; the live checks above are the author's. +- Not verified live: `login_required`, `challenge`, `rate_limited` and `site_changed` responses (fixture + coverage only, with response shapes assumed from GraphQL conventions and the observed `NOT_FOUND` shape). + The `challenge` detection looks only at non-JSON bodies. +- `basis` depends on a header control that is absent on the signed-out landing pages and `/home` (then + `unknown`) and on the placeholder text "Your Address" (English; another locale would read as a selected + address). A guest-entered address (never exercised: the tool and its testing never set one) is assumed to + show as address text, and that a guest address changes DoorDash's figures is unobserved. Signed in without + any saved address is unobserved. That the placeholder location is the same for every guest is inferred from + two egress locations only. +- The delivery estimate and availability are for one moment and one context. `fulfillmentType` is fixed to + Delivery in the request; the same response carries the pickup status. Scheduled orders are not modelled. +- `operation_status` is a pass-through string; only `OPEN` was observed. There is deliberately no + `open_now` boolean. +- The trimmed query names fields and types taken from the site's own query on 2026-09-29. If DoorDash + renames them the tool returns `site_changed`; the query is not a public API. +- Loading a store page or this query may register a store view in DoorDash's own analytics, as any visit does. +- Only stores DoorDash reports under `/store/` were sampled, plus one convenience store; other store + types (for example shipping-only) are unobserved. Single-digit ids return an empty header rather than an error. +- Delivery figures depend on the session's address; the egress location did not change them in the two + locations tried. Coordinates and street in `address` are the store's own. + +### URL patterns + +`get_store` is narrowed to `https://www.doordash.com/*`; `login` keeps the default pair. The tool calls +`www.doordash.com`'s own GraphQL endpoint with a relative URL, and the other `*.doordash.com` hosts are +unrelated products (`merchants.`, `dasher.`, `business.`, `help.`, `developer.`, `about.`, `identity.`) +that do not serve it; they were observed on 2026-09-29 to have no native tools and a different purpose. +`login` cannot be narrowed the same way because sign-in runs on `identity.doordash.com`. The namespace +therefore mixes both forms, against the `SHOULD` in `spec/repository.md` §6.2 that one host be used per +namespace; this is flagged for review. + ## Sessions - Long-lived signed-in browser (profile `personal-stuff`, residential proxy): `wjdkr9p7ugcc9ui4ssf7e4mm` (24-hour inactivity timeout; left alive for the search and cart phase). diff --git a/domains/doordash.com/get_store.js b/domains/doordash.com/get_store.js new file mode 100644 index 00000000..81067f2a --- /dev/null +++ b/domains/doordash.com/get_store.js @@ -0,0 +1,267 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.doordash.com/*"] }, + tool: { + name: "get_store", + description: "Get one DoorDash (www.doordash.com) store by store_id (the id from search_restaurants or the number at the end of a doordash.com/store/-/ URL): name, cuisines, price level, rating, address and coordinates, timezone, whether it offers delivery, pickup, group orders, catering and scheduling, open-hours schedules, its menus (menu_id is the menuId that the site's own add_to_cart tool needs), and whether delivery and pickup can be ordered right now with an estimate range and the site's delivery-fee text. Delivery availability, estimate and distance are computed by DoorDash for the delivery address selected in this browser session, not for the store alone, so delivery_context.basis states which case applies: selected_address (figures are for that address), no_address_selected (the figures DoorDash returns then describe a placeholder location, not the customer, and every store reads 'Too far away', so delivery.available, its reason and estimate, distance and too_far_from_consumer are withheld as null), or unknown (the page has no header address control, as on the signed-out landing page and /home; figures are passed through unverified, so open a store, search or DashPass page first for a definite basis). signed_in says whether the account is signed in. This tool never selects or changes an address and never returns address text. It does not return menu items (use the site's get_restaurant_menu on the store page), reviews, or a fee quote: the fee text depends on the address, the account's benefits and the basket. Convenience and grocery stores return their header details with menu null. Read-only; works signed out; runs from any www.doordash.com page without navigating.", + inputSchema: { + type: "object", additionalProperties: false, required: ["store_id"], + properties: { + store_id: { type: "string", pattern: "^[0-9]{1,12}$", description: "DoorDash store id, digits only, e.g. the id field returned by search_restaurants." } + } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", additionalProperties: false, + required: ["status", "store_id", "name", "url", "store_type", "description", "cuisines", "price_range", "rating", "address", "timezone", "offers", "operation_status", "operation_text", "hours", "delivery", "pickup", "delivery_context", "menu"], + properties: { + status: { const: "ok" }, + store_id: { type: "string", pattern: "^[0-9]{1,12}$" }, + name: { type: "string", minLength: 1 }, + url: { type: "string", pattern: "^https://www\\.doordash\\.com/(convenience/)?store/[0-9]+/$", description: "Canonical store URL; the site redirects it to the slugged form." }, + store_type: { enum: ["restaurant", "convenience"], description: "convenience covers grocery and retail stores, whose catalog is not a menu." }, + description: { type: ["string", "null"] }, + cuisines: { type: "array", maxItems: 10, items: { type: "string" }, description: "The site's business tags, e.g. Italian, Pizza." }, + price_range: { + type: "object", additionalProperties: false, required: ["level", "text"], + properties: { level: { type: ["integer", "null"], minimum: 1, maximum: 4 }, text: { type: ["string", "null"], description: "$ to $$$$ as the site shows it." } } + }, + rating: { + type: "object", additionalProperties: false, required: ["average", "count", "count_text", "is_new"], + properties: { average: { type: ["number", "null"] }, count: { type: ["integer", "null"] }, count_text: { type: ["string", "null"], description: "For example (9k+)." }, is_new: { type: ["boolean", "null"] } } + }, + address: { + type: "object", additionalProperties: false, required: ["street", "city", "state", "latitude", "longitude"], + description: "The store's own address, not the customer's.", + properties: { street: { type: ["string", "null"] }, city: { type: ["string", "null"] }, state: { type: ["string", "null"] }, latitude: { type: ["number", "null"] }, longitude: { type: ["number", "null"] } } + }, + timezone: { type: ["string", "null"] }, + offers: { + type: "object", additionalProperties: false, required: ["delivery", "pickup", "group_order", "catering", "scheduling", "dashpass_partner"], + description: "Which services the store supports in general; not whether they can be ordered right now (see delivery and pickup).", + properties: { + delivery: { type: ["boolean", "null"] }, pickup: { type: ["boolean", "null"] }, group_order: { type: ["boolean", "null"] }, + catering: { type: ["boolean", "null"] }, scheduling: { type: ["boolean", "null"] }, dashpass_partner: { type: ["boolean", "null"] } + } + }, + operation_status: { type: ["string", "null"], description: "The site's operating status for DoorDash orders, e.g. OPEN. Pass-through; other values are not enumerated." }, + operation_text: { type: ["string", "null"], description: "For example: Accepting DoorDash orders until 2:40 AM." }, + hours: { + type: "object", additionalProperties: false, required: ["store", "doordash_orders"], + properties: { + store: { type: "array", maxItems: 7, items: { type: "object", additionalProperties: false, required: ["day", "slots"], properties: { day: { type: "string" }, slots: { type: "array", maxItems: 6, items: { type: "string" }, description: "Open periods as the site prints them, e.g. 10 AM - 2:40 AM." } } }, description: "The store's own hours of operation." }, + doordash_orders: { type: "array", maxItems: 7, items: { type: "object", additionalProperties: false, required: ["day", "slots"], properties: { day: { type: "string" }, slots: { type: "array", maxItems: 6, items: { type: "string" }, description: "Open periods as the site prints them, e.g. 10 AM - 2:40 AM." } } }, description: "The hours in which the store accepts DoorDash orders." } + } + }, + delivery: { + type: "object", additionalProperties: false, required: ["available", "unavailable_reason", "unavailable_message", "estimate_minutes", "fee_display"], + description: "Delivery to the session's delivery context (see delivery_context).", + properties: { + available: { type: ["boolean", "null"], description: "True when DoorDash reports delivery can be ordered now to the selected address. Null when no address is selected (delivery_context.basis no_address_selected) or the site did not say." }, + unavailable_reason: { type: ["string", "null"], description: "The site's reason when not available, e.g. Too far away. Null when withheld." }, + unavailable_message: { type: ["string", "null"], description: "Null when withheld." }, + estimate_minutes: { type: ["string", "null"], description: "The site's estimate range, e.g. 27 - 37. Null unless delivery is available: the site sends a range even for unavailable delivery." }, + fee_display: { + type: "object", additionalProperties: false, required: ["title", "subtitle", "text", "is_surging"], + description: "DoorDash's own delivery-fee strings, unparsed. Not a quote: they depend on the address, account benefits and basket.", + properties: { title: { type: ["string", "null"] }, subtitle: { type: ["string", "null"] }, text: { type: ["string", "null"] }, is_surging: { type: ["boolean", "null"] } } + } + } + }, + pickup: { + type: "object", additionalProperties: false, required: ["available", "unavailable_reason", "estimate_minutes"], + properties: { + available: { type: ["boolean", "null"], description: "True when DoorDash reports pickup can be ordered now." }, + unavailable_reason: { type: ["string", "null"] }, + estimate_minutes: { type: ["string", "null"], description: "Null unless pickup is available." } + } + }, + delivery_context: { + type: "object", additionalProperties: false, required: ["basis", "address_selected", "signed_in", "distance", "too_far_from_consumer", "note"], + properties: { + basis: { enum: ["selected_address", "no_address_selected", "unknown"], description: "What the delivery figures describe. selected_address: the address chosen in this browser. no_address_selected: DoorDash used a placeholder location, so delivery available, reason, estimate, distance and too_far_from_consumer are withheld (null). unknown: this page has no header address control; the figures are passed through unverified." }, + address_selected: { type: ["boolean", "null"], description: "True when this document's header shows a delivery address, false when it shows the site's 'Your Address' placeholder, null when this page has no header address control. The address text is never returned." }, + signed_in: { type: "boolean", description: "From the dd_cx_logged_in cookie; a hint only. Fees and promotions can differ by account." }, + distance: { + anyOf: [{ type: "null" }, { + type: "object", additionalProperties: false, required: ["value", "unit"], + properties: { value: { type: "number" }, unit: { type: "string" } } + }], + description: "Distance from the location DoorDash used to the store. Null when withheld (no address selected)." + }, + too_far_from_consumer: { type: ["boolean", "null"], description: "DoorDash's own flag that the store is outside delivery range of that location. Null when withheld." }, + note: { type: "string" } + } + }, + menu: { + anyOf: [{ type: "null" }, { + type: "object", additionalProperties: false, required: ["menu_id", "name", "display_hours", "menus", "menus_truncated"], + properties: { + menu_id: { type: ["string", "null"], description: "The default menu id; pass it as menuId to the site's add_to_cart tool." }, + name: { type: ["string", "null"] }, + display_hours: { type: ["string", "null"] }, + menus: { + type: "array", maxItems: 20, + items: { + type: "object", additionalProperties: false, required: ["menu_id", "name", "display_hours"], + properties: { menu_id: { type: "string" }, name: { type: ["string", "null"] }, display_hours: { type: ["string", "null"] } } + }, + description: "Every menu the store offers (for example All Day, Lunch), each usable as menuId." + }, + menus_truncated: { type: "boolean", description: "True when the store lists more than 20 menus." } + } + }], + description: "Null for convenience stores." + } + } + }, + { + type: "object", additionalProperties: false, required: ["status", "error_kind", "error_message"], + properties: { + status: { const: "error" }, + error_kind: { enum: ["invalid_input", "wrong_page", "not_found", "login_required", "challenge", "rate_limited", "site_changed", "site_error"] }, + error_message: { type: "string", minLength: 1 } + } + } + ] + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, untrustedContentHint: true } + }, + execute: async (input, context) => { + const ORIGIN = "https://www.doordash.com"; + const QUERY = "query storepageFeed($storeId: ID!, $fulfillmentType: FulfillmentType, $isMerchantPreview: Boolean, $entryPoint: StoreEntryPoint) { storepageFeed(storeId: $storeId, fulfillmentType: $fulfillmentType, isMerchantPreview: $isMerchantPreview, entryPoint: $entryPoint) { storeHeader { id name description timezone priceRange priceRangeDisplayString offersDelivery offersPickup offersGroupOrder offersCatering offersScheduling isConvenience isDashpassPartner address { lat lng city state street } businessTags { name } ratings { numRatings numRatingsDisplayString averageRating isNewlyAdded } distanceFromConsumer { value label } deliveryFeeLayout { title subtitle isSurging displayDeliveryFee } asapStatus { displayUnavailableReason { title } } status { delivery { isAvailable minutes unavailableReason isTooFarFromConsumer } pickup { isAvailable minutes unavailableReason } } } mxInfo { operationInfo { operationStatusInfo { description operationStatus } storeOperationHourInfo { operationSchedule { dayOfWeek timeSlotList } } doordashOperationHourInfo { operationSchedule { dayOfWeek timeSlotList } } } } menuBook { id name displayOpenHours menuList { id name displayOpenHours } } } }"; + const NOTE = "Delivery availability, estimates and distance are computed by DoorDash for the delivery address selected in this browser session. With no address selected they describe a placeholder location, not the customer, and are withheld. This tool never changes the address."; + const CHALLENGE = /just a moment|challenge-platform|cf-chl|captcha|turnstile|verify (?:that )?you(?:'re| are) (?:a )?human|access denied|px-captcha/i; + const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); + const text = (value, max = 300) => typeof value === "string" && value.trim() ? value.trim().slice(0, max) : null; + const id = value => (typeof value === "string" || typeof value === "number") && String(value).trim() ? String(value).trim().slice(0, 30) : null; + const bool = value => typeof value === "boolean" ? value : null; + const number = value => typeof value === "number" && isFinite(value) ? value : (typeof value === "string" && value.trim() && isFinite(Number(value)) ? Number(value) : null); + const days = info => { + const schedule = info && Array.isArray(info.operationSchedule) ? info.operationSchedule : []; + return schedule.filter(d => d && text(d.dayOfWeek, 20)).slice(0, 7).map(d => ({ + day: text(d.dayOfWeek, 20), + slots: (Array.isArray(d.timeSlotList) ? d.timeSlotList : []).map(s => text(s, 60)).filter(Boolean).slice(0, 6) + })); + }; + + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => k !== "store_id") || typeof input.store_id !== "string" || !/^[0-9]{1,12}$/.test(input.store_id)) { + return fail("invalid_input", "store_id must be a string of 1 to 12 digits and is the only accepted field. No request was made."); + } + if (location.origin !== ORIGIN) return fail("wrong_page", "Open a https://www.doordash.com/ page in this tab, then call again. Nothing was changed."); + const storeId = input.store_id; + const csrf = (document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("csrf_token=")) || "").slice("csrf_token=".length); + const signals = [AbortSignal.timeout(25000)]; + if (context && context.signal) signals.push(context.signal); + + let response; + let body; + try { + response = await fetch("/graphql/storepageFeed?operation=storepageFeed", { + method: "POST", credentials: "include", signal: AbortSignal.any(signals), + headers: { "content-type": "application/json", accept: "*/*", "x-csrftoken": csrf, "x-channel-id": "marketplace", "x-experience-id": "doordash" }, + body: JSON.stringify({ + operationName: "storepageFeed", + variables: { storeId, fulfillmentType: "Delivery", isMerchantPreview: false, entryPoint: "External" }, + query: QUERY + }) + }); + body = await response.text(); + } catch (error) { + return fail("site_error", (error && error.name === "TimeoutError" ? "DoorDash did not answer within 25 seconds." : "The request to DoorDash failed or was cancelled.") + " Nothing was changed."); + } + if (response.status === 429) return fail("rate_limited", "DoorDash is rate limiting requests from this browser; wait before calling again and do not retry automatically."); + if (response.status === 401) return fail("login_required", "DoorDash refused the request as unauthenticated (HTTP 401). Sign in to DoorDash in this browser and call again."); + let json = null; + try { json = JSON.parse(body); } catch { /* not JSON */ } + if (!json) { + if (CHALLENGE.test(body.slice(0, 4000))) return fail("challenge", "DoorDash is showing a bot check in this browser (HTTP " + response.status + "). A person has to pass it on doordash.com; do not retry automatically."); + return fail("site_error", "DoorDash answered with HTTP " + response.status + " and a response that is not JSON."); + } + if (Array.isArray(json.errors) && json.errors.length) { + const errors = json.errors.map(e => (e && typeof e.message === "string" ? e.message : "") + " " + ((e && e.extensions && e.extensions.code) || "")); + const joined = errors.join(" | "); + if (/not_found|no stores returned/i.test(joined)) return fail("not_found", "DoorDash has no store with id " + storeId + "."); + if (/unauthenticated|unauthorized|not logged in|login required/i.test(joined)) return fail("login_required", "DoorDash requires a signed-in account for this request. Sign in in this browser and call again."); + if (/graphql_validation_failed|cannot query field|unknown (?:type|argument)|bad_user_input|variable "/i.test(joined)) return fail("site_changed", "DoorDash rejected the store query as invalid, so its schema has probably changed: " + joined.slice(0, 200)); + return fail("site_error", "DoorDash returned an error for this store: " + joined.slice(0, 200)); + } + if (!response.ok) return fail("site_error", "DoorDash answered with HTTP " + response.status + "."); + const feed = json.data && json.data.storepageFeed; + const header = feed && feed.storeHeader; + if (!header || typeof header !== "object") return fail("site_changed", "DoorDash's answer has no store header, so its response format has probably changed."); + if (!text(header.name)) return fail("not_found", "DoorDash returned an empty store for id " + storeId + "."); + if (header.id !== undefined && header.id !== null && String(header.id) !== storeId) return fail("site_changed", "DoorDash answered with a different store than requested."); + + const operation = feed.mxInfo && feed.mxInfo.operationInfo || {}; + const status = header.status || {}; + const delivery = status.delivery || {}; + const pickup = status.pickup || {}; + const fee = header.deliveryFeeLayout || {}; + const convenience = header.isConvenience === true; + const menuBook = convenience ? null : feed.menuBook; + const menuList = menuBook && Array.isArray(menuBook.menuList) ? menuBook.menuList.filter(m => m && id(m.id)) : []; + const distance = header.distanceFromConsumer; + const addressButton = document.querySelector("[data-testid='addressTextButton']"); + const addressText = addressButton ? (addressButton.innerText || "").trim() : ""; + const addressSelected = addressText ? addressText !== "Your Address" : null; + // With no address selected DoorDash answers for a placeholder location, so its delivery figures are withheld. + const withheld = addressSelected === false; + const storeAddress = header.address || {}; + + return { + status: "ok", + store_id: storeId, + name: text(header.name, 200), + url: ORIGIN + (convenience ? "/convenience" : "") + "/store/" + storeId + "/", + store_type: convenience ? "convenience" : "restaurant", + description: text(header.description), + cuisines: (Array.isArray(header.businessTags) ? header.businessTags : []).map(t => text(t && t.name, 60)).filter(Boolean).slice(0, 10), + price_range: { level: Number.isInteger(header.priceRange) && header.priceRange >= 1 && header.priceRange <= 4 ? header.priceRange : null, text: text(header.priceRangeDisplayString, 10) }, + rating: { + average: number(header.ratings && header.ratings.averageRating), + count: Number.isInteger(header.ratings && header.ratings.numRatings) ? header.ratings.numRatings : null, + count_text: text(header.ratings && header.ratings.numRatingsDisplayString, 30), + is_new: bool(header.ratings && header.ratings.isNewlyAdded) + }, + address: { street: text(storeAddress.street, 200), city: text(storeAddress.city, 100), state: text(storeAddress.state, 50), latitude: number(storeAddress.lat), longitude: number(storeAddress.lng) }, + timezone: text(header.timezone, 60), + offers: { + delivery: bool(header.offersDelivery), pickup: bool(header.offersPickup), group_order: bool(header.offersGroupOrder), + catering: bool(header.offersCatering), scheduling: bool(header.offersScheduling), dashpass_partner: bool(header.isDashpassPartner) + }, + operation_status: text(operation.operationStatusInfo && operation.operationStatusInfo.operationStatus, 40), + operation_text: text(operation.operationStatusInfo && operation.operationStatusInfo.description), + hours: { store: days(operation.storeOperationHourInfo), doordash_orders: days(operation.doordashOperationHourInfo) }, + delivery: { + available: withheld ? null : bool(delivery.isAvailable), + unavailable_reason: withheld ? null : text(delivery.unavailableReason, 100), + unavailable_message: withheld ? null : text(header.asapStatus && header.asapStatus.displayUnavailableReason && header.asapStatus.displayUnavailableReason.title), + estimate_minutes: !withheld && delivery.isAvailable === true ? text(delivery.minutes, 30) : null, + fee_display: { title: text(fee.title, 100), subtitle: text(fee.subtitle, 100), text: text(fee.displayDeliveryFee, 100), is_surging: bool(fee.isSurging) } + }, + pickup: { + available: bool(pickup.isAvailable), + unavailable_reason: text(pickup.unavailableReason, 100), + estimate_minutes: pickup.isAvailable === true ? text(pickup.minutes, 30) : null + }, + delivery_context: { + basis: addressSelected === true ? "selected_address" : withheld ? "no_address_selected" : "unknown", + address_selected: addressSelected, + signed_in: /(?:^|;\s*)dd_cx_logged_in=true(?:;|$)/.test(document.cookie), + distance: !withheld && distance && number(distance.value) !== null && text(distance.label, 10) ? { value: number(distance.value), unit: text(distance.label, 10) } : null, + too_far_from_consumer: withheld ? null : bool(delivery.isTooFarFromConsumer), + note: NOTE + }, + menu: menuBook ? { + menu_id: id(menuBook.id), + name: text(menuBook.name, 100), + display_hours: text(menuBook.displayOpenHours, 60), + menus: menuList.slice(0, 20).map(m => ({ menu_id: id(m.id), name: text(m.name, 100), display_hours: text(m.displayOpenHours, 60) })), + menus_truncated: menuList.length > 20 + } : null + }; + } +}] diff --git a/registry.json b/registry.json index 6ea1c9e8..bdc1dc78 100644 --- a/registry.json +++ b/registry.json @@ -113,6 +113,19 @@ "last_verified": null, "notes": "Back to draft after a source change on 2026-09-29: airport boxes are now cleared through the page and typed with the inlined lib/input.js cadence, and the typed code is checked before choosing (fixes 'CVCVG' on macOS Chrome, where CDP Meta+A does not select all). First successful full live invocation: CVG-SFO one way, 1 adult, regular Chrome 154 on macOS via the xwebmcp extension, status ok with first-page flights. Needs fresh independent QA; earlier qa_passed sign-off (agt_b189571b6d300a05921c) covered the previous source." }, + { + "domain": "doordash.com", + "tool": "get_store", + "file": "domains/doordash.com/get_store.js", + "kind": "page", + "namespace": "doordash.com", + "url_patterns": [ + "https://www.doordash.com/*" + ], + "status": "draft", + "last_verified": null, + "notes": "Page tool. Store details the site's native menu tools lack: hours, address, service modes, open status, and delivery and pickup availability with estimate range and the site's own fee strings, plus the menus with the menu_id the native add_to_cart needs. Delivery figures are only meaningful for a selected address, so the output states its basis (selected address, none selected, or unknown) and withholds DoorDash's placeholder-location delivery answer when no address is selected; it never selects or changes an address and never returns address text. One same-origin read of the site's storepageFeed query, no navigation; works signed out. Author live checks 2026-09-29 on the Kernel runtime; awaiting independent QA." + }, { "domain": "doordash.com", "tool": "login", diff --git a/tests/doordash-get-store.test.mjs b/tests/doordash-get-store.test.mjs new file mode 100644 index 00000000..b0791245 --- /dev/null +++ b/tests/doordash-get-store.test.mjs @@ -0,0 +1,373 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; + +// Fixture tests for doordash.com/get_store. The page (location, document, fetch) is stubbed; +// nothing here contacts the live site and every store, id, address and cookie is fabricated. +const source = readFileSync(new URL("../domains/doordash.com/get_store.js", import.meta.url), "utf8"); +const registry = JSON.parse(readFileSync(new URL("../registry.json", import.meta.url), "utf8")); +const plain = value => JSON.parse(JSON.stringify(value)); +const HOME = "https://www.doordash.com"; +const HEADER_STREET = "1 Fabricated Header Street, Apt 9"; + +function conforms(schema, value) { + if (schema.anyOf && !schema.anyOf.some(s => conforms(s, value))) return false; + if (schema.oneOf && schema.oneOf.filter(s => conforms(s, value)).length !== 1) return false; + if ("const" in schema && value !== schema.const) return false; + if (schema.enum && !schema.enum.includes(value)) return false; + if (schema.type && ![schema.type].flat().some(type => { + if (type === "null") return value === null; + if (type === "integer") return Number.isInteger(value); + if (type === "array") return Array.isArray(value); + if (type === "object") return value !== null && typeof value === "object" && !Array.isArray(value); + return typeof value === type; + })) return false; + if (Array.isArray(value)) { + if (schema.maxItems !== undefined && value.length > schema.maxItems) return false; + if (schema.items && !value.every(item => conforms(schema.items, item))) return false; + } + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + if (schema.required?.some(key => !Object.hasOwn(value, key))) return false; + const props = schema.properties || {}; + if (Object.entries(props).some(([key, s]) => key in value && !conforms(s, value[key]))) return false; + if (schema.additionalProperties === false && Object.keys(value).some(key => !(key in props))) return false; + } + if (typeof value === "string") { + if (value.length < (schema.minLength ?? 0) || value.length > (schema.maxLength ?? Infinity)) return false; + if (schema.pattern && !new RegExp(schema.pattern).test(value)) return false; + } + if (typeof value === "number" && (value < (schema.minimum ?? -Infinity) || value > (schema.maximum ?? Infinity))) return false; + return true; +} + +// --- fabricated site data ------------------------------------------------------------------------ + +const week = (slot, only) => ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"].slice(0, only ?? 7) + .map(dayOfWeek => ({ dayOfWeek, timeSlotList: [slot] })); + +const feed = (overrides = {}) => ({ + storeHeader: { + id: "1001", name: "Fabricated Noodle House", description: "Noodles", timezone: "US/Eastern", priceRange: 2, priceRangeDisplayString: "$$", + offersDelivery: true, offersPickup: true, offersGroupOrder: true, offersCatering: false, offersScheduling: true, isConvenience: false, isDashpassPartner: true, + address: { lat: "40.5", lng: "-73.5", city: "Testville", state: "NY", street: "5 Test Road" }, + businessTags: [{ name: "Noodles" }, { name: "Asian" }], + ratings: { numRatings: 1234, numRatingsDisplayString: "(1k+)", averageRating: 4.6, isNewlyAdded: false }, + distanceFromConsumer: { value: 2.5, label: "mi" }, + deliveryFeeLayout: { title: "$0 delivery fee on $12+", subtitle: "pricing & fees", isSurging: false, displayDeliveryFee: "$0 delivery fee over $12" }, + asapStatus: { displayUnavailableReason: { title: "" } }, + status: { + delivery: { isAvailable: true, minutes: "27 - 37", unavailableReason: "", isTooFarFromConsumer: false }, + pickup: { isAvailable: true, minutes: "19 - 29", unavailableReason: "" } + }, + ...overrides.storeHeader + }, + mxInfo: { + operationInfo: { + operationStatusInfo: { description: "Accepting DoorDash orders until 9 PM", operationStatus: "OPEN" }, + storeOperationHourInfo: { operationSchedule: week("10 AM - 10 PM") }, + doordashOperationHourInfo: { operationSchedule: week("10 AM - 9 PM") } + } + }, + menuBook: { id: "5001", name: "All Day", displayOpenHours: "10:00 AM - 9:00 PM", menuList: [{ id: "5001", name: "All Day", displayOpenHours: "10:00 AM - 9:00 PM" }, { id: "5002", name: "Lunch", displayOpenHours: "11:00 AM - 3:00 PM" }] }, + ...overrides.rest +}); +const ok = data => ({ status: 200, json: { data: { storepageFeed: data } } }); + +function harness(options = {}) { + const calls = []; + const responder = options.respond ?? (() => ok(feed())); + const stubFetch = async (url, init) => { + calls.push({ url, init: { ...init, body: init.body ? JSON.parse(init.body) : undefined } }); + if (options.fetchError) throw options.fetchError; + const reply = responder(url, init); + const body = reply.body ?? JSON.stringify(reply.json); + return { status: reply.status, ok: reply.status >= 200 && reply.status < 300, text: async () => body }; + }; + const button = options.button === undefined ? { innerText: HEADER_STREET } : options.button; + const context = { + location: { origin: options.origin ?? HOME, pathname: "/home" }, + document: { + cookie: options.cookie ?? "a=1; csrf_token=fabricated-csrf; dd_cx_logged_in=true", + querySelector: selector => selector === "[data-testid='addressTextButton']" ? button : null + }, + fetch: stubFetch, AbortSignal, JSON, Object, Array, Number, String, Boolean, Math, Promise, Error, RegExp, isFinite + }; + const [definition] = runInNewContext(`(${source})`, context); + return { definition, calls, run: (input, signal) => definition.execute(input, signal ? { signal } : undefined) }; +} +const validOutput = (definition, value) => conforms(definition.tool.outputSchema, value); + +// --- contract ------------------------------------------------------------------------------------ + +test("single page definition, narrowed to www, agrees with the registry", () => { + const { definition } = harness(); + assert.equal(definition.kind, "page"); + assert.equal(definition.tool.name, "get_store"); + assert.deepEqual(plain(definition.match.url_patterns), ["https://www.doordash.com/*"]); + const entry = registry.tools.find(t => t.domain === "doordash.com" && t.tool === "get_store"); + assert.ok(entry, "registry entry exists"); + assert.equal(entry.kind, "page"); + assert.deepEqual(entry.url_patterns, plain(definition.match.url_patterns)); + assert.equal(entry.file, "domains/doordash.com/get_store.js"); + assert.equal(definition.tool.annotations.readOnlyHint, true); + assert.ok(definition.tool.outputSchema && definition.tool.inputSchema); +}); + +test("source has no import, require, Node globals or trailing semicolon, and execute is self-contained", () => { + assert.doesNotMatch(source, /\brequire\s*\(|\bimport\s|\bBuffer\b|\bprocess\./); + assert.doesNotMatch(source.trimEnd(), /;$/); + // The runtime re-creates a page tool's execute in the page from its source text alone. + const { definition } = harness(); + const stubs = { location: { origin: HOME }, document: { cookie: "", querySelector: () => null }, fetch: async () => ({ status: 500, ok: false, text: async () => "" }), AbortSignal }; + const recreated = runInNewContext(`(${definition.execute.toString()})`, stubs); + assert.equal(typeof recreated, "function"); +}); + +// --- success paths ------------------------------------------------------------------------------- + +test("restaurant: maps the allowlisted fields and issues exactly one read", async () => { + const { definition, calls, run } = harness(); + const out = plain(await run({ store_id: "1001" })); + assert.ok(validOutput(definition, out), JSON.stringify(out)); + assert.equal(out.status, "ok"); + assert.equal(out.store_id, "1001"); + assert.equal(out.url, "https://www.doordash.com/store/1001/"); + assert.equal(out.store_type, "restaurant"); + assert.deepEqual(out.cuisines, ["Noodles", "Asian"]); + assert.deepEqual(out.price_range, { level: 2, text: "$$" }); + assert.deepEqual(out.rating, { average: 4.6, count: 1234, count_text: "(1k+)", is_new: false }); + assert.deepEqual(out.address, { street: "5 Test Road", city: "Testville", state: "NY", latitude: 40.5, longitude: -73.5 }); + assert.equal(out.offers.dashpass_partner, true); + assert.equal(out.operation_status, "OPEN"); + assert.equal(out.hours.store.length, 7); + assert.deepEqual(out.hours.doordash_orders[0], { day: "MONDAY", slots: ["10 AM - 9 PM"] }); + assert.equal(out.delivery.available, true); + assert.equal(out.delivery.estimate_minutes, "27 - 37"); + assert.deepEqual(out.delivery.fee_display, { title: "$0 delivery fee on $12+", subtitle: "pricing & fees", text: "$0 delivery fee over $12", is_surging: false }); + assert.equal(out.pickup.estimate_minutes, "19 - 29"); + assert.deepEqual(out.menu.menus.map(m => m.menu_id), ["5001", "5002"]); + assert.equal(out.menu.menu_id, "5001"); + assert.equal(out.menu.menus_truncated, false); + + assert.equal(calls.length, 1); + const [call] = calls; + assert.equal(call.url, "/graphql/storepageFeed?operation=storepageFeed"); + assert.equal(call.init.method, "POST"); + assert.equal(call.init.credentials, "include"); + assert.equal(call.init.headers["x-csrftoken"], "fabricated-csrf"); + assert.equal(call.init.headers["x-channel-id"], "marketplace"); + assert.deepEqual(call.init.body.variables, { storeId: "1001", fulfillmentType: "Delivery", isMerchantPreview: false, entryPoint: "External" }); + assert.match(call.init.body.query, /^query storepageFeed\(/); + assert.doesNotMatch(call.init.body.query, /mutation/i); +}); + +test("signed out: empty csrf header, signed_in false", async () => { + const { calls, run } = harness({ cookie: "a=1" }); + const out = plain(await run({ store_id: "1001" })); + assert.equal(calls[0].init.headers["x-csrftoken"], ""); + assert.equal(out.delivery_context.signed_in, false); +}); + +const farStore = () => ok(feed({ storeHeader: { + distanceFromConsumer: { value: 5385, label: "mi" }, + asapStatus: { displayUnavailableReason: { title: "Your address is not in this store's delivery area" } }, + status: { + delivery: { isAvailable: false, minutes: "51 - 61", unavailableReason: "Too far away", isTooFarFromConsumer: true }, + pickup: { isAvailable: false, minutes: "6 - 16", unavailableReason: "" } + } +} })); + +test("selected address, store out of range: the reason is reported and no estimate is invented", async () => { + const { definition, run } = harness({ respond: farStore }); + const out = plain(await run({ store_id: "1001" })); + assert.ok(validOutput(definition, out), JSON.stringify(out)); + assert.equal(out.delivery_context.basis, "selected_address"); + assert.equal(out.delivery.available, false); + assert.equal(out.delivery.estimate_minutes, null); + assert.equal(out.delivery.unavailable_reason, "Too far away"); + assert.equal(out.delivery.unavailable_message, "Your address is not in this store's delivery area"); + assert.equal(out.pickup.available, false); + assert.equal(out.pickup.estimate_minutes, null); + assert.deepEqual(out.delivery_context.distance, { value: 5385, unit: "mi" }); + assert.equal(out.delivery_context.too_far_from_consumer, true); + assert.match(out.delivery_context.note, /never changes the address/); +}); + +test("no address selected: the placeholder-location delivery figures are withheld, pickup and store data are not", async () => { + const { definition, run } = harness({ button: { innerText: "Your Address" }, respond: farStore }); + const out = plain(await run({ store_id: "1001" })); + assert.ok(validOutput(definition, out), JSON.stringify(out)); + assert.equal(out.delivery_context.basis, "no_address_selected"); + assert.equal(out.delivery_context.address_selected, false); + assert.deepEqual(out.delivery, { available: null, unavailable_reason: null, unavailable_message: null, estimate_minutes: null, fee_display: out.delivery.fee_display }); + assert.equal(out.delivery_context.distance, null); + assert.equal(out.delivery_context.too_far_from_consumer, null); + assert.equal(out.name, "Fabricated Noodle House"); + assert.equal(out.operation_status, "OPEN"); + assert.equal(out.hours.store.length, 7); + assert.equal(out.delivery.fee_display.text, "$0 delivery fee over $12"); + assert.doesNotMatch(JSON.stringify(out), /Too far away|5385/); + assert.match(out.delivery_context.note, /withheld/); + + const available = harness({ button: { innerText: "Your Address" } }); + const open = plain(await available.run({ store_id: "1001" })); + assert.equal(open.delivery.available, null, "an 'available' answer for a placeholder location is withheld too"); + assert.equal(open.delivery.estimate_minutes, null); + assert.equal(open.pickup.estimate_minutes, "19 - 29"); +}); + +test("unknown basis (no header address control): DoorDash's figures pass through, labelled unverified", async () => { + const { definition, run } = harness({ button: null, respond: farStore }); + const out = plain(await run({ store_id: "1001" })); + assert.ok(validOutput(definition, out), JSON.stringify(out)); + assert.equal(out.delivery_context.basis, "unknown"); + assert.equal(out.delivery_context.address_selected, null); + assert.equal(out.delivery.available, false); + assert.equal(out.delivery.unavailable_reason, "Too far away"); + assert.deepEqual(out.delivery_context.distance, { value: 5385, unit: "mi" }); +}); + +test("address_selected reads the header control and never returns its text", async () => { + const selected = harness(); + const withAddress = plain(await selected.run({ store_id: "1001" })); + assert.equal(withAddress.delivery_context.address_selected, true); + assert.equal(withAddress.delivery_context.basis, "selected_address"); + assert.ok(!JSON.stringify(withAddress).includes("Fabricated Header Street")); + + assert.equal(plain(await harness({ button: { innerText: "Your Address" } }).run({ store_id: "1001" })).delivery_context.address_selected, false); + assert.equal(plain(await harness({ button: null }).run({ store_id: "1001" })).delivery_context.address_selected, null); + assert.equal(plain(await harness({ button: { innerText: " " } }).run({ store_id: "1001" })).delivery_context.address_selected, null); +}); + +test("convenience store: header details, canonical convenience URL, no menu", async () => { + const { definition, run } = harness({ respond: () => ok(feed({ storeHeader: { id: "2002", name: "Fabricated Market", isConvenience: true }, rest: { menuBook: null } })) }); + const out = plain(await run({ store_id: "2002" })); + assert.ok(validOutput(definition, out), JSON.stringify(out)); + assert.equal(out.store_type, "convenience"); + assert.equal(out.url, "https://www.doordash.com/convenience/store/2002/"); + assert.equal(out.menu, null); +}); + +test("optional and missing fields become nulls, not errors", async () => { + const { definition, run } = harness({ + respond: () => ok({ + storeHeader: { id: "1001", name: "Sparse Store", status: {} }, + mxInfo: null, menuBook: { id: null, menuList: [] } + }) + }); + const out = plain(await run({ store_id: "1001" })); + assert.ok(validOutput(definition, out), JSON.stringify(out)); + assert.equal(out.rating.average, null); + assert.equal(out.operation_status, null); + assert.deepEqual(out.hours, { store: [], doordash_orders: [] }); + assert.equal(out.delivery.available, null); + assert.equal(out.delivery_context.distance, null); + assert.equal(out.delivery_context.too_far_from_consumer, null); + assert.deepEqual(out.menu.menus, []); + assert.equal(out.menu.menu_id, null); +}); + +test("output is bounded: menus, cuisines, hours slots and long strings are capped", async () => { + const many = Array.from({ length: 30 }, (_, i) => ({ id: String(9000 + i), name: "Menu " + i, displayOpenHours: "All day" })); + const { definition, run } = harness({ + respond: () => ok(feed({ + storeHeader: { description: "x".repeat(5000), businessTags: Array.from({ length: 25 }, (_, i) => ({ name: "Tag " + i })) }, + rest: { + menuBook: { id: "9000", name: "Big", displayOpenHours: "All day", menuList: many }, + mxInfo: { operationInfo: { storeOperationHourInfo: { operationSchedule: [...week("a"), ...week("b")] }, doordashOperationHourInfo: { operationSchedule: [{ dayOfWeek: "MONDAY", timeSlotList: Array.from({ length: 12 }, (_, i) => "slot " + i) }] } } } + } + })) + }); + const out = plain(await run({ store_id: "1001" })); + assert.ok(validOutput(definition, out), JSON.stringify(out).slice(0, 300)); + assert.equal(out.menu.menus.length, 20); + assert.equal(out.menu.menus_truncated, true); + assert.equal(out.cuisines.length, 10); + assert.equal(out.description.length, 300); + assert.equal(out.hours.store.length, 7); + assert.equal(out.hours.doordash_orders[0].slots.length, 6); + assert.ok(JSON.stringify(out).length < 20000); +}); + +// --- failures ------------------------------------------------------------------------------------ + +test("malformed input is rejected before any request", async () => { + const { definition, calls, run } = harness(); + for (const input of [undefined, null, [], "1001", {}, { store_id: 1001 }, { store_id: "" }, { store_id: "abc" }, { store_id: "12 34" }, { store_id: "-1" }, { store_id: "1234567890123" }, { store_id: "1001", extra: true }]) { + const out = plain(await run(input)); + assert.equal(out.status, "error", JSON.stringify(input)); + assert.equal(out.error_kind, "invalid_input"); + assert.ok(validOutput(definition, out)); + } + assert.equal(calls.length, 0); +}); + +test("a page outside www.doordash.com is refused without a request", async () => { + const { definition, calls, run } = harness({ origin: "https://help.doordash.com" }); + const out = plain(await run({ store_id: "1001" })); + assert.equal(out.error_kind, "wrong_page"); + assert.ok(validOutput(definition, out)); + assert.equal(calls.length, 0); +}); + +test("unknown store ids are not_found, whether DoorDash errors or returns an empty header", async () => { + const errored = harness({ respond: () => ({ status: 200, json: { data: { storepageFeed: null }, errors: [{ message: "5 NOT_FOUND: no stores returned", extensions: { code: "INTERNAL_SERVER_ERROR" } }] } }) }); + const a = plain(await errored.run({ store_id: "99999999" })); + assert.equal(a.error_kind, "not_found"); + assert.ok(validOutput(errored.definition, a)); + const empty = harness({ respond: () => ok(feed({ storeHeader: { id: "1", name: "" } })) }); + assert.equal(plain(await empty.run({ store_id: "1" })).error_kind, "not_found"); +}); + +test("permission, rate-limit, challenge, site-change and generic failures are distinct errors", async () => { + const cases = [ + ["HTTP 401", { status: 401, body: "{}" }, "login_required"], + ["unauthenticated GraphQL error", { status: 200, json: { data: null, errors: [{ message: "unauthenticated request", extensions: { code: "UNAUTHENTICATED" } }] } }, "login_required"], + ["HTTP 429", { status: 429, body: "slow down" }, "rate_limited"], + ["challenge page", { status: 403, body: "Just a moment...
" }, "challenge"], + ["challenge page with HTTP 200", { status: 200, body: "Please verify you are human. captcha" }, "challenge"], + ["non-JSON without a challenge", { status: 502, body: "Bad gateway" }, "site_error"], + ["schema change", { status: 200, json: { errors: [{ message: "Cannot query field \"storeHeader\" on type \"Feed\"", extensions: { code: "GRAPHQL_VALIDATION_FAILED" } }] } }, "site_changed"], + ["missing store header", { status: 200, json: { data: { storepageFeed: { mxInfo: {} } } } }, "site_changed"], + ["null feed without errors", { status: 200, json: { data: { storepageFeed: null } } }, "site_changed"], + ["another store's data", ok(feed({ storeHeader: { id: "7777" } })), "site_changed"], + ["server error JSON", { status: 500, json: {} }, "site_error"], + ["other GraphQL error", { status: 200, json: { errors: [{ message: "internal failure" }] } }, "site_error"] + ]; + for (const [label, reply, kind] of cases) { + const { definition, calls, run } = harness({ respond: () => reply }); + const out = plain(await run({ store_id: "1001" })); + assert.equal(out.status, "error", label); + assert.equal(out.error_kind, kind, label + ": " + JSON.stringify(out)); + assert.ok(out.error_message.length > 0); + assert.ok(validOutput(definition, out), label); + assert.equal(calls.length, 1, label + " must not retry"); + } +}); + +test("network failure and timeout are site_error and are not retried", async () => { + const failed = harness({ fetchError: new TypeError("Failed to fetch") }); + const a = plain(await failed.run({ store_id: "1001" })); + assert.equal(a.error_kind, "site_error"); + assert.equal(failed.calls.length, 1); + const timedOut = harness({ fetchError: Object.assign(new Error("timed out"), { name: "TimeoutError" }) }); + assert.match(plain(await timedOut.run({ store_id: "1001" })).error_message, /25 seconds/); +}); + +test("the caller's abort signal is passed to the request", async () => { + const controller = new AbortController(); + const { calls, run } = harness(); + await run({ store_id: "1001" }, controller.signal); + assert.ok(calls[0].init.signal instanceof AbortSignal); + controller.abort(); + assert.equal(calls[0].init.signal.aborted, true); +}); + +test("the tool never navigates, writes, or reads anything but the header control and two cookies", async () => { + const { calls, run } = harness(); + await run({ store_id: "1001" }); + assert.equal(calls.length, 1); + assert.match(calls[0].init.body.query, /^query /); + assert.doesNotMatch(source, /location\.(assign|replace|href\s*=)|history\.|localStorage|sessionStorage|\.click\(|dispatchEvent|\.submit\(/); +}); From eda32a4c07b52355efe1edfdc384ef55a0224fe4 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:33:19 +0000 Subject: [PATCH 02/13] Recognise a selected address positively and withhold delivery figures otherwise --- domains/doordash.com/README.md | 123 ++++++++++++++++++++---------- domains/doordash.com/get_store.js | 56 ++++++-------- registry.json | 2 +- tests/doordash-get-store.test.mjs | 81 ++++++++++++++------ 4 files changed, 165 insertions(+), 97 deletions(-) diff --git a/domains/doordash.com/README.md b/domains/doordash.com/README.md index 0da04bc6..ffe78fe7 100644 --- a/domains/doordash.com/README.md +++ b/domains/doordash.com/README.md @@ -5,7 +5,7 @@ Tools for DoorDash (https://www.doordash.com). | tool | file | status | notes | |------|------|--------|-------| | login | [login.js](login.js) | qa_passed | Email + password sign-in with a two-step guided flow; existing sessions verified against the profile page; `otp` mode for the 2-Step Verification modal | -| get_store | [get_store.js](get_store.js) | draft | Page tool: store hours, service modes, delivery and pickup availability with estimates and fee strings, and menu ids, with the delivery basis (selected address, none selected, unknown) stated explicitly and unreliable delivery figures withheld; never changes the address | +| get_store | [get_store.js](get_store.js) | draft | Page tool: store hours, service modes, delivery and pickup availability with estimates and fee strings, and menu ids, with the delivery basis (selected address, none selected, unknown) stated explicitly and delivery figures returned only for a selected address; never changes the address | ## Native WebMCP tools @@ -266,7 +266,7 @@ the branch touch only this README and the registry. ## get_store -`get_store({ store_id })` reads one DoorDash store and reports the things the native tools do not: +`get_store({ store_id })` reads one DoorDash store (`store_id`: digits without a leading zero) and reports the things the native tools do not: open status and hours, the services the store offers, whether delivery and pickup can be ordered **right now**, an estimate range only when the site marks the mode available, the site's own delivery-fee strings, and the store's menus, together with the delivery basis those figures rest on. It never returns menu items @@ -281,36 +281,45 @@ surface (native `get_convenience_store`, `search_retail_products`). ### Delivery context -DoorDash computes delivery availability, estimate and distance for the delivery address selected in -the session, not for the store alone. With no address selected it does **not** use the egress IP: on +DoorDash computes delivery availability and estimate for the delivery address selected in the +session, not for the store alone. With no address selected it does **not** use the egress IP: on 2026-09-29 two guest browsers, one whose `getGeoByIP` answered with a point in the middle of the United States and one behind a New York residential proxy whose `getGeoByIP` answered with the Bronx, -got the identical distance to the same New York store (5385 mi) and "Too far away" for it. Those figures -describe a placeholder location, not the customer, and they read "Too far away" for every store. The -output says which case applies in `delivery_context.basis`: - -- `selected_address`: the header address control shows an address; delivery `available`, its reason, - `estimate_minutes`, `distance` and `too_far_from_consumer` are DoorDash's answer for that address. -- `no_address_selected`: the header shows the site's "Your Address" placeholder. Delivery `available`, - `unavailable_reason`, `unavailable_message`, `estimate_minutes`, `distance` and `too_far_from_consumer` - are `null` (withheld), because reporting `available: false` there would say the store does not deliver - when nothing is known. Store details, hours, pickup and the fee strings are still returned. -- `unknown`: the page has no header address control (the signed-out landing page and `/home`); the - figures are passed through unverified. Invoke from a store, search, DashPass or grocery page for a - definite basis. +got the identical distance to the same New York store (5385 mi) and "Too far away" for it, and an +independent review saw the same identical values from two other egress locations. Those figures +describe a placeholder location, not the customer, and read "Too far away" for every store. The output +therefore carries `delivery_context.basis` and returns the delivery figures **only** when an address is +positively recognised: + +- `selected_address`: the header address control (`data-testid="addressTextButton"`) shows text that + is not a known placeholder and contains a digit (a street number or ZIP). Delivery `available`, + `unavailable_reason`, `unavailable_message`, `estimate_minutes` and `too_far_from_consumer` are + DoorDash's answer for that address. +- `no_address_selected`: the header shows a known placeholder ("Your Address", "Tu dirección") or, when + the page has no header control, the signed-out landing page's address form + (`data-testid="LandingPageAddressSearch"`) is present. +- `unknown`: anything else, such as a header text in a locale whose placeholder is not recognised and + that has no digit, or a page with neither signal. + +For `no_address_selected` and `unknown` the five delivery fields above are `null` (withheld): reporting +`available: false` would say a store does not deliver when nothing is known. Store details, hours, +pickup and the fee strings are still returned. The placeholder text is localised, so it is matched +narrowly and everything unrecognised falls to `unknown` rather than to `selected_address`. Other fields: -- `delivery_context.address_selected` is the boolean behind `basis` (`data-testid="addressTextButton"` - text other than "Your Address" is `true`). The tool never returns the address text and never opens, - changes or selects an address. -- `delivery_context.signed_in` is from the `dd_cx_logged_in=true` cookie, a hint only; fees and - promotions can differ by account (first-order and DashPass messages). +- `delivery_context.address_selected` is the boolean behind `basis` (`true`, `false`, `null`). The tool + never returns the address text and never opens, changes or selects an address. +- No distance is returned. DoorDash's distance from the customer, together with the store coordinates + in `address`, could be combined across stores to estimate where the customer is. +- `delivery_context.signed_in` is from the `dd_cx_logged_in=true` cookie, a hint only (the cookie can be + stale); fees and promotions can differ by account (first-order and DashPass messages). - `delivery.estimate_minutes` and `pickup.estimate_minutes` are `null` unless that mode is available, because the site sends a range even for a mode it reports unavailable. - `delivery.fee_display` returns the site's `title`, `subtitle` and `text` unparsed; the strings differ by store type ("$0 delivery fee over $12" on a restaurant, "$0.00" plus "delivery fee on $12+" on a convenience store) and are not a quote. They are returned in every basis. +- `rating.average` is `null` for a store with no ratings (the site sends 0). ### Why a page tool @@ -329,8 +338,9 @@ headers `x-csrftoken` (the `csrf_token` cookie when signed in, an empty string s trimmed query for the same operation that selects only the fields listed in the output. The trimmed query was checked live from `/home` and from other store, search, DashPass and grocery pages, returning HTTP 200 and about 3.4 KB per store, in about 0.6 to 0.9 s. Unknown ids return HTTP 200 with a -GraphQL `NOT_FOUND` error and a null feed; some ids (for example a one-digit id) return a header with an -empty name, which the tool also reports as `not_found`. The server-rendered store HTML carries the same +GraphQL `NOT_FOUND` error and a null feed; id 1 returns a header with an empty name, which the tool also +reports as `not_found`. `store_id` must not start with a zero: DoorDash normalises `029417` to `29417`, and id `0` +returns an internal-looking record, so both are rejected as `invalid_input`. The server-rendered store HTML carries the same header, but inside a Next.js payload in which a leading `$` in a string is escaped as `$$` (for example `"$$0 delivery fee"`); it was compared field by field with the GraphQL response and rejected as the source because of that encoding and its 2.5 to 3 MB size. @@ -346,18 +356,21 @@ response is never turned into an empty success, and nothing is retried. ### Live evidence for get_store -Kernel runtime (`kernel` CLI 0.41.0), 2026-09-29, one profile-less stealth browser signed out with a -default egress far from the sampled stores, registering the whole `doordash.com` namespace -(`login` and `get_store`) in one `force_overwrite_namespace` batch: +Kernel runtime (`kernel` CLI 0.41.0), 2026-09-29, profile-less stealth browsers signed out, registering +the whole `doordash.com` namespace (`login` and `get_store`) in one `force_overwrite_namespace` batch. +Results are for the final source of this PR; the findings of the independent review that led to the +current `basis` rules are listed under "Independent QA". | case | result | |---|---| -| store 29417 from a search page, `/home`, its own store page, a DashPass page | `ok`, 0.6 to 0.8 s, no navigation (a marker on `window` survived), output valid under Ajv against the schema the runtime lists | +| stores 29417, 46989, 1227820 from `/`, `/home`, a search page, a store page, a DashPass page | `ok`, 0.6 to 0.9 s, no navigation (a marker on `window` survived), output valid under Ajv against the schema the runtime lists; `basis: no_address_selected` everywhere (landing address form on `/` and `/home`, placeholder text elsewhere); delivery figures `null`, pickup and store data returned | +| `/es-US/`, `/en-CA/` and `/fr-CA/` store pages | header "Tu dirección" and "Your Address" give `no_address_selected`; fr-CA's "Votre adresse" (no digit, not a known placeholder) gives `unknown`; delivery figures `null` in all three | | store 36030759 (convenience) from a grocery tab | `store_type: convenience`, `menu: null`, `pickup.available: false` with `estimate_minutes: null` | +| unrated store 37757465 | `rating.average: null`, `count: 0` | | unknown id 99999999999 and empty-header id 1 | `not_found` | -| eight malformed inputs (`abc`, `{}`, 13 digits, a number, an extra field, a space, `-1`, `""`) | `invalid_input`; CDP network events showed no `storepageFeed` request, while one valid call showed exactly one | +| malformed inputs (`abc`, `{}`, 13 digits, a number, an extra field, a space, `-1`, `""`, `029417`, `0`, padded and newline-terminated ids) | `invalid_input`; CDP network events showed no `storepageFeed` request, while one valid call showed exactly one | | help., merchants., identity.doordash.com | `get_store` is not listed (only `login`); the apex redirects to `www` where it is listed | -| signed out, no address, central-US egress and New York residential egress | identical: `basis: no_address_selected` on search, store, DashPass and grocery pages, delivery figures `null`, pickup and store data returned; on `/home` (no header control) `basis: unknown` and DoorDash's own "Too far away" passed through. The same store read 5385 mi from both egress locations before the figures were withheld | +| two guest egress locations (central US and New York residential) | identical results; before the delivery figures were withheld both showed the same distance (5385 mi) to a New York store | The same calls in the approved signed-in browser (read only; the custom tools were registered for the check and removed afterwards, and no address text or account data was recorded): a store in range of @@ -366,27 +379,57 @@ available with estimate ranges; a store outside that range returned `too_far_fro pickup still available; a convenience store in range returned delivery available and pickup unavailable. Store ids and distances from this account are deliberately not recorded. +### Independent QA + +Round 1, 2026-09-29, agent `agt_e3cd254315c78d609b12` against commit `3488ced` in two profile-less +stealth browsers (one default egress, one New York proxy; both deleted) and in a throwaway local +Chromium 153 with the extension runtime. Static review, the full test suite, live matrix, 57-store +sweep, 71-cookie leakage check, 44 source mutants against the tests. Verdict: defects found, none a +blocker. Findings and what changed: + +| finding | severity | resolution | +|---|---|---| +| `addressText !== "Your Address"` read any non-English placeholder ("Tu dirección" on `/es-US/`) as `selected_address`, so placeholder figures were labelled as a real address's | major | An address is now recognised positively only (text with a digit that is not a known placeholder); known placeholders are `no_address_selected`; everything else is `unknown`. Tests cover es-US, an unrecognised locale, a digit-less address and an empty header. | +| `basis: unknown` on `/` and `/home` passed DoorDash's placeholder figures through as definite values | major (design) | Delivery figures are now withheld for `unknown` as well; only `selected_address` returns them. The signed-out landing address form is recognised as `no_address_selected`. | +| a leading-zero id (`029417`) was accepted and then reported as `site_changed` | minor | The id pattern rejects leading zeros (`invalid_input`); id `0` is rejected too. | +| `rating.average: 0` for unrated stores | minor | `null` when the site reports zero ratings. | +| README and description inaccuracies (single-digit ids, only `OPEN` observed, `signed_in` wording) | minor | Corrected below and in the tool description. | +| seven surviving source mutants (padded ids, `signed_in: true`, the convenience guard, the timeout, the error-text bound) | minor | Tests added; a local re-run of 15 mutants of the new logic and of those gaps found all caught. | +| distance plus the store coordinates could be combined across stores to estimate the customer's location | privacy judgement | The numeric distance is no longer returned or requested. | + +The review also confirmed live that `menu_id` equals the id the native `get_restaurant_menu` reports for +five stores, that six concurrent invocations in one tab succeed, that a document replaced mid-call is +reported by the runtime as `outcome_unknown` (the extension reports an unobservable outcome after 5 s), +and that the delivery placeholder values were identical across the two egress locations. Its synthetic +checks (a selected address, `delivery.available: true`, and the 401, 429, challenge, non-JSON, +site-changed and timeout branches, driven with stubs in throwaway browsers) are not live evidence of real +site responses. + ### Known limitations of get_store -- Draft. No independent QA has signed off; the live checks above are the author's. +- Draft. Round 1 of independent QA found defects that are fixed in the current source; no sign-off has been + recorded, so the status stays `draft`. - Not verified live: `login_required`, `challenge`, `rate_limited` and `site_changed` responses (fixture coverage only, with response shapes assumed from GraphQL conventions and the observed `NOT_FOUND` shape). The `challenge` detection looks only at non-JSON bodies. -- `basis` depends on a header control that is absent on the signed-out landing pages and `/home` (then - `unknown`) and on the placeholder text "Your Address" (English; another locale would read as a selected - address). A guest-entered address (never exercised: the tool and its testing never set one) is assumed to - show as address text, and that a guest address changes DoorDash's figures is unobserved. Signed in without - any saved address is unobserved. That the placeholder location is the same for every guest is inferred from - two egress locations only. +- `basis` rests on the header address control, the landing address form and two localised placeholders + ("Your Address", "Tu dirección"). Other locales fall to `unknown` (fr-CA was seen live), so delivery figures + are missing there even for a guest who has an address without a digit in it. A guest-entered address (never + exercised: the tool and its testing never set one) is assumed to show as text containing a digit, and that a + guest address changes DoorDash's figures is unobserved. Signed in without any saved address is unobserved. That + the placeholder location is the same for every guest was seen from four egress locations in total (two by the + author, two by the reviewer). +- `selected_address` is the only basis with delivery figures, and it was live-verified only in the one signed-in + session described above; `delivery.available: true` for a guest-selected address is not verified. - The delivery estimate and availability are for one moment and one context. `fulfillmentType` is fixed to Delivery in the request; the same response carries the pickup status. Scheduled orders are not modelled. -- `operation_status` is a pass-through string; only `OPEN` was observed. There is deliberately no - `open_now` boolean. +- `operation_status` is a pass-through string: `OPEN` was seen by the author, and `OPEN_CLOSING_SOON` and + `UNAVAILABLE` in the reviewer's 57-store sweep. There is deliberately no `open_now` boolean. - The trimmed query names fields and types taken from the site's own query on 2026-09-29. If DoorDash renames them the tool returns `site_changed`; the query is not a public API. - Loading a store page or this query may register a store view in DoorDash's own analytics, as any visit does. - Only stores DoorDash reports under `/store/` were sampled, plus one convenience store; other store - types (for example shipping-only) are unobserved. Single-digit ids return an empty header rather than an error. + types (for example shipping-only) are unobserved. - Delivery figures depend on the session's address; the egress location did not change them in the two locations tried. Coordinates and street in `address` are the store's own. diff --git a/domains/doordash.com/get_store.js b/domains/doordash.com/get_store.js index 81067f2a..da1140a8 100644 --- a/domains/doordash.com/get_store.js +++ b/domains/doordash.com/get_store.js @@ -3,11 +3,11 @@ match: { url_patterns: ["https://www.doordash.com/*"] }, tool: { name: "get_store", - description: "Get one DoorDash (www.doordash.com) store by store_id (the id from search_restaurants or the number at the end of a doordash.com/store/-/ URL): name, cuisines, price level, rating, address and coordinates, timezone, whether it offers delivery, pickup, group orders, catering and scheduling, open-hours schedules, its menus (menu_id is the menuId that the site's own add_to_cart tool needs), and whether delivery and pickup can be ordered right now with an estimate range and the site's delivery-fee text. Delivery availability, estimate and distance are computed by DoorDash for the delivery address selected in this browser session, not for the store alone, so delivery_context.basis states which case applies: selected_address (figures are for that address), no_address_selected (the figures DoorDash returns then describe a placeholder location, not the customer, and every store reads 'Too far away', so delivery.available, its reason and estimate, distance and too_far_from_consumer are withheld as null), or unknown (the page has no header address control, as on the signed-out landing page and /home; figures are passed through unverified, so open a store, search or DashPass page first for a definite basis). signed_in says whether the account is signed in. This tool never selects or changes an address and never returns address text. It does not return menu items (use the site's get_restaurant_menu on the store page), reviews, or a fee quote: the fee text depends on the address, the account's benefits and the basket. Convenience and grocery stores return their header details with menu null. Read-only; works signed out; runs from any www.doordash.com page without navigating.", + description: "Get one DoorDash (www.doordash.com) store by store_id (the id from search_restaurants or the number at the end of a doordash.com/store/-/ URL, without leading zeros): name, cuisines, price level, rating, the store's own address and coordinates, timezone, whether it offers delivery, pickup, group orders, catering and scheduling, open-hours schedules, its menus (menu_id is the menuId that the site's own add_to_cart tool needs), and whether delivery and pickup can be ordered right now with an estimate range and the site's delivery-fee text. Delivery availability and estimate are computed by DoorDash for the delivery address selected in this browser session, not for the store alone, so delivery_context.basis states which case applies: selected_address (the page header shows an address, so the delivery figures are for that address) or no_address_selected (the header shows the site's placeholder or the signed-out landing page shows its address form: DoorDash then answers for a placeholder location and every store reads 'Too far away') or unknown (the header text is not recognised). Delivery available, its reason and estimate, and too_far_from_consumer are returned only for selected_address and are null otherwise; pickup, hours and store details do not depend on it. signed_in is a cookie-based hint, not proof. This tool never selects or changes an address, never returns address text, and returns no customer distance. It does not return menu items (use the site's get_restaurant_menu on the store page), reviews, or a fee quote: the fee text depends on the address, the account's benefits and the basket. Convenience and grocery stores return their header details with menu null. Read-only; works signed out; runs from any www.doordash.com page without navigating.", inputSchema: { type: "object", additionalProperties: false, required: ["store_id"], properties: { - store_id: { type: "string", pattern: "^[0-9]{1,12}$", description: "DoorDash store id, digits only, e.g. the id field returned by search_restaurants." } + store_id: { type: "string", pattern: "^[1-9][0-9]{0,11}$", description: "DoorDash store id, digits only and without leading zeros, e.g. the id field returned by search_restaurants." } } }, outputSchema: { @@ -18,9 +18,9 @@ required: ["status", "store_id", "name", "url", "store_type", "description", "cuisines", "price_range", "rating", "address", "timezone", "offers", "operation_status", "operation_text", "hours", "delivery", "pickup", "delivery_context", "menu"], properties: { status: { const: "ok" }, - store_id: { type: "string", pattern: "^[0-9]{1,12}$" }, + store_id: { type: "string", pattern: "^[1-9][0-9]{0,11}$" }, name: { type: "string", minLength: 1 }, - url: { type: "string", pattern: "^https://www\\.doordash\\.com/(convenience/)?store/[0-9]+/$", description: "Canonical store URL; the site redirects it to the slugged form." }, + url: { type: "string", pattern: "^https://www\\.doordash\\.com/(convenience/)?store/[1-9][0-9]*/$", description: "Canonical store URL; the site redirects it to the slugged form." }, store_type: { enum: ["restaurant", "convenience"], description: "convenience covers grocery and retail stores, whose catalog is not a menu." }, description: { type: ["string", "null"] }, cuisines: { type: "array", maxItems: 10, items: { type: "string" }, description: "The site's business tags, e.g. Italian, Pizza." }, @@ -59,9 +59,9 @@ type: "object", additionalProperties: false, required: ["available", "unavailable_reason", "unavailable_message", "estimate_minutes", "fee_display"], description: "Delivery to the session's delivery context (see delivery_context).", properties: { - available: { type: ["boolean", "null"], description: "True when DoorDash reports delivery can be ordered now to the selected address. Null when no address is selected (delivery_context.basis no_address_selected) or the site did not say." }, - unavailable_reason: { type: ["string", "null"], description: "The site's reason when not available, e.g. Too far away. Null when withheld." }, - unavailable_message: { type: ["string", "null"], description: "Null when withheld." }, + available: { type: ["boolean", "null"], description: "True when DoorDash reports delivery can be ordered now to the selected address. Null unless delivery_context.basis is selected_address, or when the site did not say." }, + unavailable_reason: { type: ["string", "null"], description: "The site's reason when not available, e.g. Too far away. Null unless the basis is selected_address." }, + unavailable_message: { type: ["string", "null"], description: "Null unless the basis is selected_address." }, estimate_minutes: { type: ["string", "null"], description: "The site's estimate range, e.g. 27 - 37. Null unless delivery is available: the site sends a range even for unavailable delivery." }, fee_display: { type: "object", additionalProperties: false, required: ["title", "subtitle", "text", "is_surging"], @@ -79,19 +79,12 @@ } }, delivery_context: { - type: "object", additionalProperties: false, required: ["basis", "address_selected", "signed_in", "distance", "too_far_from_consumer", "note"], + type: "object", additionalProperties: false, required: ["basis", "address_selected", "signed_in", "too_far_from_consumer", "note"], properties: { - basis: { enum: ["selected_address", "no_address_selected", "unknown"], description: "What the delivery figures describe. selected_address: the address chosen in this browser. no_address_selected: DoorDash used a placeholder location, so delivery available, reason, estimate, distance and too_far_from_consumer are withheld (null). unknown: this page has no header address control; the figures are passed through unverified." }, - address_selected: { type: ["boolean", "null"], description: "True when this document's header shows a delivery address, false when it shows the site's 'Your Address' placeholder, null when this page has no header address control. The address text is never returned." }, - signed_in: { type: "boolean", description: "From the dd_cx_logged_in cookie; a hint only. Fees and promotions can differ by account." }, - distance: { - anyOf: [{ type: "null" }, { - type: "object", additionalProperties: false, required: ["value", "unit"], - properties: { value: { type: "number" }, unit: { type: "string" } } - }], - description: "Distance from the location DoorDash used to the store. Null when withheld (no address selected)." - }, - too_far_from_consumer: { type: ["boolean", "null"], description: "DoorDash's own flag that the store is outside delivery range of that location. Null when withheld." }, + basis: { enum: ["selected_address", "no_address_selected", "unknown"], description: "What the delivery figures describe. selected_address: the header of this page shows an address (text containing a digit that is not the placeholder), so delivery figures are DoorDash's answer for that address. no_address_selected: the header shows a known placeholder ('Your Address', 'Tu dirección') or the signed-out landing page shows its address form; DoorDash answered for a placeholder location. unknown: the header text is not recognised or absent. Delivery available, reason, estimate and too_far_from_consumer are null unless the basis is selected_address." }, + address_selected: { type: ["boolean", "null"], description: "True for basis selected_address, false for no_address_selected, null for unknown. The address text is never returned." }, + signed_in: { type: "boolean", description: "From the dd_cx_logged_in cookie; a hint only, since the cookie can be stale. Fees and promotions can differ by account." }, + too_far_from_consumer: { type: ["boolean", "null"], description: "DoorDash's own flag that the store is outside delivery range of the selected address. Null unless the basis is selected_address." }, note: { type: "string" } } }, @@ -131,8 +124,8 @@ }, execute: async (input, context) => { const ORIGIN = "https://www.doordash.com"; - const QUERY = "query storepageFeed($storeId: ID!, $fulfillmentType: FulfillmentType, $isMerchantPreview: Boolean, $entryPoint: StoreEntryPoint) { storepageFeed(storeId: $storeId, fulfillmentType: $fulfillmentType, isMerchantPreview: $isMerchantPreview, entryPoint: $entryPoint) { storeHeader { id name description timezone priceRange priceRangeDisplayString offersDelivery offersPickup offersGroupOrder offersCatering offersScheduling isConvenience isDashpassPartner address { lat lng city state street } businessTags { name } ratings { numRatings numRatingsDisplayString averageRating isNewlyAdded } distanceFromConsumer { value label } deliveryFeeLayout { title subtitle isSurging displayDeliveryFee } asapStatus { displayUnavailableReason { title } } status { delivery { isAvailable minutes unavailableReason isTooFarFromConsumer } pickup { isAvailable minutes unavailableReason } } } mxInfo { operationInfo { operationStatusInfo { description operationStatus } storeOperationHourInfo { operationSchedule { dayOfWeek timeSlotList } } doordashOperationHourInfo { operationSchedule { dayOfWeek timeSlotList } } } } menuBook { id name displayOpenHours menuList { id name displayOpenHours } } } }"; - const NOTE = "Delivery availability, estimates and distance are computed by DoorDash for the delivery address selected in this browser session. With no address selected they describe a placeholder location, not the customer, and are withheld. This tool never changes the address."; + const QUERY = "query storepageFeed($storeId: ID!, $fulfillmentType: FulfillmentType, $isMerchantPreview: Boolean, $entryPoint: StoreEntryPoint) { storepageFeed(storeId: $storeId, fulfillmentType: $fulfillmentType, isMerchantPreview: $isMerchantPreview, entryPoint: $entryPoint) { storeHeader { id name description timezone priceRange priceRangeDisplayString offersDelivery offersPickup offersGroupOrder offersCatering offersScheduling isConvenience isDashpassPartner address { lat lng city state street } businessTags { name } ratings { numRatings numRatingsDisplayString averageRating isNewlyAdded } deliveryFeeLayout { title subtitle isSurging displayDeliveryFee } asapStatus { displayUnavailableReason { title } } status { delivery { isAvailable minutes unavailableReason isTooFarFromConsumer } pickup { isAvailable minutes unavailableReason } } } mxInfo { operationInfo { operationStatusInfo { description operationStatus } storeOperationHourInfo { operationSchedule { dayOfWeek timeSlotList } } doordashOperationHourInfo { operationSchedule { dayOfWeek timeSlotList } } } } menuBook { id name displayOpenHours menuList { id name displayOpenHours } } } }"; + const NOTE = "Delivery availability and estimates are computed by DoorDash for the delivery address selected in this browser session. Without a recognised selected address they describe a placeholder location, not the customer, and are withheld. This tool never changes the address."; const CHALLENGE = /just a moment|challenge-platform|cf-chl|captcha|turnstile|verify (?:that )?you(?:'re| are) (?:a )?human|access denied|px-captcha/i; const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); const text = (value, max = 300) => typeof value === "string" && value.trim() ? value.trim().slice(0, max) : null; @@ -147,8 +140,8 @@ })); }; - if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => k !== "store_id") || typeof input.store_id !== "string" || !/^[0-9]{1,12}$/.test(input.store_id)) { - return fail("invalid_input", "store_id must be a string of 1 to 12 digits and is the only accepted field. No request was made."); + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => k !== "store_id") || typeof input.store_id !== "string" || !/^[1-9][0-9]{0,11}$/.test(input.store_id)) { + return fail("invalid_input", "store_id must be a string of 1 to 12 digits without a leading zero and is the only accepted field. No request was made."); } if (location.origin !== ORIGIN) return fail("wrong_page", "Open a https://www.doordash.com/ page in this tab, then call again. Nothing was changed."); const storeId = input.store_id; @@ -203,12 +196,14 @@ const convenience = header.isConvenience === true; const menuBook = convenience ? null : feed.menuBook; const menuList = menuBook && Array.isArray(menuBook.menuList) ? menuBook.menuList.filter(m => m && id(m.id)) : []; - const distance = header.distanceFromConsumer; const addressButton = document.querySelector("[data-testid='addressTextButton']"); const addressText = addressButton ? (addressButton.innerText || "").trim() : ""; - const addressSelected = addressText ? addressText !== "Your Address" : null; - // With no address selected DoorDash answers for a placeholder location, so its delivery figures are withheld. - const withheld = addressSelected === false; + // Placeholder text is localised, so an address is only recognised positively (a street number or ZIP); anything else is unknown. + const basis = addressText + ? (/^(?:your address|tu direcci[oó]n)$/i.test(addressText) ? "no_address_selected" : /\d/.test(addressText) ? "selected_address" : "unknown") + : (document.querySelector("[data-testid='LandingPageAddressSearch']") ? "no_address_selected" : "unknown"); + // Without a selected address DoorDash answers for a placeholder location, so its delivery figures are withheld. + const withheld = basis !== "selected_address"; const storeAddress = header.address || {}; return { @@ -221,7 +216,7 @@ cuisines: (Array.isArray(header.businessTags) ? header.businessTags : []).map(t => text(t && t.name, 60)).filter(Boolean).slice(0, 10), price_range: { level: Number.isInteger(header.priceRange) && header.priceRange >= 1 && header.priceRange <= 4 ? header.priceRange : null, text: text(header.priceRangeDisplayString, 10) }, rating: { - average: number(header.ratings && header.ratings.averageRating), + average: Number.isInteger(header.ratings && header.ratings.numRatings) && header.ratings.numRatings === 0 ? null : number(header.ratings && header.ratings.averageRating), count: Number.isInteger(header.ratings && header.ratings.numRatings) ? header.ratings.numRatings : null, count_text: text(header.ratings && header.ratings.numRatingsDisplayString, 30), is_new: bool(header.ratings && header.ratings.isNewlyAdded) @@ -248,10 +243,9 @@ estimate_minutes: pickup.isAvailable === true ? text(pickup.minutes, 30) : null }, delivery_context: { - basis: addressSelected === true ? "selected_address" : withheld ? "no_address_selected" : "unknown", - address_selected: addressSelected, + basis, + address_selected: basis === "selected_address" ? true : basis === "no_address_selected" ? false : null, signed_in: /(?:^|;\s*)dd_cx_logged_in=true(?:;|$)/.test(document.cookie), - distance: !withheld && distance && number(distance.value) !== null && text(distance.label, 10) ? { value: number(distance.value), unit: text(distance.label, 10) } : null, too_far_from_consumer: withheld ? null : bool(delivery.isTooFarFromConsumer), note: NOTE }, diff --git a/registry.json b/registry.json index bdc1dc78..60bbf139 100644 --- a/registry.json +++ b/registry.json @@ -124,7 +124,7 @@ ], "status": "draft", "last_verified": null, - "notes": "Page tool. Store details the site's native menu tools lack: hours, address, service modes, open status, and delivery and pickup availability with estimate range and the site's own fee strings, plus the menus with the menu_id the native add_to_cart needs. Delivery figures are only meaningful for a selected address, so the output states its basis (selected address, none selected, or unknown) and withholds DoorDash's placeholder-location delivery answer when no address is selected; it never selects or changes an address and never returns address text. One same-origin read of the site's storepageFeed query, no navigation; works signed out. Author live checks 2026-09-29 on the Kernel runtime; awaiting independent QA." + "notes": "Page tool. Store details the site's native menu tools lack: hours, address, service modes, open status, and delivery and pickup availability with estimate range and the site's own fee strings, plus the menus with the menu_id the native add_to_cart needs. Delivery figures are meaningful only for a selected address, so the output states its basis (selected address, none selected, or unknown) and returns delivery availability and estimate only when an address is positively recognised; it never selects or changes an address and returns neither address text nor customer distance. One same-origin read of the site's storepageFeed query, no navigation; works signed out. Author live checks 2026-09-29 on the Kernel runtime and a local Chromium extension; independent QA round 1 found defects that are fixed; awaiting re-review." }, { "domain": "doordash.com", diff --git a/tests/doordash-get-store.test.mjs b/tests/doordash-get-store.test.mjs index b0791245..3ff65846 100644 --- a/tests/doordash-get-store.test.mjs +++ b/tests/doordash-get-store.test.mjs @@ -53,7 +53,6 @@ const feed = (overrides = {}) => ({ address: { lat: "40.5", lng: "-73.5", city: "Testville", state: "NY", street: "5 Test Road" }, businessTags: [{ name: "Noodles" }, { name: "Asian" }], ratings: { numRatings: 1234, numRatingsDisplayString: "(1k+)", averageRating: 4.6, isNewlyAdded: false }, - distanceFromConsumer: { value: 2.5, label: "mi" }, deliveryFeeLayout: { title: "$0 delivery fee on $12+", subtitle: "pricing & fees", isSurging: false, displayDeliveryFee: "$0 delivery fee over $12" }, asapStatus: { displayUnavailableReason: { title: "" } }, status: { @@ -85,16 +84,18 @@ function harness(options = {}) { return { status: reply.status, ok: reply.status >= 200 && reply.status < 300, text: async () => body }; }; const button = options.button === undefined ? { innerText: HEADER_STREET } : options.button; + const signalSpy = { timeouts: [], anys: 0 }; + const SpiedAbortSignal = { timeout: ms => { signalSpy.timeouts.push(ms); return AbortSignal.timeout(ms); }, any: list => { signalSpy.anys += 1; return AbortSignal.any(list); } }; const context = { location: { origin: options.origin ?? HOME, pathname: "/home" }, document: { cookie: options.cookie ?? "a=1; csrf_token=fabricated-csrf; dd_cx_logged_in=true", - querySelector: selector => selector === "[data-testid='addressTextButton']" ? button : null + querySelector: selector => selector === "[data-testid='addressTextButton']" ? button : selector === "[data-testid='LandingPageAddressSearch']" && options.landing ? {} : null }, - fetch: stubFetch, AbortSignal, JSON, Object, Array, Number, String, Boolean, Math, Promise, Error, RegExp, isFinite + fetch: stubFetch, AbortSignal: SpiedAbortSignal, JSON, Object, Array, Number, String, Boolean, Math, Promise, Error, RegExp, isFinite }; const [definition] = runInNewContext(`(${source})`, context); - return { definition, calls, run: (input, signal) => definition.execute(input, signal ? { signal } : undefined) }; + return { definition, calls, signalSpy, run: (input, signal) => definition.execute(input, signal ? { signal } : undefined) }; } const validOutput = (definition, value) => conforms(definition.tool.outputSchema, value); @@ -142,8 +143,11 @@ test("restaurant: maps the allowlisted fields and issues exactly one read", asyn assert.equal(out.operation_status, "OPEN"); assert.equal(out.hours.store.length, 7); assert.deepEqual(out.hours.doordash_orders[0], { day: "MONDAY", slots: ["10 AM - 9 PM"] }); + assert.equal(out.delivery_context.basis, "selected_address"); + assert.equal(out.delivery_context.signed_in, true); assert.equal(out.delivery.available, true); assert.equal(out.delivery.estimate_minutes, "27 - 37"); + assert.equal("distance" in out.delivery_context, false); assert.deepEqual(out.delivery.fee_display, { title: "$0 delivery fee on $12+", subtitle: "pricing & fees", text: "$0 delivery fee over $12", is_surging: false }); assert.equal(out.pickup.estimate_minutes, "19 - 29"); assert.deepEqual(out.menu.menus.map(m => m.menu_id), ["5001", "5002"]); @@ -170,7 +174,6 @@ test("signed out: empty csrf header, signed_in false", async () => { }); const farStore = () => ok(feed({ storeHeader: { - distanceFromConsumer: { value: 5385, label: "mi" }, asapStatus: { displayUnavailableReason: { title: "Your address is not in this store's delivery area" } }, status: { delivery: { isAvailable: false, minutes: "51 - 61", unavailableReason: "Too far away", isTooFarFromConsumer: true }, @@ -189,7 +192,6 @@ test("selected address, store out of range: the reason is reported and no estima assert.equal(out.delivery.unavailable_message, "Your address is not in this store's delivery area"); assert.equal(out.pickup.available, false); assert.equal(out.pickup.estimate_minutes, null); - assert.deepEqual(out.delivery_context.distance, { value: 5385, unit: "mi" }); assert.equal(out.delivery_context.too_far_from_consumer, true); assert.match(out.delivery_context.note, /never changes the address/); }); @@ -201,13 +203,12 @@ test("no address selected: the placeholder-location delivery figures are withhel assert.equal(out.delivery_context.basis, "no_address_selected"); assert.equal(out.delivery_context.address_selected, false); assert.deepEqual(out.delivery, { available: null, unavailable_reason: null, unavailable_message: null, estimate_minutes: null, fee_display: out.delivery.fee_display }); - assert.equal(out.delivery_context.distance, null); assert.equal(out.delivery_context.too_far_from_consumer, null); assert.equal(out.name, "Fabricated Noodle House"); assert.equal(out.operation_status, "OPEN"); assert.equal(out.hours.store.length, 7); assert.equal(out.delivery.fee_display.text, "$0 delivery fee over $12"); - assert.doesNotMatch(JSON.stringify(out), /Too far away|5385/); + assert.doesNotMatch(JSON.stringify(out), /Too far away/); assert.match(out.delivery_context.note, /withheld/); const available = harness({ button: { innerText: "Your Address" } }); @@ -217,36 +218,54 @@ test("no address selected: the placeholder-location delivery figures are withhel assert.equal(open.pickup.estimate_minutes, "19 - 29"); }); -test("unknown basis (no header address control): DoorDash's figures pass through, labelled unverified", async () => { +test("unknown basis: delivery figures are withheld like the no-address case, not passed through", async () => { const { definition, run } = harness({ button: null, respond: farStore }); const out = plain(await run({ store_id: "1001" })); assert.ok(validOutput(definition, out), JSON.stringify(out)); assert.equal(out.delivery_context.basis, "unknown"); assert.equal(out.delivery_context.address_selected, null); - assert.equal(out.delivery.available, false); - assert.equal(out.delivery.unavailable_reason, "Too far away"); - assert.deepEqual(out.delivery_context.distance, { value: 5385, unit: "mi" }); + assert.equal(out.delivery.available, null); + assert.equal(out.delivery.unavailable_reason, null); + assert.equal(out.delivery_context.too_far_from_consumer, null); + assert.equal(out.pickup.available, false); + assert.doesNotMatch(JSON.stringify(out), /Too far away/); }); -test("address_selected reads the header control and never returns its text", async () => { - const selected = harness(); - const withAddress = plain(await selected.run({ store_id: "1001" })); - assert.equal(withAddress.delivery_context.address_selected, true); - assert.equal(withAddress.delivery_context.basis, "selected_address"); - assert.ok(!JSON.stringify(withAddress).includes("Fabricated Header Street")); +test("basis is recognised positively, in any locale, and the header text is never returned", async () => { + const basisOf = async options => plain(await harness(options).run({ store_id: "1001" })).delivery_context; + const selected = await basisOf({}); + assert.equal(selected.basis, "selected_address"); + assert.equal(selected.address_selected, true); + assert.ok(!JSON.stringify(plain(await harness().run({ store_id: "1001" }))).includes("Fabricated Header Street")); - assert.equal(plain(await harness({ button: { innerText: "Your Address" } }).run({ store_id: "1001" })).delivery_context.address_selected, false); - assert.equal(plain(await harness({ button: null }).run({ store_id: "1001" })).delivery_context.address_selected, null); - assert.equal(plain(await harness({ button: { innerText: " " } }).run({ store_id: "1001" })).delivery_context.address_selected, null); + assert.equal((await basisOf({ button: { innerText: "Your Address" } })).basis, "no_address_selected"); + assert.equal((await basisOf({ button: { innerText: " your address " } })).basis, "no_address_selected"); + assert.equal((await basisOf({ button: { innerText: "Tu dirección" } })).basis, "no_address_selected"); + const unrecognised = await basisOf({ button: { innerText: "Votre adresse" } }); + assert.equal(unrecognised.basis, "unknown", "an unrecognised placeholder must not read as a selected address"); + assert.equal(unrecognised.address_selected, null); + assert.equal((await basisOf({ button: { innerText: "Main Street" } })).basis, "unknown"); + assert.equal((await basisOf({ button: { innerText: "12 Rue Fabriquée" } })).basis, "selected_address"); + assert.equal((await basisOf({ button: { innerText: " " } })).basis, "unknown"); + assert.equal((await basisOf({ button: null })).basis, "unknown"); + assert.equal((await basisOf({ button: null, landing: true })).basis, "no_address_selected"); + assert.equal((await basisOf({ button: null, landing: true })).address_selected, false); }); test("convenience store: header details, canonical convenience URL, no menu", async () => { - const { definition, run } = harness({ respond: () => ok(feed({ storeHeader: { id: "2002", name: "Fabricated Market", isConvenience: true }, rest: { menuBook: null } })) }); + const { definition, run } = harness({ respond: () => ok(feed({ storeHeader: { id: "2002", name: "Fabricated Market", isConvenience: true } })) }); const out = plain(await run({ store_id: "2002" })); assert.ok(validOutput(definition, out), JSON.stringify(out)); assert.equal(out.store_type, "convenience"); assert.equal(out.url, "https://www.doordash.com/convenience/store/2002/"); - assert.equal(out.menu, null); + assert.equal(out.menu, null, "a convenience store never reports a menu, even if the site sends one"); +}); + +test("a store with no ratings reports no average instead of 0", async () => { + const { definition, run } = harness({ respond: () => ok(feed({ storeHeader: { ratings: { numRatings: 0, numRatingsDisplayString: "", averageRating: 0, isNewlyAdded: true } } })) }); + const out = plain(await run({ store_id: "1001" })); + assert.ok(validOutput(definition, out)); + assert.deepEqual(out.rating, { average: null, count: 0, count_text: null, is_new: true }); }); test("optional and missing fields become nulls, not errors", async () => { @@ -262,7 +281,6 @@ test("optional and missing fields become nulls, not errors", async () => { assert.equal(out.operation_status, null); assert.deepEqual(out.hours, { store: [], doordash_orders: [] }); assert.equal(out.delivery.available, null); - assert.equal(out.delivery_context.distance, null); assert.equal(out.delivery_context.too_far_from_consumer, null); assert.deepEqual(out.menu.menus, []); assert.equal(out.menu.menu_id, null); @@ -294,7 +312,7 @@ test("output is bounded: menus, cuisines, hours slots and long strings are cappe test("malformed input is rejected before any request", async () => { const { definition, calls, run } = harness(); - for (const input of [undefined, null, [], "1001", {}, { store_id: 1001 }, { store_id: "" }, { store_id: "abc" }, { store_id: "12 34" }, { store_id: "-1" }, { store_id: "1234567890123" }, { store_id: "1001", extra: true }]) { + for (const input of [undefined, null, [], "1001", {}, { store_id: 1001 }, { store_id: "" }, { store_id: "abc" }, { store_id: "12 34" }, { store_id: "-1" }, { store_id: "1234567890123" }, { store_id: "029417" }, { store_id: "0" }, { store_id: " 1001" }, { store_id: "1001 " }, { store_id: "1001\n" }, { store_id: "١٢٣" }, { store_id: "1001", extra: true }]) { const out = plain(await run(input)); assert.equal(out.status, "error", JSON.stringify(input)); assert.equal(out.error_kind, "invalid_input"); @@ -346,6 +364,19 @@ test("permission, rate-limit, challenge, site-change and generic failures are di } }); +test("the request is bounded by a 25 second timeout and error text from the site is bounded", async () => { + const { signalSpy, run } = harness(); + await run({ store_id: "1001" }); + assert.deepEqual(signalSpy.timeouts, [25000]); + assert.equal(signalSpy.anys, 1); + const noisy = harness({ respond: () => ({ status: 200, json: { errors: [{ message: "internal failure " + "x".repeat(50000) }] } }) }); + const out = plain(await noisy.run({ store_id: "1001" })); + assert.equal(out.error_kind, "site_error"); + assert.ok(out.error_message.length < 300, String(out.error_message.length)); + const invalid = harness({ respond: () => ({ status: 200, json: { errors: [{ message: "Cannot query field " + "y".repeat(50000), extensions: { code: "GRAPHQL_VALIDATION_FAILED" } }] } }) }); + assert.ok(plain(await invalid.run({ store_id: "1001" })).error_message.length < 400); +}); + test("network failure and timeout are site_error and are not retried", async () => { const failed = harness({ fetchError: new TypeError("Failed to fetch") }); const a = plain(await failed.run({ store_id: "1001" })); From c4c29b6005d85fdd89ca20740dd269e0879471ca Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:34:20 +0000 Subject: [PATCH 03/13] Record local extension check for doordash.com get_store --- domains/doordash.com/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/domains/doordash.com/README.md b/domains/doordash.com/README.md index ffe78fe7..6348b4af 100644 --- a/domains/doordash.com/README.md +++ b/domains/doordash.com/README.md @@ -379,6 +379,16 @@ available with estimate ranges; a store outside that range returned `too_far_fro pickup still available; a convenience store in range returned delivery available and pickup unavailable. Store ids and distances from this account are deliberately not recorded. +Local Chrome extension runtime (Chromium 153.0.8010.52 under Xvfb, throwaway profile and state directory), +2026-09-29, author check of `get_store.js` at commit `ab34f98`: installed with `xwebmcp add` pinned to that +commit, attached with `xwebmcp attach` (both tools in one batch), site access granted in the popup. Both +tools were listed on a store page; `get_store` calls made from the page (`document.modelContext.executeTool`) +and through `xwebmcp mcp` (`open_tab` then `invoke_tool`) returned the same outputs as on Kernel, including +`no_address_selected` on `/es-US/`, `not_found`, and `invalid_input` for `abc`, an extra field and `029417`, +with no navigation. `xwebmcp invoke ` refused a tab it had not opened (`tab_not_allowed`), as the +README describes. DoorDash's native tools were not listed by `getTools()` in this Chromium, unlike on +Kernel; the cause was not investigated. Not run locally: a signed-in profile and real address. + ### Independent QA Round 1, 2026-09-29, agent `agt_e3cd254315c78d609b12` against commit `3488ced` in two profile-less From f6a8915d25e1ce1daaa72a080f8b564def559f72 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:05:28 +0000 Subject: [PATCH 04/13] Record independent QA sign-off for doordash.com get_store and close test gaps --- domains/doordash.com/README.md | 36 +++++++++++++++++++++++++++---- registry.json | 9 +++++--- tests/doordash-get-store.test.mjs | 28 ++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/domains/doordash.com/README.md b/domains/doordash.com/README.md index 6348b4af..37549578 100644 --- a/domains/doordash.com/README.md +++ b/domains/doordash.com/README.md @@ -5,7 +5,7 @@ Tools for DoorDash (https://www.doordash.com). | tool | file | status | notes | |------|------|--------|-------| | login | [login.js](login.js) | qa_passed | Email + password sign-in with a two-step guided flow; existing sessions verified against the profile page; `otp` mode for the 2-Step Verification modal | -| get_store | [get_store.js](get_store.js) | draft | Page tool: store hours, service modes, delivery and pickup availability with estimates and fee strings, and menu ids, with the delivery basis (selected address, none selected, unknown) stated explicitly and delivery figures returned only for a selected address; never changes the address | +| get_store | [get_store.js](get_store.js) | qa_passed | Page tool: store hours, service modes, delivery and pickup availability with estimates and fee strings, and menu ids, with the delivery basis (selected address, none selected, unknown) stated explicitly and delivery figures returned only for a selected address; never changes the address | ## Native WebMCP tools @@ -407,6 +407,32 @@ blocker. Findings and what changed: | seven surviving source mutants (padded ids, `signed_in: true`, the convenience guard, the timeout, the error-text bound) | minor | Tests added; a local re-run of 15 mutants of the new logic and of those gaps found all caught. | | distance plus the store coordinates could be combined across stores to estimate the customer's location | privacy judgement | The numeric distance is no longer returned or requested. | +Round 2, 2026-09-29, same agent `agt_e3cd254315c78d609b12` against commit `27aedc3` (`get_store.js` sha256 +`f5223a9ef3fa82d5552605c5d57fb1d979cab05f115ebdfabf28e7679bd30a1d`, unchanged in the later commits) in two +new profile-less stealth browsers (deleted) and the same throwaway local Chromium method. Verdict: **no +defects; signed off on that source as ready for `qa_passed`**. It re-verified all seven round 1 findings; +probed the header on en-US, es-US, en-CA, fr-CA, en-AU, en-NZ and en-GB (every other locale path it tried +returned 404 or redirected to en-US) and on `/`, `/home`, search, store, DashPass and grocery pages plus an +emulated mobile viewport, with no signed-out case reading `selected_address` and none reading +`no_address_selected` while an address was showing; sent 21 malformed inputs (0 requests) and 11 valid calls +(exactly 11 requests); swept 57 stores (0 schema failures, every delivery field `null`); checked 73 cookie +values for leakage (none; one false positive from a cookie whose value is `doordash.com`); and ran 77 +source mutants, of which 68 were killed. Of the 9 survivors, four were equivalent, two were the unanchored +placeholder regex and three were test gaps; the tests now cover the gaps (request omits +`distanceFromConsumer`, `execute` agrees with the schema pattern, exact `dd_cx_logged_in=true` cookie +boundary, a placeholder is matched only as the whole text) and a re-run of those mutants finds all caught. +Its minor nits: the description's "not recognised" does not mention that `unknown` also covers a page with +neither a header control nor the landing form (seen live on a mobile `/home`; the schema text says "or +absent"), and one Known limitations line still said two egress locations (fixed). Not checked by the +reviewer: any signed-in state, a real selected address, the NY-proxy egress in the extension, and real +401, 429 or challenge responses (stub-driven only). It also noted that `"Your Address 2"`-style text reads +as `selected_address` because any digit counts, which is unreachable with real placeholders. + +Status: `qa_passed`, not `verified`. The `selected_address` branch and the delivery figures it releases have +only the author's live evidence, from one signed-in session, and no independent live check, so the +"live end-to-end success" bar of `spec/repository.md` §5.4 for the tool's central claim is not met even +though independent live successes exist from several pages. + The review also confirmed live that `menu_id` equals the id the native `get_restaurant_menu` reports for five stores, that six concurrent invocations in one tab succeed, that a document replaced mid-call is reported by the runtime as `outcome_unknown` (the extension reports an unobservable outcome after 5 s), @@ -417,8 +443,8 @@ site responses. ### Known limitations of get_store -- Draft. Round 1 of independent QA found defects that are fixed in the current source; no sign-off has been - recorded, so the status stays `draft`. +- `qa_passed` on `get_store.js` sha256 `f5223a9e…` (commit `ab34f98`; later commits touch only this README, the + tests and the registry). Not `verified`: see "Independent QA". - Not verified live: `login_required`, `challenge`, `rate_limited` and `site_changed` responses (fixture coverage only, with response shapes assumed from GraphQL conventions and the observed `NOT_FOUND` shape). The `challenge` detection looks only at non-JSON bodies. @@ -429,6 +455,8 @@ site responses. guest address changes DoorDash's figures is unobserved. Signed in without any saved address is unobserved. That the placeholder location is the same for every guest was seen from four egress locations in total (two by the author, two by the reviewer). +- `unknown` also covers a page with neither a header control nor the landing form (seen live on a mobile + `/home`), which the tool description does not spell out. - `selected_address` is the only basis with delivery figures, and it was live-verified only in the one signed-in session described above; `delivery.available: true` for a guest-selected address is not verified. - The delivery estimate and availability are for one moment and one context. `fulfillmentType` is fixed to @@ -440,7 +468,7 @@ site responses. - Loading a store page or this query may register a store view in DoorDash's own analytics, as any visit does. - Only stores DoorDash reports under `/store/` were sampled, plus one convenience store; other store types (for example shipping-only) are unobserved. -- Delivery figures depend on the session's address; the egress location did not change them in the two +- Delivery figures depend on the session's address; the egress location did not change them in the four locations tried. Coordinates and street in `address` are the store's own. ### URL patterns diff --git a/registry.json b/registry.json index 60bbf139..1a02463b 100644 --- a/registry.json +++ b/registry.json @@ -122,9 +122,12 @@ "url_patterns": [ "https://www.doordash.com/*" ], - "status": "draft", - "last_verified": null, - "notes": "Page tool. Store details the site's native menu tools lack: hours, address, service modes, open status, and delivery and pickup availability with estimate range and the site's own fee strings, plus the menus with the menu_id the native add_to_cart needs. Delivery figures are meaningful only for a selected address, so the output states its basis (selected address, none selected, or unknown) and returns delivery availability and estimate only when an address is positively recognised; it never selects or changes an address and returns neither address text nor customer distance. One same-origin read of the site's storepageFeed query, no navigation; works signed out. Author live checks 2026-09-29 on the Kernel runtime and a local Chromium extension; independent QA round 1 found defects that are fixed; awaiting re-review." + "status": "qa_passed", + "last_verified": "2026-09-29", + "notes": "Page tool. Store details the site's native menu tools lack: hours, address, service modes, open status, and delivery and pickup availability with estimate range and the site's own fee strings, plus the menus with the menu_id the native add_to_cart needs. Delivery figures are meaningful only for a selected address, so the output states its basis (selected address, none selected, or unknown) and returns delivery availability and estimate only when an address is positively recognised; it never selects or changes an address and returns neither address text nor customer distance. One same-origin read of the site's storepageFeed query, no navigation; works signed out. Independent QA (two rounds, 2026-09-29) signed off on the current source on the Kernel runtime and a local Chromium extension; the selected-address path was live-checked only by the author in one signed-in session, and the 401, 429, challenge and site-changed branches are stub-driven only.", + "provenance": { + "qa_agent": "agt_e3cd254315c78d609b12" + } }, { "domain": "doordash.com", diff --git a/tests/doordash-get-store.test.mjs b/tests/doordash-get-store.test.mjs index 3ff65846..b8ca7a7d 100644 --- a/tests/doordash-get-store.test.mjs +++ b/tests/doordash-get-store.test.mjs @@ -164,6 +164,7 @@ test("restaurant: maps the allowlisted fields and issues exactly one read", asyn assert.deepEqual(call.init.body.variables, { storeId: "1001", fulfillmentType: "Delivery", isMerchantPreview: false, entryPoint: "External" }); assert.match(call.init.body.query, /^query storepageFeed\(/); assert.doesNotMatch(call.init.body.query, /mutation/i); + assert.doesNotMatch(call.init.body.query, /distanceFromConsumer/, "the customer's distance is never requested"); }); test("signed out: empty csrf header, signed_in false", async () => { @@ -245,6 +246,10 @@ test("basis is recognised positively, in any locale, and the header text is neve assert.equal(unrecognised.basis, "unknown", "an unrecognised placeholder must not read as a selected address"); assert.equal(unrecognised.address_selected, null); assert.equal((await basisOf({ button: { innerText: "Main Street" } })).basis, "unknown"); + for (const mixed of ["set your address", "your address please", "Your Address, Suite", "Tu dirección aquí"]) { + assert.equal((await basisOf({ button: { innerText: mixed } })).basis, "unknown", "a placeholder is matched as the whole text: " + mixed); + } + assert.equal((await basisOf({ button: { innerText: "Your Address 2" } })).basis, "selected_address", "any digit counts as an address"); assert.equal((await basisOf({ button: { innerText: "12 Rue Fabriquée" } })).basis, "selected_address"); assert.equal((await basisOf({ button: { innerText: " " } })).basis, "unknown"); assert.equal((await basisOf({ button: null })).basis, "unknown"); @@ -321,6 +326,29 @@ test("malformed input is rejected before any request", async () => { assert.equal(calls.length, 0); }); +test("execute accepts exactly the ids the input schema pattern accepts", async () => { + const { definition, calls, run } = harness(); + const pattern = new RegExp(definition.tool.inputSchema.properties.store_id.pattern); + for (const id of ["1", "1001", "123456789012", "0", "01", "029417", "1234567890123", "", " 1001", "1001 ", "1001\n", "10.1", "1e3", "\u0661\u0662"]) { + const before = calls.length; + const out = plain(await run({ store_id: id })); + const accepted = out.error_kind !== "invalid_input"; + assert.equal(accepted, pattern.test(id), JSON.stringify(id)); + assert.equal(calls.length - before, accepted ? 1 : 0, JSON.stringify(id)); + } +}); + +test("signed_in reads exactly dd_cx_logged_in=true", async () => { + const signedIn = async cookie => plain(await harness({ cookie }).run({ store_id: "1001" })).delivery_context.signed_in; + assert.equal(await signedIn("dd_cx_logged_in=true"), true); + assert.equal(await signedIn("a=1; dd_cx_logged_in=true; b=2"), true); + assert.equal(await signedIn("a=1; dd_cx_logged_in=true"), true); + assert.equal(await signedIn("dd_cx_logged_in=false"), false); + assert.equal(await signedIn("dd_cx_logged_in=truex"), false); + assert.equal(await signedIn("xdd_cx_logged_in=true"), false); + assert.equal(await signedIn(""), false); +}); + test("a page outside www.doordash.com is refused without a request", async () => { const { definition, calls, run } = harness({ origin: "https://help.doordash.com" }); const out = plain(await run({ store_id: "1001" })); From dd017035031bd04bc98d22b48f1199137d555967 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:06:08 +0000 Subject: [PATCH 05/13] Add account-checked cart reads, removal and checkout preparation --- domains/doordash.com/README.md | 80 ++++++- domains/doordash.com/get_cart.js | 207 +++++++++++++++++ domains/doordash.com/get_checkout_summary.js | 208 +++++++++++++++++ domains/doordash.com/remove_cart_item.js | 222 ++++++++++++++++++ registry.json | 41 +++- tests/doordash-cart.test.mjs | 224 +++++++++++++++++++ 6 files changed, 979 insertions(+), 3 deletions(-) create mode 100644 domains/doordash.com/get_cart.js create mode 100644 domains/doordash.com/get_checkout_summary.js create mode 100644 domains/doordash.com/remove_cart_item.js create mode 100644 tests/doordash-cart.test.mjs diff --git a/domains/doordash.com/README.md b/domains/doordash.com/README.md index 37549578..de8b9879 100644 --- a/domains/doordash.com/README.md +++ b/domains/doordash.com/README.md @@ -5,6 +5,9 @@ Tools for DoorDash (https://www.doordash.com). | tool | file | status | notes | |------|------|--------|-------| | login | [login.js](login.js) | qa_passed | Email + password sign-in with a two-step guided flow; existing sessions verified against the profile page; `otp` mode for the 2-Step Verification modal | +| get_cart | [get_cart.js](get_cart.js) | draft | Account-checked open-cart read with line ids, options, provisional totals and revision | +| get_checkout_summary | [get_checkout_summary.js](get_checkout_summary.js) | draft | Read-only preparation and manual checkout handoff; never opens checkout or orders | +| remove_cart_item | [remove_cart_item.js](remove_cart_item.js) | draft | Exact non-last personal cart-line removal with account/revision/duplicate guards; fixture-only mutation evidence | | get_store | [get_store.js](get_store.js) | qa_passed | Page tool: store hours, service modes, delivery and pickup availability with estimates and fee strings, and menu ids, with the delivery basis (selected address, none selected, unknown) stated explicitly and delivery figures returned only for a selected address; never changes the address | ## Native WebMCP tools @@ -102,7 +105,7 @@ challenge appeared in any run. ```javascript const fs = await import("node:fs"); -const tools = ["login", "get_store"].map(name => eval(fs.readFileSync(`domains/doordash.com/${name}.js`, "utf8"))[0]); +const tools = ["login", "get_store", "get_cart", "get_checkout_summary", "remove_cart_item"].map(name => eval(fs.readFileSync(`domains/doordash.com/${name}.js`, "utf8"))[0]); await webmcp.addCustomTools({ namespace: "doordash.com", tools, forceOverwriteNamespace: true }); const tool = (await webmcp.listTools()).find(t => t.source.custom?.namespace === "doordash.com" && t.tool.name === "login"); const result = await webmcp.invokeTool(tool.tool_ref, { email: "qa@example.invalid", password: "fabricated" }, { timeoutSec: 120 }); @@ -110,7 +113,7 @@ const result = await webmcp.invokeTool(tool.tool_ref, { email: "qa@example.inval // await webmcp.invokeTool(tool.tool_ref, { email: "qa@example.invalid", otp: "123456" }, { timeoutSec: 120 }); ``` -`forceOverwriteNamespace: true` replaces every tool in `doordash.com`, so register `login` and `get_store` +`forceOverwriteNamespace: true` replaces every tool in `doordash.com`, so register all five tools together as one array. Take a fresh `tool_ref` after navigation. Use `timeoutSec: 120` (the WebMCP maximum); the post-submit observation window is 45 seconds and a full search-entry run took about 15 seconds. The runtime only lists a CDP tool on pages matching its `url_patterns`, so @@ -486,3 +489,76 @@ namespace; this is flagged for review. - Long-lived signed-in browser (profile `personal-stuff`, residential proxy): `wjdkr9p7ugcc9ui4ssf7e4mm` (24-hour inactivity timeout; left alive for the search and cart phase). - Exploration browser (no profile): `f8hxya0e603z86s1nsamz01b` (deleted after this work). - Source: this repository is canonical; sessions expire but files do not. No credentials or codes are stored here. + + +## Cart reads, reversible removal and order preparation + +Rechecked native coverage on 2026-09-30 in the explicitly authorized session: home exposes search/context/navigation; +restaurant store exposes `get_restaurant_menu`, `get_item_details`, `add_to_cart`, context/navigation. These +menu/item/add capabilities are not duplicated. The native page polyfill still lists them after custom +namespace registration, although the current Kernel discovery API did not surface them in this recheck. + +All three new tools are `page`: same-origin requests from the current session, no navigation. They require +`expected_account_email`, supplied by the user for the intended account, and compare it against the site's +`consumerBucketing` query before reading private cart data. No email, account id, payment data, address street, +entry codes, instructions, raw errors, cookies or signed URLs are returned. Do not infer the intended account +from whatever session happens to be present. This account comparison is not human approval. + +- `get_cart`: reads `listDetailedCarts`, at most 20 open carts, 100 lines/cart, 50 options/line and 30 fee lines; + returns truncation flags, currency and integer minor-unit amounts (null when the site omits them). Every cart + has a SHA-256 `revision` of the queried cart shape for a fresh-state check. A whole output over 180 KB is refused. +- `get_checkout_summary`: reads the same list and selects the exact `cart_id`, with an allowlisted cart summary, + explicit delivery context and a generated manual checkout URL. It does not load checkout or create checkout + state. Totals and fee lines are provisional and can be incomplete. `ready_for_order` is always false. +- `remove_cart_item`: requires exact `cart_id`, `line_id` (cart line id, not menu item id), `expected_store_id` + and `expected_revision` from `get_cart`. It removes only an ordinary personal non-bundle, non-catering, + non-shipping cart line, with one non-finalized order and at least two lines. It refuses last-line removal, + incomplete/truncated carts, a missing/duplicate line, wrong store/account and stale revision before writing. + +### Safety design + +Removal uses the site's `removeCartItemV2` mutation, one attempt, with `returnCartFromOrderService: false`, +`monitoringContext.isGroup: false` and `cartContext.deleteBundleCarts: false`. It never calls checkout, +quantity/option updates, address/default changes, payment selection, scheduling, or order placement. The +query and mutation were extracted from the site's public client bundle; the trimmed cart query and account +query were accepted live. No mutation was sent to a live cart. The intended account is checked again before +and after the write. A per-cart sessionStorage guard is installed synchronously before the write; an uncertain +outcome retains it with no automatic expiry or retry. A fresh read must prove the line absent and every other +queried cart identity and line field unchanged before reporting `removed: true` and clearing the guard. +Fee/subtotal/total changes are expected and excluded from the unchanged-line check. + +The guard is per tab and is not a server-side lock. There is no observed atomic compare-and-swap API: a +cross-tab change or account switch between the final read and write remains a race. Run serially and do +not edit the cart elsewhere during a call. The site UI's Apollo cache is not updated by this request; refresh +or read with `get_cart` rather than treating the old drawer as current. A cancelled/failed/partial write +returns `outcome_unknown`, `write_attempted: true`; inspect manually and do not retry. No automatic guard +clearance is exposed as a tool. + +`place_order` and payment execution are deliberately unshipped: the runtime provides no supported trusted +human approval path bound to exact account, cart/items/quantities, address, final price, tip, delivery time +and terms. A model-supplied `confirmed=true` is not authorization and is rejected as an extra field. The +summary produces only preparation/handoff; it never triggers execution automatically. + +### Delivery context and limitations + +Header `basis` uses the same conservative rule as `get_store`. `default_address_region` is the account's +saved default city/state/postal/country, not the actual cart address. The description and note explicitly +state that distinction; neither address is changed. The cart query has no observed per-cart delivery-address +field, so do not claim the default is selected for checkout. No real street, coordinates or address id is +returned. Preview totals are not a final payable quote; tax/tip/promos/benefits can change at checkout. + +Quantity updates are omitted: the site's `updateCartItemV2` input includes fulfillment and customization +context, and a complete preserve-options contract has not been observed. Removal is grounded separately +and does not rebuild item options. New menu/item/add tools are omitted because the site provides them natively. + +### New-tool QA evidence and known limitations + +Author, 2026-09-30: authorized session still alive with its original 12-hour expiration unchanged. `consumerBucketing` +confirmed a non-guest account and a default region (only field names recorded); trimmed `listDetailedCarts` +returned HTTP 200 with an empty list. Whole five-tool namespace registered atomically, `get_cart` returned +`ok`, empty list and `account_matched: true` from a different store page; wrong intended account returned +`wrong_account` before the private cart query. No user cart/address/payment changed. Populated cart, actual +fee/total semantics, and removal success remain fixture-only; no live mutation consent was supplied. + +Independent QA for the broadened source is pending. Prior `get_store`/`login` evidence above is historical, +not sign-off on new cart tools. All new entries remain draft until this review completes. diff --git a/domains/doordash.com/get_cart.js b/domains/doordash.com/get_cart.js new file mode 100644 index 00000000..35c5e0da --- /dev/null +++ b/domains/doordash.com/get_cart.js @@ -0,0 +1,207 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.doordash.com/*"] }, + tool: { + name: "get_cart", + description: "Read open DoorDash carts, line ids/options/quantities, currency and provisional subtotal/total/fee lines from the current authorized session. Requires expected_account_email (the intended account supplied by the user); checks the signed-in account before reading private carts, never returns email/payment data. Returns at most 20 carts, 100 lines and 50 options per line with truncation flags and a revision for remove_cart_item. Delivery context states the header basis and saved default region, not a verified cart address. Does not open checkout, set an address, add items, pay or place orders. Menu/item reads and adding items are native tools, not duplicated. Empty open-cart list is a valid success.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["expected_account_email"], + properties: { + expected_account_email: { + type: "string", + minLength: 3, + maxLength: 254, + pattern: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$", + description: "The intended account email supplied by the user. Compared with the current session before reading carts or changing anything; never returned. Not a human approval token." + } + } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", + additionalProperties: false, + required: ["status","account_matched","carts","carts_truncated","delivery_context"], + properties: { + status: {"const":"ok"}, + account_matched: {"const":true}, + carts: { + type: "array", + maxItems: 20, + items: { + type: "object", + additionalProperties: false, + required: ["cart_id","store_id","store_name","fulfillment_type","group_cart","bundle","catering","merchant_shipping","currency","subtotal_minor","total_minor","lines","fee_lines","revision","truncated"], + properties: { + cart_id: {"type":"string","maxLength":64}, + store_id: {"type":["string","null"],"maxLength":200}, + store_name: {"type":["string","null"],"maxLength":200}, + fulfillment_type: {"enum":["pickup","delivery","unknown"]}, + group_cart: {"type":["boolean","null"]}, + bundle: {"type":["boolean","null"]}, + catering: {"type":["boolean","null"]}, + merchant_shipping: {"type":["boolean","null"]}, + currency: {"type":["string","null"],"maxLength":10}, + subtotal_minor: {"type":["integer","null"]}, + total_minor: {"type":["integer","null"]}, + lines: { + type: "array", + maxItems: 100, + items: { + type: "object", + additionalProperties: false, + required: ["line_id","item_id","name","quantity","unit_price_minor","total_minor","price_display","options"], + properties: { + line_id: {"type":"string","maxLength":64}, + item_id: {"type":["string","null"],"maxLength":200}, + name: {"type":["string","null"],"maxLength":200}, + quantity: {"type":["number","null"]}, + unit_price_minor: {"type":["integer","null"]}, + total_minor: {"type":["integer","null"]}, + price_display: {"type":["string","null"],"maxLength":200}, + options: { + type: "array", + maxItems: 50, + items: { + type: "object", + additionalProperties: false, + required: ["option_id","name","quantity","price_minor"], + properties: {"option_id":{"type":"string","maxLength":64},"name":{"type":["string","null"],"maxLength":200},"quantity":{"type":["number","null"]},"price_minor":{"type":["integer","null"]}} + } + } + } + } + }, + fee_lines: { + type: "array", + maxItems: 30, + items: { + type: "object", + additionalProperties: false, + required: ["label","amount_minor","display"], + properties: {"label":{"type":["string","null"],"maxLength":200},"amount_minor":{"type":["integer","null"]},"display":{"type":["string","null"],"maxLength":200}} + } + }, + revision: {"type":"string","pattern":"^[a-f0-9]{64}$"}, + truncated: {"type":"boolean"} + } + } + }, + carts_truncated: {"type":"boolean"}, + delivery_context: { + type: "object", + additionalProperties: false, + required: ["basis","default_address_region","note"], + properties: { + basis: {"enum":["selected_address","no_address_selected","unknown"]}, + default_address_region: { + type: "object", + additionalProperties: false, + required: ["city","state","postal_code","country_code"], + properties: { + city: {"type":["string","null"],"maxLength":200}, + state: {"type":["string","null"],"maxLength":200}, + postal_code: {"type":["string","null"],"maxLength":200}, + country_code: {"type":["string","null"],"maxLength":200} + } + }, + note: {"type":"string","maxLength":500} + } + } + } + }, + { + type: "object", + additionalProperties: false, + required: ["status","error_kind","error_message","write_attempted"], + properties: { + status: {"const":"error"}, + error_kind: {"enum":["invalid_input","wrong_page","login_required","wrong_account","blocked","rate_limited","site_changed","site_error","cancelled","not_found","stale_state","busy","outcome_unknown"]}, + error_message: {"type":"string","maxLength":400}, + write_attempted: {"type":"boolean"} + } + } + ] + }, + annotations: {"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"untrustedContentHint":true} + }, + execute: async (input, context) => { + const fail = (error_kind, error_message, write_attempted = false) => ({ status: "error", error_kind, error_message, write_attempted }); + const allowed = ["expected_account_email"]; + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k)) || allowed.some(k => typeof input[k] !== "string") || input.expected_account_email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.expected_account_email)) return fail("invalid_input", "Supply only the documented fields, including the intended account email. No request was made."); + + if (location.origin !== "https://www.doordash.com") return fail("wrong_page", "Open www.doordash.com in this tab. Nothing was changed."); + if (context?.signal?.aborted) return fail("cancelled", "Cancelled before any request."); + const signals = [AbortSignal.timeout(25000)]; + if (context?.signal) signals.push(context.signal); + const signal = AbortSignal.any(signals); + const csrf = (document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("csrf_token=")) || "").slice(11); + const request = async (operationName, query, variables) => { + if (signal.aborted) throw fail("cancelled", "Cancelled before the request."); + let r, raw; + try { + r = await fetch("/graphql/" + operationName + "?operation=" + operationName, { method: "POST", credentials: "include", signal, headers: { "content-type": "application/json", "x-csrftoken": csrf, "x-channel-id": "marketplace", "x-experience-id": "doordash" }, body: JSON.stringify({ operationName, variables, query }) }); + raw = await r.text(); + } catch { throw fail("site_error", "DoorDash did not answer or the read was cancelled."); } + if (r.status === 401) throw fail("login_required", "Sign in to the intended DoorDash account manually."); + if (r.status === 429) throw fail("rate_limited", "DoorDash is rate limiting this session. Do not retry automatically."); + if (r.status === 403) throw fail("blocked", "DoorDash denied this request. Resolve access manually; do not bypass or retry automatically."); + if (new TextEncoder().encode(raw).byteLength > 2000000) throw fail("site_changed", "DoorDash returned an oversized response."); + let j; + try { j = JSON.parse(raw); } catch { throw fail("blocked", "DoorDash returned a non-JSON page, possibly a challenge. Resolve it manually."); } + if (j.errors?.length) { + const codes = j.errors.map(e => String(e?.extensions?.code || "")).join(" "); + const kind = /UNAUTHENTICATED|UNAUTHORIZED/.test(codes) ? "login_required" : /PERMISSION_DENIED|FORBIDDEN/.test(codes) ? "blocked" : /RATE_LIMIT/.test(codes) ? "rate_limited" : "site_changed"; + throw fail(kind, "DoorDash rejected the query. No private server error text is returned."); + } + if (!r.ok || !j.data || typeof j.data !== "object") throw fail("site_changed", "DoorDash's response is incomplete."); + return j.data; + }; + const accountQuery = "query consumerBucketing { consumer { email isGuest defaultAddress { city state zipCode countryCode } } }"; + const cartQuery = "query listDetailedCarts($input: ListDetailedCartsInput!) { listDetailedCarts(input: $input) { detailedCarts { cart { id groupCart isBundle isCatering isMerchantShipping isPrescriptionDelivery isConsumerPickup currencyCode subtotal total restaurant { id name } orders { id isSubCartFinalized orderItems { id quantity singlePrice priceOfTotalQuantity priceDisplayString cartItemStatusType nestedOptions specialInstructions continuousQuantity purchaseType isPrescriptionItem options { id name quantity price } item { id name storeId } } lineItems { label finalMoney { unitAmount displayString } } } } } } }"; + const text = (v, n = 200) => typeof v === "string" && v.trim() ? v.trim().slice(0, n) : null; + const ident = v => (typeof v === "string" || Number.isSafeInteger(v)) && /^[A-Za-z0-9_-]{1,64}$/.test(String(v)) ? String(v) : null; + const integer = v => Number.isSafeInteger(v) ? v : null; + const bool = v => typeof v === "boolean" ? v : null; + const canonical = v => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v; + const revision = async c => [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(canonical(c)))))].map(b => b.toString(16).padStart(2, "0")).join(""); + const readAccount = async () => { + const data = await request("consumerBucketing", accountQuery, {}); + const c = data.consumer; + if (!c || c.isGuest === true || !text(c.email)) throw fail("login_required", "A non-guest signed-in account is required. Sign in manually."); + if (c.isGuest !== false) throw fail("site_changed", "DoorDash did not confirm the account type."); + if (c.email.trim().toLowerCase() !== input.expected_account_email.toLowerCase()) throw fail("wrong_account", "This browser is signed in to a different account. Nothing was changed."); + return c; + }; + const readCarts = async () => { + const data = await request("listDetailedCarts", cartQuery, { input: {} }); + const list = data.listDetailedCarts?.detailedCarts; + if (!Array.isArray(list)) throw fail("site_changed", "DoorDash did not return its open-cart list."); + return list.map(d => d?.cart); + }; + const project = async c => { + if (!c || !ident(c.id) || !Array.isArray(c.orders) || c.orders.some(o => !o || !Array.isArray(o.orderItems))) throw fail("site_changed", "An open cart has an unsupported shape."); + const rawLines = c.orders.flatMap(o => o.orderItems); + if (rawLines.some(l => !l || !ident(l.id))) throw fail("site_changed", "A cart line has no usable id."); + const rawFees = c.orders.flatMap(o => Array.isArray(o.lineItems) ? o.lineItems : []); + return { cart_id: ident(c.id), store_id: ident(c.restaurant?.id), store_name: text(c.restaurant?.name), fulfillment_type: c.isConsumerPickup === true ? "pickup" : c.isConsumerPickup === false ? "delivery" : "unknown", group_cart: bool(c.groupCart), bundle: bool(c.isBundle), catering: bool(c.isCatering), merchant_shipping: bool(c.isMerchantShipping), currency: text(c.currencyCode, 10), subtotal_minor: integer(c.subtotal), total_minor: integer(c.total), lines: rawLines.slice(0, 100).map(l => ({ line_id: ident(l.id), item_id: ident(l.item?.id), name: text(l.item?.name), quantity: typeof l.quantity === "number" && Number.isFinite(l.quantity) ? l.quantity : null, unit_price_minor: integer(l.singlePrice), total_minor: integer(l.priceOfTotalQuantity), price_display: text(l.priceDisplayString), options: (Array.isArray(l.options) ? l.options : []).slice(0, 50).map(o => ({ option_id: ident(o?.id) || "unknown", name: text(o?.name), quantity: typeof o?.quantity === "number" && Number.isFinite(o.quantity) ? o.quantity : null, price_minor: integer(o?.price) })) })), fee_lines: rawFees.slice(0, 30).map(l => ({ label: text(l?.label), amount_minor: integer(l?.finalMoney?.unitAmount), display: text(l?.finalMoney?.displayString) })), revision: await revision(c), truncated: rawLines.length > 100 || rawFees.length > 30 || rawLines.some(l => Array.isArray(l.options) && l.options.length > 50) }; + }; + const deliveryContext = c => { + const label = (document.querySelector("[data-testid='addressTextButton']")?.innerText || "").trim(); + const basis = label ? /^(?:your address|tu direcci[oó]n)$/i.test(label) ? "no_address_selected" : /\d/.test(label) ? "selected_address" : "unknown" : document.querySelector("[data-testid='LandingPageAddressSearch']") ? "no_address_selected" : "unknown"; + return { basis, default_address_region: { city: text(c.defaultAddress?.city), state: text(c.defaultAddress?.state), postal_code: text(c.defaultAddress?.zipCode), country_code: text(c.defaultAddress?.countryCode) }, note: "Basis describes the current header, not a verified cart delivery address. The region is the account's saved default, which may differ. No address is selected or changed. Totals and fees are provisional, not a payable quote." }; + }; + try { + const account = await readAccount(); + const carts = await readCarts(); + const output = { status: "ok", account_matched: true, carts: await Promise.all(carts.slice(0, 20).map(project)), carts_truncated: carts.length > 20, delivery_context: deliveryContext(account) }; + if (new TextEncoder().encode(JSON.stringify(output)).byteLength > 180000) return fail("blocked", "The cart list exceeds the safe output limit; inspect the site manually."); + return output; + } catch (error) { + return error?.status === "error" ? error : fail("site_changed", "DoorDash returned an unsupported cart or account shape. Nothing was changed."); + } + } +}] diff --git a/domains/doordash.com/get_checkout_summary.js b/domains/doordash.com/get_checkout_summary.js new file mode 100644 index 00000000..f1ffb54d --- /dev/null +++ b/domains/doordash.com/get_checkout_summary.js @@ -0,0 +1,208 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.doordash.com/*"] }, + tool: { + name: "get_checkout_summary", + description: "Prepare a read-only DoorDash order summary for an exact open cart: lines, provisional fees/totals, currency and explicit delivery context, plus a manually opened checkout URL. Requires intended expected_account_email and cart_id. Reads open carts only; does not load checkout, reserve inventory, select/change an address, tip, payment or scheduling, accept terms, or place an order. ready_for_order is always false: no trusted human approval path is provided by this runtime. Totals can be incomplete; final cost/account/address must be reviewed in the site UI. Not an execution tool.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["expected_account_email","cart_id"], + properties: { + expected_account_email: { + type: "string", + minLength: 3, + maxLength: 254, + pattern: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$", + description: "The intended account email supplied by the user. Compared with the current session before reading carts or changing anything; never returned. Not a human approval token." + }, + cart_id: {"type":"string","pattern":"^[A-Za-z0-9_-]{1,64}$"} + } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", + additionalProperties: false, + required: ["status","account_matched","cart","delivery_context","checkout_url","ready_for_order","execution_blocker"], + properties: { + status: {"const":"ok"}, + account_matched: {"const":true}, + cart: { + type: "object", + additionalProperties: false, + required: ["cart_id","store_id","store_name","fulfillment_type","group_cart","bundle","catering","merchant_shipping","currency","subtotal_minor","total_minor","lines","fee_lines","revision","truncated"], + properties: { + cart_id: {"type":"string","maxLength":64}, + store_id: {"type":["string","null"],"maxLength":200}, + store_name: {"type":["string","null"],"maxLength":200}, + fulfillment_type: {"enum":["pickup","delivery","unknown"]}, + group_cart: {"type":["boolean","null"]}, + bundle: {"type":["boolean","null"]}, + catering: {"type":["boolean","null"]}, + merchant_shipping: {"type":["boolean","null"]}, + currency: {"type":["string","null"],"maxLength":10}, + subtotal_minor: {"type":["integer","null"]}, + total_minor: {"type":["integer","null"]}, + lines: { + type: "array", + maxItems: 100, + items: { + type: "object", + additionalProperties: false, + required: ["line_id","item_id","name","quantity","unit_price_minor","total_minor","price_display","options"], + properties: { + line_id: {"type":"string","maxLength":64}, + item_id: {"type":["string","null"],"maxLength":200}, + name: {"type":["string","null"],"maxLength":200}, + quantity: {"type":["number","null"]}, + unit_price_minor: {"type":["integer","null"]}, + total_minor: {"type":["integer","null"]}, + price_display: {"type":["string","null"],"maxLength":200}, + options: { + type: "array", + maxItems: 50, + items: { + type: "object", + additionalProperties: false, + required: ["option_id","name","quantity","price_minor"], + properties: {"option_id":{"type":"string","maxLength":64},"name":{"type":["string","null"],"maxLength":200},"quantity":{"type":["number","null"]},"price_minor":{"type":["integer","null"]}} + } + } + } + } + }, + fee_lines: { + type: "array", + maxItems: 30, + items: { + type: "object", + additionalProperties: false, + required: ["label","amount_minor","display"], + properties: {"label":{"type":["string","null"],"maxLength":200},"amount_minor":{"type":["integer","null"]},"display":{"type":["string","null"],"maxLength":200}} + } + }, + revision: {"type":"string","pattern":"^[a-f0-9]{64}$"}, + truncated: {"type":"boolean"} + } + }, + delivery_context: { + type: "object", + additionalProperties: false, + required: ["basis","default_address_region","note"], + properties: { + basis: {"enum":["selected_address","no_address_selected","unknown"]}, + default_address_region: { + type: "object", + additionalProperties: false, + required: ["city","state","postal_code","country_code"], + properties: { + city: {"type":["string","null"],"maxLength":200}, + state: {"type":["string","null"],"maxLength":200}, + postal_code: {"type":["string","null"],"maxLength":200}, + country_code: {"type":["string","null"],"maxLength":200} + } + }, + note: {"type":"string","maxLength":500} + } + }, + checkout_url: {"type":"string","pattern":"^https://www\\.doordash\\.com/consumer/checkout/\\?order_cart_id=[A-Za-z0-9_-]+$"}, + ready_for_order: {"const":false}, + execution_blocker: {"type":"string","maxLength":300} + } + }, + { + type: "object", + additionalProperties: false, + required: ["status","error_kind","error_message","write_attempted"], + properties: { + status: {"const":"error"}, + error_kind: {"enum":["invalid_input","wrong_page","login_required","wrong_account","blocked","rate_limited","site_changed","site_error","cancelled","not_found","stale_state","busy","outcome_unknown"]}, + error_message: {"type":"string","maxLength":400}, + write_attempted: {"type":"boolean"} + } + } + ] + }, + annotations: {"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"untrustedContentHint":true} + }, + execute: async (input, context) => { + const fail = (error_kind, error_message, write_attempted = false) => ({ status: "error", error_kind, error_message, write_attempted }); + const allowed = ["expected_account_email","cart_id"]; + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k)) || allowed.some(k => typeof input[k] !== "string") || input.expected_account_email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.expected_account_email)) return fail("invalid_input", "Supply only the documented fields, including the intended account email. No request was made."); + if (!/^[A-Za-z0-9_-]{1,64}$/.test(input.cart_id)) return fail("invalid_input", "Invalid cart_id. No request was made."); + if (location.origin !== "https://www.doordash.com") return fail("wrong_page", "Open www.doordash.com in this tab. Nothing was changed."); + if (context?.signal?.aborted) return fail("cancelled", "Cancelled before any request."); + const signals = [AbortSignal.timeout(25000)]; + if (context?.signal) signals.push(context.signal); + const signal = AbortSignal.any(signals); + const csrf = (document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("csrf_token=")) || "").slice(11); + const request = async (operationName, query, variables) => { + if (signal.aborted) throw fail("cancelled", "Cancelled before the request."); + let r, raw; + try { + r = await fetch("/graphql/" + operationName + "?operation=" + operationName, { method: "POST", credentials: "include", signal, headers: { "content-type": "application/json", "x-csrftoken": csrf, "x-channel-id": "marketplace", "x-experience-id": "doordash" }, body: JSON.stringify({ operationName, variables, query }) }); + raw = await r.text(); + } catch { throw fail("site_error", "DoorDash did not answer or the read was cancelled."); } + if (r.status === 401) throw fail("login_required", "Sign in to the intended DoorDash account manually."); + if (r.status === 429) throw fail("rate_limited", "DoorDash is rate limiting this session. Do not retry automatically."); + if (r.status === 403) throw fail("blocked", "DoorDash denied this request. Resolve access manually; do not bypass or retry automatically."); + if (new TextEncoder().encode(raw).byteLength > 2000000) throw fail("site_changed", "DoorDash returned an oversized response."); + let j; + try { j = JSON.parse(raw); } catch { throw fail("blocked", "DoorDash returned a non-JSON page, possibly a challenge. Resolve it manually."); } + if (j.errors?.length) { + const codes = j.errors.map(e => String(e?.extensions?.code || "")).join(" "); + const kind = /UNAUTHENTICATED|UNAUTHORIZED/.test(codes) ? "login_required" : /PERMISSION_DENIED|FORBIDDEN/.test(codes) ? "blocked" : /RATE_LIMIT/.test(codes) ? "rate_limited" : "site_changed"; + throw fail(kind, "DoorDash rejected the query. No private server error text is returned."); + } + if (!r.ok || !j.data || typeof j.data !== "object") throw fail("site_changed", "DoorDash's response is incomplete."); + return j.data; + }; + const accountQuery = "query consumerBucketing { consumer { email isGuest defaultAddress { city state zipCode countryCode } } }"; + const cartQuery = "query listDetailedCarts($input: ListDetailedCartsInput!) { listDetailedCarts(input: $input) { detailedCarts { cart { id groupCart isBundle isCatering isMerchantShipping isPrescriptionDelivery isConsumerPickup currencyCode subtotal total restaurant { id name } orders { id isSubCartFinalized orderItems { id quantity singlePrice priceOfTotalQuantity priceDisplayString cartItemStatusType nestedOptions specialInstructions continuousQuantity purchaseType isPrescriptionItem options { id name quantity price } item { id name storeId } } lineItems { label finalMoney { unitAmount displayString } } } } } } }"; + const text = (v, n = 200) => typeof v === "string" && v.trim() ? v.trim().slice(0, n) : null; + const ident = v => (typeof v === "string" || Number.isSafeInteger(v)) && /^[A-Za-z0-9_-]{1,64}$/.test(String(v)) ? String(v) : null; + const integer = v => Number.isSafeInteger(v) ? v : null; + const bool = v => typeof v === "boolean" ? v : null; + const canonical = v => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v; + const revision = async c => [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(canonical(c)))))].map(b => b.toString(16).padStart(2, "0")).join(""); + const readAccount = async () => { + const data = await request("consumerBucketing", accountQuery, {}); + const c = data.consumer; + if (!c || c.isGuest === true || !text(c.email)) throw fail("login_required", "A non-guest signed-in account is required. Sign in manually."); + if (c.isGuest !== false) throw fail("site_changed", "DoorDash did not confirm the account type."); + if (c.email.trim().toLowerCase() !== input.expected_account_email.toLowerCase()) throw fail("wrong_account", "This browser is signed in to a different account. Nothing was changed."); + return c; + }; + const readCarts = async () => { + const data = await request("listDetailedCarts", cartQuery, { input: {} }); + const list = data.listDetailedCarts?.detailedCarts; + if (!Array.isArray(list)) throw fail("site_changed", "DoorDash did not return its open-cart list."); + return list.map(d => d?.cart); + }; + const project = async c => { + if (!c || !ident(c.id) || !Array.isArray(c.orders) || c.orders.some(o => !o || !Array.isArray(o.orderItems))) throw fail("site_changed", "An open cart has an unsupported shape."); + const rawLines = c.orders.flatMap(o => o.orderItems); + if (rawLines.some(l => !l || !ident(l.id))) throw fail("site_changed", "A cart line has no usable id."); + const rawFees = c.orders.flatMap(o => Array.isArray(o.lineItems) ? o.lineItems : []); + return { cart_id: ident(c.id), store_id: ident(c.restaurant?.id), store_name: text(c.restaurant?.name), fulfillment_type: c.isConsumerPickup === true ? "pickup" : c.isConsumerPickup === false ? "delivery" : "unknown", group_cart: bool(c.groupCart), bundle: bool(c.isBundle), catering: bool(c.isCatering), merchant_shipping: bool(c.isMerchantShipping), currency: text(c.currencyCode, 10), subtotal_minor: integer(c.subtotal), total_minor: integer(c.total), lines: rawLines.slice(0, 100).map(l => ({ line_id: ident(l.id), item_id: ident(l.item?.id), name: text(l.item?.name), quantity: typeof l.quantity === "number" && Number.isFinite(l.quantity) ? l.quantity : null, unit_price_minor: integer(l.singlePrice), total_minor: integer(l.priceOfTotalQuantity), price_display: text(l.priceDisplayString), options: (Array.isArray(l.options) ? l.options : []).slice(0, 50).map(o => ({ option_id: ident(o?.id) || "unknown", name: text(o?.name), quantity: typeof o?.quantity === "number" && Number.isFinite(o.quantity) ? o.quantity : null, price_minor: integer(o?.price) })) })), fee_lines: rawFees.slice(0, 30).map(l => ({ label: text(l?.label), amount_minor: integer(l?.finalMoney?.unitAmount), display: text(l?.finalMoney?.displayString) })), revision: await revision(c), truncated: rawLines.length > 100 || rawFees.length > 30 || rawLines.some(l => Array.isArray(l.options) && l.options.length > 50) }; + }; + const deliveryContext = c => { + const label = (document.querySelector("[data-testid='addressTextButton']")?.innerText || "").trim(); + const basis = label ? /^(?:your address|tu direcci[oó]n)$/i.test(label) ? "no_address_selected" : /\d/.test(label) ? "selected_address" : "unknown" : document.querySelector("[data-testid='LandingPageAddressSearch']") ? "no_address_selected" : "unknown"; + return { basis, default_address_region: { city: text(c.defaultAddress?.city), state: text(c.defaultAddress?.state), postal_code: text(c.defaultAddress?.zipCode), country_code: text(c.defaultAddress?.countryCode) }, note: "Basis describes the current header, not a verified cart delivery address. The region is the account's saved default, which may differ. No address is selected or changed. Totals and fees are provisional, not a payable quote." }; + }; + try { + const account = await readAccount(); + const carts = await readCarts(); + const matching = carts.filter(c => c?.id === input.cart_id); + if (matching.length !== 1) return fail("not_found", "The exact open cart was not found in this account."); + const projected = await project(matching[0]); + if (new TextEncoder().encode(JSON.stringify(projected)).byteLength > 170000) return fail("blocked", "The cart exceeds safe summary bounds; inspect the site manually."); + return { status: "ok", account_matched: true, cart: projected, delivery_context: deliveryContext(account), checkout_url: "https://www.doordash.com/consumer/checkout/?order_cart_id=" + input.cart_id, ready_for_order: false, execution_blocker: "Final order placement/payment is unshipped: no supported trusted human approval enforcement exists. Review the exact account, items, address, price and terms manually on DoorDash." }; + } catch (error) { + return error?.status === "error" ? error : fail("site_changed", "DoorDash returned an unsupported cart or account shape. Nothing was changed."); + } + } +}] diff --git a/domains/doordash.com/remove_cart_item.js b/domains/doordash.com/remove_cart_item.js new file mode 100644 index 00000000..94e1f5f6 --- /dev/null +++ b/domains/doordash.com/remove_cart_item.js @@ -0,0 +1,222 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.doordash.com/*"] }, + tool: { + name: "remove_cart_item", + description: "Remove exactly one existing line from an ordinary personal DoorDash cart using the site's removeCartItem operation. Requires the user's intended expected_account_email, cart_id, line_id (NOT menu item id), expected_store_id and a fresh get_cart expected_revision. Checks the current account, queried cart revision and store immediately before one write; refuses group, bundle, catering, shipping, finalized/unsupported carts and last-line removal. Does not change address, payment, quantity/options of other lines, or place an order. Returns removed only after rereading the open carts proves this line absent and all other queried cart identity/line fields unchanged. Never retries writes: an uncertain outcome leaves a sessionStorage guard; inspect the cart manually rather than reinvoking. No live mutation QA has been authorized; mutation evidence is fabricated fixtures only. Call serially; server provides no atomic compare-and-swap, so cross-tab changes remain a race.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["expected_account_email","cart_id","line_id","expected_store_id","expected_revision"], + properties: { + expected_account_email: { + type: "string", + minLength: 3, + maxLength: 254, + pattern: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$", + description: "The intended account email supplied by the user. Compared with the current session before reading carts or changing anything; never returned. Not a human approval token." + }, + cart_id: {"type":"string","pattern":"^[A-Za-z0-9_-]{1,64}$"}, + line_id: {"type":"string","pattern":"^[A-Za-z0-9_-]{1,64}$"}, + expected_store_id: {"type":"string","pattern":"^[1-9][0-9]{0,11}$"}, + expected_revision: {"type":"string","pattern":"^[a-f0-9]{64}$","description":"Fresh get_cart revision of the exact cart. All cart fields must still match. Not approval."} + } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", + additionalProperties: false, + required: ["status","account_matched","removed","cart_id","line_id","write_attempted","remaining_cart"], + properties: { + status: {"const":"ok"}, + account_matched: {"const":true}, + removed: {"const":true}, + cart_id: {"type":"string","pattern":"^[A-Za-z0-9_-]{1,64}$"}, + line_id: {"type":"string","pattern":"^[A-Za-z0-9_-]{1,64}$"}, + write_attempted: {"const":true}, + remaining_cart: { + type: "object", + additionalProperties: false, + required: ["cart_id","store_id","store_name","fulfillment_type","group_cart","bundle","catering","merchant_shipping","currency","subtotal_minor","total_minor","lines","fee_lines","revision","truncated"], + properties: { + cart_id: {"type":"string","maxLength":64}, + store_id: {"type":["string","null"],"maxLength":200}, + store_name: {"type":["string","null"],"maxLength":200}, + fulfillment_type: {"enum":["pickup","delivery","unknown"]}, + group_cart: {"type":["boolean","null"]}, + bundle: {"type":["boolean","null"]}, + catering: {"type":["boolean","null"]}, + merchant_shipping: {"type":["boolean","null"]}, + currency: {"type":["string","null"],"maxLength":10}, + subtotal_minor: {"type":["integer","null"]}, + total_minor: {"type":["integer","null"]}, + lines: { + type: "array", + maxItems: 100, + items: { + type: "object", + additionalProperties: false, + required: ["line_id","item_id","name","quantity","unit_price_minor","total_minor","price_display","options"], + properties: { + line_id: {"type":"string","maxLength":64}, + item_id: {"type":["string","null"],"maxLength":200}, + name: {"type":["string","null"],"maxLength":200}, + quantity: {"type":["number","null"]}, + unit_price_minor: {"type":["integer","null"]}, + total_minor: {"type":["integer","null"]}, + price_display: {"type":["string","null"],"maxLength":200}, + options: { + type: "array", + maxItems: 50, + items: { + type: "object", + additionalProperties: false, + required: ["option_id","name","quantity","price_minor"], + properties: {"option_id":{"type":"string","maxLength":64},"name":{"type":["string","null"],"maxLength":200},"quantity":{"type":["number","null"]},"price_minor":{"type":["integer","null"]}} + } + } + } + } + }, + fee_lines: { + type: "array", + maxItems: 30, + items: { + type: "object", + additionalProperties: false, + required: ["label","amount_minor","display"], + properties: {"label":{"type":["string","null"],"maxLength":200},"amount_minor":{"type":["integer","null"]},"display":{"type":["string","null"],"maxLength":200}} + } + }, + revision: {"type":"string","pattern":"^[a-f0-9]{64}$"}, + truncated: {"type":"boolean"} + } + } + } + }, + { + type: "object", + additionalProperties: false, + required: ["status","error_kind","error_message","write_attempted"], + properties: { + status: {"const":"error"}, + error_kind: {"enum":["invalid_input","wrong_page","login_required","wrong_account","blocked","rate_limited","site_changed","site_error","cancelled","not_found","stale_state","busy","outcome_unknown"]}, + error_message: {"type":"string","maxLength":400}, + write_attempted: {"type":"boolean"} + } + } + ] + }, + annotations: {"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"untrustedContentHint":true} + }, + execute: async (input, context) => { + const fail = (error_kind, error_message, write_attempted = false) => ({ status: "error", error_kind, error_message, write_attempted }); + const allowed = ["expected_account_email","cart_id","line_id","expected_store_id","expected_revision"]; + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k)) || allowed.some(k => typeof input[k] !== "string") || input.expected_account_email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.expected_account_email)) return fail("invalid_input", "Supply only the documented fields, including the intended account email. No request was made."); + if (![input.cart_id, input.line_id].every(v => /^[A-Za-z0-9_-]{1,64}$/.test(v)) || !/^[1-9][0-9]{0,11}$/.test(input.expected_store_id) || !/^[a-f0-9]{64}$/.test(input.expected_revision)) return fail("invalid_input", "Invalid cart, line, store or revision. No request was made."); + if (location.origin !== "https://www.doordash.com") return fail("wrong_page", "Open www.doordash.com in this tab. Nothing was changed."); + if (context?.signal?.aborted) return fail("cancelled", "Cancelled before any request."); + const signals = [AbortSignal.timeout(25000)]; + if (context?.signal) signals.push(context.signal); + const signal = AbortSignal.any(signals); + const csrf = (document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("csrf_token=")) || "").slice(11); + let writeAttempted = false; + const request = async (operationName, query, variables) => { + if (signal.aborted) throw fail(writeAttempted ? "outcome_unknown" : "cancelled", writeAttempted ? "The write outcome is unknown. Inspect the cart manually; do not retry automatically." : "Cancelled before the request.", writeAttempted); + let r, raw; + try { + r = await fetch("/graphql/" + operationName + "?operation=" + operationName, { method: "POST", credentials: "include", signal, headers: { "content-type": "application/json", "x-csrftoken": csrf, "x-channel-id": "marketplace", "x-experience-id": "doordash" }, body: JSON.stringify({ operationName, variables, query }) }); + raw = await r.text(); + } catch { throw fail(writeAttempted ? "outcome_unknown" : "site_error", writeAttempted ? "The write outcome is unknown. Inspect the cart manually; do not retry automatically." : "DoorDash did not answer or the read was cancelled.", writeAttempted); } + if (writeAttempted && !r.ok) throw fail("outcome_unknown", "DoorDash did not confirm the cart write. Inspect manually; do not retry automatically.", true); + if (r.status === 401) throw fail("login_required", "Sign in to the intended DoorDash account manually."); + if (r.status === 429) throw fail("rate_limited", "DoorDash is rate limiting this session. Do not retry automatically."); + if (r.status === 403) throw fail("blocked", "DoorDash denied this request. Resolve access manually; do not bypass or retry automatically."); + if (new TextEncoder().encode(raw).byteLength > 2000000) throw fail(writeAttempted ? "outcome_unknown" : "site_changed", "DoorDash returned an oversized response.", writeAttempted); + let j; + try { j = JSON.parse(raw); } catch { throw fail(writeAttempted ? "outcome_unknown" : "blocked", writeAttempted ? "The cart write has no readable confirmation. Inspect manually; do not retry." : "DoorDash returned a non-JSON page, possibly a challenge. Resolve it manually.", writeAttempted); } + if (j.errors?.length) { + const codes = j.errors.map(e => String(e?.extensions?.code || "")).join(" "); + const kind = writeAttempted ? "outcome_unknown" : /UNAUTHENTICATED|UNAUTHORIZED/.test(codes) ? "login_required" : /PERMISSION_DENIED|FORBIDDEN/.test(codes) ? "blocked" : /RATE_LIMIT/.test(codes) ? "rate_limited" : "site_changed"; + throw fail(kind, writeAttempted ? "DoorDash did not confirm the cart write. Inspect manually; do not retry automatically." : "DoorDash rejected the query. No private server error text is returned.", writeAttempted); + } + if (!r.ok || !j.data || typeof j.data !== "object") throw fail(writeAttempted ? "outcome_unknown" : "site_changed", "DoorDash's response is incomplete.", writeAttempted); + return j.data; + }; + const accountQuery = "query consumerBucketing { consumer { email isGuest } }"; + const cartQuery = "query listDetailedCarts($input: ListDetailedCartsInput!) { listDetailedCarts(input: $input) { detailedCarts { cart { id groupCart isBundle isCatering isMerchantShipping isPrescriptionDelivery isConsumerPickup currencyCode subtotal total restaurant { id name } orders { id isSubCartFinalized orderItems { id quantity singlePrice priceOfTotalQuantity priceDisplayString cartItemStatusType nestedOptions specialInstructions continuousQuantity purchaseType isPrescriptionItem options { id name quantity price } item { id name storeId } } lineItems { label finalMoney { unitAmount displayString } } } } } } }"; + const text = (v, n = 200) => typeof v === "string" && v.trim() ? v.trim().slice(0, n) : null; + const ident = v => (typeof v === "string" || Number.isSafeInteger(v)) && /^[A-Za-z0-9_-]{1,64}$/.test(String(v)) ? String(v) : null; + const integer = v => Number.isSafeInteger(v) ? v : null; + const bool = v => typeof v === "boolean" ? v : null; + const canonical = v => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v; + const revision = async c => [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(canonical(c)))))].map(b => b.toString(16).padStart(2, "0")).join(""); + const readAccount = async () => { + const data = await request("consumerBucketing", accountQuery, {}); + const c = data.consumer; + if (!c || c.isGuest === true || !text(c.email)) throw fail("login_required", "A non-guest signed-in account is required. Sign in manually."); + if (c.isGuest !== false) throw fail("site_changed", "DoorDash did not confirm the account type."); + if (c.email.trim().toLowerCase() !== input.expected_account_email.toLowerCase()) throw fail("wrong_account", "This browser is signed in to a different account. Nothing was changed."); + return c; + }; + const readCarts = async () => { + const data = await request("listDetailedCarts", cartQuery, { input: {} }); + const list = data.listDetailedCarts?.detailedCarts; + if (!Array.isArray(list)) throw fail("site_changed", "DoorDash did not return its open-cart list."); + return list.map(d => d?.cart); + }; + const project = async c => { + if (!c || !ident(c.id) || !Array.isArray(c.orders) || c.orders.some(o => !o || !Array.isArray(o.orderItems))) throw fail("site_changed", "An open cart has an unsupported shape."); + const rawLines = c.orders.flatMap(o => o.orderItems); + if (rawLines.some(l => !l || !ident(l.id))) throw fail("site_changed", "A cart line has no usable id."); + const rawFees = c.orders.flatMap(o => Array.isArray(o.lineItems) ? o.lineItems : []); + return { cart_id: ident(c.id), store_id: ident(c.restaurant?.id), store_name: text(c.restaurant?.name), fulfillment_type: c.isConsumerPickup === true ? "pickup" : c.isConsumerPickup === false ? "delivery" : "unknown", group_cart: bool(c.groupCart), bundle: bool(c.isBundle), catering: bool(c.isCatering), merchant_shipping: bool(c.isMerchantShipping), currency: text(c.currencyCode, 10), subtotal_minor: integer(c.subtotal), total_minor: integer(c.total), lines: rawLines.slice(0, 100).map(l => ({ line_id: ident(l.id), item_id: ident(l.item?.id), name: text(l.item?.name), quantity: typeof l.quantity === "number" && Number.isFinite(l.quantity) ? l.quantity : null, unit_price_minor: integer(l.singlePrice), total_minor: integer(l.priceOfTotalQuantity), price_display: text(l.priceDisplayString), options: (Array.isArray(l.options) ? l.options : []).slice(0, 50).map(o => ({ option_id: ident(o?.id) || "unknown", name: text(o?.name), quantity: typeof o?.quantity === "number" && Number.isFinite(o.quantity) ? o.quantity : null, price_minor: integer(o?.price) })) })), fee_lines: rawFees.slice(0, 30).map(l => ({ label: text(l?.label), amount_minor: integer(l?.finalMoney?.unitAmount), display: text(l?.finalMoney?.displayString) })), revision: await revision(c), truncated: rawLines.length > 100 || rawFees.length > 30 || rawLines.some(l => Array.isArray(l.options) && l.options.length > 50) }; + }; + + try { + await readAccount(); + const carts = await readCarts(); + const matches = carts.filter(c => c?.id === input.cart_id); + if (matches.length !== 1) return fail("not_found", "The exact open cart is not present in this account."); + const before = matches[0]; + if (String(before.restaurant?.id) !== input.expected_store_id) return fail("stale_state", "The cart belongs to another store. Nothing was changed."); + if (before.groupCart !== false || before.isBundle !== false || before.isCatering !== false || before.isMerchantShipping !== false || before.isPrescriptionDelivery !== false || before.orders?.length !== 1 || before.orders[0]?.isSubCartFinalized !== false) return fail("blocked", "Only ordinary personal, non-bundle, non-catering, non-shipping carts with one order are supported."); + const projectedBefore = await project(before); + if (projectedBefore.truncated || new TextEncoder().encode(JSON.stringify(projectedBefore)).byteLength > 170000) return fail("blocked", "The complete cart exceeds safe bounds. Nothing was changed."); + const lines = before.orders[0].orderItems; + if (!Array.isArray(lines) || lines.length < 2 || lines.length > 100 || lines.some(l => !l || !ident(l.id))) return fail("blocked", "Last-line removal or an incomplete/unsupported cart is not supported."); + if (lines.filter(l => String(l.id) === input.line_id).length !== 1) return fail("not_found", "That exact line is not present exactly once in this cart."); + if (await revision(before) !== input.expected_revision) return fail("stale_state", "The cart changed after get_cart. Read it again; no write was sent."); + await readAccount(); + if (signal.aborted) return fail("cancelled", "Cancelled before the cart write."); + const key = "doordash.com.remove_cart_item.pending.v1:" + input.cart_id; + let lock; + try { + if (sessionStorage.getItem(key)) return fail("busy", "A previous cart write has an uncertain or in-flight outcome. Inspect manually; do not retry."); + lock = crypto.randomUUID(); + sessionStorage.setItem(key, lock); + if (sessionStorage.getItem(key) !== lock) return fail("busy", "Another cart write is in progress."); + } catch { return fail("blocked", "The duplicate-write guard is unavailable; no write was sent."); } + if (signal.aborted) { sessionStorage.removeItem(key); return fail("cancelled", "Cancelled before the cart write."); } + writeAttempted = true; + const result = await request("removeCartItem", "mutation removeCartItem($cartId: ID!, $itemId: ID!, $returnCartFromOrderService: Boolean, $monitoringContext: MonitoringContextInput, $cartContext: CartContextInput) { removeCartItemV2(cartId: $cartId, itemId: $itemId, returnCartFromOrderService: $returnCartFromOrderService, monitoringContext: $monitoringContext, cartContext: $cartContext) { id } }", { cartId: input.cart_id, itemId: input.line_id, returnCartFromOrderService: false, monitoringContext: { isGroup: false }, cartContext: { deleteBundleCarts: false } }); + if (result.removeCartItemV2?.id !== input.cart_id) return fail("outcome_unknown", "DoorDash did not acknowledge the exact cart. Inspect manually; do not retry.", true); + await readAccount(); + const afterList = await readCarts(); + const afterMatches = afterList.filter(c => c?.id === input.cart_id); + if (afterMatches.length !== 1) return fail("outcome_unknown", "The cart could not be observed after the write. Inspect manually; do not retry.", true); + const after = afterMatches[0]; + const expected = { ...before, subtotal: after.subtotal, total: after.total, orders: before.orders.map(o => ({ ...o, orderItems: o.orderItems.filter(l => String(l.id) !== input.line_id), lineItems: after.orders?.[0]?.lineItems })) }; + if (JSON.stringify(canonical(expected)) !== JSON.stringify(canonical(after))) return fail("outcome_unknown", "The exact removal with unchanged other lines was not confirmed. Inspect manually; do not retry.", true); + const remaining = await project(after); + if (remaining.truncated || new TextEncoder().encode(JSON.stringify(remaining)).byteLength > 170000) return fail("outcome_unknown", "The remaining cart exceeds safe observation bounds. Inspect manually; do not retry.", true); + if (sessionStorage.getItem(key) === lock) sessionStorage.removeItem(key); + return { status: "ok", account_matched: true, removed: true, cart_id: input.cart_id, line_id: input.line_id, write_attempted: true, remaining_cart: remaining }; + } catch (error) { + if (writeAttempted) return fail("outcome_unknown", "The exact cart write postcondition was not observed. Inspect manually; do not retry automatically.", true); + return error?.status === "error" ? error : fail("site_changed", "DoorDash returned an unsupported cart or account shape. Nothing was changed."); + } + } +}] diff --git a/registry.json b/registry.json index 1a02463b..58194937 100644 --- a/registry.json +++ b/registry.json @@ -113,6 +113,32 @@ "last_verified": null, "notes": "Back to draft after a source change on 2026-09-29: airport boxes are now cleared through the page and typed with the inlined lib/input.js cadence, and the typed code is checked before choosing (fixes 'CVCVG' on macOS Chrome, where CDP Meta+A does not select all). First successful full live invocation: CVG-SFO one way, 1 adult, regular Chrome 154 on macOS via the xwebmcp extension, status ok with first-page flights. Needs fresh independent QA; earlier qa_passed sign-off (agt_b189571b6d300a05921c) covered the previous source." }, + { + "domain": "doordash.com", + "tool": "get_cart", + "file": "domains/doordash.com/get_cart.js", + "kind": "page", + "namespace": "doordash.com", + "url_patterns": [ + "https://www.doordash.com/*" + ], + "status": "draft", + "last_verified": null, + "notes": "Account-checked cart read/order preparation." + }, + { + "domain": "doordash.com", + "tool": "get_checkout_summary", + "file": "domains/doordash.com/get_checkout_summary.js", + "kind": "page", + "namespace": "doordash.com", + "url_patterns": [ + "https://www.doordash.com/*" + ], + "status": "draft", + "last_verified": null, + "notes": "Account-checked cart read/order preparation." + }, { "domain": "doordash.com", "tool": "get_store", @@ -146,6 +172,19 @@ "qa_agent": "agt_5b1753eb7444ed26b1d6" } }, + { + "domain": "doordash.com", + "tool": "remove_cart_item", + "file": "domains/doordash.com/remove_cart_item.js", + "kind": "page", + "namespace": "doordash.com", + "url_patterns": [ + "https://www.doordash.com/*" + ], + "status": "draft", + "last_verified": null, + "notes": "Exact personal cart-line removal with revision, account and duplicate-write guards. Mutation QA is fixture-only; no live cart consent." + }, { "domain": "etsy.com", "tool": "add_to_cart", @@ -534,7 +573,7 @@ ], "status": "verified", "last_verified": "2026-09-24", - "notes": "Independent search/parser QA passed; domain migration live-verified from /travel-agents. Returns initial outbound cards only, including round trips; 1\u20137 adults; displayed cash fares are not trip totals. Inventory failures remain explicit errors.", + "notes": "Independent search/parser QA passed; domain migration live-verified from /travel-agents. Returns initial outbound cards only, including round trips; 1–7 adults; displayed cash fares are not trip totals. Inventory failures remain explicit errors.", "provenance": { "qa_agent": "agt_8da83a2e489269a5ff05" } diff --git a/tests/doordash-cart.test.mjs b/tests/doordash-cart.test.mjs new file mode 100644 index 00000000..54c49861 --- /dev/null +++ b/tests/doordash-cart.test.mjs @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; +import { webcrypto } from "node:crypto"; + +// All account, cart and line values are fabricated. No tests contact DoorDash. +const sources = Object.fromEntries(["get_cart", "get_checkout_summary", "remove_cart_item"].map(name => [name, readFileSync(new URL(`../domains/doordash.com/${name}.js`, import.meta.url), "utf8")])); +const plain = value => JSON.parse(JSON.stringify(value)); +const email = "shopper@example.invalid"; +const account = () => ({ email, isGuest: false, defaultAddress: { city: "Testville", state: "ZZ", zipCode: "00000", countryCode: "US", entryCode: "secret-entry" } }); +const item = (id, quantity = 1) => ({ id, quantity, singlePrice: 700, priceOfTotalQuantity: 700 * quantity, priceDisplayString: "$7.00", cartItemStatusType: "CART_ITEM_STATUS_TYPE_UNSPECIFIED", options: [{ id: "extra-1", name: "Tofu", quantity: 1, price: 100 }], item: { id: "menu-" + id, name: "Fabricated bowl", storeId: "1001" } }); +const cart = () => ({ id: "cart-1", groupCart: false, isBundle: false, isCatering: false, isMerchantShipping: false, isPrescriptionDelivery: false, isConsumerPickup: false, currencyCode: "USD", subtotal: 1400, total: 1700, restaurant: { id: "1001", name: "Fabricated Kitchen" }, orders: [{ id: "order-1", isSubCartFinalized: false, orderItems: [item("line-1"), item("line-2")], lineItems: [{ label: "Service fee", finalMoney: { unitAmount: 300, displayString: "$3.00" } }] }] }); + +function conforms(s, v) { + if (s.oneOf && s.oneOf.filter(x => conforms(x, v)).length !== 1) return false; + if (s.anyOf && !s.anyOf.some(x => conforms(x, v))) return false; + if ("const" in s && s.const !== v || s.enum && !s.enum.includes(v)) return false; + if (s.type && ![s.type].flat().some(t => t === "null" ? v === null : t === "integer" ? Number.isInteger(v) : t === "array" ? Array.isArray(v) : t === "object" ? v && typeof v === "object" && !Array.isArray(v) : typeof v === t)) return false; + if (v && typeof v === "object" && !Array.isArray(v)) { + if (s.required?.some(k => !Object.hasOwn(v, k))) return false; + if (s.additionalProperties === false && Object.keys(v).some(k => !Object.hasOwn(s.properties || {}, k))) return false; + if (Object.entries(s.properties || {}).some(([k, x]) => k in v && !conforms(x, v[k]))) return false; + } + if (Array.isArray(v) && (v.length > (s.maxItems ?? Infinity) || s.items && !v.every(x => conforms(s.items, x)))) return false; + if (typeof v === "string" && (v.length < (s.minLength ?? 0) || v.length > (s.maxLength ?? Infinity) || s.pattern && !new RegExp(s.pattern).test(v))) return false; + return true; +} + +function harness(options = {}) { + const calls = [], store = new Map(); + let carts = options.carts ?? [cart()], consumer = options.consumer === undefined ? account() : options.consumer; + const fetch = async (url, init) => { + const body = JSON.parse(init.body); + calls.push({ url, ...body, headers: init.headers, signal: init.signal }); + let data; + const custom = await options.respond?.(body, { carts, consumer, calls }); + if (custom) return { status: custom.status ?? 200, ok: (custom.status ?? 200) < 400, text: async () => custom.raw ?? JSON.stringify(custom.json) }; + if (body.operationName === "consumerBucketing") data = { consumer }; + else if (body.operationName === "listDetailedCarts") data = { listDetailedCarts: { detailedCarts: carts.map(c => ({ cart: c })) } }; + else if (body.operationName === "removeCartItem") { + if (options.writeError) throw new Error("transport-secret"); + if (!options.noRemoval) { + const c = carts.find(c => c.id === body.variables.cartId); + c.orders[0].orderItems = c.orders[0].orderItems.filter(l => l.id !== body.variables.itemId); + c.subtotal = 700; c.total = 1000; + options.afterWrite?.(c); + } + data = { removeCartItemV2: options.nullAck ? null : { id: body.variables.cartId } }; + } else throw new Error("unexpected operation"); + return { status: 200, ok: true, text: async () => JSON.stringify({ data }) }; + }; + const context = { location: { origin: options.origin ?? "https://www.doordash.com", pathname: "/home" }, document: { cookie: "csrf_token=fabricated-token; dd_cx_logged_in=true", querySelector: selector => selector.includes("addressTextButton") && options.header !== null ? { innerText: options.header ?? "1 Fabricated Street" } : null }, fetch, crypto: webcrypto, TextEncoder, AbortSignal, sessionStorage: { getItem: k => options.storageError ? (() => { throw Error("blocked"); })() : store.get(k) ?? null, setItem: (k, v) => store.set(k, v), removeItem: k => store.delete(k) } }; + const definitions = Object.fromEntries(Object.entries(sources).map(([name, source]) => [name, runInNewContext(`(${source})`, context)[0]])); + const run = async (name, input, signal) => { + const result = plain(await definitions[name].execute(input, { signal })); + assert.ok(conforms(definitions[name].tool.outputSchema, result), JSON.stringify(result)); + return result; + }; + return { run, calls, store, definitions, getCarts: () => carts, setConsumer: c => { consumer = c; } }; +} +const read = { expected_account_email: email }; +async function removalInput(h) { + const out = await h.run("get_cart", read); + assert.equal(out.status, "ok"); + return { ...read, cart_id: "cart-1", line_id: "line-1", expected_store_id: "1001", expected_revision: out.carts[0].revision }; +} +const writes = h => h.calls.filter(c => c.operationName === "removeCartItem"); + +for (const name of Object.keys(sources)) { + test(`${name}: page tool and schemas match the registry`, () => { + const h = harness(), d = h.definitions[name]; + const registry = JSON.parse(readFileSync(new URL("../registry.json", import.meta.url), "utf8")); + const entry = registry.tools.find(e => e.domain === "doordash.com" && e.tool === name); + assert.equal(d.kind, "page"); + assert.equal(d.tool.name, name); + assert.deepEqual(plain(d.match.url_patterns), ["https://www.doordash.com/*"]); + assert.deepEqual(entry.url_patterns, plain(d.match.url_patterns)); + assert.equal(d.tool.annotations.readOnlyHint, name !== "remove_cart_item"); + assert.doesNotMatch(sources[name].trimEnd(), /;$/); + assert.doesNotMatch(sources[name], /\brequire\s*\(|\bimport\s|\bBuffer\b|\bprocess\./); + const copied = runInNewContext(`(${d.execute.toString()})`, { location: { origin: "https://example.invalid" } }); + assert.ok(copied); + }); + test(`${name}: malformed input before site interaction`, async () => { + const h = harness(); + for (const bad of [null, [], {}, { expected_account_email: "no-email" }, { ...read, confirmed: true }, { ...read, expected_account_email: " shopper@example.invalid" }, { ...read, expected_account_email: "a".repeat(260) + "@example.invalid" }]) { + assert.equal((await h.run(name, bad)).error_kind, "invalid_input"); + } + assert.equal(h.calls.length, 0); + }); + test(`${name}: wrong page and already cancelled`, async () => { + const input = name === "remove_cart_item" ? { ...read, cart_id: "cart-1", line_id: "line-1", expected_store_id: "1001", expected_revision: "a".repeat(64) } : name === "get_checkout_summary" ? { ...read, cart_id: "cart-1" } : read; + const h = harness({ origin: "https://help.doordash.com" }); + assert.equal((await h.run(name, input)).error_kind, "wrong_page"); + assert.equal(h.calls.length, 0); + const aborted = new AbortController(); aborted.abort(); + const h2 = harness(); + assert.equal((await h2.run(name, input, aborted.signal)).error_kind, "cancelled"); + assert.equal(h2.calls.length, 0); + }); +} + +test("cart read exposes only allowlisted fields, minor amounts, options and revision", async () => { + const c = cart(); c.password = "private-password"; c.orders[0].paymentCard = { stripeId: "private-stripe" }; c.orders[0].orderItems[0].specialInstructions = "private-instruction"; + const h = harness({ carts: [c] }); const out = await h.run("get_cart", read); + assert.equal(out.status, "ok"); assert.equal(out.account_matched, true); + assert.equal(out.carts[0].subtotal_minor, 1400); assert.equal(out.carts[0].lines[0].options[0].name, "Tofu"); + assert.match(out.carts[0].revision, /^[a-f0-9]{64}$/); + assert.equal(out.delivery_context.basis, "selected_address"); + for (const secret of [email, "private-password", "private-stripe", "private-instruction", "secret-entry", "fabricated-token", "1 Fabricated Street"]) assert.ok(!JSON.stringify(out).includes(secret)); + assert.equal(h.calls.length, 2); assert.ok(h.calls.every(c => c.query.startsWith("query "))); + assert.equal(h.calls[0].headers["x-csrftoken"], "fabricated-token"); +}); + +test("empty cart list is an observed empty success; guest is not empty success", async () => { + const h = harness({ carts: [] }); assert.deepEqual((await h.run("get_cart", read)).carts, []); + for (const consumer of [null, { ...account(), isGuest: true }, { ...account(), email: null }]) { + const h2 = harness({ consumer }); assert.equal((await h2.run("get_cart", read)).error_kind, "login_required"); assert.equal(h2.calls.length, 1); + } +}); + +test("wrong account fails before private cart query", async () => { + const h = harness({ consumer: { ...account(), email: "other@example.invalid" } }); + assert.equal((await h.run("get_cart", read)).error_kind, "wrong_account"); assert.equal(h.calls.length, 1); +}); + +test("unknown account type fails closed, email match is case-insensitive", async () => { + assert.equal((await harness({ consumer: { ...account(), isGuest: null } }).run("get_cart", read)).error_kind, "site_changed"); + assert.equal((await harness().run("get_cart", { expected_account_email: email.toUpperCase() })).status, "ok"); +}); + +test("header basis and saved default region do not claim cart-selected address", async () => { + for (const [header, basis] of [["Your Address", "no_address_selected"], ["Tu dirección", "no_address_selected"], ["Votre adresse", "unknown"], [null, "unknown"], ["Plain Street", "unknown"]]) { + const out = await harness({ header }).run("get_cart", read); assert.equal(out.delivery_context.basis, basis); assert.match(out.delivery_context.note, /not a verified cart delivery address/); + } +}); + +test("preview never opens checkout and never fabricates approval", async () => { + const h = harness(); const out = await h.run("get_checkout_summary", { ...read, cart_id: "cart-1" }); + assert.equal(out.status, "ok"); assert.equal(out.ready_for_order, false); assert.match(out.execution_blocker, /trusted human approval/); + assert.equal(out.checkout_url, "https://www.doordash.com/consumer/checkout/?order_cart_id=cart-1"); + assert.ok(h.calls.every(c => !c.url.includes("checkout") && !c.query.includes("mutation"))); + assert.equal((await h.run("get_checkout_summary", { ...read, cart_id: "cart-1", confirmed: true })).error_kind, "invalid_input"); + assert.equal((await h.run("get_checkout_summary", { ...read, cart_id: "missing" })).error_kind, "not_found"); +}); + +test("cart projection bounds and truncation flags", async () => { + const c = cart(); c.orders[0].orderItems = Array.from({ length: 101 }, (_, i) => item("line-" + i)); + c.orders[0].orderItems[0].options = Array.from({ length: 51 }, (_, i) => ({ id: "o-" + i, name: "x", quantity: 1, price: 2 })); + const h = harness({ carts: [c] }); const out = await h.run("get_cart", read); + assert.equal(out.carts[0].lines.length, 100); assert.equal(out.carts[0].lines[0].options.length, 50); assert.equal(out.carts[0].truncated, true); +}); + +for (const [status, raw, kind] of [[401, "", "login_required"], [429, "", "rate_limited"], [403, "", "blocked"], [200, "captcha", "blocked"], [500, "{}", "site_changed"]]) { + test(`read failure ${status}/${kind} is explicit and no write`, async () => { + const h = harness({ respond: () => ({ status, raw }) }); + const out = await h.run("get_cart", read); assert.equal(out.error_kind, kind); assert.equal(out.write_attempted, false); assert.equal(writes(h).length, 0); + }); +} + +test("GraphQL private error text is never echoed", async () => { + const h = harness({ respond: () => ({ json: { errors: [{ message: "private-token-in-error", extensions: { code: "FORBIDDEN" } }] } }) }); + const out = await h.run("get_cart", read); assert.equal(out.error_kind, "blocked"); assert.ok(!JSON.stringify(out).includes("private-token")); +}); + +test("mocked exact removal, one write, observed unchanged other line, guard cleared", async () => { + const h = harness(); const input = await removalInput(h); const out = await h.run("remove_cart_item", input); + assert.equal(out.status, "ok"); assert.equal(out.removed, true); assert.deepEqual(out.remaining_cart.lines.map(l => l.line_id), ["line-2"]); + assert.equal(writes(h).length, 1); assert.equal(h.store.size, 0); + assert.equal(writes(h)[0].variables.itemId, "line-1"); + assert.deepEqual(writes(h)[0].variables.cartContext, { deleteBundleCarts: false }); + assert.equal(writes(h)[0].variables.returnCartFromOrderService, false); + assert.equal((await h.run("remove_cart_item", input)).error_kind, "blocked"); assert.equal(writes(h).length, 1); +}); + +test("stale revision and wrong store prevent any write", async () => { + const h = harness(); const input = await removalInput(h); + for (const bad of [{ ...input, expected_revision: "a".repeat(64) }, { ...input, expected_store_id: "1002" }]) assert.equal((await h.run("remove_cart_item", bad)).error_kind, "stale_state"); + assert.equal(writes(h).length, 0); +}); + +for (const flag of ["groupCart", "isBundle", "isCatering", "isMerchantShipping", "isPrescriptionDelivery"]) { + test(`removal refuses ${flag} and unknown ${flag}`, async () => { + for (const value of [true, null, undefined]) { + const c = cart(); c[flag] = value; const h = harness({ carts: [c] }); const input = await removalInput(h); + assert.equal((await h.run("remove_cart_item", input)).error_kind, "blocked"); assert.equal(writes(h).length, 0); + } + }); +} + +test("finalized, multi-order, last-line and duplicate-line carts are refused", async () => { + for (const change of [c => { c.orders[0].isSubCartFinalized = true; }, c => { delete c.orders[0].isSubCartFinalized; }, c => { c.orders.push(plain(c.orders[0])); }, c => { c.orders[0].orderItems.pop(); }, c => { c.orders[0].orderItems[1].id = "line-1"; }]) { + const c = cart(); change(c); const h = harness({ carts: [c] }); const input = await removalInput(h); + const out = await h.run("remove_cart_item", input); assert.notEqual(out.status, "ok"); assert.equal(writes(h).length, 0); + } +}); + +test("missing cart, missing line and storage denial prevent a write", async () => { + const h = harness(); const input = await removalInput(h); + for (const key of ["cart_id", "line_id"]) assert.equal((await h.run("remove_cart_item", { ...input, [key]: "missing" })).error_kind, "not_found"); + assert.equal(writes(h).length, 0); + const h2 = harness({ storageError: true }); const i2 = await removalInput(h2); assert.equal((await h2.run("remove_cart_item", i2)).error_kind, "blocked"); assert.equal(writes(h2).length, 0); +}); + +test("post-write wrong account does not claim success and retains the guard", async () => { + let h; h = harness({ afterWrite: () => h.setConsumer({ ...account(), email: "other@example.invalid" }) }); + const input = await removalInput(h); const out = await h.run("remove_cart_item", input); + assert.equal(out.error_kind, "outcome_unknown"); assert.equal(out.write_attempted, true); assert.equal(h.store.size, 1); +}); + +for (const options of [{ writeError: true }, { noRemoval: true }, { nullAck: true }, { afterWrite: c => { c.orders[0].orderItems[0].quantity = 9; } }, { afterWrite: c => { c.restaurant.id = "1002"; } }]) { + test(`uncertain mocked write fails closed, is never retried (${Object.keys(options)[0]})`, async () => { + const h = harness(options); const input = await removalInput(h); const out = await h.run("remove_cart_item", input); + assert.equal(out.error_kind, "outcome_unknown"); assert.equal(out.write_attempted, true); assert.equal(h.store.size, 1); assert.equal(writes(h).length, 1); + await h.run("remove_cart_item", input); assert.equal(writes(h).length, 1); + }); +} + +test("concurrent removals share a per-cart guard, at most one write", async () => { + const h = harness({ writeError: true }); const input = await removalInput(h); + const out = await Promise.all([h.run("remove_cart_item", input), h.run("remove_cart_item", input)]); + assert.ok(out.some(o => o.error_kind === "busy")); assert.equal(writes(h).length, 1); +}); From bd42a0d5322213cbc507804e3e656eb00cbaa582 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:34:06 +0000 Subject: [PATCH 06/13] Guard cart account switches and unsupported removal lines --- domains/doordash.com/README.md | 31 ++++++++-- domains/doordash.com/get_cart.js | 3 +- domains/doordash.com/get_checkout_summary.js | 3 +- domains/doordash.com/remove_cart_item.js | 2 + registry.json | 2 +- tests/doordash-cart.test.mjs | 60 +++++++++++++++++++- 6 files changed, 90 insertions(+), 11 deletions(-) diff --git a/domains/doordash.com/README.md b/domains/doordash.com/README.md index de8b9879..6d0e0885 100644 --- a/domains/doordash.com/README.md +++ b/domains/doordash.com/README.md @@ -500,7 +500,8 @@ namespace registration, although the current Kernel discovery API did not surfac All three new tools are `page`: same-origin requests from the current session, no navigation. They require `expected_account_email`, supplied by the user for the intended account, and compare it against the site's -`consumerBucketing` query before reading private cart data. No email, account id, payment data, address street, +`consumerBucketing` query before and after reading private cart data; an observable account switch discards +all results. No email, account id, payment data, address street, entry codes, instructions, raw errors, cookies or signed URLs are returned. Do not infer the intended account from whatever session happens to be present. This account comparison is not human approval. @@ -512,8 +513,10 @@ from whatever session happens to be present. This account comparison is not huma state. Totals and fee lines are provisional and can be incomplete. `ready_for_order` is always false. - `remove_cart_item`: requires exact `cart_id`, `line_id` (cart line id, not menu item id), `expected_store_id` and `expected_revision` from `get_cart`. It removes only an ordinary personal non-bundle, non-catering, - non-shipping cart line, with one non-finalized order and at least two lines. It refuses last-line removal, - incomplete/truncated carts, a missing/duplicate line, wrong store/account and stale revision before writing. + non-shipping, non-prescription cart line, with one non-finalized order and at least two lines. All lines + must have explicit non-prescription status, matching item/store ids, complete option ids and a positive + finite quantity. It refuses last-line removal, incomplete/truncated carts, a missing/duplicate line, + wrong store/account and stale revision before writing. ### Safety design @@ -560,5 +563,23 @@ returned HTTP 200 with an empty list. Whole five-tool namespace registered atomi `wrong_account` before the private cart query. No user cart/address/payment changed. Populated cart, actual fee/total semantics, and removal success remain fixture-only; no live mutation consent was supplied. -Independent QA for the broadened source is pending. Prior `get_store`/`login` evidence above is historical, -not sign-off on new cart tools. All new entries remain draft until this review completes. +Independent QA round 1, 2026-09-30, same reviewer `agt_e3cd254315c78d609b12` using +`openai-codex/gpt-6.1-sol` (high reasoning; Codex auth runtime-checked), at `dd01703`: defects found. +Two major findings were reproduced with isolated fixtures: account switches after the private cart read +could release mismatched results, and line-level prescription/incomplete identity data did not block removal. +Both are now guarded with regressions. A minor late-cancellation case could clear the guard after the final +hash completed; cancellation is now checked before guard clearance and returns `outcome_unknown`. + +That review passed 1637 tests and 89 independent scratch cases, compiled all new schemas with Ajv, and +exercised the full namespace on Kernel and the local extension (23 extension invocations). All populated +cart, preview and removal successes were fixture-only, with zero tool-fetch escapes; live guest calls failed +as `login_required`. Existing `get_store` locale/distance safeguards remained unchanged. Native menu/item/add +were seen alongside all customs on a fully loaded Kernel store page; native home tools remained available +in navigator but not API discovery. Re-review of the fixes is pending; new entries remain draft. + +Author local-extension check at `dd01703`: exact-SHA install in a throwaway Chromium 153 profile, atomic +five-tool attach and site grant; page-originated mocked cart→preview→remove success made one mocked write, +zero network escapes and preserved the document marker. Extension-owned-tab calls covered live guest and +malformed-input failures plus signed-out `get_store` success. These are not claims of live cart mutation. +The original authorized login session reached its unchanged expiration and was deleted after the deadline. +No signed-in/populated extension profile was used. diff --git a/domains/doordash.com/get_cart.js b/domains/doordash.com/get_cart.js index 35c5e0da..8f012108 100644 --- a/domains/doordash.com/get_cart.js +++ b/domains/doordash.com/get_cart.js @@ -195,8 +195,9 @@ return { basis, default_address_region: { city: text(c.defaultAddress?.city), state: text(c.defaultAddress?.state), postal_code: text(c.defaultAddress?.zipCode), country_code: text(c.defaultAddress?.countryCode) }, note: "Basis describes the current header, not a verified cart delivery address. The region is the account's saved default, which may differ. No address is selected or changed. Totals and fees are provisional, not a payable quote." }; }; try { - const account = await readAccount(); + await readAccount(); const carts = await readCarts(); + const account = await readAccount(); const output = { status: "ok", account_matched: true, carts: await Promise.all(carts.slice(0, 20).map(project)), carts_truncated: carts.length > 20, delivery_context: deliveryContext(account) }; if (new TextEncoder().encode(JSON.stringify(output)).byteLength > 180000) return fail("blocked", "The cart list exceeds the safe output limit; inspect the site manually."); return output; diff --git a/domains/doordash.com/get_checkout_summary.js b/domains/doordash.com/get_checkout_summary.js index f1ffb54d..1f45bdd2 100644 --- a/domains/doordash.com/get_checkout_summary.js +++ b/domains/doordash.com/get_checkout_summary.js @@ -194,8 +194,9 @@ return { basis, default_address_region: { city: text(c.defaultAddress?.city), state: text(c.defaultAddress?.state), postal_code: text(c.defaultAddress?.zipCode), country_code: text(c.defaultAddress?.countryCode) }, note: "Basis describes the current header, not a verified cart delivery address. The region is the account's saved default, which may differ. No address is selected or changed. Totals and fees are provisional, not a payable quote." }; }; try { - const account = await readAccount(); + await readAccount(); const carts = await readCarts(); + const account = await readAccount(); const matching = carts.filter(c => c?.id === input.cart_id); if (matching.length !== 1) return fail("not_found", "The exact open cart was not found in this account."); const projected = await project(matching[0]); diff --git a/domains/doordash.com/remove_cart_item.js b/domains/doordash.com/remove_cart_item.js index 94e1f5f6..72dca83e 100644 --- a/domains/doordash.com/remove_cart_item.js +++ b/domains/doordash.com/remove_cart_item.js @@ -187,6 +187,7 @@ if (projectedBefore.truncated || new TextEncoder().encode(JSON.stringify(projectedBefore)).byteLength > 170000) return fail("blocked", "The complete cart exceeds safe bounds. Nothing was changed."); const lines = before.orders[0].orderItems; if (!Array.isArray(lines) || lines.length < 2 || lines.length > 100 || lines.some(l => !l || !ident(l.id))) return fail("blocked", "Last-line removal or an incomplete/unsupported cart is not supported."); + if (lines.some(l => l.isPrescriptionItem !== false || !ident(l.item?.id) || String(l.item?.storeId) !== input.expected_store_id || !Array.isArray(l.options) || l.options.some(o => !o || !ident(o.id)) || typeof l.quantity !== "number" || !Number.isFinite(l.quantity) || l.quantity <= 0)) return fail("blocked", "Prescription, incomplete or conflicting cart lines are not supported. Nothing was changed."); if (lines.filter(l => String(l.id) === input.line_id).length !== 1) return fail("not_found", "That exact line is not present exactly once in this cart."); if (await revision(before) !== input.expected_revision) return fail("stale_state", "The cart changed after get_cart. Read it again; no write was sent."); await readAccount(); @@ -211,6 +212,7 @@ const expected = { ...before, subtotal: after.subtotal, total: after.total, orders: before.orders.map(o => ({ ...o, orderItems: o.orderItems.filter(l => String(l.id) !== input.line_id), lineItems: after.orders?.[0]?.lineItems })) }; if (JSON.stringify(canonical(expected)) !== JSON.stringify(canonical(after))) return fail("outcome_unknown", "The exact removal with unchanged other lines was not confirmed. Inspect manually; do not retry.", true); const remaining = await project(after); + if (signal.aborted) return fail("outcome_unknown", "Cancelled before the final cart result was returned. Inspect manually; do not retry.", true); if (remaining.truncated || new TextEncoder().encode(JSON.stringify(remaining)).byteLength > 170000) return fail("outcome_unknown", "The remaining cart exceeds safe observation bounds. Inspect manually; do not retry.", true); if (sessionStorage.getItem(key) === lock) sessionStorage.removeItem(key); return { status: "ok", account_matched: true, removed: true, cart_id: input.cart_id, line_id: input.line_id, write_attempted: true, remaining_cart: remaining }; diff --git a/registry.json b/registry.json index 58194937..45cbc4fb 100644 --- a/registry.json +++ b/registry.json @@ -573,7 +573,7 @@ ], "status": "verified", "last_verified": "2026-09-24", - "notes": "Independent search/parser QA passed; domain migration live-verified from /travel-agents. Returns initial outbound cards only, including round trips; 1–7 adults; displayed cash fares are not trip totals. Inventory failures remain explicit errors.", + "notes": "Independent search/parser QA passed; domain migration live-verified from /travel-agents. Returns initial outbound cards only, including round trips; 1\u20137 adults; displayed cash fares are not trip totals. Inventory failures remain explicit errors.", "provenance": { "qa_agent": "agt_8da83a2e489269a5ff05" } diff --git a/tests/doordash-cart.test.mjs b/tests/doordash-cart.test.mjs index 54c49861..c070f184 100644 --- a/tests/doordash-cart.test.mjs +++ b/tests/doordash-cart.test.mjs @@ -9,7 +9,7 @@ const sources = Object.fromEntries(["get_cart", "get_checkout_summary", "remove_ const plain = value => JSON.parse(JSON.stringify(value)); const email = "shopper@example.invalid"; const account = () => ({ email, isGuest: false, defaultAddress: { city: "Testville", state: "ZZ", zipCode: "00000", countryCode: "US", entryCode: "secret-entry" } }); -const item = (id, quantity = 1) => ({ id, quantity, singlePrice: 700, priceOfTotalQuantity: 700 * quantity, priceDisplayString: "$7.00", cartItemStatusType: "CART_ITEM_STATUS_TYPE_UNSPECIFIED", options: [{ id: "extra-1", name: "Tofu", quantity: 1, price: 100 }], item: { id: "menu-" + id, name: "Fabricated bowl", storeId: "1001" } }); +const item = (id, quantity = 1) => ({ id, quantity, singlePrice: 700, priceOfTotalQuantity: 700 * quantity, priceDisplayString: "$7.00", cartItemStatusType: "CART_ITEM_STATUS_TYPE_UNSPECIFIED", isPrescriptionItem: false, options: [{ id: "extra-1", name: "Tofu", quantity: 1, price: 100 }], item: { id: "menu-" + id, name: "Fabricated bowl", storeId: "1001" } }); const cart = () => ({ id: "cart-1", groupCart: false, isBundle: false, isCatering: false, isMerchantShipping: false, isPrescriptionDelivery: false, isConsumerPickup: false, currencyCode: "USD", subtotal: 1400, total: 1700, restaurant: { id: "1001", name: "Fabricated Kitchen" }, orders: [{ id: "order-1", isSubCartFinalized: false, orderItems: [item("line-1"), item("line-2")], lineItems: [{ label: "Service fee", finalMoney: { unitAmount: 300, displayString: "$3.00" } }] }] }); function conforms(s, v) { @@ -50,7 +50,7 @@ function harness(options = {}) { } else throw new Error("unexpected operation"); return { status: 200, ok: true, text: async () => JSON.stringify({ data }) }; }; - const context = { location: { origin: options.origin ?? "https://www.doordash.com", pathname: "/home" }, document: { cookie: "csrf_token=fabricated-token; dd_cx_logged_in=true", querySelector: selector => selector.includes("addressTextButton") && options.header !== null ? { innerText: options.header ?? "1 Fabricated Street" } : null }, fetch, crypto: webcrypto, TextEncoder, AbortSignal, sessionStorage: { getItem: k => options.storageError ? (() => { throw Error("blocked"); })() : store.get(k) ?? null, setItem: (k, v) => store.set(k, v), removeItem: k => store.delete(k) } }; + const context = { location: { origin: options.origin ?? "https://www.doordash.com", pathname: "/home" }, document: { cookie: "csrf_token=fabricated-token; dd_cx_logged_in=true", querySelector: selector => selector.includes("addressTextButton") && options.header !== null ? { innerText: options.header ?? "1 Fabricated Street" } : null }, fetch, crypto: options.crypto ?? webcrypto, TextEncoder, AbortSignal, sessionStorage: { getItem: k => options.storageError ? (() => { throw Error("blocked"); })() : store.get(k) ?? null, setItem: (k, v) => store.set(k, v), removeItem: k => store.delete(k) } }; const definitions = Object.fromEntries(Object.entries(sources).map(([name, source]) => [name, runInNewContext(`(${source})`, context)[0]])); const run = async (name, input, signal) => { const result = plain(await definitions[name].execute(input, { signal })); @@ -109,7 +109,7 @@ test("cart read exposes only allowlisted fields, minor amounts, options and revi assert.match(out.carts[0].revision, /^[a-f0-9]{64}$/); assert.equal(out.delivery_context.basis, "selected_address"); for (const secret of [email, "private-password", "private-stripe", "private-instruction", "secret-entry", "fabricated-token", "1 Fabricated Street"]) assert.ok(!JSON.stringify(out).includes(secret)); - assert.equal(h.calls.length, 2); assert.ok(h.calls.every(c => c.query.startsWith("query "))); + assert.equal(h.calls.length, 3); assert.ok(h.calls.every(c => c.query.startsWith("query "))); assert.equal(h.calls[0].headers["x-csrftoken"], "fabricated-token"); }); @@ -222,3 +222,57 @@ test("concurrent removals share a per-cart guard, at most one write", async () = const out = await Promise.all([h.run("remove_cart_item", input), h.run("remove_cart_item", input)]); assert.ok(out.some(o => o.error_kind === "busy")); assert.equal(writes(h).length, 1); }); + + +for (const name of ["get_cart", "get_checkout_summary"]) { + test(`${name}: observable account switch after private read discards all cart data`, async () => { + let h; + h = harness({ respond: body => { + if (body.operationName === "listDetailedCarts") { + h.setConsumer({ ...account(), email: "other@example.invalid" }); + const other = cart(); other.restaurant.name = "OTHER_ACCOUNT_CART"; + return { json: { data: { listDetailedCarts: { detailedCarts: [{ cart: other }] } } } }; + } + } }); + const out = await h.run(name, name === "get_cart" ? read : { ...read, cart_id: "cart-1" }); + assert.equal(out.error_kind, "wrong_account"); + assert.ok(!JSON.stringify(out).includes("OTHER_ACCOUNT_CART")); + assert.equal(writes(h).length, 0); + }); +} + +for (const [label, change] of [ + ["prescription", l => { l.isPrescriptionItem = true; }], + ["unknown prescription status", l => { delete l.isPrescriptionItem; }], + ["missing item", l => { l.item = null; }], + ["missing item id", l => { l.item.id = null; }], + ["conflicting store", l => { l.item.storeId = "1002"; }], + ["missing options", l => { l.options = null; }], + ["missing option id", l => { l.options[0].id = null; }], + ["invalid quantity", l => { l.quantity = 0; }] +]) { + test(`removal refuses ${label} before any guard or write`, async () => { + for (const lineIndex of [0, 1]) { + const c = cart(); change(c.orders[0].orderItems[lineIndex]); + const h = harness({ carts: [c] }); const input = await removalInput(h); + const out = await h.run("remove_cart_item", input); + assert.equal(out.error_kind, "blocked"); assert.equal(out.write_attempted, false); + assert.equal(writes(h).length, 0); assert.equal(h.store.size, 0); + } + }); +} + +test("late cancellation during final digest retains the uncertain-outcome guard", async () => { + const controller = new AbortController(); + let actionStarted = false, digests = 0; + const crypto = { randomUUID: () => webcrypto.randomUUID(), subtle: { digest: async (...args) => { + if (actionStarted && ++digests === 3) controller.abort(); + return webcrypto.subtle.digest(...args); + } } }; + const h = harness({ crypto }); const input = await removalInput(h); + actionStarted = true; + const out = await h.run("remove_cart_item", input, controller.signal); + assert.equal(out.error_kind, "outcome_unknown"); assert.equal(out.write_attempted, true); + assert.equal(writes(h).length, 1); assert.equal(h.store.size, 1); + await h.run("remove_cart_item", input); assert.equal(writes(h).length, 1); +}); From d0af2d2323aff4da23686f627c526be66da80555 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:57:53 +0000 Subject: [PATCH 07/13] Record final DoorDash cart QA evidence --- domains/doordash.com/README.md | 49 +++++++++++++++++++++++++++++----- registry.json | 27 ++++++++++++------- 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/domains/doordash.com/README.md b/domains/doordash.com/README.md index 6d0e0885..cc92d1f8 100644 --- a/domains/doordash.com/README.md +++ b/domains/doordash.com/README.md @@ -5,9 +5,9 @@ Tools for DoorDash (https://www.doordash.com). | tool | file | status | notes | |------|------|--------|-------| | login | [login.js](login.js) | qa_passed | Email + password sign-in with a two-step guided flow; existing sessions verified against the profile page; `otp` mode for the 2-Step Verification modal | -| get_cart | [get_cart.js](get_cart.js) | draft | Account-checked open-cart read with line ids, options, provisional totals and revision | -| get_checkout_summary | [get_checkout_summary.js](get_checkout_summary.js) | draft | Read-only preparation and manual checkout handoff; never opens checkout or orders | -| remove_cart_item | [remove_cart_item.js](remove_cart_item.js) | draft | Exact non-last personal cart-line removal with account/revision/duplicate guards; fixture-only mutation evidence | +| get_cart | [get_cart.js](get_cart.js) | qa_passed | Account-checked open-cart read with line ids, options, provisional totals and revision | +| get_checkout_summary | [get_checkout_summary.js](get_checkout_summary.js) | qa_passed | Read-only preparation and manual checkout handoff; never opens checkout or orders | +| remove_cart_item | [remove_cart_item.js](remove_cart_item.js) | qa_passed | Exact non-last personal cart-line removal with account/revision/duplicate guards; fixture-only mutation evidence | | get_store | [get_store.js](get_store.js) | qa_passed | Page tool: store hours, service modes, delivery and pickup availability with estimates and fee strings, and menu ids, with the delivery basis (selected address, none selected, unknown) stated explicitly and delivery figures returned only for a selected address; never changes the address | ## Native WebMCP tools @@ -55,8 +55,9 @@ Schemas as read from the polyfill (inputs only; the polyfill exposes no output s None of the native tools sign in, so `login` is custom. Search, item options and adding to the cart are covered natively. The native tools give no store details (hours, address, availability, fees, -estimates) and no cart read or edit, address, or order tools; `get_store` below covers the first -gap only. +estimates) and no cart read or edit, address, or order tools. `get_store` covers the store-details gap; +the bounded cart tools below cover reads, limited line removal and preparation, not address selection or +order execution. ## Entry path and flow @@ -575,7 +576,7 @@ exercised the full namespace on Kernel and the local extension (23 extension inv cart, preview and removal successes were fixture-only, with zero tool-fetch escapes; live guest calls failed as `login_required`. Existing `get_store` locale/distance safeguards remained unchanged. Native menu/item/add were seen alongside all customs on a fully loaded Kernel store page; native home tools remained available -in navigator but not API discovery. Re-review of the fixes is pending; new entries remain draft. +in navigator but not API discovery. The subsequent final-source recheck below supersedes the pending state. Author local-extension check at `dd01703`: exact-SHA install in a throwaway Chromium 153 profile, atomic five-tool attach and site grant; page-originated mocked cart→preview→remove success made one mocked write, @@ -583,3 +584,39 @@ zero network escapes and preserved the document marker. Extension-owned-tab call malformed-input failures plus signed-out `get_store` success. These are not claims of live cart mutation. The original authorized login session reached its unchanged expiration and was deleted after the deadline. No signed-in/populated extension profile was used. + +### final-source independent sign-off — 2026-09-30 + +Same reviewer, same runtime/auth settings; initial review plus one fix/recheck round, no third reviewer or +unbounded review loop. At `bd42a0d5322213cbc507804e3e656eb00cbaa582`, verdict **NO DEFECTS**, explicitly +signed off on all three new tools as `qa_passed`, not `verified`. Hashes matched checked-out and installed +extension sources: + +```text +get_cart 987beebde9fb872deb4b6c7d0cdcce20418b896457c8219986dbc665a648c91b +get_checkout_summary 2a1de8f774db4b2d29d3475e434eef803e08aa6f4ea224cd72b61da5a7ec2372 +remove_cart_item dc74c41f35745c7d1e650ff2531604f2229fa8904d993e6d70e379e53c0d091e +``` + +Recheck: 83 tools valid, full suite 1648/1648, cart tests 49/49, get_store tests 22/22; six schemas compiled +with Ajv, 140 independent scratch cases reviewed (132 assertion checks passed, eight exploratory probes), +four regression mutants killed. All original findings closed; observable account switches/uncertain account +responses discard reads, unsupported target/other lines fail before guard/write, and late cancellation retains +the uncertain-outcome guard. Actual page-originated aborts surfaced caller `AbortError` with that guard retained. + +Kernel: fresh public browser, atomic five-tool registration, real guest failures, all three fixture-only success +paths, regression failures, zero fixture fetch escapes and unchanged location/history/marker. Store native +menu/item/add/context/navigation coexisted with customs; help.doordash.com exposed only login, unrelated +example.com none. get_store es-US/fr-CA safeguards passed unchanged. + +Local extension: exact-SHA install, five-tool attach/grant, 25 schema-valid result-bearing invocations and +separate late-cancellation check. Owned-tab and page-originated fixture success/failure paths passed, with +zero fixture fetch escapes/no navigation. Home navigator natives remained intact while extension discovery +listed customs only; no blanket discovery parity is claimed. + +Author independently repeated final-hash Kernel and local-extension registration/discovery, live guest and +malformed-input failures, signed-out get_store success, and isolated read→preview→remove success (one mocked +write, zero escapes). Local page marker survived; Kernel marker/path survived real public checks. No final-source +signed-in/populated read, real removal, checkout/payment/address change or order was tested. Both sides deleted +only their own scratch browsers and stopped local Chromium/Xvfb. Existing login/get_store hashes remain unchanged; +metadata/docs-only closeout preserves the signed-off sources. diff --git a/registry.json b/registry.json index 45cbc4fb..d69ec4ba 100644 --- a/registry.json +++ b/registry.json @@ -122,9 +122,12 @@ "url_patterns": [ "https://www.doordash.com/*" ], - "status": "draft", - "last_verified": null, - "notes": "Account-checked cart read/order preparation." + "status": "qa_passed", + "last_verified": "2026-09-30", + "notes": "Account-checked open-cart read with bounded lines/options, provisional currency/fee/totals, queried revision and explicit header/default-region context. Independent Kernel and local-extension success/failure QA passed on final source; populated success is fixture-only. Earlier source read an authorized empty live cart. No address, payment or checkout changes.", + "provenance": { + "qa_agent": "agt_e3cd254315c78d609b12" + } }, { "domain": "doordash.com", @@ -135,9 +138,12 @@ "url_patterns": [ "https://www.doordash.com/*" ], - "status": "draft", - "last_verified": null, - "notes": "Account-checked cart read/order preparation." + "status": "qa_passed", + "last_verified": "2026-09-30", + "notes": "Read-only exact-cart preparation with provisional fees/totals, explicit address-context limitations and a manual checkout URL. Does not load checkout or execute orders/payment; ready_for_order is always false because no trusted approval enforcement exists. Independent Kernel/local-extension success/failure QA passed; populated success is fixture-only.", + "provenance": { + "qa_agent": "agt_e3cd254315c78d609b12" + } }, { "domain": "doordash.com", @@ -181,9 +187,12 @@ "url_patterns": [ "https://www.doordash.com/*" ], - "status": "draft", - "last_verified": null, - "notes": "Exact personal cart-line removal with revision, account and duplicate-write guards. Mutation QA is fixture-only; no live cart consent." + "status": "qa_passed", + "last_verified": "2026-09-30", + "notes": "Exact non-last ordinary personal cart-line removal with account/store/revision, complete non-prescription line checks, persistent uncertain-write guard and observed postconditions. One attempt, no automatic retries. Independent Kernel/local-extension QA passed; all mutation success is fixture-only, never live-verified. Cross-tab races are not atomically excluded; final order/payment execution is absent.", + "provenance": { + "qa_agent": "agt_e3cd254315c78d609b12" + } }, { "domain": "etsy.com", From 5cdc275c73bdb7a678b7f097fa273306f4cbe5b9 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:04:23 +0000 Subject: [PATCH 08/13] doordash.com: add set_delivery_address for signed-out visits Setting the delivery address is the step agents spend longest on in a DoorDash task, and some models cannot work the picker at all. The new page tool sends the picker's own autocomplete, get-or-create and addConsumerAddressV2 requests, confirms the address from DoorDash's answer, and refuses signed-in visits, where save_address applies. The bench gives it to DoorDash's prepare intents. --- bench/tasks/doordash.com/domain.json | 5 + domains/doordash.com/README.md | 1 + domains/doordash.com/set_delivery_address.js | 128 ++++++++++++++ registry.json | 14 ++ tests/doordash-set-delivery-address.test.mjs | 165 +++++++++++++++++++ 5 files changed, 313 insertions(+) create mode 100644 bench/tasks/doordash.com/domain.json create mode 100644 domains/doordash.com/set_delivery_address.js create mode 100644 tests/doordash-set-delivery-address.test.mjs diff --git a/bench/tasks/doordash.com/domain.json b/bench/tasks/doordash.com/domain.json new file mode 100644 index 00000000..f037c9b6 --- /dev/null +++ b/bench/tasks/doordash.com/domain.json @@ -0,0 +1,5 @@ +{ + "actions": { + "set_delivery_address": "sets a signed-out visit's delivery address, as the header's address picker does" + } +} diff --git a/domains/doordash.com/README.md b/domains/doordash.com/README.md index fca231d1..f1192e11 100644 --- a/domains/doordash.com/README.md +++ b/domains/doordash.com/README.md @@ -10,3 +10,4 @@ | register | Creates a DoorDash consumer account from the email sign-up form, or submits a visible six-digit sign-up verification code; refuses when already signed in. | | remove_cart_item | Remove exactly one existing line from an ordinary personal DoorDash cart using the site's removeCartItem operation. | | save_address | Makes a delivery address the account's current default: selects it if already saved, otherwise adds it from DoorDash's autocomplete with optional details. | +| set_delivery_address | Sets a signed-out visit's delivery address from DoorDash's address autocomplete, as the header's address picker does, and confirms it from the site's answer. | diff --git a/domains/doordash.com/set_delivery_address.js b/domains/doordash.com/set_delivery_address.js new file mode 100644 index 00000000..da11cf3e --- /dev/null +++ b/domains/doordash.com/set_delivery_address.js @@ -0,0 +1,128 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.doordash.com/*"] }, + tool: { + name: "set_delivery_address", + description: "Set the delivery address of a signed-out DoorDash visit, as the header's address picker does: it looks the address up in DoorDash's address autocomplete, chooses the suggestion you name (the first by default, as the picker lists them) and makes it the visit's delivery address with the same requests the picker sends. Success is confirmed by DoorDash's answer, which states the address it now delivers to. Store fees, delivery estimates, search results and get_store then use that address, but pages already open keep showing the old one until they load again: the site itself opens /home after an address is chosen, so navigate before reading, then list the tools again. Returns the suggestions when none matches the request or the chosen number is out of range; never edits saved account addresses and refuses when signed in (use save_address then). Only call it with an address the user gave.", + inputSchema: { + type: "object", additionalProperties: false, required: ["address"], + properties: { + address: { type: "string", minLength: 3, maxLength: 200, description: "The address as you would type it into the picker, e.g. 'Times Square, New York, NY 10036'." }, + suggestion: { type: "integer", minimum: 1, maximum: 10, default: 1, description: "Which autocomplete suggestion to choose, counting from 1 in the order DoorDash lists them; returned suggestions show that order." } + } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", additionalProperties: false, required: ["status", "address", "suggestions", "next_step"], + properties: { + status: { const: "ok" }, + address: { + type: "object", additionalProperties: false, required: ["printable", "street", "city", "state", "zip_code", "submarket"], + description: "The delivery address as DoorDash now reports it for this visit.", + properties: { + printable: { type: "string" }, street: { type: ["string", "null"] }, city: { type: ["string", "null"] }, state: { type: ["string", "null"] }, + zip_code: { type: ["string", "null"] }, submarket: { type: ["string", "null"] } + } + }, + suggestions: { type: "array", maxItems: 10, items: { type: "string" }, description: "Every suggestion the autocomplete offered, in its order." }, + next_step: { type: "string" } + } + }, + { + type: "object", additionalProperties: false, required: ["status", "error_kind", "error_message", "suggestions"], + properties: { + status: { const: "error" }, + error_kind: { enum: ["invalid_input", "wrong_page", "signed_in", "address_not_found", "rate_limited", "site_changed", "site_error"] }, + error_message: { type: "string" }, + suggestions: { type: "array", maxItems: 10, items: { type: "string" } } + } + } + ] + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false } + }, + execute: async (input, context) => { + const ORIGIN = "https://www.doordash.com"; + const MUTATION = "mutation addConsumerAddressV2($lat: Float!, $lng: Float!, $city: String!, $state: String!, $zipCode: String!, $printableAddress: String!, $shortname: String!, $googlePlaceId: String!, $addressId: String) {\n addConsumerAddressV2(\n lat: $lat\n lng: $lng\n city: $city\n state: $state\n zipCode: $zipCode\n printableAddress: $printableAddress\n shortname: $shortname\n googlePlaceId: $googlePlaceId\n addressId: $addressId\n ) {\n defaultAddress {\n id\n street\n city\n state\n zipCode\n printableAddress\n submarket\n __typename\n }\n isGuest\n __typename\n }\n}\n"; + const fail = (kind, message, suggestions = []) => ({ status: "error", error_kind: kind, error_message: message, suggestions }); + const text = (value, max = 200) => typeof value === "string" && value.trim() ? value.trim().slice(0, max) : null; + + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => k !== "address" && k !== "suggestion")) return fail("invalid_input", "Only address and suggestion are accepted. Nothing was changed."); + const address = typeof input.address === "string" ? input.address.trim().replace(/\s+/g, " ") : ""; + if (address.length < 3 || address.length > 200) return fail("invalid_input", "address must be 3 to 200 characters. Nothing was changed."); + const choice = input.suggestion === undefined ? 1 : input.suggestion; + if (!Number.isInteger(choice) || choice < 1 || choice > 10) return fail("invalid_input", "suggestion must be an integer from 1 to 10. Nothing was changed."); + if (location.origin !== ORIGIN) return fail("wrong_page", "Open a https://www.doordash.com/ page in this tab, then call again. Nothing was changed."); + if (/(?:^|;\s*)dd_cx_logged_in=true(?:;|$)/.test(document.cookie)) return fail("signed_in", "This browser is signed in to DoorDash, where choosing an address saves it to the account; use save_address instead. Nothing was changed."); + + const signals = [AbortSignal.timeout(25000)]; + if (context && context.signal) signals.push(context.signal); + const signal = AbortSignal.any(signals); + const gateway = { accept: "application/json", "x-experience-id": "doordash", "x-unified-gateway-generated-source": "v1" }; + const call = async (path, init) => { + let response, body; + try { + response = await fetch(path, { credentials: "include", signal, ...init }); + body = await response.text(); + } catch (error) { + return { error: error && error.name === "TimeoutError" ? "DoorDash did not answer within 25 seconds." : "The request to DoorDash failed or was cancelled." }; + } + if (response.status === 429) return { status: 429 }; + let json = null; + try { json = JSON.parse(body); } catch { /* not JSON */ } + return { status: response.status, json }; + }; + + const found = await call("/unified-gateway/geo-intelligence/v2/address/autocomplete?input_address=" + encodeURIComponent(address), { headers: gateway }); + if (found.error) return fail("site_error", found.error + " Nothing was changed."); + if (found.status === 429) return fail("rate_limited", "DoorDash is rate limiting requests from this browser; wait before calling again. Nothing was changed."); + if (found.status !== 200 || !found.json || !Array.isArray(found.json.predictions)) return fail("site_changed", "DoorDash's address autocomplete answered HTTP " + found.status + " without its predictions list. Nothing was changed."); + const predictions = found.json.predictions.filter(p => p && text(p.formatted_address) && text(p.source_place_id, 500)).slice(0, 10); + const suggestions = predictions.map(p => text(p.formatted_address)); + if (!predictions.length) return fail("address_not_found", "DoorDash's address autocomplete has no suggestion for '" + address.slice(0, 80) + "'. Try a street address with city and ZIP. Nothing was changed."); + if (choice > predictions.length) return fail("address_not_found", "DoorDash offered " + predictions.length + " suggestion(s), so there is no suggestion " + choice + ". Nothing was changed.", suggestions); + const picked = predictions[choice - 1]; + + const created = await call("/unified-gateway/geo-intelligence/v2/address/get-or-create", { + method: "POST", headers: { ...gateway, "content-type": "application/json" }, + body: JSON.stringify({ address_identifier: { _type: "source_place_id_request", source_place_id: picked.source_place_id } }) + }); + if (created.error) return fail("site_error", created.error + " Nothing was changed.", suggestions); + if (created.status === 429) return fail("rate_limited", "DoorDash is rate limiting requests from this browser; wait before calling again. Nothing was changed.", suggestions); + const place = created.json && created.json.address; + const lat = place && Number(place.lat), lng = place && Number(place.lng); + if (created.status !== 200 || !place || !text(place.id, 40) || !text(place.source_place_id, 200) || !isFinite(lat) || !isFinite(lng) || !text(place.locality) || !text(place.administrative_area_level1) || !text(place.postal_code)) { + return fail("site_changed", "DoorDash did not resolve the chosen suggestion to an address (HTTP " + created.status + "). Nothing was changed.", suggestions); + } + const segments = Array.isArray(place.formatted_address_segmented) ? place.formatted_address_segmented : []; + const shortname = text(place.formatted_address_short) || text(place.street_address) || text(segments[0]); + const printable = text(segments[1]) || text(place.formatted_address); + + const csrf = (document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("csrf_token=")) || "").slice("csrf_token=".length); + const saved = await call("/graphql/addConsumerAddressV2?operation=addConsumerAddressV2", { + method: "POST", + headers: { "content-type": "application/json", accept: "*/*", "x-csrftoken": csrf, "x-channel-id": "marketplace", "x-experience-id": "doordash" }, + body: JSON.stringify({ + operationName: "addConsumerAddressV2", + variables: { googlePlaceId: place.source_place_id, printableAddress: printable, lat, lng, city: place.locality, state: place.administrative_area_level1, zipCode: place.postal_code, shortname, addressId: String(place.id) }, + query: MUTATION + }) + }); + if (saved.error) return fail("site_error", saved.error + " The address may or may not have been set; reload the page and check the header before calling again.", suggestions); + if (saved.status === 429) return fail("rate_limited", "DoorDash is rate limiting requests from this browser; wait before calling again. The address was not confirmed.", suggestions); + const result = saved.json && saved.json.data && saved.json.data.addConsumerAddressV2; + const current = result && result.defaultAddress; + if (saved.status !== 200 || !current || !text(current.printableAddress) || (saved.json.errors && saved.json.errors.length)) { + return fail("site_error", "DoorDash did not confirm the address (HTTP " + saved.status + "). Reload the page and check the header before calling again.", suggestions); + } + if (result.isGuest === false) return fail("signed_in", "DoorDash answered for a signed-in account, so the address may now be saved there; check the account's addresses. Use save_address for signed-in visits.", suggestions); + return { + status: "ok", + address: { printable: text(current.printableAddress), street: text(current.street), city: text(current.city), state: text(current.state, 20), zip_code: text(current.zipCode, 20), submarket: text(current.submarket) }, + suggestions, + next_step: "Navigate (for example to https://www.doordash.com/home, where the site goes after an address is chosen, or to a search page), then list the tools again; pages already open still show the previous address." + }; + } +}] diff --git a/registry.json b/registry.json index cf01cef6..a0421734 100644 --- a/registry.json +++ b/registry.json @@ -1511,6 +1511,20 @@ "qa_agent": null } }, + { + "domain": "doordash.com", + "tool": "set_delivery_address", + "file": "domains/doordash.com/set_delivery_address.js", + "kind": "page", + "namespace": "doordash.com", + "url_patterns": [ + "https://www.doordash.com/*" + ], + "url_patterns_reason": "Narrowed to www.doordash.com, the consumer marketplace: these tools call its same-origin GraphQL endpoint with the page's session; other doordash.com hosts are separate products such as the merchant portal and help center.", + "status": "draft", + "last_verified": null, + "notes": "Signed-out visits only; refuses when the dd_cx_logged_in cookie is set (save_address covers accounts). Sends the address picker's own three requests (autocomplete, get-or-create, addConsumerAddressV2) and confirms the address from DoorDash's answer; does not navigate. Draft, no independent QA. Live on 2026-10-21 in a signed-out Kernel browser: 'Times Square, New York, NY 10036' set 4 Times Sq in under a second and the header showed it after loading /home; an address with no suggestions returned address_not_found." + }, { "domain": "etsy.com", "tool": "add_to_cart", diff --git a/tests/doordash-set-delivery-address.test.mjs b/tests/doordash-set-delivery-address.test.mjs new file mode 100644 index 00000000..2bafde9a --- /dev/null +++ b/tests/doordash-set-delivery-address.test.mjs @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; + +// Fixture tests for doordash.com/set_delivery_address. The page (location, document, fetch) is stubbed; +// nothing here contacts the live site and every address, id and cookie is fabricated. +const source = readFileSync(new URL("../domains/doordash.com/set_delivery_address.js", import.meta.url), "utf8"); +const registry = JSON.parse(readFileSync(new URL("../registry.json", import.meta.url), "utf8")); +const plain = value => JSON.parse(JSON.stringify(value)); +const HOME = "https://www.doordash.com"; +const AUTOCOMPLETE = "/unified-gateway/geo-intelligence/v2/address/autocomplete?input_address="; +const CREATE = "/unified-gateway/geo-intelligence/v2/address/get-or-create"; +const MUTATION = "/graphql/addConsumerAddressV2?operation=addConsumerAddressV2"; + +function conforms(schema, value) { + if (schema.oneOf && schema.oneOf.filter(s => conforms(s, value)).length !== 1) return false; + if ("const" in schema && value !== schema.const) return false; + if (schema.enum && !schema.enum.includes(value)) return false; + if (schema.type && ![schema.type].flat().some(type => { + if (type === "null") return value === null; + if (type === "integer") return Number.isInteger(value); + if (type === "array") return Array.isArray(value); + if (type === "object") return value !== null && typeof value === "object" && !Array.isArray(value); + return typeof value === type; + })) return false; + if (Array.isArray(value)) { + if (schema.maxItems !== undefined && value.length > schema.maxItems) return false; + if (schema.items && !value.every(item => conforms(schema.items, item))) return false; + } + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + if (schema.required?.some(key => !Object.hasOwn(value, key))) return false; + const props = schema.properties || {}; + if (Object.entries(props).some(([key, s]) => key in value && !conforms(s, value[key]))) return false; + if (schema.additionalProperties === false && Object.keys(value).some(key => !(key in props))) return false; + } + return true; +} + +// --- fabricated site data ------------------------------------------------------------------------ + +const prediction = (n, extra = {}) => ({ + lat: 40.1 + n / 1000, lng: -73.1, formatted_address: `${n} Fabricated Plaza, Testville, NY 10001, USA`, formatted_address_short: `${n} Fabricated Plaza`, + source_place_id: `fabricated-source-place-${n}`, postal_code: "10001", administrative_area_level1: "NY", locality: "Testville", street_address: `${n} Fabricated Plaza`, ...extra +}); +const place = (n, extra = {}) => ({ + id: String(900 + n), lat: 40.1 + n / 1000, lng: -73.1, formatted_address: `${n} Fabricated Plaza, Testville, NY 10001, USA`, street_address: `${n} Fabricated Plaza`, + locality: "Testville", administrative_area_level1: "NY", postal_code: "10001", formatted_address_short: `${n} Fabricated Plaza`, + formatted_address_segmented: [`${n} Fabricated Plaza`, "Testville, NY 10001, USA"], source_place_id: `P000FAKE${n}`, ...extra +}); +const saved = (n, extra = {}) => ({ + data: { addConsumerAddressV2: { defaultAddress: { id: String(900 + n), street: `${n} Fabricated Plaza`, city: "Testville", state: "NY", zipCode: "10001", printableAddress: `${n} Fabricated Plaza, Testville, NY 10001, USA`, submarket: "Testville" }, isGuest: true, ...extra } } +}); + +function harness(options = {}) { + const calls = []; + const replies = { + [AUTOCOMPLETE]: () => ({ status: 200, json: { predictions: [prediction(1), prediction(2), prediction(3)] } }), + [CREATE]: init => ({ status: 200, json: { address: place(Number(/\d+$/.exec(JSON.parse(init.body).address_identifier.source_place_id)[0])) } }), + [MUTATION]: init => ({ status: 200, json: saved(Number(JSON.parse(init.body).variables.addressId) - 900) }), + ...options.replies + }; + const stubFetch = async (url, init) => { + calls.push({ url, init: { ...init, body: init.body ? JSON.parse(init.body) : undefined } }); + if (options.fetchError) throw options.fetchError; + const key = Object.keys(replies).find(k => url.startsWith(k)); + assert.ok(key, "unexpected request " + url); + const reply = replies[key](init); + const body = reply.body ?? JSON.stringify(reply.json); + return { status: reply.status, ok: reply.status >= 200 && reply.status < 300, text: async () => body }; + }; + const context = { + location: { origin: options.origin ?? HOME, pathname: "/" }, + document: { cookie: options.cookie ?? "a=1; csrf_token=fabricated-csrf" }, + fetch: stubFetch, AbortSignal, JSON, Object, Array, Number, String, Boolean, Math, Promise, Error, RegExp, isFinite, encodeURIComponent + }; + const [definition] = runInNewContext(`(${source})`, context); + return { + definition, calls, + run: async input => { + const result = plain(await definition.execute(input)); + assert.ok(conforms(definition.tool.outputSchema, result), JSON.stringify(result).slice(0, 300)); + return result; + } + }; +} + +// --- contract ------------------------------------------------------------------------------------ + +test("single page definition, narrowed to www, not read-only, and agrees with the registry", () => { + const { definition } = harness(); + assert.equal(definition.kind, "page"); + assert.equal(definition.tool.name, "set_delivery_address"); + assert.deepEqual(plain(definition.match.url_patterns), ["https://www.doordash.com/*"]); + assert.equal(definition.tool.annotations.readOnlyHint, false); + const entry = registry.tools.find(t => t.domain === "doordash.com" && t.tool === "set_delivery_address"); + assert.equal(entry.kind, "page"); + assert.deepEqual(entry.url_patterns, ["https://www.doordash.com/*"]); +}); + +test("sets the first suggestion with the picker's three requests and reports DoorDash's confirmed address", async () => { + const h = harness(); + const result = await h.run({ address: " 1 Fabricated Plaza, Testville " }); + assert.equal(result.status, "ok"); + assert.deepEqual(result.address, { printable: "1 Fabricated Plaza, Testville, NY 10001, USA", street: "1 Fabricated Plaza", city: "Testville", state: "NY", zip_code: "10001", submarket: "Testville" }); + assert.equal(result.suggestions.length, 3); + assert.match(result.next_step, /list the tools again/); + assert.deepEqual(h.calls.map(c => c.url), [AUTOCOMPLETE + "1%20Fabricated%20Plaza%2C%20Testville", CREATE, MUTATION]); + assert.equal(h.calls[0].init.method, undefined, "the lookup is a GET"); + assert.deepEqual(h.calls[1].init.body, { address_identifier: { _type: "source_place_id_request", source_place_id: "fabricated-source-place-1" } }); + const mutation = h.calls[2].init; + assert.equal(mutation.headers["x-csrftoken"], "fabricated-csrf"); + assert.equal(mutation.body.operationName, "addConsumerAddressV2"); + assert.deepEqual(mutation.body.variables, { googlePlaceId: "P000FAKE1", printableAddress: "Testville, NY 10001, USA", lat: 40.101, lng: -73.1, city: "Testville", state: "NY", zipCode: "10001", shortname: "1 Fabricated Plaza", addressId: "901" }); + assert.match(mutation.body.query, /^mutation addConsumerAddressV2\(/); +}); + +test("chooses the numbered suggestion, and returns the suggestions when that number is out of range", async () => { + const second = harness(); + assert.equal((await second.run({ address: "Fabricated Plaza", suggestion: 2 })).address.street, "2 Fabricated Plaza"); + const beyond = harness(); + const result = await beyond.run({ address: "Fabricated Plaza", suggestion: 4 }); + assert.equal(result.error_kind, "address_not_found"); + assert.equal(result.suggestions.length, 3); + assert.equal(beyond.calls.length, 1, "nothing is created or set"); +}); + +test("refuses bad input, other origins and signed-in visits before any request", async () => { + for (const input of [null, {}, { address: "ab" }, { address: "x".repeat(201) }, { address: "1 Plaza", suggestion: 0 }, { address: "1 Plaza", suggestion: 1.5 }, { address: "1 Plaza", extra: 1 }]) { + const h = harness(); + assert.equal((await h.run(input)).error_kind, "invalid_input", JSON.stringify(input)); + assert.equal(h.calls.length, 0); + } + const elsewhere = harness({ origin: "https://merchant-portal.doordash.com" }); + assert.equal((await elsewhere.run({ address: "1 Plaza" })).error_kind, "wrong_page"); + const signedIn = harness({ cookie: "dd_cx_logged_in=true; csrf_token=x" }); + const refused = await signedIn.run({ address: "1 Plaza" }); + assert.equal(refused.error_kind, "signed_in"); + assert.match(refused.error_message, /save_address/); + assert.equal(elsewhere.calls.length + signedIn.calls.length, 0); +}); + +test("an address with no suggestion is address_not_found and changes nothing", async () => { + const h = harness({ replies: { [AUTOCOMPLETE]: () => ({ status: 200, json: { predictions: [] } }) } }); + assert.equal((await h.run({ address: "Nowhere Fabricated" })).error_kind, "address_not_found"); + assert.equal(h.calls.length, 1); +}); + +test("site problems are reported, and an unconfirmed mutation says to check the page", async () => { + const limited = harness({ replies: { [AUTOCOMPLETE]: () => ({ status: 429, json: {} }) } }); + assert.equal((await limited.run({ address: "1 Plaza" })).error_kind, "rate_limited"); + const drifted = harness({ replies: { [AUTOCOMPLETE]: () => ({ status: 200, json: { results: [] } }) } }); + assert.equal((await drifted.run({ address: "1 Plaza" })).error_kind, "site_changed"); + const unresolved = harness({ replies: { [CREATE]: () => ({ status: 200, json: { address: place(1, { postal_code: "" }) } }) } }); + assert.equal((await unresolved.run({ address: "1 Plaza" })).error_kind, "site_changed"); + assert.equal(unresolved.calls.length, 2, "the mutation is not sent for an incomplete address"); + const rejected = harness({ replies: { [MUTATION]: () => ({ status: 200, json: { errors: [{ message: "nope" }], data: null } }) } }); + const failed = await rejected.run({ address: "1 Plaza" }); + assert.equal(failed.error_kind, "site_error"); + assert.match(failed.error_message, /check the header/); + const thrown = harness({ fetchError: new Error("offline") }); + assert.equal((await thrown.run({ address: "1 Plaza" })).error_kind, "site_error"); + const account = harness({ replies: { [MUTATION]: () => ({ status: 200, json: saved(1, { isGuest: false }) }) } }); + assert.equal((await account.run({ address: "1 Plaza" })).error_kind, "signed_in"); +}); From 7c166915db7446f9f3dc15a388f1ef3e89f99b71 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:21:43 +0000 Subject: [PATCH 09/13] doordash.com: confirm a signed-out visit and the chosen address in set_delivery_address Ask DoorDash's consumer query whether the visit is a guest before any address request, since the cookie alone can miss a signed-in session and the mutation would then save the address to the account. Report success only when DoorDash's answer has the chosen suggestion's city and ZIP code. Restore main's README row for login, which a merge had reworded. --- domains/doordash.com/README.md | 2 +- domains/doordash.com/set_delivery_address.js | 38 +++++++++++----- registry.json | 2 +- tests/doordash-set-delivery-address.test.mjs | 46 ++++++++++++++++---- 4 files changed, 67 insertions(+), 21 deletions(-) diff --git a/domains/doordash.com/README.md b/domains/doordash.com/README.md index f1192e11..dbe19134 100644 --- a/domains/doordash.com/README.md +++ b/domains/doordash.com/README.md @@ -6,7 +6,7 @@ | get_cart | Read open DoorDash carts, line ids/options/quantities, currency and provisional subtotal/total/fee lines from the current authorized session. | | get_checkout_summary | Prepare a read-only DoorDash order summary for an exact open cart: lines, provisional fees/totals, currency and explicit delivery context, plus a manually opened checkout URL. | | get_store | Reads one DoorDash store by store_id: details, hours, menus, and whether delivery and pickup can be ordered now with the site's estimate and delivery-fee text. | -| login | Sign in to a DoorDash consumer account with email and password, or submit a six-digit code for a verification step that is already visible. | +| login | Signs in to a DoorDash consumer account with email and password, or submits a visible six-digit verification code; confirms an existing session without submitting. | | register | Creates a DoorDash consumer account from the email sign-up form, or submits a visible six-digit sign-up verification code; refuses when already signed in. | | remove_cart_item | Remove exactly one existing line from an ordinary personal DoorDash cart using the site's removeCartItem operation. | | save_address | Makes a delivery address the account's current default: selects it if already saved, otherwise adds it from DoorDash's autocomplete with optional details. | diff --git a/domains/doordash.com/set_delivery_address.js b/domains/doordash.com/set_delivery_address.js index da11cf3e..9d3dc2ef 100644 --- a/domains/doordash.com/set_delivery_address.js +++ b/domains/doordash.com/set_delivery_address.js @@ -3,7 +3,7 @@ match: { url_patterns: ["https://www.doordash.com/*"] }, tool: { name: "set_delivery_address", - description: "Set the delivery address of a signed-out DoorDash visit, as the header's address picker does: it looks the address up in DoorDash's address autocomplete, chooses the suggestion you name (the first by default, as the picker lists them) and makes it the visit's delivery address with the same requests the picker sends. Success is confirmed by DoorDash's answer, which states the address it now delivers to. Store fees, delivery estimates, search results and get_store then use that address, but pages already open keep showing the old one until they load again: the site itself opens /home after an address is chosen, so navigate before reading, then list the tools again. Returns the suggestions when none matches the request or the chosen number is out of range; never edits saved account addresses and refuses when signed in (use save_address then). Only call it with an address the user gave.", + description: "Set the delivery address of a signed-out DoorDash visit, as the header's address picker does: it looks the address up in DoorDash's address autocomplete, chooses the suggestion you name (the first by default, as the picker lists them) and makes it the visit's delivery address with the same requests the picker sends. Success is confirmed by DoorDash's answer, which states the address it now delivers to and must have the chosen suggestion's city and ZIP code. Store fees, delivery estimates, search results and get_store then use that address, but pages already open keep showing the old one until they load again: the site itself opens /home after an address is chosen, so navigate before reading, then list the tools again. Returns the suggestions when none matches the request or the chosen number is out of range; never edits saved account addresses: it asks DoorDash whether the visit is signed out before changing anything and refuses when it is not (use save_address then). Only call it with an address the user gave.", inputSchema: { type: "object", additionalProperties: false, required: ["address"], properties: { @@ -22,26 +22,26 @@ type: "object", additionalProperties: false, required: ["printable", "street", "city", "state", "zip_code", "submarket"], description: "The delivery address as DoorDash now reports it for this visit.", properties: { - printable: { type: "string" }, street: { type: ["string", "null"] }, city: { type: ["string", "null"] }, state: { type: ["string", "null"] }, - zip_code: { type: ["string", "null"] }, submarket: { type: ["string", "null"] } + printable: { type: "string", maxLength: 200 }, street: { type: ["string", "null"], maxLength: 200 }, city: { type: ["string", "null"], maxLength: 200 }, state: { type: ["string", "null"], maxLength: 20 }, + zip_code: { type: ["string", "null"], maxLength: 20 }, submarket: { type: ["string", "null"], maxLength: 200 } } }, - suggestions: { type: "array", maxItems: 10, items: { type: "string" }, description: "Every suggestion the autocomplete offered, in its order." }, - next_step: { type: "string" } + suggestions: { type: "array", maxItems: 10, items: { type: "string", maxLength: 200 }, description: "Every suggestion the autocomplete offered, in its order." }, + next_step: { type: "string", maxLength: 500 } } }, { type: "object", additionalProperties: false, required: ["status", "error_kind", "error_message", "suggestions"], properties: { status: { const: "error" }, - error_kind: { enum: ["invalid_input", "wrong_page", "signed_in", "address_not_found", "rate_limited", "site_changed", "site_error"] }, - error_message: { type: "string" }, - suggestions: { type: "array", maxItems: 10, items: { type: "string" } } + error_kind: { enum: ["invalid_input", "wrong_page", "signed_in", "address_not_found", "address_mismatch", "rate_limited", "site_changed", "site_error"] }, + error_message: { type: "string", maxLength: 500 }, + suggestions: { type: "array", maxItems: 10, items: { type: "string", maxLength: 200 } } } } ] }, - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false } + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, untrustedContentHint: true } }, execute: async (input, context) => { const ORIGIN = "https://www.doordash.com"; @@ -60,6 +60,8 @@ const signals = [AbortSignal.timeout(25000)]; if (context && context.signal) signals.push(context.signal); const signal = AbortSignal.any(signals); + const csrf = (document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("csrf_token=")) || "").slice("csrf_token=".length); + const graphql = { "content-type": "application/json", accept: "*/*", "x-csrftoken": csrf, "x-channel-id": "marketplace", "x-experience-id": "doordash" }; const gateway = { accept: "application/json", "x-experience-id": "doordash", "x-unified-gateway-generated-source": "v1" }; const call = async (path, init) => { let response, body; @@ -75,6 +77,17 @@ return { status: response.status, json }; }; + // The cookie can be missing on a signed-in visit, and for an account the mutation below saves the address there. + const visitor = await call("/graphql/consumerBucketing?operation=consumerBucketing", { + method: "POST", headers: graphql, + body: JSON.stringify({ operationName: "consumerBucketing", variables: {}, query: "query consumerBucketing { consumer { isGuest } }" }) + }); + if (visitor.error) return fail("site_error", visitor.error + " Nothing was changed."); + if (visitor.status === 429) return fail("rate_limited", "DoorDash is rate limiting requests from this browser; wait before calling again. Nothing was changed."); + const isGuest = visitor.json && visitor.json.data && visitor.json.data.consumer ? visitor.json.data.consumer.isGuest : undefined; + if (isGuest === false) return fail("signed_in", "This browser is signed in to DoorDash, where choosing an address saves it to the account; use save_address instead. Nothing was changed."); + if (visitor.status !== 200 || isGuest !== true) return fail("site_changed", "DoorDash did not confirm this is a signed-out visit (HTTP " + visitor.status + "). Nothing was changed."); + const found = await call("/unified-gateway/geo-intelligence/v2/address/autocomplete?input_address=" + encodeURIComponent(address), { headers: gateway }); if (found.error) return fail("site_error", found.error + " Nothing was changed."); if (found.status === 429) return fail("rate_limited", "DoorDash is rate limiting requests from this browser; wait before calling again. Nothing was changed."); @@ -100,10 +113,9 @@ const shortname = text(place.formatted_address_short) || text(place.street_address) || text(segments[0]); const printable = text(segments[1]) || text(place.formatted_address); - const csrf = (document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("csrf_token=")) || "").slice("csrf_token=".length); const saved = await call("/graphql/addConsumerAddressV2?operation=addConsumerAddressV2", { method: "POST", - headers: { "content-type": "application/json", accept: "*/*", "x-csrftoken": csrf, "x-channel-id": "marketplace", "x-experience-id": "doordash" }, + headers: graphql, body: JSON.stringify({ operationName: "addConsumerAddressV2", variables: { googlePlaceId: place.source_place_id, printableAddress: printable, lat, lng, city: place.locality, state: place.administrative_area_level1, zipCode: place.postal_code, shortname, addressId: String(place.id) }, @@ -118,6 +130,10 @@ return fail("site_error", "DoorDash did not confirm the address (HTTP " + saved.status + "). Reload the page and check the header before calling again.", suggestions); } if (result.isGuest === false) return fail("signed_in", "DoorDash answered for a signed-in account, so the address may now be saved there; check the account's addresses. Use save_address for signed-in visits.", suggestions); + const same = (a, b) => typeof a === "string" && typeof b === "string" && a.trim().toLowerCase() === b.trim().toLowerCase(); + if (!same(current.zipCode, place.postal_code) || !same(current.city, place.locality)) { + return fail("address_mismatch", "DoorDash now reports '" + text(current.printableAddress, 120) + "' as the delivery address, not the chosen suggestion. Reload the page and check the header before calling again.", suggestions); + } return { status: "ok", address: { printable: text(current.printableAddress), street: text(current.street), city: text(current.city), state: text(current.state, 20), zip_code: text(current.zipCode, 20), submarket: text(current.submarket) }, diff --git a/registry.json b/registry.json index a0421734..2cb99e2f 100644 --- a/registry.json +++ b/registry.json @@ -1523,7 +1523,7 @@ "url_patterns_reason": "Narrowed to www.doordash.com, the consumer marketplace: these tools call its same-origin GraphQL endpoint with the page's session; other doordash.com hosts are separate products such as the merchant portal and help center.", "status": "draft", "last_verified": null, - "notes": "Signed-out visits only; refuses when the dd_cx_logged_in cookie is set (save_address covers accounts). Sends the address picker's own three requests (autocomplete, get-or-create, addConsumerAddressV2) and confirms the address from DoorDash's answer; does not navigate. Draft, no independent QA. Live on 2026-10-21 in a signed-out Kernel browser: 'Times Square, New York, NY 10036' set 4 Times Sq in under a second and the header showed it after loading /home; an address with no suggestions returned address_not_found." + "notes": "Signed-out visits only: refuses when the dd_cx_logged_in cookie is set or DoorDash's consumer query reports a non-guest visitor, before any address request (save_address covers accounts). Sends the address picker's own three requests (autocomplete, get-or-create, addConsumerAddressV2) and reports success only when DoorDash's answer has the chosen suggestion's city and ZIP code; does not navigate. Draft, no independent QA. Live in a signed-out Kernel browser: on 2026-10-21 'Times Square, New York, NY 10036' set 4 Times Sq and the header showed it after loading /home, and an address with no suggestions returned address_not_found; on 2026-10-22 the source with the guest check and city/ZIP confirmation set the same address." }, { "domain": "etsy.com", diff --git a/tests/doordash-set-delivery-address.test.mjs b/tests/doordash-set-delivery-address.test.mjs index 2bafde9a..4340fda3 100644 --- a/tests/doordash-set-delivery-address.test.mjs +++ b/tests/doordash-set-delivery-address.test.mjs @@ -12,6 +12,7 @@ const HOME = "https://www.doordash.com"; const AUTOCOMPLETE = "/unified-gateway/geo-intelligence/v2/address/autocomplete?input_address="; const CREATE = "/unified-gateway/geo-intelligence/v2/address/get-or-create"; const MUTATION = "/graphql/addConsumerAddressV2?operation=addConsumerAddressV2"; +const BUCKETING = "/graphql/consumerBucketing?operation=consumerBucketing"; function conforms(schema, value) { if (schema.oneOf && schema.oneOf.filter(s => conforms(s, value)).length !== 1) return false; @@ -55,6 +56,7 @@ const saved = (n, extra = {}) => ({ function harness(options = {}) { const calls = []; const replies = { + [BUCKETING]: () => ({ status: 200, json: { data: { consumer: { isGuest: true } } } }), [AUTOCOMPLETE]: () => ({ status: 200, json: { predictions: [prediction(1), prediction(2), prediction(3)] } }), [CREATE]: init => ({ status: 200, json: { address: place(Number(/\d+$/.exec(JSON.parse(init.body).address_identifier.source_place_id)[0])) } }), [MUTATION]: init => ({ status: 200, json: saved(Number(JSON.parse(init.body).variables.addressId) - 900) }), @@ -98,17 +100,18 @@ test("single page definition, narrowed to www, not read-only, and agrees with th assert.deepEqual(entry.url_patterns, ["https://www.doordash.com/*"]); }); -test("sets the first suggestion with the picker's three requests and reports DoorDash's confirmed address", async () => { +test("checks the visit is signed out, sets the first suggestion with the picker's three requests and reports DoorDash's confirmed address", async () => { const h = harness(); const result = await h.run({ address: " 1 Fabricated Plaza, Testville " }); assert.equal(result.status, "ok"); assert.deepEqual(result.address, { printable: "1 Fabricated Plaza, Testville, NY 10001, USA", street: "1 Fabricated Plaza", city: "Testville", state: "NY", zip_code: "10001", submarket: "Testville" }); assert.equal(result.suggestions.length, 3); assert.match(result.next_step, /list the tools again/); - assert.deepEqual(h.calls.map(c => c.url), [AUTOCOMPLETE + "1%20Fabricated%20Plaza%2C%20Testville", CREATE, MUTATION]); - assert.equal(h.calls[0].init.method, undefined, "the lookup is a GET"); - assert.deepEqual(h.calls[1].init.body, { address_identifier: { _type: "source_place_id_request", source_place_id: "fabricated-source-place-1" } }); - const mutation = h.calls[2].init; + assert.deepEqual(h.calls.map(c => c.url), [BUCKETING, AUTOCOMPLETE + "1%20Fabricated%20Plaza%2C%20Testville", CREATE, MUTATION]); + assert.equal(h.calls[0].init.body.query, "query consumerBucketing { consumer { isGuest } }"); + assert.equal(h.calls[1].init.method, undefined, "the lookup is a GET"); + assert.deepEqual(h.calls[2].init.body, { address_identifier: { _type: "source_place_id_request", source_place_id: "fabricated-source-place-1" } }); + const mutation = h.calls[3].init; assert.equal(mutation.headers["x-csrftoken"], "fabricated-csrf"); assert.equal(mutation.body.operationName, "addConsumerAddressV2"); assert.deepEqual(mutation.body.variables, { googlePlaceId: "P000FAKE1", printableAddress: "Testville, NY 10001, USA", lat: 40.101, lng: -73.1, city: "Testville", state: "NY", zipCode: "10001", shortname: "1 Fabricated Plaza", addressId: "901" }); @@ -122,7 +125,7 @@ test("chooses the numbered suggestion, and returns the suggestions when that num const result = await beyond.run({ address: "Fabricated Plaza", suggestion: 4 }); assert.equal(result.error_kind, "address_not_found"); assert.equal(result.suggestions.length, 3); - assert.equal(beyond.calls.length, 1, "nothing is created or set"); + assert.equal(beyond.calls.length, 2, "nothing is created or set"); }); test("refuses bad input, other origins and signed-in visits before any request", async () => { @@ -143,7 +146,7 @@ test("refuses bad input, other origins and signed-in visits before any request", test("an address with no suggestion is address_not_found and changes nothing", async () => { const h = harness({ replies: { [AUTOCOMPLETE]: () => ({ status: 200, json: { predictions: [] } }) } }); assert.equal((await h.run({ address: "Nowhere Fabricated" })).error_kind, "address_not_found"); - assert.equal(h.calls.length, 1); + assert.equal(h.calls.length, 2); }); test("site problems are reported, and an unconfirmed mutation says to check the page", async () => { @@ -153,7 +156,7 @@ test("site problems are reported, and an unconfirmed mutation says to check the assert.equal((await drifted.run({ address: "1 Plaza" })).error_kind, "site_changed"); const unresolved = harness({ replies: { [CREATE]: () => ({ status: 200, json: { address: place(1, { postal_code: "" }) } }) } }); assert.equal((await unresolved.run({ address: "1 Plaza" })).error_kind, "site_changed"); - assert.equal(unresolved.calls.length, 2, "the mutation is not sent for an incomplete address"); + assert.equal(unresolved.calls.length, 3, "the mutation is not sent for an incomplete address"); const rejected = harness({ replies: { [MUTATION]: () => ({ status: 200, json: { errors: [{ message: "nope" }], data: null } }) } }); const failed = await rejected.run({ address: "1 Plaza" }); assert.equal(failed.error_kind, "site_error"); @@ -163,3 +166,30 @@ test("site problems are reported, and an unconfirmed mutation says to check the const account = harness({ replies: { [MUTATION]: () => ({ status: 200, json: saved(1, { isGuest: false }) }) } }); assert.equal((await account.run({ address: "1 Plaza" })).error_kind, "signed_in"); }); + +test("a signed-in visit without the cookie is refused before any address request", async () => { + const account = harness({ replies: { [BUCKETING]: () => ({ status: 200, json: { data: { consumer: { isGuest: false } } } }) } }); + const refused = await account.run({ address: "1 Plaza" }); + assert.equal(refused.error_kind, "signed_in"); + assert.match(refused.error_message, /save_address/); + assert.deepEqual(account.calls.map(c => c.url), [BUCKETING]); + for (const reply of [{ status: 200, json: { data: { consumer: null } } }, { status: 200, json: { errors: [{ message: "x" }] } }, { status: 500, json: {} }]) { + const unclear = harness({ replies: { [BUCKETING]: () => reply } }); + assert.equal((await unclear.run({ address: "1 Plaza" })).error_kind, "site_changed", JSON.stringify(reply)); + assert.equal(unclear.calls.length, 1); + } + const limited = harness({ replies: { [BUCKETING]: () => ({ status: 429, json: {} }) } }); + assert.equal((await limited.run({ address: "1 Plaza" })).error_kind, "rate_limited"); +}); + +test("an answer naming a different address than the chosen suggestion is not success", async () => { + for (const changed of [{ zipCode: "10002" }, { city: "Elsewhere" }, { zipCode: null }]) { + const h = harness({ replies: { [MUTATION]: () => { const reply = saved(1); Object.assign(reply.data.addConsumerAddressV2.defaultAddress, changed); return { status: 200, json: reply }; } } }); + const result = await h.run({ address: "1 Plaza" }); + assert.equal(result.error_kind, "address_mismatch", JSON.stringify(changed)); + assert.match(result.error_message, /check the header/); + assert.equal(result.suggestions.length, 3); + } + const caseOnly = harness({ replies: { [MUTATION]: () => { const reply = saved(1); reply.data.addConsumerAddressV2.defaultAddress.city = "TESTVILLE "; return { status: 200, json: reply }; } } }); + assert.equal((await caseOnly.run({ address: "1 Plaza" })).status, "ok"); +}); From 4ab97e8f900ac952cea00ac9d35f8b42b7183f89 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:05:32 +0000 Subject: [PATCH 10/13] doordash.com: confirm the chosen street in set_delivery_address, and keep the cart reads out of signed-out bench intents --- bench/tasks/doordash.com/domain.json | 4 ++++ domains/doordash.com/set_delivery_address.js | 4 ++-- registry.json | 2 +- tests/doordash-set-delivery-address.test.mjs | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/bench/tasks/doordash.com/domain.json b/bench/tasks/doordash.com/domain.json index f037c9b6..780acdc8 100644 --- a/bench/tasks/doordash.com/domain.json +++ b/bench/tasks/doordash.com/domain.json @@ -1,4 +1,8 @@ { + "needs_sign_in": { + "get_cart": "reads the signed-in account's carts", + "get_checkout_summary": "reads the signed-in account's carts" + }, "actions": { "set_delivery_address": "sets a signed-out visit's delivery address, as the header's address picker does" } diff --git a/domains/doordash.com/set_delivery_address.js b/domains/doordash.com/set_delivery_address.js index 9d3dc2ef..21b57173 100644 --- a/domains/doordash.com/set_delivery_address.js +++ b/domains/doordash.com/set_delivery_address.js @@ -3,7 +3,7 @@ match: { url_patterns: ["https://www.doordash.com/*"] }, tool: { name: "set_delivery_address", - description: "Set the delivery address of a signed-out DoorDash visit, as the header's address picker does: it looks the address up in DoorDash's address autocomplete, chooses the suggestion you name (the first by default, as the picker lists them) and makes it the visit's delivery address with the same requests the picker sends. Success is confirmed by DoorDash's answer, which states the address it now delivers to and must have the chosen suggestion's city and ZIP code. Store fees, delivery estimates, search results and get_store then use that address, but pages already open keep showing the old one until they load again: the site itself opens /home after an address is chosen, so navigate before reading, then list the tools again. Returns the suggestions when none matches the request or the chosen number is out of range; never edits saved account addresses: it asks DoorDash whether the visit is signed out before changing anything and refuses when it is not (use save_address then). Only call it with an address the user gave.", + description: "Set the delivery address of a signed-out DoorDash visit, as the header's address picker does: it looks the address up in DoorDash's address autocomplete, chooses the suggestion you name (the first by default, as the picker lists them) and makes it the visit's delivery address with the same requests the picker sends. Success is confirmed by DoorDash's answer, which states the address it now delivers to and must have the chosen suggestion's street, city and ZIP code. Store fees, delivery estimates, search results and get_store then use that address, but pages already open keep showing the old one until they load again: the site itself opens /home after an address is chosen, so navigate before reading, then list the tools again. Returns the suggestions when none matches the request or the chosen number is out of range; never edits saved account addresses: it asks DoorDash whether the visit is signed out before changing anything and refuses when it is not (use save_address then). Only call it with an address the user gave.", inputSchema: { type: "object", additionalProperties: false, required: ["address"], properties: { @@ -131,7 +131,7 @@ } if (result.isGuest === false) return fail("signed_in", "DoorDash answered for a signed-in account, so the address may now be saved there; check the account's addresses. Use save_address for signed-in visits.", suggestions); const same = (a, b) => typeof a === "string" && typeof b === "string" && a.trim().toLowerCase() === b.trim().toLowerCase(); - if (!same(current.zipCode, place.postal_code) || !same(current.city, place.locality)) { + if (!same(current.zipCode, place.postal_code) || !same(current.city, place.locality) || (text(place.street_address) && !same(current.street, place.street_address))) { return fail("address_mismatch", "DoorDash now reports '" + text(current.printableAddress, 120) + "' as the delivery address, not the chosen suggestion. Reload the page and check the header before calling again.", suggestions); } return { diff --git a/registry.json b/registry.json index 9ed250e6..5f035e5b 100644 --- a/registry.json +++ b/registry.json @@ -1523,7 +1523,7 @@ "url_patterns_reason": "Narrowed to www.doordash.com, the consumer marketplace: these tools call its same-origin GraphQL endpoint with the page's session; other doordash.com hosts are separate products such as the merchant portal and help center.", "status": "draft", "last_verified": null, - "notes": "Signed-out visits only: refuses when the dd_cx_logged_in cookie is set or DoorDash's consumer query reports a non-guest visitor, before any address request (save_address covers accounts). Sends the address picker's own three requests (autocomplete, get-or-create, addConsumerAddressV2) and reports success only when DoorDash's answer has the chosen suggestion's city and ZIP code; does not navigate. Draft, no independent QA. Live in a signed-out Kernel browser: on 2026-10-21 'Times Square, New York, NY 10036' set 4 Times Sq and the header showed it after loading /home, and an address with no suggestions returned address_not_found; on 2026-10-22 the source with the guest check and city/ZIP confirmation set the same address." + "notes": "Signed-out visits only: refuses when the dd_cx_logged_in cookie is set or DoorDash's consumer query reports a non-guest visitor, before any address request (save_address covers accounts). Sends the address picker's own three requests (autocomplete, get-or-create, addConsumerAddressV2) and reports success only when DoorDash's answer has the chosen suggestion's street, city and ZIP code; does not navigate. Draft, no independent QA. Live in a signed-out Kernel browser: on 2026-10-21 'Times Square, New York, NY 10036' set 4 Times Sq and the header showed it after loading /home, and an address with no suggestions returned address_not_found; on 2026-10-22 the source with the guest check and street/city/ZIP confirmation set the same address, 350 5th Ave, 1600 Amphitheatre Pkwy and 233 S Wacker Dr, each confirmed by DoorDash's answer." }, { "domain": "etsy.com", diff --git a/tests/doordash-set-delivery-address.test.mjs b/tests/doordash-set-delivery-address.test.mjs index 4340fda3..4cf9c29f 100644 --- a/tests/doordash-set-delivery-address.test.mjs +++ b/tests/doordash-set-delivery-address.test.mjs @@ -183,7 +183,7 @@ test("a signed-in visit without the cookie is refused before any address request }); test("an answer naming a different address than the chosen suggestion is not success", async () => { - for (const changed of [{ zipCode: "10002" }, { city: "Elsewhere" }, { zipCode: null }]) { + for (const changed of [{ zipCode: "10002" }, { city: "Elsewhere" }, { zipCode: null }, { street: "2 Fabricated Plaza" }, { street: null }]) { const h = harness({ replies: { [MUTATION]: () => { const reply = saved(1); Object.assign(reply.data.addConsumerAddressV2.defaultAddress, changed); return { status: 200, json: reply }; } } }); const result = await h.run({ address: "1 Plaza" }); assert.equal(result.error_kind, "address_mismatch", JSON.stringify(changed)); From b38843891114ddca9098e76577d978ec1959ae85 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:28:06 +0000 Subject: [PATCH 11/13] Let remove_cart_item clear a spent write guard and accept repriced lines --- domains/doordash.com/remove_cart_item.js | 24 +++++++++++++++----- registry.json | 9 +++----- tests/doordash-cart.test.mjs | 29 ++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/domains/doordash.com/remove_cart_item.js b/domains/doordash.com/remove_cart_item.js index 72dca83e..8d8e22ba 100644 --- a/domains/doordash.com/remove_cart_item.js +++ b/domains/doordash.com/remove_cart_item.js @@ -3,7 +3,7 @@ match: { url_patterns: ["https://www.doordash.com/*"] }, tool: { name: "remove_cart_item", - description: "Remove exactly one existing line from an ordinary personal DoorDash cart using the site's removeCartItem operation. Requires the user's intended expected_account_email, cart_id, line_id (NOT menu item id), expected_store_id and a fresh get_cart expected_revision. Checks the current account, queried cart revision and store immediately before one write; refuses group, bundle, catering, shipping, finalized/unsupported carts and last-line removal. Does not change address, payment, quantity/options of other lines, or place an order. Returns removed only after rereading the open carts proves this line absent and all other queried cart identity/line fields unchanged. Never retries writes: an uncertain outcome leaves a sessionStorage guard; inspect the cart manually rather than reinvoking. No live mutation QA has been authorized; mutation evidence is fabricated fixtures only. Call serially; server provides no atomic compare-and-swap, so cross-tab changes remain a race.", + description: "Remove exactly one existing line from an ordinary personal DoorDash cart using the site's removeCartItem operation. Requires the user's intended expected_account_email, cart_id, line_id (NOT menu item id), expected_store_id and a fresh get_cart expected_revision. Checks the current account, queried cart revision and store immediately before one write; refuses group, bundle, catering, shipping, finalized/unsupported carts and last-line removal. Does not change address, payment, quantity/options of other lines, or place an order. Returns removed only after rereading the open carts proves this line absent and the other lines' identity, quantity and options unchanged (the site may reprice them). Never retries writes: an uncertain outcome leaves a sessionStorage guard that refuses further removals from this cart until a fresh get_cart revision shows the cart has changed; inspect the cart on the site rather than reinvoking. No live mutation QA has been authorized; mutation evidence is fabricated fixtures only. Call serially; server provides no atomic compare-and-swap, so cross-tab changes remain a race.", inputSchema: { type: "object", additionalProperties: false, @@ -194,11 +194,20 @@ if (signal.aborted) return fail("cancelled", "Cancelled before the cart write."); const key = "doordash.com.remove_cart_item.pending.v1:" + input.cart_id; let lock; + const owned = () => { try { return JSON.parse(sessionStorage.getItem(key))?.lock === lock; } catch { return false; } }; try { - if (sessionStorage.getItem(key)) return fail("busy", "A previous cart write has an uncertain or in-flight outcome. Inspect manually; do not retry."); + const pending = sessionStorage.getItem(key); + if (pending) { + // The guard records the cart revision the earlier write started from. A fresh expected_revision that differs + // shows the caller re-read the cart after that write's outcome became visible, so the guard is spent. + let started = null; + try { started = JSON.parse(pending).revision; } catch { /* unreadable guard stays in force */ } + if (typeof started !== "string" || started === input.expected_revision) return fail("busy", "A previous write to this cart has an uncertain or in-flight outcome and the cart has not changed since. Check the cart on the site; do not retry."); + sessionStorage.removeItem(key); + } lock = crypto.randomUUID(); - sessionStorage.setItem(key, lock); - if (sessionStorage.getItem(key) !== lock) return fail("busy", "Another cart write is in progress."); + sessionStorage.setItem(key, JSON.stringify({ lock, revision: input.expected_revision })); + if (!owned()) return fail("busy", "Another cart write is in progress."); } catch { return fail("blocked", "The duplicate-write guard is unavailable; no write was sent."); } if (signal.aborted) { sessionStorage.removeItem(key); return fail("cancelled", "Cancelled before the cart write."); } writeAttempted = true; @@ -209,12 +218,15 @@ const afterMatches = afterList.filter(c => c?.id === input.cart_id); if (afterMatches.length !== 1) return fail("outcome_unknown", "The cart could not be observed after the write. Inspect manually; do not retry.", true); const after = afterMatches[0]; - const expected = { ...before, subtotal: after.subtotal, total: after.total, orders: before.orders.map(o => ({ ...o, orderItems: o.orderItems.filter(l => String(l.id) !== input.line_id), lineItems: after.orders?.[0]?.lineItems })) }; + // The site may reprice the remaining lines (promotions, bundles); their identity, quantity and options must not change. + const repriced = new Map((Array.isArray(after.orders?.[0]?.orderItems) ? after.orders[0].orderItems : []).map(l => [String(l?.id), l])); + const kept = l => { const now = repriced.get(String(l.id)); return { ...l, singlePrice: now?.singlePrice, priceOfTotalQuantity: now?.priceOfTotalQuantity, priceDisplayString: now?.priceDisplayString }; }; + const expected = { ...before, subtotal: after.subtotal, total: after.total, orders: before.orders.map(o => ({ ...o, orderItems: o.orderItems.filter(l => String(l.id) !== input.line_id).map(kept), lineItems: after.orders?.[0]?.lineItems })) }; if (JSON.stringify(canonical(expected)) !== JSON.stringify(canonical(after))) return fail("outcome_unknown", "The exact removal with unchanged other lines was not confirmed. Inspect manually; do not retry.", true); const remaining = await project(after); if (signal.aborted) return fail("outcome_unknown", "Cancelled before the final cart result was returned. Inspect manually; do not retry.", true); if (remaining.truncated || new TextEncoder().encode(JSON.stringify(remaining)).byteLength > 170000) return fail("outcome_unknown", "The remaining cart exceeds safe observation bounds. Inspect manually; do not retry.", true); - if (sessionStorage.getItem(key) === lock) sessionStorage.removeItem(key); + if (owned()) sessionStorage.removeItem(key); return { status: "ok", account_matched: true, removed: true, cart_id: input.cart_id, line_id: input.line_id, write_attempted: true, remaining_cart: remaining }; } catch (error) { if (writeAttempted) return fail("outcome_unknown", "The exact cart write postcondition was not observed. Inspect manually; do not retry automatically.", true); diff --git a/registry.json b/registry.json index 5f035e5b..b2ae72bb 100644 --- a/registry.json +++ b/registry.json @@ -1487,12 +1487,9 @@ "https://www.doordash.com/*" ], "url_patterns_reason": "Narrowed to www.doordash.com, the consumer marketplace: these tools call its same-origin GraphQL endpoint with the page's session; other doordash.com hosts are separate products such as the merchant portal and help center.", - "status": "qa_passed", - "last_verified": "2026-09-30", - "notes": "Exact non-last ordinary personal cart-line removal with account/store/revision, complete non-prescription line checks, persistent uncertain-write guard and observed postconditions. One attempt, no automatic retries. Independent Kernel/local-extension QA passed; all mutation success is fixture-only, never live-verified. Cross-tab races are not atomically excluded; final order/payment execution is absent.", - "provenance": { - "qa_agent": "agt_e3cd254315c78d609b12" - } + "status": "draft", + "last_verified": null, + "notes": "Exact non-last ordinary personal cart-line removal with account/store/revision, complete non-prescription line checks, an uncertain-write guard and observed postconditions. One attempt, no automatic retries. The guard refuses further removals from the cart until a fresh get_cart revision shows the cart changed; the postcondition lets the site reprice the remaining lines but not change their identity, quantity or options. Independent Kernel/local-extension QA passed on 2026-09-30 for earlier source; the guard and postcondition changed since and need fresh independent QA. All mutation success is fixture-only, never live-verified. Cross-tab races are not atomically excluded; final order/payment execution is absent." }, { "domain": "doordash.com", diff --git a/tests/doordash-cart.test.mjs b/tests/doordash-cart.test.mjs index c070f184..c8b994c2 100644 --- a/tests/doordash-cart.test.mjs +++ b/tests/doordash-cart.test.mjs @@ -101,6 +101,21 @@ for (const name of Object.keys(sources)) { }); } +// remove_cart_item accepts a get_cart revision, so the code that reads and hashes a cart must not drift between the tools. +test("cart query, projection and revision code is identical in every cart tool", () => { + const block = (source, name) => { + const lines = source.split("\n"), start = lines.findIndex(l => l.startsWith(` const ${name} = `)); + assert.notEqual(start, -1, name); + let end = start + 1; + while (end < lines.length && !/^ \S/.test(lines[end])) end++; + return lines.slice(start, /^ \}/.test(lines[end]) ? end + 1 : end).join("\n"); + }; + for (const name of ["cartQuery", "text", "ident", "integer", "bool", "canonical", "revision", "readCarts", "project"]) { + assert.equal(block(sources.get_checkout_summary, name), block(sources.get_cart, name), "get_checkout_summary " + name); + assert.equal(block(sources.remove_cart_item, name), block(sources.get_cart, name), "remove_cart_item " + name); + } +}); + test("cart read exposes only allowlisted fields, minor amounts, options and revision", async () => { const c = cart(); c.password = "private-password"; c.orders[0].paymentCard = { stripeId: "private-stripe" }; c.orders[0].orderItems[0].specialInstructions = "private-instruction"; const h = harness({ carts: [c] }); const out = await h.run("get_cart", read); @@ -217,6 +232,20 @@ for (const options of [{ writeError: true }, { noRemoval: true }, { nullAck: tru }); } +test("remaining lines repriced by the site still confirm the removal", async () => { + const h = harness({ afterWrite: c => { Object.assign(c.orders[0].orderItems[0], { singlePrice: 600, priceOfTotalQuantity: 600, priceDisplayString: "$6.00" }); } }); + const out = await h.run("remove_cart_item", await removalInput(h)); + assert.equal(out.status, "ok"); assert.equal(out.remaining_cart.lines[0].total_minor, 600); assert.equal(h.store.size, 0); +}); + +test("an uncertain write blocks the unchanged cart but not a fresh revision of a changed one", async () => { + const h = harness({ noRemoval: true }); const input = await removalInput(h); + assert.equal((await h.run("remove_cart_item", input)).error_kind, "outcome_unknown"); assert.equal(h.store.size, 1); + assert.equal((await h.run("remove_cart_item", await removalInput(h))).error_kind, "busy"); assert.equal(writes(h).length, 1); + h.getCarts()[0].orders[0].orderItems.push(item("line-3")); + assert.equal((await h.run("remove_cart_item", await removalInput(h))).error_kind, "outcome_unknown"); assert.equal(writes(h).length, 2); +}); + test("concurrent removals share a per-cart guard, at most one write", async () => { const h = harness({ writeError: true }); const input = await removalInput(h); const out = await Promise.all([h.run("remove_cart_item", input), h.run("remove_cart_item", input)]); From 650be5de53903eb58153f259e84055f26778de15 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:49:39 +0000 Subject: [PATCH 12/13] doordash.com: withhold get_store's delivery-fee text without a recognised address, as its other delivery figures are --- domains/doordash.com/get_store.js | 8 ++++---- registry.json | 8 ++++---- tests/doordash-get-store.test.mjs | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/domains/doordash.com/get_store.js b/domains/doordash.com/get_store.js index da1140a8..e454ad7d 100644 --- a/domains/doordash.com/get_store.js +++ b/domains/doordash.com/get_store.js @@ -3,7 +3,7 @@ match: { url_patterns: ["https://www.doordash.com/*"] }, tool: { name: "get_store", - description: "Get one DoorDash (www.doordash.com) store by store_id (the id from search_restaurants or the number at the end of a doordash.com/store/-/ URL, without leading zeros): name, cuisines, price level, rating, the store's own address and coordinates, timezone, whether it offers delivery, pickup, group orders, catering and scheduling, open-hours schedules, its menus (menu_id is the menuId that the site's own add_to_cart tool needs), and whether delivery and pickup can be ordered right now with an estimate range and the site's delivery-fee text. Delivery availability and estimate are computed by DoorDash for the delivery address selected in this browser session, not for the store alone, so delivery_context.basis states which case applies: selected_address (the page header shows an address, so the delivery figures are for that address) or no_address_selected (the header shows the site's placeholder or the signed-out landing page shows its address form: DoorDash then answers for a placeholder location and every store reads 'Too far away') or unknown (the header text is not recognised). Delivery available, its reason and estimate, and too_far_from_consumer are returned only for selected_address and are null otherwise; pickup, hours and store details do not depend on it. signed_in is a cookie-based hint, not proof. This tool never selects or changes an address, never returns address text, and returns no customer distance. It does not return menu items (use the site's get_restaurant_menu on the store page), reviews, or a fee quote: the fee text depends on the address, the account's benefits and the basket. Convenience and grocery stores return their header details with menu null. Read-only; works signed out; runs from any www.doordash.com page without navigating.", + description: "Get one DoorDash (www.doordash.com) store by store_id (the id from search_restaurants or the number at the end of a doordash.com/store/-/ URL, without leading zeros): name, cuisines, price level, rating, the store's own address and coordinates, timezone, whether it offers delivery, pickup, group orders, catering and scheduling, open-hours schedules, its menus (menu_id is the menuId that the site's own add_to_cart tool needs), and whether delivery and pickup can be ordered right now with an estimate range and the site's delivery-fee text. Delivery availability and estimate are computed by DoorDash for the delivery address selected in this browser session, not for the store alone, so delivery_context.basis states which case applies: selected_address (the page header shows an address, so the delivery figures are for that address) or no_address_selected (the header shows the site's placeholder or the signed-out landing page shows its address form: DoorDash then answers for a placeholder location and every store reads 'Too far away') or unknown (the header text is not recognised). Delivery available, its reason, estimate and fee text, and too_far_from_consumer are returned only for selected_address and are null otherwise; pickup, hours and store details do not depend on it. signed_in is a cookie-based hint, not proof. This tool never selects or changes an address, never returns address text, and returns no customer distance. It does not return menu items (use the site's get_restaurant_menu on the store page), reviews, or a fee quote: the fee text depends on the address, the account's benefits and the basket. Convenience and grocery stores return their header details with menu null. Read-only; works signed out; runs from any www.doordash.com page without navigating.", inputSchema: { type: "object", additionalProperties: false, required: ["store_id"], properties: { @@ -65,7 +65,7 @@ estimate_minutes: { type: ["string", "null"], description: "The site's estimate range, e.g. 27 - 37. Null unless delivery is available: the site sends a range even for unavailable delivery." }, fee_display: { type: "object", additionalProperties: false, required: ["title", "subtitle", "text", "is_surging"], - description: "DoorDash's own delivery-fee strings, unparsed. Not a quote: they depend on the address, account benefits and basket.", + description: "DoorDash's own delivery-fee strings, unparsed. Not a quote: they depend on the address, account benefits and basket. All null unless the basis is selected_address.", properties: { title: { type: ["string", "null"] }, subtitle: { type: ["string", "null"] }, text: { type: ["string", "null"] }, is_surging: { type: ["boolean", "null"] } } } } @@ -125,7 +125,7 @@ execute: async (input, context) => { const ORIGIN = "https://www.doordash.com"; const QUERY = "query storepageFeed($storeId: ID!, $fulfillmentType: FulfillmentType, $isMerchantPreview: Boolean, $entryPoint: StoreEntryPoint) { storepageFeed(storeId: $storeId, fulfillmentType: $fulfillmentType, isMerchantPreview: $isMerchantPreview, entryPoint: $entryPoint) { storeHeader { id name description timezone priceRange priceRangeDisplayString offersDelivery offersPickup offersGroupOrder offersCatering offersScheduling isConvenience isDashpassPartner address { lat lng city state street } businessTags { name } ratings { numRatings numRatingsDisplayString averageRating isNewlyAdded } deliveryFeeLayout { title subtitle isSurging displayDeliveryFee } asapStatus { displayUnavailableReason { title } } status { delivery { isAvailable minutes unavailableReason isTooFarFromConsumer } pickup { isAvailable minutes unavailableReason } } } mxInfo { operationInfo { operationStatusInfo { description operationStatus } storeOperationHourInfo { operationSchedule { dayOfWeek timeSlotList } } doordashOperationHourInfo { operationSchedule { dayOfWeek timeSlotList } } } } menuBook { id name displayOpenHours menuList { id name displayOpenHours } } } }"; - const NOTE = "Delivery availability and estimates are computed by DoorDash for the delivery address selected in this browser session. Without a recognised selected address they describe a placeholder location, not the customer, and are withheld. This tool never changes the address."; + const NOTE = "Delivery availability, estimates and fees are computed by DoorDash for the delivery address selected in this browser session. Without a recognised selected address they describe a placeholder location, not the customer, and are withheld. This tool never changes the address."; const CHALLENGE = /just a moment|challenge-platform|cf-chl|captcha|turnstile|verify (?:that )?you(?:'re| are) (?:a )?human|access denied|px-captcha/i; const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); const text = (value, max = 300) => typeof value === "string" && value.trim() ? value.trim().slice(0, max) : null; @@ -235,7 +235,7 @@ unavailable_reason: withheld ? null : text(delivery.unavailableReason, 100), unavailable_message: withheld ? null : text(header.asapStatus && header.asapStatus.displayUnavailableReason && header.asapStatus.displayUnavailableReason.title), estimate_minutes: !withheld && delivery.isAvailable === true ? text(delivery.minutes, 30) : null, - fee_display: { title: text(fee.title, 100), subtitle: text(fee.subtitle, 100), text: text(fee.displayDeliveryFee, 100), is_surging: bool(fee.isSurging) } + fee_display: withheld ? { title: null, subtitle: null, text: null, is_surging: null } : { title: text(fee.title, 100), subtitle: text(fee.subtitle, 100), text: text(fee.displayDeliveryFee, 100), is_surging: bool(fee.isSurging) } }, pickup: { available: bool(pickup.isAvailable), diff --git a/registry.json b/registry.json index b2ae72bb..0bf882bf 100644 --- a/registry.json +++ b/registry.json @@ -1436,11 +1436,11 @@ "https://www.doordash.com/*" ], "url_patterns_reason": "Narrowed to www.doordash.com, the consumer marketplace: these tools call its same-origin GraphQL endpoint with the page's session; other doordash.com hosts are separate products such as the merchant portal and help center.", - "status": "qa_passed", - "last_verified": "2026-09-29", - "notes": "Page tool. Store details the site's native menu tools lack: hours, address, service modes, open status, and delivery and pickup availability with estimate range and the site's own fee strings, plus the menus with the menu_id the native add_to_cart needs. Delivery figures are meaningful only for a selected address, so the output states its basis (selected address, none selected, or unknown) and returns delivery availability and estimate only when an address is positively recognised; it never selects or changes an address and returns neither address text nor customer distance. One same-origin read of the site's storepageFeed query, no navigation; works signed out. Independent QA (two rounds, 2026-09-29) signed off on the current source on the Kernel runtime and a local Chromium extension; the selected-address path was live-checked only by the author in one signed-in session, and the 401, 429, challenge and site-changed branches are stub-driven only.", + "status": "draft", + "last_verified": null, + "notes": "Page tool. Store details the site's native menu tools lack: hours, address, service modes, open status, and delivery and pickup availability with estimate range and the site's own fee strings, plus the menus with the menu_id the native add_to_cart needs. Delivery figures are meaningful only for a selected address, so the output states its basis (selected address, none selected, or unknown) and returns delivery availability, estimate and fee strings only when an address is positively recognised; it never selects or changes an address and returns neither address text nor customer distance. One same-origin read of the site's storepageFeed query, no navigation; works signed out. Independent QA (two rounds, 2026-09-29) signed off on earlier source on the Kernel runtime and a local Chromium extension; the fee strings have since been withheld without a recognised address too, since they change with it (live on 2026-10-23, signed out, a store read '$0 delivery fee, first order' with no address and '--' once a distant address was set), so it needs fresh independent QA. The selected-address path was live-checked only by the author, and the 401, 429, challenge and site-changed branches are stub-driven only.", "provenance": { - "qa_agent": "agt_e3cd254315c78d609b12" + "qa_agent": null } }, { diff --git a/tests/doordash-get-store.test.mjs b/tests/doordash-get-store.test.mjs index b8ca7a7d..d20dabd0 100644 --- a/tests/doordash-get-store.test.mjs +++ b/tests/doordash-get-store.test.mjs @@ -203,13 +203,12 @@ test("no address selected: the placeholder-location delivery figures are withhel assert.ok(validOutput(definition, out), JSON.stringify(out)); assert.equal(out.delivery_context.basis, "no_address_selected"); assert.equal(out.delivery_context.address_selected, false); - assert.deepEqual(out.delivery, { available: null, unavailable_reason: null, unavailable_message: null, estimate_minutes: null, fee_display: out.delivery.fee_display }); + assert.deepEqual(out.delivery, { available: null, unavailable_reason: null, unavailable_message: null, estimate_minutes: null, fee_display: { title: null, subtitle: null, text: null, is_surging: null } }); assert.equal(out.delivery_context.too_far_from_consumer, null); assert.equal(out.name, "Fabricated Noodle House"); assert.equal(out.operation_status, "OPEN"); assert.equal(out.hours.store.length, 7); - assert.equal(out.delivery.fee_display.text, "$0 delivery fee over $12"); - assert.doesNotMatch(JSON.stringify(out), /Too far away/); + assert.doesNotMatch(JSON.stringify(out), /Too far away|delivery fee/, "the fee strings change with the address, so they are withheld too"); assert.match(out.delivery_context.note, /withheld/); const available = harness({ button: { innerText: "Your Address" } }); @@ -227,6 +226,7 @@ test("unknown basis: delivery figures are withheld like the no-address case, not assert.equal(out.delivery_context.address_selected, null); assert.equal(out.delivery.available, null); assert.equal(out.delivery.unavailable_reason, null); + assert.equal(out.delivery.fee_display.text, null); assert.equal(out.delivery_context.too_far_from_consumer, null); assert.equal(out.pickup.available, false); assert.doesNotMatch(JSON.stringify(out), /Too far away/); From db6d14515ed903bedf1b75b0fb944ee5ac1afa26 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:49:39 +0000 Subject: [PATCH 13/13] doordash.com: let remove_cart_item confirm a removal when the site reorders the remaining lines Also word the new README rows in the third person like the existing ones. --- domains/doordash.com/README.md | 6 +++--- domains/doordash.com/remove_cart_item.js | 6 ++++-- registry.json | 2 +- tests/doordash-cart.test.mjs | 7 +++++++ 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/domains/doordash.com/README.md b/domains/doordash.com/README.md index dbe19134..2e368c5b 100644 --- a/domains/doordash.com/README.md +++ b/domains/doordash.com/README.md @@ -3,11 +3,11 @@ | tool | description | |------|-------------| | create_business_profile | Creates the signed-in account's business profile with a receipt email and a new credit/debit card as the business payment method. | -| get_cart | Read open DoorDash carts, line ids/options/quantities, currency and provisional subtotal/total/fee lines from the current authorized session. | -| get_checkout_summary | Prepare a read-only DoorDash order summary for an exact open cart: lines, provisional fees/totals, currency and explicit delivery context, plus a manually opened checkout URL. | +| get_cart | Reads open DoorDash carts, line ids/options/quantities, currency and provisional subtotal/total/fee lines from the current authorized session. | +| get_checkout_summary | Prepares a read-only DoorDash order summary for an exact open cart: lines, provisional fees/totals, currency and explicit delivery context, plus a manually opened checkout URL. | | get_store | Reads one DoorDash store by store_id: details, hours, menus, and whether delivery and pickup can be ordered now with the site's estimate and delivery-fee text. | | login | Signs in to a DoorDash consumer account with email and password, or submits a visible six-digit verification code; confirms an existing session without submitting. | | register | Creates a DoorDash consumer account from the email sign-up form, or submits a visible six-digit sign-up verification code; refuses when already signed in. | -| remove_cart_item | Remove exactly one existing line from an ordinary personal DoorDash cart using the site's removeCartItem operation. | +| remove_cart_item | Removes exactly one existing line from an ordinary personal DoorDash cart using the site's removeCartItem operation. | | save_address | Makes a delivery address the account's current default: selects it if already saved, otherwise adds it from DoorDash's autocomplete with optional details. | | set_delivery_address | Sets a signed-out visit's delivery address from DoorDash's address autocomplete, as the header's address picker does, and confirms it from the site's answer. | diff --git a/domains/doordash.com/remove_cart_item.js b/domains/doordash.com/remove_cart_item.js index 8d8e22ba..86d4bb79 100644 --- a/domains/doordash.com/remove_cart_item.js +++ b/domains/doordash.com/remove_cart_item.js @@ -3,7 +3,7 @@ match: { url_patterns: ["https://www.doordash.com/*"] }, tool: { name: "remove_cart_item", - description: "Remove exactly one existing line from an ordinary personal DoorDash cart using the site's removeCartItem operation. Requires the user's intended expected_account_email, cart_id, line_id (NOT menu item id), expected_store_id and a fresh get_cart expected_revision. Checks the current account, queried cart revision and store immediately before one write; refuses group, bundle, catering, shipping, finalized/unsupported carts and last-line removal. Does not change address, payment, quantity/options of other lines, or place an order. Returns removed only after rereading the open carts proves this line absent and the other lines' identity, quantity and options unchanged (the site may reprice them). Never retries writes: an uncertain outcome leaves a sessionStorage guard that refuses further removals from this cart until a fresh get_cart revision shows the cart has changed; inspect the cart on the site rather than reinvoking. No live mutation QA has been authorized; mutation evidence is fabricated fixtures only. Call serially; server provides no atomic compare-and-swap, so cross-tab changes remain a race.", + description: "Remove exactly one existing line from an ordinary personal DoorDash cart using the site's removeCartItem operation. Requires the user's intended expected_account_email, cart_id, line_id (NOT menu item id), expected_store_id and a fresh get_cart expected_revision. Checks the current account, queried cart revision and store immediately before one write; refuses group, bundle, catering, shipping, finalized/unsupported carts and last-line removal. Does not change address, payment, quantity/options of other lines, or place an order. Returns removed only after rereading the open carts proves this line absent and the other lines' identity, quantity and options unchanged (the site may reprice or reorder them). Never retries writes: an uncertain outcome leaves a sessionStorage guard that refuses further removals from this cart until a fresh get_cart revision shows the cart has changed; inspect the cart on the site rather than reinvoking. No live mutation QA has been authorized; mutation evidence is fabricated fixtures only. Call serially; server provides no atomic compare-and-swap, so cross-tab changes remain a race.", inputSchema: { type: "object", additionalProperties: false, @@ -222,7 +222,9 @@ const repriced = new Map((Array.isArray(after.orders?.[0]?.orderItems) ? after.orders[0].orderItems : []).map(l => [String(l?.id), l])); const kept = l => { const now = repriced.get(String(l.id)); return { ...l, singlePrice: now?.singlePrice, priceOfTotalQuantity: now?.priceOfTotalQuantity, priceDisplayString: now?.priceDisplayString }; }; const expected = { ...before, subtotal: after.subtotal, total: after.total, orders: before.orders.map(o => ({ ...o, orderItems: o.orderItems.filter(l => String(l.id) !== input.line_id).map(kept), lineItems: after.orders?.[0]?.lineItems })) }; - if (JSON.stringify(canonical(expected)) !== JSON.stringify(canonical(after))) return fail("outcome_unknown", "The exact removal with unchanged other lines was not confirmed. Inspect manually; do not retry.", true); + // The order of the remaining lines is not part of their identity. + const byLineId = c => ({ ...c, orders: Array.isArray(c.orders) ? c.orders.map(o => (o && Array.isArray(o.orderItems) ? { ...o, orderItems: [...o.orderItems].sort((x, y) => String(x?.id).localeCompare(String(y?.id))) } : o)) : c.orders }); + if (JSON.stringify(canonical(byLineId(expected))) !== JSON.stringify(canonical(byLineId(after)))) return fail("outcome_unknown", "The exact removal with unchanged other lines was not confirmed. Inspect manually; do not retry.", true); const remaining = await project(after); if (signal.aborted) return fail("outcome_unknown", "Cancelled before the final cart result was returned. Inspect manually; do not retry.", true); if (remaining.truncated || new TextEncoder().encode(JSON.stringify(remaining)).byteLength > 170000) return fail("outcome_unknown", "The remaining cart exceeds safe observation bounds. Inspect manually; do not retry.", true); diff --git a/registry.json b/registry.json index 0bf882bf..e9c979c0 100644 --- a/registry.json +++ b/registry.json @@ -1489,7 +1489,7 @@ "url_patterns_reason": "Narrowed to www.doordash.com, the consumer marketplace: these tools call its same-origin GraphQL endpoint with the page's session; other doordash.com hosts are separate products such as the merchant portal and help center.", "status": "draft", "last_verified": null, - "notes": "Exact non-last ordinary personal cart-line removal with account/store/revision, complete non-prescription line checks, an uncertain-write guard and observed postconditions. One attempt, no automatic retries. The guard refuses further removals from the cart until a fresh get_cart revision shows the cart changed; the postcondition lets the site reprice the remaining lines but not change their identity, quantity or options. Independent Kernel/local-extension QA passed on 2026-09-30 for earlier source; the guard and postcondition changed since and need fresh independent QA. All mutation success is fixture-only, never live-verified. Cross-tab races are not atomically excluded; final order/payment execution is absent." + "notes": "Exact non-last ordinary personal cart-line removal with account/store/revision, complete non-prescription line checks, an uncertain-write guard and observed postconditions. One attempt, no automatic retries. The guard refuses further removals from the cart until a fresh get_cart revision shows the cart changed; the postcondition lets the site reprice or reorder the remaining lines but not change their identity, quantity or options. Independent Kernel/local-extension QA passed on 2026-09-30 for earlier source; the guard and postcondition changed since and need fresh independent QA. All mutation success is fixture-only, never live-verified. Cross-tab races are not atomically excluded; final order/payment execution is absent." }, { "domain": "doordash.com", diff --git a/tests/doordash-cart.test.mjs b/tests/doordash-cart.test.mjs index c8b994c2..a69d5645 100644 --- a/tests/doordash-cart.test.mjs +++ b/tests/doordash-cart.test.mjs @@ -238,6 +238,13 @@ test("remaining lines repriced by the site still confirm the removal", async () assert.equal(out.status, "ok"); assert.equal(out.remaining_cart.lines[0].total_minor, 600); assert.equal(h.store.size, 0); }); +test("remaining lines listed in another order still confirm the removal", async () => { + const three = cart(); three.orders[0].orderItems.push(item("line-3")); + const h = harness({ carts: [three], afterWrite: c => { c.orders[0].orderItems.reverse(); } }); + const out = await h.run("remove_cart_item", await removalInput(h)); + assert.equal(out.status, "ok"); assert.deepEqual(out.remaining_cart.lines.map(l => l.line_id), ["line-3", "line-2"]); assert.equal(h.store.size, 0); +}); + test("an uncertain write blocks the unchanged cart but not a fresh revision of a changed one", async () => { const h = harness({ noRemoval: true }); const input = await removalInput(h); assert.equal((await h.run("remove_cart_item", input)).error_kind, "outcome_unknown"); assert.equal(h.store.size, 1);