From 310380add607d7868637737e7bfd652e96971c3d Mon Sep 17 00:00:00 2001 From: Maxwell Fortney Date: Fri, 2 Oct 2026 01:40:38 -0400 Subject: [PATCH 1/2] Answer checkout forms with typed fields and protected inputs Agent Checkouts replaced JSON Schema forms and the separate password request with typed fields. Each field is standard or protected, and a form is answered once, with every field. A protected field takes only the { protectedInputId } that Crossmint's protected field returns. - core: typed fields on PendingUserAction, submitResponse with answers, and a flat RenderedField with select, multiselect and protected kinds. protectedResponse and the protected interaction are gone. - server: protectedRequest replaces passwordRequest. A protected field answered with a plain value, or a required one left out, is refused with 409 protected_input_required. - ui: PendingActionForm renders every field kind and collects each protected field with CrossmintProtectedInput (client SDK 4.9.0) on submit. AnswerProtectedRequest replaces AnswerPasswordRequest. It reads the form from the checkout, and a resend with the same answers keeps the same message id. - web chat: awaiting_protected_input replaces awaiting_password. The model answers standard fields by key and never a protected one. - mcp and cli: a protected form is never shown to the agent as a form; the agent gets the link to the checkout's page. - docs, the skill and the build prompt follow. The payment step's merchant is read as name and url, as the API sends it. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +- apps/web/components/chat/message.tsx | 26 +- apps/web/components/chat/messages.tsx | 6 +- apps/web/components/chat/parts.ts | 68 ++-- ...word-request.tsx => protected-request.tsx} | 85 ++-- apps/web/components/chat/use-agent-chat.ts | 8 +- .../components/experiences/desktop-app.tsx | 2 +- .../components/experiences/messaging-app.tsx | 73 ++-- .../web/components/experiences/mobile-app.tsx | 75 ++-- apps/web/lib/build-prompt.ts | 5 +- apps/web/lib/chat/prompt.ts | 8 +- apps/web/lib/chat/tools.ts | 39 +- apps/web/package.json | 2 +- apps/web/public/skill.md | 10 +- docs/API.md | 18 +- packages/cli/src/commands/checkout.ts | 22 +- packages/cli/src/commands/shared.ts | 41 +- packages/cli/src/types.ts | 13 +- packages/cli/test/checkout.test.ts | 91 ++++- packages/core/src/checkout-messages.ts | 52 +-- packages/core/src/client.ts | 26 +- packages/core/src/pending-action.ts | 196 ++++----- packages/core/src/tool-docs.ts | 16 +- packages/core/src/types.ts | 159 +++++--- packages/core/test/client.test.ts | 2 +- packages/core/test/pending-action.test.ts | 192 ++++++--- packages/mcp/src/api.ts | 19 +- packages/mcp/src/tools.ts | 80 ++-- packages/mcp/test/tools.test.ts | 148 ++++++- packages/server/src/handlers/checkouts.ts | 83 ++-- packages/server/src/index.ts | 2 +- packages/server/src/schemas.ts | 39 +- packages/server/test/handlers.test.ts | 138 +++---- packages/ui/package.json | 2 +- packages/ui/src/api/types.ts | 11 +- .../components/answer-protected-request.tsx | 175 ++++++++ packages/ui/src/components/checkout-steps.tsx | 21 +- packages/ui/src/components/checkout-view.tsx | 18 +- .../ui/src/components/pending-action-form.tsx | 362 +++++++++++------ .../ui/src/components/protected-input.tsx | 377 ++++++------------ packages/ui/src/hooks/use-checkout.ts | 7 +- packages/ui/src/index.ts | 14 +- packages/ui/test/pending-action-form.test.tsx | 138 +++++++ plugins/claude/skills/agent-commerce/SKILL.md | 10 +- plugins/cursor/skills/agent-commerce/SKILL.md | 10 +- pnpm-lock.yaml | 166 ++++---- skills/agent-commerce/SKILL.md | 10 +- 47 files changed, 1862 insertions(+), 1208 deletions(-) rename apps/web/components/chat/{password-request.tsx => protected-request.tsx} (52%) create mode 100644 packages/ui/src/components/answer-protected-request.tsx create mode 100644 packages/ui/test/pending-action-form.test.tsx diff --git a/README.md b/README.md index 63dfbbf..affcc86 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,9 @@ Build these parts: a merchant and an expiry. Show an approval screen in my app where the user picks a card and approves. Run OrderIntentVerification when the card rail needs it. 3. Agent Checkouts. Start a run at a product URL with a max cost and stream its - messages. Answer its form requests. Pay its payment step with an agent card for - the exact amount. Collect store passwords with CrossmintProtectedInput. + messages. Answer each form request with all its fields in one answer. Pay its + payment step with an agent card for the exact amount. Collect each protected + field, such as a store password, with CrossmintProtectedInput. 4. Buyer details. Save the user's name, contact and shipping address as a buyer profile, so checkouts do not stop to ask for them. 5. Agent tools. Give my agent tools to list saved cards, request an agent card, diff --git a/apps/web/components/chat/message.tsx b/apps/web/components/chat/message.tsx index 2d0f146..d27f8e5 100644 --- a/apps/web/components/chat/message.tsx +++ b/apps/web/components/chat/message.tsx @@ -15,7 +15,7 @@ import type { } from "@/lib/chat/tools"; import type { ChatMessage, ChatMessagePart } from "@/lib/chat/types"; import { AgentCardApproval } from "./agent-card-approval"; -import { PasswordRequest } from "./password-request"; +import { ProtectedRequest } from "./protected-request"; import { AddCard } from "./add-card"; import { BuyerDetailsRequest } from "./buyer-details-request"; import { PaymentChoiceInThread } from "./payment-choice"; @@ -38,7 +38,7 @@ import { isToolError, isCheckoutPart, messageText, - passwordRequestOf, + protectedRequestOf, productFor, sentBackFirst, toolBusy, @@ -54,7 +54,7 @@ export interface MessageProps { /** True while this message is still streaming in. */ streaming: boolean; onApprovalOutcome: (toolCallId: string, outcome: ApprovalOutcome) => void; - onPasswordOutcome: (toolCallId: string, outcome: ProtectedInputOutcome) => void; + onProtectedInputOutcome: (toolCallId: string, outcome: ProtectedInputOutcome) => void; onCardSaved: (toolCallId: string, outcome: SavedCardOutcome) => void; onBuyerDetails: (toolCallId: string, outcome: BuyerDetailsOutcome) => void; onPaymentChoice: (toolCallId: string, outcome: PaymentChoiceOutcome) => void; @@ -76,7 +76,7 @@ export function Message({ message, streaming, onApprovalOutcome, - onPasswordOutcome, + onProtectedInputOutcome, onCardSaved, onBuyerDetails, onPaymentChoice, @@ -106,7 +106,7 @@ export function Message({ part={part} streaming={streaming} onApprovalOutcome={onApprovalOutcome} - onPasswordOutcome={onPasswordOutcome} + onProtectedInputOutcome={onProtectedInputOutcome} onCardSaved={onCardSaved} onBuyerDetails={onBuyerDetails} onPaymentChoice={onPaymentChoice} @@ -198,7 +198,7 @@ function Part({ part, streaming, onApprovalOutcome, - onPasswordOutcome, + onProtectedInputOutcome, onCardSaved, onBuyerDetails, onPaymentChoice, @@ -212,7 +212,7 @@ function Part({ part: ChatMessagePart; streaming: boolean; onApprovalOutcome: MessageProps["onApprovalOutcome"]; - onPasswordOutcome: MessageProps["onPasswordOutcome"]; + onProtectedInputOutcome: MessageProps["onProtectedInputOutcome"]; onCardSaved: MessageProps["onCardSaved"]; onBuyerDetails: MessageProps["onBuyerDetails"]; onPaymentChoice: MessageProps["onPaymentChoice"]; @@ -329,18 +329,16 @@ function Part({ ); - // The store asks for a password: Crossmint's field in a dialog, never words. + // The store asks for secrets: Crossmint's fields in a dialog, never words. case "tool-await_protected_input": { - const request = passwordRequestOf(part, watches); + const request = protectedRequestOf(part, watches); if (request && (part.state === "input-available" || part.state === "output-available")) { return ( - ); } diff --git a/apps/web/components/chat/messages.tsx b/apps/web/components/chat/messages.tsx index 02dd6d9..b5691bf 100644 --- a/apps/web/components/chat/messages.tsx +++ b/apps/web/components/chat/messages.tsx @@ -24,7 +24,7 @@ export interface MessagesProps { messages: ChatMessage[]; status: ChatStatus; onApprovalOutcome: (toolCallId: string, outcome: ApprovalOutcome) => void; - onPasswordOutcome: (toolCallId: string, outcome: ProtectedInputOutcome) => void; + onProtectedInputOutcome: (toolCallId: string, outcome: ProtectedInputOutcome) => void; onCardSaved: (toolCallId: string, outcome: SavedCardOutcome) => void; onBuyerDetails: (toolCallId: string, outcome: BuyerDetailsOutcome) => void; onPaymentChoice: (toolCallId: string, outcome: PaymentChoiceOutcome) => void; @@ -42,7 +42,7 @@ export function Messages({ messages, status, onApprovalOutcome, - onPasswordOutcome, + onProtectedInputOutcome, onCardSaved, onBuyerDetails, onPaymentChoice, @@ -71,7 +71,7 @@ export function Messages({ message={m} streaming={status === "streaming" && i === messages.length - 1} onApprovalOutcome={onApprovalOutcome} - onPasswordOutcome={onPasswordOutcome} + onProtectedInputOutcome={onProtectedInputOutcome} onCardSaved={onCardSaved} onBuyerDetails={onBuyerDetails} onPaymentChoice={onPaymentChoice} diff --git a/apps/web/components/chat/parts.ts b/apps/web/components/chat/parts.ts index e2cbc39..72c09bb 100644 --- a/apps/web/components/chat/parts.ts +++ b/apps/web/components/chat/parts.ts @@ -136,15 +136,17 @@ export function stopReason( }; } const action = view.pendingUserAction; - // A password: the chat shows Crossmint's protected field, never a question in words. - if (view.status === "awaiting_input" && action?.protected && !asked.has(action.id)) { + // Secrets: the chat shows Crossmint's protected fields, never a question in words. + const secure = view.protectedRequest; + if (view.status === "awaiting_input" && secure && !asked.has(secure.requestId)) { return { checkoutId: view.id, - status: "awaiting_password", - password: { - requestId: action.id, - question: action.question || "The store asks for your password.", - ...(action.protected.merchant ? { domain: action.protected.merchant.domain } : {}), + status: "awaiting_protected_input", + protectedInput: { + requestId: secure.requestId, + question: secure.question || "The store asks for a secret.", + secrets: secure.fields.map((f) => f.label), + ...(secure.merchantDomain ? { domain: secure.merchantDomain } : {}), }, }; } @@ -158,7 +160,8 @@ export function stopReason( requestId: action.id, question: action.question || view.rendered?.title || "The store needs an answer.", ...(action.expiresAt ? { expiresAt: action.expiresAt } : {}), - responseSchema: action.responseSchema as Record, + // The model answers by key; the handling is always standard here. + fields: action.fields.map(({ key, label, required, input }) => ({ key, label, required, input })), ...(asksPasswordInForm(action) ? { note: PASSWORD_IN_FORM_NOTE } : asksCardInForm(action) @@ -180,9 +183,9 @@ export function watchedHere(message: ChatMessage, part: ChatMessagePart): boolea return message.parts.some((p) => p.type === "tool-watch_checkout" && p.input?.checkoutId === id); } -/** What the agent is told when a store asks for a password in a plain form. */ +/** What the agent is told when a store asks for a password in a plain field. */ const PASSWORD_IN_FORM_NOTE = - "This asks for the user's password in a plain form. Never ask the user for it and never send it: it would pass through the chat, and the store's agent does not use a password sent that way. Tell the user in one line that this store wants them to sign in, which cannot be done safely here, and ask whether to check out as a guest instead (answer_checkout with action alternative) or stop (action decline)."; + "This asks for the user's password in a plain field. Never ask the user for it and never send it: it would pass through the chat, and the store's agent does not use a password sent that way. Tell the user in one line that this store wants them to sign in, which cannot be done safely here, and ask whether to check out as a guest instead (answer_checkout with action alternative) or stop (action decline)."; /** What the agent is told when a store asks for card details in a plain form, not a payment request. */ const CARD_IN_FORM_NOTE = @@ -200,8 +203,8 @@ export interface WatchIndex { sites: Map; /** Each checkout's first `watch_checkout`, by tool call id. */ firstWatch: Map; - /** Password requests a watch handed back, by requestId: the store the password is for, from the checkout itself. */ - passwords: Map; + /** Protected requests a watch handed back, by requestId: the store the secrets are for, from the checkout itself. */ + protectedInputs: Map; /** * Watches whose card the next watch takes over: the stretch stopped on a * question the agent answered itself, with nothing shown in between, so the @@ -229,7 +232,7 @@ export function watchIndex(messages: ChatMessage[]): WatchIndex { paymentRequests: new Set(), sites: new Map(), firstWatch: new Map(), - passwords: new Map(), + protectedInputs: new Map(), absorbed: new Set(), carried: new Map(), products: new Map(), @@ -274,11 +277,13 @@ export function watchIndex(messages: ChatMessage[]): WatchIndex { if (!shown) index.shown.set(out.checkoutId, (shown = new Set())); for (const u of out.updates ?? []) shown.add(u.id); if (out.question) index.asked.add(out.question.requestId); - if (out.password) { - index.asked.add(out.password.requestId); - index.passwords.set(out.password.requestId, { + if (out.protectedInput) { + index.asked.add(out.protectedInput.requestId); + index.protectedInputs.set(out.protectedInput.requestId, { checkoutId: out.checkoutId, - ...(out.password.domain ? { domain: out.password.domain } : {}), + question: out.protectedInput.question, + secrets: out.protectedInput.secrets, + ...(out.protectedInput.domain ? { domain: out.protectedInput.domain } : {}), }); } if (out.payment) index.paymentRequests.add(out.payment.requestId); @@ -287,23 +292,36 @@ export function watchIndex(messages: ChatMessage[]): WatchIndex { return index; } +/** An `await_protected_input` call, as its card shows it. */ +export interface ProtectedRequestSummary { + checkoutId: string; + requestId: string; + /** The store's question: "Sign in to shop.example to continue." */ + question: string; + /** The secrets it asks for, by label: "Password". */ + secrets: string[]; + domain: string; +} + /** * An `await_protected_input` call, with what its card needs: the checkout, - * the request, and the store the password is for. The store comes from the + * the request, what it asks for, and the store. All of it comes from the * watch that handed the request back, else the checkout's own site, never - * from the model's arguments: the password is bound to it. + * from the model's arguments. */ -export function passwordRequestOf( +export function protectedRequestOf( part: ChatMessagePart, watches: WatchIndex, -): { checkoutId: string; requestId: string; domain: string } | undefined { +): ProtectedRequestSummary | undefined { if (part.type !== "tool-await_protected_input" || part.state === "input-streaming") return undefined; const requestId = part.input?.requestId; - const known = requestId ? watches.passwords.get(requestId) : undefined; + const known = requestId ? watches.protectedInputs.get(requestId) : undefined; if (!requestId || !known) return undefined; const domain = known.domain ?? watches.sites.get(known.checkoutId)?.host; - return domain ? { checkoutId: known.checkoutId, requestId, domain } : undefined; + return domain + ? { checkoutId: known.checkoutId, requestId, question: known.question, secrets: known.secrets, domain } + : undefined; } /** @@ -317,7 +335,7 @@ export function stoppedForUser(outcome: CheckoutOutcome | undefined): boolean { return ( outcome?.status === "awaiting_input" || outcome?.status === "awaiting_payment" || - outcome?.status === "awaiting_password" + outcome?.status === "awaiting_protected_input" ); } @@ -552,7 +570,7 @@ export function toolTitle(type: string): string { "tool-get_agent_card": "Checking an agent card", "tool-request_agent_card": "Requesting an agent card", "tool-await_agent_card_approval": "Waiting for your approval", - "tool-await_protected_input": "Waiting for your password", + "tool-await_protected_input": "Waiting for your answer", "tool-await_saved_card": "Waiting for your card", "tool-await_buyer_details": "Waiting for your details", "tool-await_payment_choice": "Waiting for how you want to pay", diff --git a/apps/web/components/chat/password-request.tsx b/apps/web/components/chat/protected-request.tsx similarity index 52% rename from apps/web/components/chat/password-request.tsx rename to apps/web/components/chat/protected-request.tsx index 67d5035..5e12080 100644 --- a/apps/web/components/chat/password-request.tsx +++ b/apps/web/components/chat/protected-request.tsx @@ -2,24 +2,33 @@ import { useCallback, useState } from "react"; import { - AnswerPasswordRequest, + AnswerProtectedRequest, Badge, Button, Dialog, DialogContent, DialogTitle, cn, - type PasswordRequestOutcome, + type ProtectedRequestOutcome, } from "@agent-commerce/ui"; import { AGENT_COMPANY } from "@/components/brand"; +import type { ProtectedRequestSummary } from "@/components/chat/parts"; import type { ProtectedInputOutcome } from "@/lib/chat/tools"; import { APPROVAL_DONE_LINGER_MS } from "./agent-card-approval"; import { SiteIcon } from "./checkout-site"; import { AgentBubble } from "./text"; -/** What the agent asks when a store wants the password of the user's account there. */ -export function passwordQuestion(domain: string): string { - return `Can you sign in to ${siteName(domain)}?`; +/** "Password", "Password and one-time code": what the store asks for, in lower case, for a sentence. */ +export function secretsPhrase(secrets: string[]): string { + const words = secrets.map((s) => s.toLowerCase()); + if (words.length === 0) return "details"; + if (words.length === 1) return words[0]!; + return `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`; +} + +/** What the agent asks when a store wants secrets, such as the password of the user's account there. */ +export function protectedQuestion(request: Pick): string { + return `Can you enter your ${secretsPhrase(request.secrets)} for ${siteName(request.domain)}?`; } /** "www.amazon.com" → "amazon.com", for reading only. */ @@ -33,33 +42,35 @@ const OUTCOME_LABEL: Record = { }; /** - * A store's password request in the thread, the same in every chat frame, - * and laid out like an approval: the agent asks in its bubble, and one small - * card sits under it, the store and what happens to the password, with - * Enter password, which opens Crossmint's protected field in the frame's own - * sheet or dialog. Answered, the card says whether it was sent. + * A store's question with secrets in the thread, the same in every chat + * frame, and laid out like an approval: the agent asks in its bubble, and + * one small card sits under it, the store and what happens to the secrets, + * with a button that opens the question, with Crossmint's protected fields, + * in the frame's own sheet or dialog. Answered, the card says whether it was + * sent. * - * `domain` comes from the checkout (the watch that handed the request back), - * never from the model: the password is bound to it. + * `request` comes from the watch that handed the request back, never from + * the model. */ -export function PasswordInThread({ - domain, +export function ProtectedRequestInThread({ + request, output, onEnter, bubbleClassName, buttonSize = "xl", className, }: { - domain: string; + request: ProtectedRequestSummary; output?: ProtectedInputOutcome; onEnter: () => void; bubbleClassName?: string; buttonSize?: "lg" | "xl"; className?: string; }) { + const site = siteName(request.domain); return ( <> - +

- - Sign in to {siteName(domain)} + + {request.question}

{output ? OUTCOME_LABEL[output.status] : "Pending"}

- Encrypted and only used on {siteName(domain)}. {AGENT_COMPANY} never sees it. + Encrypted and only used on {site}. {AGENT_COMPANY} never sees it.

{output ? null : ( )}
@@ -89,30 +100,26 @@ export function PasswordInThread({ } /** - * The desktop's password request: the card in the thread, and Enter password - * opens the field in a dialog. The model called + * The desktop's protected request: the card in the thread, and its button + * opens the question in a dialog. The model called * `await_protected_input({ checkoutId, requestId })` and the stream stopped; - * once the user sent the password (or skipped it), `onOutcome` hands back - * only that, and the chat resubmits itself. + * once the user sent it (or skipped it), `onOutcome` hands back only that, + * and the chat resubmits itself. */ -export function PasswordRequest({ +export function ProtectedRequest({ toolCallId, - checkoutId, - requestId, - domain, + request, output, onOutcome, }: { toolCallId: string; - checkoutId: string; - requestId: string; - domain: string; + request: ProtectedRequestSummary; output?: ProtectedInputOutcome; onOutcome: (toolCallId: string, outcome: ProtectedInputOutcome) => void; }) { const [open, setOpen] = useState(false); const handleDone = useCallback( - (status: PasswordRequestOutcome) => { + (status: ProtectedRequestOutcome) => { onOutcome(toolCallId, { status }); setTimeout(() => setOpen(false), APPROVAL_DONE_LINGER_MS); }, @@ -121,20 +128,20 @@ export function PasswordRequest({ return ( <> - setOpen(true)} /> - Sign in to {domain} + {request.question} {open ? ( - diff --git a/apps/web/components/chat/use-agent-chat.ts b/apps/web/components/chat/use-agent-chat.ts index 6ebfe27..25846b9 100644 --- a/apps/web/components/chat/use-agent-chat.ts +++ b/apps/web/components/chat/use-agent-chat.ts @@ -38,8 +38,8 @@ export interface AgentChat { onApprovalOutcome: (toolCallId: string, outcome: ApprovalOutcome) => void; /** Hand the card form's result (saved or cancelled) back to the `await_saved_card` tool call. */ onCardSaved: (toolCallId: string, outcome: SavedCardOutcome) => void; - /** Hand the password field's answer (submitted or declined) back to the `await_protected_input` tool call. */ - onPasswordOutcome: (toolCallId: string, outcome: ProtectedInputOutcome) => void; + /** Hand the protected fields' answer (submitted or declined) back to the `await_protected_input` tool call. */ + onProtectedInputOutcome: (toolCallId: string, outcome: ProtectedInputOutcome) => void; /** Hand the details form's result (saved or skipped) back to the `await_buyer_details` tool call. */ onBuyerDetails: (toolCallId: string, outcome: BuyerDetailsOutcome) => void; /** Hand the way the user chose to pay back to the `await_payment_choice` tool call. */ @@ -192,7 +192,7 @@ export function useAgentChat({ id, initialMessages, persist }: UseAgentChatOptio [hand], ); - const onPasswordOutcome = useCallback( + const onProtectedInputOutcome = useCallback( (toolCallId: string, output: ProtectedInputOutcome) => { hand({ tool: "await_protected_input", toolCallId, output }); }, @@ -247,7 +247,7 @@ export function useAgentChat({ id, initialMessages, persist }: UseAgentChatOptio send, stop: () => void stop(), onApprovalOutcome, - onPasswordOutcome, + onProtectedInputOutcome, onCardSaved, onBuyerDetails, onPaymentChoice, diff --git a/apps/web/components/experiences/desktop-app.tsx b/apps/web/components/experiences/desktop-app.tsx index 4d843b2..e930577 100644 --- a/apps/web/components/experiences/desktop-app.tsx +++ b/apps/web/components/experiences/desktop-app.tsx @@ -124,7 +124,7 @@ function Workspace({ email, chat, thread, chats, chatEnabled, attachmentsEnabled ) : (
- chat.send(text)} onSend={(text) => chat.send(text)} /> + chat.send(text)} onSend={(text) => chat.send(text)} />
{chat.error ? ( diff --git a/apps/web/components/experiences/messaging-app.tsx b/apps/web/components/experiences/messaging-app.tsx index 161d4c5..c62242b 100644 --- a/apps/web/components/experiences/messaging-app.tsx +++ b/apps/web/components/experiences/messaging-app.tsx @@ -11,7 +11,7 @@ import { type ReactNode, } from "react"; import { - AnswerPasswordRequest, + AnswerProtectedRequest, ApproveAgentCard, SaveCard, errorMessage, @@ -21,7 +21,7 @@ import { PAYMENT_STEP_ASK, type CheckoutStep, type ApproveOutcome, - type PasswordRequestOutcome, + type ProtectedRequestOutcome, type SaveCardResult, } from "@agent-commerce/ui"; import { @@ -36,7 +36,8 @@ import { checkoutSiteOf, findPaymentStep, findRequest, - passwordRequestOf, + protectedRequestOf, + type ProtectedRequestSummary, pendingCall, pendingWatches, productFor, @@ -219,8 +220,8 @@ interface ChoicesBubble { used: boolean; } -/** A store's password request, open in the browser sheet. */ -type PasswordAsk = { toolCallId: string; checkoutId: string; requestId: string; domain: string }; +/** A store's question with secrets, open in the browser sheet. */ +type ProtectedAsk = ProtectedRequestSummary & { toolCallId: string }; type Approval = { requestId: string; @@ -244,7 +245,7 @@ function toBubbles( watches: WatchIndex, live: ReadonlyMap, onReview: (a: Approval) => void, - onEnterPassword: (ask: PasswordAsk) => void, + onEnterProtected: (ask: ProtectedAsk) => void, onAddCard: (toolCallId: string) => void, onAddDetails: (toolCallId: string) => void, onBuyerDetails: (toolCallId: string, outcome: BuyerDetailsOutcome) => void, @@ -609,10 +610,10 @@ function toBubbles( } return; } - // A password: a link to the checkout's page, where Crossmint's field takes it. - const password = passwordRequestOf(part, watches); + // Secrets: a link to the checkout's page, where Crossmint's fields take them. + const protectedAsk = protectedRequestOf(part, watches); if ( - password && + protectedAsk && part.type === "tool-await_protected_input" && (part.state === "input-available" || part.state === "output-available") ) { @@ -626,12 +627,12 @@ function toBubbles( key, kind: "link", side: "recv", - title: `Sign in to ${password.domain}`, - path: `/checkouts/${password.checkoutId}`, + title: protectedAsk.question, + path: `/checkouts/${protectedAsk.checkoutId}`, done, onOpen: done ? undefined - : () => onEnterPassword({ toolCallId: part.toolCallId, ...password }), + : () => onEnterProtected({ toolCallId: part.toolCallId, ...protectedAsk }), }); return; } @@ -1533,8 +1534,8 @@ function SignedIn({ }: ExperienceProps & { Chrome: Chrome }) { const [approval, setApproval] = useState(null); const [approvalOpen, setApprovalOpen] = useState(false); - const [password, setPassword] = useState(null); - const [passwordOpen, setPasswordOpen] = useState(false); + const [protectedAsk, setProtectedAsk] = useState(null); + const [protectedOpen, setProtectedOpen] = useState(false); // The add_card call whose form is open in the browser sheet. const [addCard, setAddCard] = useState(null); const [addCardOpen, setAddCardOpen] = useState(false); @@ -1575,13 +1576,13 @@ function SignedIn({ [approval, chat, closeApproval], ); - const onEnterPassword = useCallback((ask: PasswordAsk) => { - setPassword(ask); - setPasswordOpen(true); + const onEnterProtected = useCallback((ask: ProtectedAsk) => { + setProtectedAsk(ask); + setProtectedOpen(true); }, []); - const closePassword = useCallback(() => { - setPasswordOpen(false); - setTimeout(() => setPassword(null), PAGE_SHEET_TRANSITION_MS); + const closeProtected = useCallback(() => { + setProtectedOpen(false); + setTimeout(() => setProtectedAsk(null), PAGE_SHEET_TRANSITION_MS); }, []); const onAddCard = useCallback((toolCallId: string) => { setAddCard(toolCallId); @@ -1598,12 +1599,12 @@ function SignedIn({ }, [addCard, chat, closeAddCard], ); - const onPasswordDone = useCallback( - (status: PasswordRequestOutcome) => { - if (password) chat.onPasswordOutcome(password.toolCallId, { status }); - setTimeout(closePassword, DONE_LINGER_MS); + const onProtectedDone = useCallback( + (status: ProtectedRequestOutcome) => { + if (protectedAsk) chat.onProtectedInputOutcome(protectedAsk.toolCallId, { status }); + setTimeout(closeProtected, DONE_LINGER_MS); }, - [password, chat, closePassword], + [protectedAsk, chat, closeProtected], ); // The await_buyer_details call whose form is open, from its link. const [details, setDetails] = useState(null); @@ -1714,7 +1715,7 @@ function SignedIn({ watches, live, onReview, - onEnterPassword, + onEnterProtected, onAddCard, onAddDetails, chat.onBuyerDetails, @@ -1782,20 +1783,20 @@ function SignedIn({ - {password ? ( - ) : null} diff --git a/apps/web/components/experiences/mobile-app.tsx b/apps/web/components/experiences/mobile-app.tsx index 0327ea5..73af6e8 100644 --- a/apps/web/components/experiences/mobile-app.tsx +++ b/apps/web/components/experiences/mobile-app.tsx @@ -23,7 +23,7 @@ import { import { AgentCardDetailBody, AgentCardTable, - AnswerPasswordRequest, + AnswerProtectedRequest, ApproveAgentCard, Button, CardMark, @@ -38,7 +38,7 @@ import { usePaymentMethods, type AgentCardGroup, type ApproveOutcome, - type PasswordRequestOutcome, + type ProtectedRequestOutcome, type SaveCardResult, } from "@agent-commerce/ui"; import { AGENT_COMPANY, AGENT_NAME, AgentAvatar, PLATFORM_NAME } from "@/components/brand"; @@ -60,7 +60,8 @@ import { messageText, toolBusy, toolTitle, - passwordRequestOf, + protectedRequestOf, + type ProtectedRequestSummary, productFor, sentBackFirst, toApprovalOutcome, @@ -69,7 +70,7 @@ import { type WatchIndex, } from "@/components/chat/parts"; import { APPROVAL_DONE_LINGER_MS, ApprovalInThread } from "@/components/chat/agent-card-approval"; -import { PasswordInThread } from "@/components/chat/password-request"; +import { ProtectedRequestInThread } from "@/components/chat/protected-request"; import { AddCardInThread, savedCardOutcome } from "@/components/chat/add-card"; import { BUYER_DETAILS_NOTE, @@ -134,8 +135,8 @@ export function MobileApp(props: ExperienceProps) { // Signed in // --------------------------------------------------------------------------- -/** A store's password request, open in the sheet. */ -type PasswordAsk = { toolCallId: string; checkoutId: string; requestId: string; domain: string }; +/** A store's question with secrets, open in the sheet. */ +type ProtectedAsk = ProtectedRequestSummary & { toolCallId: string }; /** The details sheet: answering the agent's question, or changing the details from the account. */ type DetailsSheet = @@ -163,7 +164,7 @@ function Home({ const [cardsOpen, setCardsOpen] = useState(false); const [accountOpen, setAccountOpen] = useState(false); const [approval, setApproval] = useState(null); - const [password, setPassword] = useState(null); + const [protectedAsk, setProtectedAsk] = useState(null); // The add_card call whose form is open in the sheet. const [addCard, setAddCard] = useState(null); // The product whose details are open, over the chat. @@ -193,13 +194,13 @@ function Home({ }, [addCard, chat], ); - const onPasswordDone = useCallback( - (status: PasswordRequestOutcome) => { - if (!password) return; - chat.onPasswordOutcome(password.toolCallId, { status }); - setTimeout(() => setPassword(null), APPROVAL_DONE_LINGER_MS); + const onProtectedDone = useCallback( + (status: ProtectedRequestOutcome) => { + if (!protectedAsk) return; + chat.onProtectedInputOutcome(protectedAsk.toolCallId, { status }); + setTimeout(() => setProtectedAsk(null), APPROVAL_DONE_LINGER_MS); }, - [password, chat], + [protectedAsk, chat], ); const onDetailsDone = useCallback( (outcome: BuyerDetailsOutcome) => { @@ -241,7 +242,7 @@ function Home({ loading={thread.loading} chatEnabled={chatEnabled} onReview={setApproval} - onEnterPassword={setPassword} + onEnterProtected={setProtectedAsk} onAddCard={setAddCard} onAddDetails={askDetails} onOpenProduct={setProduct} @@ -299,20 +300,20 @@ function Home({ !open && setPassword(null)} + open={protectedAsk !== null} + onOpenChange={(open) => !open && setProtectedAsk(null)} container={screen} - title={password ? `Sign in to ${password.domain}` : "Sign in"} + title={protectedAsk ? protectedAsk.question : "Sign in"} hideTitle > - {password ? ( - ) : null} @@ -379,7 +380,7 @@ function Thread({ loading, chatEnabled, onReview, - onEnterPassword, + onEnterProtected, onAddCard, onAddDetails, onOpenProduct, @@ -388,7 +389,7 @@ function Thread({ loading: boolean; chatEnabled: boolean; onReview: (approval: Approval) => void; - onEnterPassword: (ask: PasswordAsk) => void; + onEnterProtected: (ask: ProtectedAsk) => void; onAddCard: (toolCallId: string) => void; onAddDetails: (toolCallId: string) => void; onOpenProduct: (product: FoundProduct) => void; @@ -442,7 +443,7 @@ function Thread({ message={m} streaming={chat.status === "streaming" && i === chat.messages.length - 1} onReview={onReview} - onEnterPassword={onEnterPassword} + onEnterProtected={onEnterProtected} onAddCard={onAddCard} onAddDetails={onAddDetails} onPaymentChoice={chat.onPaymentChoice} @@ -470,7 +471,7 @@ function CompactMessage({ message, streaming, onReview, - onEnterPassword, + onEnterProtected, onAddCard, onAddDetails, onPaymentChoice, @@ -484,7 +485,7 @@ function CompactMessage({ message: ChatMessage; streaming: boolean; onReview: (a: Approval) => void; - onEnterPassword: (ask: PasswordAsk) => void; + onEnterProtected: (ask: ProtectedAsk) => void; onAddCard: (toolCallId: string) => void; onAddDetails: (toolCallId: string) => void; onPaymentChoice: (toolCallId: string, outcome: PaymentChoiceOutcome) => void; @@ -516,7 +517,7 @@ function CompactMessage({ part={part} message={message} onReview={onReview} - onEnterPassword={onEnterPassword} + onEnterProtected={onEnterProtected} onAddCard={onAddCard} onAddDetails={onAddDetails} onPaymentChoice={onPaymentChoice} @@ -542,7 +543,7 @@ function CompactPart({ part, message, onReview, - onEnterPassword, + onEnterProtected, onAddCard, onAddDetails, onPaymentChoice, @@ -556,7 +557,7 @@ function CompactPart({ part: ChatMessagePart; message: ChatMessage; onReview: (a: Approval) => void; - onEnterPassword: (ask: PasswordAsk) => void; + onEnterProtected: (ask: ProtectedAsk) => void; onAddCard: (toolCallId: string) => void; onAddDetails: (toolCallId: string) => void; onPaymentChoice: (toolCallId: string, outcome: PaymentChoiceOutcome) => void; @@ -681,15 +682,15 @@ function CompactPart({ ); - // The store asks for a password: Crossmint's field in a sheet, never words. + // The store asks for secrets: Crossmint's fields in a sheet, never words. case "tool-await_protected_input": { - const request = passwordRequestOf(part, watches); + const request = protectedRequestOf(part, watches); if (request && (part.state === "input-available" || part.state === "output-available")) { return ( - onEnterPassword({ toolCallId: part.toolCallId, ...request })} + onEnter={() => onEnterProtected({ toolCallId: part.toolCallId, ...request })} bubbleClassName="max-w-[85%] px-3.5 py-2 text-[15px] leading-snug" className="max-w-none" /> diff --git a/apps/web/lib/build-prompt.ts b/apps/web/lib/build-prompt.ts index 7457731..3ca8245 100644 --- a/apps/web/lib/build-prompt.ts +++ b/apps/web/lib/build-prompt.ts @@ -27,8 +27,9 @@ Build these parts: a merchant and an expiry. Show an approval screen in my app where the user picks a card and approves. Run OrderIntentVerification when the card rail needs it. 3. Agent Checkouts. Start a run at a product URL with a max cost and stream its - messages. Answer its form requests. Pay its payment step with an agent card for - the exact amount. Collect store passwords with CrossmintProtectedInput. + messages. Answer each form request with all its fields in one answer. Pay its + payment step with an agent card for the exact amount. Collect each protected + field, such as a store password, with CrossmintProtectedInput. 4. Buyer details. Save the user's name, contact and shipping address as a buyer profile, so checkouts do not stop to ask for them. 5. Agent tools. Give my agent tools to list saved cards, request an agent card, diff --git a/apps/web/lib/chat/prompt.ts b/apps/web/lib/chat/prompt.ts index a1fb142..11ae27f 100644 --- a/apps/web/lib/chat/prompt.ts +++ b/apps/web/lib/chat/prompt.ts @@ -30,16 +30,16 @@ export function systemPrompt(opts: { userEmail?: string; buyerProfile?: BuyerPro "1. Call create_checkout with the product URL, a task describing what to buy, a purpose and an action. The purpose is what the purchase is, in a few words the user sees when they approve the payment, such as Blue Pikachu erasable pen or Dinner for 2 at Nopa; never instructions or their details. The action is what you are doing, starting with a verb, for the site card, such as Buying a pouch of Sweet Fish, Booking a table for 2 at Nopa, or Getting tickets for a show in Madrid. Pass the agentCardId from the payment choice or from await_budget (step 0), or payWithoutCard. If create_checkout comes back with budget_required, you skipped step 0: follow its next. Do not pass a maxCost unless the user gave a spending limit, or the payment choice gave a budget for Shop Pay or another way; the store states the exact total at the payment step.", "2. Call watch_checkout with the checkoutId straight away, with no text in between. While it runs, the chat posts each update from the store's agent to the user on its own. Do not repeat those updates.", "3. When watch_checkout returns awaiting_input, the store has a question. If what you know about the user (below) answers it, such as their email or saved address, answer it yourself with answer_checkout and call watch_checkout again, without asking. Otherwise ask it the way a friend helping them shop would: one short, casual line. Do not list every option; name one or two only when that helps. Never show field names, ids or the schema. Then stop and wait.", - "4. When the user replies, turn what they said into values that fit the question's responseSchema, using its exact option values, and call answer_checkout with the checkoutId and requestId. If nothing fits, send action alternative with their words as text; if they want to skip, action decline; if you cannot tell what they mean, ask once more. Then call watch_checkout again, with no text in between.", - "If a question carries a note, follow the note over these steps: it says when a store asks for a password in a way that is not safe.", - "When watch_checkout returns awaiting_password, the store asks for the password of the user's account there. Call await_protected_input with the checkoutId and password.requestId straight away, with no text in between: the chat asks the user and gives them a secure field, and the app answers the store itself. Never ask for a password in words. If the user writes one in the chat anyway, do not repeat it or send it anywhere, in answer_checkout or otherwise; tell them to use the secure field instead. When await_protected_input returns submitted, call watch_checkout again. If it returns declined, ask in one line whether to check out as a guest instead (answer_checkout with action alternative) or stop.", + "4. When the user replies, turn what they said into an answer for each of the question's fields, keyed by its key, using a choice's exact option values, and call answer_checkout with the checkoutId and requestId. If nothing fits, send action alternative with their words as text; if they want to skip, action decline; if you cannot tell what they mean, ask once more. Then call watch_checkout again, with no text in between.", + "If a question carries a note, follow the note over these steps: it says when a store asks for a password or card details in a way that is not safe.", + "When watch_checkout returns awaiting_protected_input, the store asks for secrets, such as the password of the user's account there or a one-time code. Call await_protected_input with the checkoutId and protectedInput.requestId straight away, with no text in between: the chat asks the user and gives them a secure field for each secret, and the app answers the store itself. Never ask for a password or another secret in words. If the user writes one in the chat anyway, do not repeat it or send it anywhere, in answer_checkout or otherwise; tell them to use the secure field instead. When await_protected_input returns submitted, call watch_checkout again. If it returns declined, ask in one line whether to check out as a guest instead (answer_checkout with action alternative) or stop.", "5. When watch_checkout returns awaiting_payment, the run is at its payment step. Call list_agent_cards. A card fits when it is active, has at least payment.amount left in that currency, and is not locked to another store (a card with a merchant pays only at that store). If a fitting card is a budget whose purpose covers this purchase (a Clothing budget covers socks, Groceries covers milk), pay from it straight away with pay_checkout_with_agent_card, with no question, then call watch_checkout; say in a few words which budget paid. If a card fits but its purpose does not clearly cover this purchase, ask in one line whether to pay with it (say what it is for and what is left) or set up a new card, then stop. If none fits, call await_agent_card_approval with payment.requestId straight away, with no text in between.", "6. If they pick an existing card, call pay_checkout_with_agent_card, then watch_checkout. If they want a new one, call await_agent_card_approval with payment.requestId; once it comes back active, call watch_checkout. If they deny it, ask what they want to do: another card, or cancel.", "7. When watch_checkout returns succeeded, call show_receipt with the checkoutId, what kind of checkout it was, the merchant, and what the store reported: the items with their amounts for a purchase or food, or a title and details (date, time, party, seats, venue) for a booking. Pass as its message one short, happy sentence on how it went, and write no other text in that turn. The chat shows the receipt with the total, the order number and the card, so do not repeat them. If show_receipt returns an error, say in one or two sentences what was bought, the total and the order number. For any other final status, say in one or two sentences why it stopped and what they could try.", "8. Never poll get_checkout and never send card fields. Use cancel_checkout if the user asks to stop.", "", "What you can do: buy from any online store, book a table, book flights, buy tickets for events and experiences. Agent Checkouts works on any website, not only shops.", - "Signing in to the user's own accounts: a checkout can sign in to the user's account at a store (Amazon, Target, an airline) and buy with it, with their saved addresses and order history. Do it when they ask; never say you cannot use their account. Start the checkout at the store's own site, and put in the task which account to sign in with, in their words, e.g. 'Sign in to Amazon with the account jane@example.com, then find and buy a well-rated pack of toilet paper.' The store's agent signs in inside Crossmint's browser, and a store the user signed into once stays signed in on later checkouts. When it needs their password, the chat gives them a secure field (awaiting_password); you never ask for it.", + "Signing in to the user's own accounts: a checkout can sign in to the user's account at a store (Amazon, Target, an airline) and buy with it, with their saved addresses and order history. Do it when they ask; never say you cannot use their account. Start the checkout at the store's own site, and put in the task which account to sign in with, in their words, e.g. 'Sign in to Amazon with the account jane@example.com, then find and buy a well-rated pack of toilet paper.' The store's agent signs in inside Crossmint's browser, and a store the user signed into once stays signed in on later checkouts. When it needs their password, the chat gives them a secure field (awaiting_protected_input); you never ask for it.", "- A named store with no product link (toilet paper on Amazon): start the checkout at that store's home page, with a task to find the item there and what matters (size, count, price). Do not search other stores instead.", "", "The ways a new chat offers to start, and the requests like them:", diff --git a/apps/web/lib/chat/tools.ts b/apps/web/lib/chat/tools.ts index 6822c59..5308386 100644 --- a/apps/web/lib/chat/tools.ts +++ b/apps/web/lib/chat/tools.ts @@ -95,7 +95,7 @@ export const savedCardOutcomeSchema = z.object({ }); export type SavedCardOutcome = z.infer; -/** What `await_protected_input` hands back: whether the user gave the password. Never the password, never its id. */ +/** What `await_protected_input` hands back: whether the user answered. Never a secret, never its id. */ export const protectedInputOutcomeSchema = z.object({ status: z.enum(["submitted", "declined"]), }); @@ -176,20 +176,30 @@ export const checkoutOutcomeSchema = z.object({ "cancelled", "awaiting_input", "awaiting_payment", - "awaiting_password", + "awaiting_protected_input", ]), updates: z.array(checkoutUpdateSchema), /** When the chat started and stopped following this stretch, ISO 8601: its card shows how long it took. */ startedAt: z.string().optional(), endedAt: z.string().optional(), - /** On `awaiting_input`: what the store asks, and the JSON Schema the answer must fit. */ + /** + * On `awaiting_input`: what the store asks, and the fields to answer, each + * by its key: text, a number, true or false, or a choice's option values. + */ question: z .object({ requestId: z.string(), question: z.string(), expiresAt: z.string().optional(), - responseSchema: z.record(z.string(), z.unknown()), - /** Set when the question asks for a password in a plain form, which is never answered with values. */ + fields: z.array( + z.object({ + key: z.string(), + label: z.string(), + required: z.boolean(), + input: z.record(z.string(), z.unknown()), + }), + ), + /** Set when the question asks for a password in a plain field, or card details in a form: never answered with values. */ note: z.string().optional(), }) .optional(), @@ -208,14 +218,16 @@ export const checkoutOutcomeSchema = z.object({ }) .optional(), /** - * On `awaiting_password`: the store asks for the password of the user's - * account there. `requestId` is what await_protected_input takes. The - * password itself never reaches the chat. + * On `awaiting_protected_input`: the store asks for secrets, such as the + * password of the user's account there, named by label in `secrets`. + * `requestId` is what await_protected_input takes. The secrets never reach + * the chat. */ - password: z + protectedInput: z .object({ requestId: z.string(), question: z.string(), + secrets: z.array(z.string()), domain: z.string().optional(), }) .optional(), @@ -553,9 +565,9 @@ export function createChatTools(api: AgentCommerceClient, opts: { userEmail?: st outputSchema: savedCardOutcomeSchema, }), - // Client-side tool: no `execute`. The chat shows Crossmint's password - // field; the app answers the run with what it returns, and the model - // only hears whether the user did. + // Client-side tool: no `execute`. The chat shows the question with + // Crossmint's protected fields; the app answers the run with what they + // return, and the model only hears whether the user did. await_protected_input: tool({ description: describeTool("await_protected_input"), inputSchema: z.object({ @@ -1088,8 +1100,9 @@ export function createChatTools(api: AgentCommerceClient, opts: { userEmail?: st .enum(["submit", "decline", "alternative"]) .optional() .describe(paramDoc("answer_checkout", "action")), + // Plain answers only: a protected field is answered in the app, never by the model. values: z - .record(z.string(), z.unknown()) + .record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])) .optional() .describe(paramDoc("answer_checkout", "values")), text: z.string().max(20000).optional().describe(paramDoc("answer_checkout", "text")), diff --git a/apps/web/package.json b/apps/web/package.json index 09a7ed8..d0f6baf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,7 +23,7 @@ "@ai-sdk/anthropic": "^4.0.0", "@ai-sdk/openai": "^4.0.66", "@ai-sdk/react": "^4.0.0", - "@crossmint/client-sdk-react-ui": "^4.8.0", + "@crossmint/client-sdk-react-ui": "^4.9.0", "@modelcontextprotocol/sdk": "^1.30.0", "@stytch/nextjs": "^22.3.0", "@stytch/vanilla-js": "^6.3.0", diff --git a/apps/web/public/skill.md b/apps/web/public/skill.md index 91fd8d7..6f45eef 100644 --- a/apps/web/public/skill.md +++ b/apps/web/public/skill.md @@ -42,9 +42,9 @@ Exit codes while waiting: - Exit 0: succeeded. The output shows the total and the order id. Report both to the user. - Exit 2, **payment step**: see step 2. -- Exit 2, **password needed**: the store asks for the password of the user's account there. The output prints a URL: show it to the user, who types the password into a secure field on that page. Never ask for the password, never pass it to the CLI, and never put it in `--values`, `--alternative` or a message; the run refuses it anyway. Then run `agent-commerce checkout get --wait`. If the user would rather not sign in, `--decline` the request or send `--alternative "check out as a guest"`. -- Exit 2, **password asked in a form**: the store wants the user to sign in but asks in a plain form, which is never filled in. Do not ask for the password. Offer the printed guest-checkout `--alternative`, or `--decline`. -- Exit 2: the agent asked a question (shipping address, size, a confirmation). The output shows the question, lists the fields, marks required ones with `*`, and prints a ready `agent-commerce checkout answer` command with a values template. Fill it in from what you know; ask the user for anything you do not know. Answer before the printed expiry or the checkout fails. Then run: +- Exit 2, **secure input needed**: the store asks for a secret, such as the password of the user's account there or a one-time code. The output names what it asks for and prints a URL: show it to the user, who answers the whole question on that page, with each secret in a secure field. Never ask for a secret, never pass it to the CLI, and never put it in `--values`, `--alternative` or a message; the run refuses it anyway. Then run `agent-commerce checkout get --wait`. If the user would rather not, `--decline` the request or send `--alternative "check out as a guest"`. +- Exit 2, **password asked in a form**: the store wants the user to sign in but asks for the password in a plain field, which is never filled in. Do not ask for the password. Offer the printed guest-checkout `--alternative`, or `--decline`. +- Exit 2: the agent asked a question (shipping address, size, a confirmation). The output shows the question, lists the fields by key with their options, marks required ones with `*`, and prints a ready `agent-commerce checkout answer` command with a values template. Answer every field in one command, using the option values exactly. Fill it in from what you know; ask the user for anything you do not know. Answer before the printed expiry or the checkout fails. Then run: ```sh agent-commerce checkout answer --values '{"fullName":"Ada Lovelace","country":"US"}' --wait @@ -60,7 +60,7 @@ agent-commerce checkout cancel # if the user changes their mind Shipping details repeat across purchases. Create a buyer profile once with `agent-commerce buyer-profile create --json-file profile.json` and pass `--buyer-profile ` to `checkout create`. -Merchant logins are kept for you. A store the user signed into on an earlier checkout has them signed in on the next one, so do not ask for merchant passwords and do not pass `--browser-profile`. When a store does need one, the run asks for it itself (exit 2, **password needed**, above). If a run is stuck on a stale or half-finished login, retry it once with `--fresh-browser` and let the user sign in again. +Merchant logins are kept for you. A store the user signed into on an earlier checkout has them signed in on the next one, so do not ask for merchant passwords and do not pass `--browser-profile`. When a store does need one, the run asks for it itself (exit 2, **secure input needed**, above). If a run is stuck on a stale or half-finished login, retry it once with `--fresh-browser` and let the user sign in again. Check on any checkout later with `agent-commerce checkout get --wait`. @@ -134,7 +134,7 @@ agent-commerce logout | ---- | -------------- | ------------------------------------------------------ | | 0 | ok | continue | | 1 | error | read stderr, tell the user | -| 2 | needs the user | show the payment step, password or approval URL, or answer the checkout question | +| 2 | needs the user | show the payment step, secure input or approval URL, or answer the checkout question | | 3 | not logged in | ask the user to run `agent-commerce login` | Errors go to stderr. With `--json`, errors are `{ "error": { "code", "message" } }`. diff --git a/docs/API.md b/docs/API.md index 5f5f7fc..5fcf699 100644 --- a/docs/API.md +++ b/docs/API.md @@ -168,10 +168,17 @@ interface CheckoutView { messageId?: string; question: string; expiresAt?: string; // answer before this or the run fails with input_expired - responseSchema: JsonSchema; - uiSchema?: object; + fields: CheckoutField[]; // core: { key, label, required, handling: "standard" | "protected", input } }; rendered?: RenderedAction; // from renderPendingAction, for UIs + protectedRequest?: { // the open form has protected fields; only the app answers it + requestId: string; + question: string; + fields: Array<{ key: string; label: string }>; // the protected ones: "Password" + merchantDomain?: string; // the checkout's store, from its start URL + expiresAt?: string; + url: string; // the checkout's page in the app, with the secure fields + }; embedUrl?: string; // absolute URL for a view-only iframe of the agent's browser result?: { outcome; summary; code?; purchase? }; // on succeeded, blocked, cancelled receipt?: { total: { amount; currency }; merchantOrderId? }; // on succeeded, when captured @@ -183,21 +190,24 @@ interface CheckoutView { `GET /v1/checkouts/:id` → `CheckoutView`. Poll it about every 1.5s; there are no webhooks. -While polling, when the run reaches its **payment step** (a payment input request, `interaction.kind: "payment"`, stating the amount and the merchant's domain) the server never passes it on. It answers it with the id of an order intent (an agent card), as Agent Checkouts requires: `input_response` with `response: { kind: "payment", orderIntentId }`. No card number is ever sent; the checkout mints the credential itself. Which order intent depends on the checkout: +While polling, when the run reaches its **payment step** (a payment input request, `interaction.kind: "payment"`, stating the amount and the merchant) the server never passes it on. It answers it with the id of an order intent (an agent card), as Agent Checkouts requires: `input_response` with `response: { kind: "payment", orderIntentId }`. No card number is ever sent; the checkout mints the credential itself. Which order intent depends on the checkout: - **With an `agentCardId`** (passed to `POST /v1/checkouts`, set with `POST /v1/checkouts/:id/agent-card`, or made earlier in this run): the server answers with it before returning. The view reports `running` while that is in flight. - **Without one**: the server creates an agent card request for the run as Agent Checkouts wants the order intent: the **exact amount** the payment request states, **no merchant** (the checkout binds the credential to the store itself), and an expiry of **two hours**. It returns it as `paymentRequest`, and the status stays `awaiting_input`. The user answers it through the ordinary `POST /v1/agent-card-requests/:id/approve`, or at `approvalUrl`; once the card is active, the next read answers the run with it. The request is reused on every read while it fits; when the run asks again after an answer (the card expired, was too small, or was cancelled), the server raises a fresh one and does not offer the card that failed. +A form's fields are typed. Each has a `key`, a `label`, `required`, an `input` (`text`, `number`, `integer`, `boolean`, or `choice` of one or many options) and a `handling`. A `standard` field is answered with a plain value. A `protected` field holds a secret, such as the password of the user's account at the store or a one-time code. When a form has one, the view carries `protectedRequest`: show the user its `url`. On that page each secret is typed into Crossmint's protected field (`CrossmintProtectedInput` in `@crossmint/client-sdk-react-ui`). Crossmint stores it and returns `{ protectedInputId }`, and the page sends the whole form at once. An agent never answers such a form, and never asks for a secret; it may decline it or send an alternative. + `POST /v1/checkouts/:id/messages` body, one of: ```json { "requestId": "…", "values": { "fullName": "Ada Lovelace" } } // submit the form (action defaults to "submit") +{ "requestId": "…", "values": { "email": "ada@example.com", "password": { "protectedInputId": "…" } } } // a form with a protected field { "requestId": "…", "action": "decline" } // refuse the request { "requestId": "…", "action": "alternative", "text": "cheapest shipping" } { "text": "prefer the blue one if the black is out" } // a note to the agent, no request ``` -Optional `messageId` (≤200 chars) makes a retry idempotent. → `CheckoutView`. If the `requestId` names a payment request: `409 payment_handled_by_server`. If it names a plain form that asks for card details, a submit fails with `409 card_in_form`: decline it or send an alternative. Only a payment request (`interaction.kind: "payment"`) is answered with an order intent, and only one with a live rail whose `credentialFormats` include `"card"`. +`values` holds every field's answer, keyed by its `key`: a string, a number, `true` or `false`, a choice's option value, or a list of option values for a choice of many. A protected field takes only `{ "protectedInputId": "…" }`. Optional `messageId` (≤200 chars) makes a retry idempotent: when no reply came back, send the same answer with the same `messageId`. → `CheckoutView`. If a protected field is answered with a plain value, or a required one is missing: `409 protected_input_required`. If a standard text field looks like a password (masked, a password autocomplete, or a password name): `409 password_in_form`, and the view has no `rendered`; decline it or send an alternative. If the `requestId` names a payment request: `409 payment_handled_by_server`. If it names a plain form that asks for card details, a submit fails with `409 card_in_form`: decline it or send an alternative. Only a payment request (`interaction.kind: "payment"`) is answered with an order intent, and only one with a live rail whose `credentialFormats` include `"card"`. `GET /v1/checkouts/:id/messages?cursor&limit` → Crossmint's message list (progress, activity, input requests, result, and what was sent) as is. Its `streamCursor` is where the stream below picks up. diff --git a/packages/cli/src/commands/checkout.ts b/packages/cli/src/commands/checkout.ts index b27140d..de3198f 100644 --- a/packages/cli/src/commands/checkout.ts +++ b/packages/cli/src/commands/checkout.ts @@ -240,7 +240,7 @@ async function waitForCheckout( isTerminalCheckout(v) || pendingAction(v) !== undefined || v.paymentRequest !== undefined || - v.passwordRequest !== undefined || + v.protectedRequest !== undefined || passwordInForm(v) !== undefined; if (done(initial)) return initial; let last = initial; @@ -276,7 +276,7 @@ function passwordInForm(view: CheckoutView): PendingUserAction | undefined { function report(ctx: CliContext, view: CheckoutView, json: boolean | undefined): void { const action = pendingAction(view); const payment = view.paymentRequest; - const password = view.passwordRequest; + const secure = view.protectedRequest; const formPassword = passwordInForm(view); if (json) { ctx.out(toJson(view)); @@ -293,15 +293,16 @@ function report(ctx: CliContext, view: CheckoutView, json: boolean | undefined): ` ${pc.dim(`It mints an agent card for up to ${payment.amount.value} ${payment.amount.currency}. Then run: agent-commerce checkout get ${view.id} --wait`)}`, ); } - if (password) { + if (secure) { // A secret never goes through the CLI: the user types it in a browser. + const labels = secure.fields.map((f) => f.label).join(", "); ctx.out(""); ctx.out( - ` ${pc.bold("Password needed.")} ${password.merchantDomain ?? "The store"} asks for the password of your account there. Open this to type it into a secure field:`, + ` ${pc.bold("Secure input needed.")} ${secure.merchantDomain ?? "The store"} asks for ${labels}. Open this to type each into a secure field:`, ); - ctx.out(` ${password.url}`); + ctx.out(` ${secure.url}`); ctx.out( - ` ${pc.dim(`Never pass the password to this CLI. Then run: agent-commerce checkout get ${view.id} --wait`)}`, + ` ${pc.dim(`Never pass a secret to this CLI. Then run: agent-commerce checkout get ${view.id} --wait`)}`, ); } if (formPassword) { @@ -327,11 +328,14 @@ function report(ctx: CliContext, view: CheckoutView, json: boolean | undefined): "password_in_form", ); } - if (password && !action) { + if (secure && !action) { + const labels = secure.fields.map((f) => f.label).join(", "); throw new CliExit( EXIT.NEEDS_USER_ACTION, - json ? `Checkout ${view.id} is waiting for the user's password at ${password.url}.` : "", - "password_needed", + json + ? `Checkout ${view.id} is waiting for secure input (${labels}): the user types it at ${secure.url}. Never ask for it.` + : "", + "protected_input_needed", ); } if (payment && !action) { diff --git a/packages/cli/src/commands/shared.ts b/packages/cli/src/commands/shared.ts index 4f2fba3..969021f 100644 --- a/packages/cli/src/commands/shared.ts +++ b/packages/cli/src/commands/shared.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import type { Merchant, PendingUserAction, RenderedAction, RenderedField } from "@agent-commerce/core"; -import { asksPasswordInForm, renderPendingAction } from "@agent-commerce/core"; +import { asksPasswordInForm, isProtectedAction, renderPendingAction } from "@agent-commerce/core"; import pc from "picocolors"; import { AgentCommerceApi } from "../api.js"; import { resolveConfig } from "../config.js"; @@ -78,10 +78,11 @@ export function parseJsonValues(raw: string): Record { /** The action a checkout is waiting on, rendered. Prefers the server's `rendered`. */ export function pendingAction(view: CheckoutView): RenderedAction | undefined { - if (view.rendered) return view.rendered; - // A password has no form: the user types it on the page passwordRequest links to. + // A form with secrets is never answered here, even though the server renders + // it for the app: the user types them on the page protectedRequest links to. const pending = view.pendingUserAction as PendingUserAction | undefined; - if (view.passwordRequest || pending?.protected) return undefined; + if (view.protectedRequest || (pending && isProtectedAction(pending))) return undefined; + if (view.rendered) return view.rendered; // Nor does a password asked for in a plain form: it is declined, never filled in. if (pending && asksPasswordInForm(pending)) return undefined; if (view.pendingUserAction) @@ -93,10 +94,9 @@ export function pendingAction(view: CheckoutView): RenderedAction | undefined { export function describeAction(checkoutId: string, action: RenderedAction): string[] { const lines: string[] = []; lines.push(`${pc.yellow("Action needed:")} ${pc.bold(action.title)} ${pc.dim(`(${action.id})`)}`); - if (action.description) lines.push(` ${action.description}`); if (action.expiresAt) lines.push(` ${pc.dim("Expires:")} ${action.expiresAt}`); lines.push(""); - for (const line of fieldLines(action.fields, " ")) lines.push(line); + for (const line of fieldLines(action.fields)) lines.push(line); lines.push(""); lines.push("Answer with:"); lines.push( @@ -106,30 +106,31 @@ export function describeAction(checkoutId: string, action: RenderedAction): stri return lines; } -function fieldLines(fields: RenderedField[], indent: string): string[] { - const out: string[] = []; - for (const f of fields) { +function fieldLines(fields: RenderedField[]): string[] { + return fields.map((f) => { const req = f.required ? pc.red("*") : " "; - let kind: string = f.kind; - if (f.kind === "select" && f.options) - kind = `one of: ${f.options.map((o) => JSON.stringify(o.value)).join(", ")}`; - const desc = f.description ? ` ${pc.dim(f.description)}` : ""; - out.push(`${indent}${f.name}${req} ${pc.dim(`(${kind})`)} ${f.label}${desc}`); - if (f.children?.length) out.push(...fieldLines(f.children, `${indent} `)); - } - return out; + const options = f.options?.filter((o) => !o.disabled).map((o) => JSON.stringify(o.value)); + let kind: string = f.kind === "number" && f.integer ? "whole number" : f.kind; + if (f.kind === "select" && options) kind = `one of: ${options.join(", ")}`; + if (f.kind === "multiselect" && options) { + const count = + f.max !== undefined ? `${f.min ?? 0} to ${f.max}` : f.min ? `at least ${f.min}` : "any"; + kind = `list of ${count} of: ${options.join(", ")}`; + } + return ` ${f.name}${req} ${pc.dim(`(${kind})`)} ${f.label}`; + }); } /** A `values` object with one placeholder per field. */ export function valuesTemplate(fields: RenderedField[]): Record { const out: Record = {}; for (const f of fields) { + const firstOption = f.options?.find((o) => !o.disabled)?.value; if (f.default !== undefined) out[f.name] = f.default; - else if (f.kind === "object" && f.children) out[f.name] = valuesTemplate(f.children); - else if (f.kind === "select" && f.options?.[0]) out[f.name] = f.options[0].value; + else if (f.kind === "select" && firstOption !== undefined) out[f.name] = firstOption; + else if (f.kind === "multiselect") out[f.name] = []; else if (f.kind === "boolean") out[f.name] = false; else if (f.kind === "number") out[f.name] = 0; - else if (f.kind === "array") out[f.name] = []; else out[f.name] = ""; } return out; diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index bf5f6c7..cae8b16 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -119,13 +119,16 @@ export interface CheckoutPaymentRequest { } /** - * The store asks for the password of the user's account there. The user - * types it at `url`, the checkout's page, into a secure field; nothing here - * ever carries the password. + * The open form has protected fields, such as the password of the user's + * account at the store or a one-time code. The user types each at `url`, the + * checkout's page, into a secure field; nothing here ever carries a secret. */ -export interface CheckoutPasswordRequest { +export interface CheckoutProtectedRequest { requestId: string; question: string; + /** The protected fields, by key and label: "Password". Never their values. */ + fields: Array<{ key: string; label: string }>; + /** The store the checkout runs on: "shop.example.com". */ merchantDomain?: string; expiresAt?: string; url: string; @@ -136,7 +139,7 @@ export interface CheckoutView { status: CheckoutStatus; agentCardId?: string; paymentRequest?: CheckoutPaymentRequest; - passwordRequest?: CheckoutPasswordRequest; + protectedRequest?: CheckoutProtectedRequest; pendingUserAction?: PendingUserAction; rendered?: RenderedAction; embedUrl?: string; diff --git a/packages/cli/test/checkout.test.ts b/packages/cli/test/checkout.test.ts index 49cd70f..fe4a2b3 100644 --- a/packages/cli/test/checkout.test.ts +++ b/packages/cli/test/checkout.test.ts @@ -19,14 +19,44 @@ describe("agent-commerce checkout", () => { id: "req_1", question: "Where should we ship this?", expiresAt: "2026-09-18T00:00:00.000Z", - responseSchema: { - type: "object", - required: ["fullName"], - properties: { - fullName: { type: "string" }, - country: { type: "string", enum: ["US", "GB"] }, + fields: [ + { + key: "fullName", + label: "Full name", + required: true, + handling: "standard", + input: { kind: "text", autoComplete: "name" }, }, - }, + { + key: "country", + label: "Country", + required: false, + handling: "standard", + input: { + kind: "choice", + selection: { kind: "one" }, + options: [ + { value: "US", label: "United States", disabled: false, selected: false, placeholder: false }, + { value: "GB", label: "United Kingdom", disabled: false, selected: false, placeholder: false }, + ], + }, + }, + { + key: "addOns", + label: "Add-ons", + required: false, + handling: "standard", + input: { + kind: "choice", + selection: { kind: "many", min: 0 }, + options: [ + { value: "wrap", label: "Gift wrap", disabled: false, selected: false, placeholder: false }, + ], + }, + }, + { key: "quantity", label: "Quantity", required: true, handling: "standard", input: { kind: "integer" } }, + { key: "gift", label: "Gift", required: false, handling: "standard", input: { kind: "boolean" } }, + ], }, }); }, @@ -61,8 +91,11 @@ describe("agent-commerce checkout", () => { const out = t.stdout.join("\n"); expect(out).toContain("Action needed: Where should we ship this?"); expect(out).toContain("fullName*"); + expect(out).toContain('(one of: "US", "GB") Country'); + expect(out).toContain('(list of any of: "wrap") Add-ons'); + expect(out).toContain("(whole number) Quantity"); expect(out).toContain( - `agent-commerce checkout answer run_1 req_1 --values '{"fullName":"","country":"US"}'`, + `agent-commerce checkout answer run_1 req_1 --values '{"fullName":"","country":"US","addOns":[],"quantity":0,"gift":false}'`, ); }); @@ -128,7 +161,14 @@ describe("agent-commerce checkout", () => { expect(t2.stderr.join("\n")).toContain("input_expired"); }); - it("get stops at a password request with the page to open, never a question to answer", async () => { + it("get stops at a protected request with the page to open, never a form to answer", async () => { + const passwordField = { + key: "password", + label: "Password", + required: true, + handling: "protected", + input: { kind: "text", display: "masked", autoComplete: "current-password" }, + }; const { fetch } = fakeFetch({ "GET /v1/checkouts/run_pw": () => json({ @@ -136,13 +176,28 @@ describe("agent-commerce checkout", () => { status: "awaiting_input", pendingUserAction: { id: "req_pw", - question: "Enter your password for shop.example to sign in.", - responseSchema: {}, - protected: { purpose: "password", merchant: { domain: "shop.example" } }, + question: "Sign in to shop.example to continue.", + fields: [passwordField], + }, + // The server renders the form for the app's own page, protected field included. + rendered: { + id: "req_pw", + type: "input_response", + title: "Sign in to shop.example to continue.", + fields: [ + { + name: "password", + label: "Password", + kind: "protected", + required: true, + protectedField: passwordField, + }, + ], }, - passwordRequest: { + protectedRequest: { requestId: "req_pw", - question: "Enter your password for shop.example to sign in.", + question: "Sign in to shop.example to continue.", + fields: [{ key: "password", label: "Password" }], merchantDomain: "shop.example", url: "https://wallet.test/checkouts/run_pw", }, @@ -153,14 +208,18 @@ describe("agent-commerce checkout", () => { EXIT.NEEDS_USER_ACTION, ); const out = t.stdout.join("\n"); - expect(out).toContain("Password needed."); + expect(out).toContain("Secure input needed."); + expect(out).toContain("shop.example asks for Password."); expect(out).toContain("https://wallet.test/checkouts/run_pw"); expect(out).not.toContain("Action needed"); + expect(out).not.toContain("--values"); const t2 = testContext({ fetch }); expect(await runCli(["checkout", "get", "run_pw", "--json"], t2.overrides)).toBe( EXIT.NEEDS_USER_ACTION, ); - expect(JSON.parse(t2.stderr.join("\n"))).toMatchObject({ error: { code: "password_needed" } }); + const error = JSON.parse(t2.stderr.join("\n")) as { error: { code: string; message: string } }; + expect(error.error.code).toBe("protected_input_needed"); + expect(error.error.message).toContain("https://wallet.test/checkouts/run_pw"); }); it("cancel posts to the cancel route", async () => { diff --git a/packages/core/src/checkout-messages.ts b/packages/core/src/checkout-messages.ts index 8082329..e44f7d9 100644 --- a/packages/core/src/checkout-messages.ts +++ b/packages/core/src/checkout-messages.ts @@ -1,5 +1,6 @@ import type { Checkout, + CheckoutFormAnswer, CheckoutMessage, CheckoutResult, InputResponsePart, @@ -20,8 +21,12 @@ export function newMessageId(): string { return `msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; } -export function submitResponse(requestId: string, values: Record): InputResponsePart { - return { type: "input_response", requestId, action: "submit", response: { kind: "form", values } }; +/** + * The answer to a form: every field in one message, keyed by field `key`. + * A protected field's answer is `{ protectedInputId }`, never the secret. + */ +export function submitResponse(requestId: string, answers: Record): InputResponsePart { + return { type: "input_response", requestId, action: "submit", response: { kind: "form", answers } }; } /** @@ -33,16 +38,6 @@ export function paymentResponse(requestId: string, orderIntentId: string): Input return { type: "input_response", requestId, action: "submit", response: { kind: "payment", orderIntentId } }; } -/** - * The answer to a protected input request, such as a password: the id of - * what the buyer typed into Crossmint's own field. The secret never passes - * through here; Agent Checkouts reads it from Crossmint's vault on the - * merchant's sign-in page alone. - */ -export function protectedResponse(requestId: string, protectedInputId: string): InputResponsePart { - return { type: "input_response", requestId, action: "submit", response: { kind: "protected", protectedInputId } }; -} - export function declineResponse(requestId: string): InputResponsePart { return { type: "input_response", requestId, action: "decline" }; } @@ -58,33 +53,12 @@ export function alternativeResponse(requestId: string, text: string): InputRespo export function pendingActionOf(checkout: Pick): PendingUserAction | undefined { const ra = checkout.requiredAction; if (!ra || checkout.status !== "awaiting_input") return undefined; - const req = ra.request; - const interaction = req.interaction; - return { - id: ra.requestId, - messageId: ra.messageId, - question: req.question, - expiresAt: req.expiresAt, - responseSchema: interaction?.responseSchema ?? {}, - uiSchema: interaction?.uiSchema, - ...(interaction?.kind === "payment" - ? { - payment: { - method: interaction.method ?? "card", - ...(interaction.amount ? { amount: interaction.amount } : {}), - ...(interaction.merchant ? { merchant: interaction.merchant } : {}), - }, - } - : {}), - ...(interaction?.kind === "protected" - ? { - protected: { - purpose: interaction.purpose ?? "password", - ...(interaction.merchant ? { merchant: interaction.merchant } : {}), - }, - } - : {}), - }; + const { question, expiresAt, interaction } = ra.request; + const action: PendingUserAction = { id: ra.requestId, messageId: ra.messageId, question, expiresAt, fields: [] }; + if (interaction.kind === "form") action.fields = interaction.fields; + else if (interaction.kind === "payment") + action.payment = { method: interaction.method, amount: interaction.amount, merchant: interaction.merchant }; + return action; } /** The receipt of a succeeded run, when the agent could read one. */ diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 29a2b91..59f8415 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1,6 +1,7 @@ -import { newMessageId, paymentResponse, protectedResponse, submitResponse } from "./checkout-messages.js"; +import { newMessageId, paymentResponse, submitResponse } from "./checkout-messages.js"; import { CrossmintApiError } from "./errors.js"; import type { + CheckoutFormAnswer, BrowserProfile, BrowserProfileInput, BrowserProfileList, @@ -275,17 +276,21 @@ export class CrossmintClient { { auth: this.checkoutAuth(ctx), body: input, baseUrl: this.checkoutsBaseUrl }, ), - /** Answer an open input request with form values. One message, one part. */ + /** + * Answer an open form with every field in one message, keyed by field + * `key`. A protected field's answer is the `{ protectedInputId }` that + * Crossmint's protected field returned. + */ respond: ( ctx: CheckoutContext, runId: string, requestId: string, - values: Record, + answers: Record, messageId?: string, ): Promise => this.checkouts.sendMessage(ctx, runId, { id: messageId ?? newMessageId(), - parts: [submitResponse(requestId, values)], + parts: [submitResponse(requestId, answers)], }), /** Answer the payment step with an order intent the user authorized. */ @@ -301,19 +306,6 @@ export class CrossmintClient { parts: [paymentResponse(requestId, orderIntentId)], }), - /** Answer a protected input request (a password) with the id Crossmint's field returned. */ - answerProtected: ( - ctx: CheckoutContext, - runId: string, - requestId: string, - protectedInputId: string, - messageId?: string, - ): Promise => - this.checkouts.sendMessage(ctx, runId, { - id: messageId ?? newMessageId(), - parts: [protectedResponse(requestId, protectedInputId)], - }), - cancel: (ctx: CheckoutContext, runId: string): Promise => this.request( "POST", diff --git a/packages/core/src/pending-action.ts b/packages/core/src/pending-action.ts index 4e92400..983e1c7 100644 --- a/packages/core/src/pending-action.ts +++ b/packages/core/src/pending-action.ts @@ -1,105 +1,101 @@ -import type { JsonSchema, PendingUserAction } from "./types.js"; +import type { CheckoutField, CheckoutProtectedField, CheckoutTextInput, PendingUserAction } from "./types.js"; -export type FieldKind = "text" | "number" | "boolean" | "select" | "email" | "url" | "date" | "object" | "array"; +/** + * `select`: one option value. `multiselect`: a list of option values. + * `protected`: a secret the buyer types into Crossmint's protected field; + * only the app's own UI answers it, never an agent. + */ +export type FieldKind = "text" | "number" | "boolean" | "select" | "multiselect" | "protected"; export interface RenderedField { + /** The field's `key`: what its answer is keyed by. */ name: string; - path: string[]; label: string; - description?: string; kind: FieldKind; required: boolean; - options?: Array<{ value: unknown; label: string }>; - default?: unknown; - format?: string; - pattern?: string; - children?: RenderedField[]; + /** On `select` and `multiselect`: the options that can be picked. Placeholder options are left out. */ + options?: Array<{ value: string; label: string; disabled?: boolean }>; + /** On `multiselect`: how many to pick. */ + min?: number; + max?: number; + /** On `number`: whole numbers only. */ + integer?: boolean; + /** On `text`: several lines. */ + multiline?: boolean; + /** On `text`: hide what is typed. */ + masked?: boolean; + placeholder?: string; + autoComplete?: CheckoutTextInput["autoComplete"]; + inputMode?: CheckoutTextInput["inputMode"]; + /** What the store preselected, when it did. */ + default?: string | string[]; + /** On `protected`: the descriptor to hand `CrossmintProtectedInput` as its `field`, unchanged. */ + protectedField?: CheckoutProtectedField; } export interface RenderedAction { id: string; type: string; title: string; - description?: string; expiresAt?: string; fields: RenderedField[]; } /** - * Walk an input request's JSON Schema into a flat, neutral field list. - * The UI renders inputs from it. The CLI prints prompts from it. Neither - * hardcodes field names, so shipping, sizes, and payment all render the same way. + * An input request's fields as a flat, neutral list. The UI renders inputs + * from it. The CLI prints prompts from it. Neither hardcodes field names, so + * shipping, sizes, and sign-in all render the same way. */ export function renderPendingAction(action: PendingUserAction): RenderedAction { - const schema = action.responseSchema ?? {}; return { id: action.id, type: "input_response", - title: action.question || schema.title || "Input needed", - description: action.question && schema.title ? schema.title : schema.description, + title: action.question || "Input needed", expiresAt: action.expiresAt, - fields: renderSchemaFields(schema), + fields: action.fields.map(renderField), }; } -export function renderSchemaFields(schema: JsonSchema, parentPath: string[] = []): RenderedField[] { - const props = schema.properties ?? {}; - const required = new Set(schema.required ?? []); - return Object.entries(props).map(([name, sub]) => renderField(name, sub, required.has(name), parentPath)); -} - -function renderField(name: string, schema: JsonSchema, required: boolean, parentPath: string[]): RenderedField { - const path = [...parentPath, name]; - const kind = fieldKind(schema); - const field: RenderedField = { - name, - path, - label: schema.title ?? humanize(name), - description: schema.description, - kind, - required, - default: schema.default, - format: schema.format, - pattern: schema.pattern, - }; - const options = enumOptions(schema); - if (options) field.options = options; - if (kind === "object") field.children = renderSchemaFields(schema, path); - return field; -} - -function fieldKind(schema: JsonSchema): FieldKind { - if (enumOptions(schema)) return "select"; - const type = Array.isArray(schema.type) ? schema.type.find((t) => t !== "null") : schema.type; - switch (type) { +export function renderField(field: CheckoutField): RenderedField { + const base = { name: field.key, label: field.label || humanize(field.key), required: field.required }; + if (field.handling === "protected") return { ...base, kind: "protected", protectedField: field }; + const input = field.input; + switch (input.kind) { case "boolean": - return "boolean"; + return { ...base, kind: "boolean" }; case "number": case "integer": - return "number"; - case "object": - return "object"; - case "array": - return "array"; + return { ...base, kind: "number", ...(input.kind === "integer" ? { integer: true } : {}) }; + case "choice": { + const options = input.options + .filter((o) => !o.placeholder) + .map((o) => ({ value: o.value, label: o.label, ...(o.disabled ? { disabled: true } : {}) })); + const selected = input.options.filter((o) => o.selected && !o.placeholder).map((o) => o.value); + if (input.selection.kind === "many") { + return { + ...base, + kind: "multiselect", + options, + min: input.selection.min, + ...(input.selection.max !== undefined ? { max: input.selection.max } : {}), + ...(selected.length ? { default: selected } : {}), + }; + } + return { ...base, kind: "select", options, ...(selected[0] !== undefined ? { default: selected[0] } : {}) }; + } default: - if (schema.format === "email") return "email"; - if (schema.format === "uri" || schema.format === "url") return "url"; - if (schema.format === "date" || schema.format === "date-time") return "date"; - return "text"; + return { + ...base, + kind: "text", + ...(input.multiline ? { multiline: true } : {}), + ...(input.display === "masked" ? { masked: true } : {}), + ...(input.placeholder ? { placeholder: input.placeholder } : {}), + ...(input.autoComplete ? { autoComplete: input.autoComplete } : {}), + ...(input.inputMode ? { inputMode: input.inputMode } : {}), + }; } } -function enumOptions(schema: JsonSchema): Array<{ value: unknown; label: string }> | undefined { - if (Array.isArray(schema.enum)) { - return schema.enum.map((value) => ({ value, label: typeof value === "string" ? humanize(value) : String(value) })); - } - const variants = schema.oneOf ?? schema.anyOf; - if (variants && variants.every((v) => "const" in v)) { - return variants.map((v) => ({ value: v.const, label: v.title ?? String(v.const) })); - } - return undefined; -} - export function humanize(key: string): string { return key .replace(/([a-z0-9])([A-Z])/g, "$1 $2") @@ -109,42 +105,48 @@ export function humanize(key: string): string { .replace(/^./, (c) => c.toUpperCase()); } +/** The fields the buyer types into Crossmint's protected field. */ +export function protectedFields(action: Pick): CheckoutProtectedField[] { + return action.fields.filter((f): f is CheckoutProtectedField => f.handling === "protected"); +} + /** - * Is this the checkout's payment step? A payment input request says so - * (`interaction.kind: "payment"`); the server answers it with an order - * intent, and no caller ever sees it. A form asking for card fields counts - * too, so it never reaches a caller either: decided by the field names, never - * by the question text alone, since "which card" as a choice is not a - * payment form. + * True when the form has a protected field, such as the password of the + * buyer's account at the store. Only the app's own UI answers it: the buyer + * types each secret into Crossmint's protected field, and the app sends the + * whole form with the ids those fields return. An agent never answers it, + * though it may decline it or suggest another way. */ -/** Field names and titles that ask for a password, in the languages stores ask in. */ +export function isProtectedAction(action: Pick): boolean { + return protectedFields(action).length > 0; +} + +/** Field names and labels that ask for a password, in the languages stores ask in. */ const PASSWORD_FIELD = /pass(word|code|phrase)|passwd|\bpwd\b|contraseña|mot de passe|kennwort|passwort|senha/i; /** - * True when a plain form asks for a password: a store agent that could not - * raise a protected request (the project has protected inputs off) asks in a - * form instead. It is never answered with values: Agent Checkouts does not - * fill a password from them, and the secret would pass through the app and - * the agent. Decline it, or suggest another way, such as a guest checkout. + * True when a standard field, one the caller answers with a plain value, + * looks like a password: masked, a password autocomplete, or a password + * name or label. Agent Checkouts asks for a password as a protected field; + * this guards against one that slips through as plain text. It is never + * answered with values, since the secret would pass through the app and the + * agent. Decline it, or suggest another way, such as a guest checkout. */ -export function asksPasswordInForm(action: Pick): boolean { - if (action.protected) return false; - const props = (action.responseSchema?.properties ?? {}) as Record< - string, - { type?: unknown; title?: unknown; enum?: unknown; oneOf?: unknown; anyOf?: unknown } - >; - return Object.entries(props).some(([key, field]) => { +export function asksPasswordInForm(action: Pick): boolean { + return action.fields.some((field) => { // A choice ("password or passkey?") is fine to answer; only free text can hold the secret. - const freeText = field?.type === "string" && !field.enum && !field.oneOf && !field.anyOf; - return freeText && (PASSWORD_FIELD.test(key) || PASSWORD_FIELD.test(String(field.title ?? ""))); + if (field.handling !== "standard" || field.input.kind !== "text") return false; + const { display, autoComplete } = field.input; + return ( + display === "masked" || + autoComplete === "current-password" || + autoComplete === "new-password" || + PASSWORD_FIELD.test(field.key) || + PASSWORD_FIELD.test(field.label) + ); }); } -/** True when the store asks for a secret the buyer types into Crossmint's protected field, never into a form. */ -export function isProtectedAction(action: Pick): boolean { - return Boolean(action.protected); -} - /** * True for the run's payment request (`interaction.kind === "payment"`): the * one answered with an order intent, never with card fields. @@ -158,13 +160,13 @@ export function isPaymentRequest(action: Pick): bo * is never answered with them, and never with an order intent: that answer * does not fit a form. Decline it, or ask for another way to pay. */ -export function asksCardInForm(action: Pick): boolean { +export function asksCardInForm(action: Pick): boolean { return !action.payment && isPaymentAction(action); } /** True for the payment request, or a form that asks for card details. Card fields never answer either. */ -export function isPaymentAction(action: Pick): boolean { +export function isPaymentAction(action: Pick): boolean { if (action.payment) return true; - const keys = Object.keys(action.responseSchema?.properties ?? {}).map((k) => k.toLowerCase()); + const keys = action.fields.map((f) => f.key.toLowerCase()); return keys.some((k) => /cardnumber|card_number|^pan$|^number$|cvc|cvv|securitycode|security_code|expir/.test(k)); } diff --git a/packages/core/src/tool-docs.ts b/packages/core/src/tool-docs.ts index 87aa7b0..9f589c1 100644 --- a/packages/core/src/tool-docs.ts +++ b/packages/core/src/tool-docs.ts @@ -154,7 +154,7 @@ export const TOOL_DOCS = { "Follow a running checkout for the user. While it runs, the chat shows the user each update the store's agent writes, as its own message; you do not repeat them. " + "Call it straight after create_checkout, and again after each answer you send; never poll get_checkout meanwhile. It returns with the updates it showed and one of four reasons. " + "awaiting_input with a question: the store asks something, and you put it to the user. awaiting_payment with a payment: the run reached its payment step, and the user chooses how to pay. " + - "awaiting_password with a password: the store asks for the password of the user's account there; call await_protected_input, and never ask for the password in words. " + + "awaiting_protected_input with a protectedInput: the store asks for a secret, such as the password of the user's account there or a one-time code; call await_protected_input, and never ask for the secret in words. " + "A final status (succeeded, blocked, failed, cancelled): the run ended, with the total and the order number, or why it stopped.", params: { checkoutId: PARAM_DOCS.checkoutId }, surfaces: ["chat"], @@ -207,13 +207,13 @@ export const TOOL_DOCS = { surfaces: ["chat"], }, await_protected_input: { - title: "Wait for a password", + title: "Wait for a secret", summary: - "When a checkout asks for the password of the user's account at the store, show the user a secure field for it and wait. The password goes straight to Crossmint's vault: neither you nor the chat ever sees it, and the app answers the checkout itself. " + - "Call it straight after watch_checkout returns awaiting_password, with no text in between; the chat asks the user. It returns submitted, then call watch_checkout again, or declined, when the user would rather not sign in: ask whether to check out as a guest (answer_checkout with action alternative) or stop.", + "When a checkout asks for a secret, such as the password of the user's account at the store or a one-time code, show the user the question with a secure field for each secret, and wait. Each secret goes straight to Crossmint's vault: neither you nor the chat ever sees it, and the app answers the checkout itself, with the other fields of the same question. " + + "Call it straight after watch_checkout returns awaiting_protected_input, with no text in between; the chat asks the user. It returns submitted, then call watch_checkout again, or declined, when the user would rather not: ask whether to check out as a guest (answer_checkout with action alternative) or stop.", params: { checkoutId: PARAM_DOCS.checkoutId, - requestId: "The password request's requestId, from watch_checkout's password.", + requestId: "The request's requestId, from watch_checkout's protectedInput.", }, surfaces: ["chat"], }, @@ -292,14 +292,14 @@ export const TOOL_DOCS = { answer_checkout: { title: "Answer a checkout question", summary: - "Answer the open question on a checkout. Pass requestId with values keyed by field name (as listed by get_checkout) to submit, action decline to refuse it, or action alternative with text to suggest another way (e.g. use the cheapest shipping). " + + "Answer the open question on a checkout. Pass requestId with values keyed by field key (as listed by get_checkout), with every field in one answer, to submit; action decline to refuse it; or action alternative with text to suggest another way (e.g. use the cheapest shipping). " + "Without requestId, text is a note to the agent mid-run. Never send card fields: the payment step is answered from the agent card the user picks. " + - "Never send a password, and never ask the user for one: a request for the password of their account at the store (pendingUserAction.protected) is answered by the user in a secure field, on the checkout's page in the app. You may decline it, or send an alternative such as checking out as a guest.", + "Never send a password or another secret, and never ask the user for one: a question with a protected field (handling protected, such as the password of their account at the store) is answered by the user in a secure field, on the checkout's page in the app. You may decline it, or send an alternative such as checking out as a guest.", params: { checkoutId: PARAM_DOCS.checkoutId, requestId: "The pending request id from get_checkout.", action: "submit (default), decline, or alternative.", - values: "Field values keyed by field name, for submit.", + values: "For submit: an answer for each field, keyed by field key. Text as a string, a number, true or false, a choice's option value, or a list of option values for a choice of many.", text: "Free text: the alternative, or a note for the agent.", }, surfaces: ["mcp", "chat"], diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e4b11fc..04cfe60 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -322,27 +322,6 @@ export interface CreateCheckoutInput { merchantGuidance?: string; } -/** A JSON Schema object. Kept loose on purpose; Agent Commerce walks `.properties`. */ -export interface JsonSchema { - type?: string | string[]; - title?: string; - description?: string; - properties?: Record; - required?: string[]; - enum?: unknown[]; - oneOf?: JsonSchema[]; - anyOf?: JsonSchema[]; - items?: JsonSchema; - format?: string; - default?: unknown; - minimum?: number; - maximum?: number; - minLength?: number; - maxLength?: number; - pattern?: string; - [key: string]: unknown; -} - /** What a payment input request asks the caller to authorize. */ export interface CheckoutPaymentAmount { /** `exact` is the verified payable total; `maximum` is the run's cost ceiling. */ @@ -352,28 +331,106 @@ export interface CheckoutPaymentAmount { currency: string; } -export interface CheckoutInteraction { - /** - * `form`: answer with values that fit `responseSchema`. `payment`: the - * payment step; answer with an order intent (`paymentResponse`), never - * with card details. `protected`: a secret such as a password; the buyer - * types it into Crossmint's own field, and the answer is only the id of - * what they typed (`protectedResponse`). The request carries no secret. - */ - kind: "form" | "payment" | "protected" | (string & {}); - /** On a form. */ - responseSchema?: JsonSchema; - uiSchema?: Record; - /** On a payment request, e.g. "checkout_payment". On a protected one, what the secret is: "password". */ - purpose?: string; - /** On a payment request: what the checkout accepts. Today always "card". */ - method?: "card" | (string & {}); - /** On a payment request: the total to authorize, which the order intent must match. */ - amount?: CheckoutPaymentAmount; - /** On a payment or protected request: the merchant the credential or secret is bound to. */ - merchant?: { domain: string }; +/** A free-text field. `display: "masked"` hides what is typed. */ +export type CheckoutTextInput = { + kind: "text"; + multiline?: boolean; + placeholder?: string; + display?: "masked"; + /** An HTML autocomplete token. */ + autoComplete?: + | "on" + | "off" + | "name" + | "given-name" + | "family-name" + | "email" + | "username" + | "tel" + | "current-password" + | "new-password" + | "one-time-code" + | "street-address" + | "postal-code"; + /** An HTML inputmode. */ + inputMode?: "none" | "text" | "decimal" | "numeric" | "tel" | "search" | "email" | "url"; +}; + +/** One option of a choice. A `placeholder` option ("Select a size") is never an answer. */ +export type CheckoutChoiceOption = { + value: string; + label: string; + disabled: boolean; + selected: boolean; + placeholder: boolean; +}; + +export type CheckoutChoiceInput = { + kind: "choice"; + /** `one`: answer with one option value. `many`: answer with a list of them. */ + selection: { kind: "one" } | { kind: "many"; min: number; max?: number }; + options: CheckoutChoiceOption[]; +}; + +export type CheckoutFieldInput = + | CheckoutTextInput + | { kind: "boolean" } + | { kind: "number" } + | { kind: "integer" } + | CheckoutChoiceInput; + +/** A field the caller answers with a plain value. */ +export interface CheckoutStandardField { + /** The answer's key. */ + key: string; + label: string; + required: boolean; + handling: "standard"; + input: CheckoutFieldInput; +} + +/** + * A field holding a secret, such as a password or a one-time code. Never + * answered with a value: the buyer types it into Crossmint's protected field + * (`CrossmintProtectedInput`), and the answer is only the id it returns, + * `{ protectedInputId }`. The request carries no secret. + */ +export interface CheckoutProtectedField { + key: string; + label: string; + required: boolean; + handling: "protected"; + input: (CheckoutTextInput & { multiline?: false }) | { kind: "number" } | { kind: "integer" }; } +export type CheckoutField = CheckoutStandardField | CheckoutProtectedField; + +/** + * `form`: answer each field by its `key` (`submitResponse`). `payment`: the + * payment step; answer with an order intent (`paymentResponse`), never with + * card details. + */ +export type CheckoutInteraction = + | { kind: "form"; fields: CheckoutField[] } + | { + kind: "payment"; + /** "checkout_payment". */ + purpose: string; + /** What the checkout accepts. Today always "card". */ + method: "card" | (string & {}); + /** The total to authorize, which the order intent must match. */ + amount: CheckoutPaymentAmount; + /** The store the credential is bound to. */ + merchant: { name: string; url: string; countryCode: string }; + }; + +/** + * One form answer. A standard field takes a string, number, boolean or, for a + * choice of many, a list of option values. A protected field takes only the + * id Crossmint's protected field returned. + */ +export type CheckoutFormAnswer = string | number | boolean | string[] | { protectedInputId: string }; + /** What the agent is asking, as it appears in `requiredAction.request` and in `input_request` message parts. */ export interface CheckoutInputRequest { question: string; @@ -399,22 +456,13 @@ export interface PendingUserAction { messageId?: string; question: string; expiresAt?: string; - responseSchema: JsonSchema; - uiSchema?: Record; + /** The form's fields, in order. Empty on the payment step. */ + fields: CheckoutField[]; /** Set when this is the payment step: what to authorize, and where. */ payment?: { method: string; - amount?: CheckoutPaymentAmount; - merchant?: { domain: string }; - }; - /** - * Set when the store asks for a secret, such as the password of the - * buyer's account there. Never answer it with values: the buyer types it - * into Crossmint's protected field, which returns an id to answer with. - */ - protected?: { - purpose: "password" | (string & {}); - merchant?: { domain: string }; + amount: CheckoutPaymentAmount; + merchant: { name: string; url: string; countryCode: string }; }; } @@ -462,9 +510,8 @@ export interface CheckoutList { // result; the caller writes input responses and free text. export type InputResponsePart = - | { type: "input_response"; requestId: string; action: "submit"; response: { kind: "form"; values: Record } } + | { type: "input_response"; requestId: string; action: "submit"; response: { kind: "form"; answers: Record } } | { type: "input_response"; requestId: string; action: "submit"; response: { kind: "payment"; orderIntentId: string } } - | { type: "input_response"; requestId: string; action: "submit"; response: { kind: "protected"; protectedInputId: string } } | { type: "input_response"; requestId: string; action: "decline" } | { type: "input_response"; requestId: string; action: "alternative"; text: string }; diff --git a/packages/core/test/client.test.ts b/packages/core/test/client.test.ts index 64bac20..9f7b48e 100644 --- a/packages/core/test/client.test.ts +++ b/packages/core/test/client.test.ts @@ -44,7 +44,7 @@ describe("CrossmintClient", () => { expect(url).toBe("https://www.crossmint.com/api/unstable/agent-checkouts/run_1/messages"); expect(JSON.parse((init as RequestInit).body as string)).toEqual({ id: "my-id", - parts: [{ type: "input_response", requestId: "req_1", action: "submit", response: { kind: "form", values: { size: "m" } } }], + parts: [{ type: "input_response", requestId: "req_1", action: "submit", response: { kind: "form", answers: { size: "m" } } }], }); }); it("throws CrossmintApiError on non-2xx", async () => { diff --git a/packages/core/test/pending-action.test.ts b/packages/core/test/pending-action.test.ts index 857fb91..d111914 100644 --- a/packages/core/test/pending-action.test.ts +++ b/packages/core/test/pending-action.test.ts @@ -1,61 +1,116 @@ import { describe, expect, it } from "vitest"; -import { declineResponse, pendingActionOf, protectedResponse, receiptOf, submitResponse } from "../src/checkout-messages.js"; -import { asksPasswordInForm, isPaymentAction, isProtectedAction, renderPendingAction } from "../src/pending-action.js"; -import type { Checkout, PendingUserAction } from "../src/types.js"; +import { declineResponse, pendingActionOf, receiptOf, submitResponse } from "../src/checkout-messages.js"; +import { + asksCardInForm, + asksPasswordInForm, + isPaymentAction, + isProtectedAction, + protectedFields, + renderPendingAction, +} from "../src/pending-action.js"; +import type { Checkout, CheckoutField, PendingUserAction } from "../src/types.js"; -const shipping = { +const text = (key: string, label: string, extra: Partial> = {}): CheckoutField => ({ + key, + label, + required: true, + handling: "standard", + input: { kind: "text", ...extra }, +}); + +const option = (value: string, extra: { selected?: boolean; placeholder?: boolean; disabled?: boolean } = {}) => ({ + value, + label: value.toUpperCase(), + disabled: extra.disabled ?? false, + selected: extra.selected ?? false, + placeholder: extra.placeholder ?? false, +}); + +const password: CheckoutField = { + key: "password", + label: "Password", + required: true, + handling: "protected", + input: { kind: "text", display: "masked", autoComplete: "current-password" }, +}; + +const action = (fields: CheckoutField[], extra: Partial = {}): PendingUserAction => ({ id: "req_1", question: "Where should we ship this?", expiresAt: "2026-09-18T10:00:00.000Z", - responseSchema: { - type: "object", - required: ["fullName"], - properties: { - fullName: { type: "string", title: "Full name" }, - country: { type: "string", enum: ["US", "GB"] }, - express: { type: "boolean" }, - }, - }, -}; + fields, + ...extra, +}); describe("renderPendingAction", () => { - it("walks properties into fields and titles the form with the question", () => { + it("lists the fields in order and titles the form with the question", () => { + const shipping = action([ + text("fullName", "Full name"), + { + key: "country", + label: "Country", + required: false, + handling: "standard", + input: { kind: "choice", selection: { kind: "one" }, options: [option("", { placeholder: true }), option("US", { selected: true }), option("GB")] }, + }, + { key: "express", label: "Express", required: false, handling: "standard", input: { kind: "boolean" } }, + { key: "quantity", label: "Quantity", required: true, handling: "standard", input: { kind: "integer" } }, + ]); const r = renderPendingAction(shipping); expect(r.id).toBe("req_1"); expect(r.title).toBe("Where should we ship this?"); expect(r.expiresAt).toBe(shipping.expiresAt); - expect(r.fields.map((f) => f.name)).toEqual(["fullName", "country", "express"]); + expect(r.fields.map((f) => [f.name, f.kind])).toEqual([ + ["fullName", "text"], + ["country", "select"], + ["express", "boolean"], + ["quantity", "number"], + ]); expect(r.fields[0]?.required).toBe(true); - expect(r.fields[1]?.kind).toBe("select"); - expect(r.fields[2]?.kind).toBe("boolean"); + // The placeholder option is never an answer; the preselected one is the default. + expect(r.fields[1]?.options?.map((o) => o.value)).toEqual(["US", "GB"]); + expect(r.fields[1]?.default).toBe("US"); + expect(r.fields[3]?.integer).toBe(true); expect(isPaymentAction(shipping)).toBe(false); }); - it("detects and fills payment requests", () => { - const pay = { - id: "req_2", - question: "Enter the card to pay with", - responseSchema: { - type: "object", - properties: { - cardNumber: { type: "string" }, - expMonth: { type: "string" }, - expYear: { type: "string" }, - cvc: { type: "string" }, + it("renders a choice of many with its bounds", () => { + const r = renderPendingAction( + action([ + { + key: "toppings", + label: "Toppings", + required: true, + handling: "standard", + input: { kind: "choice", selection: { kind: "many", min: 1, max: 2 }, options: [option("a"), option("b"), option("c", { disabled: true })] }, }, - }, - }; + ]), + ); + expect(r.fields[0]).toMatchObject({ kind: "multiselect", min: 1, max: 2 }); + expect(r.fields[0]?.options?.[2]).toEqual({ value: "c", label: "C", disabled: true }); + }); + it("keeps a protected field's descriptor for Crossmint's protected field, unchanged", () => { + const r = renderPendingAction(action([text("email", "Email"), password])); + expect(r.fields[1]).toEqual({ name: "password", label: "Password", required: true, kind: "protected", protectedField: password }); + }); + it("detects payment requests", () => { + const pay = action(["cardNumber", "expMonth", "expYear", "cvc"].map((k) => text(k, k))); expect(isPaymentAction(pay)).toBe(true); - // The payment step as Agent Checkouts sends it now: no fields, an amount to authorize. - const step = { - id: "p", - question: "Authorize a card payment of 42.50 USD at shop.example.com.", - responseSchema: {}, - payment: { method: "card", amount: { kind: "exact", value: "42.50", currency: "USD" }, merchant: { domain: "shop.example.com" } }, - }; + expect(asksCardInForm(pay)).toBe(true); + // The payment step as Agent Checkouts sends it: no fields, an amount to authorize. + const step = action([], { + payment: { + method: "card", + amount: { kind: "exact", value: "42.50", currency: "USD" }, + merchant: { name: "Shop", url: "https://shop.example.com", countryCode: "US" }, + }, + }); expect(isPaymentAction(step)).toBe(true); + expect(asksCardInForm(step)).toBe(false); }); it("does not call a card choice a payment form", () => { - const pick = { id: "r", question: "Which saved card should I use?", responseSchema: { type: "object", properties: { card: { type: "string", enum: ["visa-4242", "amex-0005"] } } } }; + const pick = action([ + { key: "card", label: "Which saved card?", required: true, handling: "standard", input: { kind: "choice", selection: { kind: "one" }, options: [option("visa-4242"), option("amex-0005")] } }, + ]); expect(isPaymentAction(pick)).toBe(false); }); }); @@ -63,6 +118,7 @@ describe("renderPendingAction", () => { describe("checkout run helpers", () => { const base = { runId: "run_1", revision: 3, createdAt: "2026-09-17T00:00:00Z", input: { request: { startUrl: "https://shop.example/p" }, constraints: { maxCost: { amount: "10.00", currency: "USD" } } } }; it("flattens requiredAction into a pending action only while awaiting input", () => { + const size: CheckoutField = { key: "size", label: "Size", required: true, handling: "standard", input: { kind: "choice", selection: { kind: "one" }, options: [option("s"), option("m")] } }; const awaiting: Checkout = { ...base, status: "awaiting_input", @@ -70,40 +126,46 @@ describe("checkout run helpers", () => { type: "input_response", requestId: "req_9", messageId: "msg_9", - request: { question: "Size?", expiresAt: "2026-09-18T00:00:00Z", interaction: { kind: "form", responseSchema: { type: "object", properties: { size: { enum: ["s", "m"] } } }, uiSchema: {} } }, + request: { question: "Size?", expiresAt: "2026-09-18T00:00:00Z", interaction: { kind: "form", fields: [size] } }, }, }; - expect(pendingActionOf(awaiting)).toMatchObject({ id: "req_9", messageId: "msg_9", question: "Size?", responseSchema: { properties: { size: { enum: ["s", "m"] } } } }); + expect(pendingActionOf(awaiting)).toEqual({ id: "req_9", messageId: "msg_9", question: "Size?", expiresAt: "2026-09-18T00:00:00Z", fields: [size] }); expect(pendingActionOf({ ...awaiting, status: "running" })).toBeUndefined(); expect(pendingActionOf({ ...base, status: "running", requiredAction: null })).toBeUndefined(); }); - it("marks a password request as protected, with no form to fill", () => { - const password: Checkout = { + it("flattens the payment step with no fields", () => { + const merchant = { name: "Shop", url: "https://shop.example", countryCode: "US" }; + const amount = { kind: "exact", value: "9.50", currency: "USD" }; + const pay = pendingActionOf({ ...base, status: "awaiting_input", requiredAction: { type: "input_response", - requestId: "req_pw", - messageId: "msg_pw", - request: { question: "Enter your password for shop.example to sign in.", expiresAt: "2026-09-18T00:00:00Z", interaction: { kind: "protected", purpose: "password", merchant: { domain: "shop.example" } } }, + requestId: "req_pay", + messageId: "msg_pay", + request: { question: "Pay?", expiresAt: "2026-09-18T00:00:00Z", interaction: { kind: "payment", purpose: "checkout_payment", method: "card", amount, merchant } }, }, - }; - const action = pendingActionOf(password)!; - expect(action).toMatchObject({ id: "req_pw", protected: { purpose: "password", merchant: { domain: "shop.example" } }, responseSchema: {} }); - expect(isProtectedAction(action)).toBe(true); - expect(isPaymentAction(action)).toBe(false); - expect(action.payment).toBeUndefined(); + })!; + expect(pay.fields).toEqual([]); + expect(pay.payment).toEqual({ method: "card", amount, merchant }); }); - it("spots a plain form that asks for a password, and nothing else", () => { - const form = (properties: Record) => ({ - responseSchema: { type: "object", properties } as PendingUserAction["responseSchema"], - }); - expect(asksPasswordInForm(form({ amazon_password: { type: "string", title: "Amazon account password" } }))).toBe(true); - expect(asksPasswordInForm(form({ secret: { type: "string", title: "Contraseña" } }))).toBe(true); + it("marks a form with a protected field as protected", () => { + const signIn = action([text("email", "Email"), password]); + expect(isProtectedAction(signIn)).toBe(true); + expect(protectedFields(signIn)).toEqual([password]); + expect(isProtectedAction(action([text("email", "Email")]))).toBe(false); + // A protected password is the right way to ask; it is not a password in a plain form. + expect(asksPasswordInForm(signIn)).toBe(false); + }); + it("spots a standard field that holds a password, and nothing else", () => { + expect(asksPasswordInForm(action([text("amazon_password", "Amazon account password")]))).toBe(true); + expect(asksPasswordInForm(action([text("secret", "Contraseña")]))).toBe(true); + expect(asksPasswordInForm(action([text("secret", "Secret", { display: "masked" })]))).toBe(true); + expect(asksPasswordInForm(action([text("secret", "Secret", { autoComplete: "current-password" })]))).toBe(true); // A choice of sign-in method is not the secret itself. - expect(asksPasswordInForm(form({ amazon_auth_method: { type: "string", title: "Password or passkey?", oneOf: [{ const: "password" }, { const: "passkey" }] } }))).toBe(false); - expect(asksPasswordInForm(form({ email: { type: "string", title: "Account email" } }))).toBe(false); - expect(asksPasswordInForm({ ...form({}), protected: { purpose: "password" } })).toBe(false); + const method: CheckoutField = { key: "amazon_auth_method", label: "Password or passkey?", required: true, handling: "standard", input: { kind: "choice", selection: { kind: "one" }, options: [option("password"), option("passkey")] } }; + expect(asksPasswordInForm(action([method]))).toBe(false); + expect(asksPasswordInForm(action([text("email", "Account email")]))).toBe(false); }); it("reads the receipt from a captured purchase", () => { const done: Checkout = { ...base, status: "succeeded", result: { outcome: "succeeded", summary: "Bought.", purchase: { kind: "receipt_captured", receipt: { total: { amount: "9.50", currency: "USD" }, merchantOrderId: "ord_1" } } } }; @@ -111,8 +173,12 @@ describe("checkout run helpers", () => { expect(receiptOf({ result: { outcome: "succeeded", summary: "Bought.", purchase: { kind: "confirmed_without_receipt" } } })).toBeUndefined(); }); it("builds input_response parts", () => { - expect(submitResponse("req_1", { size: "m" })).toEqual({ type: "input_response", requestId: "req_1", action: "submit", response: { kind: "form", values: { size: "m" } } }); + expect(submitResponse("req_1", { email: "a@b.co", password: { protectedInputId: "pi_1" } })).toEqual({ + type: "input_response", + requestId: "req_1", + action: "submit", + response: { kind: "form", answers: { email: "a@b.co", password: { protectedInputId: "pi_1" } } }, + }); expect(declineResponse("req_1")).toEqual({ type: "input_response", requestId: "req_1", action: "decline" }); - expect(protectedResponse("req_pw", "pi_1")).toEqual({ type: "input_response", requestId: "req_pw", action: "submit", response: { kind: "protected", protectedInputId: "pi_1" } }); }); }); diff --git a/packages/mcp/src/api.ts b/packages/mcp/src/api.ts index 603f2e5..876824a 100644 --- a/packages/mcp/src/api.ts +++ b/packages/mcp/src/api.ts @@ -8,6 +8,7 @@ import type { AgentCard, Amount, BuyerProfileInput, + CheckoutFormAnswer, CheckoutReceipt, CheckoutResult, CheckoutStatus, @@ -121,7 +122,8 @@ export interface CreateCheckoutInput { export interface CheckoutMessageInput { requestId?: string; action?: "submit" | "decline" | "alternative"; - values?: Record; + /** One answer per field, keyed by field key. */ + values?: Record; text?: string; messageId?: string; } @@ -141,12 +143,19 @@ export interface CheckoutPaymentRequest { failureReason?: string; } -export interface CheckoutPasswordRequest { +/** + * The open form has protected fields, such as the password of the user's + * account at the store. Only the app answers it, at `url`. + */ +export interface CheckoutProtectedRequest { requestId: string; question: string; + /** The protected fields, by key and label: "Password". Never their values. */ + fields: Array<{ key: string; label: string }>; + /** The store the checkout runs on: "shop.example.com". */ merchantDomain?: string; expiresAt?: string; - /** The checkout's page in the app, with Crossmint's protected field. */ + /** The checkout's page in the app, with Crossmint's protected fields. */ url: string; } @@ -155,8 +164,8 @@ export interface CheckoutView { status: CheckoutStatus; agentCardId?: string; paymentRequest?: CheckoutPaymentRequest; - /** The store asks for the user's password there: show them `url`, where they type it into a secure field. */ - passwordRequest?: CheckoutPasswordRequest; + /** The store asks for secrets: show the user `url`, where they type each into a secure field. */ + protectedRequest?: CheckoutProtectedRequest; pendingUserAction?: PendingUserAction; rendered?: RenderedAction; embedUrl?: string; diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index 850c7a5..d48a2c6 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -6,6 +6,7 @@ import { PARAM_DOCS, paramDoc, asksPasswordInForm, + isProtectedAction, renderPendingAction, TOOL_DOCS, toDecimalString, @@ -292,7 +293,9 @@ export function registerAgentCommerceTools( freshBrowser: args.freshBrowser, merchantGuidance: args.merchantGuidance, }); - return ok(`Checkout ${checkout.id} created.\n${describeCheckout(checkout)}`, { checkout }); + return ok(`Checkout ${checkout.id} created.\n${describeCheckout(checkout)}`, { + checkout: forAgent(checkout), + }); }), ); @@ -302,17 +305,14 @@ export function registerAgentCommerceTools( title: title("get_checkout"), description: describeTool( "get_checkout", - "Here paymentRequest carries an approvalUrl: show it to the user, then keep polling until they have chosen. passwordRequest carries a url the same way: the store asks for the user's password there, which they type on that page; never ask for it yourself.", + "Here paymentRequest carries an approvalUrl: show it to the user, then keep polling until they have chosen. protectedRequest carries a url the same way: the store asks for secrets, such as the user's password there, which they type on that page; never ask for them yourself.", ), inputSchema: { checkoutId: z.string().describe(paramDoc("get_checkout", "checkoutId")) }, annotations: { readOnlyHint: true, openWorldHint: true }, }, guard(async ({ checkoutId }) => { const checkout = await api.getCheckout(checkoutId); - const rendered = renderedAction(checkout); - return ok(describeCheckout(checkout), { - checkout: rendered ? { ...checkout, rendered } : checkout, - }); + return ok(describeCheckout(checkout), { checkout: forAgent(checkout) }); }), ); @@ -328,8 +328,9 @@ export function registerAgentCommerceTools( .enum(["submit", "decline", "alternative"]) .optional() .describe(paramDoc("answer_checkout", "action")), + // Plain answers only: a protected field is answered on the app's page, never by the agent. values: z - .record(z.string(), z.unknown()) + .record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])) .optional() .describe(paramDoc("answer_checkout", "values")), text: z.string().max(20000).optional().describe(paramDoc("answer_checkout", "text")), @@ -338,7 +339,7 @@ export function registerAgentCommerceTools( }, guard(async ({ checkoutId, ...input }) => { const checkout = await api.answerCheckout(checkoutId, input); - return ok(describeCheckout(checkout), { checkout }); + return ok(describeCheckout(checkout), { checkout: forAgent(checkout) }); }), ); @@ -352,7 +353,7 @@ export function registerAgentCommerceTools( }, guard(async ({ checkoutId }) => { const checkout = await api.cancelCheckout(checkoutId); - return ok(describeCheckout(checkout), { checkout }); + return ok(describeCheckout(checkout), { checkout: forAgent(checkout) }); }), ); } @@ -475,9 +476,11 @@ function describeCredential( } function renderedAction(checkout: CheckoutView): RenderedAction | undefined { + // A form with secrets is not the agent's to answer, even though the server + // renders it for the app: the user types them on the page protectedRequest links to. + const pending = checkout.pendingUserAction; + if (checkout.protectedRequest || (pending && isProtectedAction(pending))) return undefined; if (checkout.rendered) return checkout.rendered; - // A password has no form: the user types it on the page passwordRequest links to. - if (checkout.passwordRequest || checkout.pendingUserAction?.protected) return undefined; // Nor does a password asked for in a plain form: it is declined, never filled in. if (checkout.pendingUserAction && asksPasswordInForm(checkout.pendingUserAction)) return undefined; @@ -491,30 +494,45 @@ function renderedAction(checkout: CheckoutView): RenderedAction | undefined { return undefined; } -function describeField(f: RenderedField, indent = " "): string { - const opts = f.options - ? ` one of: ${f.options.map((o) => JSON.stringify(o.value)).join(", ")}` - : ""; - const desc = f.description ? ` — ${f.description}` : ""; - const line = `${indent}- ${f.name} (${f.kind}${f.required ? ", required" : ""})${opts}${desc}`; - const children = f.children?.map((c) => describeField(c, indent + " ")) ?? []; - return [line, ...children].join("\n"); +/** The checkout as the agent gets it: `rendered` only when the form is the agent's to answer. */ +function forAgent(checkout: CheckoutView): CheckoutView { + const { rendered: _serverRendered, ...rest } = checkout; + const rendered = renderedAction(checkout); + return rendered ? { ...rest, rendered } : rest; +} + +/** One field as a line the agent can answer from: its key, its kind, and what it accepts. */ +function describeField(f: RenderedField): string { + const traits: string[] = [f.kind === "number" && f.integer ? "whole number" : f.kind]; + if (f.multiline) traits.push("several lines"); + if (f.required) traits.push("required"); + const options = (f.options ?? []) + .filter((o) => !o.disabled) + .map((o) => `${JSON.stringify(o.value)} (${o.label})`) + .join(", "); + let accepts = ""; + if (f.kind === "select") accepts = ` one of: ${options}`; + if (f.kind === "multiselect") { + const count = + f.max !== undefined ? `${f.min ?? 0} to ${f.max}` : f.min ? `at least ${f.min}` : "any"; + accepts = ` a list of ${count} of: ${options}`; + } + const preset = f.default !== undefined ? `; preselected ${JSON.stringify(f.default)}` : ""; + return ` - ${f.name} "${f.label}" (${traits.join(", ")})${accepts}${preset}`; } function describeCheckout(checkout: CheckoutView): string { const lines = [`Checkout ${checkout.id}: status ${checkout.status}.`]; const action = renderedAction(checkout); if (action) { - lines.push( - `Question (requestId "${action.id}"): ${action.title}${action.description ? ` — ${action.description}` : ""}`, - ); + lines.push(`Question (requestId "${action.id}"): ${action.title}`); if (action.fields.length) { lines.push("Fields:", ...action.fields.map((f) => describeField(f))); } if (action.expiresAt) lines.push(`Answer before ${action.expiresAt}, or the checkout fails.`); lines.push( `Ask the user if you do not know a value. Then call answer_checkout with checkoutId "${checkout.id}" and requestId "${action.id}" ` + - `(values to submit, or action "decline" / "alternative").`, + `(values with every field to submit, or action "decline" / "alternative").`, ); } if (checkout.paymentRequest) { @@ -531,14 +549,16 @@ function describeCheckout(checkout: CheckoutView): string { `The store asks for the user's password in a plain form. Never ask for it and never send it: it would pass through you, and the store's agent does not use a password sent that way. Tell the user this store wants them to sign in, which cannot be done safely here, and offer to check out as a guest (answer_checkout with action "alternative") or stop (action "decline").`, ); } - if (checkout.passwordRequest) { - const pw = checkout.passwordRequest; - const store = pw.merchantDomain ?? "the store"; + if (checkout.protectedRequest) { + const pr = checkout.protectedRequest; + const store = pr.merchantDomain ?? "the store"; + const labels = pr.fields.map((f) => f.label).join(", "); lines.push( - `Password request (requestId "${pw.requestId}"): ${store} asks for the password of the user's account there. Show the user this link, where they type it into a secure field and the app answers the checkout: ${pw.url}`, - `Never ask for the password and never send it, not in answer_checkout or anywhere else. Then keep polling get_checkout. If the user would rather not sign in, call answer_checkout with action "decline", or "alternative" with text such as checking out as a guest.`, + `Secure input request (requestId "${pr.requestId}"): ${pr.question}`, + `${store} asks for secrets: ${labels}. Show the user this link, where they type each into a secure field and the app answers the checkout: ${pr.url}`, + `Never ask the user for them and never send them, not in answer_checkout or anywhere else. Then keep polling get_checkout. If the user would rather not, call answer_checkout with action "decline", or "alternative" with text such as checking out as a guest.`, ); - if (pw.expiresAt) lines.push(`The user has until ${pw.expiresAt}, or the checkout fails.`); + if (pr.expiresAt) lines.push(`The user has until ${pr.expiresAt}, or the checkout fails.`); } if (checkout.embedUrl) lines.push(`The user can watch the agent's browser at: ${checkout.embedUrl}`); @@ -562,7 +582,7 @@ function describeCheckout(checkout: CheckoutView): string { ); } if (checkout.spentUsd) lines.push(`Spent so far: ${checkout.spentUsd} USD.`); - const waitingOnUser = checkout.passwordRequest || (pending && asksPasswordInForm(pending)); + const waitingOnUser = checkout.protectedRequest || (pending && asksPasswordInForm(pending)); if (!action && !waitingOnUser && !checkout.failure && checkout.status !== "succeeded") { lines.push("Still running. Poll get_checkout again in a few seconds."); } diff --git a/packages/mcp/test/tools.test.ts b/packages/mcp/test/tools.test.ts index d69b901..c0ac6be 100644 --- a/packages/mcp/test/tools.test.ts +++ b/packages/mcp/test/tools.test.ts @@ -92,26 +92,58 @@ describe("Agent Commerce tools", () => { expect(result.structuredContent).toMatchObject({ enforced: false, card: { number: "4111111111111111" } }); }); - it("get_checkout hands a password request over as a link, never as a question", async () => { + it("get_checkout hands a protected request over as a link, never as a form", async () => { + const passwordField = { + key: "password", + label: "Password", + required: true, + handling: "protected", + input: { kind: "text", display: "masked", autoComplete: "current-password" }, + }; const { client } = await connect( mockAgentCommerceFetch({ "GET /v1/checkouts/run_pw": { status: 200, body: { - id: "run_pw", - status: "awaiting_input", - pendingUserAction: { - id: "req_pw", - question: "Enter your password for shop.example to sign in.", - responseSchema: {}, - protected: { purpose: "password", merchant: { domain: "shop.example" } }, - }, - passwordRequest: { - requestId: "req_pw", - question: "Enter your password for shop.example to sign in.", - merchantDomain: "shop.example", - url: "https://wallet.example.com/checkouts/run_pw", - }, + id: "run_pw", + status: "awaiting_input", + pendingUserAction: { + id: "req_pw", + question: "Sign in to shop.example to continue.", + fields: [ + { + key: "email", + label: "Email", + required: true, + handling: "standard", + input: { kind: "text", autoComplete: "email" }, + }, + passwordField, + ], + }, + // The server renders the form for the app's own page, protected field included. + rendered: { + id: "req_pw", + type: "input_response", + title: "Sign in to shop.example to continue.", + fields: [ + { name: "email", label: "Email", kind: "text", required: true }, + { + name: "password", + label: "Password", + kind: "protected", + required: true, + protectedField: passwordField, + }, + ], + }, + protectedRequest: { + requestId: "req_pw", + question: "Sign in to shop.example to continue.", + fields: [{ key: "password", label: "Password" }], + merchantDomain: "shop.example", + url: "https://wallet.example.com/checkouts/run_pw", + }, }, }, }), @@ -120,12 +152,96 @@ describe("Agent Commerce tools", () => { expect(result.isError).toBeFalsy(); const text = (result.content as Array<{ type: string; text: string }>)[0]!.text; expect(text).toContain("https://wallet.example.com/checkouts/run_pw"); - expect(text).toContain("Never ask for the password"); + expect(text).toContain("shop.example asks for secrets: Password."); + expect(text).toContain("Never ask the user for them"); expect(text).not.toContain("Question (requestId"); + expect(text).not.toContain("Fields:"); expect(text).not.toContain("Still running"); expect((result.structuredContent as { checkout: Record }).checkout.rendered).toBeUndefined(); }); + it("get_checkout lists each field with the answers it accepts", async () => { + const { client } = await connect( + mockAgentCommerceFetch({ + "GET /v1/checkouts/run_1": { + status: 200, + body: { + id: "run_1", + status: "awaiting_input", + pendingUserAction: { + id: "req_1", + question: "Pick your options.", + fields: [ + { + key: "size", + label: "Size", + required: true, + handling: "standard", + input: { + kind: "choice", + selection: { kind: "one" }, + options: [ + { value: "", label: "Select a size", disabled: false, selected: false, placeholder: true }, + { value: "m", label: "Medium", disabled: false, selected: true, placeholder: false }, + { value: "l", label: "Large", disabled: true, selected: false, placeholder: false }, + ], + }, + }, + { + key: "extras", + label: "Extras", + required: false, + handling: "standard", + input: { + kind: "choice", + selection: { kind: "many", min: 0, max: 2 }, + options: [ + { value: "wrap", label: "Gift wrap", disabled: false, selected: false, placeholder: false }, + { value: "note", label: "Card", disabled: false, selected: false, placeholder: false }, + ], + }, + }, + { key: "quantity", label: "Quantity", required: true, handling: "standard", input: { kind: "integer" } }, + { key: "notes", label: "Notes", required: false, handling: "standard", input: { kind: "text", multiline: true } }, + ], + }, + }, + }, + }), + ); + const result = await client.callTool({ name: "get_checkout", arguments: { checkoutId: "run_1" } }); + const text = (result.content as Array<{ type: string; text: string }>)[0]!.text; + expect(text).toContain('Question (requestId "req_1"): Pick your options.'); + expect(text).toContain('- size "Size" (select, required) one of: "m" (Medium); preselected "m"'); + expect(text).toContain('- extras "Extras" (multiselect) a list of 0 to 2 of: "wrap" (Gift wrap), "note" (Card)'); + expect(text).toContain('- quantity "Quantity" (whole number, required)'); + expect(text).toContain('- notes "Notes" (text, several lines)'); + const checkout = (result.structuredContent as { checkout: { rendered?: { fields: unknown[] } } }).checkout; + expect(checkout.rendered?.fields).toHaveLength(4); + }); + + it("answer_checkout sends plain answers and refuses a protectedInputId from the agent", async () => { + const fetchMock = mockAgentCommerceFetch({ + "POST /v1/checkouts/run_1/messages": { body: { id: "run_1", status: "running" } }, + }); + const { client } = await connect(fetchMock); + const values = { size: "m", extras: ["wrap"], quantity: 2, gift: true }; + const sent = await client.callTool({ + name: "answer_checkout", + arguments: { checkoutId: "run_1", requestId: "req_1", values }, + }); + expect(sent.isError).toBeFalsy(); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ requestId: "req_1", values }); + + const refused = await client.callTool({ + name: "answer_checkout", + arguments: { checkoutId: "run_1", requestId: "req_1", values: { password: { protectedInputId: "pi_1" } } }, + }); + expect(refused.isError).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("turns Agent Commerce API errors into isError results", async () => { const { client } = await connect( mockAgentCommerceFetch({ diff --git a/packages/server/src/handlers/checkouts.ts b/packages/server/src/handlers/checkouts.ts index c514734..64e6fe4 100644 --- a/packages/server/src/handlers/checkouts.ts +++ b/packages/server/src/handlers/checkouts.ts @@ -14,13 +14,14 @@ import { newMessageId, normalizeBuyerProfile, pendingActionOf, - protectedResponse, + protectedFields, receiptOf, renderPendingAction, submitResponse, type Amount, type Checkout, type CheckoutContext, + type CheckoutFormAnswer, type CheckoutMessageList, type CheckoutReceipt, type CheckoutResult, @@ -106,12 +107,12 @@ export interface CheckoutView { /** The receipt of a `succeeded` run, when the agent captured one. */ receipt?: CheckoutReceipt; /** - * The store asks for the password of the user's account there. It is - * never answered with values: show the user `url`, the checkout's page in - * the app, where they type it into Crossmint's protected field and the app - * answers the run. + * The open form has a protected field, such as the password of the user's + * account at the store. Only the app answers it: show the user `url`, the + * checkout's page, where they type each secret into Crossmint's protected + * field and the app sends the whole form. */ - passwordRequest?: CheckoutPasswordRequest; + protectedRequest?: CheckoutProtectedRequest; /** On `failed` (Crossmint's `reason`), `blocked` (the `code`) and `cancelled`. */ failure?: { reason: string; message?: string }; /** What the run has spent so far, in USD. */ @@ -119,14 +120,16 @@ export interface CheckoutView { createdAt?: string; } -/** A password request, as a caller shows it: where the user types it, and for which store. */ -export interface CheckoutPasswordRequest { +/** A form with protected fields, as a caller shows it: where the user answers it, and for which store. */ +export interface CheckoutProtectedRequest { requestId: string; question: string; - /** The store the password is for: "shop.example.com". */ + /** The protected fields, by key and label: "Password". Never their values. */ + fields: Array<{ key: string; label: string }>; + /** The store the checkout runs on: "shop.example.com". */ merchantDomain?: string; expiresAt?: string; - /** `${webBaseUrl}/checkouts/${id}`: the checkout's page, with the secure field. */ + /** `${webBaseUrl}/checkouts/${id}`: the checkout's page, with the secure fields. */ url: string; } @@ -295,8 +298,8 @@ export async function streamCheckoutMessages( * POST /v1/checkouts/:id/messages * Answer the open input request, or send the agent a note. Card fields are * refused: the server answers payment requests itself from the agent card. - * A password request is answered with the id from Crossmint's protected - * field, which the app's own UI sends here; the secret never passes through. + * A form with protected fields is answered by the app's own UI, with the + * ids Crossmint's protected fields returned; the secrets never pass through. */ export async function sendCheckoutMessage( req: Request, @@ -310,8 +313,7 @@ export async function sendCheckoutMessage( let note: string | undefined; if (body.requestId) { const action = body.action ?? "submit"; - if (body.protectedInputId) part = protectedResponse(body.requestId, body.protectedInputId); - else if (action === "submit") part = submitResponse(body.requestId, body.values ?? {}); + if (action === "submit") part = submitResponse(body.requestId, body.values ?? {}); else if (action === "decline") part = declineResponse(body.requestId); else part = alternativeResponse(body.requestId, body.text!); if (body.text && action !== "alternative") note = body.text; @@ -395,7 +397,7 @@ export async function setCheckoutAgentCard( { agentCardId: body.agentCardId, available: card.amount.available, needed: asked.value }, ); } - const storeHost = action?.payment?.merchant?.domain ?? merchantFromCheckout(checkout)?.url; + const storeHost = action?.payment?.merchant.url ?? merchantFromCheckout(checkout)?.url; if (card.merchant && storeHost && hostOf(card.merchant.url) !== hostOf(storeHost)) { throw new HttpError( 409, @@ -543,7 +545,11 @@ async function answer( { checkoutId: runId, requestId }, ); } - if (open && open.id === requestId && isProtectedAction(open) && submit === "form") { + const answers = + part.type === "input_response" && part.action === "submit" && part.response.kind === "form" + ? part.response.answers + : undefined; + if (open && open.id === requestId && answers && !answersProtectedFields(open, answers)) { throw new HttpError( 409, "protected_input_required", @@ -551,19 +557,11 @@ async function answer( { checkoutId: runId, requestId }, ); } - if (open && open.id === requestId && asksPasswordInForm(open) && submit === "form") { + if (open && open.id === requestId && asksPasswordInForm(open) && answers) { throw new HttpError( 409, "password_in_form", - "The store asks for a password in a plain form. It is never answered with values: Agent Checkouts does not fill a password from them, and it would pass through the app and the agent. Decline it, or send an alternative such as checking out as a guest. Asking for a password with a secure field needs protected inputs enabled on the Crossmint project.", - { checkoutId: runId, requestId }, - ); - } - if (submit === "protected" && !(open && open.id === requestId && isProtectedAction(open))) { - throw new HttpError( - 409, - "not_a_protected_request", - "The open request does not ask for a protected input, or it is no longer open.", + "The store asks for a password in a plain field, not a protected one. It is never answered with values: it would pass through the app and the agent. Decline it, or send an alternative such as checking out as a guest.", { checkoutId: runId, requestId }, ); } @@ -582,6 +580,22 @@ async function answer( return settlePayment(ctx, user, cctx, checkout, link); } +/** + * True when every protected field is answered only with the id Crossmint's + * protected field returned, or left out when optional. A plain value there + * would be the secret itself, typed somewhere other than the secure field. + */ +function answersProtectedFields( + action: PendingUserAction, + answers: Record, +): boolean { + return protectedFields(action).every((field) => { + const answer = answers[field.key]; + if (answer === undefined) return !field.required; + return typeof answer === "object" && !Array.isArray(answer) && typeof answer.protectedInputId === "string"; + }); +} + /** Look up what Agent Commerce knows about a checkout. 403 when another user owns it. */ async function ownedLink( ctx: Ctx, @@ -733,7 +747,7 @@ async function paymentStepRequest( const now = ctx.now(); const id = agentCardRequestId(); - const domain = action.payment?.merchant?.domain ?? merchantFromCheckout(checkout)?.name; + const domain = action.payment?.merchant.name ?? merchantFromCheckout(checkout)?.name; // As Agent Checkouts wants it: the exact amount the run asks for, for the // rest of the checkout only, and no merchant, because the checkout binds // the credential to the store itself when it mints it. @@ -812,20 +826,19 @@ function toView( if (opts.hidePayment) view.status = "running"; } else { view.pendingUserAction = action; - // A secret has no form: the app shows Crossmint's protected field instead. + // A password in a plain field gets no form: it is declined, never filled in. + if (!asksPasswordInForm(action)) view.rendered = renderPendingAction(action); + // Secrets: only the app's page answers, with Crossmint's protected fields. if (isProtectedAction(action)) { - view.passwordRequest = { + const domain = merchantFromCheckout(checkout)?.name; + view.protectedRequest = { requestId: action.id, question: action.question, - ...(action.protected?.merchant - ? { merchantDomain: action.protected.merchant.domain } - : {}), + fields: protectedFields(action).map(({ key, label }) => ({ key, label })), + ...(domain ? { merchantDomain: domain } : {}), ...(action.expiresAt ? { expiresAt: action.expiresAt } : {}), url: `${webBaseUrl.replace(/\/$/, "")}/checkouts/${encodeURIComponent(checkout.runId)}`, }; - } else if (!asksPasswordInForm(action)) { - // A password in a plain form gets no form either: it is declined, never filled in. - view.rendered = renderPendingAction(action); } } } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e9bb0f6..39a88c8 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -39,7 +39,7 @@ import { import { Router } from "./router.js"; import type { AgentCommerceServerConfig } from "./types.js"; -export type { CheckoutPasswordRequest, CheckoutView } from "./handlers/checkouts.js"; +export type { CheckoutProtectedRequest, CheckoutView } from "./handlers/checkouts.js"; export { buildPublicConfig } from "./handlers/config.js"; export { HttpError, type ErrorCode } from "./errors.js"; export { routePath } from "./router.js"; diff --git a/packages/server/src/schemas.ts b/packages/server/src/schemas.ts index ec77b3d..c716489 100644 --- a/packages/server/src/schemas.ts +++ b/packages/server/src/schemas.ts @@ -83,49 +83,48 @@ export const createCheckoutSchema = z path: ["startUrl"], }); +/** + * One form answer: text, a number, true or false, an option value, a list of + * option values, or for a protected field the `{ protectedInputId }` that + * Crossmint's protected field returned. + */ +const formAnswerSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.string()), + z.object({ protectedInputId: z.string().min(1).max(200) }).strict(), +]); + /** * Body of POST /v1/checkouts/:id/messages. With `requestId`: answer the open - * input request (`submit` with `values`, `decline`, or `alternative` with - * `text`). A protected request, such as a password, is submitted with the - * `protectedInputId` Crossmint's protected field returned, never with values. - * Without `requestId`: a free-text note to the agent. + * input request (`submit` with `values`, every field keyed by its key; + * `decline`; or `alternative` with `text`). Without `requestId`: a free-text + * note to the agent. */ export const checkoutMessageSchema = z .object({ requestId: z.string().min(1).optional(), action: z.enum(["submit", "decline", "alternative"]).optional(), - values: z.record(z.string(), z.unknown()).optional(), - protectedInputId: z.string().min(1).max(200).optional(), + values: z.record(z.string(), formAnswerSchema).optional(), text: z.string().min(1).max(20000).optional(), messageId: z.string().min(1).max(200).optional(), }) .superRefine((b, ctx) => { if (b.requestId) { const action = b.action ?? "submit"; - if (action === "submit" && !b.values && !b.protectedInputId) + if (action === "submit" && !b.values) ctx.addIssue({ code: "custom", path: ["values"], message: "values are required to submit", }); - if (b.protectedInputId && (action !== "submit" || b.values)) - ctx.addIssue({ - code: "custom", - path: ["protectedInputId"], - message: "protectedInputId is a submit of its own, with no values", - }); if (action === "alternative" && !b.text) ctx.addIssue({ code: "custom", path: ["text"], message: "text is required for an alternative", }); - } else if (b.protectedInputId) { - ctx.addIssue({ - code: "custom", - path: ["requestId"], - message: "requestId is required with protectedInputId", - }); } else if (!b.text) { ctx.addIssue({ code: "custom", path: ["text"], message: "text or requestId is required" }); } @@ -138,7 +137,7 @@ export const checkoutAgentCardSchema = z.object({ }); export const submitActionSchema = z.object({ - values: z.record(z.string(), z.unknown()), + values: z.record(z.string(), formAnswerSchema), }); export const buyerProfileSchema = z.object({ diff --git a/packages/server/test/handlers.test.ts b/packages/server/test/handlers.test.ts index 2465d52..059f58c 100644 --- a/packages/server/test/handlers.test.ts +++ b/packages/server/test/handlers.test.ts @@ -9,6 +9,11 @@ import { type FakeCall, } from "./helpers.js"; +/** A required text field the caller answers with a plain value. */ +function textField(key: string, label: string) { + return { key, label, required: true, handling: "standard", input: { kind: "text" } }; +} + describe("auth", () => { it("rejects a missing bearer token", async () => { const { handlers } = makeServer(); @@ -637,7 +642,7 @@ describe("checkouts", () => { purpose: "checkout_payment", method: "card", amount: { kind: "exact", value: "28.40", currency: "USD" }, - merchant: { domain: "shop.example" }, + merchant: { name: "shop.example", url: "https://shop.example", countryCode: "US" }, }, }, }; @@ -1136,10 +1141,7 @@ describe("checkouts", () => { expiresAt: "2026-09-18T00:00:00.000Z", interaction: { kind: "form", - responseSchema: { - type: "object", - properties: { cardNumber: { type: "string" }, cvc: { type: "string" } }, - }, + fields: [textField("cardNumber", "Card number"), textField("cvc", "CVC")], }, }, }; @@ -1315,10 +1317,7 @@ describe("checkouts", () => { request: { question: "Which size?", expiresAt: "2026-09-18T00:00:00.000Z", - interaction: { - kind: "form", - responseSchema: { type: "object", properties: { size: { type: "string" } } }, - }, + interaction: { kind: "form", fields: [textField("size", "Size")] }, }, }; const { handlers, calls } = makeServer([ @@ -1353,7 +1352,7 @@ describe("checkouts", () => { expect(res.status).toBe(200); const sent = calls.filter((c) => c.method === "POST" && c.path.endsWith("/run_note/messages")); expect(sent.map((c) => (c.body as { parts: unknown[] }).parts)).toEqual([ - [{ type: "input_response", requestId: "req_size", action: "submit", response: { kind: "form", values: { size: "M" } } }], + [{ type: "input_response", requestId: "req_size", action: "submit", response: { kind: "form", answers: { size: "M" } } }], [{ type: "text", text: "Gift wrap it, please" }], ]); }); @@ -1419,12 +1418,25 @@ describe("checkouts", () => { expiresAt: "2026-09-18T00:00:00.000Z", interaction: { kind: "form", - responseSchema: { - type: "object", - properties: { size: { type: "string", enum: ["s", "m"] } }, - required: ["size"], - }, - uiSchema: {}, + fields: [ + { + key: "size", + label: "Size", + required: true, + handling: "standard", + input: { + kind: "choice", + selection: { kind: "one" }, + options: ["s", "m"].map((value) => ({ + value, + label: value.toUpperCase(), + disabled: false, + selected: false, + placeholder: false, + })), + }, + }, + ], }, }, }; @@ -1468,22 +1480,29 @@ describe("checkouts", () => { type: "input_response", requestId: "req_size", action: "submit", - response: { kind: "form", values: { size: "m" } }, + response: { kind: "form", answers: { size: "m" } }, }, ], }); }); - it("answers a password request with the protected input id, and never with values", async () => { + it("answers a sign-in form only with protected input ids for its secrets", async () => { let answered = false; - const passwordRequest = { + const password = { + key: "password", + label: "Password", + required: true, + handling: "protected", + input: { kind: "text", display: "masked", autoComplete: "current-password" }, + }; + const signIn = { type: "input_response", requestId: "req_pw", messageId: "msg_pw", request: { - question: "Enter your password for shop.example to sign in.", + question: "Sign in to shop.example to continue.", expiresAt: "2026-09-18T00:00:00.000Z", - interaction: { kind: "protected", purpose: "password", merchant: { domain: "shop.example" } }, + interaction: { kind: "form", fields: [textField("email", "Email"), password] }, }, }; const { handlers, calls } = makeServer([ @@ -1494,7 +1513,11 @@ describe("checkouts", () => { body: run({ runId: "run_pw", status: answered ? "running" : "awaiting_input", - requiredAction: answered ? null : passwordRequest, + requiredAction: answered ? null : signIn, + input: { + request: { startUrl: "https://www.shop.example/p/1" }, + constraints: { maxCost: { amount: "10.00", currency: "USD" } }, + }, }), }), }, @@ -1507,32 +1530,36 @@ describe("checkouts", () => { }, }, ]); - // The view names the secret and where it is for, and offers no form for it. + // The form renders with the protected field's descriptor, for Crossmint's field. const view = await (await call(handlers, "GET", "/v1/checkouts/run_pw")).json(); - expect(view.pendingUserAction).toMatchObject({ - id: "req_pw", - protected: { purpose: "password", merchant: { domain: "shop.example" } }, - }); - expect(view.rendered).toBeUndefined(); - // Where the user types it, for a caller that cannot show the field itself. - expect(view.passwordRequest).toEqual({ + expect(view.rendered.fields.map((f: { kind: string }) => f.kind)).toEqual(["text", "protected"]); + expect(view.rendered.fields[1].protectedField).toEqual(password); + // Where the user answers it, for a caller that cannot show the field itself. + expect(view.protectedRequest).toEqual({ requestId: "req_pw", - question: "Enter your password for shop.example to sign in.", + question: "Sign in to shop.example to continue.", + fields: [{ key: "password", label: "Password" }], merchantDomain: "shop.example", expiresAt: "2026-09-18T00:00:00.000Z", url: "https://wallet.test/checkouts/run_pw", }); - // A password typed as a value is refused, and nothing reaches Crossmint. - const typed = await call(handlers, "POST", "/v1/checkouts/run_pw/messages", { - body: { requestId: "req_pw", values: { password: "hunter2" } }, - }); - expect(typed.status).toBe(409); - expect((await typed.json()).error.code).toBe("protected_input_required"); + // A password typed as a value, or left out, is refused, and nothing reaches Crossmint. + for (const values of [{ email: "a@b.co", password: "hunter2" }, { email: "a@b.co" }]) { + const refused = await call(handlers, "POST", "/v1/checkouts/run_pw/messages", { + body: { requestId: "req_pw", values }, + }); + expect(refused.status).toBe(409); + expect((await refused.json()).error.code).toBe("protected_input_required"); + } expect(calls.some((c) => c.method === "POST" && c.path.endsWith("/run_pw/messages"))).toBe(false); const reply = await call(handlers, "POST", "/v1/checkouts/run_pw/messages", { - body: { requestId: "req_pw", protectedInputId: "pi_1", messageId: "client-pw" }, + body: { + requestId: "req_pw", + values: { email: "a@b.co", password: { protectedInputId: "pi_1" } }, + messageId: "client-pw", + }, }); expect((await reply.json()).status).toBe("running"); const sent = calls.find((c) => c.method === "POST" && c.path.endsWith("/run_pw/messages"))!; @@ -1543,13 +1570,13 @@ describe("checkouts", () => { type: "input_response", requestId: "req_pw", action: "submit", - response: { kind: "protected", protectedInputId: "pi_1" }, + response: { kind: "form", answers: { email: "a@b.co", password: { protectedInputId: "pi_1" } } }, }, ], }); }); - it("refuses a password asked for in a plain form, and renders no form for it", async () => { + it("refuses a password asked for in a plain field, and renders no form for it", async () => { const passwordForm = { type: "input_response", requestId: "req_pwf", @@ -1558,11 +1585,7 @@ describe("checkouts", () => { expiresAt: "2026-09-18T00:00:00.000Z", interaction: { kind: "form", - responseSchema: { - type: "object", - properties: { amazon_password: { type: "string", title: "Amazon account password" } }, - required: ["amazon_password"], - }, + fields: [textField("amazon_password", "Amazon account password")], }, }, }; @@ -1576,6 +1599,7 @@ describe("checkouts", () => { const view = await (await call(handlers, "GET", "/v1/checkouts/run_pwf")).json(); expect(view.pendingUserAction).toMatchObject({ id: "req_pwf" }); expect(view.rendered).toBeUndefined(); + expect(view.protectedRequest).toBeUndefined(); const res = await call(handlers, "POST", "/v1/checkouts/run_pwf/messages", { body: { requestId: "req_pwf", values: { amazon_password: "hunter2" } }, }); @@ -1584,30 +1608,6 @@ describe("checkouts", () => { expect(calls.some((c) => c.method === "POST" && c.path.endsWith("/run_pwf/messages"))).toBe(false); }); - it("refuses a protected input id for a request that asks for no secret", async () => { - const sizeRequest = { - type: "input_response", - requestId: "req_size2", - request: { - question: "Which size?", - expiresAt: "2026-09-18T00:00:00.000Z", - interaction: { kind: "form", responseSchema: { type: "object", properties: { size: { type: "string" } } } }, - }, - }; - const { handlers } = makeServer([ - { - method: "GET", - path: "/unstable/agent-checkouts/run_sz", - reply: { body: run({ runId: "run_sz", status: "awaiting_input", requiredAction: sizeRequest }) }, - }, - ]); - const res = await call(handlers, "POST", "/v1/checkouts/run_sz/messages", { - body: { requestId: "req_size2", protectedInputId: "pi_2" }, - }); - expect(res.status).toBe(409); - expect((await res.json()).error.code).toBe("not_a_protected_request"); - }); - it("refuses card fields from callers and maps blocked runs to a failure", async () => { const { handlers } = makeServer([ { diff --git a/packages/ui/package.json b/packages/ui/package.json index 1a31130..675f8c1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -26,7 +26,7 @@ "clean": "rm -rf dist .turbo" }, "dependencies": { - "@crossmint/client-sdk-react-ui": "^4.8.0", + "@crossmint/client-sdk-react-ui": "^4.9.0", "@agent-commerce/core": "workspace:*", "@radix-ui/react-slot": "^1.2.0", "class-variance-authority": "^0.7.1", diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index 79efbba..a869a92 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -18,6 +18,7 @@ import type { RegisterCardInput, RegisterCardResult, RenderedAction, + CheckoutFormAnswer, } from "@agent-commerce/core"; export interface AgentCommerceErrorEnvelope { @@ -164,9 +165,8 @@ export interface CreateCheckoutInput { export interface CheckoutMessageInput { requestId?: string; action?: "submit" | "decline" | "alternative"; - values?: Record; - /** A password request's answer: the id Crossmint's protected field returned, never the password. */ - protectedInputId?: string; + /** Every field's answer, keyed by field name. A protected field's is `{ protectedInputId }`, never the secret. */ + values?: Record; text?: string; messageId?: string; } @@ -193,10 +193,11 @@ export interface CheckoutView { startUrl?: string; agentCardId?: string; paymentRequest?: CheckoutPaymentRequest; - /** The store asks for the user's password there; `url` is the checkout's page with the secure field. */ - passwordRequest?: { + /** The open form has protected fields, such as a password; `url` is the checkout's page with the secure fields. */ + protectedRequest?: { requestId: string; question: string; + fields: Array<{ key: string; label: string }>; merchantDomain?: string; expiresAt?: string; url: string; diff --git a/packages/ui/src/components/answer-protected-request.tsx b/packages/ui/src/components/answer-protected-request.tsx new file mode 100644 index 0000000..9ad87b2 --- /dev/null +++ b/packages/ui/src/components/answer-protected-request.tsx @@ -0,0 +1,175 @@ +"use client"; + +import * as React from "react"; +import { newMessageId } from "@agent-commerce/core"; +import { AlertCircle, Lock } from "lucide-react"; +import { AgentCommerceApiError, errorMessage } from "../api/client.js"; +import type { CheckoutView } from "../api/types.js"; +import { useCheckout } from "../hooks/use-checkout.js"; +import { cn } from "../lib/utils.js"; +import { useAgentCommerce } from "../provider.js"; +import { PendingActionForm, type FormAnswers } from "./pending-action-form.js"; +import { Button } from "./primitives/button.js"; +import { Skeleton } from "./primitives/skeleton.js"; + +export type ProtectedRequestOutcome = "submitted" | "declined"; + +export interface AnswerProtectedRequestProps { + checkoutId: string; + /** The open request with protected fields: `protectedRequest.requestId` of the checkout's view. */ + requestId: string; + /** The store the checkout runs on, for the reassurance: "shop.example.com". Default the checkout's own. */ + merchantDomain?: string; + /** The heading is left to a host that asks in its own words. Default true. */ + showHeading?: boolean; + /** Who cannot see the secrets, for the reassurance under the heading. Default "This app". */ + platformName?: string; + /** Fires once the run has the answer, with the view the server returned. */ + onDone?: (outcome: ProtectedRequestOutcome, view?: CheckoutView) => void; + className?: string; +} + +/** + * A store's question with protected fields, such as a sign-in, answered: the + * whole form, read from the checkout, with each secret in Crossmint's + * protected field. On submit the + * secrets go to Crossmint's vault, and the run is answered once, with the + * other fields and the ids. "Not now" declines the request instead, and the + * store's agent carries on without it, or asks another way. + * + * When the answer may have reached the run but no reply came back, sending + * the same answers again reuses the same message id, so the run applies it + * once. + */ +export function AnswerProtectedRequest({ + checkoutId, + requestId, + merchantDomain, + showHeading = true, + platformName = "This app", + onDone, + className, +}: AnswerProtectedRequestProps) { + const { api } = useAgentCommerce(); + // The fields come from the checkout, never from the agent that asked. + const checkout = useCheckout(checkoutId, { poll: false }); + const action = checkout.data?.rendered?.id === requestId ? checkout.data.rendered : undefined; + const [state, setState] = React.useState<"answering" | "sending" | "sent" | "declined">( + "answering", + ); + const [failure, setFailure] = React.useState(undefined); + // The answer whose reply was lost: the same answers go again under the same id. + const unconfirmed = React.useRef<{ messageId: string; body: string } | undefined>(undefined); + + const send = async (values: FormAnswers) => { + const body = JSON.stringify(values); + const messageId = + unconfirmed.current?.body === body ? unconfirmed.current.messageId : newMessageId(); + unconfirmed.current = { messageId, body }; + setState("sending"); + setFailure(undefined); + try { + const view = await api.answerCheckout(checkoutId, { requestId, values, messageId }); + unconfirmed.current = undefined; + setState("sent"); + onDone?.("submitted", view); + } catch (e) { + // A refusal is definite: the next answer is a new message. + if (e instanceof AgentCommerceApiError && e.status < 500) unconfirmed.current = undefined; + setFailure(e); + setState("answering"); + } + }; + + const decline = async () => { + setState("sending"); + setFailure(undefined); + try { + const view = await api.answerCheckout(checkoutId, { requestId, action: "decline" }); + setState("declined"); + onDone?.("declined", view); + } catch (e) { + setFailure(e); + setState("answering"); + } + }; + + const domain = merchantDomain ?? checkout.data?.protectedRequest?.merchantDomain; + const site = domain ? siteName(domain) : "the store"; + if (state === "sent" || state === "declined") { + return ( +

+ {state === "sent" + ? `Sent. The agent carries on at ${site}.` + : "Skipped. The agent carries on without it."} +

+ ); + } + if (checkout.loading) return ; + if (!action) { + return ( + + ); + } + return ( +
+

+ + Fields with a lock are encrypted and only used on {site}. {platformName} never sees them. +

+ {failure ? ( + + ) : null} + + +
+ ); +} + +/** "www.amazon.com" → "amazon.com", for reading. */ +function siteName(domain: string): string { + return domain.replace(/^www\./, ""); +} + +/** A fault, said plainly: the icon, a title, one line. */ +function Problem({ + title, + message, + className, +}: { + title: string; + message: string; + className?: string; +}) { + return ( +
+ +
+

{title}

+

{message}

+
+
+ ); +} diff --git a/packages/ui/src/components/checkout-steps.tsx b/packages/ui/src/components/checkout-steps.tsx index 4d4eabd..53021cb 100644 --- a/packages/ui/src/components/checkout-steps.tsx +++ b/packages/ui/src/components/checkout-steps.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { Check, X } from "lucide-react"; -import type { CheckoutMessage, JsonSchema } from "@agent-commerce/core"; +import type { CheckoutMessage } from "@agent-commerce/core"; import type { CheckoutView } from "../api/types.js"; import { cn } from "../lib/utils.js"; @@ -16,7 +16,11 @@ export interface CheckoutStep { const RUNNING = new Set(["queued", "running"]); -/** What the user sent for each question, in a few words: "US 10", or "Skipped". */ +/** + * What the user sent for each question, in a few words: their alternative, + * or "Skipped". The transcript keeps which fields a submit answered, never + * their values, so a submit adds nothing. + */ function answersOf(messages: CheckoutMessage[]): Map { const out = new Map(); for (const message of messages) { @@ -26,16 +30,6 @@ function answersOf(messages: CheckoutMessage[]): Map { if (part.action === "decline") out.set(part.requestId, "Skipped"); else if (part.action === "alternative" && typeof part.text === "string") out.set(part.requestId, part.text); - else { - const values = - "response" in part - ? ((part.response as { values?: Record })?.values ?? {}) - : {}; - const words = Object.values(values) - .filter((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean") - .map(String); - if (words.length) out.set(part.requestId, words.join(", ")); - } } } return out; @@ -69,8 +63,7 @@ export function checkoutSteps( } if (part.type === "input_request" && typeof part.requestId === "string") { const open = part.status === "open"; - const interaction = part.interaction as - { kind?: string; responseSchema?: JsonSchema } | undefined; + const interaction = part.interaction as { kind?: string } | undefined; // Only the payment request: a form asking for card details is a question. const payment = interaction?.kind === "payment"; const step: CheckoutStep = { diff --git a/packages/ui/src/components/checkout-view.tsx b/packages/ui/src/components/checkout-view.tsx index b0ed7a9..678b410 100644 --- a/packages/ui/src/components/checkout-view.tsx +++ b/packages/ui/src/components/checkout-view.tsx @@ -19,7 +19,7 @@ import { Skeleton } from "./primitives/skeleton.js"; import { Spinner } from "./primitives/spinner.js"; import { ApproveAgentCard, PAYMENT_STEP_ASK } from "./approve-agent-card.js"; import { CheckoutSteps, checkoutSteps } from "./checkout-steps.js"; -import { AnswerPasswordRequest } from "./protected-input.js"; +import { AnswerProtectedRequest } from "./answer-protected-request.js"; import { PendingActionForm } from "./pending-action-form.js"; export interface CheckoutViewProps { @@ -210,14 +210,14 @@ export function CheckoutView({
) : null} - {/* A password request: Crossmint's protected field, never a form. */} - {!terminal && data.pendingUserAction?.protected ? ( + {/* A form with secrets: each one in Crossmint's protected field, the rest as usual. */} + {!terminal && data.protectedRequest && data.rendered ? (
- { void refetch(); @@ -227,7 +227,7 @@ export function CheckoutView({
) : null} - {/* A password in a plain form: never filled in here, only skipped. */} + {/* A password in a plain field: never filled in here, only skipped. */} {!terminal && data.pendingUserAction && asksPasswordInForm(data.pendingUserAction) ? (
) : null} - {!terminal && data.rendered ? ( + {!terminal && data.rendered && !data.protectedRequest ? (
{actionError ? ( ; export interface PendingActionFormProps { action: RenderedAction; - onSubmit: (values: Record) => void | Promise; + /** + * Fires with every field's answer, keyed by field name. A protected field's + * answer is the `{ protectedInputId }` Crossmint's field returned, collected + * on submit; the secret never reaches this app. + */ + onSubmit: (answers: FormAnswers) => void | Promise; submitting?: boolean; submitLabel?: string; className?: string; } -type Values = Record; +type Value = string | number | boolean | string[] | undefined; +type Values = Record; /** The phone-screen field: tall, 12px corners, on the grey fill, no border. */ const FIELD = "h-12 rounded-xl border-0 bg-muted px-4 shadow-none"; -function getAt(values: Values, path: string[]): unknown { - let cur: unknown = values; - for (const key of path) { - if (typeof cur !== "object" || cur === null) return undefined; - cur = (cur as Record)[key]; - } - return cur; -} - -function setAt(values: Values, path: string[], value: unknown): Values { - if (path.length === 0) return values; - const [head, ...rest] = path as [string, ...string[]]; - const next = { ...values }; - if (rest.length === 0) { - next[head] = value; - } else { - const child = next[head]; - next[head] = setAt(typeof child === "object" && child !== null ? (child as Values) : {}, rest, value); - } - return next; -} - -function defaultsFor(fields: RenderedField[], acc: Values = {}): Values { - let out = acc; +function defaultsFor(fields: RenderedField[]): Values { + const out: Values = {}; for (const f of fields) { - if (f.kind === "object" && f.children) out = defaultsFor(f.children, out); - else if (f.default !== undefined) out = setAt(out, f.path, f.default); - else if (f.kind === "boolean") out = setAt(out, f.path, false); + if (f.default !== undefined) out[f.name] = f.default; + else if (f.kind === "boolean") out[f.name] = false; + else if (f.kind === "multiselect") out[f.name] = []; } return out; } +/** The fields still missing an answer, by label. Protected ones are checked by Crossmint's field on submit. */ +function missingOf(fields: RenderedField[], values: Values): string[] { + return fields + .filter((f) => { + const value = values[f.name]; + if (f.kind === "protected" || f.kind === "boolean") return false; + if (f.kind === "multiselect") { + const n = Array.isArray(value) ? value.length : 0; + if (n === 0 && !f.required) return false; + return n < (f.min ?? 0) || (f.max !== undefined && n > f.max); + } + if (f.kind === "number" && value !== undefined) { + return typeof value !== "number" || !Number.isFinite(value) || (f.integer === true && !Number.isSafeInteger(value)); + } + return f.required && (value === undefined || value === ""); + }) + .map((f) => f.label); +} + /** Most options a question may have and still show as buttons. */ const MAX_CHOICES = 8; @@ -67,15 +74,22 @@ function singleChoice(fields: RenderedField[]): RenderedField | undefined { } /** - * Renders a checkout's pending user action (shipping, size, a question) from - * its JSON Schema, walked into fields by `renderPendingAction` in core. + * Renders a checkout's pending user action (shipping, size, a sign-in) from + * its fields, as `renderPendingAction` in core lists them. * * A question that is one choice shows its options as buttons, and a tap - * answers it. Everything else is a form with a submit button. + * answers it. Everything else is a form with a submit button. A protected + * field, such as a password, is Crossmint's own field: on submit, each one + * is collected into Crossmint's vault, and the form sends only the ids. */ export function PendingActionForm({ action, onSubmit, submitting = false, submitLabel = "Continue", className }: PendingActionFormProps) { const [values, setValues] = React.useState(() => defaultsFor(action.fields)); - const [picked, setPicked] = React.useState(undefined); + const [picked, setPicked] = React.useState(undefined); + // Optional secrets are left out until the user chooses to add them. + const [included, setIncluded] = React.useState>(() => requiredSecrets(action.fields)); + const [errors, setErrors] = React.useState>({}); + const [collecting, setCollecting] = React.useState(false); + const refs = React.useRef(new Map()); // Start over for a new question, and only then. A poll hands back the same // question as a fresh object every few seconds; keying on the object would @@ -86,15 +100,62 @@ export function PendingActionForm({ action, onSubmit, submitting = false, submit setShownId(action.id); setValues(defaultsFor(action.fields)); setPicked(undefined); + setIncluded(requiredSecrets(action.fields)); + setErrors({}); } - const update = (path: string[], value: unknown) => setValues((prev) => setAt(prev, path, value)); + const busy = submitting || collecting; + const update = (name: string, value: Value) => setValues((prev) => ({ ...prev, [name]: value })); const choice = singleChoice(action.fields); + const missing = missingOf(action.fields, values); + + const submit = async () => { + if (busy || missing.length > 0) return; + const answers: FormAnswers = {}; + for (const f of action.fields) { + const value = values[f.name]; + if (f.kind === "protected" || value === undefined || value === "") continue; + if (Array.isArray(value) && value.length === 0 && !f.required) continue; + answers[f.name] = value; + } + const secrets = action.fields.filter((f) => f.kind === "protected" && included.has(f.name)); + if (secrets.length > 0) { + setCollecting(true); + setErrors({}); + try { + // Every field reports at once, so the user sees each problem together. + const results = await Promise.all( + secrets.map(async (f) => { + const field = refs.current.get(f.name); + if (!field) { + return [f.name, { status: "unavailable", message: "The secure field is still loading. Try again." }] as const; + } + return [f.name, await field.collect()] as const; + }), + ); + const failed: Record = {}; + for (const [name, result] of results) { + if (result.status === "collected") answers[name] = result.input; + else failed[name] = result.message; + } + if (Object.keys(failed).length > 0) { + setErrors(failed); + return; + } + } catch { + // An unexpected SDK fault: say so, never the provider's payload. + setErrors(Object.fromEntries(secrets.map((f) => [f.name, "The secure field is unavailable. Try again."]))); + return; + } finally { + setCollecting(false); + } + } + await onSubmit(answers); + }; const heading = (
-

{action.title}

- {action.description ?

{action.description}

: null} + {action.title ?

{action.title}

: null} {action.expiresAt ? (

Answer before {formatDateTime(action.expiresAt)}.

) : null} @@ -107,21 +168,21 @@ export function PendingActionForm({ action, onSubmit, submitting = false, submit {heading}
{choice.options!.map((o) => { - const busy = submitting && picked === o.value; + const sending = submitting && picked === o.value; return ( ); @@ -136,69 +197,116 @@ export function PendingActionForm({ action, onSubmit, submitting = false, submit className={cn("flex flex-col gap-5", className)} onSubmit={(e) => { e.preventDefault(); - void onSubmit(values); + void submit(); }} > {heading} - - ); } -function FieldList({ - fields, - values, - update, +function requiredSecrets(fields: RenderedField[]): ReadonlySet { + return new Set(fields.filter((f) => f.kind === "protected" && f.required).map((f) => f.name)); +} + +/** A protected field, or for an optional one, the choice to add it. */ +function SecretField({ + field, + included, + onInclude, + collectorRef, + error, disabled, }: { - fields: RenderedField[]; - values: Values; - update: (path: string[], value: unknown) => void; + field: RenderedField; + included: boolean; + onInclude: (include: boolean) => void; + collectorRef: React.Ref; + error?: string; disabled: boolean; }) { + const id = `paf-${field.name}`; return ( -
- {fields.map((f) => ( - - ))} +
+ {field.required ? null : ( +
+ onInclude(e.target.checked)} + /> + +
+ )} + {included ? ( + + ) : null}
); } function Field({ field, - values, + value, update, disabled, }: { field: RenderedField; - values: Values; - update: (path: string[], value: unknown) => void; + value: Value; + update: (name: string, value: Value) => void; disabled: boolean; }) { - const id = `paf-${field.path.join("-")}`; - const value = getAt(values, field.path); + const id = `paf-${field.name}`; const label = ( ); - const help = field.description ?

{field.description}

: null; switch (field.kind) { - case "object": - return ( -
- {field.label} - {help} - -
- ); case "boolean": return (
@@ -206,13 +314,11 @@ function Field({ id={id} type="checkbox" className="size-4 rounded border-input accent-primary" - checked={Boolean(value)} - required={field.required} + checked={value === true} disabled={disabled} - onChange={(e) => update(field.path, e.target.checked)} + onChange={(e) => update(field.name, e.target.checked)} /> {label} - {help}
); case "select": @@ -224,49 +330,46 @@ function Field({ className={FIELD} required={field.required} disabled={disabled} - value={value === undefined || value === null ? "" : String(value)} - onChange={(e) => { - const raw = e.target.value; - const match = field.options?.find((o) => String(o.value) === raw); - update(field.path, match ? match.value : raw); - }} + value={typeof value === "string" ? value : ""} + onChange={(e) => update(field.name, e.target.value === "" ? undefined : e.target.value)} > - {field.options?.map((o) => ( - ))} - {help}
); - case "array": + case "multiselect": { + const picked = Array.isArray(value) ? value : []; return ( -
- {label} - - update( - field.path, - e.target.value - .split(",") - .map((s) => s.trim()) - .filter(Boolean), - ) - } - /> - {help} -
+
+ + {field.label} + {field.required ? * : null} + + {field.options?.map((o) => ( +
+ + update(field.name, e.target.checked ? [...picked, o.value] : picked.filter((v) => v !== o.value)) + } + /> + +
+ ))} +
); + } case "number": return (
@@ -275,32 +378,43 @@ function Field({ id={id} className={FIELD} type="number" - inputMode="decimal" + inputMode={field.integer ? "numeric" : "decimal"} + step={field.integer ? 1 : "any"} required={field.required} disabled={disabled} - value={value === undefined || value === null ? "" : String(value)} - onChange={(e) => update(field.path, e.target.value === "" ? undefined : Number(e.target.value))} + value={typeof value === "number" ? String(value) : ""} + onChange={(e) => update(field.name, e.target.value === "" ? undefined : Number(e.target.value))} /> - {help}
); default: { - const type = - field.kind === "email" ? "email" : field.kind === "url" ? "url" : field.kind === "date" ? (field.format === "date-time" ? "datetime-local" : "date") : "text"; + const shared = { + id, + required: field.required, + disabled, + placeholder: field.placeholder, + autoComplete: field.autoComplete, + value: typeof value === "string" ? value : "", + }; return (
{label} - update(field.path, e.target.value)} - /> - {help} + {field.multiline ? ( +