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
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions examples/compliance-merchant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -117,10 +117,10 @@
"url": "https://github.com/agentscore/node-commerce/issues"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"@agent-score/sdk": "^2.0.0"
"@agent-score/sdk": "^2.1.0"
},
"overrides": {
"axios": "^1.15.0"
Expand Down
24 changes: 14 additions & 10 deletions src/_denial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = 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));
}

Expand Down
70 changes: 69 additions & 1 deletion src/_response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`) 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.',
});

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: <poll_secret>` until status=verified. The poll returns a one-time operator_token.',
'Retry the original request with header `X-Operator-Token: <opc_...>`.',
],
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: <poll_secret>` until status=verified. The poll returns a fresh one-time operator_token.',
'Retry the original request with header `X-Operator-Token: <new_opc_...>`.',
],
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<Record<DenialCode, string>> = {
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<DenialCode, string> = {
missing_identity:
'No identity provided. Send X-Wallet-Address (wallet) or X-Operator-Token (credential).',
Expand Down Expand Up @@ -63,7 +130,8 @@ export function denialReasonToBody(reason: DenialReason): Record<string, unknown
if (reason.session_id) body.session_id = reason.session_id;
if (reason.poll_secret) body.poll_secret = reason.poll_secret;
if (reason.poll_url) body.poll_url = reason.poll_url;
if (reason.agent_instructions) body.agent_instructions = reason.agent_instructions;
const instructions = reason.agent_instructions ?? DEFAULT_AGENT_INSTRUCTIONS[reason.code];
if (instructions) body.agent_instructions = instructions;
if (reason.agent_memory) body.agent_memory = reason.agent_memory;
if (reason.claimed_operator) body.claimed_operator = reason.claimed_operator;
if (reason.code === 'wallet_signer_mismatch') body.actual_signer_operator = reason.actual_signer_operator ?? null;
Expand Down
Loading
Loading