@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/README.md +24 -2
- package/dist/badge.d.ts +51 -0
- package/dist/badge.js +103 -0
- package/dist/client.d.ts +26 -0
- package/dist/client.js +46 -0
- package/dist/explorer.d.ts +17 -4
- package/dist/explorer.js +22 -10
- package/dist/extension.d.ts +20 -6
- package/dist/extension.js +55 -10
- package/dist/header.d.ts +15 -5
- package/dist/header.js +29 -16
- package/dist/holder/chain.d.ts +6 -1
- package/dist/holder/chain.js +24 -8
- package/dist/holder/cli.d.ts +2 -0
- package/dist/holder/cli.js +25 -4
- package/dist/holder/holder.d.ts +8 -0
- package/dist/holder/holder.js +81 -15
- package/dist/holder/index.d.ts +6 -6
- package/dist/holder/index.js +5 -5
- package/dist/holder/killswitch.d.ts +128 -45
- package/dist/holder/killswitch.js +236 -56
- package/dist/holder/process.d.ts +9 -0
- package/dist/holder/process.js +9 -0
- package/dist/holder/types.d.ts +52 -15
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -4
- package/dist/keys.d.ts +22 -1
- package/dist/keys.js +25 -7
- package/dist/receipt.d.ts +8 -3
- package/dist/receipt.js +11 -5
- package/dist/verdict.d.ts +53 -8
- package/dist/verdict.js +121 -17
- package/dist/webhook.d.ts +4 -2
- package/dist/webhook.js +6 -12
- package/package.json +18 -2
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 {
|
|
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
|
-
/**
|
|
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
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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
|
|
165
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
122
|
-
*
|
|
123
|
-
*
|
|
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
|
|
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(
|
|
137
|
-
`,"payTo":${goJson(
|
|
138
|
-
`,"resourceUrl":${goJson(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
26
|
-
*
|
|
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 {
|
|
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
|
|
59
|
-
*
|
|
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
|
-
|
|
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.
|
|
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"
|