@ashlar-blue/x402-trust 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/LICENSE +21 -0
- package/README.md +198 -0
- package/dist/attested-directory.d.ts +78 -0
- package/dist/attested-directory.d.ts.map +1 -0
- package/dist/attested-directory.js +163 -0
- package/dist/attested-directory.js.map +1 -0
- package/dist/badges.d.ts +131 -0
- package/dist/badges.d.ts.map +1 -0
- package/dist/badges.js +213 -0
- package/dist/badges.js.map +1 -0
- package/dist/catalog-mcp.d.ts +145 -0
- package/dist/catalog-mcp.d.ts.map +1 -0
- package/dist/catalog-mcp.js +409 -0
- package/dist/catalog-mcp.js.map +1 -0
- package/dist/census-reconcile.d.ts +69 -0
- package/dist/census-reconcile.d.ts.map +1 -0
- package/dist/census-reconcile.js +162 -0
- package/dist/census-reconcile.js.map +1 -0
- package/dist/directory-classify.d.ts +47 -0
- package/dist/directory-classify.d.ts.map +1 -0
- package/dist/directory-classify.js +54 -0
- package/dist/directory-classify.js.map +1 -0
- package/dist/discovery.d.ts +304 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +926 -0
- package/dist/discovery.js.map +1 -0
- package/dist/evidence-ref.d.ts +96 -0
- package/dist/evidence-ref.d.ts.map +1 -0
- package/dist/evidence-ref.js +179 -0
- package/dist/evidence-ref.js.map +1 -0
- package/dist/facilitator-config.d.ts +83 -0
- package/dist/facilitator-config.d.ts.map +1 -0
- package/dist/facilitator-config.js +94 -0
- package/dist/facilitator-config.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest-sig.d.ts +131 -0
- package/dist/manifest-sig.d.ts.map +1 -0
- package/dist/manifest-sig.js +262 -0
- package/dist/manifest-sig.js.map +1 -0
- package/dist/merkle-transcript.d.ts +34 -0
- package/dist/merkle-transcript.d.ts.map +1 -0
- package/dist/merkle-transcript.js +162 -0
- package/dist/merkle-transcript.js.map +1 -0
- package/dist/open-catalog.d.ts +111 -0
- package/dist/open-catalog.d.ts.map +1 -0
- package/dist/open-catalog.js +307 -0
- package/dist/open-catalog.js.map +1 -0
- package/dist/reconcile.d.ts +150 -0
- package/dist/reconcile.d.ts.map +1 -0
- package/dist/reconcile.js +136 -0
- package/dist/reconcile.js.map +1 -0
- package/dist/trust-sdk.d.ts +127 -0
- package/dist/trust-sdk.d.ts.map +1 -0
- package/dist/trust-sdk.js +147 -0
- package/dist/trust-sdk.js.map +1 -0
- package/dist/types.d.ts +213 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +68 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -0
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
// GENERATED from src/x402/discovery.ts by scripts/sdk/sync-sdk.ts — do not edit here.
|
|
2
|
+
// The canonical module is the one FlareClaw runs in production; this is a copy so
|
|
3
|
+
// that what we publish and what we run cannot diverge. Edit the canonical file.
|
|
4
|
+
// ──────────────────── TXT record grammar ────────────────────
|
|
5
|
+
const TXT_VERSION = 'x402-1';
|
|
6
|
+
/**
|
|
7
|
+
* Parse a `_x402.<domain>` TXT record ("v=x402-1; wk=https://…; k=…").
|
|
8
|
+
* Returns null for records that aren't x402-1 (callers skip them).
|
|
9
|
+
* Throws on records that claim x402-1 but are malformed.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* A HARD discovery violation — a record or redirect that is present but
|
|
13
|
+
* malformed or spoofing (missing/HTTP wk, invalid k, duplicate key/record,
|
|
14
|
+
* out-of-domain or downgraded redirect). Distinguished by TYPE, not by message
|
|
15
|
+
* text, so classification cannot be fooled by attacker-controlled strings (a
|
|
16
|
+
* wk URL containing the word "conflicting") and cannot silently swallow a
|
|
17
|
+
* malformed record as a DNS miss. These cache as `invalid`; DNS misses do not.
|
|
18
|
+
*/
|
|
19
|
+
export class DiscoveryViolation extends Error {
|
|
20
|
+
}
|
|
21
|
+
export function parseX402TxtRecord(txt) {
|
|
22
|
+
const fields = new Map();
|
|
23
|
+
for (const part of txt.split(';')) {
|
|
24
|
+
const trimmed = part.trim();
|
|
25
|
+
if (!trimmed)
|
|
26
|
+
continue;
|
|
27
|
+
const eq = trimmed.indexOf('=');
|
|
28
|
+
if (eq === -1)
|
|
29
|
+
continue;
|
|
30
|
+
const key = trimmed.slice(0, eq).trim().toLowerCase();
|
|
31
|
+
// A repeated key inside one record is malformed. "Last wins" (Map.set)
|
|
32
|
+
// is silent and DIVERGENT: another conformant implementation could take
|
|
33
|
+
// the first, and the two resolve to different manifests with no way to
|
|
34
|
+
// detect it — the same hazard the duplicate-RECORD rule refuses, one
|
|
35
|
+
// level down. Only meaningful once we know it's an x402 record, so the
|
|
36
|
+
// throw is deferred until after the v= check. (Review F6.)
|
|
37
|
+
if (fields.has(key)) {
|
|
38
|
+
fields.set('__dupkey', key); // remember, decide after v= gate
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
fields.set(key, trimmed.slice(eq + 1).trim());
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Version token is matched case-sensitively (the token defines the format
|
|
45
|
+
// version); records that don't declare THIS version are skipped, not an
|
|
46
|
+
// error, so a future version's record does not fail an old resolver.
|
|
47
|
+
if (fields.get('v') !== TXT_VERSION)
|
|
48
|
+
return null;
|
|
49
|
+
const dup = fields.get('__dupkey');
|
|
50
|
+
if (dup)
|
|
51
|
+
throw new DiscoveryViolation(`[x402-discovery] duplicate key "${dup}" in one TXT record — ambiguous, refusing`);
|
|
52
|
+
const wk = fields.get('wk');
|
|
53
|
+
if (!wk)
|
|
54
|
+
throw new DiscoveryViolation('[x402-discovery] TXT record missing wk=<manifest url>');
|
|
55
|
+
if (!wk.startsWith('https://'))
|
|
56
|
+
throw new DiscoveryViolation(`[x402-discovery] wk must be HTTPS: ${wk}`);
|
|
57
|
+
const k = fields.get('k');
|
|
58
|
+
if (k && !['facilitator', 'resource-server', 'both'].includes(k)) {
|
|
59
|
+
throw new DiscoveryViolation(`[x402-discovery] invalid k=${k}`);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
v: TXT_VERSION,
|
|
63
|
+
wk,
|
|
64
|
+
...(k ? { k: k } : {}),
|
|
65
|
+
...(fields.has('net') ? { net: fields.get('net').split(',').map(s => s.trim()).filter(Boolean) } : {}),
|
|
66
|
+
...(fields.has('scheme') ? { scheme: fields.get('scheme').split(',').map(s => s.trim()).filter(Boolean) } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Keys observed in the wild carrying the manifest URL under another name. */
|
|
70
|
+
const NEAR_MISS_URL_KEYS = ['url', 'manifest', 'x402-manifest', 'wk', 'href', 'uri'];
|
|
71
|
+
export function diagnoseX402TxtRecord(txt) {
|
|
72
|
+
try {
|
|
73
|
+
const rec = parseX402TxtRecord(txt);
|
|
74
|
+
if (rec)
|
|
75
|
+
return { kind: 'conformant', record: rec };
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
if (e instanceof DiscoveryViolation)
|
|
79
|
+
return { kind: 'malformed', message: e.message };
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
const raw = txt.trim();
|
|
83
|
+
const hints = [];
|
|
84
|
+
let manifestUrl;
|
|
85
|
+
// Shape 1: a bare https URL with no key at all.
|
|
86
|
+
if (/^https:\/\/\S+$/i.test(raw)) {
|
|
87
|
+
manifestUrl = raw;
|
|
88
|
+
hints.push('record is a bare URL; the grammar is `v=x402-1; wk=<url>`');
|
|
89
|
+
return { kind: 'near-miss', hints, manifestUrl };
|
|
90
|
+
}
|
|
91
|
+
const fields = new Map();
|
|
92
|
+
for (const part of raw.split(';')) {
|
|
93
|
+
const eq = part.indexOf('=');
|
|
94
|
+
if (eq === -1)
|
|
95
|
+
continue;
|
|
96
|
+
fields.set(part.slice(0, eq).trim().toLowerCase(), part.slice(eq + 1).trim());
|
|
97
|
+
}
|
|
98
|
+
// Shape 2: a version token that only differs by punctuation or case.
|
|
99
|
+
const v = fields.get('v');
|
|
100
|
+
if (v && v !== TXT_VERSION && v.toLowerCase().replace(/[-_.\s]/g, '') === TXT_VERSION.replace(/-/g, '')) {
|
|
101
|
+
hints.push(`version token is "${v}"; it must be exactly "${TXT_VERSION}"`);
|
|
102
|
+
}
|
|
103
|
+
// Shape 3: the manifest URL present under a key other than wk=.
|
|
104
|
+
for (const k of NEAR_MISS_URL_KEYS) {
|
|
105
|
+
const val = fields.get(k);
|
|
106
|
+
if (k !== 'wk' && val && /^https:\/\//i.test(val)) {
|
|
107
|
+
manifestUrl = val;
|
|
108
|
+
hints.push(`manifest URL is carried in "${k}="; the grammar names it "wk="`);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (hints.length === 0)
|
|
113
|
+
return { kind: 'foreign' };
|
|
114
|
+
return { kind: 'near-miss', hints, ...(manifestUrl ? { manifestUrl } : {}) };
|
|
115
|
+
}
|
|
116
|
+
/** Render the TXT record value for a manifest URL (for operators with DNS). */
|
|
117
|
+
export function buildX402TxtRecord(p) {
|
|
118
|
+
const parts = [`v=${TXT_VERSION}`, `wk=${p.manifestUrl}`];
|
|
119
|
+
if (p.kind)
|
|
120
|
+
parts.push(`k=${p.kind}`);
|
|
121
|
+
if (p.networks?.length)
|
|
122
|
+
parts.push(`net=${p.networks.join(',')}`);
|
|
123
|
+
if (p.schemes?.length)
|
|
124
|
+
parts.push(`scheme=${p.schemes.join(',')}`);
|
|
125
|
+
return parts.join('; ');
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Spec security rule: the wk URL must live on the queried domain or a
|
|
129
|
+
* subdomain of it — a TXT record may not claim another operator's manifest.
|
|
130
|
+
*/
|
|
131
|
+
/**
|
|
132
|
+
* A facilitator endpoint value that is safe to concatenate onto baseUrl and
|
|
133
|
+
* fetch: a rooted relative path only. A leading single "/" makes authority
|
|
134
|
+
* injection impossible (nothing after "/" reaches the authority component), and
|
|
135
|
+
* "@", "://", "//" and whitespace/backslash are refused outright. This is the
|
|
136
|
+
* SYNTACTIC guard; isWkInDomain on the assembled URL is the semantic backstop.
|
|
137
|
+
*/
|
|
138
|
+
export function isSafeRelativePath(p) {
|
|
139
|
+
return typeof p === 'string'
|
|
140
|
+
&& p.startsWith('/') && !p.startsWith('//')
|
|
141
|
+
&& !p.includes('@') && !p.includes('://')
|
|
142
|
+
&& !/[\s\\]/.test(p);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Normalize a DNS name to the form a comparison can be made in: A-labels,
|
|
146
|
+
* lowercase, no trailing dot.
|
|
147
|
+
*
|
|
148
|
+
* BOTH SIDES OF THE COMPARISON GO THROUGH THIS, and that is the whole point.
|
|
149
|
+
* The previous version lowercased both sides but normalized only one of the
|
|
150
|
+
* other two axes on each, which made the rule ASYMMETRIC in two places:
|
|
151
|
+
*
|
|
152
|
+
* manifest host `api.example.com.` vs domain `api.example.com` → false
|
|
153
|
+
* domain `api.example.com.` vs host `api.example.com` → true
|
|
154
|
+
* manifest host `xn--bcher-kva.de` vs domain `bücher.de` → false
|
|
155
|
+
* domain `xn--bcher-kva.de` vs host `bücher.de` → true
|
|
156
|
+
*
|
|
157
|
+
* The trailing dot is the same name in the DNS and a U-label is the same name
|
|
158
|
+
* as its A-label, so all four of those are one name compared with itself. Both
|
|
159
|
+
* false answers FAIL CLOSED into "the wk= is out of domain", which drops a
|
|
160
|
+
* conforming publisher out of discovery silently — the direction that costs the
|
|
161
|
+
* publisher and never the crawler, so nobody complains and nothing is logged.
|
|
162
|
+
*
|
|
163
|
+
* Found by @Circadian-agent on x402-foundation/x402#2979 as an underspecified
|
|
164
|
+
* comparison in the extension text; it was also a live defect in this
|
|
165
|
+
* implementation, in both of the directions they named.
|
|
166
|
+
*
|
|
167
|
+
* The WHATWG URL parser does the A-label conversion, so this module keeps its
|
|
168
|
+
* zero-import property: there is no second normalizer to disagree with the one
|
|
169
|
+
* already used on the manifest side.
|
|
170
|
+
*/
|
|
171
|
+
function toComparableName(rawHost) {
|
|
172
|
+
if (typeof rawHost !== 'string' || rawHost === '')
|
|
173
|
+
return null;
|
|
174
|
+
try {
|
|
175
|
+
// The WHATWG parser does the A-label conversion, so this module keeps
|
|
176
|
+
// its zero-import property and there is exactly ONE normalizer in it.
|
|
177
|
+
// Two normalizers is how the halves of a comparison drift apart.
|
|
178
|
+
return new URL(`https://${rawHost}/`).hostname.toLowerCase().replace(/\.$/, '') || null;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function normalizeDnsName(name) {
|
|
185
|
+
// A bare DNS name only. Anything that could carry authority or a path is
|
|
186
|
+
// rejected rather than parsed, so a caller passing a URL by mistake cannot
|
|
187
|
+
// have its hostname silently extracted and compared.
|
|
188
|
+
if (typeof name !== 'string' || /[\/?#@:\\\s]/.test(name))
|
|
189
|
+
return null;
|
|
190
|
+
return toComparableName(name);
|
|
191
|
+
}
|
|
192
|
+
export function isWkInDomain(wkUrl, domain) {
|
|
193
|
+
try {
|
|
194
|
+
const host = new URL(wkUrl).hostname.toLowerCase().replace(/\.$/, '');
|
|
195
|
+
const d = normalizeDnsName(domain);
|
|
196
|
+
if (!host || !d)
|
|
197
|
+
return false;
|
|
198
|
+
return host === d || host.endsWith(`.${d}`);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// ──────────────────── Which name do you ask? ────────────────────
|
|
205
|
+
//
|
|
206
|
+
// THE AMBIGUITY, AND HOW WE FOUND IT. The spec says the record lives at
|
|
207
|
+
// `_x402.<domain>` and never says which domain a consumer holding a RESOURCE
|
|
208
|
+
// URL should ask for. A census of 1,609 hosts on 2026-08-22 asked
|
|
209
|
+
// `_x402.<exact-host>` and recorded the draft's own author as a non-publisher:
|
|
210
|
+
// our record is at the apex `flareclaw.app` with `wk=` pointing at the
|
|
211
|
+
// subdomain `api.flareclaw.app`. Publishing at the apex is the natural choice
|
|
212
|
+
// for an operator with one DNS zone and several service hosts, and the naive
|
|
213
|
+
// reading makes them invisible.
|
|
214
|
+
//
|
|
215
|
+
// So a consumer must be allowed to walk up. That immediately creates a worse
|
|
216
|
+
// problem: on shared hosting (`*.workers.dev`, `*.up.railway.app`) the ancestor
|
|
217
|
+
// belongs to the PLATFORM, not the tenant, and one record there would make
|
|
218
|
+
// every tenant beneath it discoverable — including tenants who never published
|
|
219
|
+
// anything, and including hosts that do not exist. Our own control demonstrates
|
|
220
|
+
// it: `zzq-...-9931.flareclaw.app` inherits our apex record.
|
|
221
|
+
//
|
|
222
|
+
// A public-suffix list would answer "is this ancestor an operator or a
|
|
223
|
+
// platform", and we deliberately do NOT use one: it is a mutable external
|
|
224
|
+
// dependency, it disagrees with reality on private suffixes, and being wrong
|
|
225
|
+
// silently converts into false adoption claims.
|
|
226
|
+
//
|
|
227
|
+
// THE RULE INSTEAD — the ancestor must NAME the host. An ancestor's manifest
|
|
228
|
+
// speaks for a descendant only if the manifest itself references that host, in
|
|
229
|
+
// `facilitator.baseUrl` or in `resources[]`. That is a positive statement by
|
|
230
|
+
// the party who controls the zone, it needs no external list, and it closes
|
|
231
|
+
// the shared-hosting hole exactly: `workers.dev` publishing a record does not
|
|
232
|
+
// make tenant X discoverable unless the record's manifest names tenant X.
|
|
233
|
+
/** How far up a consumer may walk. Two ancestors, never below two labels. */
|
|
234
|
+
export const MAX_ANCESTOR_STEPS = 2;
|
|
235
|
+
/**
|
|
236
|
+
* The `_x402` owner names to query for a host, nearest first.
|
|
237
|
+
*
|
|
238
|
+
* The FIRST entry is the host itself and is always authoritative for it. Later
|
|
239
|
+
* entries are ancestors, and a record found at one is only usable if
|
|
240
|
+
* `manifestCoversHost` accepts it.
|
|
241
|
+
*/
|
|
242
|
+
export function discoveryNamesFor(hostOrUrl) {
|
|
243
|
+
let host = hostOrUrl.trim().toLowerCase();
|
|
244
|
+
if (host.includes('://')) {
|
|
245
|
+
try {
|
|
246
|
+
host = new URL(host).hostname.toLowerCase();
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
host = host.replace(/\.$/, '').replace(/:\d+$/, '');
|
|
253
|
+
if (!host || host.startsWith('.') || host.includes('/') || host.includes(' '))
|
|
254
|
+
return [];
|
|
255
|
+
// THE SAME NORMALIZATION ON BOTH INPUT SHAPES. Before this line, a URL
|
|
256
|
+
// argument was A-labelled by the parser above and a BARE NAME was not, so
|
|
257
|
+
// `discoveryNamesFor('bücher.de')` and `discoveryNamesFor('https://bücher.de/')`
|
|
258
|
+
// returned different owner names for one host — and the query went to a
|
|
259
|
+
// name that does not exist. Same defect class as the same-domain rule
|
|
260
|
+
// (@Circadian-agent, x402#2979): a value normalized on one path and not
|
|
261
|
+
// the other.
|
|
262
|
+
const normalized = toComparableName(host);
|
|
263
|
+
if (!normalized)
|
|
264
|
+
return [];
|
|
265
|
+
host = normalized;
|
|
266
|
+
// AN ADDRESS LITERAL HAS NO NAME TO PUBLISH UNDER, so it gets no names at
|
|
267
|
+
// all — not even itself. The dots in an IPv4 address are not delegation:
|
|
268
|
+
// without this guard the walk asks for `_x402.0.0.1` and `_x402.0.1`, which
|
|
269
|
+
// are strangers' zones, and `isWkInDomain(wk, '0.1')` would then accept
|
|
270
|
+
// whatever was found there as speaking for 127.0.0.1. Found 2026-08-22
|
|
271
|
+
// wiring this resolver into a suite whose mock target is a loopback literal.
|
|
272
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.startsWith('[') || host.includes(':'))
|
|
273
|
+
return [];
|
|
274
|
+
const labels = host.split('.');
|
|
275
|
+
if (labels.length < 2 || labels.some(l => l === ''))
|
|
276
|
+
return [host].filter(Boolean);
|
|
277
|
+
const names = [host];
|
|
278
|
+
for (let i = 1; i <= MAX_ANCESTOR_STEPS && labels.length - i >= 2; i++) {
|
|
279
|
+
names.push(labels.slice(i).join('.'));
|
|
280
|
+
}
|
|
281
|
+
return names;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Does a manifest published at `ownerName` speak for `host`?
|
|
285
|
+
*
|
|
286
|
+
* Always true when the manifest was found at the host itself. For an ancestor,
|
|
287
|
+
* the manifest MUST name the host — otherwise a platform's record would vouch
|
|
288
|
+
* for every tenant under it, which is how a discovery layer manufactures
|
|
289
|
+
* adopters it does not have.
|
|
290
|
+
*
|
|
291
|
+
* Referenced URLs are only counted when they are themselves in-domain of the
|
|
292
|
+
* owner, so a manifest cannot vouch for a host it does not control either.
|
|
293
|
+
*/
|
|
294
|
+
export function manifestCoversHost(m, ownerName, host) {
|
|
295
|
+
// ALL THREE SIDES OF THIS COMPARISON GO THROUGH ONE NORMALIZER. Previously
|
|
296
|
+
// `owner` and `target` were lowercased and dot-stripped but never
|
|
297
|
+
// A-labelled, while the names read out of the manifest were A-labelled by
|
|
298
|
+
// the URL parser and never dot-stripped — so a manifest naming
|
|
299
|
+
// `https://api.example.com./x`, or a host given as a U-label, did not match
|
|
300
|
+
// itself. It failed CLOSED: the ancestor's manifest was judged not to cover
|
|
301
|
+
// the host, and a conforming publisher dropped out of discovery silently.
|
|
302
|
+
const owner = toComparableName(ownerName);
|
|
303
|
+
const target = toComparableName(host);
|
|
304
|
+
if (!owner || !target)
|
|
305
|
+
return false;
|
|
306
|
+
if (owner === target)
|
|
307
|
+
return true; // found at the host itself
|
|
308
|
+
if (!(target === owner || target.endsWith(`.${owner}`)))
|
|
309
|
+
return false; // not even below it
|
|
310
|
+
if (typeof m !== 'object' || m === null)
|
|
311
|
+
return false;
|
|
312
|
+
const man = m;
|
|
313
|
+
const hostOf = (u) => {
|
|
314
|
+
if (typeof u !== 'string')
|
|
315
|
+
return null;
|
|
316
|
+
try {
|
|
317
|
+
return toComparableName(new URL(u).hostname);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
const named = [];
|
|
324
|
+
const base = hostOf(man.facilitator?.baseUrl);
|
|
325
|
+
if (base)
|
|
326
|
+
named.push(base);
|
|
327
|
+
if (Array.isArray(man.resources)) {
|
|
328
|
+
for (const r of man.resources) {
|
|
329
|
+
const h = hostOf(typeof r === 'string' ? r : r?.url ?? r?.resource);
|
|
330
|
+
if (h)
|
|
331
|
+
named.push(h);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// A named host only counts if the OWNER could legitimately speak for it.
|
|
335
|
+
return named.some(n => n === target && (n === owner || n.endsWith(`.${owner}`)));
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Collect EVERY structural violation instead of stopping at the first.
|
|
339
|
+
*
|
|
340
|
+
* Why this exists (Melchiorre Oliva on PR #2979, 2026-08-18): our resolver
|
|
341
|
+
* refused his manifest with `invalid kind: undefined` and read nothing else.
|
|
342
|
+
* Everything else in the record was valid, but a first-throw validator cannot
|
|
343
|
+
* say so — the operator learns one field per debugging round-trip and has no
|
|
344
|
+
* way to tell "one edit away" from "fundamentally wrong". He measured 383
|
|
345
|
+
* hosts (39.5% of those serving a manifest) violating nothing except fields
|
|
346
|
+
* this extension introduces. That population is invisible unless a resolver
|
|
347
|
+
* reports the whole diagnosis and says which requirements are new.
|
|
348
|
+
*
|
|
349
|
+
* The GATE ITSELF IS UNCHANGED: resolveX402 still refuses a manifest that
|
|
350
|
+
* fails any rule. Strict enforcement, generous diagnostics.
|
|
351
|
+
*/
|
|
352
|
+
export function diagnoseManifest(m) {
|
|
353
|
+
const v = [];
|
|
354
|
+
if (typeof m !== 'object' || m === null) {
|
|
355
|
+
return { ok: false, violations: [{ field: '(root)', message: 'manifest is not an object', introducedBy: 'core' }] };
|
|
356
|
+
}
|
|
357
|
+
const man = m;
|
|
358
|
+
if (typeof man.x402Version !== 'number') {
|
|
359
|
+
v.push({ field: 'x402Version', message: 'missing x402Version', introducedBy: 'core' });
|
|
360
|
+
}
|
|
361
|
+
if (!['facilitator', 'resource-server', 'both'].includes(man.kind)) {
|
|
362
|
+
v.push({
|
|
363
|
+
field: 'kind',
|
|
364
|
+
message: man.kind === undefined
|
|
365
|
+
? 'missing kind — REQUIRED by this extension; add "facilitator", "resource-server" or "both"'
|
|
366
|
+
: `invalid kind: ${JSON.stringify(man.kind)}`,
|
|
367
|
+
introducedBy: 'x402-discovery',
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
// `facilitator` is an OBJECT (the capability block) when kind includes
|
|
371
|
+
// facilitator, and MAY be a STRING — the base URL of the third-party
|
|
372
|
+
// facilitator this host routes through — when kind is resource-server.
|
|
373
|
+
// 12 of 14 publishers carrying the field ship the string (Circadian-agent,
|
|
374
|
+
// x402#2979), and the spec now gives that incumbent a meaning. What it must
|
|
375
|
+
// never be is undefined: this block used to report a string as five
|
|
376
|
+
// missing sub-fields of an object, which told an operator to fix the wrong
|
|
377
|
+
// thing (doteyeso-ops, x402#2979 review).
|
|
378
|
+
const facKind = man.kind === 'facilitator' || man.kind === 'both';
|
|
379
|
+
if (man.facilitator !== undefined && typeof man.facilitator === 'string') {
|
|
380
|
+
if (facKind) {
|
|
381
|
+
v.push({ field: 'facilitator', message: 'facilitator must be an object when kind includes facilitator; a string names a third-party facilitator and is permitted only for kind "resource-server"', introducedBy: 'x402-discovery' });
|
|
382
|
+
}
|
|
383
|
+
else if (!/^https:\/\/[^\s/?#@]+/i.test(man.facilitator)) {
|
|
384
|
+
v.push({ field: 'facilitator', message: 'a string facilitator must be the HTTPS base URL of the facilitator this host routes through', introducedBy: 'x402-discovery' });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
else if (man.facilitator !== undefined && (typeof man.facilitator !== 'object' || man.facilitator === null || Array.isArray(man.facilitator))) {
|
|
388
|
+
v.push({ field: 'facilitator', message: 'facilitator must be an object (capability block) or, for a resource-server, an HTTPS URL string', introducedBy: 'x402-discovery' });
|
|
389
|
+
}
|
|
390
|
+
if (facKind && typeof man.facilitator !== 'string') {
|
|
391
|
+
const f = man.facilitator;
|
|
392
|
+
if (!f || typeof f !== 'object' || Array.isArray(f)) {
|
|
393
|
+
if (f === undefined)
|
|
394
|
+
v.push({ field: 'facilitator', message: 'kind includes facilitator but facilitator block missing', introducedBy: 'x402-discovery' });
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
if (typeof f.baseUrl !== 'string' || !f.baseUrl.startsWith('https://')) {
|
|
398
|
+
v.push({ field: 'facilitator.baseUrl', message: 'facilitator.baseUrl must be HTTPS', introducedBy: 'x402-discovery' });
|
|
399
|
+
}
|
|
400
|
+
for (const ep of ['supported', 'verify', 'settle']) {
|
|
401
|
+
const val = f.endpoints?.[ep];
|
|
402
|
+
if (typeof val !== 'string') {
|
|
403
|
+
v.push({ field: `facilitator.endpoints.${ep}`, message: `facilitator.endpoints.${ep} missing`, introducedBy: 'x402-discovery' });
|
|
404
|
+
}
|
|
405
|
+
else if (!isSafeRelativePath(val)) {
|
|
406
|
+
// An endpoint is concatenated onto baseUrl and fetched. If
|
|
407
|
+
// it can inject authority (`@victim.com/x`, `.victim.com/x`,
|
|
408
|
+
// `//victim.com/x`, an absolute `https://victim/x`), it aims
|
|
409
|
+
// a conforming crawler at a third party — the amplification
|
|
410
|
+
// the same-origin rule exists to stop, moved from baseUrl to
|
|
411
|
+
// the endpoints. Require a plain rooted path. (Review F1.)
|
|
412
|
+
v.push({ field: `facilitator.endpoints.${ep}`, message: `facilitator.endpoints.${ep} must be a rooted relative path (leading "/", no scheme/authority)`, introducedBy: 'x402-discovery' });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (!Array.isArray(f.kinds) || f.kinds.length === 0) {
|
|
416
|
+
v.push({ field: 'facilitator.kinds', message: 'facilitator.kinds must be a non-empty array', introducedBy: 'x402-discovery' });
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
for (const k of f.kinds) {
|
|
420
|
+
if (typeof k?.scheme !== 'string' || typeof k?.network !== 'string') {
|
|
421
|
+
v.push({ field: 'facilitator.kinds[]', message: 'each kind needs {scheme, network}', introducedBy: 'x402-discovery' });
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return { ok: v.length === 0, violations: v };
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* True when a manifest's ONLY problems are requirements this extension
|
|
432
|
+
* introduced — i.e. it was a valid x402 manifest before this spec and is one
|
|
433
|
+
* mechanical edit from valid after it. This is the population worth counting
|
|
434
|
+
* when anyone asks what the extension costs deployed hosts.
|
|
435
|
+
*/
|
|
436
|
+
export function isOneEditAway(m) {
|
|
437
|
+
const d = diagnoseManifest(m);
|
|
438
|
+
return !d.ok && d.violations.every(x => x.introducedBy === 'x402-discovery');
|
|
439
|
+
}
|
|
440
|
+
/** Structural validation per the spec. Throws with a reason on failure. */
|
|
441
|
+
export function validateManifest(m) {
|
|
442
|
+
const d = diagnoseManifest(m);
|
|
443
|
+
if (!d.ok) {
|
|
444
|
+
// Report ALL of it, and say when the record was valid before this
|
|
445
|
+
// extension existed — one debugging round-trip instead of one per
|
|
446
|
+
// field, and the operator can tell a small edit from a real problem.
|
|
447
|
+
const all = d.violations.map(x => `${x.field}: ${x.message}`).join('; ');
|
|
448
|
+
const hint = d.violations.every(x => x.introducedBy === 'x402-discovery')
|
|
449
|
+
? ' — all violations are fields THIS extension introduces; the manifest was otherwise valid'
|
|
450
|
+
: '';
|
|
451
|
+
throw new Error(`[x402-discovery] ${all}${hint}`);
|
|
452
|
+
}
|
|
453
|
+
return m;
|
|
454
|
+
}
|
|
455
|
+
// ──────────────────── Peer hints ────────────────────
|
|
456
|
+
//
|
|
457
|
+
// Peers are hints, not vouchers: each surviving entry is only a name to
|
|
458
|
+
// feed back into resolveX402() from the beginning, with every check in
|
|
459
|
+
// this module applied unchanged. Nothing transfers from the referring
|
|
460
|
+
// manifest, which is why an attacker-writable list is safe to consume —
|
|
461
|
+
// provided the consumer enforces the cap and the bare-name grammar
|
|
462
|
+
// below instead of dereferencing whatever it is handed.
|
|
463
|
+
/** Spec cap: entries beyond this are ignored (and worth flagging). */
|
|
464
|
+
export const MAX_PEER_HINTS = 32;
|
|
465
|
+
const BARE_DNS_NAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
|
466
|
+
/**
|
|
467
|
+
* Filter a manifest's `peers` array down to entries a consumer may act on:
|
|
468
|
+
* bare multi-label DNS names — no scheme, path, port, or userinfo, no IP
|
|
469
|
+
* literals, not the manifest's own domain — capped at MAX_PEER_HINTS.
|
|
470
|
+
* Everything dropped is returned as a signal, mirroring droppedResources.
|
|
471
|
+
*/
|
|
472
|
+
export function sanitizePeers(raw, ownDomain) {
|
|
473
|
+
if (!Array.isArray(raw))
|
|
474
|
+
return { peers: [], dropped: [] };
|
|
475
|
+
const own = ownDomain.toLowerCase().replace(/\.$/, '');
|
|
476
|
+
const peers = [];
|
|
477
|
+
const dropped = [];
|
|
478
|
+
const seen = new Set();
|
|
479
|
+
for (const entry of raw) {
|
|
480
|
+
if (typeof entry !== 'string')
|
|
481
|
+
continue;
|
|
482
|
+
const name = entry.trim().toLowerCase().replace(/\.$/, '');
|
|
483
|
+
const isIpV4 = /^[\d.]+$/.test(name);
|
|
484
|
+
if (!BARE_DNS_NAME.test(name) || isIpV4 || name.includes(':') || name === own) {
|
|
485
|
+
dropped.push(entry);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (seen.has(name))
|
|
489
|
+
continue;
|
|
490
|
+
seen.add(name);
|
|
491
|
+
if (peers.length >= MAX_PEER_HINTS) {
|
|
492
|
+
dropped.push(entry);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
peers.push(name);
|
|
496
|
+
}
|
|
497
|
+
return { peers, dropped };
|
|
498
|
+
}
|
|
499
|
+
// ──────────────────── Resolution algorithm ────────────────────
|
|
500
|
+
/** Bounded so a redirect loop can't spin a crawler. */
|
|
501
|
+
const MAX_MANIFEST_REDIRECTS = 3;
|
|
502
|
+
/** A manifest is a small JSON document. Anything larger is an attack. */
|
|
503
|
+
const MAX_MANIFEST_BYTES = 256 * 1024;
|
|
504
|
+
const MANIFEST_TIMEOUT_MS = 10_000;
|
|
505
|
+
// ── Resolved-address guard ──
|
|
506
|
+
//
|
|
507
|
+
// HTTPS-only plus the in-domain rule is NOT enough to stop a crawler
|
|
508
|
+
// being aimed inside its own network: the publisher controls their own
|
|
509
|
+
// DNS, so an in-domain hostname can resolve to 10.0.0.5 or
|
|
510
|
+
// 169.254.169.254, and a DNS-01 ACME challenge issues a perfectly valid
|
|
511
|
+
// certificate for a name that never points anywhere public. TLS then
|
|
512
|
+
// fails against the internal victim, but the crawler has still made the
|
|
513
|
+
// connection — a connect/port-probe primitive handed to anyone who can
|
|
514
|
+
// publish a TXT record. So: resolve first, refuse private destinations.
|
|
515
|
+
/** True for addresses no discovery fetch should ever connect to:
|
|
516
|
+
* loopback, link-local, RFC1918/ULA private ranges, unspecified. */
|
|
517
|
+
export function isPrivateAddress(ip) {
|
|
518
|
+
const v4 = ip.match(/^(?:::ffff:)?(\d+)\.(\d+)\.(\d+)\.(\d+)$/i);
|
|
519
|
+
if (v4) {
|
|
520
|
+
const [a, b] = [Number(v4[1]), Number(v4[2])];
|
|
521
|
+
return a === 0 || a === 10 || a === 127
|
|
522
|
+
|| (a === 100 && b >= 64 && b <= 127) // CGNAT
|
|
523
|
+
|| (a === 169 && b === 254) // link-local (cloud metadata)
|
|
524
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
525
|
+
|| (a === 192 && b === 168);
|
|
526
|
+
}
|
|
527
|
+
const v6 = ip.toLowerCase();
|
|
528
|
+
return v6 === '::' || v6 === '::1'
|
|
529
|
+
|| v6.startsWith('fe80:') // link-local
|
|
530
|
+
|| v6.startsWith('fc') || v6.startsWith('fd'); // ULA fc00::/7
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Reject a URL whose host is, or resolves to, a private address. Checked
|
|
534
|
+
* before EVERY fetch, including each redirect hop — a public first hop
|
|
535
|
+
* redirecting to an internal name is the classic bypass. (DNS rebinding
|
|
536
|
+
* between this check and the connect remains possible; this is the
|
|
537
|
+
* standard mitigation, not a sandbox — pin resolution if you need more.)
|
|
538
|
+
*/
|
|
539
|
+
async function assertPublicDestination(url, lookup) {
|
|
540
|
+
const host = new URL(url).hostname.replace(/^\[|\]$/g, '');
|
|
541
|
+
// Literal IP: judge it directly.
|
|
542
|
+
if (/^[\d.]+$/.test(host) || host.includes(':')) {
|
|
543
|
+
if (isPrivateAddress(host)) {
|
|
544
|
+
throw new Error(`[x402-discovery] refusing private destination ${host} (${url})`);
|
|
545
|
+
}
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const addrs = await lookup(host);
|
|
549
|
+
if (addrs.length === 0)
|
|
550
|
+
throw new Error(`[x402-discovery] ${host} resolved to no addresses`);
|
|
551
|
+
const bad = addrs.find(isPrivateAddress);
|
|
552
|
+
if (bad) {
|
|
553
|
+
throw new Error(`[x402-discovery] refusing ${host}: resolves to private address ${bad} (${url})`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
async function defaultLookup(hostname) {
|
|
557
|
+
const dns = await import('dns');
|
|
558
|
+
const results = await dns.promises.lookup(hostname, { all: true, verbatim: true });
|
|
559
|
+
return results.map(r => r.address);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Fetch a manifest with a deadline and a size cap.
|
|
563
|
+
*
|
|
564
|
+
* Neither existed before: a hostile host could serve a multi-gigabyte
|
|
565
|
+
* body, or trickle bytes forever, and every conforming crawler would
|
|
566
|
+
* hang or exhaust memory. Discovery is designed to be crawled by
|
|
567
|
+
* strangers, so an unbounded read is a denial-of-service handed to
|
|
568
|
+
* anyone who publishes a TXT record.
|
|
569
|
+
*/
|
|
570
|
+
async function fetchBounded(url, fetchImpl) {
|
|
571
|
+
const res = await fetchImpl(url, {
|
|
572
|
+
headers: { Accept: 'application/json' },
|
|
573
|
+
redirect: 'manual',
|
|
574
|
+
signal: AbortSignal.timeout(MANIFEST_TIMEOUT_MS),
|
|
575
|
+
});
|
|
576
|
+
if (res.status >= 300 && res.status < 400)
|
|
577
|
+
return { res, body: undefined };
|
|
578
|
+
if (!res.ok)
|
|
579
|
+
return { res, body: undefined };
|
|
580
|
+
const declaredHdr = res.headers?.get?.('content-length');
|
|
581
|
+
const declared = declaredHdr != null ? Number(declaredHdr) : NaN;
|
|
582
|
+
if (Number.isFinite(declared) && declared > MAX_MANIFEST_BYTES) {
|
|
583
|
+
throw new DiscoveryViolation(`[x402-discovery] manifest too large: ${declared} bytes (cap ${MAX_MANIFEST_BYTES})`);
|
|
584
|
+
}
|
|
585
|
+
// STREAM and abort mid-body the moment the cap is exceeded. res.text() and
|
|
586
|
+
// res.json() both buffer the WHOLE body into memory first, so a host that
|
|
587
|
+
// omits Content-Length (the check above is then skipped) could push
|
|
588
|
+
// gigabytes before any length test fires — a memory-exhaustion DoS handed
|
|
589
|
+
// to every conforming crawler. The 10s timeout caps duration, not peak
|
|
590
|
+
// memory. (Adversarial F2 / silent-failure 1d.)
|
|
591
|
+
const reader = res.body?.getReader?.();
|
|
592
|
+
if (reader) {
|
|
593
|
+
const chunks = [];
|
|
594
|
+
let total = 0;
|
|
595
|
+
for (;;) {
|
|
596
|
+
const { done, value } = await reader.read();
|
|
597
|
+
if (done)
|
|
598
|
+
break;
|
|
599
|
+
total += value.byteLength;
|
|
600
|
+
if (total > MAX_MANIFEST_BYTES) {
|
|
601
|
+
await reader.cancel().catch(() => { });
|
|
602
|
+
throw new DiscoveryViolation(`[x402-discovery] manifest exceeded ${MAX_MANIFEST_BYTES} bytes mid-stream`);
|
|
603
|
+
}
|
|
604
|
+
chunks.push(value);
|
|
605
|
+
}
|
|
606
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
607
|
+
return { res, body: JSON.parse(raw) };
|
|
608
|
+
}
|
|
609
|
+
// No stream reader (an injected test fetch): text() still caps by measured
|
|
610
|
+
// length. REFUSE the json() fallback — an unbounded read would silently
|
|
611
|
+
// disable the one control this function exists to provide.
|
|
612
|
+
if (typeof res.text === 'function') {
|
|
613
|
+
const raw = await res.text();
|
|
614
|
+
if (raw.length > MAX_MANIFEST_BYTES) {
|
|
615
|
+
throw new DiscoveryViolation(`[x402-discovery] manifest exceeded ${MAX_MANIFEST_BYTES} bytes`);
|
|
616
|
+
}
|
|
617
|
+
return { res, body: JSON.parse(raw) };
|
|
618
|
+
}
|
|
619
|
+
throw new Error('[x402-discovery] response exposes neither body stream nor text() — cannot bound the read, refusing');
|
|
620
|
+
}
|
|
621
|
+
export const NEGATIVE_TTL_MS = Object.freeze({
|
|
622
|
+
'no-record': 30 * 60_000,
|
|
623
|
+
unreachable: 60_000,
|
|
624
|
+
invalid: 5 * 60_000,
|
|
625
|
+
});
|
|
626
|
+
/** Bounded so a crawler sweeping many domains cannot grow it forever. */
|
|
627
|
+
const NEGATIVE_CACHE_MAX = 5_000;
|
|
628
|
+
const negativeCache = new Map();
|
|
629
|
+
export class X402NegativeCacheError extends Error {
|
|
630
|
+
reason;
|
|
631
|
+
cachedMessage;
|
|
632
|
+
constructor(reason, cachedMessage, domain) {
|
|
633
|
+
super(`[x402-discovery] ${domain} cached as ${reason}: ${cachedMessage}`);
|
|
634
|
+
this.reason = reason;
|
|
635
|
+
this.cachedMessage = cachedMessage;
|
|
636
|
+
this.name = 'X402NegativeCacheError';
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function rememberNegative(domain, reason, message, now, ttls) {
|
|
640
|
+
// Oldest-out when full. A crawler's working set is far smaller than
|
|
641
|
+
// its total reach, so this is enough to stop unbounded growth
|
|
642
|
+
// without pretending to be an LRU.
|
|
643
|
+
if (negativeCache.size >= NEGATIVE_CACHE_MAX) {
|
|
644
|
+
const oldest = negativeCache.keys().next();
|
|
645
|
+
if (!oldest.done)
|
|
646
|
+
negativeCache.delete(oldest.value);
|
|
647
|
+
}
|
|
648
|
+
negativeCache.set(domain, { reason, message, expiresAt: now + ttls[reason] });
|
|
649
|
+
}
|
|
650
|
+
/** Drop cached negatives — tests, and operator triage after a fix. */
|
|
651
|
+
export function clearDiscoveryCache(domain) {
|
|
652
|
+
if (domain)
|
|
653
|
+
negativeCache.delete(domain);
|
|
654
|
+
else
|
|
655
|
+
negativeCache.clear();
|
|
656
|
+
}
|
|
657
|
+
/** Inspect the cache without mutating it (observability, tests). */
|
|
658
|
+
export function discoveryCacheEntry(domain, now = Date.now()) {
|
|
659
|
+
const hit = negativeCache.get(domain);
|
|
660
|
+
if (!hit || hit.expiresAt <= now)
|
|
661
|
+
return null;
|
|
662
|
+
return { reason: hit.reason, message: hit.message, expiresInMs: hit.expiresAt - now };
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Resolve a domain's x402 capability per the spec:
|
|
666
|
+
* TXT `_x402.<domain>` → manifest URL, else `https://<domain>/.well-known/x402`;
|
|
667
|
+
* then cross-check the facilitator's live `supported` endpoint.
|
|
668
|
+
*/
|
|
669
|
+
export async function resolveX402(domain, opts) {
|
|
670
|
+
const fetchImpl = opts?.fetchImpl ?? fetch;
|
|
671
|
+
const nowFn = opts?.now ?? Date.now;
|
|
672
|
+
const ttls = { ...NEGATIVE_TTL_MS, ...(opts?.negativeTtlMs ?? {}) };
|
|
673
|
+
// Private-destination guard. FAIL-CLOSED refinement (F4): the guard is
|
|
674
|
+
// disabled (null) ONLY when the caller has gone FULLY OFFLINE — injecting
|
|
675
|
+
// BOTH a fake resolveTxt AND a fake fetchImpl, i.e. a test with no real
|
|
676
|
+
// network where the guard is moot. A production caller injecting only a
|
|
677
|
+
// custom fetchImpl (a retry/proxy wrapper) still resolves real DNS, so it
|
|
678
|
+
// keeps the real guard rather than silently losing SSRF protection.
|
|
679
|
+
// lookupImpl always wins when supplied.
|
|
680
|
+
const lookup = opts?.lookupImpl
|
|
681
|
+
?? (opts?.fetchImpl && opts?.resolveTxt ? null : defaultLookup);
|
|
682
|
+
const guard = async (url) => { if (lookup)
|
|
683
|
+
await assertPublicDestination(url, lookup); };
|
|
684
|
+
const resolveTxt = opts?.resolveTxt ?? (async (name) => {
|
|
685
|
+
const dns = await import('dns');
|
|
686
|
+
return dns.promises.resolveTxt(name);
|
|
687
|
+
});
|
|
688
|
+
if (!opts?.noCache) {
|
|
689
|
+
const hit = negativeCache.get(domain);
|
|
690
|
+
if (hit && hit.expiresAt > nowFn()) {
|
|
691
|
+
throw new X402NegativeCacheError(hit.reason, hit.message, domain);
|
|
692
|
+
}
|
|
693
|
+
if (hit)
|
|
694
|
+
negativeCache.delete(domain); // expired: re-probe
|
|
695
|
+
}
|
|
696
|
+
/** Classify a failure, remember it, and rethrow unchanged. */
|
|
697
|
+
const failWith = (reason, e) => {
|
|
698
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
699
|
+
if (!opts?.noCache)
|
|
700
|
+
rememberNegative(domain, reason, message, nowFn(), ttls);
|
|
701
|
+
throw e;
|
|
702
|
+
};
|
|
703
|
+
let manifestUrl = `https://${domain}/.well-known/x402`;
|
|
704
|
+
let via = 'well-known';
|
|
705
|
+
let txtRecord;
|
|
706
|
+
try {
|
|
707
|
+
const records = await resolveTxt(`_x402.${domain}`);
|
|
708
|
+
// Take ALL parseable records, not the first. "First one wins" is a
|
|
709
|
+
// race anyone who can add a single TXT record can win by ordering —
|
|
710
|
+
// a shared DNS panel, a delegated subdomain, a partial compromise.
|
|
711
|
+
// SPF and DMARC both treat duplicates as a hard error for exactly
|
|
712
|
+
// this reason, and we hit the DMARC version of it on our own domain.
|
|
713
|
+
const parsedAll = records
|
|
714
|
+
.map(chunks => parseX402TxtRecord(chunks.join('')))
|
|
715
|
+
.filter((r) => r !== null);
|
|
716
|
+
if (parsedAll.length > 1) {
|
|
717
|
+
throw new DiscoveryViolation(`[x402-discovery] ${parsedAll.length} conflicting v=x402-1 TXT records at _x402.${domain} — ambiguous, refusing to choose`);
|
|
718
|
+
}
|
|
719
|
+
const parsed = parsedAll[0];
|
|
720
|
+
if (parsed) {
|
|
721
|
+
if (!isWkInDomain(parsed.wk, domain)) {
|
|
722
|
+
throw new DiscoveryViolation(`[x402-discovery] TXT wk points outside ${domain}: ${parsed.wk}`);
|
|
723
|
+
}
|
|
724
|
+
manifestUrl = parsed.wk;
|
|
725
|
+
via = 'dns-txt';
|
|
726
|
+
txtRecord = parsed;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
catch (e) {
|
|
730
|
+
// A HARD violation (malformed/spoofing record) caches as `invalid` — an
|
|
731
|
+
// operator must fix DNS, and it must NOT be silently ignored and fall
|
|
732
|
+
// through to well-known as if the domain published nothing. Everything
|
|
733
|
+
// else (NXDOMAIN, no TXT, SERVFAIL, timeout) is a genuine DNS miss and
|
|
734
|
+
// falls through. Classified by TYPE, so an attacker cannot dodge it
|
|
735
|
+
// with a crafted message and a real parse bug cannot masquerade as a
|
|
736
|
+
// miss. (Silent-failure 1a.)
|
|
737
|
+
if (e instanceof DiscoveryViolation)
|
|
738
|
+
failWith('invalid', e);
|
|
739
|
+
}
|
|
740
|
+
// Follow redirects MANUALLY, re-applying the in-domain rule to every
|
|
741
|
+
// hop. Default `redirect: 'follow'` checked the URL we asked for, not
|
|
742
|
+
// the URL the bytes came from: an in-domain `wk` that 302s to
|
|
743
|
+
// https://evil.example/.well-known/x402 sailed through the check and
|
|
744
|
+
// we reported the in-domain URL as its source. That defeats the one
|
|
745
|
+
// control the (unauthenticated) DNS layer has, and turns a conforming
|
|
746
|
+
// crawler into the open redirect the spec explicitly claims to prevent.
|
|
747
|
+
let fetched;
|
|
748
|
+
let res;
|
|
749
|
+
try {
|
|
750
|
+
await guard(manifestUrl);
|
|
751
|
+
fetched = await fetchBounded(manifestUrl, fetchImpl);
|
|
752
|
+
res = fetched.res;
|
|
753
|
+
for (let hop = 0; res.status >= 300 && res.status < 400 && hop < MAX_MANIFEST_REDIRECTS; hop++) {
|
|
754
|
+
const location = res.headers.get('location');
|
|
755
|
+
if (!location)
|
|
756
|
+
break;
|
|
757
|
+
const nextUrl = new URL(location, manifestUrl);
|
|
758
|
+
const next = nextUrl.toString();
|
|
759
|
+
// isWkInDomain checks only the host, not the scheme. An in-domain
|
|
760
|
+
// https manifest that 302s to http://same-domain/m passed the host
|
|
761
|
+
// check and was then fetched over cleartext — a silent downgrade.
|
|
762
|
+
// (Review F3.)
|
|
763
|
+
if (nextUrl.protocol !== 'https:') {
|
|
764
|
+
throw new DiscoveryViolation(`[x402-discovery] manifest redirect downgrades to non-HTTPS: ${next}`);
|
|
765
|
+
}
|
|
766
|
+
if (!isWkInDomain(next, domain)) {
|
|
767
|
+
throw new DiscoveryViolation(`[x402-discovery] manifest redirect leaves ${domain}: ${next}`);
|
|
768
|
+
}
|
|
769
|
+
manifestUrl = next; // report where the bytes ACTUALLY came from
|
|
770
|
+
await guard(manifestUrl); // every hop — a public host redirecting inward is the classic bypass
|
|
771
|
+
fetched = await fetchBounded(manifestUrl, fetchImpl);
|
|
772
|
+
res = fetched.res;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
catch (e) {
|
|
776
|
+
// A DiscoveryViolation (redirect leaves domain, HTTPS downgrade, size
|
|
777
|
+
// breach) is a spoofing/DoS signal an operator must FIX — it caches as
|
|
778
|
+
// `invalid` (5 min), not the short `unreachable` TTL an outage gets, so
|
|
779
|
+
// a redirect-out-of-domain does not get the fast-recovery treatment.
|
|
780
|
+
// (Review F11 / silent-failure 1c.) Everything else is a genuine
|
|
781
|
+
// transport failure.
|
|
782
|
+
if (e instanceof DiscoveryViolation)
|
|
783
|
+
return failWith('invalid', e);
|
|
784
|
+
return failWith('unreachable', e);
|
|
785
|
+
}
|
|
786
|
+
if (res.status >= 300 && res.status < 400) {
|
|
787
|
+
failWith('invalid', new Error(`[x402-discovery] too many manifest redirects for ${domain}`));
|
|
788
|
+
}
|
|
789
|
+
if (!res.ok) {
|
|
790
|
+
// 404 is the ordinary "this domain does not speak x402" answer —
|
|
791
|
+
// the majority answer across the internet, and the one worth
|
|
792
|
+
// remembering longest. Other statuses are server-side trouble.
|
|
793
|
+
failWith(res.status === 404 || res.status === 410 ? 'no-record' : 'unreachable', new Error(`[x402-discovery] manifest fetch failed: ${res.status} ${manifestUrl}`));
|
|
794
|
+
}
|
|
795
|
+
let manifest;
|
|
796
|
+
try {
|
|
797
|
+
manifest = validateManifest(fetched.body);
|
|
798
|
+
}
|
|
799
|
+
catch (e) {
|
|
800
|
+
// Served, but wrong. Needs an operator deploy to fix.
|
|
801
|
+
return failWith('invalid', e);
|
|
802
|
+
}
|
|
803
|
+
// Same-origin rule applied to the manifest's OWN pointer. Validating
|
|
804
|
+
// only the TXT `wk` left the rule one hop from useless: evil.example
|
|
805
|
+
// could serve a fully valid manifest naming someone else's facilitator
|
|
806
|
+
// as its own — "claiming another operator's facilitator", which the
|
|
807
|
+
// spec asserts is prevented — and every conforming crawler would then
|
|
808
|
+
// hammer that third party's /supported on its behalf.
|
|
809
|
+
if (manifest.facilitator && !isWkInDomain(manifest.facilitator.baseUrl, domain)) {
|
|
810
|
+
failWith('invalid', new Error(`[x402-discovery] facilitator.baseUrl points outside ${domain}: ${manifest.facilitator.baseUrl}`));
|
|
811
|
+
}
|
|
812
|
+
// Execute the spec's own "absent verification, treat as none" rule
|
|
813
|
+
// instead of merely writing it down. A `tee` block whose verifier is
|
|
814
|
+
// not a dereferenceable HTTPS URL cannot be independently checked by
|
|
815
|
+
// anyone, so it must not read as attested — that is the difference
|
|
816
|
+
// between a claim and a verified claim, which is the whole product.
|
|
817
|
+
// `attestationUsable` means "there is a same-origin HTTPS verifier a
|
|
818
|
+
// consumer could dereference", NOT "the attestation is verified" — this
|
|
819
|
+
// resolver does not fetch the verifier. It is deliberately conservative:
|
|
820
|
+
// the verifier must be in-domain, the same rule baseUrl and resources[]
|
|
821
|
+
// obey, so a manifest cannot get a positive signal by naming an
|
|
822
|
+
// attacker-run verifier. A cross-domain verifier leaves the value false
|
|
823
|
+
// rather than pointing a trusting consumer off-origin. (Review F10.)
|
|
824
|
+
const att = manifest.attestation;
|
|
825
|
+
const attestationUsable = !!att && att.type !== 'none'
|
|
826
|
+
&& typeof att.verifier === 'string' && att.verifier.startsWith('https://')
|
|
827
|
+
&& isWkInDomain(att.verifier, domain);
|
|
828
|
+
// ── SSRF defence (the spec tells crawlers to fetch these) ──
|
|
829
|
+
// `resources[].url` was validated nowhere, while the resolution
|
|
830
|
+
// algorithm explicitly invites indexers to probe each one. That is
|
|
831
|
+
// server-side request forgery by specification: a host lists
|
|
832
|
+
// http://169.254.169.254/latest/meta-data/ or an internal address and
|
|
833
|
+
// every conforming crawler fetches it from INSIDE its own network.
|
|
834
|
+
// We constrained `wk` and `baseUrl` and left the field designed to be
|
|
835
|
+
// dereferenced. Same rule, applied consistently.
|
|
836
|
+
const droppedResources = [];
|
|
837
|
+
if (Array.isArray(manifest.resources)) {
|
|
838
|
+
const kept = manifest.resources.filter(r => {
|
|
839
|
+
const ok = typeof r?.url === 'string'
|
|
840
|
+
&& r.url.startsWith('https://')
|
|
841
|
+
&& isWkInDomain(r.url, domain);
|
|
842
|
+
if (!ok && typeof r?.url === 'string')
|
|
843
|
+
droppedResources.push(r.url);
|
|
844
|
+
return ok;
|
|
845
|
+
});
|
|
846
|
+
if (kept.length !== manifest.resources.length) {
|
|
847
|
+
manifest.resources = kept;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
// Peer hints: sanitize before anyone can act on them. The raw array is
|
|
851
|
+
// attacker-writable input by design; what leaves this resolver is only
|
|
852
|
+
// names that a consumer may lawfully feed back into resolveX402().
|
|
853
|
+
const peerScan = sanitizePeers(manifest.peers, domain);
|
|
854
|
+
if (manifest.peers !== undefined) {
|
|
855
|
+
manifest.peers = peerScan.peers;
|
|
856
|
+
}
|
|
857
|
+
const out = {
|
|
858
|
+
manifest, manifestUrl, via, attestationUsable,
|
|
859
|
+
...(peerScan.peers.length ? { peers: peerScan.peers } : {}),
|
|
860
|
+
...(peerScan.dropped.length ? { droppedPeers: peerScan.dropped } : {}),
|
|
861
|
+
...(droppedResources.length ? { droppedResources } : {}),
|
|
862
|
+
...(txtRecord ? { txtRecord } : {}),
|
|
863
|
+
};
|
|
864
|
+
if (opts?.skipLiveCheck || !manifest.facilitator) {
|
|
865
|
+
out.liveCheck = 'skipped';
|
|
866
|
+
return out;
|
|
867
|
+
}
|
|
868
|
+
{
|
|
869
|
+
const f = manifest.facilitator;
|
|
870
|
+
// liveCheck is an EXPLICIT discriminant: without it, a live /supported
|
|
871
|
+
// that 500s or serves a body with no kinds[] produces the identical
|
|
872
|
+
// shape as skipLiveCheck — so "we didn't check" reads as "we checked
|
|
873
|
+
// and it matched". A consumer treating kindsMatchLive !== false as
|
|
874
|
+
// "consistent" would accept a facilitator whose one live signal is
|
|
875
|
+
// dead. (Silent-failure 1b.)
|
|
876
|
+
const liveUrl = `${f.baseUrl}${f.endpoints.supported}`;
|
|
877
|
+
// The endpoint was validated as a rooted path at diagnoseManifest, but
|
|
878
|
+
// re-check the ASSEMBLED url is same-origin before fetching — defense in
|
|
879
|
+
// depth against the F1 amplification. baseUrl is proven in-domain; this
|
|
880
|
+
// closes the concatenation.
|
|
881
|
+
if (!isSafeRelativePath(f.endpoints.supported) || !isWkInDomain(liveUrl, domain)) {
|
|
882
|
+
out.liveCheck = 'refused';
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
// SECURITY controls run OUTSIDE the health-catch and PROPAGATE: an
|
|
886
|
+
// SSRF (private-address) attempt or an oversized-body DoS on the
|
|
887
|
+
// live URL is serious enough to refuse the whole resolution, not to
|
|
888
|
+
// downgrade to a quiet 'unreachable'. guard() throws on a private
|
|
889
|
+
// destination; fetchBounded throws DiscoveryViolation on a size
|
|
890
|
+
// breach.
|
|
891
|
+
await guard(liveUrl);
|
|
892
|
+
try {
|
|
893
|
+
const live = await fetchBounded(liveUrl, fetchImpl);
|
|
894
|
+
if (!live.res.ok) {
|
|
895
|
+
out.liveCheck = 'unreachable';
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
const body = live.body;
|
|
899
|
+
if (!Array.isArray(body?.kinds)) {
|
|
900
|
+
out.liveCheck = 'malformed';
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
const norm = (ks) => ks
|
|
904
|
+
.map(k => `${k.x402Version}|${k.scheme}|${k.network}`)
|
|
905
|
+
.sort()
|
|
906
|
+
.join(',');
|
|
907
|
+
out.liveKinds = body.kinds;
|
|
908
|
+
out.kindsMatchLive = norm(body.kinds) === norm(f.kinds);
|
|
909
|
+
out.liveCheck = 'ok';
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
catch (e) {
|
|
914
|
+
// A size-cap breach is a DoS — re-throw it. A transport failure,
|
|
915
|
+
// redirect, or malformed JSON is just a dead/misconfigured live
|
|
916
|
+
// signal — a HEALTH outcome, not a crash out of resolveX402.
|
|
917
|
+
// (Silent-failure 1b/1c.)
|
|
918
|
+
if (e instanceof DiscoveryViolation)
|
|
919
|
+
throw e;
|
|
920
|
+
out.liveCheck = 'unreachable';
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
return out;
|
|
925
|
+
}
|
|
926
|
+
//# sourceMappingURL=discovery.js.map
|