diff --git a/package.json b/package.json index 5b2ed54..70b42ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/commerce", - "version": "1.8.1", + "version": "2.0.0", "description": "Agent 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 agent commerce.", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/src/challenge/body.ts b/src/challenge/body.ts index 105f444..574aa10 100644 --- a/src/challenge/body.ts +++ b/src/challenge/body.ts @@ -57,6 +57,10 @@ export function build402Body({ x402?: { accepts: unknown[]; version?: 1 | 2; + /** x402 spec `extensions` field. Per-endpoint declared extensions (e.g. + * `bazaar` discovery schema from `createBazaarDiscovery({...})`). Surfaces + * on the 402 body as `extensions` so spec-compliant crawlers can read it. */ + extensions?: Record; }; /** Vendor-specific extra fields merged at the top level. */ extra?: Record; @@ -69,6 +73,9 @@ export function build402Body({ if (x402) { body.x402Version = x402.version ?? 2; body.accepts = x402.accepts; + if (x402.extensions !== undefined && Object.keys(x402.extensions).length > 0) { + body.extensions = x402.extensions; + } } if (amountUsd !== undefined) body.amount_usd = amountUsd; diff --git a/src/checkout.ts b/src/checkout.ts index 5996135..d6e48cb 100644 --- a/src/checkout.ts +++ b/src/checkout.ts @@ -33,14 +33,25 @@ */ import { randomUUID } from 'node:crypto'; +import { denialReasonToBody } from './_response'; import { buildAcceptedMethods } from './challenge/accepted_methods'; -import { buildAgentInstructions } from './challenge/agent_instructions'; +import { type RailKey, buildAgentInstructions } from './challenge/agent_instructions'; import { firstEncounterAgentMemory } from './challenge/agent_memory'; import { build402Body } from './challenge/body'; import { buildHowToPay } from './challenge/how_to_pay'; import { buildPricingBlock, type PricingBlock } from './challenge/pricing'; import { respond402 } from './challenge/respond_402'; import { buildValidationError } from './challenge/validation_error'; +import { + type AgentIdentity, + type AgentScoreCoreOptions, + type CreateSessionOnMissing, + type DenialReason, + type EvaluateOutcome, + createAgentScoreCore, +} from './core'; +import { lazyMppxServer, lazyX402Server } from './payment/lazy'; +import { type MppxRailSpec } from './payment/mppx_server'; import { resolveRecipient, type RecipientLike, @@ -51,8 +62,10 @@ import { type X402BaseRailSpec, } from './payment/rail_spec'; import { buildX402AcceptsFor402, type X402Server } from './payment/x402_server'; -import { processX402Settle } from './payment/x402_settle'; +import { classifyX402SettleResult, processX402Settle } from './payment/x402_settle'; import { verifyX402Request } from './payment/x402_validation'; +import { zeroAmountCarveOut, type ZeroSettleRail } from './payment/zero-settle'; +import { extractPaymentSignerFromAuth } from './signer'; export type CheckoutRailSpec = | TempoRailSpec @@ -95,6 +108,15 @@ export interface PricingResult { /** Optional pre-built `PricingBlock`. When omitted, Checkout builds a minimal * block from `amountUsd` so the 402 body always carries pricing metadata. */ block?: PricingBlock; + /** Optional product block surfaced in the 402 body's `product` field. Goods + * merchants populate `{id, name, slug, list_price_usd, ...}`; API sellers leave + * this absent since per-call billing has no product concept. */ + product?: Record; + /** Optional merchant-specific fields merged into the 402 body alongside the + * standard `accepted_methods` / `agent_instructions` / `pricing` blocks. + * Useful for `redemption_code_applied`, coupon hints, or any other field the + * merchant wants the agent to see in the challenge body. */ + bodyExtras?: Record; } /** In-flight state passed to every hook in the Checkout flow. */ @@ -108,21 +130,183 @@ export interface CheckoutContext { /** rail-key → recipient address, after `mintRecipients` runs (if provided). * Static rails inherit recipients from the RailSpec. */ recipients: Record; + /** Merchant-supplied per-request state, populated by `preValidate`. Other + * hooks read from here (e.g. `ctx.state.product` after preValidate + * resolved it). Stays empty when no `preValidate` is configured. */ + state: Record; + /** Capture the signer wallet under the operator credential the gate resolved + * for this request. Set by Checkout's internal gate after a successful allow + * when an operator_token is present; `undefined` for wallet-authenticated + * requests (no operator_token to associate) or anonymous discovery legs. + * Fire-and-forget — invoke from `onSettled` with the recovered signer. */ + captureWallet?: (opts: { + walletAddress: string; + network: 'evm' | 'solana'; + idempotencyKey?: string; + }) => Promise; +} + +/** + * Derive a coarse identity status (`'verified' | 'unverified' | 'anonymous'`) + * from the assess block on a CheckoutContext. Goods merchants persist this on + * the order row so audit logs distinguish gated buyers from anonymous ones. + */ +export function getIdentityStatus( + ctx: CheckoutContext, +): 'verified' | 'unverified' | 'anonymous' { + const assess = ctx.request.assess; + if (assess === null || assess === undefined) return 'anonymous'; + const decision = (assess as { decision?: string }).decision; + if (decision === 'allow') return 'verified'; + return 'unverified'; +} + +/** + * Raised from a `preValidate` hook to short-circuit Checkout with a canonical + * 4xx envelope. + * + * Checkout catches this and emits the canonical `{ error, next_steps }` envelope + * via `buildValidationError` so merchants don't construct response bodies + * themselves in the pre-validate path. + */ +export class CheckoutValidationError extends Error { + readonly code: string; + readonly action: string; + readonly status: number; + readonly extra: Record | undefined; + constructor(opts: { + code: string; + message: string; + action?: string; + status?: number; + extra?: Record; + }) { + super(opts.message); + this.name = 'CheckoutValidationError'; + this.code = opts.code; + this.action = opts.action ?? 'fix_request'; + this.status = opts.status ?? 400; + this.extra = opts.extra; + } +} + +/** + * Hook that runs once per request before pricing/settle. Returns a state dict + * that Checkout merges into `ctx.state` so downstream hooks (`computePricing`, + * `onSettled`) can read merchant-resolved values like the product row, + * redemption code, post-discount cents, etc. + * + * Throw {@link CheckoutValidationError} to short-circuit with a 4xx envelope. + */ +export type PreValidateFn = ( + ctx: CheckoutContext, +) => Record | Promise>; + +/** Denial returned by a `CheckoutGateConfig.runGate` function. */ +export interface GateDenial { + status: number; + body: Record; + headers?: Record; +} + +/** + * Function the merchant supplies (or builds via a Checkout-gate helper) to run + * the per-request AgentScore gate. Returns `null` on accept, a `GateDenial` + * on deny. + */ +export type RunGateFn = (ctx: CheckoutContext) => Promise; + +/** + * Wires the AgentScore gate into Checkout. + * + * The SDK constructs an `AgentScoreCore` from the policy fields (KYC, age, + * sanctions, jurisdiction allowlist) and evaluates it after `preValidate` + * populates state. Supports the full `createSessionOnMissing` hook surface so + * merchants can pre-create pending orders before the verification session is + * minted (set `onBeforeSession` to upsert state keyed by `ctx.referenceId` or + * `ctx.state.*`, returning extras that the SDK folds into `reason.extra`). + * + * Three customization layers, in order of precedence: + * + * 1. ``runGate`` — full escape hatch. Replaces the SDK's gate flow entirely. + * Merchants implement assess + denial body construction themselves. Useful + * only for non-standard auth (e.g. shared signing keys, non-AgentScore IdP). + * 2. ``perRequestPolicy`` — reads `ctx.state` (populated by preValidate) and + * returns a partial policy override applied per request (e.g. product-level + * KYC requirement). When omitted, the static fields below apply uniformly. + * 3. ``onDenied`` — invoked AFTER the SDK builds the canonical DenialReason. + * Return a `GateDenial` to override the response body shape, or null to + * fall back to the canonical body from `denialReasonToBody`. + * + * Static policy fields mirror `AgentScoreCoreOptions` — see that interface + * for field semantics. + */ +export interface CheckoutGateConfig { + /** AgentScore API key. Required when `runGate` is omitted. */ + apiKey?: string; + /** Override the default `https://api.agentscore.sh` base URL. */ + baseUrl?: string; + /** Prepended to the default User-Agent on API calls. */ + userAgent?: string; + /** Require KYC verification. */ + requireKyc?: boolean; + /** Require operator clear of sanctions. */ + requireSanctionsClear?: boolean; + /** Minimum operator age bracket (18 or 21). */ + minAge?: number; + /** Blocked jurisdiction list. */ + blockedJurisdictions?: string[]; + /** Allowed jurisdiction allowlist; only these pass. */ + allowedJurisdictions?: string[]; + /** Fail-open posture for AgentScore-side infra failures. Default false. */ + failOpen?: boolean; + /** How long to cache results in seconds. Default 300. */ + cacheSeconds?: number; + /** Optional chain filter for scoring. */ + chain?: string; + /** Session-mint config for missing-identity bootstrap. Hooks receive the + * CheckoutContext so `getSessionOptions` and `onBeforeSession` can read + * state populated by `preValidate`. */ + createSessionOnMissing?: CreateSessionOnMissing; + /** Surfaced in `agent_memory` hints and audit logs. */ + merchantName?: string; + /** Free-form context label (e.g. `"purchase"`, `"orders"`). */ + context?: string; + /** Per-request policy override. Returns the partial fields to merge over + * the static config above. Return `null` to skip the gate. */ + perRequestPolicy?: (ctx: CheckoutContext) => Partial | null | Promise | null>; + /** Customize the denial response body. Called after the SDK resolves a + * DenialReason. Return a `GateDenial` to override the canonical body, or + * null to use `denialReasonToBody`. */ + onDenied?: (ctx: CheckoutContext, reason: DenialReason) => GateDenial | null | Promise; + /** Full escape hatch — replaces the SDK gate flow. */ + runGate?: RunGateFn; } /** Surface passed to `Checkout.onSettled` after a payment lands. */ export interface SettleOutcome { + /** Protocol family that handled the settle. */ rail: 'x402' | 'mpp'; /** The `PAYMENT-RESPONSE` header to echo (x402 success path). `null` for MPP. */ paymentResponseHeader: string | null; /** The underlying settle result for merchants that need to inspect tx hash / etc. */ raw: unknown; + /** On-chain transaction hash where applicable; `null` for the zero-settle carve-out + * (no on-chain settle) and for Stripe SPT. */ + txHash?: string | null; + /** Verified signer address (EVM lowercased; Solana base58 verbatim). */ + signerAddress?: string | null; + /** Network family of the signer; `'evm'` or `'solana'`. */ + signerNetwork?: string | null; + /** The merchant's `rails`-dict key that handled this settle (e.g. `'tempo'`, + * `'x402_base'`). Use to label rails in audit logs and persisted orders. */ + railKey?: string; } /** Result a `composeMppx` hook returns when handling an MPP credential. * * `status: 200` means mppx validated the `Authorization: Payment` credential - * and the settlement landed — Checkout runs `onSettled` and returns success. + * and the settlement landed; Checkout runs `onSettled` and returns success. * * `status: 402` means mppx emitted a 402 (no credential / invalid credential). * Checkout layers its rich body on top of mppx's WWW-Authenticate header and @@ -137,6 +321,15 @@ export interface MppxComposeOutcome { paymentResponseHeader?: string | null; /** The underlying mppx compose result for `onSettled` introspection. */ raw?: unknown; + /** On-chain tx hash from the mppx Receipt (when status=200 and on-chain). */ + txHash?: string | null; + /** Verified signer recovered from the credential (when status=200). */ + signerAddress?: string | null; + /** Network family of the signer; `'evm'` or `'solana'`. */ + signerNetwork?: string | null; + /** The merchant's `rails`-dict key that handled this settle. Defaults to + * `"tempo"` when unset by the hook. */ + railKey?: string; } /** Framework-neutral output of `Checkout.handle`. */ @@ -187,24 +380,129 @@ function isTempoSessionRailSpec(s: CheckoutRailSpec): s is TempoSessionRailSpec return 'escrowContract' in s && 'store' in s; } +/** Map a `*RailSpec` instance to its canonical `RailKey` slug. Tempo charge + * and Tempo session both speak MPP on Tempo, so they fold to `"tempo_mpp"`. */ +function specRailKey(spec: CheckoutRailSpec): RailKey { + if (isStripeRailSpec(spec)) return 'stripe'; + if (isTempoSessionRailSpec(spec)) return 'tempo_mpp'; + const network = (spec as { network?: string }).network ?? ''; + if (network.startsWith('eip155:')) return 'x402_base'; + if (network.startsWith('solana:') || 'rpcUrl' in spec) return 'solana_mpp'; + return 'tempo_mpp'; +} + +/** Protocol-shaped method name for the `methods: [...]` discovery array. */ +function specMethodName(spec: CheckoutRailSpec): string { + if (isStripeRailSpec(spec)) return 'stripe/spt'; + if (isTempoSessionRailSpec(spec)) return 'tempo/charge'; + const network = (spec as { network?: string }).network ?? ''; + if (network.startsWith('eip155:')) return 'x402/exact (base)'; + if (network.startsWith('solana:') || 'rpcUrl' in spec) return 'solana/charge'; + return 'tempo/charge'; +} + +/** + * Build the canonical `composeMppx` hook for pympp/mppx-backed MPP rails. + * + * Lazily resolves the server via the supplied `serverGetter` (typically the + * output of `lazyMppxServer`). Forwards the request's `Authorization: Payment` + * header and the current pricing amount to `mpp.charge`. Maps the three pympp + * outcomes to `MppxComposeOutcome`: + * + * - `Challenge` (no/invalid credential) → `status: 402` with the + * `www-authenticate` header pympp issued. + * - `(Credential, Receipt)` tuple → `status: 200` with the tx hash lifted from + * `receipt.reference` / `receipt.transaction` and the signer lifted from the + * credential's `did:pkh:...` source. + * - Any unexpected exception → `status: 402` (no headers; Checkout falls back + * to its standard 402 emit). + */ +export function makeMppxComposeHook(opts: { + serverGetter: () => Promise; +}): ComposeMppxFn { + return async (ctx: CheckoutContext): Promise => { + if (ctx.pricing === null) return { status: 402 }; + const mpp = (await opts.serverGetter()) as { + realm?: string; + charge: (args: { authorization?: string; amount: string }) => Promise; + }; + const lower = lowerHeaders(ctx.request.headers); + const authorization = lower['authorization']; + const amountStr = ctx.pricing.amountUsd.toFixed(2); + let result: unknown; + try { + result = await mpp.charge({ authorization, amount: amountStr }); + } catch { + return { status: 402 }; + } + if (!Array.isArray(result)) { + const challenge = result as { toWwwAuthenticate?: (realm: string) => string }; + const realm = mpp.realm ?? ''; + const headers: Record = + typeof challenge.toWwwAuthenticate === 'function' + ? { 'www-authenticate': challenge.toWwwAuthenticate(realm) } + : {}; + return { status: 402, headers }; + } + const [credential, receipt] = result as [ + { source?: string }, + { reference?: string; transaction?: string }, + ]; + const txHash = receipt.reference ?? receipt.transaction ?? null; + let signerAddress: string | null = null; + let signerNetwork: string | null = null; + const source = credential.source; + if (typeof source === 'string') { + const parts = source.split(':'); + if (parts.length >= 4 && parts[0] === 'did' && parts[1] === 'pkh') { + const family = parts[2]; + const addr = parts[parts.length - 1] ?? null; + if (family === 'eip155' && addr !== null) { + signerAddress = addr.toLowerCase(); + signerNetwork = 'evm'; + } else if (family === 'solana' && addr !== null) { + signerAddress = addr; + signerNetwork = 'solana'; + } + } + } + return { + status: 200, + txHash, + signerAddress, + signerNetwork, + raw: { credential, receipt }, + }; + }; +} + /** * Apply per-call recipient overrides (from `mintRecipients`) to rail specs. * Returns a new dict; original rails dict is not mutated. Stripe rails are * passed through unchanged (no on-chain recipient — they use `profileId`). + * + * When the merchant declares rails with sentinel empty-string recipients + * (per-order minting pattern) and `mintRecipients` only returns addresses + * for some rails, drop the rails that resolve to an empty recipient — those + * weren't actually minted for this request and shouldn't be advertised in + * the 402. */ function applyRecipientOverrides( rails: Record, overrides: Record, ): Record { - if (Object.keys(overrides).length === 0) return rails; const out: Record = {}; for (const [key, spec] of Object.entries(rails)) { - const override = overrides[key]; - if (override === undefined || isStripeRailSpec(spec)) { + if (isStripeRailSpec(spec)) { out[key] = spec; continue; } - out[key] = { ...spec, recipient: override } as CheckoutRailSpec; + const override = overrides[key]; + const finalRecipient = override ?? (spec as { recipient?: unknown }).recipient; + if (finalRecipient === '' || finalRecipient === undefined) continue; + out[key] = override !== undefined + ? ({ ...spec, recipient: override } as CheckoutRailSpec) + : spec; } return out; } @@ -234,26 +532,50 @@ function pickRail(rails: Record, key: string): T | * return new Response(JSON.stringify(result.body), { status: result.status, headers: result.headers }); * ``` */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class Checkout { readonly rails: Record; readonly url: string; + readonly merchantName: string | undefined; readonly computePricing: PricingFn; - readonly x402Server: X402Server | undefined; - readonly composeMppx: ComposeMppxFn | undefined; + readonly preValidate: PreValidateFn | undefined; + x402Server: X402Server | undefined; + composeMppx: ComposeMppxFn | undefined; readonly mintRecipients: RecipientsFn | undefined; readonly mintReferenceId: ReferenceIdFn | undefined; readonly onSettled: OnSettledFn | undefined; readonly isCachedAddress: IsCachedAddressFn | undefined; + readonly zeroSettleCarveOut: boolean; + readonly gate: CheckoutGateConfig | undefined; + readonly discoveryExtensions: Record | undefined; + private _x402ServerGetter: (() => Promise) | undefined; constructor(opts: { rails: Record; url: string; computePricing: PricingFn; + /** Per-request validation hook. Runs before pricing/gate/settle. Throw + * `CheckoutValidationError` to short-circuit with a 4xx envelope. Returns + * a state dict merged into `ctx.state` for downstream hooks. */ + preValidate?: PreValidateFn; /** Built via `createX402Server`. Pair it with an `X402BaseRailSpec` in - * `rails['x402_base']`; the CAIP-2 network is read from `rail.network`. */ + * `rails['x402_base']`; the CAIP-2 network is read from `rail.network`. + * When omitted, Checkout auto-derives via `lazyX402Server` from the flat + * `cdpApiKeyId` / `cdpApiKeySecret` kwargs. */ x402Server?: X402Server; - /** Required when the merchant accepts `Authorization: Payment` credentials. */ + /** Required when the merchant accepts `Authorization: Payment` credentials. + * When omitted and `mppxSecretKey` is supplied, Checkout auto-derives via + * `lazyMppxServer` + the canonical compose hook (see `makeMppxComposeHook`). */ composeMppx?: ComposeMppxFn; + /** Flat-config: when `x402Server` is omitted and any X402BaseRailSpec is in + * `rails`, Checkout lazy-builds the x402 server. Pair `cdpApiKeyId` + + * `cdpApiKeySecret` to use Coinbase's facilitator; omit both for the + * public HTTP facilitator. */ + cdpApiKeyId?: string; + cdpApiKeySecret?: string; + /** Flat-config: when `composeMppx` is omitted and an MPP rail is in `rails`, + * Checkout lazy-builds the mppx server. */ + mppxSecretKey?: string; /** Per-order deposit address minting (e.g. Stripe-multichain). */ mintRecipients?: RecipientsFn; /** Default is `randomUUID()`. */ @@ -263,8 +585,37 @@ export class Checkout { /** Pass when the merchant mints per-order addresses so `verifyX402Request` can * confirm the `payTo` was minted by this merchant. Defaults to permissive. */ isCachedAddress?: IsCachedAddressFn; + /** Engage the EIP-3009 value=0 + pympp `proof` carve-out when pricing + * resolves to $0. Goods merchants offering free redemption codes set this + * to `true` so the credential parses without an on-chain settle. */ + zeroSettleCarveOut?: boolean; + /** Per-request gate config. When set, the gate runs after `preValidate` + * populates `ctx.state` and before pricing/settle. Denials short-circuit + * with the gate's body verbatim. */ + gate?: CheckoutGateConfig; + /** Per-endpoint x402 `extensions` block emitted on the 402 body. Merge + * outputs of `createBazaarDiscovery({...})` (or other extension declarers) + * here — Checkout forwards verbatim into the 402 response body's + * `extensions` field so Bazaar crawlers and other spec-compliant clients + * read the route's declared input/output schema. */ + discoveryExtensions?: Record; }) { - if (opts.x402Server !== undefined) { + const x402Server = opts.x402Server; + let x402ServerGetter: (() => Promise) | undefined; + if (x402Server === undefined) { + const baseSpec = Object.values(opts.rails).find( + (s): s is X402BaseRailSpec => + !isTempoSessionRailSpec(s) && !isStripeRailSpec(s) && 'recipient' in s && + ((s as { network?: string }).network ?? '').startsWith('eip155:'), + ); + if (baseSpec !== undefined) { + x402ServerGetter = lazyX402Server({ + spec: baseSpec, + cdpApiKeyId: opts.cdpApiKeyId, + cdpApiKeySecret: opts.cdpApiKeySecret, + }); + } + } else { const baseSpec = opts.rails['x402_base']; if (baseSpec === undefined || !('recipient' in baseSpec)) { throw new Error( @@ -273,23 +624,114 @@ export class Checkout { ); } } + + let composeMppx = opts.composeMppx; + if (composeMppx === undefined && opts.mppxSecretKey !== undefined) { + const mppRails: Record = {}; + for (const [k, v] of Object.entries(opts.rails)) { + if (isStripeRailSpec(v) || isTempoSessionRailSpec(v) || 'recipient' in v) { + mppRails[k] = v as MppxRailSpec; + } + } + const getter = lazyMppxServer({ + rails: mppRails, + secretKey: opts.mppxSecretKey, + }); + composeMppx = makeMppxComposeHook({ serverGetter: getter }); + } + this.rails = opts.rails; this.url = opts.url; + this.merchantName = opts.gate?.merchantName; this.computePricing = opts.computePricing; - this.x402Server = opts.x402Server; - this.composeMppx = opts.composeMppx; + this.preValidate = opts.preValidate; + this.x402Server = x402Server; + this._x402ServerGetter = x402ServerGetter; + this.composeMppx = composeMppx; this.mintRecipients = opts.mintRecipients; this.mintReferenceId = opts.mintReferenceId; this.onSettled = opts.onSettled; this.isCachedAddress = opts.isCachedAddress; + this.zeroSettleCarveOut = opts.zeroSettleCarveOut ?? false; + this.gate = opts.gate; + this.discoveryExtensions = opts.discoveryExtensions; + } + + /** Canonical `RailKey` list derived from the configured rails dict. Each + * `*RailSpec` type maps to one `RailKey` (Tempo and TempoSession both fold + * to `"tempo_mpp"`). Dedupes so listing is per protocol, not per recipient. + * Use in `.well-known/mpp.json`, skill.md / llms.txt discovery responses. */ + get acceptedRails(): RailKey[] { + const out: RailKey[] = []; + const seen = new Set(); + for (const spec of Object.values(this.rails)) { + const key = specRailKey(spec); + if (seen.has(key)) continue; + seen.add(key); + out.push(key); + } + return out; + } + + /** Protocol-shaped method-name list (`"tempo/charge"`, `"x402/exact (base)"`). + * Suitable for the `methods: [...]` array of `.well-known/mpp.json`. */ + get acceptedMethodNames(): string[] { + const out: string[] = []; + const seen = new Set(); + for (const spec of Object.values(this.rails)) { + const name = specMethodName(spec); + if (seen.has(name)) continue; + seen.add(name); + out.push(name); + } + return out; + } + + /** Resolve the x402 server, awaiting the lazy getter on first use. */ + private async getX402Server(): Promise { + if (this.x402Server !== undefined) return this.x402Server; + if (this._x402ServerGetter === undefined) return undefined; + this.x402Server = await this._x402ServerGetter(); + return this.x402Server; + } + + private x402ServerAvailable(): boolean { + return this.x402Server !== undefined || this._x402ServerGetter !== undefined; + } + + /** Return the rails-dict key for the X402BaseRailSpec entry. Defaults to + * `"x402_base"` when no match found. */ + private x402RailKey(): string { + for (const [k, v] of Object.entries(this.rails)) { + if (!isStripeRailSpec(v) && !isTempoSessionRailSpec(v) && + ((v as { network?: string }).network ?? '').startsWith('eip155:')) { + return k; + } + } + return 'x402_base'; + } + + /** Return the rails-dict key for the primary MPP rail. */ + private mppRailKey(): string { + for (const [k, v] of Object.entries(this.rails)) { + const network = (v as { network?: string }).network ?? ''; + if (!isStripeRailSpec(v) && !network.startsWith('eip155:')) return k; + } + return 'tempo'; } - /** CAIP-2 read from `rails['x402_base'].network` (or its default). */ + /** CAIP-2 read from `rails['x402_base'].network` (or its default). + * Defined only when an `X402BaseRailSpec` is present in rails AND a server + * is configured (explicit or auto-derived); otherwise `null`. */ get x402BaseNetwork(): string | null { - if (this.x402Server === undefined) return null; - const spec = this.rails['x402_base'] as X402BaseRailSpec | undefined; - if (spec === undefined) return null; - return spec.network ?? 'eip155:8453'; + if (!this.x402ServerAvailable()) return null; + for (const spec of Object.values(this.rails)) { + if (!isStripeRailSpec(spec) && !isTempoSessionRailSpec(spec) && + ((spec as { network?: string }).network ?? '').startsWith('eip155:')) { + return (spec as { network?: string }).network ?? 'eip155:8453'; + } + } + return null; } async handle(request: CheckoutRequest): Promise { @@ -299,28 +741,266 @@ export class Checkout { referenceId, pricing: null, recipients: {}, + state: {}, }; + + // 1. Pre-validate (merchant-supplied per-request validation). + if (this.preValidate !== undefined) { + try { + const state = await this.preValidate(ctx); + if (state && typeof state === 'object') Object.assign(ctx.state, state); + } catch (err) { + if (err instanceof CheckoutValidationError) { + return this.validationErrorResult(ctx, err); + } + throw err; + } + } + + // 2. Per-request gate. Only fires when a payment header is present (so the + // discovery leg stays anonymous-friendly). Merchants can wrap behavior + // via `gate.runGate` for full control. + const hasPaymentHeader = + hasX402Header(request.headers) || hasMppxHeader(request.headers); + if (this.gate !== undefined && hasPaymentHeader) { + const denial = await this.runGate(ctx); + if (denial !== null) { + return { + status: denial.status, + body: denial.body, + headers: { ...(denial.headers ?? {}) }, + referenceId: ctx.referenceId, + settled: false, + }; + } + } + + // 3. Pricing. ctx.pricing = await this.computePricing(ctx); - if (hasX402Header(request.headers) && this.x402Server !== undefined && this.x402BaseNetwork !== null) { + // Recipients are read by every downstream dispatch (x402 verify+settle, + // mppx compose, 402 emit). Resolve once here so hooks see ctx.recipients + // populated. The resolver is idempotent — subsequent calls no-op. + await this.resolveRecipientsForCtx(ctx); + + const x402ServerOk = this.x402ServerAvailable() && this.x402BaseNetwork !== null; + if (hasX402Header(request.headers) && x402ServerOk) { + const zero = await this.handleZeroSettle(ctx, 'x402-base'); + if (zero !== null) return zero; return await this.handleX402(ctx); } if (hasMppxHeader(request.headers) && this.composeMppx !== undefined) { + const zero = await this.handleZeroSettle(ctx, 'tempo'); + if (zero !== null) return zero; return await this.handleMppx(ctx); } - return await this.emit402(ctx); + // Discovery leg: mint per-order recipients BEFORE composeMppx so the + // hook sees ctx.recipients populated. composeMppx mints a fresh + // WWW-Authenticate challenge that the agent needs to sign on the retry; + // the hook returns status=402 with mppx-issued headers, which we + // propagate into the rich 402 emit. + await this.resolveRecipientsForCtx(ctx); + let mppxHeaders: Record = {}; + if (this.composeMppx !== undefined) { + try { + const preComposed = await this.composeMppx(ctx); + if (preComposed.status === 402) { + mppxHeaders = { ...(preComposed.headers ?? {}) }; + } + } catch { + // Hook errors here only affect the optional MPP challenge; the 402 + // still goes out with whatever rails resolved. + } + } + return await this.emit402(ctx, mppxHeaders); + } + + private validationErrorResult( + ctx: CheckoutContext, + err: CheckoutValidationError, + ): CheckoutResult { + const body = buildValidationError({ + code: err.code, + message: err.message, + nextSteps: { action: err.action, user_message: err.message }, + extra: err.extra, + }); + return { + status: err.status, + body, + headers: {}, + referenceId: ctx.referenceId, + settled: false, + }; + } + + private async runGate(ctx: CheckoutContext): Promise { + const gate = this.gate; + if (gate === undefined) return null; + if (gate.runGate !== undefined) { + const result = await gate.runGate(ctx); + // Allow merchants to return undefined as an alias for `null` (allow). + if (result === undefined || result === null) return null; + if (typeof result !== 'object' || typeof (result as { status?: unknown }).status !== 'number') { + throw new TypeError( + 'gate.runGate must return null/undefined (allow) or an object { status, body, headers? } (deny)', + ); + } + return result; + } + if (gate.apiKey === undefined) return null; + + // Merge per-request policy overrides over the static config. + let policyOverride: Partial | null | undefined; + if (gate.perRequestPolicy !== undefined) { + policyOverride = await gate.perRequestPolicy(ctx); + if (policyOverride === null) return null; + } + const coreOpts: AgentScoreCoreOptions = { + apiKey: gate.apiKey, + ...(gate.baseUrl !== undefined && { baseUrl: gate.baseUrl }), + ...(gate.userAgent !== undefined && { userAgent: gate.userAgent }), + ...(gate.requireKyc !== undefined && { requireKyc: gate.requireKyc }), + ...(gate.requireSanctionsClear !== undefined && { requireSanctionsClear: gate.requireSanctionsClear }), + ...(gate.minAge !== undefined && { minAge: gate.minAge }), + ...(gate.blockedJurisdictions !== undefined && { blockedJurisdictions: gate.blockedJurisdictions }), + ...(gate.allowedJurisdictions !== undefined && { allowedJurisdictions: gate.allowedJurisdictions }), + ...(gate.failOpen !== undefined && { failOpen: gate.failOpen }), + ...(gate.cacheSeconds !== undefined && { cacheSeconds: gate.cacheSeconds }), + ...(gate.chain !== undefined && { chain: gate.chain }), + ...(gate.createSessionOnMissing !== undefined && { + createSessionOnMissing: gate.createSessionOnMissing as unknown as CreateSessionOnMissing, + }), + ...(policyOverride ?? {}), + }; + + const core = createAgentScoreCore(coreOpts); + const headers = lowerHeaders(ctx.request.headers); + const walletAddress = headers['x-wallet-address']; + const operatorToken = headers['x-operator-token']; + const identity: AgentIdentity | undefined = + walletAddress !== undefined || operatorToken !== undefined + ? { + ...(walletAddress !== undefined && { address: walletAddress }), + ...(operatorToken !== undefined && { operatorToken }), + } + : undefined; + const x402Header = headers['payment-signature'] ?? headers['x-payment']; + const signer = await extractPaymentSignerFromAuth(headers['authorization'], x402Header); + const outcome: EvaluateOutcome = await core.evaluate(identity, ctx, signer); + + if (outcome.kind === 'allow') { + // Stash captureWallet on ctx so onSettled can link the signer wallet to + // the operator credential without needing a framework-specific Context. + // No-op for wallet-authenticated requests (no operator_token to bind). + if (operatorToken !== undefined) { + const opToken = operatorToken; + ctx.captureWallet = async (opts) => { + await core.captureWallet({ + operatorToken: opToken, + walletAddress: opts.walletAddress, + network: opts.network, + ...(opts.idempotencyKey !== undefined && { idempotencyKey: opts.idempotencyKey }), + }); + }; + } + // Post-allow signer-match enforcement: the API composed signer_match on + // the assess call when a signer was extracted; convert non-pass verdicts + // into wallet_signer_mismatch / wallet_auth_requires_wallet_signing + // denials so the gate enforces wallet binding inline (no separate hook). + if (walletAddress !== undefined) { + const verdict = core.getSignerVerdict(walletAddress); + const sm = verdict?.signer_match; + if (sm && sm.kind !== 'pass') { + const reason: DenialReason = sm.kind === 'wallet_auth_requires_wallet_signing' + ? { + code: 'wallet_auth_requires_wallet_signing', + expected_signer: sm.claimedWallet, + agent_instructions: sm.agentInstructions, + } + : { + code: 'wallet_signer_mismatch', + ...(sm.claimedOperator !== null && { claimed_operator: sm.claimedOperator }), + actual_signer_operator: sm.actualSignerOperator, + expected_signer: sm.expectedSigner, + actual_signer: sm.actualSigner, + ...(sm.linkedWallets.length > 0 && { linked_wallets: sm.linkedWallets }), + agent_instructions: sm.agentInstructions, + }; + if (gate.onDenied !== undefined) { + const custom = await gate.onDenied(ctx, reason); + if (custom !== null) return custom; + } + const body = denialReasonToBody(reason); + return { status: 403, body: body as Record }; + } + } + return null; + } + + const reason = outcome.reason; + if (gate.onDenied !== undefined) { + const custom = await gate.onDenied(ctx, reason); + if (custom !== null) return custom; + } + const body = denialReasonToBody(reason); + const status = + reason.code === 'token_expired' || reason.code === 'invalid_credential' + ? 401 + : reason.code === 'api_error' + ? 503 + : 403; + return { status, body: body as Record }; + } + + private async handleZeroSettle( + ctx: CheckoutContext, + rail: ZeroSettleRail, + ): Promise { + if (!this.zeroSettleCarveOut || ctx.pricing === null) return null; + const cents = Math.round(ctx.pricing.amountUsd * 100); + if (cents !== 0) return null; + const headers = lowerHeaders(ctx.request.headers); + let zero; + if (rail === 'x402-base') { + const x402Header = headers['payment-signature'] ?? headers['x-payment']; + let payload: Record | null = null; + if (typeof x402Header === 'string' && x402Header.length > 0) { + try { + payload = JSON.parse(atob(x402Header)) as Record; + } catch { + payload = null; + } + } + zero = zeroAmountCarveOut({ rail, payload }); + } else { + zero = zeroAmountCarveOut({ rail, authorizationHeader: headers['authorization'] }); + } + const railKey = rail === 'x402-base' ? this.x402RailKey() : this.mppRailKey(); + const outcome: SettleOutcome = { + rail: rail === 'x402-base' ? 'x402' : 'mpp', + paymentResponseHeader: null, + raw: zero, + txHash: null, + signerAddress: zero.signerAddress, + signerNetwork: zero.signerNetwork, + railKey, + }; + return await this.buildSuccess(ctx, outcome); } private async mintRefId(request: CheckoutRequest): Promise { if (this.mintReferenceId === undefined) return randomUUID(); - const seedCtx: CheckoutContext = { request, referenceId: '', pricing: null, recipients: {} }; + const seedCtx: CheckoutContext = { request, referenceId: '', pricing: null, recipients: {}, state: {} }; return await this.mintReferenceId(seedCtx); } private async resolveRecipientsForCtx(ctx: CheckoutContext): Promise> { - if (this.mintRecipients === undefined) return {}; + if (this.mintRecipients === undefined) return ctx.recipients; + // Idempotent: if a prior call (e.g. pre-compose) already minted, skip. + if (Object.keys(ctx.recipients).length > 0) return ctx.recipients; ctx.recipients = { ...(await this.mintRecipients(ctx)) }; return ctx.recipients; } @@ -331,10 +1011,10 @@ export class Checkout { } private async handleX402(ctx: CheckoutContext): Promise { - if (ctx.pricing === null || this.x402BaseNetwork === null || this.x402Server === undefined) { + const x402Server = await this.getX402Server(); + if (ctx.pricing === null || this.x402BaseNetwork === null || x402Server === undefined) { throw new Error('Checkout.handleX402: missing pricing or x402 rail config'); } - const x402Server = this.x402Server; const fakeRequest = new Request(ctx.request.url, { method: ctx.request.method, headers: ctx.request.headers, @@ -360,7 +1040,7 @@ export class Checkout { resourceConfig: { scheme: 'exact', network: verified.signedNetwork, - price: `$${ctx.pricing.amountUsd}`, + price: `$${ctx.pricing.amountUsd.toFixed(2)}`, payTo: verified.signedPayTo, maxTimeoutSeconds: 300, }, @@ -371,6 +1051,25 @@ export class Checkout { }, }); if (!settle.success) { + // Map each failure phase to its canonical merchant-facing response: + // verify_failed → 400 payment_proof_invalid, facilitator_error / + // settle_failed → 503 payment_provider_unavailable, etc. + const classified = classifyX402SettleResult(settle); + const responseHeaders: Record = + classified !== null && classified.status >= 500 ? { 'Cache-Control': 'no-store' } : {}; + if (classified !== null) { + return { + status: classified.status, + body: { + error: { code: classified.code, message: classified.message }, + next_steps: classified.nextSteps, + }, + headers: responseHeaders, + referenceId: ctx.referenceId, + settled: false, + settlePhase: settle.phase ?? 'settle_failed', + }; + } return { status: 400, body: buildValidationError({ @@ -385,10 +1084,23 @@ export class Checkout { settlePhase: settle.phase ?? 'settle_failed', }; } + // Lift the verified signer + tx hash off the settle result so on_settled + // hooks can persist them without re-parsing the credential. + const settleRes = (settle as { settleResult?: { transaction?: string; payer?: string } }) + .settleResult ?? {}; + const verifiedFrom = ( + (verified.payload as { payload?: { authorization?: { from?: string } } }).payload + ?.authorization?.from ?? null + ); + const signerAddress = verifiedFrom !== null ? verifiedFrom.toLowerCase() : (settleRes.payer ?? null); const outcome: SettleOutcome = { rail: 'x402', paymentResponseHeader: settle.paymentResponseHeader ?? null, raw: settle, + txHash: settleRes.transaction ?? null, + signerAddress, + signerNetwork: signerAddress !== null ? 'evm' : null, + railKey: this.x402RailKey(), }; return await this.buildSuccess(ctx, outcome); } @@ -403,10 +1115,30 @@ export class Checkout { rail: 'mpp', paymentResponseHeader: composed.paymentResponseHeader ?? null, raw: composed.raw, + txHash: composed.txHash ?? null, + signerAddress: composed.signerAddress ?? null, + signerNetwork: composed.signerNetwork ?? null, + railKey: composed.railKey ?? this.mppRailKey(), }; return await this.buildSuccess(ctx, outcome); } - return await this.emit402(ctx, composed.headers ?? {}); + // handleMppx is only invoked when an `Authorization: Payment` header was + // present, so a 402 here means mppx REJECTED the credential. Surface as + // 400 payment_proof_invalid (the canonical "regenerate" denial), echoing + // mppx's fresh WWW-Authenticate so the agent's retry signs against the new + // directive id. + return { + status: 400, + body: buildValidationError({ + code: 'payment_proof_invalid', + message: 'MPP credential rejected; regenerate from a fresh 402 challenge.', + nextSteps: { action: 'regenerate_payment_credential' }, + }), + headers: { ...(composed.headers ?? {}) }, + referenceId: ctx.referenceId, + settled: false, + settlePhase: 'verify_failed', + }; } private async emit402( @@ -441,7 +1173,7 @@ export class Checkout { const howToPay = buildHowToPay({ url: this.url, retryBodyJson: JSON.stringify(ctx.request.body), - totalUsd: String(ctx.pricing.amountUsd), + totalUsd: ctx.pricing.amountUsd.toFixed(2), rails: howToPayRails, }); const pricingBlock = @@ -450,35 +1182,60 @@ export class Checkout { subtotalCents: Math.round(ctx.pricing.amountUsd * 100), currency: ctx.pricing.currency ?? 'USD', }); - const body = build402Body({ - acceptedMethods: accepted, - agentInstructions: buildAgentInstructions({ howToPay }), - pricing: pricingBlock, - amountUsd: String(ctx.pricing.amountUsd), - retryBody: ctx.request.body, - agentMemory: firstEncounterAgentMemory({ firstEncounter: true }), - }); - - let x402Block: Parameters[0]['x402']; + // Build x402 accepts BEFORE the body so they appear both in the rich body + // (agents read JSON) AND in the PAYMENT-REQUIRED header (x402-spec clients). + let x402Accepts: unknown[] = []; + let x402Resource: { url: string; mimeType: string } | undefined; const baseNetwork = this.x402BaseNetwork; - const x402Server = this.x402Server; + const x402Server = await this.getX402Server(); if (x402Server !== undefined && baseNetwork !== null) { const baseSpec = emitRails['x402_base'] as X402BaseRailSpec | undefined; if (baseSpec !== undefined) { const recipient = await resolveRecipientValue(baseSpec.recipient); - x402Block = { - x402Version: 2, - accepts: await buildX402AcceptsFor402(x402Server, { + try { + x402Accepts = await buildX402AcceptsFor402(x402Server, { network: baseNetwork, - price: `$${ctx.pricing.amountUsd}`, + price: `$${ctx.pricing.amountUsd.toFixed(2)}`, payTo: recipient, maxTimeoutSeconds: 300, - }), - resource: { url: ctx.request.url, mimeType: 'application/json' }, - }; + }); + x402Resource = { url: ctx.request.url, mimeType: 'application/json' }; + } catch { + // Facilitator/scheme build failure: drop x402 from accepts but keep + // other rails in the body. Merchant logs internally. + x402Accepts = []; + } } } + const body = build402Body({ + acceptedMethods: accepted, + agentInstructions: buildAgentInstructions({ howToPay }), + pricing: pricingBlock, + amountUsd: ctx.pricing.amountUsd.toFixed(2), + retryBody: ctx.request.body, + agentMemory: firstEncounterAgentMemory({ firstEncounter: true }), + ...(ctx.pricing.product ? { product: ctx.pricing.product as { id: string; name: string } } : {}), + ...(ctx.pricing.bodyExtras ? { extra: ctx.pricing.bodyExtras } : {}), + ...(x402Accepts.length > 0 ? { + x402: { + accepts: x402Accepts, + ...(this.discoveryExtensions !== undefined && Object.keys(this.discoveryExtensions).length > 0 + ? { extensions: this.discoveryExtensions } + : {}), + }, + } : {}), + }); + + let x402Block: Parameters[0]['x402']; + if (x402Accepts.length > 0) { + x402Block = { + x402Version: 2, + accepts: x402Accepts, + ...(x402Resource ? { resource: x402Resource } : {}), + }; + } + const respond = respond402({ mppxChallengeHeaders: mppxHeaders, body, @@ -521,3 +1278,346 @@ export class Checkout { async function resolveRecipientValue(r: RecipientLike): Promise { return await resolveRecipient(r); } + +// ───────────────────────────────────────────────────────────────────────────── +// Validation-envelope helpers + per-framework wrappers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Framework-neutral 4xx envelope (`{ error, next_steps, agent_instructions }`). + * + * Returns the body dict; merchants wrap in their framework's JSON response. + * The per-framework `validationResponse*` helpers do this for you. + */ +export function validationEnvelope(opts: { + code: string; + message: string; + action?: string; + extra?: Record; +}): Record { + const action = opts.action ?? 'fix_request'; + return buildValidationError({ + code: opts.code, + message: opts.message, + nextSteps: { action, user_message: opts.message }, + extra: opts.extra, + }); +} + +interface ValidationResponseInput { + code: string; + message: string; + action?: string; + status?: number; + extra?: Record; +} + +/** Hono one-liner; returns a `Response` via `c.json`-equivalent semantics. */ +export function validationResponseHono(input: ValidationResponseInput): Response { + const status = input.status ?? 400; + const body = validationEnvelope(input); + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +/** + * Express helper; writes the response on the supplied `res`. Returns `void` + * to match Express convention. + */ +export function validationResponseExpress( + res: { status: (code: number) => unknown; json: (body: unknown) => unknown }, + input: ValidationResponseInput, +): void { + const status = input.status ?? 400; + const body = validationEnvelope(input); + res.status(status); + res.json(body); +} + +/** Fastify helper; writes on the supplied `reply` and returns it for chaining. */ +export function validationResponseFastify( + reply: { code: (code: number) => unknown; send: (body: unknown) => unknown }, + input: ValidationResponseInput, +): unknown { + const status = input.status ?? 400; + const body = validationEnvelope(input); + reply.code(status); + return reply.send(body); +} + +/** Next.js helper; returns a `Response` (interchangeable with NextResponse.json). */ +export function validationResponseNextjs(input: ValidationResponseInput): Response { + return validationResponseHono(input); +} + +/** Web Fetch helper; returns a standard `Response`. */ +export function validationResponseWeb(input: ValidationResponseInput): Response { + return validationResponseHono(input); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Per-framework adapters on Checkout +// ───────────────────────────────────────────────────────────────────────────── + +function invalidBodyEnvelope(): Record { + return validationEnvelope({ + code: 'invalid_body', + message: 'Request body must be valid JSON.', + }); +} + +function stripContentType(headers: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() !== 'content-type') out[k] = v; + } + return out; +} + +function headersToRecord(h: Headers | Record | undefined): Record { + if (h === undefined) return {}; + if (h instanceof Headers) { + const out: Record = {}; + h.forEach((v, k) => { + out[k] = v; + }); + return out; + } + return { ...h }; +} + +declare module './checkout' { + // No-op; module-augmentation placeholder. The handler methods live on the + // Checkout class via prototype extension below to keep the constructor block + // readable in this large file. +} + +// Hono adapter: takes a Hono `Context` (loose-typed to avoid hard-importing). +(Checkout.prototype as unknown as { + handleHono: ( + this: Checkout, + c: { + req: { + method: string; + url: string; + raw?: Request; + header: (name?: string) => string | Record | undefined; + json: () => Promise; + }; + json: (body: unknown, status?: number, headers?: Record) => Response; + body: (body: string, status?: number, headers?: Record) => Response; + }, + body?: Record, + ) => Promise; +}).handleHono = async function (c, body) { + let parsedBody: Record; + if (body !== undefined) { + parsedBody = body; + } else { + try { + parsedBody = (await c.req.json()) as Record; + } catch { + return new Response(JSON.stringify(invalidBodyEnvelope()), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + } + const rawHeaders = c.req.header() as Record | undefined; + const headers = headersToRecord(rawHeaders); + const result = await this.handle({ + method: c.req.method, + url: c.req.url, + headers, + body: parsedBody, + assess: null, + raw: c.req.raw ?? c, + }); + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { 'Content-Type': 'application/json', ...stripContentType(result.headers) }, + }); +}; + +// Express adapter: takes `req` + `res`. Writes to `res`; returns void. +(Checkout.prototype as unknown as { + handleExpress: ( + this: Checkout, + req: { + method: string; + originalUrl?: string; + url?: string; + headers: Record; + body?: unknown; + }, + res: { + status: (code: number) => unknown; + setHeader: (name: string, value: string) => unknown; + json: (body: unknown) => unknown; + }, + body?: Record, + ) => Promise; +}).handleExpress = async function (req, res, body) { + const parsedBody = + body ?? (typeof req.body === 'object' && req.body !== null ? (req.body as Record) : null); + if (parsedBody === null) { + res.status(400); + res.json(invalidBodyEnvelope()); + return; + } + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (typeof v === 'string') headers[k] = v; + else if (Array.isArray(v) && v[0] !== undefined) headers[k] = v[0]; + } + const url = req.originalUrl ?? req.url ?? '/'; + const result = await this.handle({ + method: req.method, + url, + headers, + body: parsedBody, + assess: null, + raw: req, + }); + for (const [k, v] of Object.entries(stripContentType(result.headers))) res.setHeader(k, v); + res.status(result.status); + res.json(result.body); +}; + +// Fastify adapter: takes `request` + `reply`. +(Checkout.prototype as unknown as { + handleFastify: ( + this: Checkout, + request: { + method: string; + url: string; + headers: Record; + body?: unknown; + }, + reply: { + code: (code: number) => unknown; + header: (name: string, value: string) => unknown; + send: (body: unknown) => unknown; + }, + body?: Record, + ) => Promise; +}).handleFastify = async function (request, reply, body) { + const parsedBody = + body ?? (typeof request.body === 'object' && request.body !== null + ? (request.body as Record) + : null); + if (parsedBody === null) { + reply.code(400); + return reply.send(invalidBodyEnvelope()); + } + const headers: Record = {}; + for (const [k, v] of Object.entries(request.headers)) { + if (typeof v === 'string') headers[k] = v; + else if (Array.isArray(v) && v[0] !== undefined) headers[k] = v[0]; + } + const result = await this.handle({ + method: request.method, + url: request.url, + headers, + body: parsedBody, + assess: null, + raw: request, + }); + for (const [k, v] of Object.entries(stripContentType(result.headers))) reply.header(k, v); + reply.code(result.status); + return reply.send(result.body); +}; + +// Next.js / Web Fetch adapter: takes a standard `Request`, returns `Response`. +(Checkout.prototype as unknown as { + handleNextjs: (this: Checkout, request: Request, body?: Record) => Promise; +}).handleNextjs = async function (request, body) { + let parsedBody: Record; + if (body !== undefined) { + parsedBody = body; + } else { + try { + parsedBody = (await request.json()) as Record; + } catch { + return new Response(JSON.stringify(invalidBodyEnvelope()), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + } + const headers: Record = {}; + request.headers.forEach((v, k) => { + headers[k] = v; + }); + const result = await this.handle({ + method: request.method, + url: request.url, + headers, + body: parsedBody, + assess: null, + raw: request, + }); + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { 'Content-Type': 'application/json', ...stripContentType(result.headers) }, + }); +}; + +// `handleWeb` is an alias for `handleNextjs`; both consume Web Fetch Requests. +(Checkout.prototype as unknown as { + handleWeb: (this: Checkout, request: Request, body?: Record) => Promise; +}).handleWeb = (Checkout.prototype as unknown as { + handleNextjs: (this: Checkout, request: Request, body?: Record) => Promise; +}).handleNextjs; + +// Type-declare the new methods so consumers see them through Checkout's interface. +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface Checkout { + handleHono( + c: { + req: { + method: string; + url: string; + raw?: Request; + header: (name?: string) => string | Record | undefined; + json: () => Promise; + }; + json: (body: unknown, status?: number, headers?: Record) => Response; + body: (body: string, status?: number, headers?: Record) => Response; + }, + body?: Record, + ): Promise; + handleExpress( + req: { + method: string; + originalUrl?: string; + url?: string; + headers: Record; + body?: unknown; + }, + res: { + status: (code: number) => unknown; + setHeader: (name: string, value: string) => unknown; + json: (body: unknown) => unknown; + }, + body?: Record, + ): Promise; + handleFastify( + request: { + method: string; + url: string; + headers: Record; + body?: unknown; + }, + reply: { + code: (code: number) => unknown; + header: (name: string, value: string) => unknown; + send: (body: unknown) => unknown; + }, + body?: Record, + ): Promise; + handleNextjs(request: Request, body?: Record): Promise; + handleWeb(request: Request, body?: Record): Promise; +} diff --git a/src/discovery/agentscore_content.ts b/src/discovery/agentscore_content.ts new file mode 100644 index 0000000..736eabe --- /dev/null +++ b/src/discovery/agentscore_content.ts @@ -0,0 +1,262 @@ +/** + * Standard agent-facing prose for AgentScore-gated merchants. + * + * Every AgentScore merchant emits roughly the same skill.md onboarding steps, + * catalog purchase-mode notes, and endpoint descriptions. These helpers ship + * those canonical strings so merchants supply only the merchant-specific parts + * (name, URL, accepted rails) and get consistent agent-facing content back. + * + * Rationale: agents that hit one AgentScore merchant should see the same + * pattern hints at every other one. Custom prose per merchant adds noise + * without adding information; the SDK owns the cross-merchant boilerplate so + * it stays consistent. + */ + +/** + * Whether a paid surface accepts redemption codes. Applies to any merchant + * that bills per-purchase or per-call — goods (catalog rows) and API + * (per-endpoint or per-tier billing) both use this enum. + */ +export type PurchaseMode = 'redemption_only' | 'coupon_applicable' | 'paid_only'; + +/** + * Canonical agent-facing notes for each `purchase_mode`. Surface this in + * /catalog rows (goods) or in `x-service-info` / `/llms.txt` (API) so agents + * know whether to expect a `redemption_code` field in the request body. + */ +export const PURCHASE_MODE_NOTES: Readonly> = Object.freeze({ + redemption_only: + 'Requires a single-use redemption code (printed on a mailer or other ' + + 'out-of-band delivery). Submit the code in the request body as ' + + '`redemption_code`. Without a valid code the order is rejected.', + coupon_applicable: + 'Codes are optional. Without one, settle at list price. With a valid ' + + 'code the discount is applied automatically (percent_off, fixed_off, ' + + 'or fixed_settle).', + paid_only: + 'Codes are NOT accepted. Settle at the listed price. Submitting a ' + + '`redemption_code` field returns 400 codes_not_accepted.', +}); + +/** + * Canonical agent-facing note for a `purchase_mode`. Falls back to an empty + * string for unknown modes so responses don't leak `undefined` when the + * merchant introduces a non-standard mode. + */ +export function purchaseModeNote(mode: string): string { + return PURCHASE_MODE_NOTES[mode] ?? ''; +} + +/** + * Build the canonical skill.md `onboarding_steps` for an AgentScore merchant. + * + * Returns a list of imperative step strings the agent follows to bootstrap + * wallet + Passport, then either browse + buy (goods) or make the paid call + * (api). Generic across every AgentScore-gated merchant; only the + * merchantName + appUrl + rails list are substituted in. + * + * Rails accepted today: `"tempo"`, `"x402-base"`, `"solana-mpp"`, `"stripe-spt"`. + * Unknown rail names are passed through verbatim so future rails work without + * an SDK bump. + * + * Pass `vendorType: 'api'` for per-call API providers — the catalog step is + * dropped and the final step becomes "Make the paid call" instead of "Place + * the order". + */ +export function buildAgentscoreOnboardingSteps(opts: { + merchantName: string; + appUrl: string; + acceptedRails: string[]; + requiresKyc?: boolean; + vendorType?: 'goods' | 'api'; +}): string[] { + const { merchantName, appUrl, acceptedRails, requiresKyc = false, vendorType = 'goods' } = opts; + const railWordMap: Record = { + tempo: 'Tempo USDC', + 'x402-base': 'x402 USDC on Base', + 'solana-mpp': 'Solana SPL USDC', + 'stripe-spt': 'Stripe Shared Payment Token', + }; + const railsHuman = acceptedRails.map((r) => railWordMap[r] ?? r).join(', '); + + const chainPairs: ReadonlyArray = [ + ['tempo', 'tempo'], + ['x402-base', 'base'], + ['solana-mpp', 'solana'], + ]; + const flags = chainPairs.filter(([rail]) => acceptedRails.includes(rail)).map(([, flag]) => flag); + const chainFlags = flags.length > 0 ? flags.join(' | ') : 'tempo|base'; + + // Per-rail compatible-client hints; mirrors what `compatibleClientsByRails` + // emits on the 402 body so the skill.md and the runtime challenge stay in sync. + const compatibleHints: ReadonlyArray = [ + ['tempo', '`tempo request` works for tempo USDC.e'], + ['x402-base', '`x402-proxy` / `purl` work for Base x402'], + ['stripe-spt', '`@stripe/link-cli` works for Stripe SPT'], + ]; + const compatibleFragment = compatibleHints + .filter(([rail]) => acceptedRails.includes(rail)) + .map(([, hint]) => hint) + .join(', '); + + const installStep = + 'Install agentscore-pay if you don\'t already have a compatible client for your funded chain: ' + + '`npm i -g @agent-score/pay` (or `brew install agentscore/tap/agentscore-pay`). ' + + `${merchantName} accepts: ${railsHuman}. agentscore-pay speaks every supported rail; ` + + (compatibleFragment ? `the rails table also lists per-rail \`compatible_clients\` — ${compatibleFragment}. ` : '') + + 'Any spec-compliant client for an individual rail works too.'; + const bootstrapStep = + 'First-run only: bootstrap wallet + Passport. Run `agentscore-pay agent-guide --json` ' + + 'for the canonical cold-start path — it walks `agentscore-pay init` ' + + '(creates keystore + per-chain wallet), `agentscore-pay passport login` ' + + `(one-time KYC${requiresKyc ? '; required for this merchant' : ''}; the human completes a verify URL once and pay caches the operator_token), ` + + 'and `agentscore-pay balance` to see which chain has USDC. Skip if your wallet+Passport are already provisioned.'; + const stripeFallbackStep = + 'If your only payment method is a Stripe / Link card (no crypto), install `@stripe/link-cli` ' + + 'instead of agentscore-pay and use it on the SPT rail. Identity gating still applies — the ' + + 'merchant\'s 403 with `verify_url` lets you bootstrap a Passport even with no crypto wallet involved.'; + const returningUserStep = + 'Returning user note: if you\'ve paid an AgentScore-gated merchant before from this wallet, ' + + 'the wallet is already in your Passport\'s `linked_wallets[]` and identity flows through ' + + 'automatically with no re-KYC prompt. Paying from a NEW wallet while you already hold an ' + + '`opc_...` token returns 403 `wallet_signer_mismatch`; the body lists `linked_wallets[]` and ' + + '`agent_instructions.action: resign_or_switch_to_operator_token` with three deterministic ' + + 'recoveries (switch to a linked wallet, drop the operator_token to re-KYC the new wallet, ' + + 'or pre-claim the new wallet via SIWE on agentscore.sh/verify).'; + const pickRailStep = + `Pick the rail your wallet is funded for. The 402 advertises ${acceptedRails.length} rail${acceptedRails.length === 1 ? '' : 's'}. ` + + '`agentscore-pay balance` (without `--chain`) lists every chain\'s USDC; pay rejects with ' + + '`multi_rail_ambiguity` if you don\'t pass `--chain` on a multi-rail challenge.'; + const placeOrderStep = + `Place the order: \`agentscore-pay pay POST ${appUrl}/purchase --chain <${chainFlags}> ` + + '-d \'\' --max-spend ` for crypto rails. For Stripe SPT, follow the handoff ' + + 'hint pay emits and use `@stripe/link-cli` instead. Either way pay handles the 402 retry, ' + + 'signing, and Passport attachment; branch on the structured CliError `code` on non-zero ' + + 'exit (insufficient_balance, multi_rail_ambiguity, config_error for missing wallet/Passport, etc.).'; + const makeCallStep = + `Make the paid call: \`agentscore-pay pay POST ${appUrl}/ --chain <${chainFlags}> ` + + '--max-spend `; pay handles 402 retry, rail selection, signing, and Passport ' + + 'attachment. Branch on the structured CliError `code` on non-zero exit (insufficient_balance, ' + + 'multi_rail_ambiguity, config_error for missing wallet/Passport, etc.).'; + + const acceptsStripe = acceptedRails.includes('stripe-spt'); + if (vendorType === 'api') { + return [ + installStep, + bootstrapStep, + ...(acceptsStripe ? [stripeFallbackStep] : []), + returningUserStep, + pickRailStep, + makeCallStep, + ]; + } + return [ + installStep, + bootstrapStep, + ...(acceptsStripe ? [stripeFallbackStep] : []), + returningUserStep, + `Browse the catalog: \`curl ${appUrl}/catalog\`.`, + "Read each product's `purchase_mode` and `purchase_note` to decide " + + 'whether a redemption code is required, optional, or rejected.', + pickRailStep, + placeOrderStep, + ]; +} + +/** + * Canonical descriptions for the standard AgentScore **goods-merchant** + * endpoints (`/catalog`, `/catalog/{slug}`, `/purchase`, `/orders/{id}`). + * + * Use in `/` discovery JSON, OpenAPI summaries, or anywhere a goods merchant + * needs to describe what each endpoint does in agent-readable language. + * Descriptions are merchant-agnostic across goods merchants — they describe + * response semantics (402 on discovery, 400 on validation, 403 on identity, + * 200 on success), not the body schema (which varies per merchant; surface + * that in OpenAPI). + * + * Pass `includeOrderStatusRoute: true` for merchants that ship the lightweight + * `/orders/{id}/status` PII-free variant alongside `/orders/{id}`. + * + * **API merchants** (per-call paid endpoints, no catalog/orders concept) do + * not need this helper — write your own endpoints map and pass it to + * {@link buildMerchantIndexJson} via the `endpoints` field. + */ +export function standardEndpointDescriptions(opts?: { + includeOrderStatusRoute?: boolean; +}): Record { + const out: Record = { + 'GET /catalog': 'List purchasable products.', + 'GET /catalog/{slug}': 'Single product detail.', + 'POST /purchase': + 'Place an order. Returns 402 on the discovery leg with payment rails; 400 on body rejection; 403 + recovery payload when identity is required; 200 with order confirmation on success.', + 'GET /orders/{id}': 'Order detail (PII). Identity-scoped.', + }; + if (opts?.includeOrderStatusRoute) { + out['GET /orders/{id}/status'] = 'Payment status only (no PII).'; + } + return out; +} + +/** + * Build the canonical AgentScore commerce `/` root discovery body. Works for + * both goods merchants (catalog + purchase + orders) and API merchants (per- + * call paid endpoints) — `endpoints` and any merchant-specific fields are + * passed through `extra`. + * + * Common fields surfaced: `name`, `description`, `docs`, `endpoints`, + * `audience: 'agents'`, `supported_rails`. Pass `extra` for merchant-specific + * additions: `compliance` for goods merchants, `pricing` for API merchants, + * `website` for branded fronts. + * + * `docs` keys map to absolute URLs; pass whichever discovery surfaces this + * merchant ships (`llms`, `openapi`, `skill_md`, `mpp`, `agent_card`, `ucp`, + * `jwks`, `redemption`, ...). + */ +export function buildMerchantIndexJson(opts: { + name: string; + description: string; + docs: Record; + endpoints: Record; + supportedRails: string[]; + extra?: Record; +}): Record { + return { + name: opts.name, + description: opts.description, + docs: opts.docs, + endpoints: opts.endpoints, + audience: 'agents', + supported_rails: opts.supportedRails, + ...(opts.extra ?? {}), + }; +} + +/** + * Standard `next_steps` block emitted in a 200 success body. Works for both + * goods-merchant order-success and API-merchant per-call-success — the + * `user_message` reinforces the cross-merchant Passport pattern (universal), + * with merchant-specific copy overridable via `userMessage`. + * + * `orderStatusUrl` is emitted as `order_status_url`. API merchants that don't + * have an order-detail endpoint can either pass a usage/dashboard URL or omit + * the field by passing an empty string (filtered out before emit). + * + * `fulfillmentEta` is goods-specific (shipping window) — omit for API or + * digital-goods merchants. + */ +export function buildSuccessNextSteps(opts: { + orderStatusUrl?: string; + fulfillmentEta?: string; + userMessage?: string; +}): Record { + const out: Record = { + action: 'done', + user_message: + opts.userMessage ?? + 'Order complete. Your AgentScore Passport is now active across ' + + 'every AgentScore-gated merchant.', + }; + if (opts.orderStatusUrl) out.order_status_url = opts.orderStatusUrl; + if (opts.fulfillmentEta !== undefined) out.fulfillment_eta = opts.fulfillmentEta; + return out; +} diff --git a/src/discovery/index.ts b/src/discovery/index.ts index df12897..34e9cb9 100644 --- a/src/discovery/index.ts +++ b/src/discovery/index.ts @@ -6,3 +6,7 @@ export * from './llms_txt'; export * from './openapi'; export * from './robots_tag'; export * from './skill_md'; +export * from './agentscore_content'; +export * from './redemption_md'; +export * from './well_known'; +export * from './request_id'; diff --git a/src/discovery/openapi.ts b/src/discovery/openapi.ts index 57df405..0df82d9 100644 --- a/src/discovery/openapi.ts +++ b/src/discovery/openapi.ts @@ -179,19 +179,35 @@ export interface XPaymentInfoX402Protocol { } export interface XPaymentInfoMppProtocol { - mpp: { method: string; intent: string; currency: string }; + mpp: { method: string; intent: string; currency?: string; [key: string]: unknown }; } export type XPaymentInfoProtocol = XPaymentInfoX402Protocol | XPaymentInfoMppProtocol; +export interface XPaymentInfoBlock { + authMode: 'payment'; + price: XPaymentInfoPrice; + protocols: XPaymentInfoProtocol[]; + description?: string; +} + export function xPaymentInfoExtension({ price, protocols, + description, }: { price: XPaymentInfoPrice; protocols: XPaymentInfoProtocol[]; -}): { 'x-payment-info': { price: XPaymentInfoPrice; protocols: XPaymentInfoProtocol[] } } { - return { 'x-payment-info': { price, protocols } }; + description?: string; +}): { 'x-payment-info': XPaymentInfoBlock } { + return { + 'x-payment-info': { + authMode: 'payment', + price, + protocols, + ...(description !== undefined && { description }), + }, + }; } /** @@ -215,6 +231,119 @@ export function xGuidanceExtension(text: string): { 'x-guidance': string } { return { 'x-guidance': text }; } +/** + * `x-service-info` extension for the OpenAPI document's root. Discovery + * crawlers (x402scan, agent CLIs) read this to categorize the service and + * follow links to human-side docs. Spread into the OpenAPI doc's root + * alongside `paths`, `info`, etc. + * + * @example + * ```ts + * const spec = { + * openapi: '3.1.0', + * info: {...}, + * ...xServiceInfoExtension({ + * categories: ['commerce', 'wine'], + * docs: { homepage: 'https://www.martinestate.com', llms: 'https://agents.martinestate.com/llms.txt' }, + * }), + * paths: {...}, + * }; + * ``` + */ +export function xServiceInfoExtension(opts: { + categories: string[]; + docs?: Record; +}): { 'x-service-info': { categories: string[]; docs?: Record } } { + return { + 'x-service-info': { + categories: opts.categories, + ...(opts.docs !== undefined && { docs: opts.docs }), + }, + }; +} + +/** + * Derive an `x-payment-info` extension from a configured `Checkout` instance. + * + * Walks `checkout.rails` and emits one entry in `protocols[]` per rail — + * Tempo MPP, x402 (Base), Solana MPP, Stripe SPT. Saves merchants from + * enumerating protocols by hand and keeps the OpenAPI doc in sync with the + * actual rails the Checkout serves. + * + * `price` is merchant-supplied (the rail registry doesn't carry per-merchant + * pricing; rates live on each Checkout's `computePricing` hook). Per-rail + * extras (client commands, asset names) can be merged via `protocolExtras` + * keyed by rail slug (`tempo`, `base`, `solana`, `stripe`). + */ +export function xPaymentInfoFromCheckout(opts: { + checkout: { + rails: Record< + string, + { network?: string; recipient?: unknown; currency?: unknown; token?: unknown; profileId?: unknown } + >; + }; + price: XPaymentInfoPrice; + description?: string; + protocolExtras?: Partial<{ + tempo: Record; + base: Record; + solana: Record; + stripe: Record; + }>; +}): { 'x-payment-info': XPaymentInfoBlock } { + const protocols: XPaymentInfoProtocol[] = []; + const extras = opts.protocolExtras ?? {}; + for (const spec of Object.values(opts.checkout.rails)) { + const isStripe = !('recipient' in spec); + const network = typeof spec.network === 'string' ? spec.network : ''; + // MPP protocols emit `currency` = on-chain token contract (Tempo USDC.e + // address, Solana USDC mint). `spec.currency` wins when set explicitly; + // `spec.token` is the RailSpec canonical name and is the typical source. + const tokenCurrency = + (typeof spec.currency === 'string' ? spec.currency : '') || + (typeof spec.token === 'string' ? spec.token : ''); + if (isStripe) { + protocols.push({ mpp: { method: 'stripe', intent: 'charge', currency: 'usd', ...(extras.stripe ?? {}) } }); + } else if (network.startsWith('eip155:')) { + protocols.push({ + x402: { + scheme: 'exact', + network: 'base', + asset: 'USDC', + ...(extras.base ?? {}), + }, + }); + } else if (network.startsWith('solana:')) { + // Per MPP solana/charge spec (paymentauth.org/draft-solana-charge-00): + // `currency` = SPL mint address (base58) for tokens, `"sol"` for native. + // No `asset` field in the spec — token symbols are not part of the + // discovery contract. + protocols.push({ + mpp: { + method: 'solana', + intent: 'charge', + ...(tokenCurrency ? { currency: tokenCurrency } : {}), + ...(extras.solana ?? {}), + }, + }); + } else { + protocols.push({ + mpp: { + method: 'tempo', + intent: 'charge', + ...(tokenCurrency ? { currency: tokenCurrency } : {}), + ...(extras.tempo ?? {}), + }, + }); + } + } + return xPaymentInfoExtension({ + price: opts.price, + protocols, + ...(opts.description !== undefined && { description: opts.description }), + }); +} + /** * Convenience: returns a `components` snippet ready to merge into an OpenAPI document. * diff --git a/src/discovery/redemption_md.ts b/src/discovery/redemption_md.ts new file mode 100644 index 0000000..4d3f6f1 --- /dev/null +++ b/src/discovery/redemption_md.ts @@ -0,0 +1,128 @@ +/** + * Standard `/redemption.md` template for merchants offering printed-mailer + * redemption codes. + * + * Renders the canonical cold-start bootstrap section + TL;DR + recovery table + * + body/code rules. Merchants supply only the merchant-specific bits (name, + * URL, SKU intro, peer-merchant pointer) and the rest comes from this template. + * + * Mirrors the prose every AgentScore merchant otherwise hand-writes so agents + * encounter the same shape of redemption flow at any merchant. + */ + +/** + * Render the canonical `redemption.md` for an AgentScore merchant. + * + * `skuIntro` is one paragraph describing what the code unlocks at this + * merchant (e.g. "a wine SKU you'll find in /catalog with purchase_mode = + * redemption_only"). Defaults to a generic placeholder. + * + * `peerMerchantPointer` is the optional "Don't have a code?" cross-link at + * the bottom; a URL or short markdown line pointing at the merchant-name's + * sister non-code-only store. Omit to drop the section. + */ +export function buildRedemptionSkillMd(opts: { + merchantName: string; + appUrl: string; + skuIntro?: string; + peerMerchantPointer?: string; +}): string { + const { merchantName, appUrl, skuIntro, peerMerchantPointer } = opts; + const skuText = + skuIntro ?? + "The code redeems a product at this merchant which you'll find in " + + '/catalog with `purchase_mode = redemption_only`.'; + + const peerSection = peerMerchantPointer + ? '\n## Don\'t have a code?\n\nThis page is the redemption flow for printed-mailer codes. ' + + `If you're looking to buy without a code, see: ${peerMerchantPointer}\n` + : ''; + + return `# Redeeming an AgentScore mailer code at ${merchantName} + +You're reading this because the human you're working for received a printed +AgentScore mailer with a single-use redemption code. This page tells you, the +agent, exactly how to turn that code into a shipped order. + +${skuText} The 402 challenge on /purchase tells you the actual settle amount +after the code is applied; discounts can range from a partial amount off list +down to free. + +## Cold-start bootstrap (skip if your wallet + Passport are already set up) + +If \`agentscore-pay\` isn't installed yet, install it (\`npm i -g @agent-score/pay\` +or \`brew install agentscore/tap/agentscore-pay\`), then run \`agentscore-pay +agent-guide --json\` for the canonical cold-start path. That walks +\`agentscore-pay init\` (creates keystore + per-chain wallet), +\`agentscore-pay passport login\` (one-time KYC; opens a verify URL the human +completes, after which pay caches the \`operator_token\`), and +\`agentscore-pay balance\` to confirm funds. Fund enough to cover the +post-discount settle amount the 402 advertises; for $0 codes the merchant +skips the on-chain settle entirely so funds aren't required, but the wallet +still needs to exist so the credential can be signed. + +You don't have to use \`agentscore-pay\` specifically; any spec-compliant client +for the merchant's accepted rails (Tempo MPP, x402 Base, Solana MPP, Stripe SPT) +works. The 402 challenge lists every accepted rail in \`accepted_methods\`. + +## TL;DR + +1. Ask the user for their redemption code, email, and US shipping address. +2. \`GET ${appUrl}/catalog\`; find the product whose \`purchase_mode\` is + \`redemption_only\`. Read its \`purchase_note\` for any product-specific rules. +3. \`POST ${appUrl}/purchase\` with body: + \`\`\`json + { + "product_slug": "", + "redemption_code": "", + "email": "user@example.com", + "shipping": { "name": "...", "address_1": "...", "city": "...", "state": "CA", "zip": "94573" } + } + \`\`\` +4. If you get **403 \`operator_verification_required\`**, surface the body's + \`verify_url\` to the user for one-time KYC and poll \`poll_url\` with + \`poll_secret\`. After verification, retry with \`X-Operator-Token\` attached. + If you already have an \`opc_...\` from a prior AgentScore-gated merchant, + attach it on the first call and skip this step. +5. On **402**, the body carries \`accepted_methods\` and \`agent_instructions.how_to_pay\`. + Settle with \`agentscore-pay pay POST ${appUrl}/purchase --chain -d '' + --max-spend \`; pay handles 402 retry, rail selection, signing, and + Passport attachment. Pass \`--max-spend\` >= the amount in the 402. +6. **200**; order confirmed. Response carries \`order.id\`, \`next_steps.order_status_url\`, + and an \`agent_memory\` block you should persist (the cross-merchant pattern hint, + NOT the operator_token or poll_secret). For $0 redemptions \`tx_hash\` is \`null\`; + the credential is still authenticated and the code is burned single-use. + +## Body rules + +- \`quantity\` is fixed at 1; one product per code. +- \`shipping.country\` defaults to \`"US"\`; non-US shipping is rejected for + redemption-eligible products. +- \`shipping.state\` must be a 2-letter US state code; \`unsupported_jurisdiction\` + 400 if the state isn't on the merchant's allowlist. +- \`email\` must be valid; the merchant returns 422 on malformed input. + +## Code rules + +- Codes are case-insensitive (server uppercases on receipt), single-use, and + burned atomically against \`(code, operator_token)\` OR \`(code, signer_address)\` + for token-less wallet flows. A second attempt returns 400 \`redemption_already_used\`. +- Submit the code in the JSON body as \`"redemption_code"\`; never as a header. + +## Recovery on common errors + +| HTTP | error.code | What it means | What to do | +|---|---|---|---| +| 403 | \`operator_verification_required\` | User has no Passport / KYC pending | Surface \`verify_url\`; poll \`poll_url\` with \`poll_secret\`; retry with \`X-Operator-Token\` | +| 403 | \`wallet_signer_mismatch\` | Operator token + signer wallet aren't linked to the same identity | Switch to a wallet in \`linked_wallets[]\`, or drop the operator_token to re-KYC the new wallet | +| 400 | \`invalid_body\` | JSON parse failed | Fix the JSON and retry | +| 400 | \`missing_fields\` | Required field absent | Add the field per \`error.message\` and retry | +| 400 | \`product_not_found\` | \`product_slug\` doesn't match an active product | Re-check \`/catalog\` and use the exact slug | +| 400 | \`product_out_of_stock\` | Product real but stock 0 | Tell the user; no retry possible | +| 400 | \`invalid_redemption_code\` | Code unknown / expired | Ask the user for the code as printed; do not invent variants | +| 400 | \`redemption_already_used\` | Code burned | Tell the user; codes are single-use | +| 400 | \`codes_not_accepted\` | Product is \`paid_only\` and rejects codes | Drop \`redemption_code\` and retry, or pick a different product | +| 400 | \`unsupported_jurisdiction\` | Shipping state not on allowlist | Ask for an allowed shipping address | +| 402 | (challenge) | Identity OK; payment required | Run \`agentscore-pay pay\` against the same URL | +${peerSection}`; +} diff --git a/src/discovery/request_id.ts b/src/discovery/request_id.ts new file mode 100644 index 0000000..45438cb --- /dev/null +++ b/src/discovery/request_id.ts @@ -0,0 +1,36 @@ +/** + * Echo the request-id middleware sets on the context as an `X-Request-ID` + * response header. Agents correlate logs across 4xx retries by reading this. + * + * Hono variant — reads `c.get('requestId')` populated by `hono/request-id`. + * Express / Fastify / Next.js / Web Fetch variants will follow when consumers + * need them. + * + * Universal for AgentScore commerce merchants: every retry-loop pattern + * (probe-then-pay, 403-then-resume, settle-then-retry) benefits from the + * agent being able to grep server logs for the request id. + * + * @example + * ```ts + * import { Hono } from 'hono'; + * import { requestId } from 'hono/request-id'; + * import { echoRequestIdHeaderHono } from '@agent-score/commerce/discovery'; + * + * const app = new Hono(); + * app.use('*', requestId()); + * app.use('*', echoRequestIdHeaderHono()); + * ``` + */ +export function echoRequestIdHeaderHono(): ( + c: { + get: (key: 'requestId') => string | undefined; + header: (name: string, value: string) => void; + }, + next: () => Promise, +) => Promise { + return async (c, next) => { + await next(); + const id = c.get('requestId'); + if (id) c.header('X-Request-ID', id); + }; +} diff --git a/src/discovery/skill_md.ts b/src/discovery/skill_md.ts index 95a04b7..073276b 100644 --- a/src/discovery/skill_md.ts +++ b/src/discovery/skill_md.ts @@ -22,6 +22,9 @@ export interface SkillMdIdentityRequirements { sanctionsClear?: boolean; } +/** PHYSICAL-GOODS-ONLY. Shipping-policy block for skill.md. Digital goods and + * API merchants skip this (the `shipping?:` field on BuildSkillMdInput is + * optional). */ export interface SkillMdShippingPolicy { /** Allowed shipping countries (ISO 3166-1 alpha-2). */ allowedCountries?: string[]; diff --git a/src/discovery/well_known.ts b/src/discovery/well_known.ts new file mode 100644 index 0000000..c7c5155 --- /dev/null +++ b/src/discovery/well_known.ts @@ -0,0 +1,343 @@ +/** + * Spec-rooted helpers for `/.well-known/{ucp,jwks.json}` discovery surfaces. + * + * What this module collapses for every UCP-publishing merchant: + * + * - Loading + caching the signing key via `loadUCPSigningKeyFromEnv`. + * - Composing the `payment_handlers` map from the merchant's `Checkout` rails + * (TempoRailSpec → mppPaymentHandler; X402BaseRailSpec → x402PaymentHandler; + * StripeRailSpec → stripeSptPaymentHandler). + * - Building the unsigned profile + signing it. + * - Cache-Control + CORS + X-Request-ID echo per UCP section 6. + * - RFC 7517 section 8.5 `application/jwk-set+json` media type on JWKS. + * - The 503 `ucp_misconfigured` fallback envelope when no handlers can be + * derived (empty rails dict OR all rails have empty recipients). + * + * Each helper returns a framework-neutral `SignedDiscoveryResponse` that + * merchants wrap in their framework's Response builder (Hono `c.body`, Express + * `res.set/.status/.send`, Fastify `reply.headers/.code/.send`, Next.js + * `NextResponse`, Web Fetch `new Response`, etc.). + */ + +import { + type AgentScoreGatePolicy, + buildUCPProfile, + mppPaymentHandler, + stripeSptPaymentHandler, + type UCPPaymentHandlerBinding, + type UCPServiceBinding, + UCPSigningKey, + x402PaymentHandler, +} from '../identity/ucp'; +import { + buildJWKSResponse, + loadUCPSigningKeyFromEnv, + signUCPProfile, +} from '../identity/ucp-jwks'; +import type { Checkout, CheckoutRailSpec } from '../checkout'; +import type { + SolanaMppRailSpec, + StripeRailSpec, + TempoRailSpec, + TempoSessionRailSpec, + X402BaseRailSpec, +} from '../payment/rail_spec'; + +const UCP_CACHE_SECONDS = 60; +const JWKS_CACHE_SECONDS = 300; +const UCP_SHOPPING_SPEC_2026_04_08 = 'https://ucp.dev/2026-04-08/specification/overview'; + +/** + * Framework-neutral response shape for discovery endpoints. + * + * Wrap in your framework's response builder. `body` is already JSON-encoded + * bytes (as a string); do not re-serialize. + */ +export interface SignedDiscoveryResponse { + body: string; + mediaType: string; + headers: Record; + status: number; +} + +function requestId(headers: Headers | Record | undefined): string | undefined { + if (headers === undefined) return undefined; + if (headers instanceof Headers) return headers.get('x-request-id') ?? undefined; + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() === 'x-request-id') return v; + } + return undefined; +} + +function attachRequestId( + headers: Record, + requestHeaders: Headers | Record | undefined, +): void { + const rid = requestId(requestHeaders); + if (rid !== undefined) headers['X-Request-ID'] = rid; +} + +function isTempoSession(s: CheckoutRailSpec): s is TempoSessionRailSpec { + return 'escrowContract' in s && 'store' in s; +} +function isStripe(s: CheckoutRailSpec): s is StripeRailSpec { + return !('recipient' in s); +} +/** A rail spec qualifies for UCP publication when `recipient` is defined — + * whether concrete (`'0xabc'`), empty-string sentinel (per-order minted by + * the consumer), or a factory callable (per-order minted on demand). The + * `tempoToNetworkEntry` / `x402ToNetworkEntry` builders drop the recipient + * field from the emitted UCP entry when it's not a static address, so per- + * order-mint merchants advertise the rail without leaking a sentinel. */ +function railHasRecipientField(spec: { recipient?: unknown }): boolean { + return Object.hasOwn(spec, 'recipient'); +} + +function composeHandlers(checkout: Checkout): Record { + const handlers: Record = {}; + const mpp: (TempoRailSpec | SolanaMppRailSpec | TempoSessionRailSpec)[] = []; + const x402: X402BaseRailSpec[] = []; + const stripe: StripeRailSpec[] = []; + + for (const spec of Object.values(checkout.rails)) { + if (isStripe(spec)) { + stripe.push(spec); + continue; + } + if (isTempoSession(spec)) { + if (railHasRecipientField(spec)) mpp.push(spec); + continue; + } + // Distinguish Tempo (`symbol: 'USDC.e'` or `network: 'tempo-*'`) from x402-Base + // (CAIP-2 `eip155:*`) and Solana (`network: 'solana:*'`). + const network = (spec as { network?: string }).network ?? ''; + if (network.startsWith('eip155:') || ('mode' in spec)) { + if (railHasRecipientField(spec)) x402.push(spec as X402BaseRailSpec); + } else if (network.startsWith('solana:') || 'rpcUrl' in spec) { + if (railHasRecipientField(spec)) mpp.push(spec as SolanaMppRailSpec); + } else { + // Default to Tempo (network starts with `tempo-` or symbol is `USDC.e`). + if (railHasRecipientField(spec)) mpp.push(spec as TempoRailSpec); + } + } + + if (mpp.length > 0) Object.assign(handlers, mppPaymentHandler({ networks: mpp })); + if (x402.length > 0) Object.assign(handlers, x402PaymentHandler({ networks: x402 })); + for (const spec of stripe) Object.assign(handlers, stripeSptPaymentHandler({ spec })); + return handlers; +} + +function misconfiguredResponse( + requestHeaders: Headers | Record | undefined, +): SignedDiscoveryResponse { + const body = { + error: { + code: 'ucp_misconfigured', + message: 'Merchant has no configured payment handlers.', + }, + next_steps: { + action: 'contact_merchant', + user_message: 'This merchant is temporarily unable to accept agent payments.', + }, + agent_instructions: { + action: 'contact_merchant', + steps: [ + 'Surface a transient error to the user.', + 'Retry later; the merchant operator will repair the configuration.', + ], + user_message: 'Merchant temporarily offline for agent payments.', + }, + }; + // UCP section 6 forbids `no-store` on profile responses; 60s is the minimum + // cache age (short enough that recovery is fast once the merchant restores + // config). + const headers: Record = { + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': `public, max-age=${UCP_CACHE_SECONDS}`, + }; + attachRequestId(headers, requestHeaders); + return { + body: JSON.stringify(body), + mediaType: 'application/json', + headers, + status: 503, + }; +} + +/** + * Build the signed UCP profile response for `/.well-known/ucp`. + * + * Composes payment handlers from the Checkout's rails dict, builds the profile + * via `buildUCPProfile`, signs via `signUCPProfile`, and attaches the UCP + * section 6-prescribed Cache-Control + CORS + X-Request-ID headers. + * + * Returns a 503 `ucp_misconfigured` envelope (still with the section 6-compliant + * Cache-Control) when no payment handlers can be derived from rails. + * + * `services` is the spec-compliant services map (keyed by reverse-DNS service + * name). `wellKnownUcpUrl` is the canonical URL of this profile, surfaced as + * the value in `supported_versions`. + */ +export async function buildSignedUcpResponse(opts: { + checkout: Checkout; + name: string; + wellKnownUcpUrl: string; + services: Record; + requestHeaders?: Headers | Record; + signingKid?: string; + agentscoreGate?: AgentScoreGatePolicy; +}): Promise { + const { + checkout, + name, + wellKnownUcpUrl, + services, + requestHeaders, + signingKid = 'merchant-default', + agentscoreGate, + } = opts; + + const handlers = composeHandlers(checkout); + if (Object.keys(handlers).length === 0) { + return misconfiguredResponse(requestHeaders); + } + + const key = await loadUCPSigningKeyFromEnv({ defaultKid: signingKid }); + const signingKeyEntry = UCPSigningKey.fromJWK(key.publicJWK); + + const profile = buildUCPProfile({ + name, + supported_versions: { '2026-04-08': wellKnownUcpUrl }, + agentscore_gate: agentscoreGate, + services, + payment_handlers: handlers, + signing_keys: [signingKeyEntry], + }); + const signed = await signUCPProfile(profile, { + signingKey: key.privateKey, + kid: key.publicJWK.kid as string, + alg: (key.publicJWK.alg as 'EdDSA' | 'ES256' | undefined) ?? 'EdDSA', + }); + const headers: Record = { + 'Cache-Control': `public, max-age=${UCP_CACHE_SECONDS}`, + 'Access-Control-Allow-Origin': '*', + }; + attachRequestId(headers, requestHeaders); + return { + body: JSON.stringify(signed), + mediaType: 'application/json', + headers, + status: 200, + }; +} + +/** + * Build the JWKS response for `/.well-known/jwks.json`. + * + * RFC 7517 section 8.5 prescribes `application/jwk-set+json`. Five-minute + * Cache-Control balances verifier-side cache hit rate against rotation + * propagation latency. + */ +export async function buildSignedJwksResponse(opts?: { + requestHeaders?: Headers | Record; + signingKid?: string; +}): Promise { + const { requestHeaders, signingKid = 'merchant-default' } = opts ?? {}; + const key = await loadUCPSigningKeyFromEnv({ defaultKid: signingKid }); + const jwks = buildJWKSResponse([UCPSigningKey.fromJWK(key.publicJWK)]); + const headers: Record = { + 'Cache-Control': `public, max-age=${JWKS_CACHE_SECONDS}`, + 'Access-Control-Allow-Origin': '*', + }; + attachRequestId(headers, requestHeaders); + return { + body: JSON.stringify(jwks), + mediaType: 'application/jwk-set+json', + headers, + status: 200, + }; +} + +/** + * CORS preflight headers for `/.well-known/*` endpoints. + * + * Echoes `Access-Control-Request-Headers` verbatim when present rather than + * advertising `*` (which browsers reject with credentials in scope). Returns + * a 204 on the corresponding response via the merchant's framework. + */ +export function wellKnownCorsPreflightHeaders( + requestHeaders?: Headers | Record, +): Record { + const headers: Record = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Max-Age': '86400', + Vary: 'Access-Control-Request-Headers', + }; + if (requestHeaders === undefined) return headers; + const acrh = + requestHeaders instanceof Headers + ? requestHeaders.get('access-control-request-headers') + : Object.entries(requestHeaders).find(([k]) => k.toLowerCase() === 'access-control-request-headers')?.[1]; + if (acrh) headers['Access-Control-Allow-Headers'] = acrh; + return headers; +} + +/** + * Build a 204 CORS preflight `Response` for `/.well-known/*` endpoints, wrapping + * {@link wellKnownCorsPreflightHeaders}. Universal across every UCP-publishing + * merchant; saves the 4-line `new Response(null, { status: 204, headers: ... })` + * wrapper every consumer otherwise hand-rolls. + */ +export function wellKnownPreflightResponse( + requestHeaders?: Headers | Record, +): Response { + return new Response(null, { + status: 204, + headers: wellKnownCorsPreflightHeaders(requestHeaders), + }); +} + +/** + * Canonical UCP services map for a merchant publishing an A2A agent card. + * + * Returns `{"dev.ucp.shopping": [UCPServiceBinding(version: '2026-04-08', + * spec: , transport: 'a2a', endpoint: agentCardUrl)]}`; + * the binding every UCP-publishing merchant declares when their primary agent + * surface is the A2A v1.0 `/.well-known/agent-card.json` (versus a UCP MCP or + * REST endpoint). + * + * Merchants who additionally expose a UCP MCP or REST transport append further + * bindings to the same `dev.ucp.shopping` list. + */ +export function defaultA2aServices(opts: { + agentCardUrl: string; +}): Record { + return { + 'dev.ucp.shopping': [ + { + version: '2026-04-08', + spec: UCP_SHOPPING_SPEC_2026_04_08, + transport: 'a2a', + endpoint: opts.agentCardUrl, + }, + ], + }; +} + +/** + * Eager-load the UCP signing key at startup. + * + * A malformed `UCP_SIGNING_KEY_JWK_PRIVATE` env value otherwise surfaces on + * the first `/.well-known/ucp` hit after deploy, masquerading as a runtime + * 500. Calling this in the framework's startup hook fails the deploy fast. + * + * Wraps `loadUCPSigningKeyFromEnv`; throws (per that helper's contract) on a + * malformed JWK so the orchestrator marks the task unhealthy. + */ +export async function bootstrapUcpSigningKey(opts?: { + defaultKid?: string; +}): Promise { + const defaultKid = opts?.defaultKid ?? 'merchant-default'; + await loadUCPSigningKeyFromEnv({ defaultKid }); +} diff --git a/src/identity/ucp.ts b/src/identity/ucp.ts index ad8ba5b..702b00a 100644 --- a/src/identity/ucp.ts +++ b/src/identity/ucp.ts @@ -441,12 +441,13 @@ function ucpNetworkName(caip2OrUcp: string | undefined, fallback: string): strin } /** - * Return the recipient as a string when it's already concrete; `undefined` for factories. - * Per-order factory recipients cannot be advertised in the static UCP profile — the - * authoritative recipient ships in the 402 body at request time instead. + * Return the recipient as a string when it's both a string AND non-empty; + * `undefined` for factory callables OR empty-string sentinels (both signal + * per-order minting; the authoritative recipient ships in the 402 body at + * request time, not in the static UCP profile). */ function staticRecipient(r: RecipientLike): string | undefined { - return typeof r === 'string' ? r : undefined; + return typeof r === 'string' && r.length > 0 ? r : undefined; } function tempoToNetworkEntry(spec: TempoRailSpec): Record { diff --git a/src/index.ts b/src/index.ts index bcaabbc..eb30b12 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,12 @@ export type { } from './core'; export { buildAgentMemoryHint } from './core'; export type { PaymentSigner, SignerNetwork } from './signer'; -export { extractPaymentSigner, readX402PaymentHeader } from './signer'; +export { + extractPaymentSigner, + extractPaymentSignerFromAuth, + extractSignerForPrecheck, + readX402PaymentHeader, +} from './signer'; export { FIXABLE_DENIAL_REASONS, buildContactSupportNextSteps, @@ -77,16 +82,29 @@ export { hashOperatorToken } from './identity/tokens'; export { Checkout, type CheckoutContext, + type CheckoutGateConfig, type CheckoutRailSpec, type CheckoutRequest, type CheckoutResult, + CheckoutValidationError, type ComposeMppxFn, + type GateDenial, type IsCachedAddressFn, type MppxComposeOutcome, type OnSettledFn, + type PreValidateFn, type PricingFn, type PricingResult, type RecipientsFn, type ReferenceIdFn, + type RunGateFn, type SettleOutcome, + getIdentityStatus, + makeMppxComposeHook, + validationEnvelope, + validationResponseExpress, + validationResponseFastify, + validationResponseHono, + validationResponseNextjs, + validationResponseWeb, } from './checkout'; diff --git a/src/payment/amounts.ts b/src/payment/amounts.ts index 265d1fc..d74a238 100644 --- a/src/payment/amounts.ts +++ b/src/payment/amounts.ts @@ -79,3 +79,15 @@ export function usdToAtomic(usd: string | number, opts: { decimals: number }): b } return result; } + +/** + * Format an integer cent amount as a fixed-2-decimal USD string. + * + * `formatUsdCents(500)` returns `"5.00"`. Negative values are formatted with a + * leading minus. Use everywhere a merchant emits `(cents / 100).toFixed(2)`; + * consistent formatting across catalog rows, order responses, and 402 bodies + * prevents agent-side string-comparison flakiness. + */ +export function formatUsdCents(cents: number): string { + return (cents / 100).toFixed(2); +} diff --git a/src/payment/index.ts b/src/payment/index.ts index 7902f60..63a6b10 100644 --- a/src/payment/index.ts +++ b/src/payment/index.ts @@ -16,3 +16,5 @@ export * from './signer'; export * from './settlement_override'; export * from './amounts'; export * from './zero-settle'; +export * from './lazy'; +export * from './solana'; diff --git a/src/payment/lazy.ts b/src/payment/lazy.ts new file mode 100644 index 0000000..c1936bc --- /dev/null +++ b/src/payment/lazy.ts @@ -0,0 +1,89 @@ +/** + * Lazy-init helpers for x402 + mppx servers. + * + * Every merchant accepting these rails writes the same singleton-with-lock + * pattern around `createX402Server` / `createMppxServer`. These helpers collapse + * the boilerplate to a single call; the returned getter is safe to call from + * any number of concurrent handlers; only one server instance is ever + * constructed per merchant. + * + * The x402 helper also derives the facilitator choice (`coinbase` vs `http`) + * from optional CDP credentials so merchants don't repeat the boot-time + * conditional. + */ +import { createMppxServer, type MppxRailSpec } from './mppx_server'; +import { createX402Server, type X402Server, type X402SymbolicRail } from './x402_server'; +import type { X402BaseRailSpec } from './rail_spec'; + +function x402RailName(spec: X402BaseRailSpec): X402SymbolicRail { + const network = spec.network ?? 'eip155:8453'; + if (network === 'eip155:8453') return 'x402-base-mainnet'; + if (network === 'eip155:84532') return 'x402-base-sepolia'; + throw new Error( + `lazyX402Server: unsupported X402BaseRailSpec.network=${JSON.stringify(network)}`, + ); +} + +/** + * Build a memoized async getter for an x402 server. + * + * First call constructs the server; subsequent calls return the cached + * instance. Concurrent first-callers serialize on a Promise lock so we never + * construct two and discard one. + * + * When both CDP creds are passed, the server uses Coinbase's facilitator; + * otherwise it falls back to the public HTTP facilitator. Merchants who only + * have one of the two creds get the HTTP fallback. + */ +export function lazyX402Server(opts: { + spec: X402BaseRailSpec; + cdpApiKeyId?: string; + cdpApiKeySecret?: string; +}): () => Promise { + const { spec, cdpApiKeyId, cdpApiKeySecret } = opts; + const railName = x402RailName(spec); + const useCdp = Boolean(cdpApiKeyId && cdpApiKeySecret); + const facilitator: 'coinbase' | 'http' = useCdp ? 'coinbase' : 'http'; + + let cached: X402Server | undefined; + let pending: Promise | undefined; + + return async (): Promise => { + if (cached !== undefined) return cached; + if (pending !== undefined) return pending; + pending = (async () => { + const server = await createX402Server({ facilitator, rails: [railName] }); + cached = server; + pending = undefined; + return server; + })(); + return pending; + }; +} + +/** + * Build a memoized async getter for an mppx server. + * + * Same singleton + lock semantics as {@link lazyX402Server}. Forwards `rails` + * + `secretKey` unchanged to {@link createMppxServer}. + */ +export function lazyMppxServer(opts: { + rails: Record; + secretKey: string; +}): () => Promise { + const { rails, secretKey } = opts; + let cached: unknown; + let pending: Promise | undefined; + + return async (): Promise => { + if (cached !== undefined) return cached; + if (pending !== undefined) return pending; + pending = (async () => { + const server = await createMppxServer({ secretKey, rails }); + cached = server; + pending = undefined; + return server; + })(); + return pending; + }; +} diff --git a/src/payment/signer.ts b/src/payment/signer.ts index b15c642..cbf2f17 100644 --- a/src/payment/signer.ts +++ b/src/payment/signer.ts @@ -11,6 +11,7 @@ export { extractPaymentSigner, extractPaymentSignerFromAuth, + extractSignerForPrecheck, readX402PaymentHeader, } from '../signer'; export type { PaymentSigner, SignerNetwork } from '../signer'; diff --git a/src/payment/solana.ts b/src/payment/solana.ts new file mode 100644 index 0000000..735fb7b --- /dev/null +++ b/src/payment/solana.ts @@ -0,0 +1,45 @@ +/** + * Solana MPP fee-payer signer loader. + * + * Buyers paying via Solana MPP USDC don't typically carry SOL for transaction + * fees, so merchants commonly co-sign the buyer's `solana/charge` tx as the + * fee payer (~5000 lamports per tx; negligible vs the USDC value moved). + * + * `loadSolanaFeePayer({ privateKey })` accepts a Solana keypair in any of the + * three forms agents commonly export it as: + * + * - **base58** (Phantom export format) — 64-byte secret+public, or 32-byte + * secret-only + * - **hex** — 128-char string (64 bytes hex: 32-byte secret + 32-byte public) + * + * Returns a `KeyPairSigner` from `@solana/kit` ready to pass as the `signer` + * field on a `SolanaMppRailSpec`. Returns `undefined` when `privateKey` is + * empty / absent (so consumers can use `process.env.X` directly without + * null-checks). + * + * Requires the `@solana/kit` peer dependency. + */ +export async function loadSolanaFeePayer(opts: { + privateKey: string | undefined; +}): Promise { + const raw = opts.privateKey; + if (!raw) return undefined; + const moduleName = '@solana/kit'; + const kit = (await import(moduleName).catch(() => null)) as { + createKeyPairSignerFromPrivateKeyBytes?: (bytes: Uint8Array) => Promise; + getBase58Codec?: () => { encode: (s: string) => Uint8Array }; + } | null; + if (!kit?.createKeyPairSignerFromPrivateKeyBytes || !kit.getBase58Codec) { + throw new Error( + '@solana/kit not installed — `npm install @solana/kit` for loadSolanaFeePayer.', + ); + } + let bytes: Uint8Array; + if (/^[0-9a-fA-F]{128}$/.test(raw)) { + bytes = new Uint8Array(raw.match(/.{2}/g)!.map((h) => parseInt(h, 16))).slice(0, 32); + } else { + const decoded = new Uint8Array(kit.getBase58Codec().encode(raw)); + bytes = decoded.length === 64 ? decoded.slice(0, 32) : decoded; + } + return kit.createKeyPairSignerFromPrivateKeyBytes(bytes); +} diff --git a/src/payment/x402_server.ts b/src/payment/x402_server.ts index 8fbb10b..7df6368 100644 --- a/src/payment/x402_server.ts +++ b/src/payment/x402_server.ts @@ -89,7 +89,13 @@ export async function createX402Server(opts: CreateX402ServerOptions = {}): Prom /* v8 ignore stop */ let facilitator: unknown; - if (opts.facilitator === 'coinbase') { + // Auto-select the Coinbase CDP facilitator when both env vars are present. + // Lets merchants drop `facilitator: process.env.CDP_API_KEY_ID && ... ? 'coinbase' : 'http'` + // boilerplate. Explicit `facilitator` opt still wins. + const facilitatorChoice = + opts.facilitator ?? + (process.env.CDP_API_KEY_ID && process.env.CDP_API_KEY_SECRET ? 'coinbase' : 'http'); + if (facilitatorChoice === 'coinbase') { const cb = await dynamicImport('@coinbase/x402'); /* v8 ignore start -- peer-dep-absence guard; @coinbase/x402 is installed in test env */ if (!cb?.facilitator) { @@ -99,10 +105,10 @@ export async function createX402Server(opts: CreateX402ServerOptions = {}): Prom } /* v8 ignore stop */ facilitator = new x402Core.HTTPFacilitatorClient(cb.facilitator); - } else if (opts.facilitator === undefined || opts.facilitator === 'http') { + } else if (facilitatorChoice === 'http') { facilitator = new x402Core.HTTPFacilitatorClient(); } else { - facilitator = opts.facilitator; + facilitator = facilitatorChoice; } const server = new x402Core.x402ResourceServer(facilitator); diff --git a/src/payment/x402_settle.ts b/src/payment/x402_settle.ts index efe27bf..1905b98 100644 --- a/src/payment/x402_settle.ts +++ b/src/payment/x402_settle.ts @@ -62,7 +62,7 @@ export type ProcessX402SettleResult = * so the agent can pick a different rail. */ phase: 'facilitator_error'; /** Which verify-stage step threw. */ - step: 'build_requirements' | 'enrich_extensions' | 'process_payment_request'; + step: 'build_requirements' | 'enrich_extensions' | 'verify_payment'; error: unknown; }; @@ -255,19 +255,25 @@ export async function processX402Settle({ const server = x402Server as unknown as { buildPaymentRequirements: (cfg: unknown) => Promise; enrichExtensions: (ext: unknown, ctx: unknown) => unknown; - processPaymentRequest: ( - payload: unknown, - cfg: unknown, - meta: unknown, - ext: unknown, - ) => Promise<{ success: boolean; [key: string]: unknown }>; - settlePayment: (payload: unknown, requirement: unknown) => Promise; + verifyPayment: ( + paymentPayload: unknown, + requirements: unknown, + declaredExtensions?: unknown, + transportContext?: unknown, + ) => Promise<{ success: boolean; [key: string]: unknown } | { isValid?: boolean; [key: string]: unknown }>; + settlePayment: ( + paymentPayload: unknown, + requirements: unknown, + declaredExtensions?: unknown, + transportContext?: unknown, + ) => Promise; }; let builtRequirements: unknown[]; try { builtRequirements = await server.buildPaymentRequirements(resourceConfig); } catch (err) { + console.warn('[x402_settle] build_requirements failed:', err instanceof Error ? err.message : err); return { success: false, phase: 'facilitator_error', step: 'build_requirements', error: err }; } const matchedRequirement = builtRequirements[0]; @@ -286,27 +292,37 @@ export async function processX402Settle({ ? server.enrichExtensions(extension, resolvedTransportContext) : undefined; } catch (err) { + console.warn('[x402_settle] enrich_extensions failed:', err instanceof Error ? err.message : err); return { success: false, phase: 'facilitator_error', step: 'enrich_extensions', error: err }; } - let verifyResult: { success: boolean; [key: string]: unknown }; + let verifyResult: { success?: boolean; isValid?: boolean; [key: string]: unknown }; try { - verifyResult = await server.processPaymentRequest( + verifyResult = await server.verifyPayment( payload, - resourceConfig, - resourceMeta, - enrichedExt, + matchedRequirement, + enrichedExt as Record | undefined, + resolvedTransportContext, ); } catch (err) { - return { success: false, phase: 'facilitator_error', step: 'process_payment_request', error: err }; + console.warn('[x402_settle] verify_payment failed:', err instanceof Error ? err.message : err); + return { success: false, phase: 'facilitator_error', step: 'verify_payment', error: err }; } - if (!verifyResult.success) { + // x402/core's ResourceVerifyResponse uses `isValid` (per spec). Accept the + // legacy `success` field too for older facilitator builds. + const verifyOk = verifyResult.isValid === true || verifyResult.success === true; + if (!verifyOk) { return { success: false, phase: 'verify_failed', verifyResult }; } try { - const settleResult = await server.settlePayment(payload, matchedRequirement); + const settleResult = await server.settlePayment( + payload, + matchedRequirement, + enrichedExt as Record | undefined, + resolvedTransportContext, + ); const paymentResponseHeader = settleResult ? Buffer.from(JSON.stringify(settleResult)).toString('base64') : undefined; diff --git a/src/signer.ts b/src/signer.ts index b619665..34f3e76 100644 --- a/src/signer.ts +++ b/src/signer.ts @@ -189,3 +189,42 @@ export function readX402PaymentHeader(request: Request): string | undefined { undefined ); } + +function lowerHeaders(headers: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = v; + return out; +} + +/** + * One-call signer extraction across both supported credential formats. + * + * Tries the x402 `payment-signature` / `x-payment` header first (EIP-3009 + * `payload.authorization.from`), then falls back to the MPP + * `Authorization: Payment` header DID. Returns the first one that resolves, + * or `null`. + * + * Use this for wallet-cap prechecks and other "did the agent claim to sign as + * X?" checks where you need the signer BEFORE invoking Checkout. Checkout's + * own settle path runs verification separately and surfaces the verified + * signer on `SettleOutcome.signerAddress`. + * + * Accepts a plain headers dict so it works regardless of which framework the + * merchant uses (the gate adapters all serialize headers down to a dict by + * the time they reach the merchant's hooks). + */ +export async function extractSignerForPrecheck( + headers: Record, +): Promise { + const lower = lowerHeaders(headers); + const x402 = lower['payment-signature'] ?? lower['x-payment']; + if (x402) { + const signer = await extractPaymentSignerFromAuth(undefined, x402); + if (signer !== null) return signer; + } + const authorization = lower['authorization']; + if (authorization && authorization.toLowerCase().startsWith('payment ')) { + return await extractPaymentSignerFromAuth(authorization); + } + return null; +} diff --git a/tests/checkout.test.ts b/tests/checkout.test.ts index 3088d97..903cb37 100644 --- a/tests/checkout.test.ts +++ b/tests/checkout.test.ts @@ -93,7 +93,7 @@ describe('Checkout — composeMppx hook', () => { expect(onSettled).toHaveBeenCalledOnce(); }); - it('402 from compose layers the rich body on mppx WWW-Authenticate', async () => { + it('402 from compose on settle leg maps to 400 payment_proof_invalid', async () => { const composeMppx = vi.fn( async (): Promise => ({ status: 402, @@ -107,8 +107,28 @@ describe('Checkout — composeMppx hook', () => { composeMppx, }); const result = await checkout.handle(req({ headers: { authorization: 'Payment id=abc' } })); - expect(result.status).toBe(402); + expect(result.status).toBe(400); expect(result.headers['www-authenticate']).toBe('Payment id="ord_x"'); + expect((result.body.error as Record).code).toBe('payment_proof_invalid'); + expect(result.settlePhase).toBe('verify_failed'); + }); + + it('discovery-leg compose_mppx layers fresh WWW-Auth into the 402', async () => { + const composeMppx = vi.fn( + async (): Promise => ({ + status: 402, + headers: { 'www-authenticate': 'Payment id="ord_y"' }, + }), + ); + const checkout = new Checkout({ + rails: { tempo: { recipient: '0xtempo' } as TempoRailSpec }, + url: 'https://api.example/purchase', + computePricing: () => ({ amountUsd: 10 }), + composeMppx, + }); + const result = await checkout.handle(req()); + expect(result.status).toBe(402); + expect(result.headers['www-authenticate']).toBe('Payment id="ord_y"'); expect(result.body.accepted_methods).toBeDefined(); }); }); @@ -131,9 +151,9 @@ describe('Checkout — custom hooks', () => { computePricing: price, }); const anon = await checkout.handle(req()); - expect(anon.body.amount_usd).toBe('10'); + expect(anon.body.amount_usd).toBe('10.00'); const verified = await checkout.handle(req({ assess: { identity_status: 'verified' } })); - expect(verified.body.amount_usd).toBe('8'); + expect(verified.body.amount_usd).toBe('8.00'); }); it('mintRecipients overrides rail recipients', async () => { diff --git a/tests/payment/x402_settle.test.ts b/tests/payment/x402_settle.test.ts index d7d6dfc..cbe78fe 100644 --- a/tests/payment/x402_settle.test.ts +++ b/tests/payment/x402_settle.test.ts @@ -12,13 +12,13 @@ const baseInput = { function makeServer(overrides: Partial<{ buildPaymentRequirements: ReturnType; enrichExtensions: ReturnType; - processPaymentRequest: ReturnType; + verifyPayment: ReturnType; settlePayment: ReturnType; }>): X402Server { return { buildPaymentRequirements: overrides.buildPaymentRequirements ?? vi.fn().mockResolvedValue([{ matched: true }]), enrichExtensions: overrides.enrichExtensions ?? vi.fn().mockReturnValue(undefined), - processPaymentRequest: overrides.processPaymentRequest ?? vi.fn().mockResolvedValue({ success: true }), + verifyPayment: overrides.verifyPayment ?? vi.fn().mockResolvedValue({ success: true }), settlePayment: overrides.settlePayment ?? vi.fn().mockResolvedValue({ tx: '0xabc' }), } as unknown as X402Server; } @@ -45,8 +45,8 @@ describe('processX402Settle', () => { }); }); - it('returns verify_failed when processPaymentRequest yields { success: false }', async () => { - const server = makeServer({ processPaymentRequest: vi.fn().mockResolvedValue({ success: false, reason: 'invalid_credential' }) }); + it('returns verify_failed when verifyPayment yields { success: false }', async () => { + const server = makeServer({ verifyPayment: vi.fn().mockResolvedValue({ success: false, reason: 'invalid_credential' }) }); const result = await processX402Settle({ x402Server: server, ...baseInput }); expect(result.success).toBe(false); if (!result.success && result.phase === 'verify_failed') { @@ -88,14 +88,14 @@ describe('processX402Settle', () => { } }); - it('wraps processPaymentRequest throws as facilitator_error step=process_payment_request', async () => { + it('wraps verifyPayment throws as facilitator_error step=process_payment_request', async () => { const server = makeServer({ - processPaymentRequest: vi.fn().mockRejectedValue(new Error('CDP facilitator: solana:devnet not supported')), + verifyPayment: vi.fn().mockRejectedValue(new Error('CDP facilitator: solana:devnet not supported')), }); const result = await processX402Settle({ x402Server: server, ...baseInput }); expect(result.success).toBe(false); if (!result.success && result.phase === 'facilitator_error') { - expect(result.step).toBe('process_payment_request'); + expect(result.step).toBe('verify_payment'); expect((result.error as Error).message).toBe('CDP facilitator: solana:devnet not supported'); } }); diff --git a/tests/seamless-helpers.test.ts b/tests/seamless-helpers.test.ts new file mode 100644 index 0000000..81698a5 --- /dev/null +++ b/tests/seamless-helpers.test.ts @@ -0,0 +1,2168 @@ +/** + * Coverage for the seamless-merchant helpers shipped in the latest SDK additions: + * + * - `lazyX402Server` / `lazyMppxServer` (memoized async getters) + * - `extractSignerForPrecheck` (one-call signer across x402 + mpp headers) + * - `makeMppxComposeHook` (canonical `composeMppx` factory) + * - `purchaseModeNote` / `buildAgentscoreOnboardingSteps` / + * `standardEndpointDescriptions` / `buildSuccessNextSteps` + * - `buildRedemptionSkillMd` + * - `validationEnvelope` + per-framework `validationResponse*` wrappers + * - Checkout framework adapters: `handleHono` / `handleExpress` / `handleFastify` + * / `handleNextjs` / `handleWeb` + * - `defaultA2aServices` / `wellKnownCorsPreflightHeaders` + * - `formatUsdCents` + */ + +import { describe, expect, it, vi } from 'vitest'; +import { + Checkout, + type CheckoutContext, + type MppxComposeOutcome, + validationEnvelope, + validationResponseExpress, + validationResponseFastify, + validationResponseHono, + validationResponseNextjs, + validationResponseWeb, +} from '../src/checkout'; +import { makeMppxComposeHook } from '../src/checkout'; +import { + PURCHASE_MODE_NOTES, + buildAgentscoreOnboardingSteps, + buildSuccessNextSteps, + purchaseModeNote, + standardEndpointDescriptions, +} from '../src/discovery/agentscore_content'; +import { buildRedemptionSkillMd } from '../src/discovery/redemption_md'; +import { + defaultA2aServices, + wellKnownCorsPreflightHeaders, +} from '../src/discovery/well_known'; +import { formatUsdCents } from '../src/payment/amounts'; +import { lazyX402Server } from '../src/payment/lazy'; +import { extractSignerForPrecheck } from '../src/signer'; +import type { TempoRailSpec, X402BaseRailSpec } from '../src/payment/rail_spec'; + +const RECIPIENT = '0x000000000000000000000000000000000000dEaD'; + +// ───────────────────────────────────────────────────────────────────────────── +// formatUsdCents +// ───────────────────────────────────────────────────────────────────────────── + +describe('formatUsdCents', () => { + it('formats integer cents as 2-decimal USD strings', () => { + expect(formatUsdCents(0)).toBe('0.00'); + expect(formatUsdCents(5)).toBe('0.05'); + expect(formatUsdCents(500)).toBe('5.00'); + expect(formatUsdCents(7500)).toBe('75.00'); + }); + it('formats negatives with a leading minus', () => { + expect(formatUsdCents(-50)).toBe('-0.50'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// lazyX402Server / lazyMppxServer +// ───────────────────────────────────────────────────────────────────────────── + +describe('lazyX402Server', () => { + it('memoizes the server across concurrent first-callers', async () => { + const spec: X402BaseRailSpec = { recipient: RECIPIENT, network: 'eip155:84532' }; + const sentinel = {}; + let calls = 0; + vi.doMock('../src/payment/x402_server', () => ({ + createX402Server: async ({ facilitator, rails }: { facilitator: string; rails: string[] }) => { + calls += 1; + expect(facilitator).toBe('http'); + expect(rails).toEqual(['x402-base-sepolia']); + return sentinel; + }, + })); + // Re-import to pick up the mock. + const { lazyX402Server: fresh } = await import('../src/payment/lazy?lazy-fresh'); + const getter = fresh({ spec }); + const [a, b] = await Promise.all([getter(), getter()]); + expect(a).toBe(sentinel); + expect(b).toBe(sentinel); + expect(calls).toBe(1); + vi.doUnmock('../src/payment/x402_server'); + }); + + it('rejects unknown networks', () => { + const bad: X402BaseRailSpec = { recipient: RECIPIENT, network: 'eip155:1' }; + expect(() => lazyX402Server({ spec: bad })).toThrow(/unsupported X402BaseRailSpec\.network/); + }); +}); + +describe('lazyMppxServer', () => { + it('memoizes the server across concurrent first-callers', async () => { + const spec: TempoRailSpec = { recipient: RECIPIENT }; + const sentinel = {}; + let calls = 0; + vi.doMock('../src/payment/mppx_server', () => ({ + createMppxServer: async () => { + calls += 1; + return sentinel; + }, + })); + const { lazyMppxServer: fresh } = await import('../src/payment/lazy?mppx-fresh'); + const getter = fresh({ rails: { tempo: spec }, secretKey: 'secret' }); + const [a, b] = await Promise.all([getter(), getter()]); + expect(a).toBe(sentinel); + expect(b).toBe(sentinel); + expect(calls).toBe(1); + vi.doUnmock('../src/payment/mppx_server'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// extractSignerForPrecheck +// ───────────────────────────────────────────────────────────────────────────── + +function encodeX402(payload: object): string { + return Buffer.from(JSON.stringify(payload)).toString('base64'); +} + +describe('extractSignerForPrecheck', () => { + it('reads the x402 payment-signature header', async () => { + const payload = { + x402Version: 2, + scheme: 'exact', + network: 'eip155:84532', + payload: { authorization: { from: '0xAbC0000000000000000000000000000000000001' } }, + }; + const signer = await extractSignerForPrecheck({ 'Payment-Signature': encodeX402(payload) }); + expect(signer?.address).toBe('0xabc0000000000000000000000000000000000001'); + expect(signer?.network).toBe('evm'); + }); + + it('reads the x-payment alias', async () => { + const payload = { + payload: { authorization: { from: '0xAbC0000000000000000000000000000000000002' } }, + }; + const signer = await extractSignerForPrecheck({ 'X-Payment': encodeX402(payload) }); + expect(signer?.address).toBe('0xabc0000000000000000000000000000000000002'); + }); + + it('returns null with no payment headers', async () => { + expect(await extractSignerForPrecheck({})).toBeNull(); + expect(await extractSignerForPrecheck({ authorization: 'Bearer not-a-payment' })).toBeNull(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// makeMppxComposeHook +// ───────────────────────────────────────────────────────────────────────────── + +describe('makeMppxComposeHook', () => { + function buildCtx(headers: Record = {}, pricing: { amountUsd: number } | null = { amountUsd: 1.0 }): CheckoutContext { + return { + request: { method: 'POST', url: 'https://x/y', headers, body: {} }, + referenceId: 'ref', + pricing, + recipients: {}, + state: {}, + }; + } + + it('returns 402 when pricing is null', async () => { + const hook = makeMppxComposeHook({ serverGetter: async () => ({ charge: async () => ({}) }) }); + const out = await hook(buildCtx({}, null)); + expect(out.status).toBe(402); + }); + + it('emits challenge headers on a 402', async () => { + const challenge = { toWwwAuthenticate: (realm: string) => `Payment realm="${realm}"` }; + const hook = makeMppxComposeHook({ + serverGetter: async () => ({ realm: 'test-realm', charge: async () => challenge }), + }); + const out = await hook(buildCtx()); + expect(out.status).toBe(402); + expect(out.headers?.['www-authenticate']).toBe('Payment realm="test-realm"'); + }); + + it('lifts signer from a did:pkh:solana source on 200', async () => { + const credential = { source: 'did:pkh:solana:5eykt4:GeQg2TM4VL315Bd4LLkGrhBjdNfoatKjCJYHBDPM3D74' }; + const receipt = { reference: 'solana_sig', transaction: null }; + const hook = makeMppxComposeHook({ + serverGetter: async () => ({ realm: 'r', charge: async () => [credential, receipt] }), + }); + const out = await hook({ + request: { method: 'POST', url: 'https://x/y', headers: { authorization: 'Payment ' }, body: {} }, + referenceId: 'ref', + pricing: { amountUsd: 1.0 }, + recipients: {}, + state: {}, + }); + expect(out.status).toBe(200); + expect(out.signerNetwork).toBe('solana'); + expect(out.signerAddress).toBe('GeQg2TM4VL315Bd4LLkGrhBjdNfoatKjCJYHBDPM3D74'); + }); + + it('lifts txHash from transaction field when reference is unset', async () => { + const credential = { source: 'did:pkh:eip155:8453:0xABC' }; + const receipt = { reference: undefined, transaction: '0xfallback_tx' }; + const hook = makeMppxComposeHook({ + serverGetter: async () => ({ realm: 'r', charge: async () => [credential, receipt] }), + }); + const out = await hook({ + request: { method: 'POST', url: 'https://x/y', headers: { authorization: 'Payment ' }, body: {} }, + referenceId: 'ref', + pricing: { amountUsd: 1.0 }, + recipients: {}, + state: {}, + }); + expect(out.txHash).toBe('0xfallback_tx'); + }); + + it('non-did source leaves signer null', async () => { + const credential = { source: 'plain-not-did' }; + const receipt = { reference: '0xtx', transaction: null }; + const hook = makeMppxComposeHook({ + serverGetter: async () => ({ realm: 'r', charge: async () => [credential, receipt] }), + }); + const out = await hook({ + request: { method: 'POST', url: 'https://x/y', headers: { authorization: 'Payment ' }, body: {} }, + referenceId: 'ref', + pricing: { amountUsd: 1.0 }, + recipients: {}, + state: {}, + }); + expect(out.signerAddress).toBeNull(); + expect(out.signerNetwork).toBeNull(); + }); + + it('lifts signer from a did:pkh:eip155 source on 200', async () => { + const credential = { source: 'did:pkh:eip155:8453:0xABCD000000000000000000000000000000000003' }; + const receipt = { reference: '0xtx', transaction: null }; + const hook = makeMppxComposeHook({ + serverGetter: async () => ({ realm: 'r', charge: async () => [credential, receipt] }), + }); + const out = await hook(buildCtx({ authorization: 'Payment somevalidcred' })); + expect(out.status).toBe(200); + expect(out.txHash).toBe('0xtx'); + expect(out.signerAddress).toBe('0xabcd000000000000000000000000000000000003'); + expect(out.signerNetwork).toBe('evm'); + }); + + it('returns 402 when charge throws', async () => { + const hook = makeMppxComposeHook({ + serverGetter: async () => ({ + realm: 'r', + charge: async () => { + throw new Error('pympp blew up'); + }, + }), + }); + const out = await hook(buildCtx()); + expect(out.status).toBe(402); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// agentscore_content + redemption_md +// ───────────────────────────────────────────────────────────────────────────── + +describe('purchaseModeNote', () => { + it('returns the canonical note for each known mode', () => { + expect(purchaseModeNote('redemption_only')).toBe(PURCHASE_MODE_NOTES.redemption_only); + expect(purchaseModeNote('coupon_applicable')).toBe(PURCHASE_MODE_NOTES.coupon_applicable); + expect(purchaseModeNote('paid_only')).toBe(PURCHASE_MODE_NOTES.paid_only); + }); + it('returns empty string for unknown modes', () => { + expect(purchaseModeNote('not-a-mode')).toBe(''); + }); +}); + +describe('buildAgentscoreOnboardingSteps', () => { + it('substitutes merchant name, url, and rails', () => { + const steps = buildAgentscoreOnboardingSteps({ + merchantName: 'AgentScore Store', + appUrl: 'https://store.example', + acceptedRails: ['tempo', 'x402-base', 'solana-mpp'], + requiresKyc: true, + }); + const text = steps.join('\n'); + expect(text).toContain('AgentScore Store'); + expect(text).toContain('Tempo USDC'); + expect(text).toContain('x402 USDC on Base'); + expect(text).toContain('Solana SPL USDC'); + expect(text).toContain('tempo | base | solana'); + expect(text).toContain('required for this merchant'); + expect(text).toContain('https://store.example/catalog'); + }); + it('omits the KYC clause when requiresKyc is false', () => { + const steps = buildAgentscoreOnboardingSteps({ + merchantName: 'X', + appUrl: 'https://x', + acceptedRails: ['x402-base'], + }); + expect(steps.join('\n')).not.toContain('required for this merchant'); + }); + it('passes unknown rails through verbatim', () => { + const steps = buildAgentscoreOnboardingSteps({ + merchantName: 'X', + appUrl: 'https://x', + acceptedRails: ['future-rail'], + }); + expect(steps.join('\n')).toContain('future-rail'); + }); +}); + +describe('standardEndpointDescriptions', () => { + it('includes the canonical AgentScore commerce routes', () => { + const desc = standardEndpointDescriptions({ appUrl: 'https://x' }); + expect(Object.keys(desc)).toEqual([ + 'GET /catalog', + 'GET /catalog/{slug}', + 'POST /purchase', + 'GET /orders/{id}', + ]); + }); +}); + +describe('buildSuccessNextSteps', () => { + it('omits fulfillment_eta when not provided', () => { + const out = buildSuccessNextSteps({ orderStatusUrl: 'https://x/orders/1' }); + expect(out.fulfillment_eta).toBeUndefined(); + expect(out.action).toBe('done'); + expect(out.order_status_url).toBe('https://x/orders/1'); + }); + it('includes fulfillment_eta when provided', () => { + const out = buildSuccessNextSteps({ + orderStatusUrl: 'https://x/orders/1', + fulfillmentEta: '5-7 business days.', + }); + expect(out.fulfillment_eta).toBe('5-7 business days.'); + }); +}); + +describe('buildRedemptionSkillMd', () => { + it('substitutes merchant name and url, omits peer section by default', () => { + const md = buildRedemptionSkillMd({ + merchantName: 'AgentScore Store', + appUrl: 'https://store.example', + }); + expect(md).toContain('AgentScore Store'); + expect(md).toContain('https://store.example/catalog'); + expect(md).not.toContain("Don't have a code?"); + }); + it('emits the peer section when peerMerchantPointer is set', () => { + const md = buildRedemptionSkillMd({ + merchantName: 'X', + appUrl: 'https://x', + peerMerchantPointer: 'https://other.example', + skuIntro: 'a custom intro.', + }); + expect(md).toContain("Don't have a code?"); + // `see: ` prefix anchors the URL inside the rendered markdown section + // rather than a bare URL substring match (CodeQL incomplete-url-substring-sanitization). + expect(md).toContain('see: https://other.example'); + expect(md).toContain('a custom intro.'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// validationEnvelope + per-framework validationResponse* +// ───────────────────────────────────────────────────────────────────────────── + +describe('validationEnvelope + framework wrappers', () => { + it('validationEnvelope returns the canonical 4xx body', () => { + const out = validationEnvelope({ code: 'bad', message: 'nope', extra: { hint: 'x' } }); + expect((out.error as { code: string }).code).toBe('bad'); + expect((out.error as { message: string }).message).toBe('nope'); + expect((out.next_steps as { action: string }).action).toBe('fix_request'); + expect((out.hint as string | undefined) ?? null).toBe('x'); + }); + + it('validationResponseHono returns a Response with the body + status', async () => { + const resp = validationResponseHono({ code: 'bad', message: 'nope', status: 422 }); + expect(resp.status).toBe(422); + const body = (await resp.json()) as { error: { code: string } }; + expect(body.error.code).toBe('bad'); + }); + + it('validationResponseNextjs returns a Response', async () => { + const resp = validationResponseNextjs({ code: 'bad', message: 'nope' }); + expect(resp.status).toBe(400); + }); + + it('validationResponseWeb returns a Response', async () => { + const resp = validationResponseWeb({ code: 'bad', message: 'nope', status: 400 }); + expect(resp.status).toBe(400); + }); + + it('validationResponseExpress writes status + json to the supplied res', () => { + const calls: { status?: number; body?: unknown } = {}; + const res = { + status: (code: number) => { + calls.status = code; + return res; + }, + json: (body: unknown) => { + calls.body = body; + return res; + }, + }; + validationResponseExpress(res, { code: 'bad', message: 'nope', status: 400 }); + expect(calls.status).toBe(400); + expect((calls.body as { error: { code: string } }).error.code).toBe('bad'); + }); + + it('validationResponseFastify writes code + body on the supplied reply', () => { + const calls: { code?: number; body?: unknown } = {}; + const reply = { + code: (code: number) => { + calls.code = code; + return reply; + }, + send: (body: unknown) => { + calls.body = body; + return reply; + }, + }; + validationResponseFastify(reply, { code: 'bad', message: 'nope' }); + expect(calls.code).toBe(400); + expect((calls.body as { error: { code: string } }).error.code).toBe('bad'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Checkout framework adapters +// ───────────────────────────────────────────────────────────────────────────── + +function minimalCheckout(): Checkout { + return new Checkout({ + rails: { tempo: { recipient: RECIPIENT } as TempoRailSpec }, + url: 'https://api.example/purchase', + computePricing: () => ({ amountUsd: 1.0 }), + composeMppx: async (ctx: CheckoutContext): Promise => { + const auth = ctx.request.headers['authorization'] ?? ctx.request.headers['Authorization']; + if (auth === undefined || auth === '') { + return { status: 402, headers: { 'www-authenticate': 'Payment realm="test"' } }; + } + return { + status: 200, + railKey: 'tempo', + txHash: '0xtest', + signerAddress: '0xeb2ca790f72787c7e61bc6c861353a1e4acdfca5', + signerNetwork: 'evm', + }; + }, + onSettled: (_ctx, outcome) => ({ order_id: 'o-1', tx_hash: outcome.txHash ?? null }), + }); +} + +describe('Checkout framework adapters', () => { + it('handleHono emits a 402 on the discovery leg', async () => { + const checkout = minimalCheckout(); + const c = { + req: { + method: 'POST', + url: 'https://api.example/purchase', + json: async () => ({ item: 'wine' }), + header: () => ({}), + }, + json: (body: unknown, status?: number) => new Response(JSON.stringify(body), { status }), + body: (body: string, status?: number) => new Response(body, { status }), + }; + const resp = await checkout.handleHono(c); + expect(resp.status).toBe(402); + }); + + it('handleHono returns invalid_body envelope when json() throws', async () => { + const checkout = minimalCheckout(); + const c = { + req: { + method: 'POST', + url: 'https://api.example/purchase', + json: async () => { + throw new Error('not json'); + }, + header: () => ({}), + }, + json: (body: unknown, status?: number) => new Response(JSON.stringify(body), { status }), + body: (body: string, status?: number) => new Response(body, { status }), + }; + const resp = await checkout.handleHono(c); + expect(resp.status).toBe(400); + }); + + it('handleExpress writes a 402 to the supplied res', async () => { + const checkout = minimalCheckout(); + const calls: { status?: number; body?: unknown } = {}; + const req = { + method: 'POST', + url: '/purchase', + headers: {}, + body: { item: 'wine' }, + }; + const res = { + status: (code: number) => { + calls.status = code; + return res; + }, + setHeader: () => res, + json: (body: unknown) => { + calls.body = body; + return res; + }, + }; + await checkout.handleExpress(req, res); + expect(calls.status).toBe(402); + expect(calls.body).toBeDefined(); + }); + + it('handleFastify writes a 402 to the supplied reply', async () => { + const checkout = minimalCheckout(); + const calls: { code?: number; body?: unknown } = {}; + const request = { + method: 'POST', + url: '/purchase', + headers: {}, + body: { item: 'wine' }, + }; + const reply = { + code: (code: number) => { + calls.code = code; + return reply; + }, + header: () => reply, + send: (body: unknown) => { + calls.body = body; + return reply; + }, + }; + await checkout.handleFastify(request, reply); + expect(calls.code).toBe(402); + }); + + it('handleNextjs returns a 402 Response', async () => { + const checkout = minimalCheckout(); + const request = new Request('https://api.example/purchase', { + method: 'POST', + body: JSON.stringify({ item: 'wine' }), + headers: { 'content-type': 'application/json' }, + }); + const resp = await checkout.handleNextjs(request); + expect(resp.status).toBe(402); + }); + + it('handleWeb is an alias for handleNextjs', async () => { + const checkout = minimalCheckout(); + const request = new Request('https://api.example/purchase', { + method: 'POST', + body: JSON.stringify({ item: 'wine' }), + headers: { 'content-type': 'application/json' }, + }); + const resp = await checkout.handleWeb(request); + expect(resp.status).toBe(402); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Checkout accessors +// ───────────────────────────────────────────────────────────────────────────── + +describe('Checkout.acceptedRails + acceptedMethodNames', () => { + it('returns canonical RailKey + method-name lists derived from rails', () => { + const checkout = new Checkout({ + rails: { + tempo: { recipient: RECIPIENT } as TempoRailSpec, + x402_base: { recipient: RECIPIENT, network: 'eip155:8453' } as X402BaseRailSpec, + }, + url: 'https://x', + computePricing: () => ({ amountUsd: 1.0 }), + }); + expect(checkout.acceptedRails).toEqual(['tempo_mpp', 'x402_base']); + expect(checkout.acceptedMethodNames).toEqual(['tempo/charge', 'x402/exact (base)']); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// well_known helpers +// ───────────────────────────────────────────────────────────────────────────── + +describe('defaultA2aServices', () => { + it('returns the canonical dev.ucp.shopping A2A binding', () => { + const services = defaultA2aServices({ agentCardUrl: 'https://x/.well-known/agent-card.json' }); + expect(services['dev.ucp.shopping']).toBeDefined(); + expect(services['dev.ucp.shopping'][0]).toMatchObject({ + version: '2026-04-08', + transport: 'a2a', + endpoint: 'https://x/.well-known/agent-card.json', + }); + }); +}); + +describe('wellKnownCorsPreflightHeaders', () => { + it('returns CORS preflight headers without ACRH echo when absent', () => { + const headers = wellKnownCorsPreflightHeaders(); + expect(headers['Access-Control-Allow-Origin']).toBe('*'); + expect(headers['Access-Control-Allow-Methods']).toContain('GET'); + expect(headers['Access-Control-Allow-Headers']).toBeUndefined(); + }); + it('echoes Access-Control-Request-Headers verbatim when present', () => { + const headers = wellKnownCorsPreflightHeaders({ + 'access-control-request-headers': 'content-type, x-custom', + }); + expect(headers['Access-Control-Allow-Headers']).toBe('content-type, x-custom'); + }); + it('reads from Headers instance', () => { + const headers = wellKnownCorsPreflightHeaders( + new Headers({ 'access-control-request-headers': 'x-test' }), + ); + expect(headers['Access-Control-Allow-Headers']).toBe('x-test'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// wellKnownPreflightResponse +// ───────────────────────────────────────────────────────────────────────────── + +describe('buildAgentscoreOnboardingSteps - branch coverage', () => { + it('vendorType=api with stripe-spt rail includes stripe fallback step', () => { + const steps = buildAgentscoreOnboardingSteps({ + merchantName: 'API Co', + appUrl: 'https://api.example', + acceptedRails: ['stripe-spt'], + vendorType: 'api', + }); + const text = steps.join('\n'); + expect(text).toContain('@stripe/link-cli'); + // api branch has "make the paid call" + expect(text).toContain('Make the paid call'); + }); + + it('vendorType=goods (default) with stripe-spt rail includes catalog browse step', () => { + const steps = buildAgentscoreOnboardingSteps({ + merchantName: 'Store', + appUrl: 'https://store.example', + acceptedRails: ['stripe-spt', 'tempo'], + }); + const text = steps.join('\n'); + expect(text).toContain('@stripe/link-cli'); + expect(text).toContain('Browse the catalog'); + }); + + it('vendorType=api with no stripe rail omits stripe fallback step', () => { + const steps = buildAgentscoreOnboardingSteps({ + merchantName: 'API Co', + appUrl: 'https://api.example', + acceptedRails: ['x402-base'], + vendorType: 'api', + }); + const text = steps.join('\n'); + expect(text).not.toContain('@stripe/link-cli'); + }); +}); + +describe('buildSuccessNextSteps - branch coverage', () => { + it('omits order_status_url when not provided', () => { + const out = buildSuccessNextSteps({}); + expect(out.order_status_url).toBeUndefined(); + expect(out.fulfillment_eta).toBeUndefined(); + }); + it('includes fulfillment_eta when set', () => { + const out = buildSuccessNextSteps({ fulfillmentEta: 'ships 3-5 days', userMessage: 'Custom message.' }); + expect(out.fulfillment_eta).toBe('ships 3-5 days'); + expect(out.user_message).toBe('Custom message.'); + }); +}); + +describe('buildSignedJwksResponse + buildSignedUcpResponse - Headers instance request', () => { + it('reads X-Request-Id from Headers instance (not just plain object)', async () => { + const { buildSignedJwksResponse } = await import('../src/discovery/well_known'); + const { generateUCPSigningKey, _resetUCPSigningKeyCache } = await import('../src/identity/ucp-jwks'); + const { exportJWK } = await import('jose'); + _resetUCPSigningKeyCache(); + const { privateKey } = await generateUCPSigningKey({ kid: 'jwks-headers-test' }); + const privJwk = await exportJWK(privateKey as Parameters[0]); + const prev = process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + process.env.UCP_SIGNING_KEY_JWK_PRIVATE = JSON.stringify({ ...privJwk, kid: 'jwks-headers-test' }); + try { + const resp = await buildSignedJwksResponse({ + requestHeaders: new Headers({ 'x-request-id': 'req-from-headers' }), + }); + expect(resp.headers['X-Request-ID']).toBe('req-from-headers'); + } finally { + if (prev === undefined) delete process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + else process.env.UCP_SIGNING_KEY_JWK_PRIVATE = prev; + _resetUCPSigningKeyCache(); + } + }); + it('omits X-Request-ID when no header present', async () => { + const { buildSignedJwksResponse } = await import('../src/discovery/well_known'); + const { generateUCPSigningKey, _resetUCPSigningKeyCache } = await import('../src/identity/ucp-jwks'); + const { exportJWK } = await import('jose'); + _resetUCPSigningKeyCache(); + const { privateKey } = await generateUCPSigningKey({ kid: 'no-rid' }); + const privJwk = await exportJWK(privateKey as Parameters[0]); + const prev = process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + process.env.UCP_SIGNING_KEY_JWK_PRIVATE = JSON.stringify({ ...privJwk, kid: 'no-rid' }); + try { + const resp = await buildSignedJwksResponse({}); + expect(resp.headers['X-Request-ID']).toBeUndefined(); + } finally { + if (prev === undefined) delete process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + else process.env.UCP_SIGNING_KEY_JWK_PRIVATE = prev; + _resetUCPSigningKeyCache(); + } + }); +}); + +describe('wellKnownPreflightResponse', () => { + it('returns a 204 Response with CORS preflight headers', async () => { + const { wellKnownPreflightResponse } = await import('../src/discovery/well_known'); + const resp = wellKnownPreflightResponse(); + expect(resp.status).toBe(204); + expect(resp.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(resp.headers.get('Access-Control-Allow-Methods')).toContain('GET'); + }); + it('propagates the request ACRH echo when present', async () => { + const { wellKnownPreflightResponse } = await import('../src/discovery/well_known'); + const resp = wellKnownPreflightResponse({ 'access-control-request-headers': 'x-foo' }); + expect(resp.headers.get('Access-Control-Allow-Headers')).toBe('x-foo'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// buildMerchantIndexJson +// ───────────────────────────────────────────────────────────────────────────── + +describe('buildMerchantIndexJson', () => { + it('emits canonical fields', async () => { + const { buildMerchantIndexJson } = await import('../src/discovery/agentscore_content'); + const body = buildMerchantIndexJson({ + name: 'AgentScore Store', + description: 'Wine and merch for agents.', + docs: { llms: 'https://x/llms.txt' }, + endpoints: { 'GET /catalog': 'List products.' }, + supportedRails: ['tempo', 'x402-base'], + }); + expect(body.name).toBe('AgentScore Store'); + expect(body.audience).toBe('agents'); + expect(body.supported_rails).toEqual(['tempo', 'x402-base']); + expect(body.docs).toEqual({ llms: 'https://x/llms.txt' }); + }); + it('merges extras over the canonical fields', async () => { + const { buildMerchantIndexJson } = await import('../src/discovery/agentscore_content'); + const body = buildMerchantIndexJson({ + name: 'X', + description: 'Y', + docs: {}, + endpoints: {}, + supportedRails: [], + extra: { compliance: { min_age: 21 }, website: 'https://x.example' }, + }); + expect(body.compliance).toEqual({ min_age: 21 }); + expect(body.website).toBe('https://x.example'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// xServiceInfoExtension + xPaymentInfoFromCheckout (new openapi helpers) +// ───────────────────────────────────────────────────────────────────────────── + +describe('xServiceInfoExtension', () => { + it('emits minimal block with just categories', async () => { + const { xServiceInfoExtension } = await import('../src/discovery/openapi'); + const ext = xServiceInfoExtension({ categories: ['commerce', 'wine'] }); + expect(ext).toEqual({ 'x-service-info': { categories: ['commerce', 'wine'] } }); + }); + it('includes docs when provided', async () => { + const { xServiceInfoExtension } = await import('../src/discovery/openapi'); + const ext = xServiceInfoExtension({ + categories: ['commerce'], + docs: { human: 'https://x.example/about' }, + }); + expect(ext['x-service-info'].docs).toEqual({ human: 'https://x.example/about' }); + }); +}); + +describe('xPaymentInfoExtension authMode + description', () => { + it('emits authMode=payment and description when set', async () => { + const { xPaymentInfoExtension } = await import('../src/discovery/openapi'); + const ext = xPaymentInfoExtension({ + price: { mode: 'fixed', currency: 'USD', amount: '5.00' }, + protocols: [{ x402: {} }], + description: 'Per-purchase fee.', + }); + expect(ext['x-payment-info'].authMode).toBe('payment'); + expect(ext['x-payment-info'].description).toBe('Per-purchase fee.'); + }); +}); + +describe('xPaymentInfoFromCheckout', () => { + it('emits one protocol entry per rail and merges extras', async () => { + const { xPaymentInfoFromCheckout } = await import('../src/discovery/openapi'); + const checkout = { + rails: { + tempo: { recipient: RECIPIENT, network: 'tempo-mainnet', token: '0xtokenT' }, + base: { recipient: RECIPIENT, network: 'eip155:8453', token: 'USDC' }, + stripe: { profileId: 'profile_abc' }, + solana: { recipient: 'SoLaNaReCiPiEnT', network: 'solana:5eykt4', token: 'SoLaNaMiNt' }, + }, + }; + const ext = xPaymentInfoFromCheckout({ + checkout, + price: { mode: 'fixed', currency: 'USD', amount: '1.00' }, + description: 'Per-call fee.', + protocolExtras: { tempo: { client_command: 'pay --chain tempo' } }, + }); + const protocols = ext['x-payment-info'].protocols; + const tempoEntry = protocols.find((p) => 'mpp' in p && p.mpp.method === 'tempo'); + expect(tempoEntry).toBeDefined(); + expect((tempoEntry as { mpp: Record }).mpp.client_command).toBe('pay --chain tempo'); + expect(protocols.some((p) => 'mpp' in p && p.mpp.method === 'stripe')).toBe(true); + expect(protocols.some((p) => 'x402' in p)).toBe(true); + expect(protocols.some((p) => 'mpp' in p && p.mpp.method === 'solana')).toBe(true); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// loadSolanaFeePayer +// ───────────────────────────────────────────────────────────────────────────── + +describe('loadSolanaFeePayer', () => { + it('returns undefined for empty / undefined privateKey', async () => { + const { loadSolanaFeePayer } = await import('../src/payment/solana'); + expect(await loadSolanaFeePayer({ privateKey: undefined })).toBeUndefined(); + expect(await loadSolanaFeePayer({ privateKey: '' })).toBeUndefined(); + }); + it('accepts a 128-char hex keypair (takes first 32 bytes as seed)', async () => { + const { loadSolanaFeePayer } = await import('../src/payment/solana'); + const hex = '01'.repeat(64); + const signer = await loadSolanaFeePayer({ privateKey: hex }); + expect(signer).toBeDefined(); + }); + it('accepts a 64-byte base58 keypair (Phantom export)', async () => { + const kit = (await import('@solana/kit').catch(() => null)) as + | { getBase58Codec?: () => { decode: (b: Uint8Array) => string } } + | null; + if (!kit?.getBase58Codec) { + return; // peer dep missing — covered by the hex-input test + } + const { loadSolanaFeePayer } = await import('../src/payment/solana'); + const seed = Uint8Array.from({ length: 32 }, (_, i) => i + 1); + const fullBytes = new Uint8Array(64); + fullBytes.set(seed); + const codec = kit.getBase58Codec(); + const base58 = codec.decode(fullBytes); + const signer = await loadSolanaFeePayer({ privateKey: base58 }); + expect(signer).toBeDefined(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// buildSignedUcpResponse / buildSignedJwksResponse / bootstrapUcpSigningKey +// ───────────────────────────────────────────────────────────────────────────── + +describe('buildSignedUcpResponse', () => { + it('returns 503 ucp_misconfigured when no payment handlers can be derived', async () => { + const { buildSignedUcpResponse } = await import('../src/discovery/well_known'); + const emptyCheckout = new Checkout({ merchantName: 'X', rails: {} }); + const resp = await buildSignedUcpResponse({ + checkout: emptyCheckout, + name: 'X', + wellKnownUcpUrl: 'https://x/.well-known/ucp', + services: {}, + }); + expect(resp.status).toBe(503); + expect(resp.headers['Cache-Control']).toContain('max-age=60'); + const body = JSON.parse(resp.body); + expect(body.error.code).toBe('ucp_misconfigured'); + }); + it('echoes X-Request-ID from request headers on the misconfigured envelope', async () => { + const { buildSignedUcpResponse } = await import('../src/discovery/well_known'); + const emptyCheckout = new Checkout({ merchantName: 'X', rails: {} }); + const resp = await buildSignedUcpResponse({ + checkout: emptyCheckout, + name: 'X', + wellKnownUcpUrl: 'https://x/.well-known/ucp', + services: {}, + requestHeaders: { 'X-Request-Id': 'req-abc' }, + }); + expect(resp.headers['X-Request-ID']).toBe('req-abc'); + }); +}); + +describe('bootstrapUcpSigningKey', () => { + it('throws when UCP_SIGNING_KEY_JWK_PRIVATE env is malformed', async () => { + const { bootstrapUcpSigningKey } = await import('../src/discovery/well_known'); + const { _resetUCPSigningKeyCache } = await import('../src/identity/ucp-jwks'); + _resetUCPSigningKeyCache(); + const prev = process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + process.env.UCP_SIGNING_KEY_JWK_PRIVATE = 'not-json'; + try { + await expect(bootstrapUcpSigningKey()).rejects.toThrow(); + } finally { + if (prev === undefined) delete process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + else process.env.UCP_SIGNING_KEY_JWK_PRIVATE = prev; + _resetUCPSigningKeyCache(); + } + }); + it('succeeds with a valid Ed25519 JWK env', async () => { + const { bootstrapUcpSigningKey } = await import('../src/discovery/well_known'); + const { generateUCPSigningKey, _resetUCPSigningKeyCache } = await import('../src/identity/ucp-jwks'); + const { exportJWK } = await import('jose'); + _resetUCPSigningKeyCache(); + const { privateKey } = await generateUCPSigningKey({ kid: 'bootstrap-test' }); + const privJwk = await exportJWK(privateKey as Parameters[0]); + const prev = process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + process.env.UCP_SIGNING_KEY_JWK_PRIVATE = JSON.stringify({ ...privJwk, kid: 'bootstrap-test' }); + try { + await expect(bootstrapUcpSigningKey()).resolves.toBeUndefined(); + } finally { + if (prev === undefined) delete process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + else process.env.UCP_SIGNING_KEY_JWK_PRIVATE = prev; + _resetUCPSigningKeyCache(); + } + }); +}); + +describe('buildSignedJwksResponse', () => { + it('returns 200 with application/jwk-set+json and Cache-Control', async () => { + const { buildSignedJwksResponse } = await import('../src/discovery/well_known'); + const { generateUCPSigningKey, _resetUCPSigningKeyCache } = await import('../src/identity/ucp-jwks'); + const { exportJWK } = await import('jose'); + _resetUCPSigningKeyCache(); + const { privateKey } = await generateUCPSigningKey({ kid: 'jwks-test' }); + const privJwk = await exportJWK(privateKey as Parameters[0]); + const prev = process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + process.env.UCP_SIGNING_KEY_JWK_PRIVATE = JSON.stringify({ ...privJwk, kid: 'jwks-test' }); + try { + const resp = await buildSignedJwksResponse({ requestHeaders: { 'X-Request-Id': 'req-jwks' } }); + expect(resp.status).toBe(200); + expect(resp.mediaType).toBe('application/jwk-set+json'); + expect(resp.headers['Cache-Control']).toContain('max-age=300'); + expect(resp.headers['X-Request-ID']).toBe('req-jwks'); + const body = JSON.parse(resp.body) as { keys: Array<{ kid: string }> }; + expect(body.keys.length).toBe(1); + expect(body.keys[0].kid).toBe('jwks-test'); + } finally { + if (prev === undefined) delete process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + else process.env.UCP_SIGNING_KEY_JWK_PRIVATE = prev; + _resetUCPSigningKeyCache(); + } + }); +}); + +describe('buildSignedUcpResponse happy path', () => { + it('signs a UCP profile when rails + env signing key are present', async () => { + const { buildSignedUcpResponse } = await import('../src/discovery/well_known'); + const { generateUCPSigningKey, _resetUCPSigningKeyCache } = await import('../src/identity/ucp-jwks'); + const { exportJWK } = await import('jose'); + _resetUCPSigningKeyCache(); + const { privateKey } = await generateUCPSigningKey({ kid: 'ucp-test' }); + const privJwk = await exportJWK(privateKey as Parameters[0]); + const prev = process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + process.env.UCP_SIGNING_KEY_JWK_PRIVATE = JSON.stringify({ ...privJwk, kid: 'ucp-test' }); + try { + const checkout = new Checkout({ + merchantName: 'AgentScore Store', + rails: { + tempo: { + recipient: RECIPIENT, + network: 'tempo-mainnet', + } as TempoRailSpec, + base: { + recipient: RECIPIENT, + network: 'eip155:8453', + } as X402BaseRailSpec, + }, + }); + const resp = await buildSignedUcpResponse({ + checkout, + name: 'AgentScore Store', + wellKnownUcpUrl: 'https://x/.well-known/ucp', + services: { 'dev.ucp.shopping': [] }, + requestHeaders: { 'X-Request-Id': 'req-ucp' }, + signingKid: 'ucp-test', + }); + expect(resp.status).toBe(200); + expect(resp.mediaType).toBe('application/json'); + expect(resp.headers['X-Request-ID']).toBe('req-ucp'); + expect(resp.headers['Cache-Control']).toContain('max-age=60'); + const body = JSON.parse(resp.body) as { ucp: { name?: string; payment_handlers: Record }; signature: string }; + expect(body.ucp.name).toBe('AgentScore Store'); + expect(body.signature).toBeDefined(); + expect(body.ucp.payment_handlers).toBeDefined(); + } finally { + if (prev === undefined) delete process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + else process.env.UCP_SIGNING_KEY_JWK_PRIVATE = prev; + _resetUCPSigningKeyCache(); + } + }); + it('routes solana + stripe + tempo-session rails through their respective payment-handler builders', async () => { + const { buildSignedUcpResponse } = await import('../src/discovery/well_known'); + const { generateUCPSigningKey, _resetUCPSigningKeyCache } = await import('../src/identity/ucp-jwks'); + const { exportJWK } = await import('jose'); + _resetUCPSigningKeyCache(); + const { privateKey } = await generateUCPSigningKey({ kid: 'ucp-multi' }); + const privJwk = await exportJWK(privateKey as Parameters[0]); + const prev = process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + process.env.UCP_SIGNING_KEY_JWK_PRIVATE = JSON.stringify({ ...privJwk, kid: 'ucp-multi' }); + try { + const checkout = new Checkout({ + merchantName: 'Multi-Rail', + rails: { + solana: { recipient: 'SoLaNaReCiPiEnT', network: 'solana:5eykt4', rpcUrl: 'https://x' } as never, + stripe: { profileId: 'profile_abc' } as never, + tempoSession: { + recipient: RECIPIENT, + escrowContract: '0x' + '11'.repeat(20), + store: {} as never, + } as never, + }, + }); + const resp = await buildSignedUcpResponse({ + checkout, + name: 'Multi-Rail', + wellKnownUcpUrl: 'https://x/.well-known/ucp', + services: {}, + signingKid: 'ucp-multi', + }); + expect(resp.status).toBe(200); + const body = JSON.parse(resp.body) as { ucp: { payment_handlers: Record } }; + // Each handler key is reverse-DNS; at least one mpp + one stripe handler should be present. + const keys = Object.keys(body.ucp.payment_handlers); + expect(keys.some((k) => k.includes('mpp') || k.includes('stripe'))).toBe(true); + } finally { + if (prev === undefined) delete process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + else process.env.UCP_SIGNING_KEY_JWK_PRIVATE = prev; + _resetUCPSigningKeyCache(); + } + }); + it('drops rails with empty-string sentinel recipients from the published handler when not statically resolvable', async () => { + const { buildSignedUcpResponse } = await import('../src/discovery/well_known'); + const { generateUCPSigningKey, _resetUCPSigningKeyCache } = await import('../src/identity/ucp-jwks'); + const { exportJWK } = await import('jose'); + _resetUCPSigningKeyCache(); + const { privateKey } = await generateUCPSigningKey({ kid: 'ucp-sentinel' }); + const privJwk = await exportJWK(privateKey as Parameters[0]); + const prev = process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + process.env.UCP_SIGNING_KEY_JWK_PRIVATE = JSON.stringify({ ...privJwk, kid: 'ucp-sentinel' }); + try { + const checkout = new Checkout({ + merchantName: 'Per-order Recipient', + rails: { + base: { + recipient: '', // empty-string sentinel: per-order mint + network: 'eip155:8453', + } as X402BaseRailSpec, + }, + }); + const resp = await buildSignedUcpResponse({ + checkout, + name: 'X', + wellKnownUcpUrl: 'https://x/.well-known/ucp', + services: {}, + signingKid: 'ucp-sentinel', + }); + expect(resp.status).toBe(200); + } finally { + if (prev === undefined) delete process.env.UCP_SIGNING_KEY_JWK_PRIVATE; + else process.env.UCP_SIGNING_KEY_JWK_PRIVATE = prev; + _resetUCPSigningKeyCache(); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Checkout.acceptedRails / acceptedMethodNames accessors +// ───────────────────────────────────────────────────────────────────────────── + +describe('Checkout.acceptedRails + acceptedMethodNames', () => { + it('dedupes tempo + tempo_session into a single tempo_mpp slug', async () => { + const { Checkout } = await import('../src/checkout'); + const c = new Checkout({ + rails: { + tempo: { recipient: RECIPIENT, network: 'tempo-mainnet' }, + tempoSession: { + recipient: RECIPIENT, + escrowContract: '0x' + '11'.repeat(20), + store: {}, + }, + base: { recipient: RECIPIENT, network: 'eip155:8453' }, + solana: { recipient: 'SoLa', network: 'solana:5eykt4', rpcUrl: 'https://x' }, + stripe: { profileId: 'profile_abc' }, + }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + }); + const rails = c.acceptedRails; + expect(rails.filter((r: string) => r === 'tempo_mpp').length).toBe(1); + expect(rails).toContain('x402_base'); + expect(rails).toContain('solana_mpp'); + expect(rails).toContain('stripe'); + + const names = c.acceptedMethodNames; + expect(names).toContain('tempo/charge'); + expect(names).toContain('x402/exact (base)'); + expect(names).toContain('solana/charge'); + expect(names).toContain('stripe/spt'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Checkout gate hooks (CheckoutGateConfig) +// ───────────────────────────────────────────────────────────────────────────── + +async function _checkoutWithGate(gate: Record): Promise { + const { Checkout } = await import('../src/checkout'); + return new Checkout({ + rails: { tempo: { recipient: RECIPIENT, network: 'tempo-mainnet' } }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + composeMppx: async (ctx: { request: { headers: Record } }) => { + if (!ctx.request.headers.authorization) { + return { status: 402, headers: { 'www-authenticate': 'Payment realm="t"' } }; + } + return { + status: 200, + railKey: 'tempo', + txHash: '0xtest', + signerAddress: '0xabc0000000000000000000000000000000000001', + signerNetwork: 'evm', + }; + }, + onSettled: async (_ctx: unknown, outcome: { txHash?: string }) => ({ + order_id: 'o-1', + tx_hash: outcome.txHash, + }), + gate, + }); +} + +describe('Checkout gate hooks', () => { + it('runGate escape hatch returning undefined → allow → settle proceeds', async () => { + const seen: unknown[] = []; + const checkout = await _checkoutWithGate({ + apiKey: 'k', + runGate: async (ctx: unknown) => { + seen.push(ctx); + return undefined; + }, + }) as { handle: (req: unknown) => Promise<{ status: number }> }; + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { authorization: 'Payment ' }, + body: {}, + }); + expect(result.status).toBe(200); + expect(seen.length).toBe(1); + }); + it('runGate returning a denial dict propagates status + body + headers', async () => { + const checkout = await _checkoutWithGate({ + apiKey: 'k', + runGate: async () => ({ + status: 403, + body: { error: { code: 'custom_denied' } }, + headers: { 'X-Custom': 'v' }, + }), + }) as { + handle: (req: unknown) => Promise<{ + status: number; + body: Record; + headers: Record; + }>; + }; + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { authorization: 'Payment ' }, + body: {}, + }); + expect(result.status).toBe(403); + expect((result.body.error as { code: string }).code).toBe('custom_denied'); + expect(result.headers['X-Custom']).toBe('v'); + }); + it('runGate returning an invalid shape throws TypeError', async () => { + const checkout = await _checkoutWithGate({ + apiKey: 'k', + runGate: async () => 'not-a-valid-shape' as unknown, + }) as { handle: (req: unknown) => Promise }; + await expect( + checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { authorization: 'Payment ' }, + body: {}, + }), + ).rejects.toThrow(); + }); + it('perRequestPolicy returning null skips the gate entirely', async () => { + const checkout = await _checkoutWithGate({ + apiKey: 'k', + perRequestPolicy: async () => null, + }) as { handle: (req: unknown) => Promise<{ status: number }> }; + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { authorization: 'Payment ' }, + body: {}, + }); + expect(result.status).toBe(200); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Checkout constructor auto-derive paths +// ───────────────────────────────────────────────────────────────────────────── + +describe('Checkout auto-derives composeMppx from mppxSecretKey', () => { + it('passing mppxSecretKey without composeMppx wires lazyMppxServer + makeMppxComposeHook', async () => { + const checkout = new Checkout({ + rails: { tempo: { recipient: RECIPIENT, network: 'tempo-mainnet' } as TempoRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + mppxSecretKey: 'X'.repeat(32), + }); + // Discovery leg should emit 402 with the auto-derived composeMppx producing + // a www-authenticate challenge — but our mock real `lazyMppxServer` will + // need mppx peer dep. So we only assert the constructor accepted the + // auto-derive config (no throw). + expect(checkout).toBeDefined(); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: {}, + body: {}, + }); + // Discovery leg returns 402 regardless of whether composeMppx server resolves. + expect(result.status).toBe(402); + }); +}); + +describe('Checkout 402 emit with x402 server', () => { + it('builds x402 accepts from the server during 402 emit', async () => { + const checkout = new Checkout({ + rails: { x402_base: { recipient: RECIPIENT, network: 'eip155:84532' } as X402BaseRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + x402Server: _mockX402Server() as never, + }); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: {}, + body: {}, + }); + expect(result.status).toBe(402); + // x402 accepts entries should be derived from buildPaymentRequirements + expect((result.body as { accepts?: unknown[] }).accepts).toBeDefined(); + }); + + it('falls back to empty accepts when buildPaymentRequirements throws', async () => { + const server = _mockX402Server({ + buildPaymentRequirements: vi.fn().mockRejectedValue(new Error('scheme not registered')), + }); + const checkout = new Checkout({ + rails: { x402_base: { recipient: RECIPIENT, network: 'eip155:84532' } as X402BaseRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + x402Server: server as never, + }); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: {}, + body: {}, + }); + expect(result.status).toBe(402); + // The catch branch ran (else accepts would have been populated). + // accepts may be undefined or [] depending on body shape; the key signal + // is that the response was 402 with no x402 entries (the rail dropped out). + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// getIdentityStatus +// ───────────────────────────────────────────────────────────────────────────── + +describe('getIdentityStatus', () => { + it('returns anonymous when assess is undefined / null', async () => { + const { getIdentityStatus } = await import('../src/checkout'); + expect(getIdentityStatus({ request: {} as never } as never)).toBe('anonymous'); + expect(getIdentityStatus({ request: { assess: null } } as never)).toBe('anonymous'); + }); + it('returns verified when assess.decision is allow', async () => { + const { getIdentityStatus } = await import('../src/checkout'); + expect(getIdentityStatus({ request: { assess: { decision: 'allow' } } } as never)).toBe('verified'); + }); + it('returns unverified when assess is present but decision is not allow', async () => { + const { getIdentityStatus } = await import('../src/checkout'); + expect(getIdentityStatus({ request: { assess: { decision: 'deny' } } } as never)).toBe('unverified'); + expect(getIdentityStatus({ request: { assess: {} } } as never)).toBe('unverified'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// SDK gate path (createAgentScoreCore + core.evaluate) +// ───────────────────────────────────────────────────────────────────────────── + +function _mockCore(opts: { + outcome?: 'allow' | 'deny'; + reason?: Record; + signerVerdict?: Record; + captureWalletCalls?: Array>; +}) { + return { + evaluate: async () => ( + opts.outcome === 'deny' + ? { kind: 'deny', reason: opts.reason ?? { code: 'kyc_required' } } + : { kind: 'allow' } + ), + getSignerVerdict: () => opts.signerVerdict, + captureWallet: async (o: Record) => { + opts.captureWalletCalls?.push(o); + }, + }; +} + +describe('Checkout SDK gate path', () => { + it('SDK gate allow → settle proceeds and ctx.captureWallet is wired', async () => { + const captureCalls: Array> = []; + vi.doMock('../src/core', async () => { + const real = await vi.importActual('../src/core'); + return { + ...real, + createAgentScoreCore: () => _mockCore({ outcome: 'allow', captureWalletCalls: captureCalls }), + }; + }); + const { Checkout: ScopedCheckout } = await import('../src/checkout?sdk-gate-allow'); + + const checkout = new ScopedCheckout({ + rails: { tempo: { recipient: RECIPIENT, network: 'tempo-mainnet' } as TempoRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + composeMppx: async () => ({ + status: 200, + railKey: 'tempo', + txHash: '0xchain_tx', + signerAddress: '0xabc', + signerNetwork: 'evm', + }), + onSettled: async (ctx, outcome) => { + if (ctx.captureWallet !== undefined && outcome.signerAddress !== null) { + await ctx.captureWallet({ + walletAddress: outcome.signerAddress, + network: 'evm', + idempotencyKey: outcome.txHash ?? undefined, + }); + } + return { order_id: 'o-1' }; + }, + gate: { apiKey: 'k', requireKyc: true }, + }); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { authorization: 'Payment ', 'x-operator-token': 'opc_test' }, + body: {}, + }); + expect(result.status).toBe(200); + expect(captureCalls).toEqual([ + { operatorToken: 'opc_test', walletAddress: '0xabc', network: 'evm', idempotencyKey: '0xchain_tx' }, + ]); + vi.doUnmock('../src/core'); + }); + + it('SDK gate deny → 403 with denialReasonToBody envelope', async () => { + vi.doMock('../src/core', async () => { + const real = await vi.importActual('../src/core'); + return { + ...real, + createAgentScoreCore: () => + _mockCore({ + outcome: 'deny', + reason: { code: 'kyc_required', agent_instructions: { action: 'deliver_verify_url_and_poll' } }, + }), + }; + }); + const { Checkout: ScopedCheckout } = await import('../src/checkout?sdk-gate-deny'); + + const checkout = new ScopedCheckout({ + rails: { tempo: { recipient: RECIPIENT, network: 'tempo-mainnet' } as TempoRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + composeMppx: async () => ({ status: 402, headers: {} }), + onSettled: async () => ({}), + gate: { apiKey: 'k', requireKyc: true }, + }); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { authorization: 'Payment ' }, + body: {}, + }); + expect([401, 403, 503]).toContain(result.status); + expect((result.body as { error?: { code?: string } }).error?.code).toBe('kyc_required'); + vi.doUnmock('../src/core'); + }); + + it('SDK gate signer-match mismatch denies inline as wallet_signer_mismatch', async () => { + vi.doMock('../src/core', async () => { + const real = await vi.importActual('../src/core'); + return { + ...real, + createAgentScoreCore: () => ({ + evaluate: async () => ({ kind: 'allow' }), + getSignerVerdict: () => ({ + signer_match: { + kind: 'wallet_signer_mismatch', + claimedOperator: 'op_a', + actualSignerOperator: 'op_b', + expectedSigner: '0xclaimed', + actualSigner: '0xactual', + linkedWallets: ['0xlinked'], + agentInstructions: { action: 'resign_or_switch_to_operator_token' }, + claimedWallet: '0xclaimed', + }, + }), + captureWallet: async () => {}, + }), + }; + }); + const { Checkout: ScopedCheckout } = await import('../src/checkout?sdk-gate-sm'); + + const checkout = new ScopedCheckout({ + rails: { tempo: { recipient: RECIPIENT, network: 'tempo-mainnet' } as TempoRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + composeMppx: async () => ({ status: 200, railKey: 'tempo', txHash: '0x' }), + onSettled: async () => ({}), + gate: { apiKey: 'k' }, + }); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { authorization: 'Payment ', 'x-wallet-address': '0xclaimed' }, + body: {}, + }); + expect(result.status).toBe(403); + expect((result.body as { error: { code: string } }).error.code).toBe('wallet_signer_mismatch'); + vi.doUnmock('../src/core'); + }); + + it('SDK gate signer-match wallet_auth_requires_wallet_signing also denies inline', async () => { + vi.doMock('../src/core', async () => { + const real = await vi.importActual('../src/core'); + return { + ...real, + createAgentScoreCore: () => ({ + evaluate: async () => ({ kind: 'allow' }), + getSignerVerdict: () => ({ + signer_match: { + kind: 'wallet_auth_requires_wallet_signing', + claimedWallet: '0xclaimed', + agentInstructions: { action: 'switch_to_operator_token' }, + }, + }), + captureWallet: async () => {}, + }), + }; + }); + const { Checkout: ScopedCheckout } = await import('../src/checkout?sdk-gate-sm-wallet'); + const checkout = new ScopedCheckout({ + rails: { tempo: { recipient: RECIPIENT, network: 'tempo-mainnet' } as TempoRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + composeMppx: async () => ({ status: 200, railKey: 'tempo', txHash: '0x' }), + onSettled: async () => ({}), + gate: { apiKey: 'k' }, + }); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { authorization: 'Payment ', 'x-wallet-address': '0xclaimed' }, + body: {}, + }); + expect(result.status).toBe(403); + expect((result.body as { error: { code: string } }).error.code).toBe('wallet_auth_requires_wallet_signing'); + vi.doUnmock('../src/core'); + }); + + it('SDK gate onDenied callback can reshape the canonical denial body', async () => { + vi.doMock('../src/core', async () => { + const real = await vi.importActual('../src/core'); + return { + ...real, + createAgentScoreCore: () => + _mockCore({ outcome: 'deny', reason: { code: 'kyc_required' } }), + }; + }); + const { Checkout: ScopedCheckout } = await import('../src/checkout?sdk-gate-on-denied'); + + const checkout = new ScopedCheckout({ + rails: { tempo: { recipient: RECIPIENT, network: 'tempo-mainnet' } as TempoRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + composeMppx: async () => ({ status: 402, headers: {} }), + onSettled: async () => ({}), + gate: { + apiKey: 'k', + requireKyc: true, + onDenied: async (_ctx, reason) => ({ + status: 402, + body: { error: { code: 'custom_kyc', upstream: (reason as { code: string }).code } }, + headers: {}, + }), + }, + }); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { authorization: 'Payment ' }, + body: {}, + }); + expect(result.status).toBe(402); + expect((result.body as { error: { code: string; upstream: string } }).error.code).toBe('custom_kyc'); + vi.doUnmock('../src/core'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Zero-settle MPP carve-out +// ───────────────────────────────────────────────────────────────────────────── + +// ───────────────────────────────────────────────────────────────────────────── +// Checkout handleX402 + handleMppx settle paths +// ───────────────────────────────────────────────────────────────────────────── + +function _mockX402Server(overrides: Partial<{ + buildPaymentRequirements: ReturnType; + enrichExtensions: ReturnType; + verifyPayment: ReturnType; + settlePayment: ReturnType; +}> = {}): unknown { + return { + buildPaymentRequirements: overrides.buildPaymentRequirements ?? vi.fn().mockResolvedValue([ + { scheme: 'exact', network: 'eip155:84532', payTo: RECIPIENT, maxAmountRequired: '10000', extra: { name: 'USDC', version: '2' } }, + ]), + enrichExtensions: overrides.enrichExtensions ?? vi.fn().mockReturnValue(undefined), + verifyPayment: overrides.verifyPayment ?? vi.fn().mockResolvedValue({ success: true }), + settlePayment: overrides.settlePayment ?? vi.fn().mockResolvedValue({ + success: true, transaction: '0xchain_tx', network: 'eip155:84532', payer: '0xabc0000000000000000000000000000000000099', + }), + }; +} + +function _x402PaymentHeader(payerAddress: string): string { + const payload = { + x402Version: 2, + scheme: 'exact', + network: 'eip155:84532', + accepted: { + scheme: 'exact', + network: 'eip155:84532', + payTo: RECIPIENT, + maxAmountRequired: '100000', + maxTimeoutSeconds: 300, + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + extra: { name: 'USDC', version: '2' }, + }, + payload: { + signature: '0x' + 'ee'.repeat(65), + authorization: { + from: payerAddress, + to: RECIPIENT, + value: '100000', + validAfter: '0', + validBefore: '9999999999', + nonce: '0x' + '00'.repeat(32), + }, + }, + }; + return Buffer.from(JSON.stringify(payload)).toString('base64'); +} + +describe('Checkout handleX402 happy path', () => { + it('settles x402 via the mocked server and emits 200 with txHash', async () => { + const onSettledArgs: Array<{ txHash?: string | null; signerAddress?: string | null }> = []; + const checkout = new Checkout({ + rails: { x402_base: { recipient: RECIPIENT, network: 'eip155:84532' } as X402BaseRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + x402Server: _mockX402Server() as never, + isCachedAddress: () => true, + onSettled: async (_ctx, outcome) => { + onSettledArgs.push({ txHash: outcome.txHash, signerAddress: outcome.signerAddress }); + return { order_id: 'o-1', tx_hash: outcome.txHash, signer: outcome.signerAddress }; + }, + }); + const header = _x402PaymentHeader('0xAbC0000000000000000000000000000000000099'); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { 'x-payment': header }, + body: { item: 'wine' }, + }); + expect(result.status).toBe(200); + expect(result.settled).toBe(true); + expect(onSettledArgs.length).toBe(1); + expect(onSettledArgs[0].txHash).toBe('0xchain_tx'); + expect(onSettledArgs[0].signerAddress).toBe('0xabc0000000000000000000000000000000000099'); + }); + + it('returns verify_failed envelope on verify reject', async () => { + const server = _mockX402Server({ + verifyPayment: vi.fn().mockResolvedValue({ success: false, reason: 'invalid_credential' }), + }); + const checkout = new Checkout({ + rails: { x402_base: { recipient: RECIPIENT, network: 'eip155:84532' } as X402BaseRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + x402Server: server as never, + isCachedAddress: () => true, + onSettled: async () => ({}), + }); + const header = _x402PaymentHeader('0xAbC0000000000000000000000000000000000099'); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { 'x-payment': header }, + body: { item: 'wine' }, + }); + expect([400, 402, 403]).toContain(result.status); + expect(result.settled).toBe(false); + }); + + it('classifies settle failures as 503 payment_provider_unavailable on facilitator error', async () => { + const server = _mockX402Server({ + settlePayment: vi.fn().mockRejectedValue(new Error('cdp facilitator down')), + }); + const checkout = new Checkout({ + rails: { x402_base: { recipient: RECIPIENT, network: 'eip155:84532' } as X402BaseRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + x402Server: server as never, + isCachedAddress: () => true, + onSettled: async () => ({}), + }); + const header = _x402PaymentHeader('0xAbC0000000000000000000000000000000000099'); + const result = (await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { 'x-payment': header }, + body: { item: 'wine' }, + })) as { status: number; settled: boolean }; + expect(result.settled).toBe(false); + expect(result.status).toBeGreaterThanOrEqual(400); + }); +}); + +describe('Checkout handleMppx', () => { + it('emits 200 with txHash from composeMppx on settle', async () => { + const settles: Array<{ txHash?: string | null }> = []; + const checkout = new Checkout({ + rails: { tempo: { recipient: RECIPIENT, network: 'tempo-mainnet' } as TempoRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + composeMppx: async (ctx) => { + if (!ctx.request.headers.authorization) { + return { status: 402, headers: { 'www-authenticate': 'Payment realm="t"' } }; + } + return { + status: 200, + railKey: 'tempo', + txHash: '0xmppx_tx', + signerAddress: '0xabc0000000000000000000000000000000000007', + signerNetwork: 'evm', + }; + }, + onSettled: async (_ctx, outcome) => { + settles.push({ txHash: outcome.txHash }); + return { order_id: 'o-mpp', tx_hash: outcome.txHash }; + }, + }); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { authorization: 'Payment ' }, + body: { item: 'wine' }, + }); + expect(result.status).toBe(200); + expect(settles).toEqual([{ txHash: '0xmppx_tx' }]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// preValidate / CheckoutValidationError / validation envelope +// ───────────────────────────────────────────────────────────────────────────── + +describe('Checkout preValidate', () => { + it('populates ctx.state from the preValidate return value', async () => { + const seenState: Record[] = []; + const checkout = new Checkout({ + rails: { tempo: { recipient: RECIPIENT, network: 'tempo-mainnet' } as TempoRailSpec }, + url: 'https://api.example/purchase', + computePricing: async (ctx) => { + seenState.push({ ...ctx.state }); + return { amountUsd: 1.0 }; + }, + preValidate: async (_ctx) => ({ product: { slug: 'wine-2020', purchaseMode: 'paid_only' } }), + }); + await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: {}, + body: {}, + }); + expect(seenState[0]).toEqual({ product: { slug: 'wine-2020', purchaseMode: 'paid_only' } }); + }); + + it('returns canonical validation envelope on CheckoutValidationError', async () => { + const { CheckoutValidationError } = await import('../src/checkout'); + const checkout = new Checkout({ + rails: { tempo: { recipient: RECIPIENT, network: 'tempo-mainnet' } as TempoRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + preValidate: async () => { + throw new CheckoutValidationError({ + code: 'product_not_found', + message: 'No such product.', + status: 404, + extra: { slug: 'mystery' }, + }); + }, + }); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: {}, + body: { product_slug: 'mystery' }, + }); + expect(result.status).toBe(404); + expect(((result.body as { error: { code: string } }).error).code).toBe('product_not_found'); + }); + + it('CheckoutValidationError defaults to status 400 + action fix_request when unset', async () => { + const { CheckoutValidationError } = await import('../src/checkout'); + const err = new CheckoutValidationError({ + code: 'bad_thing', + message: 'nope', + }); + expect(err.status).toBe(400); + expect(err.action).toBe('fix_request'); + expect(err.extra).toBeUndefined(); + }); + + it('rethrows non-CheckoutValidationError errors from preValidate', async () => { + const checkout = new Checkout({ + rails: { tempo: { recipient: RECIPIENT, network: 'tempo-mainnet' } as TempoRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + preValidate: async () => { + throw new Error('unexpected crash'); + }, + }); + await expect( + checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: {}, + body: {}, + }), + ).rejects.toThrow('unexpected crash'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Framework adapter SETTLE leg (handleHono / handleExpress / handleFastify / handleNextjs / handleWeb) +// ───────────────────────────────────────────────────────────────────────────── + +function _settleCheckout(): Checkout { + return new Checkout({ + rails: { tempo: { recipient: RECIPIENT, network: 'tempo-mainnet' } as TempoRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + composeMppx: async (ctx) => { + if (!ctx.request.headers.authorization) { + return { status: 402, headers: { 'www-authenticate': 'Payment realm="t"' } }; + } + return { + status: 200, + railKey: 'tempo', + txHash: '0xtest_settle', + signerAddress: '0xabc', + signerNetwork: 'evm', + }; + }, + onSettled: async (_ctx, outcome) => ({ + order_id: 'order-1', + tx_hash: outcome.txHash, + }), + }); +} + +describe('Framework adapter SETTLE leg', () => { + it('handleHono 200 on settle leg with authorization', async () => { + const checkout = _settleCheckout(); + const { Hono } = await import('hono'); + const app = new Hono(); + app.post('/purchase', (c) => checkout.handleHono(c)); + const resp = await app.request('/purchase', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Payment ' }, + body: JSON.stringify({ item: 'wine' }), + }); + expect(resp.status).toBe(200); + const body = (await resp.json()) as { order_id: string; tx_hash: string }; + expect(body.order_id).toBe('order-1'); + expect(body.tx_hash).toBe('0xtest_settle'); + }); + + it('handleNextjs 200 on settle leg', async () => { + const checkout = _settleCheckout(); + const req = new Request('https://api.example/purchase', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Payment ' }, + body: JSON.stringify({ item: 'wine' }), + }); + const resp = await checkout.handleNextjs(req); + expect(resp.status).toBe(200); + const body = (await resp.json()) as { order_id: string }; + expect(body.order_id).toBe('order-1'); + }); + + it('handleWeb 200 on settle leg (alias for handleNextjs)', async () => { + const checkout = _settleCheckout(); + const req = new Request('https://api.example/purchase', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Payment ' }, + body: JSON.stringify({ item: 'wine' }), + }); + const resp = await checkout.handleWeb(req); + expect(resp.status).toBe(200); + }); + + it('handleExpress writes a 200 on settle leg', async () => { + const checkout = _settleCheckout(); + const captured: { status?: number; body?: unknown } = {}; + const req = { + headers: { 'content-type': 'application/json', authorization: 'Payment ' }, + body: { item: 'wine' }, + method: 'POST', + protocol: 'https', + get: (h: string) => (h.toLowerCase() === 'host' ? 'api.example' : ''), + originalUrl: '/purchase', + }; + const res = { + statusCode: 200, + headers: {} as Record, + set: function (k: string, v: string) { + this.headers[k] = v; + }, + status: function (s: number) { + this.statusCode = s; + captured.status = s; + return this; + }, + json: function (b: unknown) { + captured.body = b; + return this; + }, + }; + await checkout.handleExpress(req as never, res as never); + expect(captured.status).toBe(200); + expect(((captured.body as { order_id?: string })?.order_id)).toBe('order-1'); + }); + + it('handleExpress handles array-valued headers (Express normalizes to string[] for some headers)', async () => { + const checkout = _settleCheckout(); + const captured: { status?: number; body?: unknown } = {}; + const req = { + headers: { + 'content-type': 'application/json', + // Express represents multi-value headers as string[]. + 'accept': ['application/json', 'text/plain'], + // Single-value: still a string. + authorization: 'Payment ', + }, + body: { item: 'wine' }, + method: 'POST', + protocol: 'https', + get: (h: string) => (h.toLowerCase() === 'host' ? 'api.example' : ''), + originalUrl: '/purchase', + }; + const res = { + statusCode: 200, + headers: {} as Record, + set: function (k: string, v: string) { this.headers[k] = v; }, + setHeader: function (k: string, v: string) { this.headers[k] = v; }, + status: function (s: number) { this.statusCode = s; captured.status = s; return this; }, + json: function (b: unknown) { captured.body = b; return this; }, + }; + await checkout.handleExpress(req as never, res as never); + expect(captured.status).toBe(200); + }); + + it('handleExpress 400 invalid_body envelope when body is non-object', async () => { + const checkout = _settleCheckout(); + const captured: { status?: number; body?: unknown } = {}; + const req = { + headers: { 'content-type': 'application/json' }, + body: 'not-an-object', + method: 'POST', + get: (_h: string) => '', + originalUrl: '/purchase', + }; + const res = { + statusCode: 200, + headers: {} as Record, + set: function (k: string, v: string) { this.headers[k] = v; }, + setHeader: function (k: string, v: string) { this.headers[k] = v; }, + status: function (s: number) { this.statusCode = s; captured.status = s; return this; }, + json: function (b: unknown) { captured.body = b; return this; }, + }; + await checkout.handleExpress(req as never, res as never); + expect(captured.status).toBe(400); + expect(((captured.body as { error?: { code: string } })?.error?.code)).toBe('invalid_body'); + }); + + it('handleFastify handles array-valued headers + body null returns 400', async () => { + const checkout = _settleCheckout(); + let captured: { status?: number; body?: unknown } = {}; + const reply = { + code: function (s: number) { captured.status = s; return this; }, + header: function () { return this; }, + send: function (b: unknown) { captured.body = b; return this; }, + }; + // Multi-value header path + await checkout.handleFastify({ + headers: { 'content-type': 'application/json', 'x-multi': ['v1', 'v2'], authorization: 'Payment ' }, + body: { item: 'wine' }, + method: 'POST', + url: '/purchase', + } as never, reply as never); + expect(captured.status).toBe(200); + // Null body branch + captured = {}; + await checkout.handleFastify({ + headers: { 'content-type': 'application/json' }, + body: 'not-an-object', + method: 'POST', + url: '/purchase', + } as never, reply as never); + expect(captured.status).toBe(400); + }); + + it('handleFastify writes a 200 on settle leg', async () => { + const checkout = _settleCheckout(); + const captured: { status?: number; body?: unknown } = {}; + const request = { + headers: { 'content-type': 'application/json', authorization: 'Payment ' }, + body: { item: 'wine' }, + method: 'POST', + protocol: 'https', + hostname: 'api.example', + url: '/purchase', + }; + const reply = { + code: function (s: number) { + captured.status = s; + return this; + }, + header: function () { + return this; + }, + send: function (b: unknown) { + captured.body = b; + return this; + }, + }; + await checkout.handleFastify(request as never, reply as never); + expect(captured.status).toBe(200); + }); +}); + +describe('Checkout mintRecipients - branch coverage', () => { + it('drops a rail with empty-string recipient even when no override is provided', async () => { + const checkout = new Checkout({ + rails: { tempo: { recipient: '' as unknown as string, network: 'tempo-mainnet' } as TempoRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + }); + // Discovery leg should still emit 402 even with an empty-recipient rail + // (per-order mint pattern — the rail is dropped from accepts). + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: {}, + body: {}, + }); + expect(result.status).toBe(402); + }); +}); + +describe('Checkout discoveryExtensions on 402 body', () => { + it('emits x402 extensions block when discoveryExtensions is configured', async () => { + const checkout = new Checkout({ + rails: { x402_base: { recipient: RECIPIENT, network: 'eip155:84532' } as X402BaseRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + x402Server: _mockX402Server() as never, + discoveryExtensions: { bazaar: { discoveryEndpoint: 'https://x/.well-known/bazaar' } }, + }); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: {}, + body: {}, + }); + expect(result.status).toBe(402); + // Discovery extensions are emitted in the 402 body. The exact placement is + // an implementation detail of build402Body; verify the value appears somewhere. + const serialized = JSON.stringify(result.body); + expect(serialized).toContain('bazaar'); + expect(serialized).toContain('https://x/.well-known/bazaar'); + }); + + it('empty discoveryExtensions object does not emit the extensions field', async () => { + const checkout = new Checkout({ + rails: { x402_base: { recipient: RECIPIENT, network: 'eip155:84532' } as X402BaseRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + x402Server: _mockX402Server() as never, + discoveryExtensions: {}, + }); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: {}, + body: {}, + }); + const x402 = (result.body as { x402?: { extensions?: Record } }).x402; + expect(x402?.extensions).toBeUndefined(); + }); +}); + +describe('Checkout handleX402 settle returns payer from settleResult when verified.payload lacks from', () => { + it('falls back to settleResult.payer when payload.authorization.from is missing', async () => { + // The mock processX402Settle response provides payer = "0xfallback_payer". + const server = _mockX402Server({ + settlePayment: vi.fn().mockResolvedValue({ + success: true, + transaction: '0xchain_tx', + network: 'eip155:84532', + payer: '0xFallBackPayer000000000000000000000000Abcd', + }), + }); + // Craft a payload where authorization.from is missing. + const payload = { + x402Version: 2, + scheme: 'exact', + network: 'eip155:84532', + accepted: { + scheme: 'exact', network: 'eip155:84532', payTo: RECIPIENT, + maxAmountRequired: '100000', maxTimeoutSeconds: 300, + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + extra: { name: 'USDC', version: '2' }, + }, + payload: { signature: '0x' + 'ee'.repeat(65), authorization: { /* no from */ } }, + }; + const header = Buffer.from(JSON.stringify(payload)).toString('base64'); + const onSettledArgs: Array<{ signerAddress: string | null }> = []; + const checkout = new Checkout({ + rails: { x402_base: { recipient: RECIPIENT, network: 'eip155:84532' } as X402BaseRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 1.0 }), + x402Server: server as never, + isCachedAddress: () => true, + onSettled: async (_ctx, outcome) => { + onSettledArgs.push({ signerAddress: outcome.signerAddress }); + return { order_id: 'o-fallback', signer: outcome.signerAddress }; + }, + }); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { 'x-payment': header }, + body: {}, + }); + expect(result.status).toBe(200); + // Signer came from settleResult.payer (no lowercase conversion in fallback path). + expect(onSettledArgs[0].signerAddress).toBe('0xFallBackPayer000000000000000000000000Abcd'); + }); +}); + +describe('Checkout zero-settle x402-base carve-out', () => { + it('lifts signer from x402 payload at $0 and emits 200 with tx_hash null', async () => { + const checkout = new Checkout({ + rails: { x402_base: { recipient: RECIPIENT, network: 'eip155:84532' } as X402BaseRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 0.0 }), + x402Server: _mockX402Server() as never, + isCachedAddress: () => true, + onSettled: async (_ctx, outcome) => ({ + order_id: 'o-zero', + tx_hash: outcome.txHash, + signer: outcome.signerAddress, + }), + zeroSettleCarveOut: true, + }); + const header = _x402PaymentHeader('0xAbC0000000000000000000000000000000000007'); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { 'x-payment': header }, + body: {}, + }); + expect(result.status).toBe(200); + expect((result.body as { tx_hash: string | null }).tx_hash).toBeNull(); + expect((result.body as { signer: string }).signer).toBe('0xabc0000000000000000000000000000000000007'); + }); + + it('falls through gracefully when x-payment header is not valid base64 json', async () => { + const checkout = new Checkout({ + rails: { x402_base: { recipient: RECIPIENT, network: 'eip155:84532' } as X402BaseRailSpec }, + url: 'https://api.example/purchase', + computePricing: async () => ({ amountUsd: 0.0 }), + x402Server: _mockX402Server() as never, + isCachedAddress: () => true, + onSettled: async () => ({ order_id: 'o-bad' }), + zeroSettleCarveOut: true, + }); + const result = await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { 'x-payment': '!!!not-base64!!!' }, + body: {}, + }); + // Zero settle still succeeds (no signer lifted, but the carve-out doesn't gate on signer). + expect(result.status).toBe(200); + }); +}); + +describe('Checkout zero-settle MPP carve-out', () => { + it('zeroSettleCarveOut=true + $0 + MPP authorization → 200 with tx_hash null', 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: 0.0 }), + composeMppx: async () => ({ + status: 402, + headers: { 'www-authenticate': 'Payment realm="t"' }, + }), + onSettled: async (_ctx: unknown, outcome: { txHash?: string | null; railKey?: string }) => ({ + order_id: 'o-1', + tx_hash: outcome.txHash, + rail_key: outcome.railKey, + }), + zeroSettleCarveOut: true, + }); + const result = (await checkout.handle({ + method: 'POST', + url: 'https://api.example/purchase', + headers: { authorization: 'Payment ' }, + body: { item: 'wine' }, + })) as { status: number; body: Record }; + expect(result.status).toBe(200); + expect(result.body.tx_hash ?? null).toBeNull(); + }); +});