Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ 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.

`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.

`getSignerVerdict(ctx)` (per-adapter) returns the cached `signer_match` + `signer_sanctions` verdicts the gate composed on its primary `/v1/assess` call (single round trip; merchants build a 403 with `buildSignerMismatchBody({ result: verdict.signer_match })` when `kind !== 'pass'`).
Expand Down
92 changes: 12 additions & 80 deletions bun.lock

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agent-score/commerce",
"version": "2.12.2",
"version": "2.13.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",
Expand Down Expand Up @@ -153,7 +153,7 @@
"overrides": {
"axios": "^1.18.0",
"esbuild": "^0.28.1",
"viem": "2.56.8"
"viem": "2.57.0"
},
"peerDependencies": {
"@solana/kit": ">=8.0.0 <9.0.0",
Expand Down Expand Up @@ -214,11 +214,11 @@
"jose": "^6.2.12",
"knip": "^6.36.0",
"lefthook": "^2.1.14",
"mppx": "0.10.1",
"mppx": "0.11.0",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.69.0",
"viem": "2.56.8",
"viem": "2.57.0",
"vitest": "^5.0.1"
},
"packageManager": "bun@1.4.2"
Expand Down
37 changes: 35 additions & 2 deletions src/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,21 @@ 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<string, string | undefined>): boolean {
return Boolean(headers['x-operator-token'] || headers['x-wallet-address'] || hasAgentIdentityHeaderNode(headers));
}

function requestsVerificationSession(headers: Record<string, string | undefined>): boolean {
return headers[VERIFICATION_SESSION_HEADER.toLowerCase()]?.trim().toLowerCase() === VERIFICATION_SESSION_VALUE;
}

function resolveIdentityMetadata(
ctx: CheckoutContext,
): IdentityMetadataBlock | undefined {
Expand Down Expand Up @@ -1280,7 +1295,13 @@ export class Checkout {
// (dev/testnet pattern).
const hasPaymentHeader =
hasX402Header(request.headers) || hasMppxHeader(request.headers);
if (hasPaymentHeader) {
const lowerHeaders = normalizeHeadersToLowercase(request.headers);
const bootstrapsSession =
!hasPaymentHeader &&
this.hasIdentityGate() &&
!carriesIdentity(lowerHeaders) &&
requestsVerificationSession(lowerHeaders);
if (hasPaymentHeader || bootstrapsSession) {
const denial = this.gate !== undefined
? await this.runGate(ctx)
: await this.runWalletSanctionsOnly(ctx);
Expand Down Expand Up @@ -2133,6 +2154,16 @@ export class Checkout {
// wallet intent. Saves agents a round trip: they learn required_signer +
// 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;

// Enrich the declared Bazaar discovery extension with the request method +
// route so info.input.method (required by the v2 discovery schema) and
Expand Down Expand Up @@ -2165,7 +2196,9 @@ export class Checkout {
...(this.gate?.aip !== undefined && { aipTrustedIssuers: aipTrustedIssuerSet(this.gate.aip) }),
}),
...(ctx.pricing.product ? { product: ctx.pricing.product as { id: string; name: string } } : {}),
...(ctx.pricing.bodyExtras ? { extra: ctx.pricing.bodyExtras } : {}),
...(ctx.pricing.bodyExtras || identityBootstrap
? { extra: { ...(identityBootstrap && { identity_bootstrap: identityBootstrap }), ...ctx.pricing.bodyExtras } }
: {}),
...(x402Accepts.length > 0 ? {
x402: {
accepts: x402Accepts,
Expand Down
5 changes: 3 additions & 2 deletions src/discovery/agentscore_content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,9 @@ export function buildAgentscoreOnboardingSteps(opts: {
'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.';
'instead of agentscore-pay and use it on the SPT rail. Identity gating still applies: send the ' +
'purchase request with `X-Verification-Session: create` before minting a token, and the ' +
'merchant\'s 403 with `verify_url` bootstraps 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 ' +
Expand Down
2 changes: 1 addition & 1 deletion src/discovery/llms_txt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ AgentScore identity is reusable across every AgentScore-gated merchant: one KYC,

- **\`X-Wallet-Address: 0x...\` or base58**: works on signing rails (Tempo, x402, Solana MPP). The wallet you claim must sign the payment.
- **\`X-Operator-Token: opc_...\`**: works on every rail, including Stripe SPT. Reusable across AgentScore merchants until expiry.${aipBullet}
- **Neither**: you get a 403 with \`verify_url\`. Complete the session flow once and reuse the resulting \`opc_...\` everywhere.${complianceNote}`;
- **Neither**: send the purchase request with \`X-Verification-Session: create\` and no payment credential, and the 403 carries \`verify_url\`. Complete the session flow once and reuse the resulting \`opc_...\` everywhere.${complianceNote}`;
}

