@zadq/x402 0.1.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,10 +1,4 @@
1
- /**
2
- * The payment rails the seller rail covers (Platform Decision 14): USDC on
3
- * Base (`base`, an EVM address) or on Solana (`solana`). The contract is a
4
- * closed enumeration — the service answers a 400 problem to any other value,
5
- * and other EVM chains are not declared until a seller needs one — so the
6
- * type is the same closed union, not a string.
7
- */
1
+ import { type ClientDeclaration } from './client.js';
8
2
  export type Chain = 'base' | 'solana';
9
3
  /** The declared rails, in the order the contract lists them. */
10
4
  export declare const CHAINS: readonly Chain[];
@@ -12,12 +6,18 @@ export declare const CHAINS: readonly Chain[];
12
6
  export declare function isChain(value: unknown): value is Chain;
13
7
  /** Verdict outcomes, mirroring the public API contract vocabulary. */
14
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[];
15
11
  /**
16
12
  * Why a verdict is not `human_backed`: the standing read's closed vocabulary
17
13
  * (MintID's, restated — never a flag of the service's own), or `suspended`
18
- * 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.
19
17
  */
20
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[];
21
21
  /** The x402 endpoint a verdict speaks about: chain-qualified payTo + resource URL. */
22
22
  export type VerdictSubject = {
23
23
  chain: Chain;
@@ -50,19 +50,83 @@ export type Verdict = {
50
50
  signature?: string;
51
51
  /** Identifies the verdict-signing key (first 8 bytes of the public key's SHA-256, hex). */
52
52
  keyId?: string;
53
- /** 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
+ */
54
58
  reason?: VerdictReason;
59
+ /**
60
+ * The verifying party's own statements beside the canonical verdict, keyed
61
+ * by its brand in lower case (`VERDICT_EXTENSION_KEY`). Outside the signed
62
+ * bytes; absent on most verdicts. Read with `readDepositDeclaration`.
63
+ */
64
+ extensions?: Record<string, {
65
+ deposit?: DepositDeclaration;
66
+ } | undefined>;
55
67
  };
68
+ /** The declaration's public states — one code per label of the seller's deposit, as the service publishes them. */
69
+ export declare const DEPOSIT_DECLARATION_STATES: readonly ["required", "awaiting_funds", "held", "below_level", "claim_pending", "released_partial", "returning", "returned"];
70
+ export type DepositDeclarationState = (typeof DEPOSIT_DECLARATION_STATES)[number];
71
+ /** The band of the deposit, named by the schedule tier; the amount is never carried. */
72
+ export declare const DEPOSIT_LEVELS: readonly ["attested_vendor", "regulated_compliance"];
73
+ export type DepositLevel = (typeof DEPOSIT_LEVELS)[number];
74
+ /**
75
+ * The service's own statement about the seller's guarantee deposit under its
76
+ * Terms, beside the verdict: a state and a level band read from the service's
77
+ * record at `verifiedAt` — never an amount, a balance or a holder reference.
78
+ * The identity network carries no fact about this deposit; the statement is
79
+ * the service's and sits outside the verdict's signed bytes.
80
+ */
81
+ export type DepositDeclaration = {
82
+ state: DepositDeclarationState;
83
+ level: DepositLevel;
84
+ verifiedAt: string;
85
+ };
86
+ /**
87
+ * The deposit declaration a verdict carries, or `null` when it carries none
88
+ * (the deposit rail off, no deposit owed, or a reader that was served the
89
+ * verdict alone). Malformed content reads as none — nothing throws.
90
+ */
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;
56
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;
57
117
  /**
58
118
  * Fetches the current verdict for an endpoint from the free verification path.
59
119
  *
60
120
  * Degradation path: resolves to `undefined` — never rejects — on timeout,
61
- * 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
62
124
  * `no_signal` outcome: show "unknown" and carry on.
63
125
  */
64
126
  export declare function fetchVerdict(baseUrl: string, subject: VerdictSubject, options?: {
65
127
  timeoutMs?: number;
128
+ /** Which of the service's clients is calling (default: this library). See client.ts. */
129
+ client?: ClientDeclaration;
66
130
  }): Promise<Verdict | undefined>;
67
131
  /** Freshness against the cadence promise the verdict itself carries. */
68
132
  export type Freshness = 'fresh' | 'overdue' | 'unknown';
@@ -75,21 +139,27 @@ export type Freshness = 'fresh' | 'overdue' | 'unknown';
75
139
  export declare function freshness(verdict: Verdict, now?: Date): Freshness;
76
140
  /**
77
141
  * Renders the canonical signed statement byte-for-byte as the service signs
78
- * it: fixed key order, RFC 3339 UTC at second precision, and Go-style JSON
79
- * escaping of `<`, `>` and `&`. Returns `undefined` when the verdict lacks
80
- * 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.
81
149
  */
82
150
  export declare function canonicalVerdictPayload(verdict: Verdict): Uint8Array<ArrayBuffer> | undefined;
83
151
  /** Outcome of a local signature check. */
84
152
  export type SignatureCheck = 'valid' | 'invalid' | 'unverifiable';
85
153
  /**
86
154
  * Verifies the verdict's Ed25519 signature locally against a known public key
87
- * (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.
88
157
  *
89
158
  * Degradation path: `unverifiable` (missing signature, or no Ed25519 support
90
159
  * in the runtime's WebCrypto) is not a negative finding — display the verdict
91
160
  * as usual, or fall back to "unknown" if your policy prefers only locally
92
- * 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
93
163
  * "unknown".
94
164
  */
95
165
  export declare function verifyVerdictSignature(verdict: Verdict, publicKeyBase64: string): Promise<SignatureCheck>;
package/dist/verdict.js CHANGED
@@ -10,12 +10,42 @@
10
10
  // or an absent verdict as "no signal". Nothing here throws on network failure
11
11
  // and nothing blocks your payment flow — your integration keeps working
12
12
  // unchanged when no verification response is available.
13
+ /**
14
+ * The payment rails the seller rail covers (Platform Decision 14): USDC on
15
+ * Base (`base`, an EVM address) or on Solana (`solana`). The contract is a
16
+ * closed enumeration — the service answers a 400 problem to any other value,
17
+ * and other EVM chains are not declared until a seller needs one — so the
18
+ * type is the same closed union, not a string.
19
+ */
20
+ import { VERDICT_EXTENSION_KEY } from "./brand.js";
21
+ import { SDK_CLIENT, clientHeaders } from "./client.js";
13
22
  /** The declared rails, in the order the contract lists them. */
14
23
  export const CHAINS = ['base', 'solana'];
15
24
  /** Runtime guard for the `Chain` enumeration (input from a wire, an agent or an env var). */
16
25
  export function isChain(value) {
17
26
  return typeof value === 'string' && CHAINS.includes(value);
18
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
+ ];
19
49
  /**
20
50
  * The address as the service stores and echoes it. An EVM address (`base`)
21
51
  * is case-insensitive — its checksum and lowercase spellings name the same
@@ -33,12 +63,100 @@ export function canonicalPayTo(chain, payTo) {
33
63
  export function canonicalSubject(subject) {
34
64
  return { ...subject, payTo: canonicalPayTo(subject.chain, subject.payTo) };
35
65
  }
66
+ /** The declaration's public states — one code per label of the seller's deposit, as the service publishes them. */
67
+ export const DEPOSIT_DECLARATION_STATES = [
68
+ 'required',
69
+ 'awaiting_funds',
70
+ 'held',
71
+ 'below_level',
72
+ 'claim_pending',
73
+ 'released_partial',
74
+ 'returning',
75
+ 'returned',
76
+ ];
77
+ /** The band of the deposit, named by the schedule tier; the amount is never carried. */
78
+ export const DEPOSIT_LEVELS = ['attested_vendor', 'regulated_compliance'];
79
+ /**
80
+ * The deposit declaration a verdict carries, or `null` when it carries none
81
+ * (the deposit rail off, no deposit owed, or a reader that was served the
82
+ * verdict alone). Malformed content reads as none — nothing throws.
83
+ */
84
+ export function readDepositDeclaration(verdict) {
85
+ const d = verdict?.extensions?.[VERDICT_EXTENSION_KEY]?.deposit;
86
+ if (typeof d !== 'object' || d === null)
87
+ return null;
88
+ const { state, level, verifiedAt } = d;
89
+ if (!DEPOSIT_DECLARATION_STATES.includes(String(state)))
90
+ return null;
91
+ if (!DEPOSIT_LEVELS.includes(String(level)))
92
+ return null;
93
+ if (typeof verifiedAt !== 'string' || Number.isNaN(Date.parse(verifiedAt)))
94
+ return null;
95
+ return { state: state, level: level, verifiedAt };
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
+ }
36
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
+ }
37
153
  /**
38
154
  * Fetches the current verdict for an endpoint from the free verification path.
39
155
  *
40
156
  * Degradation path: resolves to `undefined` — never rejects — on timeout,
41
- * 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
42
160
  * `no_signal` outcome: show "unknown" and carry on.
43
161
  */
