From dee53c196a9acb82ce369c943c8067417a92f197 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 18 Aug 2026 13:08:49 -0700 Subject: [PATCH] Probe sample envelope always carries resource; thread extensions; bump to 2.9.0 x402 v2 envelope validators (mppx, and the shared discovery engine behind x402scan and MPPScan) refuse a PAYMENT-REQUIRED header without a resource block, so a discovery-probe sample that omitted it read as 'no valid x402 response' on every probe-configured merchant however correct its accepts were. The same engines read the Bazaar extension's example input to build valid bodies for their follow-up probes, so a probe without extensions got re-probed with junk bodies and the merchant's validation answers (400/404) were reported as paywall failures. - buildDiscoveryProbeResponse: the sample envelope now always carries a v2 resource (explicit resource, resourceUrl shorthand, or synthesized from the realm) and threads optional extensions into the header and body. - Checkout: when discoveryProbe is configured with an x402Sample, the sample inherits resource (checkout url + resourceInfo) and enriched Bazaar extensions from discoveryExtensions; explicit values still win. - Tests pin all three resource sources, the extensions passthrough, and the Checkout inheritance; README/CLAUDE.md document the behavior. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- README.md | 2 +- package.json | 2 +- src/checkout.ts | 40 ++++++++++++++++++++++++- src/discovery/probe.ts | 31 +++++++++++++++++-- tests/discovery/probe.test.ts | 54 ++++++++++++++++++++++++++++++++++ tests/seamless-helpers.test.ts | 34 +++++++++++++++++++++ 7 files changed, 158 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 79edb43..2dfe05f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ Every helper is extracted from a real consumer, not speculated. | `@agent-score/commerce/identity/{hono,express,fastify,nextjs,web}` | Trust gate middleware (KYC, age, sanctions, jurisdiction). Each adapter exports a `conditionalAgentscoreGate(options)` variant (Next.js / Web Fetch use the wrapper form `withConditionalAgentScoreGate(opts, handler)` / `createConditionalAgentScoreGate(opts) => guard(req)`) that fires only on settle legs — discovery legs (no payment credential) flow through and the handler emits a 402 with all rails. Adapters export ONLY framework-specific surface (gate fns, accessors, `captureWallet`); shared helpers like `hasPaymentHeader` / `denialReasonToBody` import from their canonical home (`@agent-score/commerce/payment` and `@agent-score/commerce` respectively). | | `@agent-score/commerce/identity/policy` | Framework-agnostic per-product / per-tier compliance policy helpers: `PolicyBlock`, `buildGateFromPolicy`, `runGateWithEnforcement`, `shippingCountryAllowed`, `shippingStateAllowed`, `validateShippingAgainstPolicy` (one-call country+state validator that raises `CheckoutValidationError` with the canonical envelope on miss) | | `@agent-score/commerce/payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `createX402Server` (peer-dep `@x402/core` + `@coinbase/x402` for the Coinbase facilitator), `buildX402AcceptsFor402` (one-call helper for the 402-emit path: builds the requirements via the registered scheme so `extra.name` matches the on-chain USDC contract per network), `buildDefaultCheckoutRails({tempo?, x402Base?, solanaMpp?, stripe?})` (canonical 4-rail `rails` dict factory: merchants pass per-rail overrides instead of redeclaring the recipient sentinel + network/chainId/token boilerplate. When a caller overrides `network` without pinning `token` / `chainId`, the helper derives them from the network: Base Sepolia → Sepolia USDC + chainId 84532, Solana devnet → devnet USDC mint. Explicit overrides always win. Solana's `network` field accepts both CAIP-2 (`solana:5eykt4UsFv8…` / `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) AND the raw `@solana/mpp` form (`mainnet-beta` / `devnet` / `localnet`)), `buildMppxComposeRails({amountUsd, tempoRecipient?, solanaRecipient?, ...})` (per-call intent factory replacing the hand-rolled `[['tempo/charge',{...}],['solana/charge',{...}],['stripe/charge',{...}]]` array; auto-handles USD→atomic conversion for Solana; auto-drops the `stripe/charge` rail with a one-time `console.warn` when `amountUsd < 0.50` since Stripe's fixed ~$0.30 fee makes sub-50-cent charges unprofitable — many Stripe accounts also reject PI creation below the floor with `amount_too_small`; sub-50-cent APIs pass `includeStripe: false` explicitly to silence the warning), `createMppxServer` (peer-dep `mppx`; the solana rail settles against a static treasury recipient whose USDC ATA is pre-funded out-of-band. `@solana/mpp` keeps the primary recipient's ATA out of scope on the client, so no self-referential split or ATA-creation flag is emitted; `@solana/mpp` 0.7.0 additionally rejects a primary-recipient split in fee-sponsored mode), `composeMppxRequest` (typed wrapper around `mppx.compose(...intents)(request)`; replaces the `(mppx as any).compose(...)` cast in custom `composeMppx` hooks), `mppxChallengeHeaders` (one-call extractor for the 402 path's `Object.fromEntries(challenge.headers)`), `processX402Settle` (verify+settle in one call), `isEvmNetwork`/`isSolanaNetwork` (CAIP-2 discriminators that hide the `startsWith('eip155:')` / `startsWith('solana:')` prefix matching), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header | -| `@agent-score/commerce/discovery` | Discovery probe middleware (`isDiscoveryProbeRequest`, `buildDiscoveryProbeResponse`), Bazaar wrapper, `/.well-known/mpp.json` builder, `llms.txt` builder, `skill.md` builder (Claude-Skill-compatible agent-discovery manifest), `buildRedemptionSkillMd` (delivery-neutral; printed/emailed/API-trial codes all covered via `deliveryIntro`/`bodyShape`/`bodyRules`/`extraRecoveryRows` overrides), `buildMerchantIndexJson` + `standardEndpointDescriptions({kind})` (canonical `/` discovery body for goods or API merchants), `buildSuccessNextSteps` (universal Passport-active success block), `buildAgentscoreOnboardingSteps`, OpenAPI snippets, `noindexNonDiscoveryPaths` Hono middleware. Plus the UCP/JWKS publish surface: `buildSignedUcpResponse`, `buildSignedJwksResponse`, `wellKnownPreflightResponse`, `defaultA2aServices`, `bootstrapUcpSigningKey`, framework-neutral `SignedDiscoveryResponse` + per-framework wrappers `signedResponse{Hono,Express,Fastify,Nextjs,Web}` | +| `@agent-score/commerce/discovery` | Discovery probe middleware (`isDiscoveryProbeRequest`, `buildDiscoveryProbeResponse`; the x402 sample envelope always carries a v2 `resource` and threads optional `extensions`, and `Checkout` fills both from its own `url`/`resourceInfo`/`discoveryExtensions`, because envelope validators (mppx, x402scan's shared engine) hard-require `resource` and read the Bazaar example input to build valid probe bodies), Bazaar wrapper, `/.well-known/mpp.json` builder, `llms.txt` builder, `skill.md` builder (Claude-Skill-compatible agent-discovery manifest), `buildRedemptionSkillMd` (delivery-neutral; printed/emailed/API-trial codes all covered via `deliveryIntro`/`bodyShape`/`bodyRules`/`extraRecoveryRows` overrides), `buildMerchantIndexJson` + `standardEndpointDescriptions({kind})` (canonical `/` discovery body for goods or API merchants), `buildSuccessNextSteps` (universal Passport-active success block), `buildAgentscoreOnboardingSteps`, OpenAPI snippets, `noindexNonDiscoveryPaths` Hono middleware. Plus the UCP/JWKS publish surface: `buildSignedUcpResponse`, `buildSignedJwksResponse`, `wellKnownPreflightResponse`, `defaultA2aServices`, `bootstrapUcpSigningKey`, framework-neutral `SignedDiscoveryResponse` + per-framework wrappers `signedResponse{Hono,Express,Fastify,Nextjs,Web}` | | `@agent-score/commerce/challenge` | 402-body builders: accepted_methods, identity metadata (auto-attached by `Checkout` when wallet header present), how_to_pay, agent_instructions, build402Body, pricing, agent_memory, `buildValidationError` (4xx body builder), `Receipt` (canonical 200-receipt shape) | | `@agent-score/commerce/stripe-multichain` | Multichain PaymentIntent helper (`createMultichainPaymentIntent` returns `{ paymentIntentId, depositAddresses }`; read `depositAddresses[network]` directly), `createPayToAddressFromStripePI({request, amountCents, stripe, piCache, networks?, staticRecipients?, metadata?, orderId?, preferredNetwork?})` — one-call per-order payTo resolver matching `Checkout.mintRecipients`: on the settle leg, reuses the buyer's signed-against payTo from the MPP credential (after `piCache.hasAddress` check OR a `staticRecipients` match — the static address is always-accepted because the merchant owns it); on the discovery leg, mints a fresh PI for the rails NOT covered by `staticRecipients`, caches the merged map, registers static addresses with `piCache.cacheAddress`. `mintMultichainRecipients({...same opts}) => { recipients, paymentIntentId?, reusedFromCredential }` — structured variant that returns the full per-rail map; prefer this when the merchant's `mintRecipients` hook needs all rail addresses (typical multi-rail merchant), and to avoid the "returned-string-is-ambiguous" trap on the settle leg when `staticRecipients` is configured (the bound recipient might be the solana static, not the tempo per-PI). Use `staticRecipients: { solana: '' }` for low-margin endpoints where rotating per-PI Solana addresses can't absorb MPP spec §13.6's ~$0.50 ATA rent per call — the SDK skips Stripe minting on that network and reuses the static recipient forever; pair with a one-time external USDC pre-funding of the recipient's ATA and every settle pays only the per-tx fee. Testnet simulator (`simulateCryptoDeposit`, `simulateDepositIfTestMode`), `simulateDepositForOutcome({outcome, depositAddress, getPaymentIntentId, stripeSecretKey, stripeVersion?})` (dispatches the simulator based on a Checkout / computeFirstCheckout settle outcome; replaces the per-merchant rail-switch + thin `simulateDepositIfTestnet(addr, network)` wrapper), `networkForOutcome` (outcome → simulator network arg, handles both Checkout-shaped `railKey` and computeFirstCheckout-shaped `mppMethod`, accepts bare scheme names AND `/charge` forms), `createPiCache`, `createMppxStripe` | | `@agent-score/commerce/api` | Re-exports `AgentScore` + `AgentScoreError` from `@agent-score/sdk` | diff --git a/README.md b/README.md index b97e9e5..4d9cb7c 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ npm install hono mppx @x402/core @x402/evm @solana/mpp @solana/kit stripe # wh | `/identity/policy` | Per-product compliance helpers for multi-product merchants (each product carries its own policy: hard gate vs soft vs none, per-product shipping allowlists): `PolicyBlock`, `GateResult`, `EnforcementMode`, `IdentityStatus`, `buildGateFromPolicy`, `runGateWithEnforcement`, `shippingCountryAllowed`, `shippingStateAllowed`, `validateShippingAgainstPolicy` (one-call country+state validator that raises `CheckoutValidationError` with the canonical envelope on miss). | | `/identity/{nextjs,web}` | Same gate, wrapper pattern: `withAgentScoreGate(opts, handler)` / `createAgentScoreGate(opts) => guard(req)`. The `data` + `degraded` + `infraReason` + `getSignerVerdict` fields land directly on the handler arg / guard result (no separate getter). Plus shared `captureWallet`. | | `/payment` | `networks`, `USDC`, `rails` registries; `paymentDirective`, `buildPaymentDirective`, `wwwAuthenticateHeader`, `paymentRequiredHeader`, `aliasAmountFields` (opt-in v1↔v2 amount-field shim: adds both `amount` and `maxAmountRequired` to an entry. The 402 builders do NOT apply it by default, strict x402 v2 settlement matches the agent's echoed requirement by exact comparison, so an extra field the server's rebuilt requirement lacks breaks settle; use only when you know a client is hardcoded to read `maxAmountRequired`), `settlementOverrideHeader`, `dispatchSettlementByNetwork`, `extractPaymentSigner` (Request-based; recovers signer from x402 EIP-3009 `payload.authorization.from` OR MPP `Authorization: Payment ` `did:pkh:eip155::` / `did:pkh:solana::` source DID, including Solana `TransferChecked` authority fallback when `@solana/kit` is installed), `extractPaymentSignerFromAuth` (header-string variant for callers that already have the `Authorization` value in hand), `detectRailFromHeaders` (returns `"x402"` / `"mpp"` / `null` from inbound headers); `createX402Server`, `createMppxServer` (the solana rail settles against a static treasury recipient with a pre-funded USDC ATA; `@solana/mpp` keeps the primary recipient's ATA out of scope on the client, so no split is emitted, and `@solana/mpp` 0.7.0 rejects a primary-recipient split in fee-sponsored mode), `buildDefaultCheckoutRails({tempo?, x402Base?, solanaMpp?, stripe?})` (canonical 4-rail `rails` dict factory: flipping `network` alone derives the right `token` + `chainId`, Base Sepolia → Sepolia USDC + chainId 84532, Solana devnet → devnet USDC mint. Solana's `network` accepts both CAIP-2 and the raw `@solana/mpp` form `mainnet-beta` / `devnet` / `localnet`. Explicit overrides always win), `buildMppxComposeRails({amountUsd, tempoRecipient?, solanaRecipient?, ...})` (per-call intent factory replacing the hand-rolled `[['tempo/charge',{...}],['solana/charge',{...}],['stripe/charge',{...}]]` array; auto-handles USD→atomic conversion for Solana; auto-drops `stripe/charge` when `amountUsd < 0.50` since Stripe's fixed ~$0.30 fee makes sub-50-cent charges unprofitable, sub-50-cent APIs pass `includeStripe: false` explicitly to silence the warning); drop-in x402 helpers: `validateX402NetworkConfig` (boot-time guard), `verifyX402Request` (parse + validate inbound X-Payment), `processX402Settle` (verify-then-settle with one call), `classifyX402SettleResult` (maps the tagged settle result to a recommended HTTP status / code / nextSteps so merchants get a controlled envelope without coupling to facilitator-specific error text), `classifyOrchestrationError` (same `ClassifiedX402Error` shape but for uncaught exceptions thrown elsewhere in the orchestration; returns `null` for unknown errors so merchants rethrow instead of swallowing); `zeroAmountCarveOut` (skip upstream verify+settle for $0 settles the upstream cannot process: x402 value=0 payloads and non-proof MPP credentials, e.g. an agent that signed against a nonzero quote the merchant re-priced to $0 at settle; parses the credential, lifts signer + network, returns a `ZeroSettleResult` shaped identically to the success path so callers branch on rail, not on result shape. MPP `proof` credentials are NOT carved out: `Checkout` delegates those to mppx's native zero-amount verification), `mppCredentialPayloadType` (reads `payload.type` from an `Authorization: Payment` header without an mppx dependency; the router behind that delegation); `usdToAtomic` (BigInt-based USD → atomic value, ROUND_HALF_UP, for Tempo / Solana / Base USDC amount construction). | -| `/discovery` | `isDiscoveryProbeRequest`, `buildDiscoveryProbeResponse` (with optional `x402Sample` for x402-aware crawlers, e.g. `awal x402 details`), `sampleX402AcceptForNetwork` (USDC sample-accept builder for known CAIP-2 networks), `buildWellKnownMpp`, `buildLlmsTxt` + `llmsTxtIdentitySection` + `llmsTxtPaymentSection` (compact + verbose modes), `buildSkillMd` (Claude-Skill-compatible `/skill.md` agent-discovery manifest; strictly agent-facing data only, no internal posture), `buildRedemptionSkillMd` (delivery-neutral redemption-code template, printed mailers, emailed codes, API trial credits all covered; `endpointPath`/`deliveryIntro`/`bodyShape`/`bodyRules`/`extraRecoveryRows` overrides for non-goods shapes), `agentscoreOpenApiSnippets`, `createBazaarDiscovery`, `noindexNonDiscoveryPaths` (Hono middleware emitting `X-Robots-Tag: noindex` on every path except the agent-discovery surfaces; pure helpers `isDiscoveryPath` + `defaultDiscoveryPaths` for non-Hono frameworks), `buildMerchantIndexJson` (canonical `/` discovery body), `standardEndpointDescriptions({kind})` (canonical method+path → description map for goods vs api merchants; optional `includeOrderStatusRoute` for goods), `buildSuccessNextSteps` (universal Passport-active success block), `buildAgentscoreOnboardingSteps` (canonical skill.md onboarding for goods or API merchants). Plus the UCP/JWKS publish surface: `buildSignedUcpResponse`, `buildSignedJwksResponse`, `wellKnownPreflightResponse`, `defaultA2aServices`, `bootstrapUcpSigningKey`, and the framework-neutral `SignedDiscoveryResponse` + per-framework wrappers `signedResponse{Hono,Express,Fastify,Nextjs,Web}`. | +| `/discovery` | `isDiscoveryProbeRequest`, `buildDiscoveryProbeResponse` (with optional `x402Sample` for x402-aware crawlers, e.g. `awal x402 details`; the sample envelope always carries a v2 `resource` (explicit, from `resourceUrl`, or synthesized from the realm) because envelope validators require it, and threads optional `extensions` (e.g. the Bazaar block with input/output schemas) into header and body so discovery engines can build valid follow-up probe bodies; `Checkout` fills both from its own `url`/`resourceInfo`/`discoveryExtensions` automatically), `sampleX402AcceptForNetwork` (USDC sample-accept builder for known CAIP-2 networks), `buildWellKnownMpp`, `buildLlmsTxt` + `llmsTxtIdentitySection` + `llmsTxtPaymentSection` (compact + verbose modes), `buildSkillMd` (Claude-Skill-compatible `/skill.md` agent-discovery manifest; strictly agent-facing data only, no internal posture), `buildRedemptionSkillMd` (delivery-neutral redemption-code template, printed mailers, emailed codes, API trial credits all covered; `endpointPath`/`deliveryIntro`/`bodyShape`/`bodyRules`/`extraRecoveryRows` overrides for non-goods shapes), `agentscoreOpenApiSnippets`, `createBazaarDiscovery`, `noindexNonDiscoveryPaths` (Hono middleware emitting `X-Robots-Tag: noindex` on every path except the agent-discovery surfaces; pure helpers `isDiscoveryPath` + `defaultDiscoveryPaths` for non-Hono frameworks), `buildMerchantIndexJson` (canonical `/` discovery body), `standardEndpointDescriptions({kind})` (canonical method+path → description map for goods vs api merchants; optional `includeOrderStatusRoute` for goods), `buildSuccessNextSteps` (universal Passport-active success block), `buildAgentscoreOnboardingSteps` (canonical skill.md onboarding for goods or API merchants). Plus the UCP/JWKS publish surface: `buildSignedUcpResponse`, `buildSignedJwksResponse`, `wellKnownPreflightResponse`, `defaultA2aServices`, `bootstrapUcpSigningKey`, and the framework-neutral `SignedDiscoveryResponse` + per-framework wrappers `signedResponse{Hono,Express,Fastify,Nextjs,Web}`. | | `/challenge` | `build402Body`, `buildAcceptedMethods`, `buildIdentityMetadata`, `buildHowToPay`, `buildAgentInstructions` (auto-emits per-rail `compatible_clients`: smoke-verified CLIs the agent should use; vendor override supported; pure helper `compatibleClientsByRails(rails)` returns the same map for vendors building custom 402s), `buildPricingBlock`, `firstEncounterAgentMemory`, `Receipt` (canonical 200-receipt shape returned post-settle, universal: goods merchants fill the shipping/fulfillment/product slots, API merchants populate only the universal fields); `respond402`, a drop-in 402 emit that preserves mppx's `WWW-Authenticate` and layers x402's `PAYMENT-REQUIRED`. `buildValidationError`: structured 4xx body builder (`{error: {code, message}, required_fields?, example_body?, next_steps?, ...extra}`) so vendors compose body shapes by name instead of inlining at every validation site. | | `/stripe-multichain` | `createMultichainPaymentIntent` (returns `{ paymentIntentId, depositAddresses }`, read `depositAddresses[network]` directly), `createPayToAddressFromStripePI({request, amountCents, stripe, piCache, networks?, staticRecipients?, metadata?, orderId?, preferredNetwork?})`, per-order payTo resolver: on the settle leg, reuses the buyer's signed-against payTo from the MPP credential (after `piCache.hasAddress` check OR a `staticRecipients` match, the static address is always-accepted because the merchant owns it); on the discovery leg, mints a fresh PI for the rails NOT covered by `staticRecipients`, caches the merged map, registers static addresses with `piCache.cacheAddress` so verify-leg lookups pass. `mintMultichainRecipients({...same opts})`, structured variant that returns `{ recipients: Record, paymentIntentId?, reusedFromCredential }`; prefer this when your `mintRecipients` hook needs the full per-rail map (typical multi-rail merchant). Solana MPP requires a static recipient: pass `staticRecipients: { solana: '' }` and pre-fund its USDC ATA once from any external wallet. Rotating (per-order, Stripe-minted) Solana recipients do not settle, since the settlement transaction does not create the recipient's token account; the SDK logs a warning when one is minted. The static recipient is reused forever, each settle pays only the ~$0.001 per-tx fee, and this also sidesteps the per-call ATA rent (~$0.50 against MPP spec §13.6). `simulateCryptoDeposit`, `createMppxStripe`; `createPiCache` (TTL'd PI / deposit-address cache, Redis-backed when `redisUrl` set, in-memory otherwise), `simulateDepositIfTestMode` (gates on `sk_test_` and looks up the PI for you), `STRIPE_TEST_TX_HASH_SUCCESS` / `STRIPE_TEST_TX_HASH_FAILED` constants. Peer dep on `stripe`. | | `/api` | Everything from `@agent-score/sdk` re-exported in one place: `AgentScore` + `AgentScoreError`, `AGENTSCORE_TEST_ADDRESSES` + `isAgentScoreTestAddress`. **Don't add `@agent-score/sdk` as a separate dep**; the two can drift versions and cause subtle type mismatches. | diff --git a/package.json b/package.json index 2230903..85afec2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/commerce", - "version": "2.8.0", + "version": "2.9.0", "description": "Agentic commerce SDK: identity middleware (Hono, Express, Fastify, Next.js, Web Fetch) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agentic commerce.", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/src/checkout.ts b/src/checkout.ts index a22400e..8399eb3 100644 --- a/src/checkout.ts +++ b/src/checkout.ts @@ -250,6 +250,8 @@ export interface DiscoveryProbeConfig { accepts?: unknown[]; amountAtomic?: string; resourceUrl?: string; + resource?: Record; + extensions?: Record; }; } @@ -1161,6 +1163,42 @@ export class Checkout { if (isProbe) { const { buildDiscoveryProbeResponse } = await import('./discovery/probe.js'); const cfg = this.discoveryProbe; + // Default the sample envelope's `resource` and `extensions` from what the + // real 402 would emit (the checkout's own url + resourceInfo, and the + // enriched Bazaar extensions): v2 validators hard-require `resource`, and + // discovery engines read the Bazaar example input to build valid bodies + // for their follow-up probes. Explicit x402Sample values still win. + let x402Sample = cfg.x402Sample; + if (x402Sample !== undefined) { + const defaultResource: Record | undefined = + x402Sample.resource ?? (x402Sample.resourceUrl !== undefined ? undefined : { + url: this.url, + mimeType: 'application/json', + ...(this.resourceInfo?.description !== undefined && { description: this.resourceInfo.description }), + ...(this.resourceInfo?.serviceName !== undefined && { serviceName: this.resourceInfo.serviceName }), + ...(this.resourceInfo?.tags !== undefined && { tags: this.resourceInfo.tags }), + ...(this.resourceInfo?.iconUrl !== undefined && { iconUrl: this.resourceInfo.iconUrl }), + }); + let defaultExtensions = x402Sample.extensions; + if (defaultExtensions === undefined && this.discoveryExtensions !== undefined) { + let requestPath = this.url; + try { + requestPath = new URL(this.url).pathname; + } catch { + /* malformed url: fall back to the raw url */ + } + const enriched = await enrichBazaarDiscoveryExtensions(this.discoveryExtensions, { + method: request.method, + path: requestPath, + }); + if (enriched !== undefined && Object.keys(enriched).length > 0) defaultExtensions = enriched; + } + x402Sample = { + ...x402Sample, + ...(defaultResource !== undefined && { resource: defaultResource }), + ...(defaultExtensions !== undefined && { extensions: defaultExtensions }), + }; + } const probe = buildDiscoveryProbeResponse({ realm: cfg.realm, sampleRail: cfg.sampleRail, @@ -1170,7 +1208,7 @@ export class Checkout { ...(cfg.ttlSeconds !== undefined && { ttlSeconds: cfg.ttlSeconds }), ...(cfg.docsUrl !== undefined && { docsUrl: cfg.docsUrl }), ...(cfg.message !== undefined && { message: cfg.message }), - ...(cfg.x402Sample !== undefined && { x402Sample: cfg.x402Sample }), + ...(x402Sample !== undefined && { x402Sample }), }); return { status: probe.status, diff --git a/src/discovery/probe.ts b/src/discovery/probe.ts index 5b3a3d4..ec6e962 100644 --- a/src/discovery/probe.ts +++ b/src/discovery/probe.ts @@ -117,6 +117,18 @@ interface DiscoveryProbeOptions { amountAtomic?: string; /** Resource URL the probe is responding for. Used in the PAYMENT-REQUIRED header. */ resourceUrl?: string; + /** Full x402 v2 ResourceInfo for the PAYMENT-REQUIRED header. Overrides the + * `resourceUrl` shorthand. When neither is set, a minimal resource is + * synthesized from the realm: v2 envelope validators (mppx, x402scan's + * shared engine) hard-require `resource`, so a resource-less sample header + * reads as "no valid x402 response" however correct the accepts are. */ + resource?: Record; + /** x402 v2 `extensions` for the sample envelope (header AND body), e.g. the + * Bazaar block with input/output schemas. Discovery validators read the + * example input from here to build VALID probe bodies for the follow-up + * checks, so a probe without it gets probed with junk bodies instead. + * `Checkout` fills this from its own `discoveryExtensions` automatically. */ + extensions?: Record; }; } @@ -172,14 +184,25 @@ export function buildDiscoveryProbeResponse(opts: DiscoveryProbeOptions): Discov ?? (opts.x402Sample.networks ?? []) .map((n) => sampleX402AcceptForNetwork(n, opts.x402Sample!.amountAtomic ?? '1000000')) .filter((e): e is Record => e !== null); + // The v2 envelope REQUIRES `resource`: validators (mppx, x402scan's shared + // engine) refuse a resource-less PAYMENT-REQUIRED header outright, so when + // the caller supplied neither form, synthesize a minimal one from the realm. + const realmUrl = opts.realm.startsWith('http') ? opts.realm : `https://${opts.realm}`; + const resource = (opts.x402Sample.resource + ?? (opts.x402Sample.resourceUrl + ? { url: opts.x402Sample.resourceUrl, mimeType: 'application/json' } + : { url: realmUrl, mimeType: 'application/json' })) as { + url: string; + [key: string]: unknown; + }; + const extensions = opts.x402Sample.extensions; // Emit the sample accepts as-is (no v1<->v2 amount alias) so the probe sample // matches what the real 402 emits; clients version-route on `x402Version`. headers['payment-required'] = paymentRequiredHeader({ x402Version, accepts: sampleAccepts, - ...(opts.x402Sample.resourceUrl - ? { resource: { url: opts.x402Sample.resourceUrl, mimeType: 'application/json' } } - : {}), + resource, + ...(extensions ? { extensions } : {}), }); // Also embed in body for clients that read body-level accepts (e.g. awal x402 details // falls back from header → body when the header isn't present). @@ -187,6 +210,8 @@ export function buildDiscoveryProbeResponse(opts: DiscoveryProbeOptions): Discov // Reuse the header's accepts so the body matches the header exactly. const headerJson = JSON.parse(Buffer.from(headers['payment-required'], 'base64').toString('utf-8')); bodyObj.accepts = headerJson.accepts; + bodyObj.resource = headerJson.resource; + if (headerJson.extensions !== undefined) bodyObj.extensions = headerJson.extensions; } return { diff --git a/tests/discovery/probe.test.ts b/tests/discovery/probe.test.ts index ea4da7a..703587c 100644 --- a/tests/discovery/probe.test.ts +++ b/tests/discovery/probe.test.ts @@ -157,6 +157,60 @@ describe('buildDiscoveryProbeResponse — x402Sample branch', () => { expect(body.accepts[0].network).toBe(networks.base.mainnet.caip2); }); + it('always emits a v2 resource in header and body: explicit, from resourceUrl, or synthesized from realm', () => { + // v2 envelope validators (mppx, x402scan's shared engine) hard-require + // `resource`; a resource-less sample header reads as "no valid x402 response". + const fromUrl = buildDiscoveryProbeResponse({ + realm: 'merchant.example', + sampleRail: 'tempo-mainnet', + sampleAmountUsd: 1, + sampleRecipient: '0x0', + x402Sample: { networks: [networks.base.mainnet.caip2], resourceUrl: 'https://merchant.example/purchase' }, + }); + const fromUrlHeader = JSON.parse(Buffer.from(fromUrl.headers['payment-required']!, 'base64').toString('utf-8')); + expect(fromUrlHeader.resource).toEqual({ url: 'https://merchant.example/purchase', mimeType: 'application/json' }); + expect(JSON.parse(fromUrl.body).resource).toEqual(fromUrlHeader.resource); + + const synthesized = buildDiscoveryProbeResponse({ + realm: 'merchant.example', + sampleRail: 'tempo-mainnet', + sampleAmountUsd: 1, + sampleRecipient: '0x0', + x402Sample: { networks: [networks.base.mainnet.caip2] }, + }); + const synthHeader = JSON.parse(Buffer.from(synthesized.headers['payment-required']!, 'base64').toString('utf-8')); + expect(synthHeader.resource).toEqual({ url: 'https://merchant.example', mimeType: 'application/json' }); + + const explicit = buildDiscoveryProbeResponse({ + realm: 'merchant.example', + sampleRail: 'tempo-mainnet', + sampleAmountUsd: 1, + sampleRecipient: '0x0', + x402Sample: { + networks: [networks.base.mainnet.caip2], + resource: { url: 'https://merchant.example/api', serviceName: 'Merchant', mimeType: 'application/json' }, + }, + }); + const explicitHeader = JSON.parse(Buffer.from(explicit.headers['payment-required']!, 'base64').toString('utf-8')); + expect(explicitHeader.resource.serviceName).toBe('Merchant'); + }); + + it('threads extensions into the header envelope and mirrors them in the body', () => { + const extensions = { + bazaar: { info: { input: { type: 'http', method: 'POST', bodyType: 'json', body: { q: 'x' } } } }, + }; + const probe = buildDiscoveryProbeResponse({ + realm: 'merchant.example', + sampleRail: 'tempo-mainnet', + sampleAmountUsd: 1, + sampleRecipient: '0x0', + x402Sample: { networks: [networks.base.mainnet.caip2], extensions }, + }); + const header = JSON.parse(Buffer.from(probe.headers['payment-required']!, 'base64').toString('utf-8')); + expect(header.extensions).toEqual(extensions); + expect(JSON.parse(probe.body).extensions).toEqual(extensions); + }); + it('skips unknown CAIP-2 networks in shorthand silently', () => { const probe = buildDiscoveryProbeResponse({ realm: 'merchant.example', diff --git a/tests/seamless-helpers.test.ts b/tests/seamless-helpers.test.ts index 1af6a65..971ad0a 100644 --- a/tests/seamless-helpers.test.ts +++ b/tests/seamless-helpers.test.ts @@ -2842,6 +2842,40 @@ describe('Checkout discoveryProbe routing', () => { expect(result.headers['www-authenticate']).toContain('realm="example"'); }); + it('probe x402 sample inherits resource (checkout url + resourceInfo) and Bazaar extensions', async () => { + const { Checkout } = await import('../src/checkout'); + const checkout = new Checkout({ + rails: { tempo: { recipient: RECIPIENT, network: 'tempo-mainnet' } }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + resourceInfo: { serviceName: 'Example API', iconUrl: 'https://api.example/logo.png' }, + discoveryExtensions: { + bazaar: { info: { input: { type: 'http', method: 'POST', bodyType: 'json', body: { item: 'wine' } } } }, + }, + discoveryProbe: { + realm: 'api.example', + sampleRail: 'tempo', + sampleAmountUsd: 1.0, + sampleRecipient: RECIPIENT, + x402Sample: { networks: ['eip155:8453'] }, + }, + }); + const result = (await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: {}, + body: {}, + })) as { status: number; body: Record; headers: Record }; + expect(result.status).toBe(402); + const header = JSON.parse( + Buffer.from(result.headers['payment-required']!, 'base64').toString('utf-8'), + ) as { resource: Record; extensions: Record }; + expect(header.resource.url).toBe('https://api.example/purchase'); + expect(header.resource.serviceName).toBe('Example API'); + expect(header.extensions.bazaar).toBeDefined(); + expect(result.body.resource).toEqual(header.resource); + }); + it('POST with Payment authorization bypasses probe routing', async () => { const { Checkout } = await import('../src/checkout'); let pricingCalled = false;