@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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +198 -0
  3. package/dist/attested-directory.d.ts +78 -0
  4. package/dist/attested-directory.d.ts.map +1 -0
  5. package/dist/attested-directory.js +163 -0
  6. package/dist/attested-directory.js.map +1 -0
  7. package/dist/badges.d.ts +131 -0
  8. package/dist/badges.d.ts.map +1 -0
  9. package/dist/badges.js +213 -0
  10. package/dist/badges.js.map +1 -0
  11. package/dist/catalog-mcp.d.ts +145 -0
  12. package/dist/catalog-mcp.d.ts.map +1 -0
  13. package/dist/catalog-mcp.js +409 -0
  14. package/dist/catalog-mcp.js.map +1 -0
  15. package/dist/census-reconcile.d.ts +69 -0
  16. package/dist/census-reconcile.d.ts.map +1 -0
  17. package/dist/census-reconcile.js +162 -0
  18. package/dist/census-reconcile.js.map +1 -0
  19. package/dist/directory-classify.d.ts +47 -0
  20. package/dist/directory-classify.d.ts.map +1 -0
  21. package/dist/directory-classify.js +54 -0
  22. package/dist/directory-classify.js.map +1 -0
  23. package/dist/discovery.d.ts +304 -0
  24. package/dist/discovery.d.ts.map +1 -0
  25. package/dist/discovery.js +926 -0
  26. package/dist/discovery.js.map +1 -0
  27. package/dist/evidence-ref.d.ts +96 -0
  28. package/dist/evidence-ref.d.ts.map +1 -0
  29. package/dist/evidence-ref.js +179 -0
  30. package/dist/evidence-ref.js.map +1 -0
  31. package/dist/facilitator-config.d.ts +83 -0
  32. package/dist/facilitator-config.d.ts.map +1 -0
  33. package/dist/facilitator-config.js +94 -0
  34. package/dist/facilitator-config.js.map +1 -0
  35. package/dist/index.d.ts +15 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +18 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/manifest-sig.d.ts +131 -0
  40. package/dist/manifest-sig.d.ts.map +1 -0
  41. package/dist/manifest-sig.js +262 -0
  42. package/dist/manifest-sig.js.map +1 -0
  43. package/dist/merkle-transcript.d.ts +34 -0
  44. package/dist/merkle-transcript.d.ts.map +1 -0
  45. package/dist/merkle-transcript.js +162 -0
  46. package/dist/merkle-transcript.js.map +1 -0
  47. package/dist/open-catalog.d.ts +111 -0
  48. package/dist/open-catalog.d.ts.map +1 -0
  49. package/dist/open-catalog.js +307 -0
  50. package/dist/open-catalog.js.map +1 -0
  51. package/dist/reconcile.d.ts +150 -0
  52. package/dist/reconcile.d.ts.map +1 -0
  53. package/dist/reconcile.js +136 -0
  54. package/dist/reconcile.js.map +1 -0
  55. package/dist/trust-sdk.d.ts +127 -0
  56. package/dist/trust-sdk.d.ts.map +1 -0
  57. package/dist/trust-sdk.js +147 -0
  58. package/dist/trust-sdk.js.map +1 -0
  59. package/dist/types.d.ts +213 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +68 -0
  62. package/dist/types.js.map +1 -0
  63. 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