diff --git a/CLAUDE.md b/CLAUDE.md index 0507486..dc879b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Every helper is extracted from a real consumer, not speculated. ## Architecture -Single TypeScript package, tsup-built CJS + ESM with subpath exports. Per-framework identity adapters expose the same surface — `agentscoreGate`, `captureWallet`, `getAgentScoreData`, `verifyWalletSignerMatch` — with network-aware address normalization (EVM lowercased, Solana base58 preserved verbatim). +Single TypeScript package, tsup-built CJS + ESM with subpath exports. Per-framework identity adapters expose the same surface — `agentscoreGate`, `captureWallet`, `getAgentScoreData`, `verifyWalletSignerMatch`, `getGateDegradedState`, `getGateQuotaInfo` — with network-aware address normalization (EVM lowercased, Solana base58 preserved verbatim). | Directory | Contents | |---|---| @@ -58,6 +58,12 @@ Denial reason codes: `missing_identity`, `identity_verification_required`, `toke Captured wallets: `captureWallet(ctx, { walletAddress, network, idempotencyKey })` is fire-and-forget — reads `operator_token` stashed during gating and POSTs to `/v1/credentials/wallets`. No-ops for wallet-authenticated requests. +Wallet-signer-match: `verifyWalletSignerMatch(ctx, { signer, network })` makes a single `/v1/assess` call with `resolve_signer` set; the API resolves both wallets and emits a `signer_match` verdict in the same response — collapses the legacy 2 follow-up assess calls into one round trip. Repeat lookups for the same `(claimed, signer)` pair hit a per-cache-entry `signerMatchBySigner` sub-map and skip the API entirely. Falls back to a 2-resolve path when the API doesn't emit `signer_match` (canary rollout safety). + +### Fail-open (opt-in) + +`failOpen: true` on `agentscoreGate({...})` flips infra-failure handling: 429 / 5xx / network-timeout return `{ kind: 'allow', degraded: true, infraReason: 'quota_exceeded' | 'api_error' | 'network_timeout' }` instead of throwing. Per-adapter `getGateDegradedState(c)` exposes the flag for merchant logging/alerting; `withAgentScoreGate` (Next.js / Web Fetch) propagates `degraded` + `infraReason` directly on the handler's `gate` arg. Default stays `failOpen: false` — regulated commerce should keep it. Compliance denials (sanctions, age, jurisdiction, signer-mismatch) still deny regardless of the flag. + ### Mount posture: gate-first vs gate-conditional `agentscoreGate(...)` returns a vanilla framework middleware. Mount it directly when the route is AgentScore-only (`app.use('/purchase', gate)` in Hono / Express, `dependencies=[Depends(gate)]` in FastAPI, etc.) — every request runs identity + policy. To support **anonymous discovery by any spec-compliant x402 wallet** (Coinbase awal, Phantom, Solflare, …), wrap the gate so it fires only when a payment credential is attached: diff --git a/README.md b/README.md index d55b7ce..9f33867 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,33 @@ app.post("/purchase", async (c) => { }); ``` +## Fail-open behavior + +By default AgentScore Gate fails closed: any AgentScore-side infrastructure failure (HTTP 429, 5xx, network timeout) returns 503 to the buyer. Set `failOpen: true` on `agentscoreGate({...})` to opt in to graceful degradation: + +```ts +import { agentscoreGate, getGateDegradedState } from '@agent-score/commerce/identity/hono'; + +const gate = agentscoreGate({ apiKey: process.env.AGENTSCORE_API_KEY!, failOpen: true }); + +app.use('/purchase', gate); + +app.post('/purchase', async (c) => { + const { degraded, infraReason } = getGateDegradedState(c); + if (degraded) { + // Compliance was NOT enforced this request — log/alert/refund-async/etc. + console.warn(`[gate] degraded: ${infraReason}`); + } + // ...rest of handler +}); +``` + +When `failOpen: true` AND the failure is infra-shape, the gate carries `degraded: true` + `infraReason: 'quota_exceeded' | 'api_error' | 'network_timeout'` so merchants can log/alert without parsing console output. **Compliance denials (sanctions, age, jurisdiction, signer-mismatch) still deny regardless of `failOpen`** — `failOpen` only covers "AgentScore couldn't tell us," never "AgentScore said no." + +For regulated commerce (alcohol, age-gated, sanctioned-jurisdiction-relevant) keep the default `failOpen: false` — outage is the correct posture; bypassing compliance on infra failure is a compliance gap. For low-stakes commerce or high-uptime SLAs, opt in and use the `degraded` flag as the audit trail. + +The `getGateDegradedState` helper is exported by every framework adapter (Hono, Express, Fastify, Next.js, Web Fetch). For `withAgentScoreGate` (Next.js / Web Fetch), the `degraded` + `infraReason` fields land directly on the `gate` object passed to your handler. + ## Examples The [examples/](./examples) directory has 7 runnable single-file Hono apps covering common merchant scenarios — copy-paste templates, not frameworks. See [examples/README.md](./examples/README.md) for the full table. diff --git a/bun.lock b/bun.lock index c0b4136..f25e811 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@agent-score/gate", "dependencies": { - "@agent-score/sdk": "^2.1.1", + "@agent-score/sdk": "^2.2.0", }, "devDependencies": { "@coinbase/x402": "^2.1.0", @@ -25,7 +25,7 @@ "fastify": "^5.8.5", "hono": "^4.12.16", "lefthook": "^2.1.6", - "mppx": "^0.6.8", + "mppx": "^0.6.13", "tsup": "^8.5.1", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", @@ -51,7 +51,7 @@ "packages": { "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], - "@agent-score/sdk": ["@agent-score/sdk@2.1.1", "", {}, "sha512-iehoDef32rlDXpCkfFbxa14ANxD11uyIPJY0K2AopVg8dD8iPA/xPlYtvlNlvBjPsfvdgb4954WSxwnyx9n+5g=="], + "@agent-score/sdk": ["@agent-score/sdk@2.2.0", "", {}, "sha512-o5pQCic9PkzmlxuUVQ64TD8xk755QnAWOO9He7vfcZVzfzG1b885yUpn7IFhH9Q7CtWmbif7zQFs+vk2ZMYZgw=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], @@ -919,7 +919,7 @@ "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], - "mppx": ["mppx@0.6.8", "", { "dependencies": { "incur": "^0.3.25", "ox": "0.14.18", "zod": "^4.3.6" }, "peerDependencies": { "@modelcontextprotocol/sdk": ">=1.25.0", "elysia": ">=1", "express": ">=5", "hono": ">=4.12.14", "viem": ">=2.47.5" }, "optionalPeers": ["@modelcontextprotocol/sdk", "elysia", "express", "hono"], "bin": { "mppx": "dist/bin.js", "mppx.src": "src/bin.ts" } }, "sha512-uXe0HyWCtB0MSXAohK5Vazj76/NfONsDxjy7DJHt2OeXj2tcpeNcdfjI3Ozzgq7ILx9unCsA88lrspl6MzxENw=="], + "mppx": ["mppx@0.6.13", "", { "dependencies": { "incur": "^0.3.25", "ox": "0.14.18", "zod": "^4.3.6" }, "peerDependencies": { "@modelcontextprotocol/sdk": ">=1.25.0", "elysia": ">=1", "express": ">=5", "hono": ">=4.12.14", "viem": ">=2.47.5" }, "optionalPeers": ["@modelcontextprotocol/sdk", "elysia", "express", "hono"], "bin": { "mppx": "dist/bin.js", "mppx.src": "src/bin.ts" } }, "sha512-/i82+nllfmtLIKkBjqnu0KEQ/cyBbk7Zg61V0EMdQeMlDhyXSXKEBhqCJtRTfVaVH5pJbZyb9dSd2UprhdP8Qg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], diff --git a/examples/README.md b/examples/README.md index 8d3ed58..c941caf 100644 --- a/examples/README.md +++ b/examples/README.md @@ -29,7 +29,7 @@ All seven examples follow the same rough shape: 2. **Discovery routes:** `/llms.txt` + `/.well-known/mpp.json` + `/openapi.json` (where applicable) using commerce/discovery helpers 3. **Per-request:** identity gate → validate body → 402 challenge (built via commerce/challenge helpers) → settle payment → return result -The commerce SDK keeps every step ~5-15 lines instead of ~50-150 lines. Vendors compose; commerce wraps the protocol-correctness boilerplate. +AgentScore Commerce keeps every step ~5–15 lines instead of ~50–150 lines. Vendors compose; the SDK wraps the protocol-correctness boilerplate. ## What stays vendor-specific @@ -41,4 +41,4 @@ These examples are intentionally thin on domain logic. Vendors plug in their own - Tax / shipping calculators - Frontend UI (none of these examples include one — they're agent-only APIs) -Commerce handles the agent commerce protocol layer; everything else is your business. +AgentScore Commerce handles the agent commerce protocol layer; everything else is your business. diff --git a/package.json b/package.json index 9e0c988..a1c480f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/commerce", - "version": "1.0.3", + "version": "1.1.0", "description": "Agent commerce SDK — identity middleware (Hono, Express, Fastify, Next.js, Web Fetch) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agent commerce.", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -120,7 +120,7 @@ "node": ">=20" }, "dependencies": { - "@agent-score/sdk": "^2.1.1" + "@agent-score/sdk": "^2.2.0" }, "overrides": { "axios": "^1.15.0" @@ -163,7 +163,7 @@ "fastify": "^5.8.5", "hono": "^4.12.16", "lefthook": "^2.1.6", - "mppx": "^0.6.8", + "mppx": "^0.6.13", "tsup": "^8.5.1", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", diff --git a/src/_response.ts b/src/_response.ts index 5c80e4c..d961d99 100644 --- a/src/_response.ts +++ b/src/_response.ts @@ -23,12 +23,13 @@ import type { DenialCode, DenialReason } from './core.js'; * - `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 + * - `api_error` — `retry_with_backoff` envelope; sole retry channel (no separate + * next_steps block emitted) * * 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. + * `verifyWalletSignerMatch` result type) are not in this map. */ const WALLET_NOT_TRUSTED_INSTRUCTIONS = JSON.stringify({ action: 'contact_support', @@ -44,11 +45,11 @@ const WALLET_NOT_TRUSTED_INSTRUCTIONS = JSON.stringify({ 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.', + 'The merchant\'s AgentScore account does not have the assess endpoint enabled, 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) so they can resolve the configuration on their side.', ], 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.', + 'This merchant\'s identity gate is misconfigured. Contact the merchant — there\'s nothing to fix on the agent side.', }); const IDENTITY_VERIFICATION_REQUIRED_FALLBACK_INSTRUCTIONS = JSON.stringify({ @@ -62,6 +63,28 @@ const IDENTITY_VERIFICATION_REQUIRED_FALLBACK_INSTRUCTIONS = JSON.stringify({ 'Identity verification is required. Visit verify_url, then poll poll_url for the operator token and retry.', }); +const API_ERROR_INSTRUCTIONS = JSON.stringify({ + action: 'retry_with_backoff', + steps: [ + 'Verification is temporarily unavailable. Retry the request after 5–30 seconds with exponential backoff.', + 'This is NOT a compliance denial — the user does not need to re-verify their identity. Send the same identity headers (X-Wallet-Address or X-Operator-Token) on retry.', + 'If the request continues to fail after 3+ retries (~60 seconds total), surface the error to the user with the merchant\'s support contact.', + ], + user_message: + 'Verification is temporarily unavailable. Please try again in a moment — this is a transient issue, not a problem with your account.', +}); + +export const QUOTA_EXCEEDED_INSTRUCTIONS = JSON.stringify({ + action: 'contact_merchant', + steps: [ + 'AgentScore identity verification is unavailable for this merchant. This is a merchant-side issue and is NOT recoverable via retry.', + 'Do not retry: the same 503 will be returned until the merchant resolves the issue on their side.', + 'Surface to the user with the merchant\'s support contact. The merchant (not the agent) needs to act.', + ], + user_message: + 'This merchant\'s identity verification is temporarily unavailable. Try again later, or contact the merchant directly.', +}); + const TOKEN_EXPIRED_FALLBACK_INSTRUCTIONS = JSON.stringify({ action: 'deliver_verify_url_and_poll', steps: [ @@ -74,6 +97,7 @@ const TOKEN_EXPIRED_FALLBACK_INSTRUCTIONS = JSON.stringify({ }); const DEFAULT_AGENT_INSTRUCTIONS: Partial> = { + api_error: API_ERROR_INSTRUCTIONS, wallet_not_trusted: WALLET_NOT_TRUSTED_INSTRUCTIONS, payment_required: PAYMENT_REQUIRED_INSTRUCTIONS, identity_verification_required: IDENTITY_VERIFICATION_REQUIRED_FALLBACK_INSTRUCTIONS, @@ -90,7 +114,7 @@ const DEFAULT_MESSAGES: Record = { api_error: 'AgentScore is unreachable. This is transient — retry in a few seconds.', payment_required: - 'AgentScore tier does not support assess. Contact support.', + 'Assess endpoint not enabled for this merchant. Contact support.', wallet_signer_mismatch: 'Payment signer does not match the wallet claimed via X-Wallet-Address. The signer and the claimed wallet must both resolve to the same AgentScore operator.', wallet_auth_requires_wallet_signing: @@ -138,11 +162,6 @@ export function denialReasonToBody(reason: DenialReason): Record 0) body.linked_wallets = reason.linked_wallets; - // api_error denials get a default retry hint so agents know it's transient. Vendors can - // override by spreading their own next_steps into a custom onDenied body. - if (reason.code === 'api_error' && !(reason.extra && (reason.extra as Record).next_steps)) { - body.next_steps = { action: 'retry', retry_after_seconds: 5 }; - } if (reason.extra) { for (const [key, value] of Object.entries(reason.extra)) { if (RESERVED_FIELDS.has(key)) { diff --git a/src/core.ts b/src/core.ts index f393fee..edc1fd5 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,4 +1,13 @@ +import { + AgentScore, + InvalidCredentialError, + PaymentRequiredError, + QuotaExceededError, + TimeoutError as SdkTimeoutError, + TokenExpiredError, +} from '@agent-score/sdk'; import { isFixableDenial } from './_denial'; +import { QUOTA_EXCEEDED_INSTRUCTIONS } from './_response'; import { normalizeAddress } from './address'; import { TTLCache } from './cache'; @@ -200,17 +209,43 @@ export interface AgentScoreData { } | null; } +/** + * Reason a failOpen allow short-circuited an evaluate call due to AgentScore-side + * infrastructure issues. Surfaced on `EvaluateOutcome` so merchants can log/alert when + * their gate is running in degraded mode (compliance not actually enforced this request). + * + * - `quota_exceeded` — AgentScore returned 429 + * - `api_error` — AgentScore returned 5xx or non-2xx that isn't 429 + * - `network_timeout` — request to /v1/assess timed out or failed at the network layer + */ +export type FailOpenInfraReason = 'quota_exceeded' | 'api_error' | 'network_timeout'; + +/** Per-account assess quota observability, captured from `X-Quota-*` response headers + * on the success path. Mirrors the SDK's `QuotaInfo` shape — re-exported from gate state + * so merchants can monitor approach-to-cap proactively (warn at 80%, alert at 95%). */ +export interface GateQuotaInfo { + limit: number | null; + used: number | null; + /** ISO-8601 timestamp, or the literal string `"never"` for unlimited tiers. */ + reset: string | null; +} + /** * Outcome from `AgentScoreCore.evaluate()`. Adapters map this to framework-specific responses. * * - `{ kind: 'allow', data }` — the request passed the policy. `data` is present on a normal * allow; `undefined` when fail-open short-circuited (identity missing, API unreachable, * timeout, or 402 paid-tier required). + * - When `failOpen: true` and the allow was the result of an AgentScore-side infrastructure + * failure (429/5xx/timeout), the result also carries `degraded: true` + `infraReason` so + * merchants can alert/log without parsing console output. + * - `quota` propagates the SDK's per-request quota observability when the API emits the + * `X-Quota-*` headers. Optional; absent on Enterprise / unlimited tiers. * - `{ kind: 'deny', reason }` — the request was denied. Adapters should render a 403 with the * reason, or invoke the caller's custom denial handler. */ export type EvaluateOutcome = - | { kind: 'allow'; data?: AgentScoreData } + | { kind: 'allow'; data?: AgentScoreData; degraded?: boolean; infraReason?: FailOpenInfraReason; quota?: GateQuotaInfo } | { kind: 'deny'; reason: DenialReason }; export interface CaptureWalletOptions { @@ -295,6 +330,13 @@ interface AssessResult { decision?: string; reasons?: string[]; raw?: unknown; + // Per-signer wallet-match verdicts cached from prior verifyWalletSignerMatch() calls + // for this same claimed wallet. Each signer gets its own slot so two payments under + // the same claimed identity but from different signer wallets don't serve stale + // verdicts to each other. Verdicts come from the API's `signer_match` response field + // (populated when the assess request carried `resolve_signer`), so reading a hit + // skips the round-trip altogether. + signerMatchBySigner?: Map>; } /** @@ -410,7 +452,31 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore const defaultUa = `@agent-score/commerce@${__VERSION__}`; const userAgentHeader = userAgent ? `${userAgent} (${defaultUa})` : defaultUa; - const API_TIMEOUT_MS = 10_000; + // Single shared SDK instance for every API call this gate makes (assess, sessions, + // credentials/wallets, telemetry). Connection pooling + typed-error classification + + // X-Quota-* header capture all flow through here. The SDK owns the transport layer + // (timeouts, retry-on-429); the gate adds policy semantics on top. Pass the + // merchant-prefixed UA — SDK appends its own default to produce a chain like + // ` (@agent-score/commerce@) (@agent-score/sdk@)`. + const sdk = new AgentScore({ apiKey, baseUrl, userAgent: userAgentHeader }); + + // createSessionOnMissing can carry its own apiKey + baseUrl (merchants sometimes wire + // a session-only key for this hook). Lazily build a separate SDK instance keyed on + // (apiKey, baseUrl) so we don't construct a new client per request. + const sessionSdkCache = new Map(); + function getSessionSdk(sessionApiKey: string, sessionBaseUrl?: string): AgentScore { + const key = `${sessionApiKey}|${sessionBaseUrl ?? ''}`; + let s = sessionSdkCache.get(key); + if (!s) { + s = new AgentScore({ + apiKey: sessionApiKey, + baseUrl: sessionBaseUrl ?? baseUrl, + userAgent: userAgentHeader, + }); + sessionSdkCache.set(key, s); + } + return s; + } const cache = new TTLCache(cacheSeconds * 1000); @@ -436,21 +502,13 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore } } - 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; + // createSessionOnMissing.apiKey may differ from the gate's apiKey (e.g. merchant + // wires a session-only key for this hook). Build a per-config SDK lazily. + const sessionSdk = getSessionSdk(createSessionOnMissing.apiKey, createSessionOnMissing.baseUrl); + const data = (await sessionSdk.createSession({ + ...(sessionBody.context !== undefined ? { context: sessionBody.context } : {}), + ...(sessionBody.product_name !== undefined ? { product_name: sessionBody.product_name } : {}), + })) as unknown 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 @@ -512,6 +570,12 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore // 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)) { + // failOpen short-circuits BEFORE the session mint. This branch isn't an infra failure + // (no AgentScore call has been made yet) so we don't mark the gate state as degraded — + // missing identity + failOpen is the explicit opt-in pass-through behavior, not a + // graceful-degradation event. Merchants who need identity-or-deny on a failOpen gate + // should add a guard at the handler that checks for X-Wallet-Address / X-Operator-Token + // before reading the gate state. if (failOpen) return { kind: 'allow' }; const sessionReason = await tryMintSessionDenial(ctx); @@ -550,7 +614,13 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore const cached = cache.get(cacheKey); if (cached) { if (cached.allow) { - return { kind: 'allow', data: cached.raw as AgentScoreData }; + const cachedRaw = cached.raw as Record | undefined; + const cachedQuota = cachedRaw?.quota as GateQuotaInfo | undefined; + return { + kind: 'allow', + data: cachedRaw as unknown as AgentScoreData, + ...(cachedQuota !== undefined && { quota: cachedQuota }), + }; } // Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the // same UX as missing_identity: mint a fresh verification session, agent polls @@ -575,182 +645,152 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore }; } - try { - const body: Record = {}; - if (identity.address) body.address = identity.address; - if (identity.operatorToken) body.operator_token = identity.operatorToken; - if (gateChain) body.chain = gateChain; - - const policy: Record = {}; - if (requireKyc != null) policy.require_kyc = requireKyc; - if (requireSanctionsClear != null) policy.require_sanctions_clear = requireSanctionsClear; - if (minAge != null) policy.min_age = minAge; - if (blockedJurisdictions != null) policy.blocked_jurisdictions = blockedJurisdictions; - if (allowedJurisdictions != null) policy.allowed_jurisdictions = allowedJurisdictions; - if (Object.keys(policy).length > 0) body.policy = policy; - - const response = await fetch(`${baseUrl}/v1/assess`, { - method: 'POST', - headers: { - 'X-API-Key': apiKey, - 'Content-Type': 'application/json', - Accept: 'application/json', - 'User-Agent': userAgentHeader, - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(API_TIMEOUT_MS), - }); + const policy: Record = {}; + if (requireKyc != null) policy.require_kyc = requireKyc; + if (requireSanctionsClear != null) policy.require_sanctions_clear = requireSanctionsClear; + if (minAge != null) policy.min_age = minAge; + if (blockedJurisdictions != null) policy.blocked_jurisdictions = blockedJurisdictions; + if (allowedJurisdictions != null) policy.allowed_jurisdictions = allowedJurisdictions; - if (response.status === 402) { + let data: Record; + try { + // Single SDK call: typed-error subclasses (PaymentRequiredError / TokenExpiredError / + // InvalidCredentialError / QuotaExceededError / TimeoutError) flow through the + // catch below; success path captures `quota` from X-Quota-* headers automatically. + const opts = { + chain: gateChain, + ...(Object.keys(policy).length > 0 ? { policy: policy as never } : {}), + }; + // SDK has two overloads — narrow by which identity is set so TS picks the right one. + const result = identity.address + ? await sdk.assess(identity.address, { ...opts, operatorToken: identity.operatorToken }) + : await sdk.assess(null, { ...opts, operatorToken: identity.operatorToken! }); + data = result as unknown as Record; + } catch (err) { + if (err instanceof PaymentRequiredError) { if (failOpen) return { kind: 'allow' }; return { kind: 'deny', reason: { code: 'payment_required' } }; } - - // Pass through the API's token_expired 401 (covers both expired and revoked - // credentials — the API deliberately doesn't distinguish). The 401 body carries - // an auto-minted session (verify_url + session_id + poll_secret + next_steps + - // agent_memory) so agents can recover without holding an API key. Forward all of - // that into the DenialReason so the gate's 403 body includes the session fields. - if (response.status === 401) { - try { - const errData = (await response.clone().json()) as { - error?: { code?: string }; - session_id?: unknown; - poll_secret?: unknown; - verify_url?: unknown; - poll_url?: unknown; - next_steps?: unknown; - agent_memory?: unknown; - }; - const code = errData?.error?.code; - if (code === 'token_expired') { - return { - kind: 'deny', - reason: { - code, - data: errData as unknown as AgentScoreData, - ...(typeof errData.verify_url === 'string' ? { verify_url: errData.verify_url } : {}), - ...(typeof errData.session_id === 'string' ? { session_id: errData.session_id } : {}), - ...(typeof errData.poll_secret === 'string' ? { poll_secret: errData.poll_secret } : {}), - ...(typeof errData.poll_url === 'string' ? { poll_url: errData.poll_url } : {}), - ...(errData.next_steps ? { agent_instructions: JSON.stringify(errData.next_steps) } : {}), - ...(errData.agent_memory ? { agent_memory: errData.agent_memory as AgentMemoryHint } : {}), - }, - }; - } - // The API returns this when the operator_token doesn't exist at all (typo, - // never minted, fabricated). Distinct from token_expired — no auto-session - // is issued because the agent may have other valid tokens to try first. - // Without this branch the gate would fall through to api_error → 503 retry, - // which loops forever on a permanent state. - if (code === 'invalid_credential') { - return { - kind: 'deny', - reason: { - code: 'invalid_credential', - agent_instructions: INVALID_CREDENTIAL_INSTRUCTIONS, - agent_memory: agentMemoryHint, - }, - }; - } - // Unknown 401 code — log so we notice if the API adds a new credential-state - // code without us mapping it. Falls through to api_error below. - if (code) { - console.warn(`[gate] /v1/assess returned 401 ${code} — no specific handler, surfacing as api_error.`); - } - } catch (err) { - // Body wasn't the expected JSON shape. Don't crash the request, but DO log — - // a silent swallow here used to mask /v1/sessions schema drift for hours. - console.warn('[gate] /v1/assess 401 body parse failed:', err instanceof Error ? err.message : err); - } + if (err instanceof TokenExpiredError) { + // SDK extracts the auto-minted session fields onto the error instance — no body + // re-parsing needed here. + return { + kind: 'deny', + reason: { + code: 'token_expired', + data: err.details as unknown as AgentScoreData, + ...(err.verifyUrl ? { verify_url: err.verifyUrl } : {}), + ...(err.sessionId ? { session_id: err.sessionId } : {}), + ...(err.pollSecret ? { poll_secret: err.pollSecret } : {}), + ...(err.pollUrl ? { poll_url: err.pollUrl } : {}), + ...(err.nextSteps ? { agent_instructions: JSON.stringify(err.nextSteps) } : {}), + ...(err.agentMemory ? { agent_memory: err.agentMemory as AgentMemoryHint } : {}), + }, + }; } - - // 4xx with a structured error body that ISN'T 401/402: log it so operators see - // misclassifications instead of opaque 503 retries. Most common cause: a merchant - // that didn't validate input shape before invoking the gate (invalid_address, - // invalid_identity). We still fall through to api_error so behavior is unchanged - // for callers — just visible now. - if (response.status >= 400 && response.status < 500 && response.status !== 402) { - try { - const errData = (await response.clone().json()) as { error?: { code?: string; message?: string } }; - const code = errData?.error?.code; - if (code && code !== 'token_expired' && code !== 'invalid_credential') { - console.warn( - `[gate] /v1/assess returned ${response.status} ${code} — surfacing as api_error. ` + - 'Validate input shape before invoking the gate to avoid this.', - ); - } - } catch { - // Body wasn't JSON or didn't have the expected shape — silent fall-through. - } + if (err instanceof InvalidCredentialError) { + // Permanent — no auto-session, agent should switch tokens or restart. + return { + kind: 'deny', + reason: { + code: 'invalid_credential', + agent_instructions: INVALID_CREDENTIAL_INSTRUCTIONS, + agent_memory: agentMemoryHint, + }, + }; } - - if (!response.ok) { - throw new Error(`AgentScore API returned ${response.status}`); + if (err instanceof QuotaExceededError) { + console.warn('[gate] /v1/assess returned 429 quota_exceeded'); + if (failOpen) return { kind: 'allow', degraded: true, infraReason: 'quota_exceeded' }; + return { + kind: 'deny', + reason: { code: 'api_error', agent_instructions: QUOTA_EXCEEDED_INSTRUCTIONS }, + }; } - - const data = (await response.json()) as Record; - const decision = data.decision as string | null | undefined; - const decisionReasons = (data.decision_reasons as string[]) ?? []; - const allow = decision === 'allow' || decision == null; - - cache.set(cacheKey, { allow, decision: decision ?? undefined, reasons: decisionReasons, raw: data }); - - if (allow) { - return { kind: 'allow', data: data as unknown as AgentScoreData }; + if (err instanceof SdkTimeoutError) { + console.warn('[gate] /v1/assess timed out:', err.message); + if (failOpen) return { kind: 'allow', degraded: true, infraReason: 'network_timeout' }; + return { kind: 'deny', reason: { code: 'api_error' } }; } - - // 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 }; + // Status-based fallbacks for AgentScoreError instances the SDK couldn't classify + // into a typed subclass (e.g. 429 with body that lacked error.code, or a fetch + // rejection whose .name doesn't match AbortError but whose status code is set). + // The real API always emits error.code on 429, so this is purely defensive. + const status = (err as { status?: number } | null)?.status; + const errName = err instanceof Error ? err.name : ''; + if (status === 429) { + console.warn('[gate] /v1/assess returned 429 (untyped — defensive)'); + if (failOpen) return { kind: 'allow', degraded: true, infraReason: 'quota_exceeded' }; + return { + kind: 'deny', + reason: { code: 'api_error', agent_instructions: QUOTA_EXCEEDED_INSTRUCTIONS }, + }; } + if (errName === 'TimeoutError' || errName === 'AbortError') { + console.warn('[gate] /v1/assess timed out (by Error.name):', err instanceof Error ? err.message : err); + if (failOpen) return { kind: 'allow', degraded: true, infraReason: 'network_timeout' }; + return { kind: 'deny', reason: { code: 'api_error' } }; + } + // Generic AgentScoreError (rate_limited, 5xx, network_error, body parse, unknown 4xx) + // or any non-AgentScoreError unexpected throw — surface as api_error. + // Include the SDK-classified error code (when available) so ops/dev see + // schema-drift cases like a new 401 error.code rather than a silent 503. + const errCode = (err as { code?: string } | null)?.code; + const msg = err instanceof Error ? err.message : String(err); + const detail = errCode ? `${errCode}: ${msg}` : msg; + console.warn(`[gate] /v1/assess call failed — surfacing as api_error: ${detail}`); + if (failOpen) return { kind: 'allow', degraded: true, infraReason: 'api_error' }; + return { kind: 'deny', reason: { code: 'api_error' } }; + } + + const decision = data.decision as string | null | undefined; + const decisionReasons = (data.decision_reasons as string[]) ?? []; + const allow = decision === 'allow' || decision == null; + + cache.set(cacheKey, { allow, decision: decision ?? undefined, reasons: decisionReasons, raw: data }); + if (allow) { + // SDK populates `quota` on the assess response from X-Quota-* headers when the + // API emits them. Surface up to the adapter so merchants can monitor approach-to-cap. + const quota = data.quota as GateQuotaInfo | undefined; return { - kind: 'deny', - reason: { - code: 'wallet_not_trusted', - decision: decision ?? undefined, - reasons: decisionReasons, - verify_url: data.verify_url as string | undefined, - data: data as unknown as AgentScoreData, - }, + kind: 'allow', + data: data as unknown as AgentScoreData, + ...(quota !== undefined && { quota }), }; - } catch (err) { - // Network failure, AbortSignal timeout, JSON parse error on a 2xx, or the - // explicit `throw new Error(...)` for an unhandled non-ok status. Log so ops - // can distinguish "API down" from "merchant config wrong" — without this, - // every transient blip looked identical to a misconfigured base URL. - console.warn('[gate] /v1/assess call failed — surfacing as api_error:', err instanceof Error ? err.message : err); - if (failOpen) return { kind: 'allow' }; - return { kind: 'deny', reason: { code: 'api_error' } }; } + + // 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 }; + } + + return { + kind: 'deny', + reason: { + code: 'wallet_not_trusted', + decision: decision ?? undefined, + reasons: decisionReasons, + verify_url: data.verify_url as string | undefined, + data: data as unknown as AgentScoreData, + }, + }; } async function captureWallet(options: CaptureWalletOptions): Promise { try { - const body: Record = { - operator_token: options.operatorToken, - wallet_address: options.walletAddress, + await sdk.associateWallet({ + operatorToken: options.operatorToken, + walletAddress: options.walletAddress, network: options.network, - }; - if (options.idempotencyKey) body.idempotency_key = options.idempotencyKey; - await fetch(`${baseUrl}/v1/credentials/wallets`, { - method: 'POST', - headers: { - 'X-API-Key': apiKey, - 'Content-Type': 'application/json', - Accept: 'application/json', - 'User-Agent': userAgentHeader, - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(API_TIMEOUT_MS), + ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}), }); } catch (err) { // Fire-and-forget: don't throw. Log so a persistent capture outage is visible @@ -800,19 +840,7 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore } try { - const response = await fetch(`${baseUrl}/v1/assess`, { - method: 'POST', - headers: { - 'X-API-Key': apiKey, - 'Content-Type': 'application/json', - Accept: 'application/json', - 'User-Agent': userAgentHeader, - }, - body: JSON.stringify({ address: walletAddress }), - signal: AbortSignal.timeout(API_TIMEOUT_MS), - }); - if (!response.ok) return { ok: false }; - const data = (await response.json()) as Record; + const data = (await sdk.assess(walletAddress)) as unknown as Record; cache.set(`resolve:${wallet}`, { allow: true, raw: data }); return { ok: true, ...extractFromCached(data) }; } catch (err) { @@ -827,33 +855,55 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore function reportSignerEvent(kind: 'pass' | 'wallet_signer_mismatch' | 'wallet_auth_requires_wallet_signing' | 'api_error'): void { // Fire-and-forget: surfaces mismatch-catch rate + api_error SLO on the dashboard. - // Never blocks, awaits, or throws — telemetry failure must not affect the gate's decision. - try { - const pending = fetch(`${baseUrl}/v1/telemetry/signer-match`, { - method: 'POST', - headers: { - 'X-API-Key': apiKey, - 'Content-Type': 'application/json', - Accept: 'application/json', - 'User-Agent': userAgentHeader, - }, - body: JSON.stringify({ kind }), - signal: AbortSignal.timeout(API_TIMEOUT_MS), - }); - if (pending && typeof pending.catch === 'function') { - pending.catch((err) => { - console.warn('[agentscore-commerce] signer-match telemetry failed:', err instanceof Error ? err.message : err); - }); - } - } catch { - // Thrown synchronously (e.g., fetch unavailable in test harness) — swallow silently. + // SDK's telemetrySignerMatch already does the catch + warn-log internally; this + // call must not affect the gate's decision so we don't await. + void sdk.telemetrySignerMatch({ kind }); + } + + // Project the API's signer_match block onto the gate's VerifyWalletSignerResult shape. + // The API authors agent_instructions, claimed/signer operators, and the linked-wallet + // set (deny-guarded server-side); the gate just shapes those fields into camelCase. + function projectSignerMatch( + sm: Record, + claimedNorm: string, + signerNorm: string, + ): VerifyWalletSignerResult { + const kind = sm.kind as string; + if (kind === 'pass') { + return { + kind: 'pass', + claimedOperator: (sm.claimed_operator as string | null | undefined) ?? null, + signerOperator: (sm.signer_operator as string | null | undefined) ?? null, + }; } + if (kind === 'wallet_auth_requires_wallet_signing') { + return { + kind: 'wallet_auth_requires_wallet_signing', + claimedWallet: (sm.claimed_wallet as string | undefined) ?? claimedNorm, + agentInstructions: + (sm.agent_instructions as string | undefined) ?? WALLET_AUTH_REQUIRES_WALLET_SIGNING_INSTRUCTIONS, + }; + } + // Default: wallet_signer_mismatch + const linked = sm.linked_wallets; + return { + kind: 'wallet_signer_mismatch', + claimedOperator: (sm.claimed_operator as string | null | undefined) ?? null, + actualSignerOperator: (sm.signer_operator as string | null | undefined) ?? null, + expectedSigner: (sm.expected_signer as string | undefined) ?? claimedNorm, + actualSigner: (sm.actual_signer as string | undefined) ?? signerNorm, + linkedWallets: Array.isArray(linked) + ? (linked as unknown[]).filter((w): w is string => typeof w === 'string') + : [], + agentInstructions: + (sm.agent_instructions as string | undefined) ?? WALLET_SIGNER_MISMATCH_INSTRUCTIONS, + }; } async function verifyWalletSignerMatch( options: VerifyWalletSignerMatchOptions, ): Promise { - const { claimedWallet, signer } = options; + const { claimedWallet, signer, network } = options; if (!signer) { reportSignerEvent('wallet_auth_requires_wallet_signing'); @@ -876,13 +926,59 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore return { kind: 'pass', claimedOperator: null, signerOperator: null }; } + // Cache hit — a prior call for this same (claimed, signer) pair already populated + // signer_match. Skip the round trip + telemetry post (the API recorded it the first + // time). Subsequent same-pair payments cost zero outbound assess calls. + const cachedEntry = cache.get(claimedNorm); + const cachedMatch = cachedEntry?.signerMatchBySigner?.get(signerNorm); + if (cachedMatch) { + return projectSignerMatch(cachedMatch, claimedNorm, signerNorm); + } + + // Single fresh assess call carrying resolve_signer. Server-side resolves both wallets + // against the operator graph and returns a signer_match verdict in the response — + // collapses the legacy 2 follow-up calls (one per wallet) into one round trip. + const inferredNetwork: 'evm' | 'solana' = network ?? (signerNorm.startsWith('0x') ? 'evm' : 'solana'); + let assessResponse: { signer_match?: Record } & Record; + try { + assessResponse = (await sdk.assess(claimedNorm, { + resolveSigner: { address: signerNorm, network: inferredNetwork }, + })) as unknown as { signer_match?: Record } & Record; + } catch (err) { + console.warn('[gate] verifyWalletSignerMatch assess failed:', err instanceof Error ? err.message : err); + reportSignerEvent('api_error'); + return { kind: 'api_error', claimedWallet: claimedNorm }; + } + + const signerMatch = assessResponse.signer_match; + if (signerMatch && typeof signerMatch === 'object') { + // Cache for repeat same-pair lookups. Server-side already recorded telemetry for + // this verdict, so skip the SDK-side reportSignerEvent — avoids double-counting. + if (cachedEntry) { + // Mutate the existing entry in place — TTLCache.get() returns a reference, so the + // store's record sees the new sub-map without a `set()` call. This preserves the + // gate's original cache TTL window (set() would reset it forward, causing the + // gate verdict to be served past its intended freshness horizon). + const map = cachedEntry.signerMatchBySigner ?? new Map>(); + map.set(signerNorm, signerMatch); + cachedEntry.signerMatchBySigner = map; + } else { + // No prior gate cache for this wallet — create a fresh entry with the verdict + // attached so a subsequent same-pair call hits cache. + const entry: AssessResult = { allow: true, raw: assessResponse }; + entry.signerMatchBySigner = new Map([[signerNorm, signerMatch]]); + cache.set(claimedNorm, entry); + } + return projectSignerMatch(signerMatch, claimedNorm, signerNorm); + } + + // API response had no signer_match (server didn't compute one). Fall back to the + // 2-resolve path so the gate still produces a verdict. const [claimedResolve, signerResolve] = await Promise.all([ resolveWalletToOperator(claimedNorm), resolveWalletToOperator(signerNorm), ]); - // Transient API failure on either resolve → emit api_error. Caller should retry or - // surface 503 rather than falsely reject a legitimate user on a network flake. if (!claimedResolve.ok || !signerResolve.ok) { reportSignerEvent('api_error'); return { kind: 'api_error', claimedWallet: claimedNorm }; @@ -903,8 +999,6 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore actualSignerOperator: signerOperator, expectedSigner: claimedNorm, actualSigner: signerNorm, - // Populated from /v1/assess.linked_wallets on the claimed wallet — the full set of - // wallets the agent CAN sign with to satisfy the claim (same-operator rule). linkedWallets: claimedResolve.linkedWallets, agentInstructions: WALLET_SIGNER_MISMATCH_INSTRUCTIONS, }; diff --git a/src/discovery/bazaar.ts b/src/discovery/bazaar.ts index 6b4ea13..d11d6b8 100644 --- a/src/discovery/bazaar.ts +++ b/src/discovery/bazaar.ts @@ -19,11 +19,13 @@ interface BazaarModule { export async function createBazaarDiscovery(config: BazaarDiscoveryConfig): Promise { const bazaar = await dynamicImport('@x402/extensions/bazaar'); + /* v8 ignore start -- peer-dep-absence guard; @x402/extensions is installed in test env */ if (!bazaar?.declareDiscoveryExtension) { throw new Error( '@x402/extensions not installed — `npm install @x402/extensions` for createBazaarDiscovery.', ); } + /* v8 ignore stop */ return bazaar.declareDiscoveryExtension(config); } @@ -31,6 +33,7 @@ async function dynamicImport(moduleName: string): Promise { try { return (await import(moduleName)) as T; } catch { + /* v8 ignore next -- catch fires only when peer dep is missing; installed in test env */ return null; } } diff --git a/src/identity/express.ts b/src/identity/express.ts index f4f59f3..3a22282 100644 --- a/src/identity/express.ts +++ b/src/identity/express.ts @@ -16,6 +16,8 @@ import type { AgentScoreData, CreateSessionOnMissing, DenialReason, + FailOpenInfraReason, + GateQuotaInfo, VerifyWalletSignerResult, } from '../core'; import type { Request, Response, NextFunction } from 'express'; @@ -26,6 +28,15 @@ interface GateState { core: AgentScoreCore; operatorToken?: string; walletAddress?: string; + /** Set to `true` only when the gate fail-open'd due to AgentScore-side infra failure + * (429/5xx/network timeout). Compliance was NOT enforced — log/alert in your handler. */ + degraded?: boolean; + /** Why the gate degraded — quota_exceeded / api_error / network_timeout. */ + infraReason?: FailOpenInfraReason; + /** Per-account assess quota observability captured from `X-Quota-*` response headers + * on the success path. Absent on Enterprise / unlimited tiers, or when the gate didn't + * call assess (failOpen + missing identity). */ + quota?: GateQuotaInfo; } export interface AgentScoreGateOptions extends Omit { @@ -72,6 +83,14 @@ export function agentscoreGate(options: AgentScoreGateOptions) { const outcome = await core.evaluate(identity, req); if (outcome.kind === 'allow') { + const state = (req as unknown as Record)[GATE_STATE_KEY]; + if (state) { + if (outcome.degraded) { + state.degraded = true; + state.infraReason = outcome.infraReason; + } + if (outcome.quota) state.quota = outcome.quota; + } if (outcome.data) (req as unknown as Record).agentscore = outcome.data; next(); return; @@ -81,6 +100,29 @@ export function agentscoreGate(options: AgentScoreGateOptions) { }; } +/** + * Read whether the gate fail-open'd due to AgentScore-side infrastructure failure on + * this request. Returns `{ degraded: false }` for normal allows; `{ degraded: true, + * infraReason }` when bypassed (compliance NOT enforced — log/alert). + */ +export function getGateDegradedState( + req: Request, +): { degraded: boolean; infraReason?: FailOpenInfraReason } { + const state = (req as unknown as Record)[GATE_STATE_KEY]; + return { degraded: state?.degraded ?? false, infraReason: state?.infraReason }; +} + +/** + * Read AgentScore assess quota observability captured from `X-Quota-*` response headers + * on this request's gate evaluate. Returns `undefined` when the request was a fail-open + * pass-through (no assess call) or when the API didn't emit quota headers (Enterprise / + * unlimited tiers). Use to monitor approach-to-cap proactively (warn at 80%, alert at 95%). + */ +export function getGateQuotaInfo(req: Request): GateQuotaInfo | undefined { + const state = (req as unknown as Record)[GATE_STATE_KEY]; + return state?.quota; +} + /** * Retrieve AgentScore assess data attached to an Express request by the gate. Returns * `undefined` if the gate did not run or attached no data (fail-open mode + missing identity, diff --git a/src/identity/fastify.ts b/src/identity/fastify.ts index caf31cd..e525c2a 100644 --- a/src/identity/fastify.ts +++ b/src/identity/fastify.ts @@ -16,6 +16,8 @@ import type { AgentScoreData, CreateSessionOnMissing, DenialReason, + FailOpenInfraReason, + GateQuotaInfo, VerifyWalletSignerResult, } from '../core'; import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; @@ -26,6 +28,13 @@ interface GateState { core: AgentScoreCore; operatorToken?: string; walletAddress?: string; + /** Set to `true` only when the gate fail-open'd due to AgentScore-side infra failure + * (429/5xx/network timeout). Compliance was NOT enforced — log/alert in your handler. */ + degraded?: boolean; + /** Why the gate degraded — quota_exceeded / api_error / network_timeout. */ + infraReason?: FailOpenInfraReason; + /** Per-account assess quota observability captured from `X-Quota-*` response headers. */ + quota?: GateQuotaInfo; } export interface AgentScoreGateOptions extends Omit { @@ -87,6 +96,14 @@ const agentscoreGatePlugin: FastifyPluginAsync = async (f const outcome = await core.evaluate(identity, request); if (outcome.kind === 'allow') { + const state = (request as unknown as Record)[GATE_STATE_KEY]; + if (state) { + if (outcome.degraded) { + state.degraded = true; + state.infraReason = outcome.infraReason; + } + if (outcome.quota) state.quota = outcome.quota; + } if (outcome.data) (request as unknown as Record).agentscore = outcome.data; return; } @@ -103,6 +120,28 @@ export function getAgentScoreData(request: FastifyRequest): AgentScoreData | und return (request as unknown as Record).agentscore; } +/** + * Read whether the gate fail-open'd due to AgentScore-side infrastructure failure on + * this request. Returns `{ degraded: false }` for normal allows; `{ degraded: true, + * infraReason }` when bypassed (compliance NOT enforced — log/alert). + */ +export function getGateDegradedState( + request: FastifyRequest, +): { degraded: boolean; infraReason?: FailOpenInfraReason } { + const state = (request as unknown as Record)[GATE_STATE_KEY]; + return { degraded: state?.degraded ?? false, infraReason: state?.infraReason }; +} + +/** + * Read AgentScore assess quota observability captured from `X-Quota-*` response headers + * on this request's gate evaluate. Returns `undefined` when the request was a fail-open + * pass-through (no assess call) or when the API didn't emit quota headers. + */ +export function getGateQuotaInfo(request: FastifyRequest): GateQuotaInfo | undefined { + const state = (request as unknown as Record)[GATE_STATE_KEY]; + return state?.quota; +} + /** * Report a wallet that paid under the operator_token extracted by the gate on this request. * Fire-and-forget: no-ops silently if the gate didn't run, the request was wallet-authenticated, diff --git a/src/identity/hono.ts b/src/identity/hono.ts index b84c393..1fdfe0d 100644 --- a/src/identity/hono.ts +++ b/src/identity/hono.ts @@ -16,6 +16,8 @@ import type { AgentScoreData, CreateSessionOnMissing, DenialReason, + FailOpenInfraReason, + GateQuotaInfo, VerifyWalletSignerResult, } from '../core'; import type { Context, MiddlewareHandler } from 'hono'; @@ -27,6 +29,16 @@ interface GateState { core: AgentScoreCore; operatorToken?: string; walletAddress?: string; + /** Set to `true` only when the gate fail-open'd due to AgentScore-side infra failure + * (429/5xx/network timeout). Compliance was NOT enforced for this request — log/alert + * in your handler. See {@link getGateDegradedState}. */ + degraded?: boolean; + /** Why the gate degraded — quota_exceeded / api_error / network_timeout. */ + infraReason?: FailOpenInfraReason; + /** Per-account assess quota observability captured from `X-Quota-*` response headers + * on the success path. Absent on Enterprise / unlimited tiers, or when the gate didn't + * call assess. */ + quota?: GateQuotaInfo; } export interface AgentScoreGateOptions extends Omit { @@ -80,6 +92,14 @@ export function agentscoreGate(options: AgentScoreGateOptions): MiddlewareHandle const outcome = await core.evaluate(identity, c); if (outcome.kind === 'allow') { + if (outcome.degraded || outcome.quota) { + const prev = c.get(GATE_STATE_KEY) as GateState; + c.set(GATE_STATE_KEY, { + ...prev, + ...(outcome.degraded && { degraded: true, infraReason: outcome.infraReason }), + ...(outcome.quota && { quota: outcome.quota }), + } satisfies GateState); + } if (outcome.data) c.set(CONTEXT_KEY, outcome.data); await next(); return; @@ -98,6 +118,31 @@ export function getAgentScoreData(c: Context): AgentScoreData | undefined { return c.get(CONTEXT_KEY) as AgentScoreData | undefined; } +/** + * Read whether the gate fail-open'd due to AgentScore-side infrastructure failure on + * this request. Returns `{ degraded: false }` for normal allows; `{ degraded: true, + * infraReason }` when the gate was bypassed (compliance NOT enforced — log/alert). + * + * Only set when `failOpen: true` was configured AND the failure was an infra failure + * (429 quota_exceeded, 5xx api_error, network_timeout). Real compliance denials never + * trigger fail-open and so never set this flag. + */ +export function getGateDegradedState(c: Context): { degraded: boolean; infraReason?: FailOpenInfraReason } { + const state = c.get(GATE_STATE_KEY) as GateState | undefined; + return { degraded: state?.degraded ?? false, infraReason: state?.infraReason }; +} + +/** + * Read AgentScore assess quota observability captured from `X-Quota-*` response headers + * on this request's gate evaluate. Returns `undefined` when the request was a fail-open + * pass-through (no assess call) or when the API didn't emit quota headers (Enterprise / + * unlimited tiers). Use to monitor approach-to-cap proactively. + */ +export function getGateQuotaInfo(c: Context): GateQuotaInfo | undefined { + const state = c.get(GATE_STATE_KEY) as GateState | undefined; + return state?.quota; +} + /** * Report a wallet that paid under the operator_token the gate extracted on this request. * Call this after a successful payment to build AgentScore's cross-merchant credential↔wallet diff --git a/src/identity/nextjs.ts b/src/identity/nextjs.ts index 358f6c5..26fc8e0 100644 --- a/src/identity/nextjs.ts +++ b/src/identity/nextjs.ts @@ -1,6 +1,6 @@ import { createAgentScoreGate } from './web'; import type { AgentScoreGateOptions as WebAgentScoreGateOptions } from './web'; -import type { AgentScoreData, VerifyWalletSignerResult } from '../core'; +import type { AgentScoreData, FailOpenInfraReason, GateQuotaInfo, VerifyWalletSignerResult } from '../core'; export type AgentScoreGateOptions = WebAgentScoreGateOptions; @@ -40,6 +40,13 @@ export function withAgentScoreGate Promise; + /** Set to `true` only when the gate fail-open'd due to AgentScore-side infra failure + * (429/5xx/network timeout). Compliance was NOT enforced — log/alert in your handler. */ + degraded?: boolean; + /** Why the gate degraded — quota_exceeded / api_error / network_timeout. */ + infraReason?: FailOpenInfraReason; + /** Per-account assess quota observability from X-Quota-* response headers. */ + quota?: GateQuotaInfo; }, ctx?: TCtx, ) => Response | Promise, @@ -54,6 +61,8 @@ export function withAgentScoreGate Promise; + /** Set to `true` only when the gate fail-open'd due to AgentScore-side infra failure + * (429/5xx/network timeout). Compliance was NOT enforced this request — log/alert. */ + degraded?: boolean; + /** Why the gate degraded — quota_exceeded / api_error / network_timeout. */ + infraReason?: FailOpenInfraReason; + /** Per-account assess quota observability from X-Quota-* response headers. */ + quota?: GateQuotaInfo; } | { allowed: false; response: Response }; @@ -120,6 +129,8 @@ export function createAgentScoreGate(options: AgentScoreGateOptions): (req: Requ data: outcome.data, captureWallet, verifyWalletSignerMatch: verifyWalletSignerMatchBound, + ...(outcome.degraded ? { degraded: true, infraReason: outcome.infraReason } : {}), + ...(outcome.quota ? { quota: outcome.quota } : {}), }; } @@ -154,6 +165,13 @@ export function withAgentScoreGate( signer?: string | null; network?: 'evm' | 'solana'; }) => Promise; + /** Set to `true` only when the gate fail-open'd due to AgentScore-side infra failure + * (429/5xx/network timeout). Compliance was NOT enforced this request — log/alert. */ + degraded?: boolean; + /** Why the gate degraded — quota_exceeded / api_error / network_timeout. */ + infraReason?: FailOpenInfraReason; + /** Per-account assess quota observability from X-Quota-* response headers. */ + quota?: GateQuotaInfo; }, ctx?: TCtx, ) => Response | Promise, @@ -168,6 +186,8 @@ export function withAgentScoreGate( data: result.data, captureWallet: result.captureWallet, verifyWalletSignerMatch: result.verifyWalletSignerMatch, + ...(result.degraded ? { degraded: true, infraReason: result.infraReason } : {}), + ...(result.quota ? { quota: result.quota } : {}), }, ctx, ); diff --git a/src/payment/mppx_server.ts b/src/payment/mppx_server.ts index 76393d6..5b35884 100644 --- a/src/payment/mppx_server.ts +++ b/src/payment/mppx_server.ts @@ -85,16 +85,20 @@ interface MppxModule { */ export async function createMppxServer(opts: CreateMppxServerOptions): Promise { const mppx = await dynamicImport('mppx/server'); + /* v8 ignore start -- peer-dep-absence guard; mppx is installed in the test env */ if (!mppx?.Mppx?.create) { throw new Error('mppx not installed — `npm install mppx` to use createMppxServer.'); } + /* v8 ignore stop */ const methods: unknown[] = [...(opts.methods ?? [])]; if (opts.rails?.tempo) { + /* v8 ignore start -- peer-dep version-mismatch guard; current mppx ships tempo.charge */ if (!mppx.tempo?.charge) { throw new Error('mppx.tempo.charge not available — check installed mppx version.'); } + /* v8 ignore stop */ const t = opts.rails.tempo; const defaultCurrency = t.testnet ? USDC.tempo.testnet.address : USDC.tempo.mainnet.address; methods.push( @@ -107,11 +111,13 @@ export async function createMppxServer(opts: CreateMppxServerOptions): Promise('@x402/core/server')) ?? null; + /* v8 ignore start -- peer-dep-absence guard; @x402/core is installed in test env */ if (!x402Core) { throw new Error( '@x402/core not installed — `npm install @x402/core` to use createX402Server.', ); } + /* v8 ignore stop */ let facilitator: unknown; if (opts.facilitator === 'coinbase') { const cb = await dynamicImport('@coinbase/x402'); + /* v8 ignore start -- peer-dep-absence guard; @coinbase/x402 is installed in test env */ if (!cb?.facilitator) { throw new Error( '@coinbase/x402 not installed — `npm install @coinbase/x402` for facilitator: "coinbase".', ); } + /* v8 ignore stop */ facilitator = new x402Core.HTTPFacilitatorClient(cb.facilitator); } else if (opts.facilitator === undefined || opts.facilitator === 'http') { facilitator = new x402Core.HTTPFacilitatorClient(); @@ -127,22 +131,28 @@ export async function createX402Server(opts: CreateX402ServerOptions = {}): Prom baseRail === 'x402-base-mainnet' ? networks.base.mainnet.caip2 : networks.base.sepolia.caip2; if (isUpto) { evmUptoModule ??= await dynamicImport('@x402/evm/upto/server'); + /* v8 ignore start -- peer-dep-absence guard; @x402/evm is installed in test env */ if (!evmUptoModule?.UptoEvmScheme) { throw new Error('@x402/evm not installed — `npm install @x402/evm` for x402 base upto rails.'); } + /* v8 ignore stop */ registerX402SchemesV1V2(server, network, new evmUptoModule.UptoEvmScheme()); } else { evmExactModule ??= await dynamicImport('@x402/evm/exact/server'); + /* v8 ignore start -- peer-dep-absence guard; @x402/evm is installed in test env */ if (!evmExactModule?.ExactEvmScheme) { throw new Error('@x402/evm not installed — `npm install @x402/evm` for x402 base rails.'); } + /* v8 ignore stop */ registerX402SchemesV1V2(server, network, new evmExactModule.ExactEvmScheme()); } } else if (rail.startsWith('x402-solana')) { svmModule ??= await dynamicImport('@x402/svm/exact/server'); + /* v8 ignore start -- peer-dep-absence guard; @x402/svm is installed in test env */ if (!svmModule?.ExactSvmScheme) { throw new Error('@x402/svm not installed — `npm install @x402/svm` for x402 solana rails.'); } + /* v8 ignore stop */ const network = rail === 'x402-solana-mainnet' ? networks.solana.mainnet.caip2 @@ -157,11 +167,13 @@ export async function createX402Server(opts: CreateX402ServerOptions = {}): Prom if (opts.bazaar) { const bazaar = await dynamicImport('@x402/extensions/bazaar'); + /* v8 ignore start -- peer-dep-absence guard; @x402/extensions is installed in test env */ if (!bazaar?.bazaarResourceServerExtension) { throw new Error( '@x402/extensions not installed — `npm install @x402/extensions` for bazaar discovery.', ); } + /* v8 ignore stop */ server.registerExtension(bazaar.bazaarResourceServerExtension); } diff --git a/src/stripe-multichain/mppx_stripe.ts b/src/stripe-multichain/mppx_stripe.ts index 845139e..29ffb01 100644 --- a/src/stripe-multichain/mppx_stripe.ts +++ b/src/stripe-multichain/mppx_stripe.ts @@ -40,11 +40,13 @@ export async function createMppxStripe(input: CreateMppxStripeInput): Promise unknown; }; } | null; + /* v8 ignore start -- peer-dep-absence guard; mppx is installed in the test env so this branch can't be exercised without mocking the dynamic import */ if (!mppx?.stripe?.charge) { throw new Error( 'mppx not installed — install with `npm install mppx` to use createMppxStripe.', ); } + /* v8 ignore stop */ return mppx.stripe.charge({ networkId: input.profileId, paymentMethodTypes: input.paymentMethodTypes ?? ['card', 'link'], diff --git a/tests/discovery/well_known_mpp.test.ts b/tests/discovery/well_known_mpp.test.ts index 6c21b5b..e0eee62 100644 --- a/tests/discovery/well_known_mpp.test.ts +++ b/tests/discovery/well_known_mpp.test.ts @@ -57,4 +57,32 @@ describe('buildWellKnownMpp', () => { }); expect(manifest.custom_field).toBe('foo'); }); + + it('populates every optional field when fully specified', () => { + const manifest = buildWellKnownMpp({ + name: 'Full', + description: 'A complete merchant manifest', + url: 'https://full.example', + openapi: 'https://full.example/openapi.json', + endpoints: { purchase: { method: 'POST', url: 'https://full.example/buy' } }, + catalog: 'https://full.example/catalog.json', + purchase: { + required_fields: ['email', 'shipping_address'], + optional_fields: ['gift_note'], + extra: { custom_purchase_meta: 'bar' }, + identity: { type: 'wallet', required: true }, + methods: ['tempo'], + }, + shipping: { countries: ['US'], states: { US: ['CA', 'NY'] } }, + }); + expect(manifest.description).toBe('A complete merchant manifest'); + expect(manifest.openapi).toBe('https://full.example/openapi.json'); + expect(manifest.catalog).toBe('https://full.example/catalog.json'); + expect(manifest.shipping).toEqual({ countries: ['US'], states: { US: ['CA', 'NY'] } }); + const purchase = manifest.purchase as Record; + expect(purchase.required_fields).toEqual(['email', 'shipping_address']); + expect(purchase.optional_fields).toEqual(['gift_note']); + expect(purchase.custom_purchase_meta).toBe('bar'); + expect(purchase.identity).toEqual({ type: 'wallet', required: true }); + }); }); diff --git a/tests/edge-cases.test.ts b/tests/edge-cases.test.ts index ffe9fe2..b7d5e64 100644 --- a/tests/edge-cases.test.ts +++ b/tests/edge-cases.test.ts @@ -50,7 +50,9 @@ describe('source code structure', () => { await def(req1, res1, next1); const call1 = (global.fetch as ReturnType).mock.calls[0]; - expect(call1[1].headers['User-Agent']).toMatch(/^@agent-score\/commerce@\d+\.\d+\.\d+$/); + expect(call1[1].headers['User-Agent']).toMatch( + /^@agent-score\/commerce@\d+\.\d+\.\d+ \(@agent-score\/sdk@\d+\.\d+\.\d+\)$/, + ); const custom = agentscoreGate({ apiKey: API_KEY, userAgent: 'express-app/1.0.0' }); const req2 = { headers: { 'x-wallet-address': WALLET } } as unknown as Request; @@ -59,7 +61,9 @@ describe('source code structure', () => { await custom(req2, res2, next2); const call2 = (global.fetch as ReturnType).mock.calls[1]; - expect(call2[1].headers['User-Agent']).toMatch(/^express-app\/1\.0\.0 \(@agent-score\/commerce@\d+\.\d+\.\d+\)$/); + expect(call2[1].headers['User-Agent']).toMatch( + /^express-app\/1\.0\.0 \(@agent-score\/commerce@\d+\.\d+\.\d+\) \(@agent-score\/sdk@\d+\.\d+\.\d+\)$/, + ); }); it('Express defaultOnDenied delegates body marshaling to the shared _response helper', () => { @@ -108,23 +112,26 @@ const DENY_RESPONSE = { }; function mockFetchOk(body: unknown): void { - global.fetch = vi.fn().mockResolvedValueOnce({ + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, - json: vi.fn().mockResolvedValueOnce(body), + headers: new Headers({ 'retry-after': '0' }), + json: vi.fn().mockResolvedValue(body), } as unknown as Response); } -function mockFetchStatus(status: number): void { - global.fetch = vi.fn().mockResolvedValueOnce({ +function mockFetchStatus(status: number, errorCode?: string): void { + const body = errorCode ? { error: { code: errorCode, message: 'mock' } } : {}; + global.fetch = vi.fn().mockResolvedValue({ ok: false, status, - json: vi.fn().mockResolvedValueOnce({}), + headers: new Headers({ 'retry-after': '0' }), + json: vi.fn().mockResolvedValue(body), } as unknown as Response); } function mockFetchReject(error?: Error): void { - global.fetch = vi.fn().mockRejectedValueOnce(error ?? new Error('Network failure')); + global.fetch = vi.fn().mockRejectedValue(error ?? new Error('Network failure')); } // --------------------------------------------------------------------------- @@ -651,19 +658,20 @@ describe('evaluate() — 401 passthrough edge cases', () => { // Revoked and expired credentials both surface as token_expired from the API with // an auto-minted session in the 401 body. Gate forwards all session fields so the // 403 downstream carries verify_url + session_id + poll_secret for agent recovery. + const tokenExpiredBody = { + error: { code: 'token_expired', message: 'invalid' }, + session_id: 'sess_auto', + poll_secret: 'poll_auto', + verify_url: 'https://agentscore.sh/verify?session=sess_auto', + poll_url: 'https://api.agentscore.sh/v1/sessions/sess_auto', + next_steps: { action: 'deliver_verify_url_and_poll' }, + }; global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401, - clone: () => ({ - json: async () => ({ - error: { code: 'token_expired', message: 'invalid' }, - session_id: 'sess_auto', - poll_secret: 'poll_auto', - verify_url: 'https://agentscore.sh/verify?session=sess_auto', - poll_url: 'https://api.agentscore.sh/v1/sessions/sess_auto', - next_steps: { action: 'deliver_verify_url_and_poll' }, - }), - }), + headers: new Headers(), + json: async () => tokenExpiredBody, + clone: () => ({ json: async () => tokenExpiredBody }), } as unknown as Response); const mw = agentscoreGate({ apiKey: API_KEY }); @@ -684,10 +692,13 @@ describe('evaluate() — 401 passthrough edge cases', () => { }); it('falls back to canonical token_expired agent_instructions when API next_steps absent', async () => { + const apiBody = { error: { code: 'token_expired' } }; global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401, - clone: () => ({ json: async () => ({ error: { code: 'token_expired' } }) }), + headers: new Headers(), + json: async () => apiBody, + clone: () => ({ json: async () => apiBody }), } as unknown as Response); const mw = agentscoreGate({ apiKey: API_KEY }); @@ -705,10 +716,13 @@ describe('evaluate() — 401 passthrough edge cases', () => { it('falls through to generic api_error when 401 body has unknown error.code', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const body = { error: { code: 'something_unknown' } }; global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401, - clone: () => ({ json: async () => ({ error: { code: 'something_unknown' } }) }), + headers: new Headers(), + json: async () => body, + clone: () => ({ json: async () => body }), } as unknown as Response); const mw = agentscoreGate({ apiKey: API_KEY }); @@ -730,10 +744,13 @@ describe('evaluate() — 401 passthrough edge cases', () => { // — invalid_credential has no recovery payload, the agent must switch tokens or // restart the session flow. Used to fall through to api_error → 503 retry which // looped forever on a permanent state. + const apiBody = { error: { code: 'invalid_credential', message: 'Operator credential not found' } }; global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401, - clone: () => ({ json: async () => ({ error: { code: 'invalid_credential', message: 'Operator credential not found' } }) }), + headers: new Headers(), + json: async () => apiBody, + clone: () => ({ json: async () => apiBody }), } as unknown as Response); const mw = agentscoreGate({ apiKey: API_KEY }); diff --git a/tests/express.test.ts b/tests/express.test.ts index 8cea11f..3aa3f4a 100644 --- a/tests/express.test.ts +++ b/tests/express.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { agentscoreGate, captureWallet } from '../src/identity/express'; +import { agentscoreGate, captureWallet, getGateDegradedState } from '../src/identity/express'; import type { NextFunction, Request, Response } from 'express'; // --------------------------------------------------------------------------- @@ -41,23 +41,29 @@ const DENY_RESPONSE = { }; function mockFetchOk(body: unknown): void { - global.fetch = vi.fn().mockResolvedValueOnce({ + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, - json: vi.fn().mockResolvedValueOnce(body), + headers: new Headers({ 'retry-after': '0' }), + json: vi.fn().mockResolvedValue(body), } as unknown as Response); } -function mockFetchStatus(status: number): void { - global.fetch = vi.fn().mockResolvedValueOnce({ +function mockFetchStatus(status: number, errorCode?: string): void { + // SDK retries once on 429 — use mockResolvedValue so both attempts get the same body. + // Include retry-after: 0 so the SDK retry path doesn't wait. + // Include error.code in the body when provided so SDK maps to a typed error subclass. + const body = errorCode ? { error: { code: errorCode, message: 'mock' } } : {}; + global.fetch = vi.fn().mockResolvedValue({ ok: false, status, - json: vi.fn().mockResolvedValueOnce({}), + headers: new Headers({ 'retry-after': '0' }), + json: vi.fn().mockResolvedValue(body), } as unknown as Response); } -function mockFetchReject(): void { - global.fetch = vi.fn().mockRejectedValueOnce(new Error('Network failure')); +function mockFetchReject(err: Error = new Error('Network failure')): void { + global.fetch = vi.fn().mockRejectedValue(err); } // --------------------------------------------------------------------------- @@ -210,6 +216,81 @@ describe('agentscoreGate middleware — API error', () => { expect(status).toHaveBeenCalledWith(503); expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.objectContaining({ code: 'api_error' }) })); }); + + it('failOpen marks degraded=true with infraReason="quota_exceeded" on 429', async () => { + mockFetchStatus(429); + const mw = agentscoreGate({ apiKey: API_KEY, failOpen: true }); + const req = makeReq(WALLET); + const { res } = makeRes(); + const next = makeNext(); + + await mw(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + const state = getGateDegradedState(req); + expect(state.degraded).toBe(true); + expect(state.infraReason).toBe('quota_exceeded'); + }); + + it('failOpen marks degraded=true with infraReason="api_error" on 500', async () => { + mockFetchStatus(500); + const mw = agentscoreGate({ apiKey: API_KEY, failOpen: true }); + const req = makeReq(WALLET); + const { res } = makeRes(); + const next = makeNext(); + + await mw(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + const state = getGateDegradedState(req); + expect(state.degraded).toBe(true); + expect(state.infraReason).toBe('api_error'); + }); + + it('failOpen marks degraded=true with infraReason="api_error" on generic fetch reject', async () => { + mockFetchReject(); + const mw = agentscoreGate({ apiKey: API_KEY, failOpen: true }); + const req = makeReq(WALLET); + const { res } = makeRes(); + const next = makeNext(); + + await mw(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + const state = getGateDegradedState(req); + expect(state.degraded).toBe(true); + expect(state.infraReason).toBe('api_error'); + }); + + it('failOpen marks degraded=true with infraReason="network_timeout" on AbortError', async () => { + global.fetch = vi.fn().mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' })); + const mw = agentscoreGate({ apiKey: API_KEY, failOpen: true }); + const req = makeReq(WALLET); + const { res } = makeRes(); + const next = makeNext(); + + await mw(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + const state = getGateDegradedState(req); + expect(state.degraded).toBe(true); + expect(state.infraReason).toBe('network_timeout'); + }); + + it('normal allow leaves degraded=false', async () => { + mockFetchOk(ALLOW_RESPONSE); + const mw = agentscoreGate({ apiKey: API_KEY }); + const req = makeReq(WALLET); + const { res } = makeRes(); + const next = makeNext(); + + await mw(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + const state = getGateDegradedState(req); + expect(state.degraded).toBe(false); + expect(state.infraReason).toBeUndefined(); + }); }); describe('agentscoreGate middleware — 402 payment required', () => { @@ -420,7 +501,10 @@ describe('agentscoreGate middleware — custom onDenied', () => { await middleware(req, res, next); const call = (global.fetch as ReturnType).mock.calls[0]; const headers = call[1].headers as Record; - expect(headers['User-Agent']).toBe(`@agent-score/commerce@${__VERSION__}`); + // SDK appends its own UA to the chain: ` (@agent-score/sdk@)`. + expect(headers['User-Agent']).toMatch( + new RegExp(`^@agent-score/commerce@${__VERSION__} \\(@agent-score/sdk@\\d+\\.\\d+\\.\\d+\\)$`), + ); }); it('prepends custom userAgent to the default on /v1/assess', async () => { @@ -432,7 +516,11 @@ describe('agentscoreGate middleware — custom onDenied', () => { await middleware(req, res, next); const call = (global.fetch as ReturnType).mock.calls[0]; const headers = call[1].headers as Record; - expect(headers['User-Agent']).toBe(`my-app/1.2.3 (@agent-score/commerce@${__VERSION__})`); + expect(headers['User-Agent']).toMatch( + new RegExp( + `^my-app/1\\.2\\.3 \\(@agent-score/commerce@${__VERSION__}\\) \\(@agent-score/sdk@\\d+\\.\\d+\\.\\d+\\)$`, + ), + ); }); it('prepends custom userAgent to the default on /v1/sessions', async () => { @@ -450,7 +538,11 @@ describe('agentscoreGate middleware — custom onDenied', () => { await middleware(req, res, next); const call = (global.fetch as ReturnType).mock.calls[0]; const headers = call[1].headers as Record; - expect(headers['User-Agent']).toBe(`my-app/1.2.3 (@agent-score/commerce@${__VERSION__})`); + expect(headers['User-Agent']).toMatch( + new RegExp( + `^my-app/1\\.2\\.3 \\(@agent-score/commerce@${__VERSION__}\\) \\(@agent-score/sdk@\\d+\\.\\d+\\.\\d+\\)$`, + ), + ); }); }); @@ -546,7 +638,9 @@ describe('agentscoreGate middleware — edge cases', () => { const fetchCall = (global.fetch as ReturnType).mock.calls[0]; const headers = fetchCall[1].headers as Record; - expect(headers['User-Agent']).toMatch(/^@agent-score\/commerce@\d+\.\d+\.\d+$/); + expect(headers['User-Agent']).toMatch( + /^@agent-score\/commerce@\d+\.\d+\.\d+ \(@agent-score\/sdk@\d+\.\d+\.\d+\)$/, + ); }); it('sends requireKyc as policy.require_kyc in request body', async () => { diff --git a/tests/fastify.test.ts b/tests/fastify.test.ts index e5e8d7f..33b6d6e 100644 --- a/tests/fastify.test.ts +++ b/tests/fastify.test.ts @@ -1,6 +1,6 @@ import Fastify from 'fastify'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { agentscoreGate, captureWallet } from '../src/identity/fastify'; +import { agentscoreGate, captureWallet, getGateDegradedState } from '../src/identity/fastify'; declare const __VERSION__: string; @@ -20,18 +20,21 @@ const DENY_RESPONSE = { }; function mockFetchOk(body: unknown): void { - global.fetch = vi.fn().mockResolvedValueOnce({ + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, - json: vi.fn().mockResolvedValueOnce(body), + headers: new Headers({ 'retry-after': '0' }), + json: vi.fn().mockResolvedValue(body), } as unknown as Response); } -function mockFetchStatus(status: number): void { - global.fetch = vi.fn().mockResolvedValueOnce({ +function mockFetchStatus(status: number, errorCode?: string): void { + const body = errorCode ? { error: { code: errorCode, message: 'mock' } } : {}; + global.fetch = vi.fn().mockResolvedValue({ ok: false, status, - json: vi.fn().mockResolvedValueOnce({}), + headers: new Headers({ 'retry-after': '0' }), + json: vi.fn().mockResolvedValue(body), } as unknown as Response); } @@ -158,7 +161,9 @@ describe('Fastify adapter — User-Agent', () => { await app.inject({ method: 'GET', url: '/test', headers: { 'x-wallet-address': WALLET } }); const fetchCall = (global.fetch as ReturnType).mock.calls[0]; - expect(fetchCall[1].headers['User-Agent']).toBe(`@agent-score/commerce@${__VERSION__}`); + expect(fetchCall[1].headers['User-Agent']).toMatch( + new RegExp(`^@agent-score/commerce@${__VERSION__} \\(@agent-score/sdk@\\d+\\.\\d+\\.\\d+\\)$`), + ); }); it('prepends custom userAgent to the default', async () => { @@ -170,7 +175,11 @@ describe('Fastify adapter — User-Agent', () => { await app.inject({ method: 'GET', url: '/test', headers: { 'x-wallet-address': WALLET } }); const fetchCall = (global.fetch as ReturnType).mock.calls[0]; - expect(fetchCall[1].headers['User-Agent']).toBe(`fastify-app/2.0 (@agent-score/commerce@${__VERSION__})`); + expect(fetchCall[1].headers['User-Agent']).toMatch( + new RegExp( + `^fastify-app/2\\.0 \\(@agent-score/commerce@${__VERSION__}\\) \\(@agent-score/sdk@\\d+\\.\\d+\\.\\d+\\)$`, + ), + ); }); }); @@ -263,6 +272,51 @@ describe('Fastify adapter — error paths', () => { const res = await app.inject({ method: 'GET', url: '/test', headers: { 'x-wallet-address': WALLET } }); expect(res.statusCode).toBe(200); }); + + it('fail_open marks degraded=true with infraReason="quota_exceeded" on 429', async () => { + mockFetchStatus(429); + const app = Fastify(); + await app.register(agentscoreGate, { apiKey: API_KEY, failOpen: true }); + app.get('/test', async (req) => getGateDegradedState(req)); + + const res = await app.inject({ method: 'GET', url: '/test', headers: { 'x-wallet-address': WALLET } }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ degraded: true, infraReason: 'quota_exceeded' }); + }); + + it('fail_open marks degraded=true with infraReason="api_error" on 500', async () => { + mockFetchStatus(500); + const app = Fastify(); + await app.register(agentscoreGate, { apiKey: API_KEY, failOpen: true }); + app.get('/test', async (req) => getGateDegradedState(req)); + + const res = await app.inject({ method: 'GET', url: '/test', headers: { 'x-wallet-address': WALLET } }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ degraded: true, infraReason: 'api_error' }); + }); + + it('fail_open marks degraded=true with infraReason="network_timeout" on AbortError', async () => { + global.fetch = vi.fn().mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' })); + const app = Fastify(); + await app.register(agentscoreGate, { apiKey: API_KEY, failOpen: true }); + app.get('/test', async (req) => getGateDegradedState(req)); + + const res = await app.inject({ method: 'GET', url: '/test', headers: { 'x-wallet-address': WALLET } }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ degraded: true, infraReason: 'network_timeout' }); + }); + + it('normal allow leaves degraded=false', async () => { + mockFetchOk(ALLOW_RESPONSE); + const app = Fastify(); + await app.register(agentscoreGate, { apiKey: API_KEY }); + app.get('/test', async (req) => getGateDegradedState(req)); + + const res = await app.inject({ method: 'GET', url: '/test', headers: { 'x-wallet-address': WALLET } }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ degraded: false }); + expect(res.json().infraReason).toBeUndefined(); + }); }); describe('Fastify adapter — chain option', () => { diff --git a/tests/hono.test.ts b/tests/hono.test.ts index a3ee2c2..d4a5066 100644 --- a/tests/hono.test.ts +++ b/tests/hono.test.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { agentscoreGate, captureWallet, getAgentScoreData } from '../src/identity/hono'; +import { agentscoreGate, captureWallet, getAgentScoreData, getGateDegradedState } from '../src/identity/hono'; declare const __VERSION__: string; @@ -39,18 +39,21 @@ const SESSION_RESPONSE = { }; function mockFetchOk(body: unknown): void { - global.fetch = vi.fn().mockResolvedValueOnce({ + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, - json: vi.fn().mockResolvedValueOnce(body), + headers: new Headers({ 'retry-after': '0' }), + json: vi.fn().mockResolvedValue(body), } as unknown as Response); } -function mockFetchStatus(status: number): void { - global.fetch = vi.fn().mockResolvedValueOnce({ +function mockFetchStatus(status: number, errorCode?: string): void { + const body = errorCode ? { error: { code: errorCode, message: 'mock' } } : {}; + global.fetch = vi.fn().mockResolvedValue({ ok: false, status, - json: vi.fn().mockResolvedValueOnce({}), + headers: new Headers({ 'retry-after': '0' }), + json: vi.fn().mockResolvedValue(body), } as unknown as Response); } @@ -561,7 +564,9 @@ describe('Hono adapter — User-Agent header', () => { await app.request('/test', { headers: { 'x-wallet-address': WALLET } }); const fetchCall = (global.fetch as ReturnType).mock.calls[0]; - expect(fetchCall[1].headers['User-Agent']).toBe(`@agent-score/commerce@${__VERSION__}`); + expect(fetchCall[1].headers['User-Agent']).toMatch( + new RegExp(`^@agent-score/commerce@${__VERSION__} \\(@agent-score/sdk@\\d+\\.\\d+\\.\\d+\\)$`), + ); }); it('prepends userAgent option to default when configured', async () => { @@ -573,7 +578,11 @@ describe('Hono adapter — User-Agent header', () => { await app.request('/test', { headers: { 'x-wallet-address': WALLET } }); const fetchCall = (global.fetch as ReturnType).mock.calls[0]; - expect(fetchCall[1].headers['User-Agent']).toBe(`wine-co/1.0.0 (@agent-score/commerce@${__VERSION__})`); + expect(fetchCall[1].headers['User-Agent']).toMatch( + new RegExp( + `^wine-co/1\\.0\\.0 \\(@agent-score/commerce@${__VERSION__}\\) \\(@agent-score/sdk@\\d+\\.\\d+\\.\\d+\\)$`, + ), + ); }); }); @@ -608,6 +617,21 @@ describe('Hono adapter — error paths', () => { expect(body.error.code).toBe('api_error'); }); + it('429 fail-closed denial carries quota-specific contact_merchant instructions, not retry_with_backoff', async () => { + mockFetchStatus(429); + const app = new Hono(); + app.use('*', agentscoreGate({ apiKey: API_KEY })); + app.get('/test', (c) => c.text('ok')); + + const res = await app.request('/test', { headers: { 'x-wallet-address': WALLET } }); + expect(res.status).toBe(503); + const body = await res.json() as { error: { code: string }; agent_instructions: string }; + expect(body.error.code).toBe('api_error'); + const instructions = JSON.parse(body.agent_instructions); + expect(instructions.action).toBe('contact_merchant'); + expect(instructions.steps[0]).toContain('merchant-side issue'); + }); + it('fail_open allows through on 402', async () => { mockFetchStatus(402); const app = new Hono(); @@ -627,6 +651,58 @@ describe('Hono adapter — error paths', () => { const res = await app.request('/test', { headers: { 'x-wallet-address': WALLET } }); expect(res.status).toBe(200); }); + + it('fail_open marks degraded=true with infraReason="quota_exceeded" on 429', async () => { + mockFetchStatus(429); + const app = new Hono(); + app.use('*', agentscoreGate({ apiKey: API_KEY, failOpen: true })); + app.get('/test', (c) => c.json(getGateDegradedState(c))); + + const res = await app.request('/test', { headers: { 'x-wallet-address': WALLET } }); + expect(res.status).toBe(200); + const body = await res.json() as { degraded: boolean; infraReason?: string }; + expect(body.degraded).toBe(true); + expect(body.infraReason).toBe('quota_exceeded'); + }); + + it('fail_open marks degraded=true with infraReason="api_error" on 500', async () => { + mockFetchStatus(500); + const app = new Hono(); + app.use('*', agentscoreGate({ apiKey: API_KEY, failOpen: true })); + app.get('/test', (c) => c.json(getGateDegradedState(c))); + + const res = await app.request('/test', { headers: { 'x-wallet-address': WALLET } }); + expect(res.status).toBe(200); + const body = await res.json() as { degraded: boolean; infraReason?: string }; + expect(body.degraded).toBe(true); + expect(body.infraReason).toBe('api_error'); + }); + + it('fail_open marks degraded=true with infraReason="network_timeout" on fetch reject', async () => { + global.fetch = vi.fn().mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' })); + const app = new Hono(); + app.use('*', agentscoreGate({ apiKey: API_KEY, failOpen: true })); + app.get('/test', (c) => c.json(getGateDegradedState(c))); + + const res = await app.request('/test', { headers: { 'x-wallet-address': WALLET } }); + expect(res.status).toBe(200); + const body = await res.json() as { degraded: boolean; infraReason?: string }; + expect(body.degraded).toBe(true); + expect(body.infraReason).toBe('network_timeout'); + }); + + it('normal allow leaves degraded=false', async () => { + mockFetchOk(ALLOW_RESPONSE); + const app = new Hono(); + app.use('*', agentscoreGate({ apiKey: API_KEY })); + app.get('/test', (c) => c.json(getGateDegradedState(c))); + + const res = await app.request('/test', { headers: { 'x-wallet-address': WALLET } }); + expect(res.status).toBe(200); + const body = await res.json() as { degraded: boolean; infraReason?: string }; + expect(body.degraded).toBe(false); + expect(body.infraReason).toBeUndefined(); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/identity-helpers.test.ts b/tests/identity-helpers.test.ts new file mode 100644 index 0000000..e55b731 --- /dev/null +++ b/tests/identity-helpers.test.ts @@ -0,0 +1,130 @@ +/** + * Cross-adapter read-helper coverage for getAgentScoreData / getGateQuotaInfo. + * + * Each adapter exposes read-only helpers that pull state stashed on the + * framework-specific request container during gate evaluate. These tests exercise + * the read-path-only contract: prime the container with a fake state and verify + * the helper returns what's there. Full end-to-end gate flow is covered by the + * per-adapter gate tests; this file targets the trivial reader surface that + * doesn't get exercised on the failOpen / fail-closed paths. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { + getAgentScoreData as getExpressAgentScoreData, + getGateQuotaInfo as getExpressGateQuotaInfo, +} from '../src/identity/express'; +import { + getAgentScoreData as getFastifyAgentScoreData, + getGateQuotaInfo as getFastifyGateQuotaInfo, +} from '../src/identity/fastify'; +import { getGateQuotaInfo as getHonoGateQuotaInfo } from '../src/identity/hono'; +import type { Request as ExpressRequest } from 'express'; +import type { FastifyRequest } from 'fastify'; +import type { Context } from 'hono'; + + +// Couples to the internal GATE_STATE_KEY string in each adapter; intentional — +// the read helpers are thin wrappers over that key, and the test exercises the +// extraction logic directly. +const GATE_STATE_KEY = '__agentscoreGate'; + +const FAKE_QUOTA = { limit: 1000, used: 780, reset: '2026-06-01T00:00:00Z' }; +const FAKE_ASSESS_DATA = { decision: 'allow', operatorToken: null } as const; + +describe('express getAgentScoreData', () => { + it('returns the attached AgentScoreData', () => { + const req = { agentscore: FAKE_ASSESS_DATA } as unknown as ExpressRequest; + expect(getExpressAgentScoreData(req)).toBe(FAKE_ASSESS_DATA); + }); + + it('returns undefined when nothing is attached', () => { + const req = {} as unknown as ExpressRequest; + expect(getExpressAgentScoreData(req)).toBeUndefined(); + }); +}); + +describe('express getGateQuotaInfo', () => { + it('returns the quota envelope when state has it', () => { + const req = { [GATE_STATE_KEY]: { quota: FAKE_QUOTA } } as unknown as ExpressRequest; + expect(getExpressGateQuotaInfo(req)).toBe(FAKE_QUOTA); + }); + + it('returns undefined when state is absent', () => { + const req = {} as unknown as ExpressRequest; + expect(getExpressGateQuotaInfo(req)).toBeUndefined(); + }); + + it('returns undefined when state is present but quota is not', () => { + const req = { [GATE_STATE_KEY]: { degraded: false } } as unknown as ExpressRequest; + expect(getExpressGateQuotaInfo(req)).toBeUndefined(); + }); +}); + +describe('fastify getAgentScoreData', () => { + it('returns the attached AgentScoreData', () => { + const request = { agentscore: FAKE_ASSESS_DATA } as unknown as FastifyRequest; + expect(getFastifyAgentScoreData(request)).toBe(FAKE_ASSESS_DATA); + }); + + it('returns undefined when nothing is attached', () => { + const request = {} as unknown as FastifyRequest; + expect(getFastifyAgentScoreData(request)).toBeUndefined(); + }); +}); + +describe('fastify getGateQuotaInfo', () => { + it('returns the quota envelope when state has it', () => { + const request = { + [GATE_STATE_KEY]: { quota: FAKE_QUOTA }, + } as unknown as FastifyRequest; + expect(getFastifyGateQuotaInfo(request)).toBe(FAKE_QUOTA); + }); + + it('returns undefined when state is absent', () => { + const request = {} as unknown as FastifyRequest; + expect(getFastifyGateQuotaInfo(request)).toBeUndefined(); + }); + + it('returns undefined when state is present but quota is not', () => { + const request = { [GATE_STATE_KEY]: { degraded: false } } as unknown as FastifyRequest; + expect(getFastifyGateQuotaInfo(request)).toBeUndefined(); + }); +}); + +describe('hono getGateQuotaInfo', () => { + it('returns the quota envelope when state has it', () => { + const c = { + get: vi.fn().mockReturnValue({ quota: FAKE_QUOTA }), + } as unknown as Context; + expect(getHonoGateQuotaInfo(c)).toBe(FAKE_QUOTA); + }); + + it('returns undefined when state is absent', () => { + const c = { get: vi.fn().mockReturnValue(undefined) } as unknown as Context; + expect(getHonoGateQuotaInfo(c)).toBeUndefined(); + }); + + it('returns undefined when state is present but quota is not', () => { + const c = { + get: vi.fn().mockReturnValue({ degraded: false }), + } as unknown as Context; + expect(getHonoGateQuotaInfo(c)).toBeUndefined(); + }); +}); + +describe('passthrough re-export modules', () => { + it('payment/signer.ts re-exports extractPaymentSigner + readX402PaymentHeader', async () => { + const mod = await import('../src/payment/signer'); + expect(mod.extractPaymentSigner).toBeDefined(); + expect(mod.readX402PaymentHeader).toBeDefined(); + }); + + it('api/index.ts re-exports AgentScore + AgentScoreError + test-address helpers', async () => { + const mod = await import('../src/api/index'); + expect(mod.AgentScore).toBeDefined(); + expect(mod.AgentScoreError).toBeDefined(); + expect(mod.AGENTSCORE_TEST_ADDRESSES).toBeDefined(); + expect(mod.isAgentScoreTestAddress).toBeDefined(); + }); +}); diff --git a/tests/nextjs.test.ts b/tests/nextjs.test.ts index 84dbf77..1b07a3a 100644 --- a/tests/nextjs.test.ts +++ b/tests/nextjs.test.ts @@ -16,18 +16,21 @@ const DENY_RESPONSE = { }; function mockFetchOk(body: unknown): void { - global.fetch = vi.fn().mockResolvedValueOnce({ + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, - json: vi.fn().mockResolvedValueOnce(body), + headers: new Headers({ 'retry-after': '0' }), + json: vi.fn().mockResolvedValue(body), } as unknown as Response); } -function mockFetchStatus(status: number): void { - global.fetch = vi.fn().mockResolvedValueOnce({ +function mockFetchStatus(status: number, errorCode?: string): void { + const body = errorCode ? { error: { code: errorCode, message: 'mock' } } : {}; + global.fetch = vi.fn().mockResolvedValue({ ok: false, status, - json: vi.fn().mockResolvedValueOnce({}), + headers: new Headers({ 'retry-after': '0' }), + json: vi.fn().mockResolvedValue(body), } as unknown as Response); } @@ -158,14 +161,17 @@ describe('Next.js adapter — withAgentScoreGate (route handler wrapper)', () => await POST(req); const fetchCall = (global.fetch as ReturnType).mock.calls[0]; - expect(fetchCall[1].headers['User-Agent']).toMatch(/^my-next-app\/1\.0\.0 \(@agent-score\/commerce@\d+\.\d+\.\d+\)$/); + expect(fetchCall[1].headers['User-Agent']).toMatch( + /^my-next-app\/1\.0\.0 \(@agent-score\/commerce@\d+\.\d+\.\d+\) \(@agent-score\/sdk@\d+\.\d+\.\d+\)$/, + ); }); it('allows through when failOpen is true and API returns 402', async () => { - global.fetch = vi.fn().mockResolvedValueOnce({ + global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 402, - json: vi.fn().mockResolvedValueOnce({}), + headers: new Headers({ 'retry-after': '0' }), + json: vi.fn().mockResolvedValue({}), } as unknown as Response); const handler = vi.fn(async () => new Response('reached')); const POST = withAgentScoreGate({ apiKey: API_KEY, failOpen: true }, handler); @@ -180,6 +186,69 @@ describe('Next.js adapter — withAgentScoreGate (route handler wrapper)', () => expect(handler).toHaveBeenCalledOnce(); }); + it('failOpen propagates degraded=true with infraReason="quota_exceeded" on 429 to handler', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 429, + headers: new Headers({ 'retry-after': '0' }), + json: vi.fn().mockResolvedValue({ error: { code: 'quota_exceeded', message: 'mock' } }), + } as unknown as Response); + let captured: { degraded?: boolean; infraReason?: string } | null = null; + const POST = withAgentScoreGate( + { apiKey: API_KEY, failOpen: true }, + async (_req, gate) => { + captured = { degraded: gate.degraded, infraReason: gate.infraReason }; + return new Response('ok'); + }, + ); + const res = await POST(new Request('https://example.com/api/x', { method: 'POST', headers: { 'x-wallet-address': WALLET } })); + expect(res.status).toBe(200); + expect(captured).toEqual({ degraded: true, infraReason: 'quota_exceeded' }); + }); + + it('failOpen propagates degraded=true with infraReason="api_error" on 500 to handler', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 500, + json: vi.fn().mockResolvedValueOnce({}), + } as unknown as Response); + let captured: { degraded?: boolean; infraReason?: string } | null = null; + const POST = withAgentScoreGate( + { apiKey: API_KEY, failOpen: true }, + async (_req, gate) => { + captured = { degraded: gate.degraded, infraReason: gate.infraReason }; + return new Response('ok'); + }, + ); + const res = await POST(new Request('https://example.com/api/x', { method: 'POST', headers: { 'x-wallet-address': WALLET } })); + expect(res.status).toBe(200); + expect(captured).toEqual({ degraded: true, infraReason: 'api_error' }); + }); + + it('normal allow leaves degraded undefined on handler gate arg', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValueOnce({ + decision: 'allow', + decision_reasons: ['no_policy_applied'], + subject: { chains: ['base'], address: WALLET }, + score: { value: 85, grade: 'A' }, + }), + } as unknown as Response); + let captured: { degraded?: boolean; infraReason?: string } | null = null; + const POST = withAgentScoreGate( + { apiKey: API_KEY }, + async (_req, gate) => { + captured = { degraded: gate.degraded, infraReason: gate.infraReason }; + return new Response('ok'); + }, + ); + const res = await POST(new Request('https://example.com/api/x', { method: 'POST', headers: { 'x-wallet-address': WALLET } })); + expect(res.status).toBe(200); + expect(captured).toEqual({ degraded: undefined, infraReason: undefined }); + }); + it('creates a session and returns 403 identity_verification_required when identity is missing', async () => { const SESSION_RESPONSE = { session_id: 'sess_nx1', diff --git a/tests/signer-match.test.ts b/tests/signer-match.test.ts index ec374d4..ae8b44f 100644 --- a/tests/signer-match.test.ts +++ b/tests/signer-match.test.ts @@ -11,7 +11,8 @@ function mockFetchSequence(responses: unknown[]): void { mock.mockResolvedValueOnce({ ok: true, status: 200, - json: vi.fn().mockResolvedValueOnce(body), + headers: new Headers({ 'retry-after': '0' }), + json: vi.fn().mockResolvedValue(body), } as unknown as Response); } global.fetch = mock; @@ -90,10 +91,15 @@ describe('AgentScoreCore.verifyWalletSignerMatch', () => { expect(assessCalls).toHaveLength(0); }); - it('returns pass when both wallets resolve to the same operator', async () => { + it('returns pass when both wallets resolve to the same operator (server-side signer_match)', async () => { + // New 1-call path: API returns signer_match in the same response as the assess. mockFetchSequence([ - { resolved_operator: 'op_123', decision: 'allow' }, - { resolved_operator: 'op_123', decision: 'allow' }, + { + decision: 'allow', + decision_reasons: [], + resolved_operator: 'op_123', + signer_match: { kind: 'pass', claimed_operator: 'op_123', signer_operator: 'op_123' }, + }, ]); const core = createAgentScoreCore({ apiKey: API_KEY }); @@ -109,10 +115,26 @@ describe('AgentScoreCore.verifyWalletSignerMatch', () => { } }); - it('returns wallet_signer_mismatch when operators differ', async () => { + it('returns wallet_signer_mismatch with full body fields from server-side signer_match', async () => { mockFetchSequence([ - { resolved_operator: 'op_claimed', decision: 'allow' }, - { resolved_operator: 'op_signer', decision: 'allow' }, + { + decision: 'allow', + decision_reasons: [], + resolved_operator: 'op_claimed', + signer_match: { + kind: 'wallet_signer_mismatch', + claimed_operator: 'op_claimed', + signer_operator: 'op_signer', + expected_signer: WALLET_A.toLowerCase(), + actual_signer: WALLET_B.toLowerCase(), + linked_wallets: [WALLET_A.toLowerCase(), '0x3333333333333333333333333333333333333333'], + agent_instructions: JSON.stringify({ + action: 'resign_or_switch_to_operator_token', + steps: ['re-sign with linked_wallets'], + user_message: 'Different operator', + }), + }, + }, ]); const core = createAgentScoreCore({ apiKey: API_KEY }); @@ -128,17 +150,20 @@ describe('AgentScoreCore.verifyWalletSignerMatch', () => { expect(result.expectedSigner).toBe(WALLET_A.toLowerCase()); expect(result.actualSigner).toBe(WALLET_B.toLowerCase()); expect(result.agentInstructions).toContain('resign_or_switch_to_operator_token'); - expect(result.agentInstructions).toContain('linked_wallets'); - // user_message lives INSIDE agent_instructions (single source of truth). - const instr = JSON.parse(result.agentInstructions) as { user_message: string }; - expect(instr.user_message).toMatch(/operator/i); + expect(result.linkedWallets.length).toBeGreaterThan(0); } }); - it('returns wallet_signer_mismatch when signer resolves to null operator (unlinked)', async () => { + it('falls back to legacy 2-resolve path when server omits signer_match', async () => { + // Server didn't include signer_match in the response. Commerce falls back to two + // resolveWalletToOperator calls so the gate still produces a verdict. mockFetchSequence([ - { resolved_operator: 'op_claimed', decision: 'allow' }, - { resolved_operator: null, decision: 'deny' }, + // First call: resolve_signer-aware assess but old API ignores the field. + { decision: 'allow', decision_reasons: [], resolved_operator: 'op_claimed' }, + // Legacy fallback resolve(claimed) + { decision: 'allow', decision_reasons: [], resolved_operator: 'op_claimed', linked_wallets: [WALLET_A.toLowerCase()] }, + // Legacy fallback resolve(signer) + { decision: 'allow', decision_reasons: [], resolved_operator: 'op_signer' }, ]); const core = createAgentScoreCore({ apiKey: API_KEY }); @@ -149,7 +174,8 @@ describe('AgentScoreCore.verifyWalletSignerMatch', () => { expect(result.kind).toBe('wallet_signer_mismatch'); if (result.kind === 'wallet_signer_mismatch') { - expect(result.actualSignerOperator).toBeNull(); + expect(result.claimedOperator).toBe('op_claimed'); + expect(result.actualSignerOperator).toBe('op_signer'); } }); @@ -194,54 +220,20 @@ describe('AgentScoreCore.verifyWalletSignerMatch', () => { }); describe('AgentScoreCore.verifyWalletSignerMatch — coverage paths', () => { - it('reads the plain evaluate() cache when the same wallet was gated first', async () => { - // Pre-warm: evaluate() writes under the wallet key. Subsequent verify should - // NOT hit the API again for the claimed side. - global.fetch = vi.fn().mockResolvedValueOnce({ - ok: true, - status: 200, - json: vi.fn().mockResolvedValueOnce({ - decision: 'allow', - decision_reasons: [], - resolved_operator: 'op_shared', - linked_wallets: ['0xaaa0000000000000000000000000000000000001'], - }), - } as unknown as Response).mockResolvedValueOnce({ - ok: true, - status: 200, - json: vi.fn().mockResolvedValueOnce({ resolved_operator: 'op_shared', linked_wallets: [] }), - } as unknown as Response).mockResolvedValue({ ok: true, status: 201, json: async () => ({}) }); - - const core = createAgentScoreCore({ apiKey: API_KEY }); - // Prime: evaluate the claimed wallet to populate the plain cache. - await core.evaluate({ address: '0xaaa0000000000000000000000000000000000000' }); - const fetchCallsBefore = (global.fetch as ReturnType).mock.calls.length; - - const result = await core.verifyWalletSignerMatch({ - claimedWallet: '0xaaa0000000000000000000000000000000000000', - signer: '0xbbb0000000000000000000000000000000000000', - }); - - expect(result.kind).toBe('pass'); - // Only the signer side (+ telemetry) hits the network; the claimed side uses the cache. - const callUrls = (global.fetch as ReturnType).mock.calls - .slice(fetchCallsBefore) - .map((c) => c[0] as string); - const assessForClaimed = callUrls.filter( - (u) => u.includes('/v1/assess'), - ).length; - expect(assessForClaimed).toBe(1); // only the signer wallet resolve, not claimed - }); - - it('reuses the resolve: cache across calls', async () => { + it('reuses cached signer_match for repeat same-pair calls (no extra /v1/assess)', async () => { let assessCount = 0; global.fetch = vi.fn().mockImplementation(async (url: string) => { - if ((url as string).includes('/v1/assess')) { + if (url.includes('/v1/assess')) { assessCount++; return { ok: true, status: 200, - json: async () => ({ resolved_operator: 'op_shared', linked_wallets: [] }), + json: async () => ({ + decision: 'allow', + decision_reasons: [], + resolved_operator: 'op_shared', + signer_match: { kind: 'pass', claimed_operator: 'op_shared', signer_operator: 'op_shared' }, + }), } as unknown as Response; } return { ok: true, status: 201, json: async () => ({}) } as unknown as Response; @@ -254,10 +246,38 @@ describe('AgentScoreCore.verifyWalletSignerMatch — coverage paths', () => { await core.verifyWalletSignerMatch(opts); const first = assessCount; await core.verifyWalletSignerMatch(opts); - // Second run hits the resolve: cache for both wallets — no extra /v1/assess calls. + // Second run reads the cached signer_match — no fresh assess call. expect(assessCount).toBe(first); }); + it('separate signer wallets on the same claimed wallet each get their own cache slot', async () => { + // The signerMatchBySigner sub-map keys by signer — so two payments under the same + // claimed wallet from different signers don't serve stale verdicts to each other. + let assessCount = 0; + global.fetch = vi.fn().mockImplementation(async (url: string) => { + if (url.includes('/v1/assess')) { + assessCount++; + return { + ok: true, + status: 200, + json: async () => ({ + decision: 'allow', + decision_reasons: [], + resolved_operator: 'op_shared', + signer_match: { kind: 'pass', claimed_operator: 'op_shared', signer_operator: 'op_shared' }, + }), + } as unknown as Response; + } + return { ok: true, status: 201, json: async () => ({}) } as unknown as Response; + }); + const core = createAgentScoreCore({ apiKey: API_KEY }); + const claimed = '0xaaa0000000000000000000000000000000000000'; + await core.verifyWalletSignerMatch({ claimedWallet: claimed, signer: '0xbbb0000000000000000000000000000000000000' }); + await core.verifyWalletSignerMatch({ claimedWallet: claimed, signer: '0xccc0000000000000000000000000000000000000' }); + // Two different signers → two separate assess calls (one per pair). + expect(assessCount).toBe(2); + }); + it('401 with non-JSON body falls through to generic error (ok: false)', async () => { // The 401 passthrough tries to parse JSON. When the body isn't JSON, the catch // block swallows and the outer !response.ok check throws. The gate catches that diff --git a/tests/web.test.ts b/tests/web.test.ts index 5538174..23f6bf4 100644 --- a/tests/web.test.ts +++ b/tests/web.test.ts @@ -26,18 +26,21 @@ const SESSION_RESPONSE = { }; function mockFetchOk(body: unknown): void { - global.fetch = vi.fn().mockResolvedValueOnce({ + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, - json: vi.fn().mockResolvedValueOnce(body), + headers: new Headers({ 'retry-after': '0' }), + json: vi.fn().mockResolvedValue(body), } as unknown as Response); } -function mockFetchStatus(status: number): void { - global.fetch = vi.fn().mockResolvedValueOnce({ +function mockFetchStatus(status: number, errorCode?: string): void { + const body = errorCode ? { error: { code: errorCode, message: 'mock' } } : {}; + global.fetch = vi.fn().mockResolvedValue({ ok: false, status, - json: vi.fn().mockResolvedValueOnce({}), + headers: new Headers({ 'retry-after': '0' }), + json: vi.fn().mockResolvedValue(body), } as unknown as Response); } @@ -149,7 +152,9 @@ describe('Web Fetch adapter — createAgentScoreGate', () => { await guard(new Request('https://example.com/', { headers: { 'x-wallet-address': WALLET } })); const fetchCall = (global.fetch as ReturnType).mock.calls[0]; - expect(fetchCall[1].headers['User-Agent']).toBe(`@agent-score/commerce@${__VERSION__}`); + expect(fetchCall[1].headers['User-Agent']).toMatch( + new RegExp(`^@agent-score/commerce@${__VERSION__} \\(@agent-score/sdk@\\d+\\.\\d+\\.\\d+\\)$`), + ); }); it('prepends userAgent to the default when configured', async () => { @@ -158,7 +163,11 @@ describe('Web Fetch adapter — createAgentScoreGate', () => { await guard(new Request('https://example.com/', { headers: { 'x-wallet-address': WALLET } })); const fetchCall = (global.fetch as ReturnType).mock.calls[0]; - expect(fetchCall[1].headers['User-Agent']).toBe(`worker/1.0 (@agent-score/commerce@${__VERSION__})`); + expect(fetchCall[1].headers['User-Agent']).toMatch( + new RegExp( + `^worker/1\\.0 \\(@agent-score/commerce@${__VERSION__}\\) \\(@agent-score/sdk@\\d+\\.\\d+\\.\\d+\\)$`, + ), + ); }); it('fails open on 402 when failOpen is true — returns allowed with no data', async () => { @@ -370,6 +379,65 @@ describe('Web Fetch adapter — error paths + chain', () => { expect(result.allowed).toBe(true); }); + it('fail_open surfaces degraded=true with infraReason="quota_exceeded" on 429', async () => { + mockFetchStatus(429); + const guard = createAgentScoreGate({ apiKey: API_KEY, failOpen: true }); + const result = await guard(new Request('https://example.com/', { headers: { 'x-wallet-address': WALLET } })); + expect(result.allowed).toBe(true); + if (result.allowed) { + expect(result.degraded).toBe(true); + expect(result.infraReason).toBe('quota_exceeded'); + } + }); + + it('fail_open surfaces degraded=true with infraReason="api_error" on 500', async () => { + mockFetchStatus(500); + const guard = createAgentScoreGate({ apiKey: API_KEY, failOpen: true }); + const result = await guard(new Request('https://example.com/', { headers: { 'x-wallet-address': WALLET } })); + expect(result.allowed).toBe(true); + if (result.allowed) { + expect(result.degraded).toBe(true); + expect(result.infraReason).toBe('api_error'); + } + }); + + it('fail_open surfaces degraded=true with infraReason="network_timeout" on AbortError', async () => { + global.fetch = vi.fn().mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' })); + const guard = createAgentScoreGate({ apiKey: API_KEY, failOpen: true }); + const result = await guard(new Request('https://example.com/', { headers: { 'x-wallet-address': WALLET } })); + expect(result.allowed).toBe(true); + if (result.allowed) { + expect(result.degraded).toBe(true); + expect(result.infraReason).toBe('network_timeout'); + } + }); + + it('normal allow leaves degraded undefined', async () => { + mockFetchOk(ALLOW_RESPONSE); + const guard = createAgentScoreGate({ apiKey: API_KEY }); + const result = await guard(new Request('https://example.com/', { headers: { 'x-wallet-address': WALLET } })); + expect(result.allowed).toBe(true); + if (result.allowed) { + expect(result.degraded).toBeUndefined(); + expect(result.infraReason).toBeUndefined(); + } + }); + + it('withAgentScoreGate propagates degraded into the handler gate arg', async () => { + mockFetchStatus(429); + let captured: { degraded?: boolean; infraReason?: string } | null = null; + const handler = withAgentScoreGate( + { apiKey: API_KEY, failOpen: true }, + async (_req, gate) => { + captured = { degraded: gate.degraded, infraReason: gate.infraReason }; + return new Response('ok'); + }, + ); + const res = await handler(new Request('https://example.com/', { headers: { 'x-wallet-address': WALLET } })); + expect(res.status).toBe(200); + expect(captured).toEqual({ degraded: true, infraReason: 'quota_exceeded' }); + }); + it('forwards constructor chain to /v1/assess body', async () => { mockFetchOk(ALLOW_RESPONSE); const guard = createAgentScoreGate({ apiKey: API_KEY, chain: 'solana' });