diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 34f6842..0021e74 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -9,13 +9,20 @@ 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`. - `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) diff --git a/package.json b/package.json index 5e7d556..10e3d3e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/sdk", - "version": "2.1.1", + "version": "2.2.0", "description": "TypeScript client for the AgentScore APIs", "main": "./dist/index.cjs", "module": "./dist/index.js", 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 c0fd353..e3b8aa4 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'; @@ -63,12 +80,15 @@ 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; - 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 { @@ -118,8 +138,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. */ @@ -146,7 +166,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' | 'api_error'; + [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 +228,100 @@ 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); + // 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); } } } + +/** 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; + // 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); +} + +/** 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..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 { @@ -174,6 +219,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[]; @@ -187,9 +245,13 @@ 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; } export interface AgentScoreErrorBody { @@ -373,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 { diff --git a/tests/index.test.ts b/tests/index.test.ts index 399be2c..c506c44 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,320 @@ 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(); + }); + + 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' }); + }); + + 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); + } + }); +}); + +// --------------------------------------------------------------------------- +// telemetrySignerMatch +// --------------------------------------------------------------------------- + +describe('AgentScore.telemetrySignerMatch()', () => { + afterEach(() => vi.restoreAllMocks()); + + 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' }); + }); + + 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(); + }); +}); 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); });