From 4a50346a0e84137e680a415af62af5f9c9117928 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 1 May 2026 06:06:18 -0700 Subject: [PATCH 01/10] docs: drop "Paid-tier merchants" qualifier from associateWallet docstring The API itself gates the endpoint by tier; the SDK doesn't need to remind callers about tier structure in a docstring. Consistent with the platform-wide pass to keep tier/pricing language out of public-package source. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index c0fd353..b69e8a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -118,8 +118,8 @@ export class AgentScore { } /** - * Report that a wallet paid under an operator credential. Paid-tier merchants observing - * agent payments call this passively to build a cross-merchant credential↔wallet profile. + * Report that a wallet paid under an operator credential. Merchants observing agent + * payments call this passively to build a cross-merchant credential↔wallet profile. * * Fire-and-forget friendly — the returned `first_seen` boolean is informational only. */ From 0998f754d113d4fa13323403b973b024364a3897 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 1 May 2026 06:09:36 -0700 Subject: [PATCH 02/10] chore: bump to v2.1.2 Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5e7d556..e0781aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/sdk", - "version": "2.1.1", + "version": "2.1.2", "description": "TypeScript client for the AgentScore APIs", "main": "./dist/index.cjs", "module": "./dist/index.js", From 2c309a067d857cc2b37253fb4a359d4762415d7c Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 1 May 2026 07:06:16 -0700 Subject: [PATCH 03/10] feat: typed assess errors + X-Quota-* header capture + telemetrySignerMatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New error subclasses (all subclass AgentScoreError so existing catches still work): PaymentRequiredError (402), TokenExpiredError (401 token_expired with parsed body fields exposed: verifyUrl/sessionId/pollSecret/pollUrl/nextSteps/agentMemory), InvalidCredentialError (401 invalid_credential), QuotaExceededError (429 quota_exceeded), RateLimitedError (429 rate_limited), TimeoutError (AbortError / request timeout) - AssessResponse gains optional quota field { limit, used, reset } captured from X-Quota-Limit / X-Quota-Used / X-Quota-Reset response headers; agents can monitor approach-to-cap proactively (warn at 80%, alert at 95%) before hitting 429 - New telemetrySignerMatch(payload) method — fire-and-forget POST to /v1/telemetry/signer-match (commerce gate currently does this raw; will switch) - Internal: request() splits into request() + requestWithHeaders(); shared buildErrorFromResponse helper does status+code routing once Backward-compatible: all new error classes inherit AgentScoreError; existing catch (err: AgentScoreError) blocks unaffected. New quota field is optional. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/errors.ts | 74 ++++++++++++++++ src/index.ts | 150 ++++++++++++++++++++++++++------ src/types.ts | 16 ++++ tests/index.test.ts | 206 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 416 insertions(+), 30 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index a9b6778..b3fa044 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -15,3 +15,77 @@ export class AgentScoreError extends Error { this.details = details; } } + +/** HTTP 402 — the endpoint is not enabled for this account. */ +export class PaymentRequiredError extends AgentScoreError { + constructor(message: string, details: Record = {}) { + super('payment_required', message, 402, details); + Object.setPrototypeOf(this, new.target.prototype); + this.name = 'PaymentRequiredError'; + } +} + +/** HTTP 401 with `error.code = 'token_expired'` — credential is no longer valid (revoked or + * TTL-expired; the API deliberately doesn't disclose which). The body carries an auto-minted + * verification session — exposed here so callers can recover without re-parsing `details`. */ +export class TokenExpiredError extends AgentScoreError { + public readonly verifyUrl?: string; + public readonly sessionId?: string; + public readonly pollSecret?: string; + public readonly pollUrl?: string; + public readonly nextSteps?: unknown; + public readonly agentMemory?: unknown; + + constructor(message: string, details: Record = {}) { + super('token_expired', message, 401, details); + Object.setPrototypeOf(this, new.target.prototype); + this.name = 'TokenExpiredError'; + this.verifyUrl = typeof details.verify_url === 'string' ? details.verify_url : undefined; + this.sessionId = typeof details.session_id === 'string' ? details.session_id : undefined; + this.pollSecret = typeof details.poll_secret === 'string' ? details.poll_secret : undefined; + this.pollUrl = typeof details.poll_url === 'string' ? details.poll_url : undefined; + this.nextSteps = details.next_steps; + this.agentMemory = details.agent_memory; + } +} + +/** HTTP 401 with `error.code = 'invalid_credential'` — the operator_token doesn't match any + * credential. Permanent: no auto-session is issued. Caller should switch tokens or restart. */ +export class InvalidCredentialError extends AgentScoreError { + constructor(message: string, details: Record = {}) { + super('invalid_credential', message, 401, details); + Object.setPrototypeOf(this, new.target.prototype); + this.name = 'InvalidCredentialError'; + } +} + +/** HTTP 429 with `error.code = 'quota_exceeded'` — account-level cap reached. Don't retry; + * the cap won't lift through retry alone. Distinct from per-second `RateLimitedError`. */ +export class QuotaExceededError extends AgentScoreError { + constructor(message: string, details: Record = {}) { + super('quota_exceeded', message, 429, details); + Object.setPrototypeOf(this, new.target.prototype); + this.name = 'QuotaExceededError'; + } +} + +/** HTTP 429 with `error.code = 'rate_limited'` — per-second sliding-window limit hit. Retry + * after the interval indicated by the `Retry-After` header (typically ≤1s). */ +export class RateLimitedError extends AgentScoreError { + constructor(message: string, details: Record = {}) { + super('rate_limited', message, 429, details); + Object.setPrototypeOf(this, new.target.prototype); + this.name = 'RateLimitedError'; + } +} + +/** Request timed out or was aborted at the network layer (the AbortController fired before a + * response arrived). Distinct from generic network errors so callers can branch on retry vs + * surface-to-user without parsing message strings. */ +export class TimeoutError extends AgentScoreError { + constructor(message: string) { + super('timeout', message, 0); + Object.setPrototypeOf(this, new.target.prototype); + this.name = 'TimeoutError'; + } +} diff --git a/src/index.ts b/src/index.ts index b69e8a4..a28e775 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,12 @@ -import { AgentScoreError } from './errors'; +import { + AgentScoreError, + InvalidCredentialError, + PaymentRequiredError, + QuotaExceededError, + RateLimitedError, + TimeoutError, + TokenExpiredError, +} from './errors'; import type { AgentScoreConfig, AgentScoreErrorBody, @@ -11,13 +19,22 @@ import type { CredentialListResponse, CredentialRevokeResponse, GetReputationOptions, + QuotaInfo, ReputationResponse, SessionCreateOptions, SessionCreateResponse, SessionPollResponse, } from './types'; -export { AgentScoreError } from './errors'; +export { + AgentScoreError, + InvalidCredentialError, + PaymentRequiredError, + QuotaExceededError, + RateLimitedError, + TimeoutError, + TokenExpiredError, +} from './errors'; export { AGENTSCORE_TEST_ADDRESSES, isAgentScoreTestAddress } from './test-mode'; export * from './types'; @@ -64,11 +81,13 @@ export class AgentScore { if (options?.refresh !== undefined) body.refresh = options.refresh; if (options?.policy) body.policy = options.policy; - return this.request('/v1/assess', { + const { data, headers } = await this.requestWithHeaders('/v1/assess', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); + const quota = extractQuota(headers); + return quota ? { ...data, quota } : data; } async createSession(options?: SessionCreateOptions): Promise { @@ -146,7 +165,37 @@ export class AgentScore { }); } + /** Fire-and-forget telemetry: report a wallet-signer-match verdict so AgentScore can + * track aggregate signer-binding behavior across merchants. Does not throw; failures + * are logged at warn level so persistent telemetry outages are visible in ops logs. + * Used internally by the commerce gate's `verifyWalletSignerMatch` helper. */ + async telemetrySignerMatch(payload: { + claimed_wallet?: string; + signer?: string | null; + network?: 'evm' | 'solana'; + kind: 'pass' | 'wallet_signer_mismatch' | 'wallet_auth_requires_wallet_signing'; + [key: string]: unknown; + }): Promise { + try { + await this.request('/v1/telemetry/signer-match', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + } catch (err) { + console.warn('[@agent-score/sdk] telemetrySignerMatch failed:', err instanceof Error ? err.message : err); + } + } + private async request(path: string, options?: RequestInit): Promise { + const { data } = await this.requestWithHeaders(path, options); + return data; + } + + /** Returns both the parsed body and the response Headers. Public methods that need to + * capture per-request headers (e.g. assess() reading X-Quota-*) use this; everything + * else uses request(). */ + private async requestWithHeaders(path: string, options?: RequestInit): Promise<{ data: T; headers: Headers }> { const url = `${this.baseUrl}${path}`; const headers: Record = { @@ -178,44 +227,89 @@ export class AgentScore { const retryTimer = setTimeout(() => retryController.abort(), this.timeout); try { const retry = await fetch(url, { ...options, headers, signal: retryController.signal }); - if (retry.ok) return (await retry.json()) as T; - - throw new AgentScoreError('rate_limited', 'Rate limit exceeded', 429); + if (retry.ok) { + const data = (await retry.json()) as T; + return { data, headers: retry.headers }; + } + // 429 still after retry — discriminate quota vs rate. + throw await buildErrorFromResponse(retry); } finally { clearTimeout(retryTimer); } } if (!response.ok) { - let code = 'unknown_error'; - let message = `Request failed with status ${response.status}`; - let details: Record = {}; - - try { - const body = (await response.json()) as AgentScoreErrorBody & Record; - if (body?.error) { - code = body.error.code; - message = body.error.message; - } - // Preserve everything except the parsed `error` block so consumers can read - // verify_url, linked_wallets, reasons, etc. for granular denial recovery. - const { error: _omit, ...rest } = body; - details = rest; - } catch { - // Use defaults - } - - throw new AgentScoreError(code, message, response.status, details); + throw await buildErrorFromResponse(response); } - return (await response.json()) as T; + const data = (await response.json()) as T; + return { data, headers: response.headers }; } catch (err) { if (err instanceof AgentScoreError) throw err; const message = err instanceof Error ? err.message : 'Unknown error'; - const code = signal.aborted ? 'timeout' : 'network_error'; - throw new AgentScoreError(code, message, 0); + if (signal.aborted) throw new TimeoutError(message); + throw new AgentScoreError('network_error', message, 0); } finally { clearTimeout(timer); } } } + +/** Parse `X-Quota-Limit`, `X-Quota-Used`, `X-Quota-Reset` from response headers. Returns + * `undefined` when none of the three are present (Enterprise / unlimited tiers). Numeric + * fields fall back to `null` if the header is malformed; reset stays as a string ('never' + * or ISO-8601 timestamp). */ +function extractQuota(headers: Headers | undefined): QuotaInfo | undefined { + // Test mocks may stub Response without a real Headers object — defend against it + // rather than blowing up the assess() return path on bad mocks. + if (!headers || typeof headers.get !== 'function') return undefined; + const limit = headers.get('x-quota-limit'); + const used = headers.get('x-quota-used'); + const reset = headers.get('x-quota-reset'); + if (limit === null && used === null && reset === null) return undefined; + return { + limit: parseQuotaNumber(limit), + used: parseQuotaNumber(used), + reset, + }; +} + +function parseQuotaNumber(raw: string | null): number | null { + if (raw === null) return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; +} + +/** Map a non-2xx Response to the right typed AgentScoreError subclass. Reads the body to + * extract `error.code` for discrimination + the rest for `details`. Falls through to a + * generic `AgentScoreError` for codes the SDK doesn't have a dedicated subclass for. */ +async function buildErrorFromResponse(response: Response): Promise { + let code = 'unknown_error'; + let message = `Request failed with status ${response.status}`; + let details: Record = {}; + + try { + const body = (await response.json()) as AgentScoreErrorBody & Record; + if (body?.error) { + code = body.error.code; + message = body.error.message; + } + // Preserve everything except the parsed `error` block so consumers can read + // verify_url, linked_wallets, reasons, etc. for granular denial recovery. + const { error: _omit, ...rest } = body; + details = rest; + } catch { + // Body wasn't JSON or didn't have the expected shape — keep defaults. + } + + if (response.status === 402) return new PaymentRequiredError(message, details); + if (response.status === 401) { + if (code === 'token_expired') return new TokenExpiredError(message, details); + if (code === 'invalid_credential') return new InvalidCredentialError(message, details); + } + if (response.status === 429) { + if (code === 'quota_exceeded') return new QuotaExceededError(message, details); + if (code === 'rate_limited') return new RateLimitedError(message, details); + } + return new AgentScoreError(code, message, response.status, details); +} diff --git a/src/types.ts b/src/types.ts index 1b73f17..ff8a748 100644 --- a/src/types.ts +++ b/src/types.ts @@ -174,6 +174,19 @@ export interface PolicyExplanation { how_to_remedy: string | null; } +/** Per-account assess quota observability, captured from `X-Quota-*` response headers on + * the success path. Fields are `null` when the API didn't include the header (Enterprise + * / unlimited tiers, or when the API is configured without a per-account quota). */ +export interface QuotaInfo { + /** `X-Quota-Limit` — total quota for the current period. */ + limit: number | null; + /** `X-Quota-Used` — current usage within the period. */ + used: number | null; + /** `X-Quota-Reset` — ISO-8601 timestamp when the period resets, or `'never'` for lifetime + * caps. The API emits the literal string `'never'` for tiers without a reset. */ + reset: string | null; +} + export interface AssessResponse { decision: string | null; decision_reasons: string[]; @@ -190,6 +203,9 @@ export interface AssessResponse { on_the_fly: boolean; updated_at: string | null; explanation?: PolicyExplanation[]; + /** Quota state for this account, captured from response headers. Use it to monitor + * approach-to-cap proactively (e.g. warn at 80%, alert at 95%) before hitting a 429. */ + quota?: QuotaInfo; } export interface AgentScoreErrorBody { diff --git a/tests/index.test.ts b/tests/index.test.ts index 399be2c..1044123 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,5 +1,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { AgentScore, AgentScoreError } from '../src/index'; +import { + AgentScore, + AgentScoreError, + InvalidCredentialError, + PaymentRequiredError, + QuotaExceededError, + RateLimitedError, + TimeoutError, + TokenExpiredError, +} from '../src/index'; // --------------------------------------------------------------------------- // Helpers @@ -16,7 +25,7 @@ function mockFetchOk(body: unknown): void { } as unknown as Response); } -function mockFetchError(status: number, errorBody?: { error: { code: string; message: string } }): void { +function mockFetchError(status: number, errorBody?: Record): void { global.fetch = vi.fn().mockResolvedValueOnce({ ok: false, status, @@ -24,6 +33,15 @@ function mockFetchError(status: number, errorBody?: { error: { code: string; mes } as unknown as Response); } +function mockFetchOkWithHeaders(body: unknown, headers: Record): void { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValueOnce(body), + headers: new Headers(headers), + } as unknown as Response); +} + const REPUTATION_RESPONSE = { subject: { chains: ['base'], address: WALLET }, score: { value: 85, grade: 'A', status: 'scored' }, @@ -682,3 +700,187 @@ describe('AgentScore.assess() — operatorToken', () => { expect(callCount).toBe(2); }); }); + +// --------------------------------------------------------------------------- +// Typed errors +// --------------------------------------------------------------------------- + +describe('AgentScore typed errors', () => { + afterEach(() => vi.restoreAllMocks()); + + it('throws PaymentRequiredError on 402 (subclass of AgentScoreError)', async () => { + mockFetchError(402, { error: { code: 'payment_required', message: 'Endpoint not enabled' } }); + const client = new AgentScore({ apiKey: API_KEY }); + try { + await client.assess(WALLET); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(PaymentRequiredError); + expect(e).toBeInstanceOf(AgentScoreError); + const err = e as PaymentRequiredError; + expect(err.code).toBe('payment_required'); + expect(err.status).toBe(402); + } + }); + + it('throws TokenExpiredError on 401 token_expired with parsed body fields exposed on the instance', async () => { + mockFetchError(401, { + error: { code: 'token_expired', message: 'Operator token expired' }, + verify_url: 'https://agentscore.sh/verify/abc', + session_id: 'sess_123', + poll_secret: 'ps_456', + poll_url: 'https://api.agentscore.sh/v1/sessions/sess_123', + next_steps: { action: 'deliver_verify_url_and_poll' }, + agent_memory: { pattern_summary: 'remembered' }, + }); + const client = new AgentScore({ apiKey: API_KEY }); + try { + await client.assess(WALLET); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(TokenExpiredError); + const err = e as TokenExpiredError; + expect(err.code).toBe('token_expired'); + expect(err.status).toBe(401); + expect(err.verifyUrl).toBe('https://agentscore.sh/verify/abc'); + expect(err.sessionId).toBe('sess_123'); + expect(err.pollSecret).toBe('ps_456'); + expect(err.pollUrl).toBe('https://api.agentscore.sh/v1/sessions/sess_123'); + expect(err.nextSteps).toEqual({ action: 'deliver_verify_url_and_poll' }); + } + }); + + it('throws InvalidCredentialError on 401 invalid_credential', async () => { + mockFetchError(401, { error: { code: 'invalid_credential', message: 'Token unknown' } }); + const client = new AgentScore({ apiKey: API_KEY }); + await expect(client.assess(WALLET)).rejects.toBeInstanceOf(InvalidCredentialError); + }); + + it('throws QuotaExceededError on 429 quota_exceeded (after retry still fails)', async () => { + // Both initial + retry mocked as 429 quota_exceeded. + let callCount = 0; + global.fetch = vi.fn().mockImplementation(() => { + callCount += 1; + return Promise.resolve({ + ok: false, + status: 429, + json: () => Promise.resolve({ error: { code: 'quota_exceeded', message: 'Account quota exceeded' } }), + headers: new Headers({ 'retry-after': '0' }), + } as unknown as Response); + }); + const client = new AgentScore({ apiKey: API_KEY }); + try { + await client.assess(WALLET); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(QuotaExceededError); + expect((e as QuotaExceededError).status).toBe(429); + } + expect(callCount).toBe(2); + }); + + it('throws RateLimitedError on 429 rate_limited (after retry still fails)', async () => { + global.fetch = vi.fn().mockImplementation(() => + Promise.resolve({ + ok: false, + status: 429, + json: () => Promise.resolve({ error: { code: 'rate_limited', message: 'Per-second cap hit' } }), + headers: new Headers({ 'retry-after': '0' }), + } as unknown as Response), + ); + const client = new AgentScore({ apiKey: API_KEY }); + await expect(client.assess(WALLET)).rejects.toBeInstanceOf(RateLimitedError); + }); + + it('throws TimeoutError on AbortError (subclass of AgentScoreError)', async () => { + global.fetch = vi.fn().mockImplementation((_url, init: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init.signal as AbortSignal; + signal.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted', 'AbortError')); + }); + }), + ); + const client = new AgentScore({ apiKey: API_KEY, timeout: 10 }); + try { + await client.assess(WALLET); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(TimeoutError); + expect(e).toBeInstanceOf(AgentScoreError); + expect((e as TimeoutError).code).toBe('timeout'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Quota header capture +// --------------------------------------------------------------------------- + +describe('AgentScore.assess() — quota capture', () => { + afterEach(() => vi.restoreAllMocks()); + + it('attaches quota field to AssessResponse when X-Quota-* headers are present', async () => { + mockFetchOkWithHeaders(ASSESS_RESPONSE, { + 'x-quota-limit': '1000', + 'x-quota-used': '780', + 'x-quota-reset': '2026-06-01T00:00:00Z', + }); + const client = new AgentScore({ apiKey: API_KEY }); + const res = await client.assess(WALLET); + expect(res.quota).toEqual({ limit: 1000, used: 780, reset: '2026-06-01T00:00:00Z' }); + }); + + it('omits quota field entirely when no X-Quota-* headers are present', async () => { + mockFetchOkWithHeaders(ASSESS_RESPONSE, {}); + const client = new AgentScore({ apiKey: API_KEY }); + const res = await client.assess(WALLET); + expect(res.quota).toBeUndefined(); + }); + + it('handles "never" reset literal for unlimited tiers', async () => { + mockFetchOkWithHeaders(ASSESS_RESPONSE, { + 'x-quota-limit': '0', + 'x-quota-used': '0', + 'x-quota-reset': 'never', + }); + const client = new AgentScore({ apiKey: API_KEY }); + const res = await client.assess(WALLET); + expect(res.quota).toEqual({ limit: 0, used: 0, reset: 'never' }); + }); + + it('falls back gracefully when headers are absent on the mock response', async () => { + // mockFetchOk produces a Response with no `headers` field at all — extractQuota + // must defend against that without crashing. + mockFetchOk(ASSESS_RESPONSE); + const client = new AgentScore({ apiKey: API_KEY }); + const res = await client.assess(WALLET); + expect(res.quota).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// telemetrySignerMatch +// --------------------------------------------------------------------------- + +describe('AgentScore.telemetrySignerMatch()', () => { + afterEach(() => vi.restoreAllMocks()); + + it('posts to /v1/telemetry/signer-match with the supplied payload', async () => { + mockFetchOk({}); + const client = new AgentScore({ apiKey: API_KEY }); + await client.telemetrySignerMatch({ kind: 'pass', signer: '0xabc', network: 'evm' }); + const fetchCall = (global.fetch as ReturnType).mock.calls[0]; + expect(fetchCall[0]).toContain('/v1/telemetry/signer-match'); + expect(fetchCall[1].method).toBe('POST'); + const body = JSON.parse(fetchCall[1].body as string); + expect(body).toEqual({ kind: 'pass', signer: '0xabc', network: 'evm' }); + }); + + it('swallows errors silently (fire-and-forget)', async () => { + mockFetchError(500, { error: { code: 'internal_error', message: 'oops' } }); + const client = new AgentScore({ apiKey: API_KEY }); + // Should NOT throw. + await expect(client.telemetrySignerMatch({ kind: 'wallet_signer_mismatch' })).resolves.toBeUndefined(); + }); +}); From f358f4335274b3cbdffcf239ce1e02b630e1d12d Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 1 May 2026 07:16:42 -0700 Subject: [PATCH 04/10] docs: README + CLAUDE.md for typed errors + quota observability + telemetrySignerMatch - README: new "Typed error classes" subsection with class table + recovery example; "Quota observability" section showing how to read AssessResponse.quota; "Telemetry" section noting the new fire-and-forget method. - CLAUDE.md: telemetrySignerMatch added to Methods; new "Errors + observability" section enumerating all 6 typed error subclasses + the quota field. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/CLAUDE.md | 7 +++++++ README.md | 52 +++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 34f6842..62bd4f9 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -16,6 +16,13 @@ Two identity paths: `X-Wallet-Address` (wallet-based) and `X-Operator-Token` (cr - `listCredentials()` — list active credentials - `revokeCredential(id)` — revoke a credential - `associateWallet({ operatorToken, walletAddress, network, idempotencyKey? })` — report a signer wallet seen paying under a credential. Fire-and-forget; use the payment intent id / tx hash as `idempotencyKey` so retries don't inflate transaction_count. +- `telemetrySignerMatch(payload)` — fire-and-forget POST to `/v1/telemetry/signer-match`; commerce gate uses this to report `pass` / `wallet_signer_mismatch` / `wallet_auth_requires_wallet_signing` verdicts. + +## Errors + observability + +Typed error subclasses of `AgentScoreError` so callers can branch on `instanceof` without parsing `err.code`: `PaymentRequiredError` (402), `TokenExpiredError` (401 token_expired — exposes parsed `verifyUrl` / `sessionId` / `pollSecret` / `pollUrl` / `nextSteps` / `agentMemory` instance fields), `InvalidCredentialError` (401 invalid_credential), `QuotaExceededError` (429 quota_exceeded — don't retry), `RateLimitedError` (429 rate_limited — retry after Retry-After), `TimeoutError` (request abort/timeout). All preserve the existing `AgentScoreError` catch behavior. + +`assess()` responses include an optional `quota` field captured from `X-Quota-Limit` / `X-Quota-Used` / `X-Quota-Reset` response headers, so callers can monitor approach-to-cap proactively before hitting 429. ## Architecture diff --git a/README.md b/README.md index f69275c..e6ec1a5 100644 --- a/README.md +++ b/README.md @@ -131,23 +131,59 @@ try { } ``` -`AgentScoreError.details` carries the rest of the response body — `verify_url`, `linked_wallets`, `claimed_operator`, `actual_signer`, `expected_signer`, `reasons`, `agent_memory` — so callers can branch on granular denial codes without re-parsing: +`AgentScoreError.details` carries the rest of the response body — `verify_url`, `linked_wallets`, `claimed_operator`, `actual_signer`, `expected_signer`, `reasons`, `agent_memory` — so callers can branch on granular denial codes without re-parsing. + +### Typed error classes + +For status-code-specific recovery, the SDK throws typed subclasses of `AgentScoreError`. All inherit from `AgentScoreError` so existing `catch (err) { if (err instanceof AgentScoreError) ... }` still works. + +| Class | Triggered by | What it adds | +|---|---|---| +| `PaymentRequiredError` | HTTP 402 | The endpoint is not enabled for this account | +| `TokenExpiredError` | HTTP 401 with `error.code = "token_expired"` | Parsed body fields exposed on the instance: `verifyUrl`, `sessionId`, `pollSecret`, `pollUrl`, `nextSteps`, `agentMemory` — recover without re-parsing `details` | +| `InvalidCredentialError` | HTTP 401 with `error.code = "invalid_credential"` | Permanent — switch tokens or restart | +| `QuotaExceededError` | HTTP 429 with `error.code = "quota_exceeded"` | Account-level cap reached; don't retry | +| `RateLimitedError` | HTTP 429 with `error.code = "rate_limited"` | Per-second sliding-window cap; retry after `Retry-After` | +| `TimeoutError` | Request aborted before a response arrived | Distinct from generic network errors | ```typescript +import { + AgentScore, AgentScoreError, TokenExpiredError, QuotaExceededError, TimeoutError, +} from "@agent-score/sdk"; + try { await client.assess("0xabc...", { policy: { require_kyc: true } }); } catch (err) { - if (!(err instanceof AgentScoreError)) throw err; - if (err.code === "wallet_signer_mismatch") { - const linked = err.details.linked_wallets as string[] | undefined; - console.log("Re-sign from one of:", linked); - } - if (err.code === "token_expired") { - console.log("Verify at:", err.details.verify_url); + if (err instanceof TokenExpiredError) { + console.log("Verify at:", err.verifyUrl, "poll with:", err.pollSecret); + } else if (err instanceof QuotaExceededError) { + console.log("Account quota reached — surface to user; don't retry."); + } else if (err instanceof TimeoutError) { + console.log("Network timeout — retry with backoff."); + } else if (err instanceof AgentScoreError) { + console.error(err.code, err.message); } } ``` +## Quota observability + +`assess()` responses include an optional `quota` field captured from `X-Quota-Limit` / `X-Quota-Used` / `X-Quota-Reset` response headers. Use it to monitor approach-to-cap proactively (warn at 80%, alert at 95%) before a 429: + +```typescript +const result = await client.assess("0xabc...", { policy: { require_kyc: true } }); +if (result.quota && result.quota.limit && result.quota.used) { + const pct = (result.quota.used / result.quota.limit) * 100; + if (pct > 80) console.warn(`AgentScore quota at ${pct.toFixed(1)}% — resets ${result.quota.reset}`); +} +``` + +`quota` is `undefined` when the API doesn't emit the headers (Enterprise / unlimited tiers). + +## Telemetry + +`telemetrySignerMatch(payload)` is a fire-and-forget POST to `/v1/telemetry/signer-match` so AgentScore can track aggregate signer-binding behavior across merchants. Used internally by `@agent-score/commerce`'s gate; available directly for custom integrations that perform their own wallet-signer-match checks. + ## Documentation - [API Reference](https://docs.agentscore.sh) From 58955fe292e7077eb3cba5acb29ed272a733c166 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 1 May 2026 07:21:15 -0700 Subject: [PATCH 05/10] fix: parseQuotaNumber strict-integer parity with python-sdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-pass review caught: Number('') is 0 (truthy, finite) while Python int('') raises ValueError. Same for decimals — Number('1.5') is 1.5; int('1.5') raises. A misconfigured proxy or test sending malformed X-Quota-* headers would surface 0 / 1.5 in node but null in python. Replace `Number(raw)` + `isFinite` with regex `^-?\\d+$` test, matching int()'s strict integer-only behavior. Empty strings, decimals, scientific notation, and non-numeric strings now all return null in both SDKs. Test added for empty + decimal headers (returns null) — parity with python-sdk's existing test_extract_quota_falls_back_to_none_for_malformed_numeric_headers. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.ts | 8 ++++++-- tests/index.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index a28e775..e524a8b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -276,8 +276,12 @@ function extractQuota(headers: Headers | undefined): QuotaInfo | undefined { function parseQuotaNumber(raw: string | null): number | null { if (raw === null) return null; - const n = Number(raw); - return Number.isFinite(n) ? n : null; + // Strict integer check — match Python int()'s behavior so the same malformed header + // produces the same `null` in both SDKs (no silent 0 from `Number("")` or float-truncation + // from `parseInt("1.5", 10)`). + const trimmed = raw.trim(); + if (!/^-?\d+$/.test(trimmed)) return null; + return Number(trimmed); } /** Map a non-2xx Response to the right typed AgentScoreError subclass. Reads the body to diff --git a/tests/index.test.ts b/tests/index.test.ts index 1044123..db3749d 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -857,6 +857,17 @@ describe('AgentScore.assess() — quota capture', () => { const res = await client.assess(WALLET); expect(res.quota).toBeUndefined(); }); + + it('returns null for malformed numeric headers (empty / decimal / non-integer) — parity with python-sdk', async () => { + mockFetchOkWithHeaders(ASSESS_RESPONSE, { + 'x-quota-limit': '', // empty — Number('') would be 0 without strict check + 'x-quota-used': '1.5', // decimal — Number('1.5') is finite but not an integer + 'x-quota-reset': '2026-06-01T00:00:00Z', + }); + const client = new AgentScore({ apiKey: API_KEY }); + const res = await client.assess(WALLET); + expect(res.quota).toEqual({ limit: null, used: null, reset: '2026-06-01T00:00:00Z' }); + }); }); // --------------------------------------------------------------------------- From 926d0e750535750ce59f50978b9f03862f296052 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 1 May 2026 07:38:18 -0700 Subject: [PATCH 06/10] chore: bump to v2.2.0 Captures TEC-274 minor: new public exports (PaymentRequiredError, TokenExpiredError, InvalidCredentialError, QuotaExceededError, RateLimitedError, TimeoutError) + AssessResponse.quota field + telemetrySignerMatch method. Skips intermediate 2.1.2 (docstring-only commit on this branch was unreleased). Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e0781aa..10e3d3e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/sdk", - "version": "2.1.2", + "version": "2.2.0", "description": "TypeScript client for the AgentScore APIs", "main": "./dist/index.cjs", "module": "./dist/index.js", From 17cf9c2f37a7a4719a3b7d00ba0b0e8f7ca6aafb Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 1 May 2026 07:57:01 -0700 Subject: [PATCH 07/10] chore(tests): drop "(free tier)" qualifier from 402 test description Pre-existing line, not introduced by TEC-274 work, but caught while sweeping node-sdk tests for tier-language under feedback_no_internal_disclosure_in_public. Tests are part of the public-package surface; "(free tier)" leaks our tier structure. Replaced with the actual error semantics ("endpoint not enabled"). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/sessions-credentials.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/sessions-credentials.test.ts b/tests/sessions-credentials.test.ts index e81d753..720fb89 100644 --- a/tests/sessions-credentials.test.ts +++ b/tests/sessions-credentials.test.ts @@ -496,8 +496,8 @@ describe('AgentScore.associateWallet()', () => { } }); - it('throws AgentScoreError on 402 payment_required (free tier)', async () => { - mockFetchError(402, { error: { code: 'payment_required', message: 'paid only' } }); + it('throws AgentScoreError on 402 payment_required', async () => { + mockFetchError(402, { error: { code: 'payment_required', message: 'endpoint not enabled' } }); const client = new AgentScore({ apiKey: API_KEY }); await expect(client.associateWallet(ASSOCIATE_OPTIONS)).rejects.toBeInstanceOf(AgentScoreError); }); From 1c3f4da672d5f73ba52210fad923216367ac565b Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 1 May 2026 08:03:14 -0700 Subject: [PATCH 08/10] test+docs: tighten parseQuotaNumber comment + add edge-case coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review (parallel agents) found three test gaps + one misleading comment: - parseQuotaNumber comment claimed Python int() doesn't trim whitespace / reject decimals — both wrong; Python int() does both. Reworded to describe Number() + parseInt() pitfalls (the actual reason for the regex), not Python diff. Added tests: - 429 → 200 retry path captures quota headers from the RETRY response (not the discarded original 429). Regression guard for requestWithHeaders retry logic. - Generic 4xx fallthrough — 400 invalid_request and 403 account_cancelled both fall through to generic AgentScoreError (not any typed subclass). Verifies the discriminator only fires for the documented status+code pairs. - TokenExpiredError with empty body — all parsed-body fields stay undefined, instance is still TokenExpiredError (not generic AgentScoreError fallthrough). - TokenExpiredError with wrong-typed body fields — the typeof string check silently ignores number/array values; raw values still flow through `details`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.ts | 7 +-- tests/index.test.ts | 121 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index e524a8b..6597df2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -276,9 +276,10 @@ function extractQuota(headers: Headers | undefined): QuotaInfo | undefined { function parseQuotaNumber(raw: string | null): number | null { if (raw === null) return null; - // Strict integer check — match Python int()'s behavior so the same malformed header - // produces the same `null` in both SDKs (no silent 0 from `Number("")` or float-truncation - // from `parseInt("1.5", 10)`). + // Strict integer-only — `Number('')` would silently return 0 and `parseInt('1.5', 10)` + // would truncate to 1; both are wrong for malformed headers. Use a regex on trimmed + // input so empty / decimal / scientific / alpha all return null. This matches the + // behavior of Python's int() (which trims whitespace and rejects non-integer strings). const trimmed = raw.trim(); if (!/^-?\d+$/.test(trimmed)) return null; return Number(trimmed); diff --git a/tests/index.test.ts b/tests/index.test.ts index db3749d..c1a3a19 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -868,6 +868,127 @@ describe('AgentScore.assess() — quota capture', () => { const res = await client.assess(WALLET); expect(res.quota).toEqual({ limit: null, used: null, reset: '2026-06-01T00:00:00Z' }); }); + + it('captures quota headers from the retry response on 429 → 200 (not the discarded original)', async () => { + let callCount = 0; + global.fetch = vi.fn().mockImplementation(() => { + callCount += 1; + if (callCount === 1) { + return Promise.resolve({ + ok: false, + status: 429, + json: () => Promise.resolve({}), + headers: new Headers({ 'retry-after': '0' }), + } as unknown as Response); + } + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(ASSESS_RESPONSE), + headers: new Headers({ + 'x-quota-limit': '500', + 'x-quota-used': '321', + 'x-quota-reset': '2026-07-01T00:00:00Z', + }), + } as unknown as Response); + }); + const client = new AgentScore({ apiKey: API_KEY }); + const res = await client.assess(WALLET); + expect(callCount).toBe(2); + expect(res.quota).toEqual({ limit: 500, used: 321, reset: '2026-07-01T00:00:00Z' }); + }); +}); + +// --------------------------------------------------------------------------- +// Generic 4xx fallthrough — codes the SDK doesn't have a typed subclass for +// --------------------------------------------------------------------------- + +describe('AgentScore — generic 4xx fallthrough', () => { + afterEach(() => vi.restoreAllMocks()); + + it('400 invalid_request falls through to generic AgentScoreError (not a typed subclass)', async () => { + mockFetchError(400, { error: { code: 'invalid_request', message: 'bad body' } }); + const client = new AgentScore({ apiKey: API_KEY }); + try { + await client.assess(WALLET); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(AgentScoreError); + // Must NOT be any typed subclass + expect(e).not.toBeInstanceOf(PaymentRequiredError); + expect(e).not.toBeInstanceOf(TokenExpiredError); + expect(e).not.toBeInstanceOf(InvalidCredentialError); + expect(e).not.toBeInstanceOf(QuotaExceededError); + expect(e).not.toBeInstanceOf(RateLimitedError); + expect(e).not.toBeInstanceOf(TimeoutError); + const err = e as AgentScoreError; + expect(err.code).toBe('invalid_request'); + expect(err.status).toBe(400); + } + }); + + it('403 account_cancelled falls through to generic AgentScoreError', async () => { + mockFetchError(403, { error: { code: 'account_cancelled', message: 'cancelled' } }); + const client = new AgentScore({ apiKey: API_KEY }); + try { + await client.assess(WALLET); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(AgentScoreError); + expect(e).not.toBeInstanceOf(PaymentRequiredError); + const err = e as AgentScoreError; + expect(err.code).toBe('account_cancelled'); + expect(err.status).toBe(403); + } + }); +}); + +// --------------------------------------------------------------------------- +// TokenExpiredError — body-field edge cases +// --------------------------------------------------------------------------- + +describe('TokenExpiredError — body-field edge cases', () => { + afterEach(() => vi.restoreAllMocks()); + + it('all parsed-body fields stay undefined when API returns 401 token_expired with no body extras', async () => { + mockFetchError(401, { error: { code: 'token_expired', message: 'Expired' } }); + const client = new AgentScore({ apiKey: API_KEY }); + try { + await client.assess(WALLET); + expect.unreachable('should have thrown'); + } catch (e) { + // Still a TokenExpiredError (not falling through to generic) + expect(e).toBeInstanceOf(TokenExpiredError); + const err = e as TokenExpiredError; + expect(err.verifyUrl).toBeUndefined(); + expect(err.sessionId).toBeUndefined(); + expect(err.pollSecret).toBeUndefined(); + expect(err.pollUrl).toBeUndefined(); + expect(err.nextSteps).toBeUndefined(); + expect(err.agentMemory).toBeUndefined(); + } + }); + + it('TokenExpiredError fields silently ignored when API returns wrong types (e.g. number for verify_url)', async () => { + mockFetchError(401, { + error: { code: 'token_expired', message: 'Expired' }, + verify_url: 12345, // wrong type + session_id: ['not', 'a', 'string'], // wrong type + }); + const client = new AgentScore({ apiKey: API_KEY }); + try { + await client.assess(WALLET); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(TokenExpiredError); + const err = e as TokenExpiredError; + // Strings only — wrong types ignored, instance fields stay undefined. + expect(err.verifyUrl).toBeUndefined(); + expect(err.sessionId).toBeUndefined(); + // The original body still flows through `details` so callers can inspect raw values. + expect(err.details.verify_url).toBe(12345); + } + }); }); // --------------------------------------------------------------------------- From eb8f9ec61902fada797bf829ab383a22fa844791 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 1 May 2026 08:27:32 -0700 Subject: [PATCH 09/10] test(telemetry): assert Content-Type: application/json on POST /v1/telemetry/signer-match Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/index.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/index.test.ts b/tests/index.test.ts index c1a3a19..c506c44 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -998,13 +998,14 @@ describe('TokenExpiredError — body-field edge cases', () => { describe('AgentScore.telemetrySignerMatch()', () => { afterEach(() => vi.restoreAllMocks()); - it('posts to /v1/telemetry/signer-match with the supplied payload', async () => { + it('posts to /v1/telemetry/signer-match with the supplied payload + Content-Type header', async () => { mockFetchOk({}); const client = new AgentScore({ apiKey: API_KEY }); await client.telemetrySignerMatch({ kind: 'pass', signer: '0xabc', network: 'evm' }); const fetchCall = (global.fetch as ReturnType).mock.calls[0]; expect(fetchCall[0]).toContain('/v1/telemetry/signer-match'); expect(fetchCall[1].method).toBe('POST'); + expect((fetchCall[1].headers as Record)['Content-Type']).toBe('application/json'); const body = JSON.parse(fetchCall[1].body as string); expect(body).toEqual({ kind: 'pass', signer: '0xabc', network: 'evm' }); }); From b9ea452313929ceed9fcd096e9412c5f8556a9e7 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 1 May 2026 12:28:28 -0700 Subject: [PATCH 10/10] feat: ResolveSigner + SignerMatch types; thread resolve_signer through assess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ResolveSigner request type and SignerMatch response type matching the API's new server-side wallet-signer-match. assess() and the AssessOptions shape gain an optional resolveSigner field; AssessResponse gains optional signer_match. Drop vestigial on_the_fly + updated_at fields from AssessResponse — they were never read by any caller. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/CLAUDE.md | 2 +- src/index.ts | 11 ++++++++-- src/types.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 62bd4f9..0021e74 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -9,7 +9,7 @@ Two identity paths: `X-Wallet-Address` (wallet-based) and `X-Operator-Token` (cr ## Methods - `getReputation(address, options?)` — cached reputation lookup (free) -- `assess(address, options?)` — identity gate with policy (paid). Accepts `operatorToken` for non-wallet agents. Response includes `linked_wallets[]` and `resolved_operator`. +- `assess(address, options?)` — identity gate with policy (paid). Accepts `operatorToken` for non-wallet agents. Response includes `linked_wallets[]` and `resolved_operator`. Optional `resolveSigner: { address, network }` opts into server-side wallet-signer-match — the response then carries a `signer_match` block describing whether the supplied signer wallet resolves to the same operator as the claimed `address`. - `createSession(options?)` — create verification session for identity bootstrapping. Returns `agent_memory` + `next_steps`. - `pollSession(sessionId, pollSecret)` — poll session status, returns credential when verified, plus `next_steps.action`. - `createCredential(options?)` — create operator credential (24h TTL default). Response includes `agent_memory`. diff --git a/src/index.ts b/src/index.ts index 6597df2..e3b8aa4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,6 +80,7 @@ export class AgentScore { if (options?.chain) body.chain = options.chain; if (options?.refresh !== undefined) body.refresh = options.refresh; if (options?.policy) body.policy = options.policy; + if (options?.resolveSigner) body.resolve_signer = options.resolveSigner; const { data, headers } = await this.requestWithHeaders('/v1/assess', { method: 'POST', @@ -173,7 +174,7 @@ export class AgentScore { claimed_wallet?: string; signer?: string | null; network?: 'evm' | 'solana'; - kind: 'pass' | 'wallet_signer_mismatch' | 'wallet_auth_requires_wallet_signing'; + kind: 'pass' | 'wallet_signer_mismatch' | 'wallet_auth_requires_wallet_signing' | 'api_error'; [key: string]: unknown; }): Promise { try { @@ -247,7 +248,13 @@ export class AgentScore { } catch (err) { if (err instanceof AgentScoreError) throw err; const message = err instanceof Error ? err.message : 'Unknown error'; - if (signal.aborted) throw new TimeoutError(message); + // Detect timeouts via either: (a) the AbortSignal we attached fired (real timeout), + // (b) the thrown Error.name is 'AbortError' or 'TimeoutError' (some fetch impls / + // test mocks throw with these names directly without firing the signal). + const errName = err instanceof Error ? err.name : ''; + if (signal.aborted || errName === 'AbortError' || errName === 'TimeoutError') { + throw new TimeoutError(message); + } throw new AgentScoreError('network_error', message, 0); } finally { clearTimeout(timer); diff --git a/src/types.ts b/src/types.ts index ff8a748..652a731 100644 --- a/src/types.ts +++ b/src/types.ts @@ -146,11 +146,56 @@ export interface DecisionPolicy { allowed_jurisdictions?: string[]; } +/** Server-side wallet-signer-match request. When present in `AssessRequest.resolve_signer`, + * the API resolves this wallet against the claimed `address` and emits a `signer_match` + * block on the response. Lets commerce gates collapse the legacy 2 follow-up assess + * calls (one per wallet) into the gate's primary assess call. Strictly additive — old + * clients that don't send this field see no `signer_match` on the response. */ +export interface ResolveSigner { + /** Recovered payment-signer wallet. `null` indicates the rail carries no wallet + * signature (Stripe SPT, card) — produces `signer_match.kind = "wallet_auth_requires_wallet_signing"`. */ + address: string | null; + /** Key-derivation family of the signer wallet. */ + network: 'evm' | 'solana'; +} + +/** Server-side wallet-signer-match verdict. Emitted on `AssessResponse.signer_match` when + * the request supplied `resolve_signer`. Mirrors the verdict shape commerce SDK gates + * produce locally; SDK consumers spread this into 403 bodies verbatim instead of + * re-deriving via 2 extra `/v1/assess` round trips. */ +export interface SignerMatch { + /** `pass` — claimed wallet and signer wallet resolve to the same operator (or are + * byte-equal). `wallet_signer_mismatch` — operators differ. + * `wallet_auth_requires_wallet_signing` — request supplied `address: null` (rail has + * no wallet signer); agent should switch to operator_token auth. */ + kind: 'pass' | 'wallet_signer_mismatch' | 'wallet_auth_requires_wallet_signing'; + /** Operator the claimed wallet resolves to. `null` if unlinked. */ + claimed_operator?: string | null; + /** Operator the signer wallet resolves to. `null` if unlinked. */ + signer_operator?: string | null; + /** Echoed only on `wallet_auth_requires_wallet_signing` — the claimed wallet from the + * request. Helps agents construct the recovery message. */ + claimed_wallet?: string; + /** Echoed on `wallet_signer_mismatch` — the claimed wallet, normalized. */ + expected_signer?: string; + /** Echoed on `wallet_signer_mismatch` — the signer wallet, normalized. */ + actual_signer?: string; + /** Same-operator linked wallets the agent could re-sign from to satisfy the claim. + * Mirrors the top-level `linked_wallets` deny-guard — omitted on `deny` verdicts. */ + linked_wallets?: string[]; + /** JSON-encoded `{action, steps, user_message}` envelope for SDK denial bodies. + * Authoritative copy lives server-side; SDK consumers spread this into their 403 + * body without re-parsing. */ + agent_instructions?: string; +} + export interface AssessRequest { address: string; chain?: string; refresh?: boolean; policy?: DecisionPolicy; + /** Optional server-side wallet-signer-match. See {@link ResolveSigner}. */ + resolve_signer?: ResolveSigner; } export interface PolicyCheck { @@ -200,9 +245,10 @@ export interface AssessResponse { linked_wallets?: string[]; verify_url?: string; policy_result?: PolicyResult | null; - on_the_fly: boolean; - updated_at: string | null; explanation?: PolicyExplanation[]; + /** Server-side wallet-signer-match verdict, returned only when the request supplied + * `resolve_signer`. Empty otherwise. */ + signer_match?: SignerMatch; /** Quota state for this account, captured from response headers. Use it to monitor * approach-to-cap proactively (e.g. warn at 80%, alert at 95%) before hitting a 429. */ quota?: QuotaInfo; @@ -389,6 +435,9 @@ export interface AssessOptions { refresh?: boolean; policy?: DecisionPolicy; operatorToken?: string; + /** Optional server-side wallet-signer-match. Lets commerce gates collapse the legacy + * 2 follow-up assess calls into the gate's primary assess call. See {@link ResolveSigner}. */ + resolveSigner?: ResolveSigner; } export interface SessionCreateOptions {