@zadq/x402 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/keys.js CHANGED
@@ -5,36 +5,54 @@
5
5
  // Graceful degradation, by design: an unreachable service resolves to
6
6
  // `undefined` — never an exception. Without a key, a signature is simply
7
7
  // "unverifiable" and your integration keeps working as before.
8
- import { DEFAULT_TIMEOUT_MS } from "./verdict.js";
8
+ import { freePathRequest } from "./verdict.js";
9
9
  /**
10
10
  * Fetches the published verification keys. Resolves to `undefined` — never
11
11
  * rejects — on timeout, network failure, a non-OK response or a malformed body.
12
12
  */
13
13
  export async function fetchVerificationKeys(baseUrl, options) {
14
14
  try {
15
- const res = await fetch(new URL('/v0/verification-keys', baseUrl), {
16
- signal: AbortSignal.timeout(options?.timeoutMs ?? DEFAULT_TIMEOUT_MS),
17
- });
15
+ const res = await fetch(new URL('/v0/verification-keys', baseUrl), freePathRequest(options?.timeoutMs, options?.client));
18
16
  if (!res.ok) {
19
17
  return undefined;
20
18
  }
21
19
  const body = (await res.json());
22
- if (!Array.isArray(body.keys)) {
20
+ if (body === null || typeof body !== 'object' || !Array.isArray(body.keys)) {
23
21
  return undefined;
24
22
  }
25
- return body.keys.filter(isVerificationKey);
23
+ return body.keys.filter(isVerificationKey).map(project);
26
24
  }
27
25
  catch {
28
26
  return undefined;
29
27
  }
30
28
  }
31
- /** Picks the key a verdict was signed with, by `keyId`. */
29
+ /**
30
+ * Picks the key a verdict was signed with, by `keyId` — never by position:
31
+ * after a rotation the `previous` key is still the right one for a verdict
32
+ * signed before it.
33
+ */
32
34
  export function findKey(keys, keyId) {
33
35
  if (keys === undefined || keyId === undefined) {
34
36
  return undefined;
35
37
  }
36
38
  return keys.find((k) => k.keyId === keyId);
37
39
  }
40
+ /**
41
+ * The published key a verdict names (`verdict.keyId`), as its base64 public
42
+ * key — `undefined` when the verdict names none or a key that is not
43
+ * published. A positive claim that names an unpublished key cannot be
44
+ * checked against the published set: a consumer that holds the set treats it
45
+ * as `invalid` (the explorer client does).
46
+ */
47
+ export function keyFor(verdict, keys) {
48
+ return findKey(keys, verdict?.keyId)?.publicKey;
49
+ }
50
+ function project(k) {
51
+ const out = { keyId: k.keyId, algorithm: 'Ed25519', publicKey: k.publicKey };
52
+ if (k.status === 'current' || k.status === 'previous')
53
+ out.status = k.status;
54
+ return out;
55
+ }
38
56
  function isVerificationKey(value) {
39
57
  if (typeof value !== 'object' || value === null)
40
58
  return false;
package/dist/receipt.d.ts CHANGED
@@ -43,8 +43,13 @@ export declare function verifyAttestationReceipt(receipt: AttestationReceipt, is
43
43
  /** The exact bytes the issuer signed (domain-separated digest of the canonical payload). */
44
44
  export declare function receiptSigningInput(receipt: AttestationReceipt): Promise<Uint8Array<ArrayBuffer>>;
45
45
  /**
46
- * Canonical JSON as the issuer produces it: keys sorted by code point, no
47
- * whitespace, every non-ASCII code unit escaped as lowercase `\uXXXX`
48
- * (the form Python's json.dumps emits with sort_keys and compact separators).
46
+ * Canonical JSON as the issuer produces it — Python's
47
+ * `json.dumps(sort_keys=True, separators=(',', ':'), ensure_ascii=True)`:
48
+ * keys sorted by code point, no whitespace, integers as decimal (a
49
+ * non-integer number throws), strings with `"` and `\` escaped, `\b \f \n
50
+ * \r \t`, other control characters as `\u00xx`, and every character outside
51
+ * U+0020–U+007E — U+007F included — as lowercase `\uXXXX` per UTF-16 code
52
+ * unit (a non-BMP character as its surrogate pair). `<`, `>` and `&` stay raw.
53
+ * Any change to these bytes is a major version of the package.
49
54
  */
50
55
  export declare function canonicalJson(value: Record<string, string | number>): string;
package/dist/receipt.js CHANGED
@@ -141,9 +141,14 @@ export async function receiptSigningInput(receipt) {
141
141
  return out;
142
142
  }
143
143
  /**
144
- * Canonical JSON as the issuer produces it: keys sorted by code point, no
145
- * whitespace, every non-ASCII code unit escaped as lowercase `\uXXXX`
146
- * (the form Python's json.dumps emits with sort_keys and compact separators).
144
+ * Canonical JSON as the issuer produces it — Python's
145
+ * `json.dumps(sort_keys=True, separators=(',', ':'), ensure_ascii=True)`:
146
+ * keys sorted by code point, no whitespace, integers as decimal (a
147
+ * non-integer number throws), strings with `"` and `\` escaped, `\b \f \n
148
+ * \r \t`, other control characters as `\u00xx`, and every character outside
149
+ * U+0020–U+007E — U+007F included — as lowercase `\uXXXX` per UTF-16 code
150
+ * unit (a non-BMP character as its surrogate pair). `<`, `>` and `&` stay raw.
151
+ * Any change to these bytes is a major version of the package.
147
152
  */
148
153
  export function canonicalJson(value) {
149
154
  const keys = Object.keys(value).sort();
@@ -161,8 +166,9 @@ function jsonScalar(v) {
161
166
  }
162
167
  function jsonString(s) {
163
168
  const escaped = JSON.stringify(s);
164
- // JSON.stringify leaves non-ASCII as-is; the issuer escapes it.
165
- return escaped.replace(/[\u0080-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
169
+ // JSON.stringify leaves DEL and non-ASCII as-is (lone surrogates aside); the
170
+ // issuer escapes every code unit from U+007F up.
171
+ return escaped.replace(/[\u007f-\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
166
172
  }
167
173
  function hexDecode(hex, length) {
168
174
  if (!/^[0-9a-f]*$/.test(hex) || hex.length !== length * 2) {
package/dist/verdict.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ClientDeclaration } from './client.js';
1
2
  export type Chain = 'base' | 'solana';
2
3
  /** The declared rails, in the order the contract lists them. */
3
4
  export declare const CHAINS: readonly Chain[];
@@ -5,12 +6,18 @@ export declare const CHAINS: readonly Chain[];
5
6
  export declare function isChain(value: unknown): value is Chain;
6
7
  /** Verdict outcomes, mirroring the public API contract vocabulary. */
7
8
  export type VerdictOutcome = 'human_backed' | 'revoked' | 'stale' | 'no_signal';
9
+ /** The outcome vocabulary as a runtime list (closed: anything else is not a verdict). */
10
+ export declare const VERDICT_OUTCOMES: readonly VerdictOutcome[];
8
11
  /**
9
12
  * Why a verdict is not `human_backed`: the standing read's closed vocabulary
10
13
  * (MintID's, restated — never a flag of the service's own), or `suspended`
11
- * when the witnessed status is suspended. Never present on `human_backed`.
14
+ * when the witnessed status is suspended. Never present on `human_backed`,
15
+ * and never required anywhere: `revoked` carries none (the status is the
16
+ * reason), nor does a `no_signal` withheld by the deposit gate.
12
17
  */
13
18
  export type VerdictReason = 'state_unavailable' | 'issuer_unknown' | 'issuer_not_active' | 'status_root_stale' | 'height_stale' | 'witness_unavailable' | 'witness_malformed' | 'witness_invalid' | 'witness_root_mismatch' | 'bond_insufficient' | 'suspended';
19
+ /** The reason vocabulary as a runtime list. */
20
+ export declare const VERDICT_REASONS: readonly VerdictReason[];
14
21
  /** The x402 endpoint a verdict speaks about: chain-qualified payTo + resource URL. */
15
22
  export type VerdictSubject = {
16
23
  chain: Chain;
@@ -43,7 +50,11 @@ export type Verdict = {
43
50
  signature?: string;
44
51
  /** Identifies the verdict-signing key (first 8 bytes of the public key's SHA-256, hex). */
45
52
  keyId?: string;
46
- /** Present on every non-positive outcome, from the closed vocabulary; absent on `human_backed`. */
53
+ /**
54
+ * Why the outcome is not positive, from the closed vocabulary — optional on
55
+ * every outcome: absent on `human_backed`, on `revoked` and on a `no_signal`
56
+ * withheld by the deposit gate. A missing reason is never malformed.
57
+ */
47
58
  reason?: VerdictReason;
48
59
  /**
49
60
  * The verifying party's own statements beside the canonical verdict, keyed
@@ -78,16 +89,44 @@ export type DepositDeclaration = {
78
89
  * verdict alone). Malformed content reads as none — nothing throws.
79
90
  */
80
91
  export declare function readDepositDeclaration(verdict: Verdict | undefined | null): DepositDeclaration | null;
92
+ /**
93
+ * Structural check of a verdict as the free path serves it: an object with an
94
+ * `outcome` from the closed vocabulary, a `subject` of the declared rails with
95
+ * string `payTo` and `resourceUrl`, an integer `recheckEveryHours`, and the
96
+ * optional fields (`assuranceLevel`, `verifiedAt`, `signature`, `keyId`,
97
+ * `reason`) strings when present. `reason` is never required, and unknown keys
98
+ * (top level or under `extensions`) are ignored, never fatal. Anything else —
99
+ * `null`, an array, a scalar, a missing subject — is not a verdict: treat it
100
+ * exactly like no verdict ("unknown").
101
+ */
102
+ export declare function isVerdict(value: unknown): value is Verdict;
103
+ /**
104
+ * Whether a served subject is the one that was asked about: `chain` and
105
+ * `resourceUrl` compared exactly, `payTo` in canonical form (case-insensitive
106
+ * on `base`, where EVM addresses are kept lowercase; exact on `solana`).
107
+ */
108
+ export declare function subjectMatches(requested: VerdictSubject, served: VerdictSubject): boolean;
81
109
  export declare const DEFAULT_TIMEOUT_MS = 3000;
110
+ /**
111
+ * The one place the free path's requests are built (verdicts, keys, the
112
+ * badge): anonymous — no credentials, no cookies — and bounded by the
113
+ * timeout. The client-declaring User-Agent, where the runtime allows one, is
114
+ * added here and nowhere else.
115
+ */
116
+ export declare function freePathRequest(timeoutMs?: number, client?: ClientDeclaration): RequestInit;
82
117
  /**
83
118
  * Fetches the current verdict for an endpoint from the free verification path.
84
119
  *
85
120
  * Degradation path: resolves to `undefined` — never rejects — on timeout,
86
- * network failure or a non-OK response. Treat `undefined` exactly like a
121
+ * network failure, a non-OK response, a body that is not JSON, a 2xx body
122
+ * that is not a structurally valid verdict (`isVerdict`), or a verdict about
123
+ * another subject (`subjectMatches`). Treat `undefined` exactly like a
87
124
  * `no_signal` outcome: show "unknown" and carry on.
88
125
  */
89
126
  export declare function fetchVerdict(baseUrl: string, subject: VerdictSubject, options?: {
90
127
  timeoutMs?: number;
128
+ /** Which of the service's clients is calling (default: this library). See client.ts. */
129
+ client?: ClientDeclaration;
91
130
  }): Promise<Verdict | undefined>;
92
131
  /** Freshness against the cadence promise the verdict itself carries. */
93
132
  export type Freshness = 'fresh' | 'overdue' | 'unknown';
@@ -100,21 +139,27 @@ export type Freshness = 'fresh' | 'overdue' | 'unknown';
100
139
  export declare function freshness(verdict: Verdict, now?: Date): Freshness;
101
140
  /**
102
141
  * Renders the canonical signed statement byte-for-byte as the service signs
103
- * it: fixed key order, RFC 3339 UTC at second precision, and Go-style JSON
104
- * escaping of `<`, `>` and `&`. Returns `undefined` when the verdict lacks
105
- * the fields a signed (positive) claim always carries.
142
+ * it (Go `encoding/json` of a flat struct): fixed key order `chain`, `payTo`,
143
+ * `resourceUrl`, `outcome`, `assuranceLevel`, `verifiedAt`,
144
+ * `recheckEveryHours`; no whitespace; UTF-8 with non-ASCII raw; `<`, `>`, `&`,
145
+ * U+2028 and U+2029 escaped as Go does; `verifiedAt` in UTC truncated to whole
146
+ * seconds. Returns `undefined` ("cannot build") when the verdict lacks the
147
+ * fields a signed (positive) claim always carries, or they are ill-typed.
148
+ * Any change to these bytes is a major version of the package.
106
149
  */
107
150
  export declare function canonicalVerdictPayload(verdict: Verdict): Uint8Array<ArrayBuffer> | undefined;
108
151
  /** Outcome of a local signature check. */
109
152
  export type SignatureCheck = 'valid' | 'invalid' | 'unverifiable';
110
153
  /**
111
154
  * Verifies the verdict's Ed25519 signature locally against a known public key
112
- * (base64, 32 raw bytes) — no round-trip involved.
155
+ * (base64, 32 raw bytes) — no round-trip involved. Never throws or rejects,
156
+ * whatever the verdict's shape.
113
157
  *
114
158
  * Degradation path: `unverifiable` (missing signature, or no Ed25519 support
115
159
  * in the runtime's WebCrypto) is not a negative finding — display the verdict
116
160
  * as usual, or fall back to "unknown" if your policy prefers only locally
117
- * checked claims. `invalid` means the bytes do not match the key: treat as
161
+ * checked claims. `invalid` means the bytes, the base64 or the key do not
162
+ * match (a signature whose signed bytes cannot be built included): treat as
118
163
  * "unknown".
119
164
  */
120
165
  export declare function verifyVerdictSignature(verdict: Verdict, publicKeyBase64: string): Promise<SignatureCheck>;
package/dist/verdict.js CHANGED
@@ -18,12 +18,34 @@
18
18
  * type is the same closed union, not a string.
19
19
  */
20
20
  import { VERDICT_EXTENSION_KEY } from "./brand.js";
21
+ import { SDK_CLIENT, clientHeaders } from "./client.js";
21
22
  /** The declared rails, in the order the contract lists them. */
22
23
  export const CHAINS = ['base', 'solana'];
23
24
  /** Runtime guard for the `Chain` enumeration (input from a wire, an agent or an env var). */
24
25
  export function isChain(value) {
25
26
  return typeof value === 'string' && CHAINS.includes(value);
26
27
  }
28
+ /** The outcome vocabulary as a runtime list (closed: anything else is not a verdict). */
29
+ export const VERDICT_OUTCOMES = [
30
+ 'human_backed',
31
+ 'revoked',
32
+ 'stale',
33
+ 'no_signal',
34
+ ];
35
+ /** The reason vocabulary as a runtime list. */
36
+ export const VERDICT_REASONS = [
37
+ 'state_unavailable',
38
+ 'issuer_unknown',
39
+ 'issuer_not_active',
40
+ 'status_root_stale',
41
+ 'height_stale',
42
+ 'witness_unavailable',
43
+ 'witness_malformed',
44
+ 'witness_invalid',
45
+ 'witness_root_mismatch',
46
+ 'bond_insufficient',
47
+ 'suspended',
48
+ ];
27
49
  /**
28
50
  * The address as the service stores and echoes it. An EVM address (`base`)
29
51
  * is case-insensitive — its checksum and lowercase spellings name the same
@@ -72,12 +94,69 @@ export function readDepositDeclaration(verdict) {
72
94
  return null;
73
95
  return { state: state, level: level, verifiedAt };
74
96
  }
97
+ /**
98
+ * Structural check of a verdict as the free path serves it: an object with an
99
+ * `outcome` from the closed vocabulary, a `subject` of the declared rails with
100
+ * string `payTo` and `resourceUrl`, an integer `recheckEveryHours`, and the
101
+ * optional fields (`assuranceLevel`, `verifiedAt`, `signature`, `keyId`,
102
+ * `reason`) strings when present. `reason` is never required, and unknown keys
103
+ * (top level or under `extensions`) are ignored, never fatal. Anything else —
104
+ * `null`, an array, a scalar, a missing subject — is not a verdict: treat it
105
+ * exactly like no verdict ("unknown").
106
+ */
107
+ export function isVerdict(value) {
108
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
109
+ return false;
110
+ const v = value;
111
+ if (!VERDICT_OUTCOMES.includes(v['outcome']))
112
+ return false;
113
+ const subject = v['subject'];
114
+ if (typeof subject !== 'object' || subject === null || Array.isArray(subject))
115
+ return false;
116
+ const s = subject;
117
+ if (!isChain(s['chain']) || typeof s['payTo'] !== 'string')
118
+ return false;
119
+ if (typeof s['resourceUrl'] !== 'string')
120
+ return false;
121
+ if (!Number.isInteger(v['recheckEveryHours']))
122
+ return false;
123
+ for (const key of ['assuranceLevel', 'verifiedAt', 'signature', 'keyId', 'reason']) {
124
+ if (v[key] !== undefined && typeof v[key] !== 'string')
125
+ return false;
126
+ }
127
+ return true;
128
+ }
129
+ /**
130
+ * Whether a served subject is the one that was asked about: `chain` and
131
+ * `resourceUrl` compared exactly, `payTo` in canonical form (case-insensitive
132
+ * on `base`, where EVM addresses are kept lowercase; exact on `solana`).
133
+ */
134
+ export function subjectMatches(requested, served) {
135
+ return (requested.chain === served.chain &&
136
+ requested.resourceUrl === served.resourceUrl &&
137
+ canonicalPayTo(requested.chain, requested.payTo) === canonicalPayTo(served.chain, served.payTo));
138
+ }
75
139
  export const DEFAULT_TIMEOUT_MS = 3000;
140
+ /**
141
+ * The one place the free path's requests are built (verdicts, keys, the
142
+ * badge): anonymous — no credentials, no cookies — and bounded by the
143
+ * timeout. The client-declaring User-Agent, where the runtime allows one, is
144
+ * added here and nowhere else.
145
+ */
146
+ export function freePathRequest(timeoutMs = DEFAULT_TIMEOUT_MS, client = SDK_CLIENT) {
147
+ return {
148
+ credentials: 'omit',
149
+ headers: clientHeaders(client),
150
+ signal: AbortSignal.timeout(timeoutMs),
151
+ };
152
+ }
76
153
  /**
77
154
  * Fetches the current verdict for an endpoint from the free verification path.
78
155
  *
79
156
  * Degradation path: resolves to `undefined` — never rejects — on timeout,
80
- * network failure or a non-OK response. Treat `undefined` exactly like a
157
+ * network failure, a non-OK response, a body that is not JSON, a 2xx body
158
+ * that is not a structurally valid verdict (`isVerdict`), or a verdict about
159
+ * another subject (`subjectMatches`). Treat `undefined` exactly like a
81
160
  * `no_signal` outcome: show "unknown" and carry on.
82
161
  */
83
162
  export async function fetchVerdict(baseUrl, subject, options) {
@@ -86,13 +165,12 @@ export async function fetchVerdict(baseUrl, subject, options) {
86
165
  url.searchParams.set('payTo', subject.payTo);
87
166
  url.searchParams.set('resourceUrl', subject.resourceUrl);
88
167
  try {
89
- const res = await fetch(url, {
90
- signal: AbortSignal.timeout(options?.timeoutMs ?? DEFAULT_TIMEOUT_MS),
91
- });
168
+ const res = await fetch(url, freePathRequest(options?.timeoutMs, options?.client));
92
169
  if (!res.ok) {
93
170
  return undefined;
94
171
  }
95
- return (await res.json());
172
+ const body = await res.json();
173
+ return isVerdict(body) && subjectMatches(subject, body.subject) ? body : undefined;
96
174
  }
97
175
  catch {
98
176
  // No signal is a normal state, never an error surface.
@@ -106,7 +184,7 @@ export async function fetchVerdict(baseUrl, subject, options) {
106
184
  * any further round-trip.
107
185
  */
108
186
  export function freshness(verdict, now = new Date()) {
109
- if (verdict.verifiedAt === undefined) {
187
+ if (typeof verdict?.verifiedAt !== 'string' || !Number.isFinite(verdict.recheckEveryHours)) {
110
188
  return 'unknown';
111
189
  }
112
190
  const verified = Date.parse(verdict.verifiedAt);
@@ -118,12 +196,27 @@ export function freshness(verdict, now = new Date()) {
118
196
  }
119
197
  /**
120
198
  * Renders the canonical signed statement byte-for-byte as the service signs
121
- * it: fixed key order, RFC 3339 UTC at second precision, and Go-style JSON
122
- * escaping of `<`, `>` and `&`. Returns `undefined` when the verdict lacks
123
- * the fields a signed (positive) claim always carries.
199
+ * it (Go `encoding/json` of a flat struct): fixed key order `chain`, `payTo`,
200
+ * `resourceUrl`, `outcome`, `assuranceLevel`, `verifiedAt`,
201
+ * `recheckEveryHours`; no whitespace; UTF-8 with non-ASCII raw; `<`, `>`, `&`,
202
+ * U+2028 and U+2029 escaped as Go does; `verifiedAt` in UTC truncated to whole
203
+ * seconds. Returns `undefined` ("cannot build") when the verdict lacks the
204
+ * fields a signed (positive) claim always carries, or they are ill-typed.
205
+ * Any change to these bytes is a major version of the package.
124
206
  */
125
207
  export function canonicalVerdictPayload(verdict) {
126
- if (verdict.assuranceLevel === undefined || verdict.verifiedAt === undefined) {
208
+ if (typeof verdict !== 'object' || verdict === null)
209
+ return undefined;
210
+ const subject = verdict.subject;
211
+ if (typeof subject !== 'object' ||
212
+ subject === null ||
213
+ typeof subject.chain !== 'string' ||
214
+ typeof subject.payTo !== 'string' ||
215
+ typeof subject.resourceUrl !== 'string' ||
216
+ typeof verdict.outcome !== 'string' ||
217
+ typeof verdict.assuranceLevel !== 'string' ||
218
+ typeof verdict.verifiedAt !== 'string' ||
219
+ !Number.isInteger(verdict.recheckEveryHours)) {
127
220
  return undefined;
128
221
  }
129
222
  const parsed = Date.parse(verdict.verifiedAt);
@@ -133,9 +226,9 @@ export function canonicalVerdictPayload(verdict) {
133
226
  // Second precision: the canonical form drops any fractional seconds the
134
227
  // transport representation may carry.
135
228
  const verifiedAt = new Date(Math.floor(parsed / 1000) * 1000).toISOString().replace('.000Z', 'Z');
136
- const json = `{"chain":${goJson(verdict.subject.chain)}` +
137
- `,"payTo":${goJson(verdict.subject.payTo)}` +
138
- `,"resourceUrl":${goJson(verdict.subject.resourceUrl)}` +
229
+ const json = `{"chain":${goJson(subject.chain)}` +
230
+ `,"payTo":${goJson(subject.payTo)}` +
231
+ `,"resourceUrl":${goJson(subject.resourceUrl)}` +
139
232
  `,"outcome":${goJson(verdict.outcome)}` +
140
233
  `,"assuranceLevel":${goJson(verdict.assuranceLevel)}` +
141
234
  `,"verifiedAt":"${verifiedAt}"` +
@@ -152,18 +245,24 @@ function goJson(value) {
152
245
  }
153
246
  /**
154
247
  * Verifies the verdict's Ed25519 signature locally against a known public key
155
- * (base64, 32 raw bytes) — no round-trip involved.
248
+ * (base64, 32 raw bytes) — no round-trip involved. Never throws or rejects,
249
+ * whatever the verdict's shape.
156
250
  *
157
251
  * Degradation path: `unverifiable` (missing signature, or no Ed25519 support
158
252
  * in the runtime's WebCrypto) is not a negative finding — display the verdict
159
253
  * as usual, or fall back to "unknown" if your policy prefers only locally
160
- * checked claims. `invalid` means the bytes do not match the key: treat as
254
+ * checked claims. `invalid` means the bytes, the base64 or the key do not
255
+ * match (a signature whose signed bytes cannot be built included): treat as
161
256
  * "unknown".
162
257
  */
163
258
  export async function verifyVerdictSignature(verdict, publicKeyBase64) {
164
- if (verdict.signature === undefined) {
259
+ const claimed = typeof verdict === 'object' && verdict !== null ? verdict.signature : undefined;
260
+ if (claimed === undefined) {
165
261
  return 'unverifiable';
166
262
  }
263
+ if (typeof claimed !== 'string') {
264
+ return 'invalid';
265
+ }
167
266
  const payload = canonicalVerdictPayload(verdict);
168
267
  if (payload === undefined) {
169
268
  return 'invalid';
@@ -171,12 +270,17 @@ export async function verifyVerdictSignature(verdict, publicKeyBase64) {
171
270
  let signature;
172
271
  let publicKey;
173
272
  try {
174
- signature = base64Decode(verdict.signature);
273
+ signature = base64Decode(claimed);
175
274
  publicKey = base64Decode(publicKeyBase64);
176
275
  }
177
276
  catch {
178
277
  return 'invalid';
179
278
  }
279
+ // A raw Ed25519 key is 32 bytes and a signature 64: anything else cannot
280
+ // match, and must not be mistaken for a runtime without Ed25519 below.
281
+ if (publicKey.byteLength !== 32 || signature.byteLength !== 64) {
282
+ return 'invalid';
283
+ }
180
284
  try {
181
285
  const key = await crypto.subtle.importKey('raw', publicKey, { name: 'Ed25519' }, false, [
182
286
  'verify',
package/dist/webhook.d.ts CHANGED
@@ -22,8 +22,10 @@ export declare function webhookKeyId(secretHex: string): Promise<string>;
22
22
  */
23
23
  export declare function verifyWebhookSignature(secretHex: string, rawBody: Uint8Array | string, headers: HeaderSource): Promise<boolean>;
24
24
  /**
25
- * Parses a delivery body into a `VerdictEvent`. Returns `undefined` for
26
- * anything that is not a well-formed `verdict.updated` event.
25
+ * Parses a delivery body into a `VerdictEvent`: the free path's verdict
26
+ * (the same structural check as `fetchVerdict`, so the signature check
27
+ * applies unchanged) with `"event": "verdict.updated"`. Returns `undefined`
28
+ * for anything else — never throws.
27
29
  */
28
30
  export declare function parseVerdictEvent(rawBody: Uint8Array | string): VerdictEvent | undefined;
29
31
  export {};
package/dist/webhook.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // Zero runtime dependencies (WebCrypto). Graceful degradation: a delivery
7
7
  // that does not verify is `false` — drop it and keep polling the free path;
8
8
  // nothing in your flow depends on webhooks arriving.
9
- import { isChain } from "./verdict.js";
9
+ import { isVerdict } from "./verdict.js";
10
10
  /** Header names used by deliveries. */
11
11
  export const SIGNATURE_HEADER = 'X-Signature';
12
12
  export const SIGNATURE_KEY_HEADER = 'X-Signature-Key';
@@ -55,22 +55,16 @@ export async function verifyWebhookSignature(secretHex, rawBody, headers) {
55
55
  }
56
56
  }
57
57
  /**
58
- * Parses a delivery body into a `VerdictEvent`. Returns `undefined` for
59
- * anything that is not a well-formed `verdict.updated` event.
58
+ * Parses a delivery body into a `VerdictEvent`: the free path's verdict
59
+ * (the same structural check as `fetchVerdict`, so the signature check
60
+ * applies unchanged) with `"event": "verdict.updated"`. Returns `undefined`
61
+ * for anything else — never throws.
60
62
  */
61
63
  export function parseVerdictEvent(rawBody) {
62
64
  try {
63
65
  const text = typeof rawBody === 'string' ? rawBody : new TextDecoder().decode(rawBody);
64
66
  const value = JSON.parse(text);
65
- const subject = value['subject'];
66
- if (value['event'] !== 'verdict.updated' ||
67
- typeof value['outcome'] !== 'string' ||
68
- typeof value['recheckEveryHours'] !== 'number' ||
69
- typeof subject !== 'object' ||
70
- subject === null ||
71
- !isChain(subject['chain']) ||
72
- typeof subject['payTo'] !== 'string' ||
73
- typeof subject['resourceUrl'] !== 'string') {
67
+ if (!isVerdict(value) || value['event'] !== 'verdict.updated') {
74
68
  return undefined;
75
69
  }
76
70
  return value;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zadq/x402",
3
- "version": "0.2.0",
4
- "description": "Client helpers for the free seller-verification path of x402 endpoints: the signed verdict and its local Ed25519 check, the HTTP 402 identity-policy extension and companion header, the issuer receipt, the explorer client, the webhook receiver and the seller holder driver. Zero runtime dependencies; every helper degrades to \"unknown\".",
3
+ "version": "0.3.0",
4
+ "description": "Client helpers for the free seller-verification path of x402 endpoints: the signed verdict and its local Ed25519 check, the HTTP 402 identity-policy extension and companion header, the issuer receipt, the badge, the explorer client, the webhook receiver and the seller holder driver. Zero runtime dependencies; every helper degrades to \"unknown\".",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "sideEffects": false,
@@ -13,6 +13,10 @@
13
13
  "types": "./dist/index.d.ts",
14
14
  "import": "./dist/index.js"
15
15
  },
16
+ "./client": {
17
+ "types": "./dist/client.d.ts",
18
+ "import": "./dist/client.js"
19
+ },
16
20
  "./verdict": {
17
21
  "types": "./dist/verdict.d.ts",
18
22
  "import": "./dist/verdict.js"
@@ -41,6 +45,18 @@
41
45
  "types": "./dist/webhook.d.ts",
42
46
  "import": "./dist/webhook.js"
43
47
  },
48
+ "./badge": {
49
+ "types": "./dist/badge.d.ts",
50
+ "import": "./dist/badge.js"
51
+ },
52
+ "./payment-required": {
53
+ "types": "./dist/extension.d.ts",
54
+ "import": "./dist/extension.js"
55
+ },
56
+ "./x-payment": {
57
+ "types": "./dist/header.d.ts",
58
+ "import": "./dist/header.js"
59
+ },
44
60
  "./holder": {
45
61
  "types": "./dist/holder/index.d.ts",
46
62
  "import": "./dist/holder/index.js"