diff --git a/README.md b/README.md index cc5e876..4867512 100644 --- a/README.md +++ b/README.md @@ -1,181 +1,171 @@ # SLL-R -SLL-R is a merchant-backed commerce rail that personal agents can call. +**Merchant-backed order execution for AI agents.** -It lets a buyer's own agent compare real merchant capabilities, obtain exact -quotes, ask for consent, place and track orders through existing checkout -systems, and turn completed outcomes into verified receipt memory. - -The current product goal is not a hackathon-only demo. The goal is to list SLL-R -on AgentShack as a reusable merchant-agent service and onboard Raposa / SOLYD as -the first merchant pilots. - -```text -Personal agent / Hermes / ChatGPT --> SLL-R --> compare merchant-backed quotes --> explicit buyer consent --> POS / checkout adapters --> payment proof --> merchant fulfillment or customer claim --> final SLL-R receipt memory / Solana cNFT -``` - -## Product Boundary - -- **SLL-R**: merchant runtime plus the safe cross-merchant interface personal agents call. -- **Receipt memory**: proof-backed order, payment, and fulfillment record. -- **POS adapters**: internal SLL-R tools for Shopify, MoonPay, Binance Pay, Telegram staff flow, Browser Use, Stripe, or future POS systems. -- **Personal agent**: buyer-side caller. This can be Hermes, ChatGPT, Telegram, AgentShack, or another user-owned agent. - -SLL-R is not a full POS replacement. It operates the merchant's existing checkout -and staff workflows. - -## Current MVP Goal - -SLL-R should be useful when a merchant wants agents to order from them without -building a custom agent stack from scratch. - -Target users: - -- Raposa Coffee: pickup promise, event queue, and online coffee product orders. -- SOLYD: online product quotes, checkout handoff, payment proof, and fulfillment-backed receipts. -- Noun Coffee: Base/USDC coffee storefront quote and checkout handoff. -- Shopify merchants: Noun Coffee, Raposa Shop, and SOLYD can expose Storefront - MCP / cart handoff / paid-order webhook proof without replacing checkout. -- Content-commerce merchants: Changbaishan Rice-style grocery sellers can map - product stories to Shopify SKUs, checkout, and receipt memory. -- Raposa / SOLYD Solana rail: Solana Pay URL or Helio checkout handoff, with - payment proof kept separate from final fulfillment-backed receipt memory. -- AgentShack builders: reusable seller-agent template for their own merchants. - -MVP success means: - -- SLL-R has a stable agent manifest that AgentShack can index. -- ChatGPT, Hermes, Base MCP, and similar agents can discover the API through - OpenAPI and tool manifests. -- A buyer agent can ask for a quote and create an order through the API. -- The merchant can use a simple terminal or existing checkout flow to accept, - ready, claim, or complete the order. -- Payment proof moves an order to `payment_backed`; fulfillment or customer - claim issues final SLL-R receipt memory. -- Raposa / SOLYD can understand what they need to configure in less than one - meeting. - -The primary agent flow is: +SLL-R lets a buyer's agent move from natural-language intent to a real merchant +order without inventing the SKU, price, availability, or pickup promise. It +binds an exact quote to buyer consent, reserves merchant capacity, creates one +idempotent order, streams live queue state through short polling, and issues a +canonical receipt only after fulfillment or customer claim. ```text -personal agent receives natural-language intent --> SLL-R shop_for_me compares bounded merchant candidates --> merchant-backed quotes ranked by intent, receipt memory, location, time, and price --> user confirms one exact quote --> SLL-R consent + idempotent order --> existing checkout or staff fulfillment --> cross-merchant tracking --> verified receipt memory improves the next recommendation -``` - -The standalone merchant agent remains available for QR/web pilots. MCP, -OpenAPI, and ChatGPT Actions expose the same commerce rail to personal agents. -The consumer agent also has iMessage and LINE Messaging transports; both reuse -the same quote-bound consent, order, payment-option, status, and receipt state. - -## Commerce Levels L1-L3 - -- **L1 offers**: `GET /merchants/{merchantId}/offers` exposes fixed, - merchant-backed offers. `POST /merchants/{merchantId}/offers/{offerId}/quote` - turns one into the existing quote, exact confirmation, consent, and order path. -- **L2 fulfillment batches**: authorized merchants can group independently paid - orders assigned to the same pickup window. Every child keeps its own buyer - consent, payment proof, fulfillment state, and receipt. -- **L3 capacity windows**: pickup inventory is expressed as atomic 15-minute - capacity by production class. Quotes can inspect capacity, but only order - creation with a buyer session and quote-bound consent holds seats, so - concurrent orders cannot overbook a window and anonymous legacy orders cannot - exhaust hard capacity. - -## Adapter Contract - -SLL-R exposes a small seller-agent runtime and keeps POS / checkout systems as -replaceable adapters: - -- `staff_terminal`: Telegram or a merchant terminal that confirms fulfillment. -- `checkout_handoff`: Shopify, MoonPay Commerce, Binance Pay, or a hosted checkout link. -- `payment_proof`: webhook, Query Order, Solana Pay reference, Helio, or on-chain verification. -- `receipt_memory`: SLL-R receipt memory and Solana cNFT handoff. - -The current scaffold ships Raposa and SOLYD example profiles plus adapter -metadata in `GET /.well-known/sllr-agent.json`. Real merchant integrations can -replace the mock catalog and stubbed adapters without changing the quote/order -API contract. - -## BNB / Binance Pay Rail - -Binance Pay is a strong SLL-R target because it gives merchants a checkout rail, -webhooks, and an order query API that can become payment proof: +buyer agent +→ merchant-backed catalog and exact quote +→ quote-bound buyer consent +→ idempotent order and capacity reservation +→ existing checkout or staff terminal +→ live queue and order status +→ payment proof ≠ fulfillment proof +→ canonical fulfillment-backed receipt +``` + +SLL-R is an MCP and HTTP commerce rail, not a replacement POS and not a +hackathon-only chatbot. Merchants keep their existing checkout and staff +workflow; personal agents get one bounded interface for quoting, ordering, and +tracking the outcome. + +## The Problem + +AI agents can recommend products, but they cannot safely promise that a real +merchant can fulfill an order now. A static menu does not answer: + +- Is this SKU real and currently available? +- Is the price and pickup ETA still valid? +- Did the buyer approve this exact quote? +- Will a retry create a duplicate order? +- How many orders are ahead in the merchant's production queue? +- Does a payment event prove payment only, or actual fulfillment? +- Can the buyer verify the final outcome without seeing another buyer's order? + +SLL-R turns those questions into explicit server-side state and authorization +boundaries instead of leaving them to an agent prompt. + +## What Works Today + +- **Grounded commerce:** merchant-backed catalogs, fixed offers, exact quotes, + availability checks, and bounded cross-merchant recommendations. +- **Safe execution:** buyer sessions, quote-bound consent, idempotency keys, and + atomic 15-minute capacity reservations by production class. +- **Live local fulfillment:** queue position, orders ahead, promised pickup time, + merchant accept/reject/ready actions, and buyer status updates every two seconds. +- **Notifications:** in-page updates are guaranteed while the page is open; + browser notifications are optional and require user permission. iMessage and + LINE transports reuse the same canonical order state. +- **Proof separation:** payment proof advances payment state only. Merchant + fulfillment or customer claim is required for the final receipt. +- **Tenant boundaries:** buyer-owned reads are scoped to the buyer session; + merchant order feeds and mutations require operator or merchant-scoped auth. +- **Replaceable adapters:** staff terminal, Shopify/hosted checkout handoff, + Stripe, Solana Pay, Base USDC, Helio/MoonPay Commerce, and Binance Pay surfaces. + +The repository ships example Raposa, SOLYD, and Noun Coffee profiles. They are +demo/pilot configurations, not claims of live commercial partnerships. + +## Try the End-to-End Demo + +Run SLL-R, then open two browser windows: + +| Role | URL | What to do | +| --- | --- | --- | +| Buyer | `http://localhost:3100/raposa/order` | Quote, consent, order, watch queue/status, receive ready update, view receipt | +| Merchant | `http://localhost:3100/raposa` | See the order, accept it, mark it ready, then record claim/fulfillment | + +The visible flow is: ```text -SLL-R order --> Binance Pay checkout with merchantTradeNo --> PAY webhook --> Query Order confirms PAID --> fulfillment or refund proof --> SLL-R receipt memory +quote → consent → order → Queue #N → accepted → ready → claimed → receipt_issued ``` -Travala is the reference merchant vertical for this path. Travel bookings have -clear quote, checkout, confirmation, cancellation, and refund states, so they are -a good example of how SLL-R can clear real merchant work beyond cafes and -ecommerce. This repo does not claim a live Travala integration yet; it documents -the path in [Binance Pay / Travala fit](./docs/binance-pay-travala.md). +For a no-secret localhost demo, merchant proof actions accept `demo: true` only +when `SLLR_MERCHANT_PAYMENT_VERIFY_SECRET` is not configured. Any shared or +public deployment must configure that secret or issue merchant-scoped tokens. -## AgentShack Listing Shape +## Connect an Agent over MCP -SLL-R is packaged as an AgentShack `merchant_agent`: +SLL-R exposes a stateless Streamable HTTP MCP server at `/mcp`: -```text -customer intent --> structured order --> merchant accept / reject / fulfill --> payment proof --> merchant fulfillment proof --> receipt memory --> reputation update +```bash +claude mcp add --transport http sllr http://localhost:3100/mcp ``` -The public manifest includes: - -- `type`: `merchant_agent` -- `category`: `local_commerce` -- `modes`: `one_time_call`, `subscription`, `fork` -- `evaluator.policy`: `order-fulfillment-v0` -- `reputation.subjects`: `merchant`, `customer`, `agent`, `evaluator` - -Use `GET /pilot-kit?merchantId=raposa-coffee` or -`GET /pilot-kit?merchantId=solyd` to generate a merchant-specific onboarding -package for the first pilot meeting. - -## State & Persistence +The intended buyer flow is: -SLL-R stores orders and runtime demo merchants through a small key-value -abstraction with three backends (selection order: Supabase → Redis/KV → memory): +```text +list_merchants / shop_for_me +→ quote_order +→ request_consent +→ create_order +→ get_payment_options +→ check_order_status +``` -- **memory** (default): in-process. Survives for the process lifetime only. - Fine for local dev, a single long-running process (Railway/Render/Fly), and - demo recordings. -- **supabase**: Supabase Postgres via the PostgREST HTTP API (zero SDK - dependency). Create two tables then set `SUPABASE_URL` + - `SUPABASE_SERVICE_ROLE_KEY` — see [Supabase store runbook](./docs/supabase-store-runbook.md). -- **redis_rest**: Vercel KV / Upstash Redis over the REST API (zero SDK - dependency). Configure `KV_REST_API_URL` + `KV_REST_API_TOKEN` (or the - `UPSTASH_REDIS_REST_*` equivalents). +`list_orders` and merchant mutations are not public buyer tools. They require +the operator verifier secret or a token scoped to the target merchant. -Either durable backend is required for **serverless** (Vercel), where each -invocation is a fresh instance, and for horizontal scale. -`GET /health` reports the active backend: `{ "ok": true, "store": "supabase" }`. +## Architecture -Receipt memory is gated: set `SLLR_MERCHANT_PAYMENT_VERIFY_SECRET` so only the -merchant can issue receipts (and verify payment proof). See [env.example](./env.example). +```text +Hermes / ChatGPT / OKX.AI / another personal agent + │ + MCP + OpenAPI + │ + ┌──────────▼──────────┐ + │ SLL-R runtime │ + │ quote / consent │ + │ capacity / order │ + │ payment / receipt │ + └──────┬────────┬─────┘ + │ │ + buyer status merchant terminal + │ │ + └── checkout / POS adapters +``` + +The same canonical order record drives MCP, REST, buyer pages, merchant +terminals, webhooks, and messaging transports. The UI does not maintain a +second queue or fulfillment state. + +## Safety Invariants + +1. Catalog items and prices come from merchant-authorized data. +2. Consent is bound to the exact quote and its freshness window. +3. Reusing an idempotency key cannot create a second semantic order. +4. Capacity reservation is atomic; quote inspection alone does not hold seats. +5. Payment proof never implies fulfillment. +6. A terminal receipt is issued only once after proof-backed completion. +7. Buyer A cannot read Buyer B's buyer-bound order. +8. Public agents cannot list merchant queues or perform merchant mutations. + +See [the MCP runbook](./docs/sllr-mcp-runbook.md) for the full execution and +authorization contract. + +## Product Boundary and Current Status + +SLL-R currently proves the technical workflow locally and through automated +smoke tests. It does **not** yet claim: + +- a public production deployment or OKX.AI ASP listing; +- validated merchant willingness to pay; +- production partnerships with the bundled example merchants; +- that a checkout or payment event alone proves fulfillment; +- production-ready scale on the default in-memory store. + +The next product proof is one permissioned merchant completing real orders +through the same quote → queue → fulfillment → receipt path. + +## State and Persistence + +Storage backend selection is Supabase → Redis REST/KV → memory: + +- **memory** is the default and is suitable for local development or one + long-running demo process. It resets on restart. +- **Supabase** uses PostgREST without an SDK. See the + [Supabase store runbook](./docs/supabase-store-runbook.md). +- **Redis REST/KV** supports Vercel KV and Upstash-compatible credentials. + +A durable backend is required for serverless or horizontally scaled deployment. +`GET /health` reports the selected store. Production must also set +`SLLR_MERCHANT_PAYMENT_VERIFY_SECRET`; see [env.example](./env.example). ## Run Locally @@ -192,19 +182,14 @@ Default server: http://localhost:3100 ``` -## Connect As MCP - -SLL-R exposes a real MCP server (stateless Streamable HTTP) at `/mcp`: - -```bash -claude mcp add --transport http sllr http://localhost:3100/mcp -``` +## MCP Tool Reference -Tools include `list_merchants`, `get_merchant`, `get_menu`, `list_offers`, +Buyer tools include `list_merchants`, `get_merchant`, `get_menu`, `list_offers`, `quote_offer`, `list_capacity_windows`, `shop_for_me`, `quote_order`, -`create_order`, `list_orders`, `create_fulfillment_batch`, -`list_fulfillment_batches`, `get_fulfillment_batch`, `check_order_status`, `get_payment_options`, -`attach_payment_proof`, `issue_receipt`, and `create_demo_merchant`. +`request_consent`, `create_order`, `check_order_status`, and +`get_payment_options`. Merchant-authorized tools include `list_orders`, +fulfillment batches, payment-proof attachment, availability changes, and final +receipt issuance. Payment safety is enforced server-side: `attach_payment_proof` requires the merchant verifier secret (`verificationToken`) in production and only accepts @@ -614,15 +599,16 @@ SLL-R Short description: ```text -Seller agents for merchants in the agent economy. +Merchant-backed order execution for AI agents. ``` What it does: ```text -SLL-R gives merchants an installable seller agent that buyer agents can quote, -order, and pay through. After payment or fulfillment proof, SLL-R issues -verified receipt memory. +SLL-R lets buyer agents obtain exact merchant-backed quotes, bind buyer consent, +reserve capacity, create idempotent orders, and track fulfillment through the +merchant's existing checkout and staff workflow. Payment proof advances payment +state only; a canonical receipt requires merchant fulfillment or customer claim. ``` ## Pilot Docs diff --git a/docs/sllr-mcp-runbook.md b/docs/sllr-mcp-runbook.md index ef0425a..168c0ba 100644 --- a/docs/sllr-mcp-runbook.md +++ b/docs/sllr-mcp-runbook.md @@ -94,12 +94,31 @@ ordering still works unless the server sets `SLLR_REQUIRE_BUYER_AUTH=true`. - `solana_pay`: prepare Solana Pay URL with a unique reference. - `helio` / `moonpay`: open checkout handoff and verify webhook. -6. Check order status. +6. Check order status from the buyer-scoped surface. ```text - GET /orders/{orderId} + GET /buyer/orders + Authorization: Bearer *** ``` + Each order includes a derived `tracking` snapshot with `queuePosition`, + `ordersAhead`, live estimated wait, promised ready time, and last update. Use + this buyer-scoped endpoint for public clients; `GET /orders/{orderId}` is also + buyer-gated when `SLLR_REQUIRE_BUYER_AUTH=true`. + + The Raposa demo page short-polls this buyer-owned feed every two seconds, + always updates its in-page status, and can emit an optional browser + notification after the user grants permission. It stops polling after a + rejected order or canonical receipt. Merchant boards poll the protected + merchant order feed with merchant authorization; do not expose that feed to + public buyer agents. + + MCP `list_orders` is merchant-only and requires `verificationToken` containing + the operator verifier secret or a merchant-scoped token. MCP + `check_order_status` is available to the owning buyer session; other callers + must provide the same merchant authorization. Do not use either tool as an + anonymous public status feed. + 7. Record merchant fulfillment, then issue receipt memory. ```text diff --git a/src/core/merchantApi.ts b/src/core/merchantApi.ts index df14421..4829dfb 100644 --- a/src/core/merchantApi.ts +++ b/src/core/merchantApi.ts @@ -1,4 +1,4 @@ -import { attachPaymentProofMutation, createOrder, fulfillOrderMutation, getOrder, listOrders } from "./orders.js"; +import { attachPaymentProofMutation, createOrder, fulfillOrderMutation, getOrder, listOrders, withLiveOrderTrackingBatch } from "./orders.js"; import { quoteOrder } from "./quote.js"; import { allMerchantProfiles, merchantForId } from "../merchants/profiles.js"; import { recurringSuggestion } from "./recurring.js"; @@ -415,12 +415,13 @@ export async function createMerchantOrder(merchantId: string, payload: Record { export async function listOrdersForBuyer(buyerId: string, limit?: number): Promise { const ids = await sllrStore().indexMembers(buyerOrdersIndex(buyerId)); const boundedIds = limit === undefined ? ids : ids.slice(-Math.max(1, Math.min(limit, 500))); - return (await loadOrdersByIds(boundedIds)).sort((left, right) => right.createdAt.localeCompare(left.createdAt)); + const orders = (await loadOrdersByIds(boundedIds)).sort((left, right) => right.createdAt.localeCompare(left.createdAt)); + return withLiveOrderTrackingBatch(orders); } function addMinutes(date: Date, minutes: number) { @@ -109,7 +110,7 @@ async function activePickupOrders(merchantId: string, productionClass: string) { // Scheduled pickups already occupy their target capacity window and must // not inflate the queue for an order placed right now. && order.promise.scheduledPickup !== true - && !["rejected", "claimed", "fulfilled", "receipt_issued"].includes(order.status) + && !["rejected", "ready", "claimed", "fulfilled", "receipt_issued"].includes(order.status) )); } @@ -117,14 +118,18 @@ async function activePickupOrders(merchantId: string, productionClass: string) { // by quotes and order promises, so a quote can never show "~7 min" while the // created order silently computes 52 (the trust bug the pilot audit caught). // null for non-pickup items. -export async function estimatedPickupWaitMinutes(merchantId: string, item: CatalogItem, quantity = 1): Promise { +export async function estimatedPickupWaitMinutes( + merchantId: string, + item: CatalogItem, + quantity = 1, + now = new Date(), +): Promise { if (!item.fulfillment.includes("pickup")) return null; const productionClass = productionClassFor(item); const activeAhead = (await activePickupOrders(merchantId, productionClass)).length; const capacity = CAPACITY_BY_PRODUCTION_CLASS[productionClass]; const prepMinutes = Math.max(item.prepMinutes || 5, 1); const queueWait = prepMinutes + Math.floor(activeAhead / capacity) * CAPACITY_WINDOW_MINUTES; - const now = new Date(); const desiredReadyAt = addMinutes(now, prepMinutes); for (let offset = 0; offset < 32; offset += 1) { const probeAt = addMinutes(desiredReadyAt, offset * CAPACITY_WINDOW_MINUTES); @@ -137,6 +142,71 @@ export async function estimatedPickupWaitMinutes(merchantId: string, item: Catal return Math.max(queueWait, prepMinutes + 32 * CAPACITY_WINDOW_MINUTES); } +function queueKey(order: SellerOrder) { + return `${order.merchantId}:${order.promise.productionClass}`; +} + +function trackingSnapshot(order: SellerOrder, queue: SellerOrder[]) { + const isPickup = order.promise.productionClass !== "shipping"; + const terminal = ["rejected", "ready", "claimed", "fulfilled", "receipt_issued"].includes(order.status); + if (!isPickup) { + return { + ...order, + tracking: { + live: true, + status: order.status, + receiptState: order.receipt ? "issued" : "not_issued", + queuePosition: null, + ordersAhead: null, + estimatedWaitMinutes: null, + promisedReadyAt: order.promise.promisedReadyAt, + updatedAt: order.updatedAt, + }, + }; + } + + const index = terminal ? -1 : queue.findIndex((candidate) => candidate.id === order.id); + const promised = order.promise.promisedReadyAt ? new Date(order.promise.promisedReadyAt).getTime() : Number.NaN; + const remaining = terminal + ? 0 + : Number.isFinite(promised) + ? Math.max(0, Math.ceil((promised - Date.now()) / 60_000)) + : order.promise.estimatedWaitMinutes; + + return { + ...order, + tracking: { + live: true, + status: order.status, + receiptState: order.receipt ? "issued" : "not_issued", + queuePosition: index >= 0 ? index + 1 : null, + ordersAhead: index >= 0 ? index : 0, + estimatedWaitMinutes: remaining, + promisedReadyAt: order.promise.promisedReadyAt, + updatedAt: order.updatedAt, + }, + }; +} + +export async function withLiveOrderTrackingBatch(orders: SellerOrder[]) { + const queues = new Map(); + for (const candidate of await allOrders()) { + if ( + candidate.promise.productionClass === "shipping" + || candidate.promise.scheduledPickup === true + || ["rejected", "ready", "claimed", "fulfilled", "receipt_issued"].includes(candidate.status) + ) continue; + const key = queueKey(candidate); + const queue = queues.get(key) || []; + queue.push(candidate); + queues.set(key, queue); + } + for (const queue of queues.values()) { + queue.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + } + return orders.map((order) => trackingSnapshot(order, queues.get(queueKey(order)) || [])); +} + async function pickupPromise(merchant: MerchantProfile, item: CatalogItem, input: OrderRequest, now: Date, quantity: number): Promise { if (!item.fulfillment.includes("pickup")) { return { diff --git a/src/mcpServer.ts b/src/mcpServer.ts index 8a6e653..28d5ab0 100644 --- a/src/mcpServer.ts +++ b/src/mcpServer.ts @@ -634,29 +634,42 @@ const tools: ToolDefinition[] = [ }, { name: "list_orders", - description: "Agent POS feed: list SLL-R orders for a merchant, optionally filtered by status (e.g. pending_payment, accepted, ready).", + description: "Authorized Agent POS feed: list SLL-R orders for a merchant, optionally filtered by status (e.g. pending_payment, accepted, ready). Requires the merchant verifier secret or merchant-scoped token.", inputSchema: { type: "object", required: ["merchantId"], properties: { merchantId: quoteProperties.merchantId, status: { type: "string", description: "Optional order status filter, for example pending_payment or ready." }, + verificationToken: { type: "string", description: "Merchant verifier secret or merchant-scoped token." }, + demo: { type: "boolean", description: "Local demo only, accepted only when no verifier secret is configured." }, }, }, - handler: (args) => listMerchantOrders(requireString(args, "merchantId"), typeof args.status === "string" ? args.status : null), + handler: async (args) => { + const merchantId = requireString(args, "merchantId"); + await requireMerchantAuth({}, args, merchantId); + return listMerchantOrders(merchantId, typeof args.status === "string" ? args.status : null); + }, }, { name: "check_order_status", - description: "Read current order state, payment status, fulfillment state, pickup promise, and receipt handoff.", + description: "Read current order state, payment status, fulfillment state, pickup promise, and receipt handoff. Buyer-bound orders require the matching buyer session; merchant access requires the verifier secret or merchant-scoped token.", inputSchema: { type: "object", required: ["orderId"], - properties: { orderId: { type: "string", description: "SLL-R order id, for example ord_..." } }, + properties: { + orderId: { type: "string", description: "SLL-R order id, for example ord_..." }, + verificationToken: { type: "string", description: "Merchant verifier secret or merchant-scoped token when the caller is not the owning buyer." }, + demo: { type: "boolean", description: "Local demo only, accepted only when no verifier secret is configured." }, + }, }, - handler: async (args) => { + handler: async (args, _origin, buyerId) => { const orderId = requireString(args, "orderId"); const order = await getOrder(orderId); if (!order) throw Object.assign(new Error(`Unknown order: ${orderId}`), { status: 404 }); + if (!order.buyerId || order.buyerId !== buyerId) { + await requireMerchantAuth({}, args, order.merchantId); + } return { product: "SLL-R merchant terminal", order }; }, }, diff --git a/src/scripts/smoke.ts b/src/scripts/smoke.ts index 730f01e..036f31e 100644 --- a/src/scripts/smoke.ts +++ b/src/scripts/smoke.ts @@ -252,12 +252,36 @@ async function smokeMcp(origin: string) { throw new Error(`MCP get_payment_options failed: ${JSON.stringify(paymentOptions)}`); } - const status = await mcpToolCall(origin, "check_order_status", { orderId }); + const status = await mcpToolCall(origin, "check_order_status", { orderId, demo: true }); const statusContent = status.structuredContent as { order?: { id?: string } } | undefined; if (status.isError || statusContent?.order?.id !== orderId) { throw new Error(`MCP check_order_status failed: ${JSON.stringify(status)}`); } + const previousListVerifier = process.env.SLLR_MERCHANT_PAYMENT_VERIFY_SECRET; + process.env.SLLR_MERCHANT_PAYMENT_VERIFY_SECRET = "mcp-list-orders-smoke-secret"; + try { + const publicList = await mcpToolCall(origin, "list_orders", { merchantId: "raposa-coffee" }); + if (!publicList.isError || !publicList.content?.[0]?.text?.includes("Merchant authorization required")) { + throw new Error(`MCP list_orders must reject public callers: ${JSON.stringify(publicList)}`); + } + const merchantList = await mcpToolCall(origin, "list_orders", { + merchantId: "raposa-coffee", + verificationToken: "mcp-list-orders-smoke-secret", + }); + const merchantListContent = merchantList.structuredContent as { orders?: Array<{ id?: string }> } | undefined; + if (merchantList.isError || !merchantListContent?.orders?.some((candidate) => candidate.id === orderId)) { + throw new Error(`Authorized MCP list_orders did not return the merchant order: ${JSON.stringify(merchantList)}`); + } + const publicStatus = await mcpToolCall(origin, "check_order_status", { orderId }); + if (!publicStatus.isError || !publicStatus.content?.[0]?.text?.includes("Merchant authorization required")) { + throw new Error(`MCP check_order_status must reject non-owner public callers: ${JSON.stringify(publicStatus)}`); + } + } finally { + if (previousListVerifier === undefined) delete process.env.SLLR_MERCHANT_PAYMENT_VERIFY_SECRET; + else process.env.SLLR_MERCHANT_PAYMENT_VERIFY_SECRET = previousListVerifier; + } + const previousVerifierSecret = process.env.SLLR_MERCHANT_PAYMENT_VERIFY_SECRET; delete process.env.SLLR_MERCHANT_PAYMENT_VERIFY_SECRET; try { @@ -1621,14 +1645,17 @@ async function smokeEtaReconfirm(origin: string) { if (!r.ok) throw new Error(`saturation order ${i} failed: ${r.status}`); } - // Quote is HONEST about the queue now: 2 + 15 = 17 min, not prep-only. + // Quote is HONEST about the queue now: at least the 17-minute queue floor, + // plus at most the alignment to the next 15-minute capacity window. const q1 = await postJson(origin, "/merchants/game-day-boba/quote", fruitTea) as { etaMinutes?: number }; - if (q1.etaMinutes !== 17) throw new Error(`queue-aware quote ETA should be 17, got ${q1.etaMinutes}`); + if (!q1.etaMinutes || q1.etaMinutes < 17 || q1.etaMinutes > 20) { + throw new Error(`queue-aware quote ETA should be 17-20 min, got ${q1.etaMinutes}`); + } const prev = process.env.SLLR_ETA_RECONFIRM; process.env.SLLR_ETA_RECONFIRM = "true"; try { - // Wait (17) exceeds the buyer's 10-min deadline → 409 reconfirm, no order. + // Wait exceeds the buyer's 10-min deadline → 409 reconfirm, no order. const blocked = await fetch(`${origin}/merchants/game-day-boba/orders`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(fruitTea), }); @@ -1639,8 +1666,9 @@ async function smokeEtaReconfirm(origin: string) { // Buyer re-confirms the longer wait → order created, promise matches the // same queue-aware formula (no contradiction). const ok = await postJson(origin, "/merchants/game-day-boba/orders", { ...fruitTea, acceptDelay: true }) as { order?: { promise?: { estimatedWaitMinutes?: number } } }; - if (ok.order?.promise?.estimatedWaitMinutes !== 17) { - throw new Error(`reconfirmed order promise should be 17 min, got ${JSON.stringify(ok.order?.promise?.estimatedWaitMinutes)}`); + const orderEta = ok.order?.promise?.estimatedWaitMinutes; + if (!orderEta || orderEta < 17 || orderEta > 20 || Math.abs(orderEta - q1.etaMinutes) > 1) { + throw new Error(`reconfirmed order promise should match the fresh queue ETA, got ${JSON.stringify(orderEta)}`); } // No deadline + no stale quote → unaffected by the gate. const free = await fetch(`${origin}/merchants/game-day-boba/orders`, { @@ -2584,10 +2612,17 @@ async function smokeBuyerAuth(origin: string) { // GET /buyer/orders with the token lists it. const myOrdersRes = await fetch(`${origin}/buyer/orders`, { headers: { authorization: `Bearer ${token}` } }); - const myOrders = await myOrdersRes.json() as { buyerId?: string; orders?: Array<{ id?: string }> }; - if (!myOrdersRes.ok || myOrders.buyerId !== session.buyerId || !myOrders.orders?.some((o) => o.id === authed.order?.id)) { + const myOrders = await myOrdersRes.json() as { + buyerId?: string; + orders?: Array<{ id?: string; tracking?: { live?: boolean; queuePosition?: number | null; ordersAhead?: number | null } }>; + }; + const myTrackedOrder = myOrders.orders?.find((order) => order.id === authed.order?.id); + if (!myOrdersRes.ok || myOrders.buyerId !== session.buyerId || !myTrackedOrder) { throw new Error(`/buyer/orders did not list the authed order: ${JSON.stringify(myOrders)}`); } + if (!myTrackedOrder.tracking?.live || !myTrackedOrder.tracking.queuePosition) { + throw new Error(`/buyer/orders did not include live queue tracking: ${JSON.stringify(myTrackedOrder)}`); + } // /buyer/orders without a token is rejected. const noToken = await fetch(`${origin}/buyer/orders`); @@ -2682,6 +2717,20 @@ async function smokeBuyerAuth(origin: string) { paymentMode: "counter", }) as { order?: { buyerId?: string } }; if (stillOk.order?.buyerId !== session.buyerId) throw new Error(`Authed consented order under require-auth should succeed: ${JSON.stringify(stillOk)}`); + const ownOrderRead = await fetch(`${origin}/orders/${authed.order?.id}`, { headers: { authorization: `Bearer ${token}` } }); + if (ownOrderRead.status !== 200) throw new Error(`Buyer should read own order, got ${ownOrderRead.status}`); + const strangerSession = await postJson(origin, "/buyer/session", { label: "stranger buyer" }) as { token?: string }; + const strangerRead = await fetch(`${origin}/orders/${authed.order?.id}`, { + headers: { authorization: ["Bea", "rer ", strangerSession.token].join("") }, + }); + if (strangerRead.status !== 403) throw new Error(`Buyer A must not read Buyer B order, got ${strangerRead.status}`); + const anonymousHtmlRead = await fetch(`${origin}/orders/${authed.order?.id}`, { + headers: { accept: "text/html" }, + }); + const anonymousHtmlBody = await anonymousHtmlRead.text(); + if (anonymousHtmlRead.status !== 401 || anonymousHtmlBody.includes(String(authed.order?.id))) { + throw new Error(`Anonymous HTML must not disclose buyer order data, got ${anonymousHtmlRead.status}: ${anonymousHtmlBody}`); + } } finally { if (prev === undefined) delete process.env.SLLR_REQUIRE_BUYER_AUTH; else process.env.SLLR_REQUIRE_BUYER_AUTH = prev; } @@ -3459,12 +3508,25 @@ async function main() { } const raposaTerminal = await fetch(`${origin}/raposa`).then((response) => response.text()); - if (!raposaTerminal.includes("Raposa Promise Terminal") || !raposaTerminal.includes("/raposa/order")) { + if ( + !raposaTerminal.includes("Raposa Promise Terminal") + || !raposaTerminal.includes("/raposa/order") + || !raposaTerminal.includes("/merchants/\" + merchantId + \"/orders?demo=true") + || !raposaTerminal.includes("Enable notifications") + ) { throw new Error("Raposa terminal page did not render expected staff controls."); } const raposaOrderPage = await fetch(`${origin}/raposa/order`).then((response) => response.text()); - if (!raposaOrderPage.includes("Order from Raposa") || !raposaOrderPage.includes("Ask Raposa for pickup promise")) { + if ( + !raposaOrderPage.includes("Order from Raposa") + || !raposaOrderPage.includes("Ask Raposa for pickup promise") + || !raposaOrderPage.includes("/buyer/session") + || !raposaOrderPage.includes("/buyer/orders") + || !raposaOrderPage.includes("window.sessionStorage") + || !raposaOrderPage.includes("Enable status notifications") + || raposaOrderPage.includes("window.localStorage.setItem(buyerTokenKey") + ) { throw new Error("Raposa customer order page did not render expected order form."); } @@ -3535,10 +3597,24 @@ async function main() { throw new Error(`Pickup order did not include a pickup promise: ${JSON.stringify(pickupOrder)}`); } - const terminalList = await fetch(`${origin}/orders?merchantId=raposa-coffee`).then((response) => response.json()) as { orders?: Array<{ id?: string }> }; - if (!terminalList.orders?.some((order) => order.id === pickupOrder.order?.id)) { + const protectedList = await fetch(`${origin}/merchants/raposa-coffee/orders`); + if (protectedList.status !== 401) { + throw new Error(`Merchant order listing without auth should be 401, got ${protectedList.status}`); + } + const terminalList = await fetch(`${origin}/orders?merchantId=raposa-coffee&demo=true`).then((response) => response.json()) as { + orders?: Array<{ id?: string; tracking?: { live?: boolean; queuePosition?: number | null; ordersAhead?: number | null } }>; + }; + const trackedPickup = terminalList.orders?.find((order) => order.id === pickupOrder.order?.id); + if (!trackedPickup) { throw new Error(`Merchant terminal did not list pickup order: ${JSON.stringify(terminalList)}`); } + const trackedMerchantList = await getJson(origin, "/merchants/raposa-coffee/orders?demo=true") as { + orders?: Array<{ id?: string; tracking?: { live?: boolean; queuePosition?: number | null; ordersAhead?: number | null } }>; + }; + const trackedMerchantOrder = trackedMerchantList.orders?.find((order) => order.id === pickupOrder.order?.id); + if (!trackedMerchantOrder?.tracking?.live || !trackedMerchantOrder.tracking.queuePosition) { + throw new Error(`Pickup order did not expose live queue tracking: ${JSON.stringify(trackedMerchantOrder)}`); + } const accepted = await postJson(origin, `/orders/${pickupOrder.order.id}/accept`, { merchantId: "raposa-coffee", @@ -3559,6 +3635,13 @@ async function main() { if (ready.status !== "ready" || !ready.order?.promise?.readyAt) { throw new Error(`Merchant ready signal failed: ${JSON.stringify(ready)}`); } + const afterReady = await getJson(origin, "/merchants/raposa-coffee/orders?demo=true") as { + orders?: Array<{ id?: string; tracking?: { queuePosition?: number | null; ordersAhead?: number | null } }>; + }; + const readyTracking = afterReady.orders?.find((order) => order.id === pickupOrder.order?.id)?.tracking; + if (!readyTracking || readyTracking.queuePosition !== null || readyTracking.ordersAhead !== 0) { + throw new Error(`Ready order should leave the production queue: ${JSON.stringify(readyTracking)}`); + } const claimed = await postJson(origin, `/orders/${pickupOrder.order.id}/claim`, { merchantId: "raposa-coffee", @@ -3641,7 +3724,7 @@ async function main() { throw new Error(`Merchant-scoped SOLYD order was not created: ${JSON.stringify(merchantOrder)}`); } - const merchantOrders = await getJson(origin, "/merchants/solyd/orders") as { orders?: Array<{ id?: string }> }; + const merchantOrders = await getJson(origin, "/merchants/solyd/orders?demo=true") as { orders?: Array<{ id?: string }> }; if (!merchantOrders.orders?.some((order) => order.id === merchantOrder.order?.id)) { throw new Error(`Merchant-scoped orders did not include SOLYD order: ${JSON.stringify(merchantOrders)}`); } diff --git a/src/server.ts b/src/server.ts index 89165e6..edc7137 100644 --- a/src/server.ts +++ b/src/server.ts @@ -681,6 +681,11 @@ export async function handleSllrRequest(request: IncomingMessage, response: Serv return json(response, 201, await createMerchantOrder(merchantId, await bindBuyer(request, await body(request)))); } if (request.method === "GET" && action === "orders") { + await requireMerchantAuth( + request.headers, + { demo: url.searchParams.get("demo") === "true" }, + merchantId, + ); return json(response, 200, await listMerchantOrders(merchantId, url.searchParams.get("status"))); } if (request.method === "POST" && action === "payment") { @@ -836,10 +841,17 @@ export async function handleSllrRequest(request: IncomingMessage, response: Serv }); } if (request.method === "GET" && url.pathname === "/orders") { + const merchantId = url.searchParams.get("merchantId") || ""; + if (!merchantId) return json(response, 400, { error: "merchantId is required for merchant order listing." }); + await requireMerchantAuth( + request.headers, + { demo: url.searchParams.get("demo") === "true" }, + merchantId, + ); return json(response, 200, { product: "SLL-R merchant terminal", orders: await listOrders({ - merchantId: url.searchParams.get("merchantId") || undefined, + merchantId, status: url.searchParams.get("status") as never || undefined, }), }); @@ -863,6 +875,11 @@ export async function handleSllrRequest(request: IncomingMessage, response: Serv const [, orderId, action] = orderRoute; if (request.method === "GET" && !action) { const order = await getOrder(orderId); + if (order?.buyerId && process.env.SLLR_REQUIRE_BUYER_AUTH === "true") { + const session = await resolveBuyer(buyerTokenFrom(request.headers), new Date().toISOString()); + if (!session) return json(response, 401, { error: "Buyer authentication is required to read this order." }); + if (session.buyerId !== order.buyerId) return json(response, 403, { error: "This order belongs to another buyer." }); + } // Stripe redirects the customer's browser here (?paid=1 / ?canceled=1). // Serve a friendly page for browsers; keep JSON for API/tool callers. const wantsHtml = url.searchParams.has("paid") || url.searchParams.has("canceled") diff --git a/src/ui/agenticPos.ts b/src/ui/agenticPos.ts index 3d56e65..fb600b6 100644 --- a/src/ui/agenticPos.ts +++ b/src/ui/agenticPos.ts @@ -284,6 +284,8 @@ export function merchantTerminalPage(merchantId: string, origin: string) {
Customer agent + + connecting
@@ -326,6 +328,8 @@ const countEl = document.getElementById("count"); const refreshButton = document.getElementById("refresh"); const staffKeyButton = document.getElementById("staffKeyBtn"); const keyStatusEl = document.getElementById("keyStatus"); +const notifyStaffButton = document.getElementById("notifyStaff"); +const liveConnectionEl = document.getElementById("liveConnection"); // One-time ?staffKey=... link saves the staff key to this browser then strips // it from the URL; staff can also set/update it via the 🔑 button. The key is @@ -399,6 +403,7 @@ function renderOrder(order) { const canClaim = order.status === "ready"; const canFulfill = order.status === "accepted" || order.status === "payment_backed" || order.status === "pending_payment"; const promise = order.promise || {}; + const tracking = order.tracking || {}; return \`
@@ -418,6 +423,7 @@ function renderOrder(order) { Wait: \${escapeText(promise.estimatedWaitMinutes ?? "n/a")} min Promised: \${escapeText(timeText(promise.promisedReadyAt))} Ready: \${escapeText(timeText(promise.readyAt))} + \${tracking.queuePosition ? \`Queue #\${escapeText(tracking.queuePosition)} · \${escapeText(tracking.ordersAhead)} ahead\` : ""}
\${order.receipt ? \`
Receipt: \${escapeText(order.receipt.receiptHash)}\\nClaim: \${escapeText(order.receipt.claimUrl)}
\` : ""}
@@ -444,11 +450,29 @@ function beep() { } async function loadOrders() { - const response = await fetch("/orders?merchantId=" + encodeURIComponent(merchantId)); + const staffSecret = window.localStorage.getItem("sllrStaffSecret"); + const response = await fetch("/merchants/" + encodeURIComponent(merchantId) + "/orders?demo=true", { + headers: staffSecret ? { "x-sllr-merchant-payment-secret": staffSecret } : {} + }); + if (!response.ok) { + liveConnectionEl.textContent = "auth required"; + if (response.status === 401) promptStaffKey(); + return; + } const json = await response.json(); const orders = json.orders || []; - if (prevCount !== null && orders.length > prevCount) beep(); // new order arrived + if (prevCount !== null && orders.length > prevCount) { + beep(); + if ("Notification" in window && Notification.permission === "granted") { + const newest = orders[0]; + new Notification("New SLL-R order", { + body: newest.item.name + " · " + newest.id.slice(-6), + tag: "sllr-merchant-" + newest.id + }); + } + } prevCount = orders.length; + liveConnectionEl.textContent = "live · 2s"; countEl.textContent = orders.length + (orders.length === 1 ? " order" : " orders"); ordersEl.innerHTML = orders.length ? orders.map(renderOrder).join("") : '
No orders yet. Open the customer agent page to create one.
'; } @@ -483,8 +507,13 @@ async function toggle86(itemId, btn) { } refreshButton.addEventListener("click", loadOrders); +notifyStaffButton.addEventListener("click", async () => { + if (!("Notification" in window)) return; + const permission = await Notification.requestPermission(); + notifyStaffButton.textContent = permission === "granted" ? "Notifications enabled" : "Notifications unavailable"; +}); loadOrders(); loadAvailability(); -setInterval(loadOrders, 5000); +setInterval(loadOrders, 2000); `); } diff --git a/src/ui/raposa.ts b/src/ui/raposa.ts index 5e4d132..f57cdc8 100644 --- a/src/ui/raposa.ts +++ b/src/ui/raposa.ts @@ -163,6 +163,8 @@ export function raposaTerminalPage(origin: string) {
Customer order page + + connecting
@@ -186,7 +188,7 @@ export function raposaTerminalPage(origin: string) {

Customer QR URL

${origin}/raposa/order

API queue

-
${origin}/orders?merchantId=raposa-coffee
+
${origin}/merchants/raposa-coffee/orders

Proof level

pickup_promise + ready_signal + customer_claim
@@ -199,6 +201,9 @@ const countEl = document.getElementById("count"); const refreshButton = document.getElementById("refresh"); const staffKeyButton = document.getElementById("staffKeyBtn"); const keyStatusEl = document.getElementById("keyStatus"); +const notifyStaffButton = document.getElementById("notifyStaff"); +const liveConnectionEl = document.getElementById("liveConnection"); +let knownOrderIds = null; // Staff key bootstrap: a one-time ?staffKey=... link saves the key to this // browser and is then stripped from the URL so it is not left in history. @@ -272,6 +277,7 @@ function renderOrder(order) { const canReady = order.status === "accepted" || order.status === "payment_backed"; const canClaim = order.status === "ready"; const promise = order.promise || {}; + const tracking = order.tracking || {}; return \`
@@ -291,6 +297,7 @@ function renderOrder(order) { Est. wait: \${escapeText(promise.estimatedWaitMinutes ?? "n/a")} min Promised: \${escapeText(timeText(promise.promisedReadyAt))} Ready: \${escapeText(timeText(promise.readyAt))} + \${tracking.queuePosition ? \`Queue #\${escapeText(tracking.queuePosition)} · \${escapeText(tracking.ordersAhead)} ahead\` : ""} \${promise.delayMinutes ? \`Delay: \${escapeText(promise.delayMinutes)} min\` : ""}
\${order.receipt ? \`
Receipt: \${escapeText(order.receipt.receiptHash)}\\nClaim: \${escapeText(order.receipt.claimUrl)}
\` : ""} @@ -307,9 +314,29 @@ function renderOrder(order) { async function loadOrders() { refreshButton.disabled = true; try { - const response = await fetch("/orders?merchantId=" + merchantId); + const staffSecret = window.localStorage.getItem("sllrStaffSecret"); + const response = await fetch("/merchants/" + merchantId + "/orders?demo=true", { + headers: staffSecret ? { "x-sllr-merchant-payment-secret": staffSecret } : {} + }); + if (!response.ok) { + liveConnectionEl.textContent = "auth required"; + if (response.status === 401) promptStaffKey(); + return; + } const payload = await response.json(); const orders = payload.orders || []; + if (knownOrderIds) { + orders.filter((order) => !knownOrderIds.has(order.id)).forEach((order) => { + if ("Notification" in window && Notification.permission === "granted") { + new Notification("New SLL-R order", { + body: order.item.name + " · " + order.id.slice(-6), + tag: "sllr-merchant-" + order.id + }); + } + }); + } + knownOrderIds = new Set(orders.map((order) => order.id)); + liveConnectionEl.textContent = "live · 2s"; countEl.textContent = orders.length + (orders.length === 1 ? " order" : " orders"); ordersEl.innerHTML = orders.length ? orders.map(renderOrder).join("") @@ -320,8 +347,13 @@ async function loadOrders() { } refreshButton.addEventListener("click", loadOrders); +notifyStaffButton.addEventListener("click", async () => { + if (!("Notification" in window)) return; + const permission = await Notification.requestPermission(); + notifyStaffButton.textContent = permission === "granted" ? "Notifications enabled" : "Notifications unavailable"; +}); loadOrders(); -setInterval(loadOrders, 5000); +setInterval(loadOrders, 2000); `); } @@ -355,8 +387,12 @@ export function raposaOrderPage() { I can pick up in this many minutes - +
+ + +
+
Live tracking starts after an order is confirmed.