44
162
  export async function fetchVerdict(baseUrl, subject, options) {
@@ -47,13 +165,12 @@ export async function fetchVerdict(baseUrl, subject, options) {
47
165
  url.searchParams.set('payTo', subject.payTo);
48
166
  url.searchParams.set('resourceUrl', subject.resourceUrl);
49
167
  try {
50
- const res = await fetch(url, {
51
- signal: AbortSignal.timeout(options?.timeoutMs ?? DEFAULT_TIMEOUT_MS),
52
- });
168
+ const res = await fetch(url, freePathRequest(options?.timeoutMs, options?.client));
53
169
  if (!res.ok) {
54
170
  return undefined;
55
171
  }
56
- return (await res.json());
172
+ const body = await res.json();
173
+ return isVerdict(body) && subjectMatches(subject, body.subject) ? body : undefined;
57
174
  }
58
175
  catch {
59
176
  // No signal is a normal state, never an error surface.
@@ -67,7 +184,7 @@ export async function fetchVerdict(baseUrl, subject, options) {
67
184
  * any further round-trip.
68
185
  */
69
186
  export function freshness(verdict, now = new Date()) {
70
- if (verdict.verifiedAt === undefined) {
187
+ if (typeof verdict?.verifiedAt !== 'string' || !Number.isFinite(verdict.recheckEveryHours)) {
71
188
  return 'unknown';
72
189
  }
73
190
  const verified = Date.parse(verdict.verifiedAt);
@@ -79,12 +196,27 @@ export function freshness(verdict, now = new Date()) {
79
196
  }
80
197
  /**
81
198
  * Renders the canonical signed statement byte-for-byte as the service signs
82
- * it: fixed key order, RFC 3339 UTC at second precision, and Go-style JSON
83
- * escaping of `<`, `>` and `&`. Returns `undefined` when the verdict lacks
84
- * 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.
85
206
  */
86
207
  export function canonicalVerdictPayload(verdict) {
87
- 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)) {
88
220
  return undefined;
89
221
  }
90
222
  const parsed = Date.parse(verdict.verifiedAt);
@@ -94,9 +226,9 @@ export function canonicalVerdictPayload(verdict) {
94
226
  // Second precision: the canonical form drops any fractional seconds the
95
227
  // transport representation may carry.
96
228
  const verifiedAt = new Date(Math.floor(parsed / 1000) * 1000).toISOString().replace('.000Z', 'Z');
97
- const json = `{"chain":${goJson(verdict.subject.chain)}` +
98
- `,"payTo":${goJson(verdict.subject.payTo)}` +
99
- `,"resourceUrl":${goJson(verdict.subject.resourceUrl)}` +
229
+ const json = `{"chain":${goJson(subject.chain)}` +
230
+ `,"payTo":${goJson(subject.payTo)}` +
231
+ `,"resourceUrl":${goJson(subject.resourceUrl)}` +
100
232
  `,"outcome":${goJson(verdict.outcome)}` +
101
233
  `,"assuranceLevel":${goJson(verdict.assuranceLevel)}` +
102
234
  `,"verifiedAt":"${verifiedAt}"` +
@@ -113,18 +245,24 @@ function goJson(value) {
113
245
  }
114
246
  /**
115
247
  * Verifies the verdict's Ed25519 signature locally against a known public key
116
- * (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.
117
250
  *
118
251
  * Degradation path: `unverifiable` (missing signature, or no Ed25519 support
119
252
  * in the runtime's WebCrypto) is not a negative finding — display the verdict
120
253
  * as usual, or fall back to "unknown" if your policy prefers only locally
121
- * 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
122
256
  * "unknown".
123
257
  */
124
258
  export async function verifyVerdictSignature(verdict, publicKeyBase64) {
125
- if (verdict.signature === undefined) {
259
+ const claimed = typeof verdict === 'object' && verdict !== null ? verdict.signature : undefined;
260
+ if (claimed === undefined) {
126
261
  return 'unverifiable';
127
262
  }
263
+ if (typeof claimed !== 'string') {
264
+ return 'invalid';
265
+ }
128
266
  const payload = canonicalVerdictPayload(verdict);
129
267
  if (payload === undefined) {
130
268
  return 'invalid';
@@ -132,12 +270,17 @@ export async function verifyVerdictSignature(verdict, publicKeyBase64) {
132
270
  let signature;
133
271
  let publicKey;
134
272
  try {
135
- signature = base64Decode(verdict.signature);
273
+ signature = base64Decode(claimed);
136
274
  publicKey = base64Decode(publicKeyBase64);
137
275
  }
138
276
  catch {
139
277
  return 'invalid';
140
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
+ }
141
284
  try {
142
285
  const key = await crypto.subtle.importKey('raw', publicKey, { name: 'Ed25519' }, false, [
143
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.1.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"