interface LlmsTxtPaymentSectionConfig {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export {
type ReferenceIdFn,
type RunGateFn,
type SettleOutcome,
VERIFICATION_SESSION_HEADER,
buildAipTrustedIssuers,
getIdentityStatus,
makeMppxComposeHook,
Expand Down
21 changes: 20 additions & 1 deletion src/stripe-multichain/payment_intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export interface MultichainPaymentIntentResult {
depositAddresses: Record<string, string>;
}

// Concurrent creates under one idempotency key collide at Stripe as a resource-specific 429
// (`stripe-should-retry: false`), so identical requests racing in one process (a crawler replaying
// the same example body in parallel) share the first call instead of each sending their own.
const inFlightByIdempotencyKey = new Map<string, Promise<MultichainPaymentIntentResult>>();

/**
* Create a Stripe PaymentIntent with `deposit_options.networks` set to multiple chains,
* returning the PI id + deposit addresses per network. The agent sends funds to the
Expand All @@ -41,7 +46,21 @@ export interface MultichainPaymentIntentResult {
* Distinct from the Stripe SPT (Shared Payment Token) flow, which is handled via
* `createMppxStripe` + the agent's own Stripe account or `link-cli`.
*/
export async function createMultichainPaymentIntent({
export function createMultichainPaymentIntent(
params: Parameters<typeof createMultichainPaymentIntentOnce>[0],
): Promise<MultichainPaymentIntentResult> {
const key = params.idempotencyKey;
if (key === undefined) return createMultichainPaymentIntentOnce(params);
const existing = inFlightByIdempotencyKey.get(key);
if (existing) return existing;
const pending = createMultichainPaymentIntentOnce(params).finally(() => {
inFlightByIdempotencyKey.delete(key);
});
inFlightByIdempotencyKey.set(key, pending);
return pending;
}

async function createMultichainPaymentIntentOnce({
stripe,
amount,
currency = 'usd',
Expand Down
118 changes: 118 additions & 0 deletions tests/checkout_verification_bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* Checkout × opt-in verification-session bootstrap.
*
* An identity-gated Checkout lets a buyer ask for a verify_url without first building a payment
* credential: the discovery 402 advertises `X-Verification-Session: create`, and a request carrying
* it (and no identity or payment credential) runs the gate, whose missing-identity path mints a
* session and answers 403. Crawlers replaying a valid example body never send the header, so they
* 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 type { StripeRailSpec, X402BaseRailSpec } from '../src/payment/rail_spec';

const { sessionCalls, assessCalls } = vi.hoisted(() => ({
sessionCalls: [] as Array<Record<string, unknown>>,
assessCalls: [] as Array<unknown>,
}));

vi.mock('@agent-score/sdk', async (importOriginal) => {
const actual = await importOriginal<typeof import('@agent-score/sdk')>();
return {
...actual,
AgentScore: class {
async createSession(body: Record<string, unknown>) {
sessionCalls.push(body);
return {
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',
};
}
async assess(...args: unknown[]) {
assessCalls.push(args);
return { decision: 'allow', decision_reasons: [] };
}
},
};
});

afterEach(() => {
sessionCalls.length = 0;
assessCalls.length = 0;
});

function req(headers: Record<string, string> = {}): CheckoutRequest {
return {
method: 'POST',
url: 'https://wine.example/purchase',
headers: { 'content-type': 'application/json', ...headers },
body: { item: 'wine' },
};
}

function gatedCheckout() {
return new Checkout({
rails: { stripe: { profileId: 'profile_x' } as StripeRailSpec },
url: 'https://wine.example/purchase',
computePricing: () => ({ amountUsd: 50 }),
gate: { apiKey: 'as_test_key', requireKyc: true, minAge: 21 },
});
}

describe('Checkout: verification-session bootstrap', () => {
it('advertises the bootstrap header on an identity-gated 402 when no identity is sent', async () => {
const res = await gatedCheckout().handle(req());
expect(res.status).toBe(402);
const bootstrap = res.body.identity_bootstrap as { header: string; value: string; instructions: string };
expect(bootstrap.header).toBe(VERIFICATION_SESSION_HEADER);
expect(bootstrap.value).toBe('create');
expect(bootstrap.instructions).toContain('verify_url');
expect(sessionCalls).toHaveLength(0);
});

it('answers the header with the gate 403 carrying verify_url and poll data, without paying', async () => {
const res = await gatedCheckout().handle(req({ [VERIFICATION_SESSION_HEADER]: 'create' }));
expect(res.status).toBe(403);
expect(res.settled).toBe(false);
expect(res.body.verify_url).toBe('https://www.agentscore.com/verify?session=sess_boot');
expect(res.body.session_id).toBe('sess_boot');
expect(res.body.poll_secret).toBe('poll_boot');
expect(sessionCalls).toHaveLength(1);
});

it('matches the header name and value case-insensitively', async () => {
const res = await gatedCheckout().handle(req({ 'x-verification-session': ' CREATE ' }));
expect(res.status).toBe(403);
expect(sessionCalls).toHaveLength(1);
});

it('ignores the header when the request already carries an identity', async () => {
const res = await gatedCheckout().handle(req({ [VERIFICATION_SESSION_HEADER]: 'create', 'X-Operator-Token': 'opc_x' }));
expect(res.status).toBe(402);
expect(res.body.identity_bootstrap).toBeUndefined();
expect(sessionCalls).toHaveLength(0);
expect(assessCalls).toHaveLength(0);
});

it('ignores any other header value', async () => {
const res = await gatedCheckout().handle(req({ [VERIFICATION_SESSION_HEADER]: 'yes' }));
expect(res.status).toBe(402);
expect(sessionCalls).toHaveLength(0);
});

it('neither advertises nor honors the header on a merchant without an identity gate', async () => {
const gateless = new Checkout({
rails: { x402_base: { recipient: '0xT' } as X402BaseRailSpec },
url: 'https://api.example/call',
computePricing: () => ({ amountUsd: 0.01 }),
});
const advertised = await gateless.handle(req());
expect(advertised.status).toBe(402);
expect(advertised.body.identity_bootstrap).toBeUndefined();
const asked = await gateless.handle(req({ [VERIFICATION_SESSION_HEADER]: 'create' }));
expect(asked.status).toBe(402);
expect(sessionCalls).toHaveLength(0);
});
});
57 changes: 57 additions & 0 deletions tests/stripe-multichain/payment_intent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,60 @@ describe('createMultichainPaymentIntent', () => {
expect(result.depositAddresses).toEqual({ tempo: '0xtempo' });
});
});

describe('createMultichainPaymentIntent single-flight', () => {
const pi = {
id: 'pi_sf',
next_action: { crypto_display_details: { deposit_addresses: { tempo: { address: '0xsf' } } } },
};

function deferredStripe() {
let release!: () => void;
const gate = new Promise<void>((r) => { release = r; });
const create = vi.fn(async () => { await gate; return pi; });
return { stripe: { paymentIntents: { create } }, create, release };
}

it('shares one Stripe call across concurrent requests with the same idempotency key', async () => {
const { stripe, create, release } = deferredStripe();
const calls = [1, 2, 3].map(() => createMultichainPaymentIntent({ stripe, amount: 100, idempotencyKey: 'pi-same-100' }));
release();
const results = await Promise.all(calls);
expect(create).toHaveBeenCalledTimes(1);
expect(results.map((r) => r.paymentIntentId)).toEqual(['pi_sf', 'pi_sf', 'pi_sf']);
});

it('does not share across different keys or unkeyed calls', async () => {
const { stripe, create, release } = deferredStripe();
const calls = [
createMultichainPaymentIntent({ stripe, amount: 100, idempotencyKey: 'pi-a-100' }),
createMultichainPaymentIntent({ stripe, amount: 100, idempotencyKey: 'pi-b-100' }),
createMultichainPaymentIntent({ stripe, amount: 100 }),
createMultichainPaymentIntent({ stripe, amount: 100 }),
];
release();
await Promise.all(calls);
expect(create).toHaveBeenCalledTimes(4);
});

it('calls Stripe again once the shared call has settled', async () => {
const { stripe, create, release } = deferredStripe();
release();
await createMultichainPaymentIntent({ stripe, amount: 100, idempotencyKey: 'pi-later-100' });
await createMultichainPaymentIntent({ stripe, amount: 100, idempotencyKey: 'pi-later-100' });
expect(create).toHaveBeenCalledTimes(2);
});

it('shares a failure with every waiter and clears the key for the next attempt', async () => {
const create = vi.fn()
.mockRejectedValueOnce(new Error('rate limited'))
.mockResolvedValueOnce(pi);
const stripe = { paymentIntents: { create } };
const a = createMultichainPaymentIntent({ stripe, amount: 100, idempotencyKey: 'pi-fail-100' });
const b = createMultichainPaymentIntent({ stripe, amount: 100, idempotencyKey: 'pi-fail-100' });
await expect(a).rejects.toThrow('rate limited');
await expect(b).rejects.toThrow('rate limited');
await expect(createMultichainPaymentIntent({ stripe, amount: 100, idempotencyKey: 'pi-fail-100' })).resolves.toMatchObject({ paymentIntentId: 'pi_sf' });
expect(create).toHaveBeenCalledTimes(2);
});
});
Loading