@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/README.md +28 -2
- package/dist/badge.d.ts +51 -0
- package/dist/badge.js +103 -0
- package/dist/brand.d.ts +1 -0
- package/dist/brand.js +3 -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 +7 -5
- package/dist/index.js +7 -5
- 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 +85 -15
- package/dist/verdict.js +160 -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,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
|
-
/**
|
|
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
|
|
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
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
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(
|
|
98
|
-
`,"payTo":${goJson(
|
|
99
|
-
`,"resourceUrl":${goJson(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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"
|