From 354d209bd4cf62ef60bd09d64ec46f63ea10f2a8 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 29 Apr 2026 18:48:18 -0700 Subject: [PATCH 1/7] fix: bootstrap fixable wallet_not_trusted denials into identity_verification_required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixable compliance reasons (kyc_required, kyc_pending, kyc_failed, jurisdiction_required without explicit restriction) now get the same UX as missing_identity: the gate auto-mints a verification session, the agent polls until status=verified, gets a fresh opc_..., and retries with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient, jurisdiction_restricted) keep the bare wallet_not_trusted denial — re-verification won't change the outcome, so the canonical agent_instructions action is now contact_support. Implementation lives in core.ts (the shared evaluate() factory used by all 5 framework adapters): extracted tryMintSessionDenial helper, wired it into both the cache-hit deny branch and the fresh-fetch deny branch when reasons are fixable. New session-bootstrap test coverage (fixable → identity_verification_ required, unfixable → bare wallet_not_trusted). _response.ts canonical agent_instructions for wallet_not_trusted updated to contact_support copy. OpenAPI denial-code description documents the re-route + contact_support action. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/_response.ts | 70 ++++++++++++- src/core.ts | 212 +++++++++++++++++++++------------------ src/discovery/openapi.ts | 6 +- tests/_response.test.ts | 39 +++++++ tests/edge-cases.test.ts | 8 +- tests/hono.test.ts | 62 ++++++++++++ 7 files changed, 296 insertions(+), 103 deletions(-) diff --git a/package.json b/package.json index fbe36bb..d14b16c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/commerce", - "version": "1.0.0", + "version": "1.0.1", "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/_response.ts b/src/_response.ts index 5e99b6d..b5896c4 100644 --- a/src/_response.ts +++ b/src/_response.ts @@ -13,6 +13,73 @@ import type { DenialCode, DenialReason } from './core.js'; +/** + * JSON-encoded canonical agent_instructions per denial code. Auto-injected by + * `denialReasonToBody` when the gate produces a DenialReason without explicit + * `agent_instructions` so every denial carries a machine-readable next step. + * + * Codes covered: + * - `wallet_not_trusted` — gate never stamps instructions today (the original gap) + * - `payment_required` — gate never stamps; merchant tier misconfig, contact-merchant action + * - `identity_verification_required` — fallback when API didn't return next_steps + * - `token_expired` — fallback when API didn't return next_steps + * + * Codes already stamped explicitly upstream in core.ts (`missing_identity`, + * `invalid_credential`) and codes that don't go through DenialReason + * (`wallet_signer_mismatch`, `wallet_auth_requires_wallet_signing` — handled by + * `verifyWalletSignerMatch` result type) are not in this map. `api_error` has + * its own `next_steps: {action: retry}` fallback below. + */ +const WALLET_NOT_TRUSTED_INSTRUCTIONS = JSON.stringify({ + action: 'contact_support', + steps: [ + 'The wallet\'s operator failed an UNFIXABLE compliance check (sanctions, age, or jurisdiction). `reasons` lists which: `sanctions_flagged` / `age_insufficient` / `jurisdiction_restricted`. KYC re-verification won\'t change the outcome — the policy denial is structural.', + 'Surface the denial to the user with the merchant\'s support contact. Do not retry the same merchant request; do not hand the user a verify_url (verification won\'t fix this code path).', + 'Fixable compliance reasons (`kyc_required`, `kyc_pending`, `kyc_failed`, `jurisdiction_required` without explicit restriction) do NOT land on this code — the gate auto-mints a verification session for those and returns `identity_verification_required` with poll endpoints, same shape as `missing_identity`.', + ], + user_message: + 'This purchase is denied by the merchant\'s compliance policy and cannot be resolved by re-verifying. Contact the merchant\'s support if you believe this is in error.', +}); + +const PAYMENT_REQUIRED_INSTRUCTIONS = JSON.stringify({ + action: 'contact_merchant', + steps: [ + 'The merchant\'s AgentScore tier does not include the assess feature, so agent identity cannot be evaluated. This is a merchant-side configuration gap — there is no agent-side recovery.', + 'Contact the merchant (their support channel — typically listed in /llms.txt or the OpenAPI servers metadata) and request they upgrade their AgentScore plan.', + ], + user_message: + 'This merchant\'s identity gate is misconfigured (AgentScore tier doesn\'t support assess). Contact the merchant — there\'s nothing to fix on the agent side.', +}); + +const IDENTITY_VERIFICATION_REQUIRED_FALLBACK_INSTRUCTIONS = JSON.stringify({ + action: 'deliver_verify_url_and_poll', + steps: [ + 'Share verify_url with the user — they complete identity verification on AgentScore.', + 'If session_id + poll_secret are present in the body, poll poll_url every 5 seconds with header `X-Poll-Secret: ` until status=verified. The poll returns a one-time operator_token.', + 'Retry the original request with header `X-Operator-Token: `.', + ], + user_message: + 'Identity verification is required. Visit verify_url, then poll poll_url for the operator token and retry.', +}); + +const TOKEN_EXPIRED_FALLBACK_INSTRUCTIONS = JSON.stringify({ + action: 'deliver_verify_url_and_poll', + steps: [ + 'The operator token is expired or revoked. AgentScore auto-mints a fresh verification session — complete it to receive a new opc_...', + 'Share verify_url with the user, then poll poll_url every 5 seconds with header `X-Poll-Secret: ` until status=verified. The poll returns a fresh one-time operator_token.', + 'Retry the original request with header `X-Operator-Token: `.', + ], + user_message: + 'Operator token is expired or revoked. A new verification session has been minted — visit verify_url to refresh.', +}); + +const DEFAULT_AGENT_INSTRUCTIONS: Partial> = { + wallet_not_trusted: WALLET_NOT_TRUSTED_INSTRUCTIONS, + payment_required: PAYMENT_REQUIRED_INSTRUCTIONS, + identity_verification_required: IDENTITY_VERIFICATION_REQUIRED_FALLBACK_INSTRUCTIONS, + token_expired: TOKEN_EXPIRED_FALLBACK_INSTRUCTIONS, +}; + const DEFAULT_MESSAGES: Record = { missing_identity: 'No identity provided. Send X-Wallet-Address (wallet) or X-Operator-Token (credential).', @@ -63,7 +130,8 @@ export function denialReasonToBody(reason: DenialReason): Record(cacheSeconds * 1000); - async function evaluate(identity: AgentIdentity | undefined, ctx?: unknown): Promise { - // Treat "returned identity object with no usable fields" the same as "no identity at all" — - // otherwise a misbehaving custom extractIdentity would send an empty body to /v1/assess. - if (!identity || (!identity.address && !identity.operatorToken)) { - if (failOpen) return { kind: 'allow' }; + // Mint a verification session via /v1/sessions and return the resulting + // identity_verification_required DenialReason — or undefined if the mint failed (network + // error, non-2xx, missing fields). Used for both the missing-identity path and the + // fixable-wallet bootstrap path: in both cases the UX is identical (agent polls the + // returned poll_url until it gets a fresh opc_... and retries). + async function tryMintSessionDenial(ctx: unknown): Promise { + if (!createSessionOnMissing) return undefined; + try { + const sessionBody: { context?: string; product_name?: string } = {}; + if (createSessionOnMissing.context != null) sessionBody.context = createSessionOnMissing.context; + if (createSessionOnMissing.productName != null) sessionBody.product_name = createSessionOnMissing.productName; - if (createSessionOnMissing) { + if (createSessionOnMissing.getSessionOptions && ctx !== undefined) { try { - // Start with static context/productName; let getSessionOptions override per-request. - const sessionBody: { context?: string; product_name?: string } = {}; - if (createSessionOnMissing.context != null) sessionBody.context = createSessionOnMissing.context; - if (createSessionOnMissing.productName != null) sessionBody.product_name = createSessionOnMissing.productName; - - if (createSessionOnMissing.getSessionOptions && ctx !== undefined) { - try { - const dynamic = await createSessionOnMissing.getSessionOptions(ctx); - if (dynamic?.context != null) sessionBody.context = dynamic.context; - if (dynamic?.productName != null) sessionBody.product_name = dynamic.productName; - } catch (err) { - console.warn('[gate] createSessionOnMissing.getSessionOptions hook failed:', err instanceof Error ? err.message : err); - } - } + const dynamic = await createSessionOnMissing.getSessionOptions(ctx); + if (dynamic?.context != null) sessionBody.context = dynamic.context; + if (dynamic?.productName != null) sessionBody.product_name = dynamic.productName; + } catch (err) { + console.warn('[gate] createSessionOnMissing.getSessionOptions hook failed:', err instanceof Error ? err.message : err); + } + } - const sessionBaseUrl = stripTrailingSlashes(createSessionOnMissing.baseUrl ?? 'https://api.agentscore.sh'); - const sessionRes = await fetch(`${sessionBaseUrl}/v1/sessions`, { - method: 'POST', - headers: { - 'X-API-Key': createSessionOnMissing.apiKey, - 'Content-Type': 'application/json', - Accept: 'application/json', - 'User-Agent': userAgentHeader, - }, - body: JSON.stringify(sessionBody), - signal: AbortSignal.timeout(API_TIMEOUT_MS), - }); - - if (sessionRes.ok) { - const data = (await sessionRes.json()) as Record; - - // Validate required fields before trusting the response. A misbehaving - // (or mocked-wrong) API could 200 without session_id/poll_secret/verify_url, - // which would propagate `undefined` into the 403 body and leave the agent - // stuck — treat that as a session-create failure and fall back to the bare - // missing_identity denial with the probe strategy copy. - if ( - typeof data.session_id !== 'string' || - typeof data.poll_secret !== 'string' || - typeof data.verify_url !== 'string' - ) { - console.warn('[gate] /v1/sessions returned 200 without required fields — falling back to bare missing_identity'); - // fall through to the bare denial below - } else { - - // Run onBeforeSession side-effect hook. Errors are swallowed — a failing DB - // write (e.g. can't insert pending order) should not block the 403. - let extra: Record | undefined; - if (createSessionOnMissing.onBeforeSession && ctx !== undefined) { - try { - const sessionMeta = { - session_id: data.session_id as string, - verify_url: data.verify_url as string, - poll_secret: data.poll_secret as string, - poll_url: data.poll_url as string, - expires_at: data.expires_at as string | undefined, - }; - const result = await createSessionOnMissing.onBeforeSession(ctx, sessionMeta); - if (result && typeof result === 'object') extra = result; - } catch (err) { - console.warn('[gate] createSessionOnMissing.onBeforeSession hook failed:', err instanceof Error ? err.message : err); - } - } - - // The API emits `next_steps` (structured object) on /v1/sessions success. - // Stringify it into the gate's `agent_instructions` contract so merchants - // get the same JSON-encoded {action, steps, user_message} envelope as every - // other gate-emitted denial. - const apiNextSteps = data.next_steps as Record | undefined; - return { - kind: 'deny', - reason: { - code: 'identity_verification_required', - verify_url: data.verify_url as string, - session_id: data.session_id as string, - poll_secret: data.poll_secret as string, - poll_url: data.poll_url as string | undefined, - agent_instructions: apiNextSteps ? JSON.stringify(apiNextSteps) : undefined, - agent_memory: agentMemoryHint, - ...(extra && { extra }), - }, - }; - } - } + const sessionBaseUrl = stripTrailingSlashes(createSessionOnMissing.baseUrl ?? 'https://api.agentscore.sh'); + const sessionRes = await fetch(`${sessionBaseUrl}/v1/sessions`, { + method: 'POST', + headers: { + 'X-API-Key': createSessionOnMissing.apiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': userAgentHeader, + }, + body: JSON.stringify(sessionBody), + signal: AbortSignal.timeout(API_TIMEOUT_MS), + }); + + if (!sessionRes.ok) return undefined; + const data = (await sessionRes.json()) as Record; + + // Validate required fields before trusting the response. A misbehaving (or mocked-wrong) + // API could 200 without session_id/poll_secret/verify_url, which would propagate + // `undefined` into the 403 body and leave the agent stuck — treat as session-create + // failure and fall back to the caller's bare denial. + if ( + typeof data.session_id !== 'string' || + typeof data.poll_secret !== 'string' || + typeof data.verify_url !== 'string' + ) { + console.warn('[gate] /v1/sessions returned 200 without required fields — falling back to bare denial'); + return undefined; + } + + // Run onBeforeSession side-effect hook. Errors are swallowed — a failing DB write + // (e.g. can't insert pending order) should not block the 403. + let extra: Record | undefined; + if (createSessionOnMissing.onBeforeSession && ctx !== undefined) { + try { + const sessionMeta = { + session_id: data.session_id as string, + verify_url: data.verify_url as string, + poll_secret: data.poll_secret as string, + poll_url: data.poll_url as string, + expires_at: data.expires_at as string | undefined, + }; + const result = await createSessionOnMissing.onBeforeSession(ctx, sessionMeta); + if (result && typeof result === 'object') extra = result; } catch (err) { - // Session-mint failed (network, /v1/sessions returned non-2xx, body parse error, - // onBeforeSession threw inside the inner try). Falling through to bare - // missing_identity is correct — agents still get a 403 with a probe-strategy - // hint. But the silent catch used to mask /v1/sessions schema drift and - // unreachable-API issues for hours, so log loudly. - console.warn('[gate] createSessionOnMissing path failed — falling back to bare missing_identity:', err instanceof Error ? err.message : err); + console.warn('[gate] createSessionOnMissing.onBeforeSession hook failed:', err instanceof Error ? err.message : err); } } + // The API emits `next_steps` (structured object) on /v1/sessions success. Stringify it + // into the gate's `agent_instructions` contract so merchants get the same JSON-encoded + // {action, steps, user_message} envelope as every other gate-emitted denial. + const apiNextSteps = data.next_steps as Record | undefined; + return { + code: 'identity_verification_required', + verify_url: data.verify_url as string, + session_id: data.session_id as string, + poll_secret: data.poll_secret as string, + poll_url: data.poll_url as string | undefined, + agent_instructions: apiNextSteps ? JSON.stringify(apiNextSteps) : undefined, + agent_memory: agentMemoryHint, + ...(extra && { extra }), + }; + } catch (err) { + // Session-mint failed (network, /v1/sessions returned non-2xx, body parse error, + // onBeforeSession threw inside the inner try). Caller falls back to a bare denial — + // agents still get a 403 with a probe-strategy hint. Log loudly so a persistent + // /v1/sessions outage isn't masked. + console.warn('[gate] createSessionOnMissing path failed — falling back to bare denial:', err instanceof Error ? err.message : err); + return undefined; + } + } + + async function evaluate(identity: AgentIdentity | undefined, ctx?: unknown): Promise { + // Treat "returned identity object with no usable fields" the same as "no identity at all" — + // otherwise a misbehaving custom extractIdentity would send an empty body to /v1/assess. + if (!identity || (!identity.address && !identity.operatorToken)) { + if (failOpen) return { kind: 'allow' }; + + const sessionReason = await tryMintSessionDenial(ctx); + if (sessionReason) return { kind: 'deny', reason: sessionReason }; + // Bare missing-identity denial (no session was auto-created). Describe the probe // strategy so agents without memory can recover: try wallet first on signing rails, // fall back to stored opc_..., fall back to session flow (noting that only @@ -550,6 +552,15 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore if (cached.allow) { return { kind: 'allow', data: cached.raw as AgentScoreData }; } + // Fixable compliance denials (kyc_required, kyc_pending, kyc_failed, + // jurisdiction_required when not explicitly restricted) get the same UX as + // missing_identity: mint a fresh verification session, agent polls until + // status=verified, gets a fresh opc_..., retries. Unfixable reasons (sanctions, + // age, jurisdiction_restricted) keep the bare wallet_not_trusted denial. + if (isFixableDenial(cached.reasons)) { + const sessionReason = await tryMintSessionDenial(ctx); + if (sessionReason) return { kind: 'deny', reason: sessionReason }; + } return { kind: 'deny', reason: { @@ -687,6 +698,17 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore return { kind: 'allow', data: data as unknown as AgentScoreData }; } + // Fixable compliance denials (kyc_required, kyc_pending, kyc_failed, + // jurisdiction_required when not explicitly restricted) get the same UX as + // missing_identity: mint a fresh verification session, agent polls until + // status=verified, gets a fresh opc_..., retries. Unfixable reasons (sanctions, + // age, jurisdiction_restricted) keep the bare wallet_not_trusted denial — re- + // verification won't fix them. + if (isFixableDenial(decisionReasons)) { + const sessionReason = await tryMintSessionDenial(ctx); + if (sessionReason) return { kind: 'deny', reason: sessionReason }; + } + return { kind: 'deny', reason: { diff --git a/src/discovery/openapi.ts b/src/discovery/openapi.ts index fceccf5..4f3e662 100644 --- a/src/discovery/openapi.ts +++ b/src/discovery/openapi.ts @@ -49,7 +49,7 @@ export function agentscoreDenialSchemas(): Record { 'payment_required', ], description: - "Denial code emitted by AgentScore's gate middleware in 403 responses. Each comes with a structured agent_instructions block describing recovery actions.", + "Denial code emitted by AgentScore's gate middleware in 403 responses. Every code carries a structured agent_instructions block describing recovery actions (per-code action: missing_identity → probe_identity_then_session, identity_verification_required / token_expired → deliver_verify_url_and_poll, invalid_credential → switch_token_or_restart_session, wallet_signer_mismatch → resign_or_switch_to_operator_token, wallet_auth_requires_wallet_signing → switch_to_operator_token, wallet_not_trusted → contact_support — UNFIXABLE compliance only (sanctions/age/jurisdiction_restricted); fixable reasons re-route to identity_verification_required, payment_required → contact_merchant).", }, AgentScoreDenialBody: { type: 'object', @@ -58,9 +58,9 @@ export function agentscoreDenialSchemas(): Record { agent_instructions: { type: 'string', description: - 'JSON-encoded { action, steps, user_message } block. Agents parse this to learn how to recover (e.g., poll a verify_url, switch headers, re-sign).', + 'JSON-encoded { action, steps, user_message } block. Always present on every denial; agents parse this to learn how to recover (e.g., poll verify_url, switch headers, re-sign).', }, - verify_url: { type: 'string', format: 'uri', description: 'Present for missing_identity / token_expired denials.' }, + verify_url: { type: 'string', format: 'uri', description: "Present for missing_identity / identity_verification_required / token_expired denials. Agent shares this with the user to complete KYC or claim a wallet. Not present on wallet_not_trusted (UNFIXABLE compliance — re-verification won't change the outcome)." }, session_id: { type: 'string' }, poll_url: { type: 'string', format: 'uri' }, poll_secret: { type: 'string' }, diff --git a/tests/_response.test.ts b/tests/_response.test.ts index b96608b..e3b495d 100644 --- a/tests/_response.test.ts +++ b/tests/_response.test.ts @@ -115,4 +115,43 @@ describe('denialReasonToBody', () => { expect(body.order_id).toBe('ord_123'); expect(body.merchant_context).toBe('wine-purchase'); }); + + it('injects canonical wallet_not_trusted agent_instructions when reason has none', () => { + // wallet_not_trusted reaches the agent ONLY for unfixable reasons (sanctions / age / + // jurisdiction_restricted). Fixable reasons (kyc_required, etc.) are rerouted to + // identity_verification_required by the gate before they ever reach the marshaller. + const body = denialReasonToBody(reason({ + code: 'wallet_not_trusted', + reasons: ['sanctions_flagged'], + verify_url: 'https://agentscore.sh/dashboard/verify?address=0xabc&chain=base', + })); + const instructions = JSON.parse(body.agent_instructions as string); + expect(instructions.action).toBe('contact_support'); + expect(instructions.steps).toBeInstanceOf(Array); + expect(typeof instructions.user_message).toBe('string'); + }); + + it('injects canonical payment_required agent_instructions when reason has none', () => { + const body = denialReasonToBody(reason({ code: 'payment_required' })); + const instructions = JSON.parse(body.agent_instructions as string); + expect(instructions.action).toBe('contact_merchant'); + }); + + it('injects fallback identity_verification_required instructions when reason has none', () => { + const body = denialReasonToBody(reason({ + code: 'identity_verification_required', + verify_url: 'https://agentscore.sh/verify?session=sess_abc', + })); + const instructions = JSON.parse(body.agent_instructions as string); + expect(instructions.action).toBe('deliver_verify_url_and_poll'); + }); + + it('explicit reason.agent_instructions takes precedence over the default map', () => { + const custom = JSON.stringify({ action: 'custom_action', steps: ['custom'] }); + const body = denialReasonToBody(reason({ + code: 'wallet_not_trusted', + agent_instructions: custom, + })); + expect(body.agent_instructions).toBe(custom); + }); }); diff --git a/tests/edge-cases.test.ts b/tests/edge-cases.test.ts index 40632d7..ffe9fe2 100644 --- a/tests/edge-cases.test.ts +++ b/tests/edge-cases.test.ts @@ -683,7 +683,7 @@ describe('evaluate() — 401 passthrough edge cases', () => { expect(next).not.toHaveBeenCalled(); }); - it('passes through token_expired without agent_instructions when next_steps absent', async () => { + it('falls back to canonical token_expired agent_instructions when API next_steps absent', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401, @@ -697,8 +697,10 @@ describe('evaluate() — 401 passthrough edge cases', () => { expect(status).toHaveBeenCalledWith(401); const body = json.mock.calls[0]![0] as Record; - expect(body.error.code).toBe('token_expired'); - expect(body).not.toHaveProperty('agent_instructions'); + expect((body.error as { code: string }).code).toBe('token_expired'); + // _response.ts injects a canonical fallback so agents always have a recovery action. + const instructions = JSON.parse(body.agent_instructions as string); + expect(instructions.action).toBe('deliver_verify_url_and_poll'); }); it('falls through to generic api_error when 401 body has unknown error.code', async () => { diff --git a/tests/hono.test.ts b/tests/hono.test.ts index 84e97ae..a3ee2c2 100644 --- a/tests/hono.test.ts +++ b/tests/hono.test.ts @@ -269,6 +269,68 @@ describe('Hono adapter — createSessionOnMissing', () => { expect(postBody.context).toBe('wine-purchase'); }); + it('fixable wallet denial (kyc_required) bootstraps a session like missing_identity', async () => { + // First fetch: /v1/assess returns deny with a fixable reason. + // Second fetch: /v1/sessions mints a session. + global.fetch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValueOnce(DENY_RESPONSE), // decision_reasons: ['kyc_required'] + } as unknown as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValueOnce(SESSION_RESPONSE), + } as unknown as Response); + + const app = new Hono(); + app.use('*', agentscoreGate({ + apiKey: API_KEY, + requireKyc: true, + createSessionOnMissing: { apiKey: API_KEY, context: 'wine-purchase' }, + })); + app.get('/test', (c) => c.text('reached')); + + const res = await app.request('/test', { headers: { 'x-wallet-address': WALLET } }); + const body = await res.json(); + + // Bootstrapped: identity_verification_required with poll fields, NOT bare wallet_not_trusted. + expect(res.status).toBe(403); + expect(body).toMatchObject({ + error: expect.objectContaining({ code: 'identity_verification_required' }), + session_id: 'sess_123', + poll_secret: 'ps_secret', + verify_url: 'https://agentscore.sh/verify/new', + }); + // Confirm the second fetch went to /v1/sessions. + expect((global.fetch as ReturnType).mock.calls[1][0]).toContain('/v1/sessions'); + }); + + it('unfixable wallet denial (sanctions_flagged) does NOT bootstrap a session', async () => { + const SANCTIONS_DENY = { ...DENY_RESPONSE, decision_reasons: ['sanctions_flagged'] }; + mockFetchOk(SANCTIONS_DENY); + + const app = new Hono(); + app.use('*', agentscoreGate({ + apiKey: API_KEY, + requireKyc: true, + createSessionOnMissing: { apiKey: API_KEY, context: 'wine-purchase' }, + })); + app.get('/test', (c) => c.text('reached')); + + const res = await app.request('/test', { headers: { 'x-wallet-address': WALLET } }); + const body = await res.json(); + + expect(res.status).toBe(403); + expect(body).toMatchObject({ + error: expect.objectContaining({ code: 'wallet_not_trusted' }), + reasons: ['sanctions_flagged'], + }); + // Only one fetch (assess); no /v1/sessions call. + expect((global.fetch as ReturnType).mock.calls.length).toBe(1); + }); + // ----------------------------------------------------------------------- // getSessionOptions hook // ----------------------------------------------------------------------- From 75cc639f988e235a4773f9458e6998430d9b3111 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 29 Apr 2026 19:38:44 -0700 Subject: [PATCH 2/7] fix: remove jurisdiction_restricted from FIXABLE_DENIAL_REASONS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API only emits jurisdiction_restricted AFTER KYC is verified — meaning the user's KYC'd country is in the merchant's blocked list (or absent from the allowed list). Re-doing KYC won't change the country, so it's permanent. Same shape as sanctions_flagged / age_insufficient — should surface contact_support, not bootstrap a doomed verification session. Also flips empty/undefined reasons to return false (don't bootstrap on unknown deny — default to bare denial). Updates the canonical wallet_not_trusted instructions copy and core.ts adapter comments to spell out the API-side rationale. Tests updated: jurisdiction_restricted now in the unfixable bucket alongside sanctions/age, empty reasons returns false. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/_denial.ts | 24 ++++++++++++++---------- src/_response.ts | 2 +- src/core.ts | 25 ++++++++++++++----------- tests/denial.test.ts | 27 ++++++++++++++++++--------- 4 files changed, 47 insertions(+), 31 deletions(-) diff --git a/src/_denial.ts b/src/_denial.ts index 8d99947..ee32ed4 100644 --- a/src/_denial.ts +++ b/src/_denial.ts @@ -21,27 +21,31 @@ import type { DenialReason, VerifyWalletSignerResult } from './core'; /** - * Compliance denial reasons that can be resolved by re-completing KYC. Sanctions hits and - * age-not-verified are NOT in this set — they're permanent policy failures on an - * otherwise-verified identity and require contact-support, not retry. + * Compliance denial reasons that can be resolved by re-completing KYC. The API emits these + * when KYC is missing/pending/failed; the user can re-verify and retry. + * + * `jurisdiction_restricted` is NOT in this set — the API only emits it AFTER KYC is verified, + * meaning the user's KYC'd country is in the merchant's blocked list (or absent from the + * allowed list). Re-doing KYC won't change the country, so it's permanent. Same shape as + * `sanctions_flagged` and `age_insufficient` — surface contact_support, don't waste a + * /v1/sessions mint. */ export const FIXABLE_DENIAL_REASONS: ReadonlySet = new Set([ 'kyc_required', 'kyc_pending', 'kyc_failed', - 'jurisdiction_restricted', ]); /** - * Returns true when a `wallet_not_trusted` denial's reasons are all "fixable" (the user can - * re-verify and retry). False when any reason is permanent (sanctions, age). + * Returns true when a `wallet_not_trusted` denial's reasons are all fixable via KYC + * re-verification. False when any reason is permanent (sanctions, age, jurisdiction_restricted). * - * Empty reasons array is treated as fixable on the assumption that an empty-reason denial - * means a generic policy fail that's worth retrying — vendors can override by checking the - * specific reasons themselves. + * Empty reasons returns false — without a known reason we can't promise a fix, so default to + * the bare denial path (vendors can override via custom onDenied if they want different + * behavior on empty reasons). */ export function isFixableDenial(reasons: readonly string[] | undefined): boolean { - if (!reasons || reasons.length === 0) return true; + if (!reasons || reasons.length === 0) return false; return reasons.every((r) => FIXABLE_DENIAL_REASONS.has(r)); } diff --git a/src/_response.ts b/src/_response.ts index b5896c4..5c80e4c 100644 --- a/src/_response.ts +++ b/src/_response.ts @@ -35,7 +35,7 @@ const WALLET_NOT_TRUSTED_INSTRUCTIONS = JSON.stringify({ steps: [ 'The wallet\'s operator failed an UNFIXABLE compliance check (sanctions, age, or jurisdiction). `reasons` lists which: `sanctions_flagged` / `age_insufficient` / `jurisdiction_restricted`. KYC re-verification won\'t change the outcome — the policy denial is structural.', 'Surface the denial to the user with the merchant\'s support contact. Do not retry the same merchant request; do not hand the user a verify_url (verification won\'t fix this code path).', - 'Fixable compliance reasons (`kyc_required`, `kyc_pending`, `kyc_failed`, `jurisdiction_required` without explicit restriction) do NOT land on this code — the gate auto-mints a verification session for those and returns `identity_verification_required` with poll endpoints, same shape as `missing_identity`.', + 'Fixable compliance reasons (`kyc_required`, `kyc_pending`, `kyc_failed`) do NOT land on this code — the gate auto-mints a verification session for those and returns `identity_verification_required` with poll endpoints, same shape as `missing_identity`. `jurisdiction_restricted` IS in the unfixable bucket because the API only emits it after KYC is verified (the user\'s KYC\'d country is in the blocked list — re-doing KYC won\'t change the country).', ], user_message: 'This purchase is denied by the merchant\'s compliance policy and cannot be resolved by re-verifying. Contact the merchant\'s support if you believe this is in error.', diff --git a/src/core.ts b/src/core.ts index 19cf3ba..f393fee 100644 --- a/src/core.ts +++ b/src/core.ts @@ -552,11 +552,13 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore if (cached.allow) { return { kind: 'allow', data: cached.raw as AgentScoreData }; } - // Fixable compliance denials (kyc_required, kyc_pending, kyc_failed, - // jurisdiction_required when not explicitly restricted) get the same UX as - // missing_identity: mint a fresh verification session, agent polls until - // status=verified, gets a fresh opc_..., retries. Unfixable reasons (sanctions, - // age, jurisdiction_restricted) keep the bare wallet_not_trusted denial. + // Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the + // same UX as missing_identity: mint a fresh verification session, agent polls + // until status=verified, gets a fresh opc_..., retries. Unfixable reasons + // (sanctions_flagged, age_insufficient, jurisdiction_restricted) keep the bare + // wallet_not_trusted denial. `jurisdiction_restricted` is unfixable: the API + // only emits it after KYC is verified (the user's KYC'd country is in the + // blocked list — re-doing KYC won't change the country). if (isFixableDenial(cached.reasons)) { const sessionReason = await tryMintSessionDenial(ctx); if (sessionReason) return { kind: 'deny', reason: sessionReason }; @@ -698,12 +700,13 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore return { kind: 'allow', data: data as unknown as AgentScoreData }; } - // Fixable compliance denials (kyc_required, kyc_pending, kyc_failed, - // jurisdiction_required when not explicitly restricted) get the same UX as - // missing_identity: mint a fresh verification session, agent polls until - // status=verified, gets a fresh opc_..., retries. Unfixable reasons (sanctions, - // age, jurisdiction_restricted) keep the bare wallet_not_trusted denial — re- - // verification won't fix them. + // Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the + // same UX as missing_identity: mint a fresh verification session, agent polls + // until status=verified, gets a fresh opc_..., retries. Unfixable reasons + // (sanctions_flagged, age_insufficient, jurisdiction_restricted) keep the bare + // wallet_not_trusted denial. `jurisdiction_restricted` is unfixable: the API + // only emits it after KYC is verified (the user's KYC'd country is in the + // blocked list — re-doing KYC won't change the country). if (isFixableDenial(decisionReasons)) { const sessionReason = await tryMintSessionDenial(ctx); if (sessionReason) return { kind: 'deny', reason: sessionReason }; diff --git a/tests/denial.test.ts b/tests/denial.test.ts index aaa7d0a..5dd657e 100644 --- a/tests/denial.test.ts +++ b/tests/denial.test.ts @@ -28,24 +28,33 @@ describe('denialReasonStatus', () => { describe('FIXABLE_DENIAL_REASONS / isFixableDenial', () => { it('classifies known fixable reasons', () => { - for (const r of ['kyc_required', 'kyc_pending', 'kyc_failed', 'jurisdiction_restricted']) { + for (const r of ['kyc_required', 'kyc_pending', 'kyc_failed']) { expect(FIXABLE_DENIAL_REASONS.has(r)).toBe(true); } }); - it('returns true for empty/undefined reasons (treated as fixable)', () => { - expect(isFixableDenial(undefined)).toBe(true); - expect(isFixableDenial([])).toBe(true); + it('jurisdiction_restricted is UNFIXABLE', () => { + // The API only emits jurisdiction_restricted AFTER KYC is verified — meaning the + // user's KYC'd country is in the merchant's blocked list. Re-doing KYC won't change + // the country, same shape as sanctions_flagged / age_insufficient. + expect(FIXABLE_DENIAL_REASONS.has('jurisdiction_restricted')).toBe(false); + }); + + it('returns false for empty/undefined reasons (default to bare denial)', () => { + expect(isFixableDenial(undefined)).toBe(false); + expect(isFixableDenial([])).toBe(false); }); it('returns true when every reason is fixable', () => { - expect(isFixableDenial(['kyc_required', 'jurisdiction_restricted'])).toBe(true); + expect(isFixableDenial(['kyc_required', 'kyc_pending'])).toBe(true); }); - it('returns false when ANY reason is permanent (sanctions, age)', () => { - expect(isFixableDenial(['sanctions_not_clear'])).toBe(false); - expect(isFixableDenial(['age_not_verified'])).toBe(false); - expect(isFixableDenial(['kyc_required', 'sanctions_not_clear'])).toBe(false); + it('returns false when ANY reason is permanent (sanctions, age, jurisdiction_restricted)', () => { + expect(isFixableDenial(['sanctions_flagged'])).toBe(false); + expect(isFixableDenial(['age_insufficient'])).toBe(false); + expect(isFixableDenial(['jurisdiction_restricted'])).toBe(false); + expect(isFixableDenial(['kyc_required', 'sanctions_flagged'])).toBe(false); + expect(isFixableDenial(['kyc_required', 'jurisdiction_restricted'])).toBe(false); }); }); From 3e6b0479e1cd7cc4871cb097d46e7d51e684c67a Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 29 Apr 2026 19:42:59 -0700 Subject: [PATCH 3/7] docs: clarify compliance-merchant.ts example for new bootstrap-fixable architecture The gate now re-routes fixable reasons (kyc_required/pending/failed) upstream, so by the time wallet_not_trusted reaches the merchant's onDenied, reasons should be unfixable. The isFixableDenial branch in the example becomes a defensive fallback (only fires if the gate's /v1/sessions mint blipped). Also clarify jurisdiction_restricted is in the unfixable bucket alongside sanctions/age. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/compliance-merchant.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/examples/compliance-merchant.ts b/examples/compliance-merchant.ts index 7a47ae1..18371de 100644 --- a/examples/compliance-merchant.ts +++ b/examples/compliance-merchant.ts @@ -11,7 +11,7 @@ * per-product session context + pre-create-pending-order recovery flow * - Custom `onDenied` that composes commerce helpers: * • `verificationAgentInstructions` for the canonical poll-and-retry instructions - * • `isFixableDenial` to branch fixable (KYC re-do) vs unfixable (sanctions/age) compliance fails + * • `isFixableDenial` defensive fallback for fixable (KYC re-do) vs unfixable (sanctions/age/jurisdiction_restricted) compliance fails. Gate normally re-routes fixable reasons to identity_verification_required upstream — this branch only fires if the /v1/sessions mint blipped. * • `buildContactSupportNextSteps` for the unfixable branch * • `denialReasonToBody` + `denialReasonStatus` for the standard fall-through (token_expired, * invalid_credential, api_error get the right status + body for free) @@ -105,13 +105,17 @@ const _complianceGate = agentscoreGate({ }, 403); } - // wallet_not_trusted = compliance fail. Branch on fixable vs not — fixable (KYC pending/ - // failed/required, jurisdiction) gets a fresh session; unfixable (sanctions, age) gets - // contact-support. + // wallet_not_trusted = UNFIXABLE compliance fail (sanctions / age / jurisdiction_restricted). + // The gate auto-routes fixable reasons (kyc_required / kyc_pending / kyc_failed) to + // identity_verification_required upstream — by the time onDenied sees wallet_not_trusted, + // the reasons should be unfixable. The isFixableDenial branch below is a defensive + // fallback in case the gate's /v1/sessions mint blipped and fell back to bare denial. if (reason.code === 'wallet_not_trusted') { const reasons = reason.reasons ?? []; if (isFixableDenial(reasons)) { - // In a real merchant: mint a new session for retry. Skipped here for brevity. + // Defensive: gate normally bootstraps these into identity_verification_required. + // If we hit this branch, the gate's /v1/sessions mint failed — surface verify_url + // so the agent can recover via the manual session flow. return c.json({ error: { code: 'compliance_recoverable', message: 'Re-verify identity and retry.' }, reasons, From 4a8d47b6082a800349cfbf7ea4ed1d54467da2af Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 29 Apr 2026 19:49:22 -0700 Subject: [PATCH 4/7] chore: replace fake reason codes in express test with real API codes \`sanctions_check_pending\` isn't a real API code. The real codes are kyc_required, kyc_pending, kyc_failed, sanctions_flagged, age_insufficient, jurisdiction_restricted. Test passes either way (gate passes reasons through verbatim), but the fake string propagates misinformation. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/express.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/express.test.ts b/tests/express.test.ts index 837214d..8cea11f 100644 --- a/tests/express.test.ts +++ b/tests/express.test.ts @@ -688,7 +688,7 @@ describe('agentscoreGate middleware — verify_url and operator_verification in const COMPLIANCE_DENY_RESPONSE = { decision: 'deny', - decision_reasons: ['kyc_required', 'sanctions_check_pending'], + decision_reasons: ['kyc_required', 'sanctions_flagged'], subject: { chains: ['base'], address: WALLET }, score: { value: 72, grade: 'C' }, operator_verification: { @@ -791,7 +791,7 @@ describe('agentscoreGate middleware — verify_url and operator_verification in expect.objectContaining({ code: 'wallet_not_trusted', decision: 'deny', - reasons: ['kyc_required', 'sanctions_check_pending'], + reasons: ['kyc_required', 'sanctions_flagged'], }), ); }); From f239b0c56df97383518902adb3f841824a55649e Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 29 Apr 2026 20:21:11 -0700 Subject: [PATCH 5/7] chore: bump engines.node from >=18 to >=20 Node 18 EOL'd April 2025; pay already declares >=20. Match the canonical floor across the publishable npm packages. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d14b16c..d4fa68c 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "url": "https://github.com/agentscore/node-commerce/issues" }, "engines": { - "node": ">=18" + "node": ">=20" }, "dependencies": { "@agent-score/sdk": "^2.0.0" From 5050029ab2a937f99554ec26e6c9c745cc2f314a Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 29 Apr 2026 20:44:31 -0700 Subject: [PATCH 6/7] =?UTF-8?q?docs:=20examples=20README=20=E2=80=94=20"si?= =?UTF-8?q?x"=20=E2=86=92=20"seven"=20(per-product-policy-merchant=20added?= =?UTF-8?q?=20previously)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index f21a838..8d3ed58 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,7 +23,7 @@ Runnable, copy-pasteable example integrations covering the most common merchant ## Patterns -All six examples follow the same rough shape: +All seven examples follow the same rough shape: 1. **Boot:** instantiate framework, identity gate (if any), x402/mppx servers (if any) via commerce factories 2. **Discovery routes:** `/llms.txt` + `/.well-known/mpp.json` + `/openapi.json` (where applicable) using commerce/discovery helpers From a74a69129b73c46fa637b94373b1b4029f527d32 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 29 Apr 2026 21:27:38 -0700 Subject: [PATCH 7/7] chore(deps): bump @agent-score/sdk to ^2.1.0 Picks up the just-published 2.1.0 (optional Subject.address + credential_prefix, optional CredentialListResponse.account_verification, invalid_credential DenialCode, engines.node >=20). Tests + lint + typecheck pass with the new version. Co-Authored-By: Claude Opus 4.7 (1M context) --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 0a280ff..990f081 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@agent-score/gate", "dependencies": { - "@agent-score/sdk": "^2.0.0", + "@agent-score/sdk": "^2.1.0", }, "devDependencies": { "@coinbase/x402": "^2.1.0", @@ -51,7 +51,7 @@ "packages": { "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], - "@agent-score/sdk": ["@agent-score/sdk@2.0.0", "", {}, "sha512-Ut64BlbeI3Ypyn7efsQ4o1c5EKXf/udZ35thkhcJ7PiKIlMDeZs1k9YTfQOikEiAxXOeoKCXtQ3LDScUMRcTpw=="], + "@agent-score/sdk": ["@agent-score/sdk@2.1.0", "", {}, "sha512-J+m9i0HuO1a3xtfhtnRHDqVx191qyx3QzqZgRtLRqhmez0xwgT6RVWtrzF7wPvo0xMi5WAKKUrvp7fjc6UTsTw=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], diff --git a/package.json b/package.json index d4fa68c..46f70a0 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "node": ">=20" }, "dependencies": { - "@agent-score/sdk": "^2.0.0" + "@agent-score/sdk": "^2.1.0" }, "overrides": { "axios": "^1.15.0"