diff --git a/CLAUDE.md b/CLAUDE.md index d4a984a..3760076 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ Every helper is extracted from a real consumer, not speculated. | Subpath | What it is | |---|---| -| `@agent-score/commerce` (top-level) | `Checkout` orchestrator (the 2.0 high-level surface) for fixed-price one-shot endpoints: one config object + hooks (preValidate, computePricing, mintRecipients, composeMppx, onSettled, gate), auto-derived x402+mppx servers, per-framework adapters `handleHono`/`handleExpress`/`handleFastify`/`handleNextjs`/`handleWeb`, signed UCP routes via `mountUcpRoutes{Hono,Express,Fastify}`, optional `discoveryProbe` config for x402-crawler auto-routing. Plus `computeFirstCheckout` — variable-cost pay-per-result helper (compute-first + exact-x402). Scope is exact-mode rails only (x402-exact Base, tempo/charge, solana/charge, Stripe SPT); does NOT use x402-upto (Permit2) or Settlement-Overrides — variable cost is captured by running the work pre-settle and emitting a 402 at the exact computed price. `createQuoteCache` — content-hash quote cache used by the compute-first helper (in-memory by default; pass `redisUrl` for distributed deployments). `createResultCache` — the neutral primitive under it: keyed JSON-value cache with the same body-hash key builder; use it to cache any probe-leg result a Checkout-class merchant replays on the settle leg (e.g. a paid upstream call made in `preValidate`). `createDefaultOnDenied` — canonical `onDenied(reason)` factory matching `Checkout`'s gate hook (handles `wallet_signer_mismatch`, `wallet_not_trusted` unfixable fallback, `payment_required`, `token_expired`/`invalid_credential`/`api_error`); merchants pass `merchantName` + `supportEmail` and override `walletNotTrustedMessage` / `paymentRequiredMessage` / `supportContext` for vendor-specific copy. `hasPaymentHeader` — discriminator that splits discovery legs (no payment credential → 402) from settle legs (`payment-signature` / `x-payment` / `Authorization: Payment `); `hasX402Header` / `hasMppxHeader` — granular dispatch helpers (x402 vs MPP credential present) for routes that branch on rail. `malformedPaymentCredential` — wire-shape gate for payment credentials (not base64 JSON, not token-shaped → reject); `Checkout` runs it before any merchant hook by default (`credentialPreCheck: false` opts out). `defaultReadOnlyOnDenied(reason)` — canonical `onDenied` for read-only resource gates (`GET /orders/:id`): collapses every denial to 401 `unauthorized` + `Cache-Control: no-store` while still spreading `denialReasonToBody` so `agent_instructions` / `verify_url` ride through. `extractOwnerScope(headers) → { walletAddress?, operatorTokenHash? }` — pull canonical owner identity from `X-Wallet-Address` / `X-Operator-Token` with safe token hashing; pair with a wallet-or-token-scoped resource query so plaintext tokens never leave the request. Plus factories: `pricingResult` (cents → typed PricingResult with optional `decimals` for sub-cent precision), `validationResponse{Hono,Express,Fastify,Nextjs,Web}` (4xx envelope per framework) | +| `@agent-score/commerce` (top-level) | `Checkout` orchestrator (the 2.0 high-level surface) for fixed-price one-shot endpoints: one config object + hooks (preValidate, computePricing, mintRecipients, composeMppx, onSettled, gate), auto-derived x402+mppx servers, per-framework adapters `handleHono`/`handleExpress`/`handleFastify`/`handleNextjs`/`handleWeb`, signed UCP routes via `mountUcpRoutes{Hono,Express,Fastify}`, optional `discoveryProbe` config for x402-crawler auto-routing. Plus `computeFirstCheckout` — variable-cost pay-per-result helper (compute-first + exact-x402). Scope is exact-mode rails only (x402-exact Base, tempo/charge, solana/charge, Stripe SPT); does NOT use x402-upto (Permit2) or Settlement-Overrides — variable cost is captured by running the work pre-settle and emitting a 402 at the exact computed price. `createQuoteCache` — content-hash quote cache used by the compute-first helper (in-memory by default; pass `redisUrl` for distributed deployments). `createResultCache` — the neutral primitive under it: keyed JSON-value cache with the same body-hash key builder; use it to cache any probe-leg result a Checkout-class merchant replays on the settle leg (e.g. a paid upstream call made in `preValidate`). `createDefaultOnDenied` — canonical `onDenied(reason)` factory matching `Checkout`'s gate hook (handles `wallet_signer_mismatch`, `wallet_not_trusted` unfixable fallback, `payment_required`, `token_expired`/`invalid_credential`/`api_error`); merchants pass `merchantName` + `supportEmail` and override `walletNotTrustedMessage` / `paymentRequiredMessage` / `supportContext` for vendor-specific copy. `shouldRunConditionalGate` / `requestsVerificationSession` / `hasIdentityHeader` / `VERIFICATION_SESSION_HEADER`: the conditional-gate predicate (payment credential or an opt-in pre-payment verification-session request) and its parts; `buildIdentityBootstrap`: the matching `identity_bootstrap` 402 block. `hasPaymentHeader` — discriminator that splits discovery legs (no payment credential → 402) from settle legs (`payment-signature` / `x-payment` / `Authorization: Payment `); `hasX402Header` / `hasMppxHeader` — granular dispatch helpers (x402 vs MPP credential present) for routes that branch on rail. `malformedPaymentCredential` — wire-shape gate for payment credentials (not base64 JSON, not token-shaped → reject); `Checkout` runs it before any merchant hook by default (`credentialPreCheck: false` opts out). `defaultReadOnlyOnDenied(reason)` — canonical `onDenied` for read-only resource gates (`GET /orders/:id`): collapses every denial to 401 `unauthorized` + `Cache-Control: no-store` while still spreading `denialReasonToBody` so `agent_instructions` / `verify_url` ride through. `extractOwnerScope(headers) → { walletAddress?, operatorTokenHash? }` — pull canonical owner identity from `X-Wallet-Address` / `X-Operator-Token` with safe token hashing; pair with a wallet-or-token-scoped resource query so plaintext tokens never leave the request. Plus factories: `pricingResult` (cents → typed PricingResult with optional `decimals` for sub-cent precision), `validationResponse{Hono,Express,Fastify,Nextjs,Web}` (4xx envelope per framework) | | `@agent-score/commerce/identity/{hono,express,fastify,nextjs,web}` | Trust gate middleware (KYC, age, sanctions, jurisdiction). Each adapter exports a `conditionalAgentscoreGate(options)` variant (Next.js / Web Fetch use the wrapper form `withConditionalAgentScoreGate(opts, handler)` / `createConditionalAgentScoreGate(opts) => guard(req)`) that fires only on settle legs — discovery legs (no payment credential) flow through and the handler emits a 402 with all rails. Adapters export ONLY framework-specific surface (gate fns, accessors, `captureWallet`); shared helpers like `hasPaymentHeader` / `denialReasonToBody` import from their canonical home (`@agent-score/commerce/payment` and `@agent-score/commerce` respectively). | | `@agent-score/commerce/identity/policy` | Framework-agnostic per-product / per-tier compliance policy helpers: `PolicyBlock`, `buildGateFromPolicy`, `runGateWithEnforcement`, `shippingCountryAllowed`, `shippingStateAllowed`, `validateShippingAgainstPolicy` (one-call country+state validator that raises `CheckoutValidationError` with the canonical envelope on miss) | | `@agent-score/commerce/payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `createX402Server` (peer-dep `@x402/core` + `@coinbase/x402` for the Coinbase facilitator), `buildX402AcceptsFor402` (one-call helper for the 402-emit path: builds the requirements via the registered scheme so `extra.name` matches the on-chain USDC contract per network), `buildDefaultCheckoutRails({tempo?, x402Base?, solanaMpp?, stripe?})` (canonical 4-rail `rails` dict factory: merchants pass per-rail overrides instead of redeclaring the recipient sentinel + network/chainId/token boilerplate. When a caller overrides `network` without pinning `token` / `chainId`, the helper derives them from the network: Base Sepolia → Sepolia USDC + chainId 84532, Solana devnet → devnet USDC mint. Explicit overrides always win. Solana's `network` field accepts both CAIP-2 (`solana:5eykt4UsFv8…` / `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) AND the raw `@solana/mpp` form (`mainnet-beta` / `devnet` / `localnet`)), `buildMppxComposeRails({amountUsd, tempoRecipient?, solanaRecipient?, ...})` (per-call intent factory replacing the hand-rolled `[['tempo/charge',{...}],['solana/charge',{...}],['stripe/charge',{...}]]` array; auto-handles USD→atomic conversion for Solana; auto-drops the `stripe/charge` rail with a one-time `console.warn` when `amountUsd < 0.50` since Stripe's fixed ~$0.30 fee makes sub-50-cent charges unprofitable — many Stripe accounts also reject PI creation below the floor with `amount_too_small`; sub-50-cent APIs pass `includeStripe: false` explicitly to silence the warning), `createMppxServer` (peer-dep `mppx`; the solana rail settles against a static treasury recipient whose USDC ATA is pre-funded out-of-band. `@solana/mpp` keeps the primary recipient's ATA out of scope on the client, so no self-referential split or ATA-creation flag is emitted; `@solana/mpp` 0.7.0 additionally rejects a primary-recipient split in fee-sponsored mode), `composeMppxRequest` (typed wrapper around `mppx.compose(...intents)(request)`; replaces the `(mppx as any).compose(...)` cast in custom `composeMppx` hooks), `mppxChallengeHeaders` (one-call extractor for the 402 path's `Object.fromEntries(challenge.headers)`), `processX402Settle` (verify+settle in one call), `isEvmNetwork`/`isSolanaNetwork` (CAIP-2 discriminators that hide the `startsWith('eip155:')` / `startsWith('solana:')` prefix matching), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header | @@ -78,7 +78,7 @@ Denial reason codes: `missing_identity`, `identity_verification_required`, `toke `createSessionOnMissing` auto-mints a verification session when no identity is present AND when `wallet_not_trusted` carries fixable reasons (`kyc_required` / `kyc_pending` / `kyc_failed`) — both paths rewrite the denial to `identity_verification_required` before reaching `onDenied`, so merchants only need to handle one code. When the merchant omits `createSessionOnMissing` from the gate config, `Checkout` auto-defaults it from `gate.apiKey` + `gate.baseUrl` + `gate.context` + `gate.merchantName` — every gated route gets the bootstrap UX out of the box. Merchants that need per-request session context or `onBeforeSession` side effects (goods merchants pre-minting an order_id) supply their own config to override. -**Getting a verify_url before paying.** On an identity-gated `Checkout`, the gate runs only on a settle leg, so a buyer with no identity used to reach the session 403 only by sending a payment credential first (an SPT buyer had to mint one). The discovery 402 now carries an `identity_bootstrap` block naming `X-Verification-Session: create` whenever the request has no identity header, and a request carrying that header, no identity and no payment credential runs the gate, which answers with the same session-bearing 403. It is opt-in on purpose: scanners replay the Bazaar example body on a schedule, so minting on every identity-less 402 would create a session and (on goods stores) a pending order per probe. Gateless merchants neither advertise nor honor it. +**Getting a verify_url before paying.** On an identity-gated `Checkout`, the gate runs only on a settle leg, so a buyer with no identity used to reach the session 403 only by sending a payment credential first (an SPT buyer had to mint one). The discovery 402 now carries an `identity_bootstrap` block (`buildIdentityBootstrap()`) naming `X-Verification-Session: create` whenever the request has no identity header (`hasIdentityHeader`), and a request carrying that header, no identity and no payment credential runs the gate, which answers with the same session-bearing 403. It is opt-in on purpose: scanners replay the Bazaar example body on a schedule, so minting on every identity-less 402 would create a session and (on goods stores) a pending order per probe. Gateless merchants neither advertise nor honor it. `buildVerificationRequiredBody(reason, opts?)` — canonical body builder for the `identity_verification_required` denial. Spreads `verify_url` / `session_id` / `poll_secret` / `poll_url` / `agent_instructions` from the gate-minted reason into a 4xx envelope with merchant-specific `error.message` and (optionally) `agentInstructions` + `extra` overrides. Saves ~10 LOC of duplicated mapping per merchant. @@ -122,7 +122,7 @@ app.use('/purchase', async (c, next) => { }); ``` -Anonymous POST flows through to the handler unauthenticated and gets a 402 with all rails + per-order pricing. Identity is verified at settle time on the retry leg (when the agent submits `X-Payment` / `Authorization: Payment`); `createSessionOnMissing` still auto-mints a verification session there. The same wrap pattern works identically across all 5 framework adapters (hono, express, fastify, nextjs, web). See `examples/multi-rail-merchant.ts` and `examples/compliance-merchant.ts`. +Anonymous POST flows through to the handler unauthenticated and gets a 402 with all rails + per-order pricing. Identity is verified at settle time on the retry leg (when the agent submits `X-Payment` / `Authorization: Payment`); `createSessionOnMissing` still auto-mints a verification session there. The shared predicate is `shouldRunConditionalGate` (from `@agent-score/commerce/payment`): a payment credential OR `requestsVerificationSession` (`X-Verification-Session: create` with no identity and no payment), so a buyer can get the session 403 before paying. Every `conditional*` adapter variant uses it; a hand-rolled wrap should too, and a merchant building its own 402 advertises the path by spreading `buildIdentityBootstrap()` into `build402Body`'s `extra` as `identity_bootstrap`. The same wrap pattern works identically across all 5 framework adapters (hono, express, fastify, nextjs, web). See `examples/multi-rail-merchant.ts` and `examples/compliance-merchant.ts`. ### `compatible_clients` field on emitted 402s diff --git a/README.md b/README.md index 37722a0..fae0969 100644 --- a/README.md +++ b/README.md @@ -74,14 +74,16 @@ const _gate = agentscoreGate({ // account sign-in (no identity documents) that still yields an operator token. }); -// Run the gate CONDITIONALLY: only when a payment credential is already attached. -// Anonymous discovery (no payment header) flows through to the handler so any spec- -// compliant x402 wallet can read the 402 challenge with rails + pricing without first -// proving identity. Identity is verified at settle time on the retry leg. -import { hasPaymentHeader } from "@agent-score/commerce/payment"; +// Run the gate CONDITIONALLY: when a payment credential is attached, or when the buyer asks +// for a verification session with `X-Verification-Session: create` and no identity. Anonymous +// discovery flows through to the handler so any spec-compliant x402 wallet can read the 402 +// challenge with rails + pricing without first proving identity; identity is verified at settle +// time, or earlier on request. Advertise the early path by spreading `buildIdentityBootstrap()` +// into your 402 body as `identity_bootstrap` (Checkout does this for you). +import { shouldRunConditionalGate } from "@agent-score/commerce/payment"; app.use("/purchase", async (c, next) => { - if (!hasPaymentHeader(c.req.raw)) { await next(); return; } + if (!shouldRunConditionalGate(c.req.raw)) { await next(); return; } return _gate(c, next); }); diff --git a/package.json b/package.json index 04d5195..ed68d4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/commerce", - "version": "2.13.0", + "version": "2.14.0", "description": "Agentic commerce SDK: identity middleware (Hono, Express, Fastify, Next.js, Web Fetch) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agentic commerce.", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/src/challenge/identity.ts b/src/challenge/identity.ts index 86920be..c703063 100644 --- a/src/challenge/identity.ts +++ b/src/challenge/identity.ts @@ -1,3 +1,5 @@ +import { VERIFICATION_SESSION_HEADER, VERIFICATION_SESSION_VALUE } from '../payment/payment_header'; + export type IdentityMode = 'wallet' | 'operator_token'; export interface SignerMatchResultLike { @@ -49,3 +51,25 @@ export function buildIdentityMetadata({ return block; } + +export interface IdentityBootstrapBlock { + header: string; + value: string; + instructions: string; +} + +/** + * Build the `identity_bootstrap` block an identity-gated 402 carries when the request has no + * identity header: it names the `X-Verification-Session: create` request that returns the gate's + * session-bearing 403 (verify_url + poll data) without a payment credential. `Checkout` attaches it + * automatically; merchants building their own 402 with `build402Body` spread it into `extra`. + */ +export function buildIdentityBootstrap(): IdentityBootstrapBlock { + return { + header: VERIFICATION_SESSION_HEADER, + value: VERIFICATION_SESSION_VALUE, + instructions: + `This purchase requires a verified identity. Without an operator token, repeat this same request with the header ${VERIFICATION_SESSION_HEADER}: ${VERIFICATION_SESSION_VALUE} and no payment credential. ` + + 'The response is a 403 carrying verify_url, session_id, poll_secret and poll_url: give verify_url to the buyer, poll poll_url for an operator_token, then pay with X-Operator-Token set.', + }; +} diff --git a/src/checkout.ts b/src/checkout.ts index 72add3d..18f08ff 100644 --- a/src/checkout.ts +++ b/src/checkout.ts @@ -45,7 +45,7 @@ import { type RailKey, buildAgentInstructions } from './challenge/agent_instruct import { firstEncounterAgentMemory } from './challenge/agent_memory'; import { build402Body, type X402ResourceInfo } from './challenge/body'; import { buildHowToPay } from './challenge/how_to_pay'; -import { type IdentityMetadataBlock, buildIdentityMetadata } from './challenge/identity'; +import { type IdentityMetadataBlock, buildIdentityBootstrap, buildIdentityMetadata } from './challenge/identity'; import { buildPricingBlock, type PricingBlock } from './challenge/pricing'; import { respond402 } from './challenge/respond_402'; import { buildValidationError } from './challenge/validation_error'; @@ -66,7 +66,7 @@ import { lazyMppxServer, lazyX402Server } from './payment/lazy'; import { classifyMppxFailure } from './payment/mppx_failures'; import { runWithMppxFailureCapture, type MppxRailSpec } from './payment/mppx_server'; import { isEvmNetwork, isSolanaNetwork } from './payment/network_kind'; -import { hasMppxHeader, hasX402Header, malformedPaymentCredential } from './payment/payment_header'; +import { hasIdentityHeader, hasMppxHeader, hasX402Header, malformedPaymentCredential, requestsVerificationSession } from './payment/payment_header'; import { resolveRecipient, type RecipientLike, @@ -574,21 +574,6 @@ function stripPaymentHeadersFromRaw(raw: unknown): unknown { return new Request(raw.url, { method: raw.method, headers }); } -/** Request header that asks an identity-gated Checkout for a verification session without paying - * first. The discovery 402 advertises it; a request carrying it and no identity or payment - * credential runs the gate, which answers with its session-bearing 403 (verify_url + poll data). - * Opt-in so crawlers replaying a valid example body never mint sessions or pending orders. */ -export const VERIFICATION_SESSION_HEADER = 'X-Verification-Session'; -const VERIFICATION_SESSION_VALUE = 'create'; - -function carriesIdentity(headers: Record): boolean { - return Boolean(headers['x-operator-token'] || headers['x-wallet-address'] || hasAgentIdentityHeaderNode(headers)); -} - -function requestsVerificationSession(headers: Record): boolean { - return headers[VERIFICATION_SESSION_HEADER.toLowerCase()]?.trim().toLowerCase() === VERIFICATION_SESSION_VALUE; -} - function resolveIdentityMetadata( ctx: CheckoutContext, ): IdentityMetadataBlock | undefined { @@ -1295,12 +1280,7 @@ export class Checkout { // (dev/testnet pattern). const hasPaymentHeader = hasX402Header(request.headers) || hasMppxHeader(request.headers); - const lowerHeaders = normalizeHeadersToLowercase(request.headers); - const bootstrapsSession = - !hasPaymentHeader && - this.hasIdentityGate() && - !carriesIdentity(lowerHeaders) && - requestsVerificationSession(lowerHeaders); + const bootstrapsSession = this.hasIdentityGate() && requestsVerificationSession(request.headers); if (hasPaymentHeader || bootstrapsSession) { const denial = this.gate !== undefined ? await this.runGate(ctx) @@ -2155,15 +2135,7 @@ export class Checkout { // linked_wallets at discovery instead of at the 403 on retry. const identityMetadata = resolveIdentityMetadata(ctx); const identityBootstrap = - this.hasIdentityGate() && !carriesIdentity(normalizeHeadersToLowercase(ctx.request.headers)) - ? { - header: VERIFICATION_SESSION_HEADER, - value: VERIFICATION_SESSION_VALUE, - instructions: - `This purchase requires a verified identity. Without an operator token, repeat this same request with the header ${VERIFICATION_SESSION_HEADER}: ${VERIFICATION_SESSION_VALUE} and no payment credential. ` + - 'The response is a 403 carrying verify_url, session_id, poll_secret and poll_url: give verify_url to the buyer, poll poll_url for an operator_token, then pay with X-Operator-Token set.', - } - : undefined; + this.hasIdentityGate() && !hasIdentityHeader(ctx.request.headers) ? buildIdentityBootstrap() : undefined; // Enrich the declared Bazaar discovery extension with the request method + // route so info.input.method (required by the v2 discovery schema) and diff --git a/src/identity/express.ts b/src/identity/express.ts index 284c9e4..d2ac54b 100644 --- a/src/identity/express.ts +++ b/src/identity/express.ts @@ -3,7 +3,7 @@ import { denialReasonToBody } from '../_response'; import { buildAipErrorBody, evaluateAipParts, type AipGateOptions } from '../aip/gate'; import { hasAgentIdentityHeaderNode } from '../aip/request'; import { createAgentScoreCore } from '../core'; -import { hasPaymentHeader } from '../payment/payment_header'; +import { shouldRunConditionalGate } from '../payment/payment_header'; import { extractPaymentSignerFromAuth } from '../signer'; import type { VerifiedAit } from '../aip/verify'; import type { @@ -198,11 +198,13 @@ export function getSignerVerdict(req: Request): SignerVerdict | undefined { /** Wrap `agentscoreGate(...)` so it only fires when a payment credential is * attached to the request. Discovery legs (no payment header) flow through * unauthenticated and the handler emits a 402 with all rails; settle legs - * trigger the full gate. */ + * trigger the full gate. + * It also fires on an `X-Verification-Session: create` request with no identity and no payment + * (`requestsVerificationSession`), so a buyer can get a verify_url before paying. */ export function conditionalAgentscoreGate(options: AgentScoreGateOptions) { const gate = agentscoreGate(options); return async function conditionalGateMiddleware(req: Request, res: Response, next: NextFunction): Promise { - if (!hasPaymentHeader(req.headers as Record)) { + if (!shouldRunConditionalGate(req.headers as Record)) { next(); return; } diff --git a/src/identity/fastify.ts b/src/identity/fastify.ts index f348177..9e6111c 100644 --- a/src/identity/fastify.ts +++ b/src/identity/fastify.ts @@ -3,7 +3,7 @@ import { denialReasonToBody } from '../_response'; import { buildAipErrorBody, evaluateAipParts, type AipGateOptions } from '../aip/gate'; import { hasAgentIdentityHeaderNode } from '../aip/request'; import { createAgentScoreCore } from '../core'; -import { hasPaymentHeader } from '../payment/payment_header'; +import { shouldRunConditionalGate } from '../payment/payment_header'; import { extractPaymentSignerFromAuth } from '../signer'; import type { VerifiedAit } from '../aip/verify'; import type { @@ -215,7 +215,10 @@ export default agentscoreGatePlugin; /** Plugin variant of `agentscoreGate` that only runs the preHandler when a * payment credential is attached. Discovery legs (no payment header) flow * through to the handler unauthenticated; settle legs trigger the full gate - * evaluation. Replaces the hand-rolled + * evaluation. + * It also fires on an `X-Verification-Session: create` request with no identity and no payment + * (`requestsVerificationSession`), so a buyer can get a verify_url before paying. + * Replaces the hand-rolled * `addHook('preHandler', (req, reply) => hasPaymentHeader(req.headers) ? gate(...) : undefined)` * wrap pattern. */ const conditionalAgentscoreGatePlugin: FastifyPluginAsync = async (fastify, options) => { @@ -223,7 +226,7 @@ const conditionalAgentscoreGatePlugin: FastifyPluginAsync const core = createAgentScoreCore(coreOptions as AgentScoreCoreOptions); fastify.addHook('preHandler', async (request, reply) => { - if (!hasPaymentHeader(request.headers as Record)) return; + if (!shouldRunConditionalGate(request.headers as Record)) return; const identity = extractIdentity(request); (request as unknown as Record)[GATE_STATE_KEY] = { core, diff --git a/src/identity/hono.ts b/src/identity/hono.ts index f19e735..b2cff55 100644 --- a/src/identity/hono.ts +++ b/src/identity/hono.ts @@ -3,7 +3,7 @@ import { denialReasonToBody } from '../_response'; import { buildAipErrorBody, evaluateAipRequest, type AipGateOptions } from '../aip/gate'; import { hasAgentIdentityHeader } from '../aip/request'; import { createAgentScoreCore } from '../core'; -import { hasPaymentHeader } from '../payment/payment_header'; +import { shouldRunConditionalGate } from '../payment/payment_header'; import { extractPaymentSigner, readX402PaymentHeader } from '../signer'; import type { VerifiedAit } from '../aip/verify'; import type { @@ -221,6 +221,8 @@ export function getSignerVerdict(c: Context): SignerVerdict | undefined { * attached to the request. Discovery legs (no payment header) flow through * unauthenticated and the handler emits a 402 with all rails; settle legs * trigger the full gate. + * It also fires on an `X-Verification-Session: create` request with no identity and no payment + * (`requestsVerificationSession`), so a buyer can get a verify_url before paying. * * Replaces the hand-rolled `if (!hasPaymentHeader(...)) { await next(); return; }` * wrap pattern in consumer codebases. @@ -228,7 +230,7 @@ export function getSignerVerdict(c: Context): SignerVerdict | undefined { export function conditionalAgentscoreGate(options: AgentScoreGateOptions): MiddlewareHandler { const gate = agentscoreGate(options); return async (c, next) => { - if (!hasPaymentHeader(c.req.raw)) { + if (!shouldRunConditionalGate(c.req.raw)) { await next(); return; } diff --git a/src/identity/nextjs.ts b/src/identity/nextjs.ts index 9d1cd1e..77e05c8 100644 --- a/src/identity/nextjs.ts +++ b/src/identity/nextjs.ts @@ -1,4 +1,4 @@ -import { hasPaymentHeader } from '../payment/payment_header'; +import { shouldRunConditionalGate } from '../payment/payment_header'; import { createAgentScoreGate } from './web'; import type { AssessResult, FailOpenInfraReason, GateQuotaInfo, OperatorHandle, SignerVerdict } from '../core'; @@ -106,14 +106,16 @@ export function agentscoreMiddleware(options: Parameters( options: Parameters>[0], handler: Parameters>[1], ): (req: TReq, ctx?: TCtx) => Promise { const wrapped = withAgentScoreGate(options, handler); return async (req: TReq, ctx?: TCtx): Promise => { - if (!hasPaymentHeader(req as unknown as Request)) { + if (!shouldRunConditionalGate(req as unknown as Request)) { const result = handler(req, {}, ctx); return result instanceof Promise ? result : Promise.resolve(result); } @@ -127,7 +129,7 @@ export function withConditionalAgentScoreGate[0]): (req: Request) => Promise { const guard = createAgentScoreGate(options); return async (req: Request) => { - if (!hasPaymentHeader(req)) return undefined; + if (!shouldRunConditionalGate(req)) return undefined; const result = await guard(req); return result.allowed ? undefined : result.response; }; diff --git a/src/identity/web.ts b/src/identity/web.ts index bdf8974..386e088 100644 --- a/src/identity/web.ts +++ b/src/identity/web.ts @@ -3,7 +3,7 @@ import { denialReasonToBody } from '../_response'; import { buildAipErrorBody, evaluateAipRequest, type AipGateOptions } from '../aip/gate'; import { hasAgentIdentityHeader } from '../aip/request'; import { createAgentScoreCore } from '../core'; -import { hasPaymentHeader } from '../payment/payment_header'; +import { shouldRunConditionalGate } from '../payment/payment_header'; import { extractPaymentSigner, readX402PaymentHeader } from '../signer'; import type { VerifiedAit } from '../aip/verify'; import type { @@ -208,11 +208,13 @@ export function withAgentScoreGate( /** Wrap `createAgentScoreGate(...)` so it only fires when a payment credential * is attached. Discovery legs flow through allowed (with `data: undefined`) - * and the handler emits a 402 with all rails; settle legs run the full gate. */ + * and the handler emits a 402 with all rails; settle legs run the full gate. + * It also fires on an `X-Verification-Session: create` request with no identity and no payment + * (`requestsVerificationSession`), so a buyer can get a verify_url before paying. */ export function createConditionalAgentScoreGate(options: AgentScoreGateOptions): (req: Request) => Promise { const guard = createAgentScoreGate(options); return async (req: Request): Promise => { - if (!hasPaymentHeader(req)) return { allowed: true }; + if (!shouldRunConditionalGate(req)) return { allowed: true }; return guard(req); }; } @@ -225,7 +227,7 @@ export function withConditionalAgentScoreGate( ): (req: Request, ctx: TCtx) => Promise { const wrapped = withAgentScoreGate(options, handler); return async (req: Request, ctx: TCtx): Promise => { - if (!hasPaymentHeader(req)) return handler(req, {}, ctx); + if (!shouldRunConditionalGate(req)) return handler(req, {}, ctx); return wrapped(req, ctx); }; } diff --git a/src/index.ts b/src/index.ts index a9c58d9..bd40c79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -110,7 +110,6 @@ export { type ReferenceIdFn, type RunGateFn, type SettleOutcome, - VERIFICATION_SESSION_HEADER, buildAipTrustedIssuers, getIdentityStatus, makeMppxComposeHook, @@ -161,12 +160,18 @@ export { } from './quote_cache'; export { createDefaultOnDenied, defaultReadOnlyOnDenied, type CreateDefaultOnDeniedOptions, type DefaultOnDeniedResult } from './identity/default_denied'; export { + VERIFICATION_SESSION_HEADER, + VERIFICATION_SESSION_VALUE, + hasIdentityHeader, hasMppxHeader, hasPaymentHeader, hasX402Header, malformedPaymentCredential, + requestsVerificationSession, + shouldRunConditionalGate, type MalformedPaymentCredential, } from './payment/payment_header'; +export { buildIdentityBootstrap, type IdentityBootstrapBlock } from './challenge/identity'; // AIP (Agentic Identity Protocol) — AIT verification (verifier role) + RFC 9421 signing. export { AGENT_IDENTITY_HEADER, diff --git a/src/payment/payment_header.ts b/src/payment/payment_header.ts index 57d45ed..6854cda 100644 --- a/src/payment/payment_header.ts +++ b/src/payment/payment_header.ts @@ -55,6 +55,39 @@ export function hasPaymentHeader(input: Request | HeadersLike): boolean { /** True when the request carries an x402 payment credential (`X-Payment` or * `Payment-Signature`). Use to dispatch to the x402 settle path. */ +/** Request header that asks an identity gate for a verification session without paying first. + * Opt-in so crawlers replaying a valid example body never mint sessions or pending orders. */ +export const VERIFICATION_SESSION_HEADER = 'X-Verification-Session'; +export const VERIFICATION_SESSION_VALUE = 'create'; + +/** True when the request carries an identity: an operator token, a wallet address, or an AIP + * `Agent-Identity` token. */ +export function hasIdentityHeader(input: Request | HeadersLike): boolean { + const headers = asHeaders(input); + return Boolean( + readHeader(headers, 'x-operator-token') || + readHeader(headers, 'x-wallet-address') || + readHeader(headers, 'agent-identity')?.split(',').some((s) => s.trim().length > 0), + ); +} + +/** True when the request asks for a verification session: `X-Verification-Session: create` + * (case-insensitive) with no identity and no payment credential. */ +export function requestsVerificationSession(input: Request | HeadersLike): boolean { + const headers = asHeaders(input); + return ( + readHeader(headers, VERIFICATION_SESSION_HEADER)?.trim().toLowerCase() === VERIFICATION_SESSION_VALUE && + !hasIdentityHeader(headers) && + !hasPaymentHeader(headers) + ); +} + +/** Whether a conditional (settle-leg) identity gate should run: a payment credential is attached, + * or the request asks for a verification session before paying. */ +export function shouldRunConditionalGate(input: Request | HeadersLike): boolean { + return hasPaymentHeader(input) || requestsVerificationSession(input); +} + export function hasX402Header(input: Request | HeadersLike): boolean { const headers = asHeaders(input); return Boolean(readHeader(headers, 'payment-signature') || readHeader(headers, 'x-payment')); diff --git a/tests/checkout_verification_bootstrap.test.ts b/tests/checkout_verification_bootstrap.test.ts index 44d65a7..0071d77 100644 --- a/tests/checkout_verification_bootstrap.test.ts +++ b/tests/checkout_verification_bootstrap.test.ts @@ -8,7 +8,8 @@ * keep getting a plain 402 and mint nothing. */ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { Checkout, VERIFICATION_SESSION_HEADER, type CheckoutRequest } from '../src/checkout'; +import { Checkout, type CheckoutRequest } from '../src/checkout'; +import { VERIFICATION_SESSION_HEADER } from '../src/index'; import type { StripeRailSpec, X402BaseRailSpec } from '../src/payment/rail_spec'; const { sessionCalls, assessCalls } = vi.hoisted(() => ({ diff --git a/tests/fastify.test.ts b/tests/fastify.test.ts index bfa861a..9dcb555 100644 --- a/tests/fastify.test.ts +++ b/tests/fastify.test.ts @@ -355,6 +355,53 @@ describe('Fastify conditional gate — settle-leg allow paths', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it('X-Verification-Session: create with no identity runs the gate and returns the session 403', async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + json: vi.fn().mockResolvedValue({ + session_id: 'sess_boot', + poll_secret: 'poll_boot', + verify_url: 'https://www.agentscore.com/verify?session=sess_boot', + poll_url: 'https://api.agentscore.com/v1/sessions/sess_boot', + }), + }); + global.fetch = fetchSpy as unknown as typeof fetch; + const app = Fastify(); + await app.register(conditionalAgentscoreGate, { + apiKey: API_KEY, + requireKyc: true, + createSessionOnMissing: { apiKey: API_KEY }, + }); + app.post('/purchase', async () => ({ ok: true })); + + const res = await app.inject({ + method: 'POST', url: '/purchase', + headers: { 'x-verification-session': 'create' }, + payload: {}, + }); + expect(res.statusCode).toBe(403); + expect(res.json()).toMatchObject({ verify_url: 'https://www.agentscore.com/verify?session=sess_boot', session_id: 'sess_boot' }); + expect(String(fetchSpy.mock.calls[0]?.[0])).toContain('/v1/sessions'); + }); + + it('X-Verification-Session with an identity header flows through like any discovery leg', async () => { + const fetchSpy = vi.fn(); + global.fetch = fetchSpy as unknown as typeof fetch; + const app = Fastify(); + await app.register(conditionalAgentscoreGate, { apiKey: API_KEY, createSessionOnMissing: { apiKey: API_KEY } }); + app.post('/purchase', async () => ({ ok: true })); + + const res = await app.inject({ + method: 'POST', url: '/purchase', + headers: { 'x-verification-session': 'create', 'x-operator-token': 'opc_x' }, + payload: {}, + }); + expect(res.statusCode).toBe(200); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it('settle leg with payment header: fail-open quota_exceeded marks degraded', async () => { mockFetchStatus(429); const app = Fastify(); diff --git a/tests/verification_session.test.ts b/tests/verification_session.test.ts new file mode 100644 index 0000000..40a10ad --- /dev/null +++ b/tests/verification_session.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { buildIdentityBootstrap } from '../src/challenge/identity'; +import { + VERIFICATION_SESSION_HEADER, + hasIdentityHeader, + requestsVerificationSession, + shouldRunConditionalGate, +} from '../src/payment/payment_header'; + +describe('verification-session request helpers', () => { + it('recognizes the header in any case, trimmed', () => { + expect(requestsVerificationSession({ 'x-verification-session': 'create' })).toBe(true); + expect(requestsVerificationSession({ [VERIFICATION_SESSION_HEADER]: ' CREATE ' })).toBe(true); + expect(requestsVerificationSession(new Headers({ 'X-Verification-Session': 'Create' }))).toBe(true); + }); + + it('rejects other values and a missing header', () => { + expect(requestsVerificationSession({ 'x-verification-session': 'yes' })).toBe(false); + expect(requestsVerificationSession({})).toBe(false); + }); + + it('does not request a session when an identity or a payment credential is present', () => { + const base = { 'x-verification-session': 'create' }; + expect(requestsVerificationSession({ ...base, 'x-operator-token': 'opc_x' })).toBe(false); + expect(requestsVerificationSession({ ...base, 'x-wallet-address': '0xabc' })).toBe(false); + expect(requestsVerificationSession({ ...base, 'agent-identity': 'eyJ.e30.sig' })).toBe(false); + expect(requestsVerificationSession({ ...base, authorization: 'Payment abc' })).toBe(false); + expect(requestsVerificationSession({ ...base, 'x-payment': 'abc' })).toBe(false); + }); + + it('treats an empty Agent-Identity value as no identity', () => { + expect(hasIdentityHeader({ 'agent-identity': ' , ' })).toBe(false); + expect(hasIdentityHeader({ 'agent-identity': 'eyJ.e30.sig' })).toBe(true); + }); + + it('runs the conditional gate on a payment credential or a session request, and not otherwise', () => { + expect(shouldRunConditionalGate({ authorization: 'Payment abc' })).toBe(true); + expect(shouldRunConditionalGate({ 'payment-signature': 'abc' })).toBe(true); + expect(shouldRunConditionalGate({ 'x-verification-session': 'create' })).toBe(true); + expect(shouldRunConditionalGate({})).toBe(false); + expect(shouldRunConditionalGate({ 'x-operator-token': 'opc_x' })).toBe(false); + }); + + it('builds the identity_bootstrap block naming the header', () => { + const block = buildIdentityBootstrap(); + expect(block.header).toBe(VERIFICATION_SESSION_HEADER); + expect(block.value).toBe('create'); + expect(block.instructions).toContain(`${VERIFICATION_SESSION_HEADER}: create`); + expect(block.instructions).toContain('verify_url'); + }); +});