agent-receipt-verify 0.0.0-stage → 0.2.1
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/CONFORMANCE.md +110 -0
- package/LICENSE +201 -0
- package/NOTICE +39 -0
- package/README.md +256 -2
- package/bin/cli.mjs +89 -0
- package/examples/receipt-live-base-sepolia-2.json +21 -0
- package/examples/receipt-live-base-sepolia-3.json +21 -0
- package/examples/receipt-live-base-sepolia.json +21 -0
- package/package.json +59 -4
- package/profiles/profile.schema.json +115 -0
- package/profiles/x402-deployed-ladder-v1.json +178 -0
- package/src/eip3009-autorisatie.mjs +195 -0
- package/src/inam-countersigned.mjs +162 -0
- package/src/mcp-server.mjs +262 -0
- package/src/profiel.mjs +145 -0
- package/src/x402-conformance-check.mjs +194 -0
- package/src/x402-conformance-treden.mjs +246 -0
- package/src/x402-discovery-census.mjs +247 -0
- package/src/x402-index-liveness.mjs +201 -0
- package/src/x402-link-onchain.mjs +280 -0
- package/src/x402-metingen-bundel.mjs +96 -0
- package/src/x402-offer-receipt.mjs +84 -0
- package/src/x402-poortwachters.mjs +241 -0
- package/tools/bundel-bouw.mjs +126 -0
- package/vectors/inam/BRON-README.md +38 -0
- package/vectors/inam/canonical-vectors.json +122 -0
- package/vectors/inam/vectors.json +96 -0
- package/vectors/kanon/amount-insufficient-001.json +52 -0
- package/vectors/kanon/cross-chain-replay-001.json +54 -0
- package/vectors/kanon/cross-contract-replay-001.json +54 -0
- package/vectors/kanon/domain-name-mismatch-001.json +53 -0
- package/vectors/kanon/expired-001.json +51 -0
- package/vectors/kanon/network-mismatch-001.json +52 -0
- package/vectors/kanon/nonce-replay-001.json +56 -0
- package/vectors/kanon/not-yet-valid-001.json +51 -0
- package/vectors/kanon/sig-malleable-high-s-001.json +54 -0
- package/vectors/kanon/valid-baseline-001.json +52 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Census of the DECLARED layer of one x402 discovery index, counted per host.
|
|
2
|
+
//
|
|
3
|
+
// WAAROM DIT APART STAAT VAN x402-conformance-check.mjs. Dat bestand meet een
|
|
4
|
+
// eindpunt LIVE: het stuurt een verzoek en leest wat er terugkomt. Dit bestand
|
|
5
|
+
// raakt geen enkel eindpunt van iemand anders aan. Het leest alleen wat partijen
|
|
6
|
+
// ZELF in een discovery-index hebben laten registreren. Dat zijn twee
|
|
7
|
+
// verschillende beweringen en ze mogen nooit tot een cijfer samengeknepen worden:
|
|
8
|
+
//
|
|
9
|
+
// gedeclareerd = wat in de index staat, door de partij zelf aangemeld
|
|
10
|
+
// live = wat het eindpunt vandaag werkelijk antwoordt
|
|
11
|
+
//
|
|
12
|
+
// Een census van de eerste is goedkoop en volledig. Een meting van de tweede kost
|
|
13
|
+
// een verzoek aan iemand anders en kan daarom alleen op een steekproef. Daarom
|
|
14
|
+
// deze volgorde: eerst de census, en de census bepaalt waar de live-steekproef
|
|
15
|
+
// naar kijkt. Een willekeurige steekproef trekken voor je weet wat er te vinden
|
|
16
|
+
// is, is verzoeken verspillen aan andermans server.
|
|
17
|
+
//
|
|
18
|
+
// DE EENHEID IS DE HOST, NIET DE RESOURCE. Een partij met driehonderd paden zou
|
|
19
|
+
// anders driehonderd keer zo zwaar wegen als een partij met een pad. Zie
|
|
20
|
+
// CONFORMANCE.md; dit bestand telt op dezelfde eenheid.
|
|
21
|
+
//
|
|
22
|
+
// WAT DIT NIET IS, en dit hoort in elke uitslag die eruit komt:
|
|
23
|
+
// - geen ecosysteem-meting. Het is EEN index, op EEN dag.
|
|
24
|
+
// - host is niet hetzelfde als operator. Twee subdomeinen van hetzelfde bedrijf
|
|
25
|
+
// tellen hier als twee. Dat is een bekende grens, niet stilzwijgend opgelost.
|
|
26
|
+
// - een registratie zegt niets over of het eindpunt vandaag nog leeft.
|
|
27
|
+
|
|
28
|
+
const UA = 'x402-conformance-check/1 (read-only, github.com/holistis/agent-receipt-verify)'
|
|
29
|
+
const INDEX = 'https://facilitator.payai.network/discovery/resources'
|
|
30
|
+
const PAGINA = 1000
|
|
31
|
+
|
|
32
|
+
const slaap = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* De door de partij zelf gedeclareerde invoer: padparameters en voorbeeldlichaam.
|
|
36
|
+
*
|
|
37
|
+
* Toegevoegd 2026-10-17, nadat de eerste editie bleek dat de index SJABLOON-URL's
|
|
38
|
+
* bevat (".../v1/ip/:ip") en dat wij die letterlijk opvroegen. De index levert de
|
|
39
|
+
* waarde ernaast; wij gebruikten hem niet. Dat gaf een 400 die niets over x402 zei.
|
|
40
|
+
* Dit veld staat er zodat een volgende meting dat niet overdoet.
|
|
41
|
+
*/
|
|
42
|
+
export function gedeclareerdeInvoer(item) {
|
|
43
|
+
const ext = item?.extensions ?? {}
|
|
44
|
+
for (const k of Object.keys(ext)) {
|
|
45
|
+
const inp = ext[k]?.info?.input
|
|
46
|
+
if (inp && typeof inp === 'object') {
|
|
47
|
+
return {
|
|
48
|
+
pathParams: inp.pathParams ?? null,
|
|
49
|
+
queryParams: inp.queryParams ?? null,
|
|
50
|
+
body: inp.body ?? null,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { pathParams: null, queryParams: null, body: null }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** De door de partij zelf gedeclareerde HTTP-methode, of null als hij niets zegt. */
|
|
58
|
+
export function gedeclareerdeMethode(item) {
|
|
59
|
+
const ext = item?.extensions ?? {}
|
|
60
|
+
for (const k of Object.keys(ext)) {
|
|
61
|
+
const m = ext[k]?.info?.input?.method
|
|
62
|
+
if (typeof m === 'string' && /^(GET|POST|PUT|PATCH|DELETE)$/i.test(m.trim())) {
|
|
63
|
+
return m.trim().toUpperCase()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function host(url) {
|
|
70
|
+
try {
|
|
71
|
+
return new URL(url).host.toLowerCase()
|
|
72
|
+
} catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Haalt de hele index op, pagina voor pagina.
|
|
79
|
+
*
|
|
80
|
+
* Een pagina die faalt WERPT, in plaats van stil een korter resultaat te geven.
|
|
81
|
+
* Een census met een onbekend gat erin is geen census, en een halve uitslag die
|
|
82
|
+
* zich als heel voordoet is precies de fout die deze hele meetlat moet vermijden.
|
|
83
|
+
*/
|
|
84
|
+
export async function haalIndex({ fetchImpl = fetch, pauzeMs = 800, maxPaginas = 60 } = {}) {
|
|
85
|
+
const items = []
|
|
86
|
+
let offset = 0
|
|
87
|
+
let gemeldTotaal = null
|
|
88
|
+
for (let p = 0; p < maxPaginas; p++) {
|
|
89
|
+
const url = INDEX + '?limit=' + PAGINA + '&offset=' + offset
|
|
90
|
+
const resp = await fetchImpl(url, {
|
|
91
|
+
headers: { 'User-Agent': UA, Accept: 'application/json' },
|
|
92
|
+
signal: AbortSignal.timeout(60000),
|
|
93
|
+
})
|
|
94
|
+
if (!resp.ok) throw new Error('index page at offset ' + offset + ' returned ' + resp.status)
|
|
95
|
+
const j = await resp.json()
|
|
96
|
+
const batch = Array.isArray(j.items) ? j.items : []
|
|
97
|
+
if (gemeldTotaal === null) gemeldTotaal = j.pagination?.total ?? null
|
|
98
|
+
// De echte lusindex, niet indexOf: twee registraties kunnen inhoudelijk gelijk
|
|
99
|
+
// zijn en dan geeft indexOf beide dezelfde positie. De positie bepaalt welke
|
|
100
|
+
// resource per host de live-steekproef in gaat, dus een verschuiving hier
|
|
101
|
+
// maakt de steekproef onnavolgbaar voor iemand die hem natrekt.
|
|
102
|
+
for (let i = 0; i < batch.length; i++) items.push({ item: batch[i], offset: offset + i })
|
|
103
|
+
offset += batch.length
|
|
104
|
+
if (batch.length < PAGINA) return { items, gemeldTotaal, paginas: p + 1 }
|
|
105
|
+
if (pauzeMs) await slaap(pauzeMs)
|
|
106
|
+
}
|
|
107
|
+
throw new Error('index did not end within ' + maxPaginas + ' pages')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Vouwt de ruwe index samen tot een telling per host. */
|
|
111
|
+
export function censusPerHost(ruw) {
|
|
112
|
+
const perHost = new Map()
|
|
113
|
+
let zonderHost = 0
|
|
114
|
+
const gezieneResources = new Set()
|
|
115
|
+
let dubbeleResources = 0
|
|
116
|
+
|
|
117
|
+
for (const { item, offset } of ruw) {
|
|
118
|
+
const res = item?.resource
|
|
119
|
+
const h = host(res)
|
|
120
|
+
if (!h) {
|
|
121
|
+
zonderHost++
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (gezieneResources.has(res)) dubbeleResources++
|
|
125
|
+
else gezieneResources.add(res)
|
|
126
|
+
|
|
127
|
+
if (!perHost.has(h)) {
|
|
128
|
+
perHost.set(h, {
|
|
129
|
+
host: h,
|
|
130
|
+
aantalRegistraties: 0,
|
|
131
|
+
eersteOffset: offset,
|
|
132
|
+
eersteResource: res,
|
|
133
|
+
eersteMethode: gedeclareerdeMethode(item),
|
|
134
|
+
eersteInvoer: gedeclareerdeInvoer(item),
|
|
135
|
+
extensieSleutels: new Set(),
|
|
136
|
+
netwerken: new Set(),
|
|
137
|
+
schemes: new Set(),
|
|
138
|
+
x402Versies: new Set(),
|
|
139
|
+
declareertOfferReceipt: false,
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
const o = perHost.get(h)
|
|
143
|
+
o.aantalRegistraties++
|
|
144
|
+
// Eerste in index-volgorde is de deterministische keuze voor de live-meting,
|
|
145
|
+
// zodat iemand anders exact dezelfde steekproef kan natrekken.
|
|
146
|
+
if (offset < o.eersteOffset) {
|
|
147
|
+
o.eersteOffset = offset
|
|
148
|
+
o.eersteResource = res
|
|
149
|
+
o.eersteMethode = gedeclareerdeMethode(item)
|
|
150
|
+
o.eersteInvoer = gedeclareerdeInvoer(item)
|
|
151
|
+
}
|
|
152
|
+
for (const k of Object.keys(item?.extensions ?? {})) {
|
|
153
|
+
o.extensieSleutels.add(k)
|
|
154
|
+
if (/offer-?receipt/i.test(k)) o.declareertOfferReceipt = true
|
|
155
|
+
}
|
|
156
|
+
const a = Array.isArray(item?.accepts) ? item.accepts[0] : null
|
|
157
|
+
if (a?.network) o.netwerken.add(String(a.network))
|
|
158
|
+
if (a?.scheme) o.schemes.add(String(a.scheme))
|
|
159
|
+
if (item?.x402Version != null) o.x402Versies.add(String(item.x402Version))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const operators = [...perHost.values()]
|
|
163
|
+
.map((o) => ({
|
|
164
|
+
...o,
|
|
165
|
+
extensieSleutels: [...o.extensieSleutels].sort(),
|
|
166
|
+
netwerken: [...o.netwerken].sort(),
|
|
167
|
+
schemes: [...o.schemes].sort(),
|
|
168
|
+
x402Versies: [...o.x402Versies].sort(),
|
|
169
|
+
}))
|
|
170
|
+
.sort((a, b) => b.aantalRegistraties - a.aantalRegistraties || a.host.localeCompare(b.host))
|
|
171
|
+
|
|
172
|
+
return { operators, zonderHost, dubbeleResources, uniekeResources: gezieneResources.size }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (process.argv[1]?.endsWith('x402-discovery-census.mjs')) {
|
|
176
|
+
const uitPad = process.argv[2]
|
|
177
|
+
const ruw = await haalIndex()
|
|
178
|
+
const c = censusPerHost(ruw.items)
|
|
179
|
+
|
|
180
|
+
const extTelling = new Map()
|
|
181
|
+
for (const o of c.operators) {
|
|
182
|
+
for (const k of o.extensieSleutels) extTelling.set(k, (extTelling.get(k) ?? 0) + 1)
|
|
183
|
+
}
|
|
184
|
+
const netwerkTelling = new Map()
|
|
185
|
+
for (const o of c.operators) {
|
|
186
|
+
for (const n of o.netwerken) netwerkTelling.set(n, (netwerkTelling.get(n) ?? 0) + 1)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
console.log('source : ' + INDEX)
|
|
190
|
+
console.log('reported : ' + ruw.gemeldTotaal + ' resources, in ' + ruw.paginas + ' pages')
|
|
191
|
+
console.log('seen : ' + ruw.items.length + ' records, ' + c.uniekeResources + ' distinct resource URLs')
|
|
192
|
+
if (c.dubbeleResources) console.log('duplicates : ' + c.dubbeleResources + ' records repeat a resource URL already seen')
|
|
193
|
+
if (c.zonderHost) console.log('unparseable : ' + c.zonderHost + ' records without a usable URL')
|
|
194
|
+
console.log('hosts : ' + c.operators.length)
|
|
195
|
+
console.log('')
|
|
196
|
+
console.log('registrations per host, the ten largest:')
|
|
197
|
+
for (const o of c.operators.slice(0, 10)) {
|
|
198
|
+
console.log(
|
|
199
|
+
' ' + String(o.aantalRegistraties).padStart(6) + ' ' + o.host +
|
|
200
|
+
' [' + (o.extensieSleutels.join(', ') || 'no extensions') + ']'
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
const een = c.operators.filter((o) => o.aantalRegistraties === 1).length
|
|
204
|
+
console.log('')
|
|
205
|
+
console.log(' ' + een + ' of ' + c.operators.length + ' hosts registered exactly one resource')
|
|
206
|
+
console.log(' largest host holds ' + c.operators[0].aantalRegistraties + ' of ' + c.uniekeResources +
|
|
207
|
+
' resources (' + ((c.operators[0].aantalRegistraties / c.uniekeResources) * 100).toFixed(1) + ' percent)')
|
|
208
|
+
console.log('')
|
|
209
|
+
console.log('extensions declared, counted per host:')
|
|
210
|
+
for (const [k, v] of [...extTelling].sort((a, b) => b[1] - a[1])) {
|
|
211
|
+
console.log(' ' + String(v).padStart(6) + ' ' + k)
|
|
212
|
+
}
|
|
213
|
+
const or = c.operators.filter((o) => o.declareertOfferReceipt)
|
|
214
|
+
console.log('')
|
|
215
|
+
console.log('offer-receipt declared by ' + or.length + ' of ' + c.operators.length + ' hosts')
|
|
216
|
+
for (const o of or.slice(0, 25)) console.log(' ' + o.host + ' ' + o.eersteResource)
|
|
217
|
+
console.log('')
|
|
218
|
+
console.log('networks, counted per host:')
|
|
219
|
+
for (const [k, v] of [...netwerkTelling].sort((a, b) => b[1] - a[1]).slice(0, 12)) {
|
|
220
|
+
console.log(' ' + String(v).padStart(6) + ' ' + k)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (uitPad) {
|
|
224
|
+
const { writeFileSync, mkdirSync } = await import('node:fs')
|
|
225
|
+
const { dirname } = await import('node:path')
|
|
226
|
+
mkdirSync(dirname(uitPad), { recursive: true })
|
|
227
|
+
writeFileSync(
|
|
228
|
+
uitPad,
|
|
229
|
+
JSON.stringify(
|
|
230
|
+
{
|
|
231
|
+
bron: INDEX,
|
|
232
|
+
gemeldTotaal: ruw.gemeldTotaal,
|
|
233
|
+
paginas: ruw.paginas,
|
|
234
|
+
recordsGezien: ruw.items.length,
|
|
235
|
+
uniekeResources: c.uniekeResources,
|
|
236
|
+
dubbeleResources: c.dubbeleResources,
|
|
237
|
+
zonderHost: c.zonderHost,
|
|
238
|
+
operators: c.operators,
|
|
239
|
+
},
|
|
240
|
+
null,
|
|
241
|
+
2
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
console.log('')
|
|
245
|
+
console.log('written: ' + uitPad)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Van de hosts die in een discovery-index STAAN, hoeveel antwoorden er vandaag
|
|
2
|
+
// werkelijk zoals x402 voorschrijft.
|
|
3
|
+
//
|
|
4
|
+
// WAAROM DEZE VRAAG, en niet de vraag waar de ladder voor gebouwd is. De census
|
|
5
|
+
// (x402-discovery-census.mjs) heeft op 2026-10-17 geteld dat nul van de 1127 hosts
|
|
6
|
+
// de offer-receipt-extensie declareert. Trede 1 tot 5 van de ladder hebben dus
|
|
7
|
+
// geen populatie. Een uitslag daarover zou een tabel met nullen zijn plus ons
|
|
8
|
+
// eigen eindpunt, en dat is geen meting maar een advertentie. Trede 0 heeft wel
|
|
9
|
+
// een populatie, en die vraag is nog door niemand gepubliceerd: een registratie
|
|
10
|
+
// in een index zegt niets over of het eindpunt nog leeft.
|
|
11
|
+
//
|
|
12
|
+
// DE STEEKPROEFREGEL STAAT HIER, zodat iemand anders exact dezelfde hosts kan
|
|
13
|
+
// natrekken en kan zien waar hij afwijkt:
|
|
14
|
+
// 1. Per host de resource met de LAAGSTE index-positie (de census bepaalt die).
|
|
15
|
+
// 2. Hosts gesorteerd op die positie, oplopend.
|
|
16
|
+
// 3. Daaruit elke n-de, met n zo gekozen dat de steekproef over de hele
|
|
17
|
+
// registratievolgorde ligt. Niet de eerste N: die zijn van een handvol
|
|
18
|
+
// partijen die vroeg massaal aanmeldden, en dat meet dan die partijen in
|
|
19
|
+
// plaats van de index.
|
|
20
|
+
// 4. De methode die de partij ZELF declareert in de index. Blind POST sturen
|
|
21
|
+
// laat een GET-route zakken op een 405 die niets over x402 zegt.
|
|
22
|
+
//
|
|
23
|
+
// TWEE CONTROLES, want een meting waarin alles faalt bewijst niet dat de
|
|
24
|
+
// werkelijkheid faalt, hij kan ook bewijzen dat het meetgereedschap kapot is:
|
|
25
|
+
// - positief: een eindpunt waarvan wij weten dat het trede 2 haalt.
|
|
26
|
+
// - negatief: een gewone pagina die niets met x402 doet.
|
|
27
|
+
// Gaan die twee niet zoals verwacht, dan is de uitslag ongeldig en zegt het
|
|
28
|
+
// script dat, in plaats van de cijfers alsnog op te leveren.
|
|
29
|
+
|
|
30
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
31
|
+
import { dirname } from 'node:path'
|
|
32
|
+
import { meetEindpunt, hoogsteTrede } from './x402-conformance-check.mjs'
|
|
33
|
+
|
|
34
|
+
const POSITIEVE_CONTROLE = { url: 'https://api.tokenizen.nl/api/paid/verify-ownership', methode: 'POST', verwacht: 2 }
|
|
35
|
+
const NEGATIEVE_CONTROLE = { url: 'https://example.com/', methode: 'GET', verwacht: -1 }
|
|
36
|
+
|
|
37
|
+
const slaap = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Vult sjabloon-parameters in een resource-URL met de waarden die de index zelf geeft.
|
|
41
|
+
*
|
|
42
|
+
* TOEGEVOEGD NA EEN EIGEN FOUT, 2026-10-17. De index bevat URL's als
|
|
43
|
+
* "https://api.apibooth.com/v1/ip/:ip" en wij vroegen die LETTERLIJK op, inclusief
|
|
44
|
+
* de dubbele punt. Dat gaf een 400 die wij bijna als "weigert het verzoek" hadden
|
|
45
|
+
* gepubliceerd, terwijl de index de waarde ("8.8.8.8") ernaast vermeldde. Dat is je
|
|
46
|
+
* eigen verzoek meten in plaats van het eindpunt.
|
|
47
|
+
*
|
|
48
|
+
* Geeft {url, ingevuld, resterend} terug: resterend is wat NIET ingevuld kon worden,
|
|
49
|
+
* zodat een meting op een half-ingevuld sjabloon zichzelf kan diskwalificeren in
|
|
50
|
+
* plaats van een getal te produceren dat nergens op rust.
|
|
51
|
+
*/
|
|
52
|
+
export function vulSjabloon(url, pathParams) {
|
|
53
|
+
const gevonden = [...String(url).matchAll(/:([A-Za-z_][A-Za-z0-9_]*)/g)].map((m) => m[1])
|
|
54
|
+
const uniek = [...new Set(gevonden)]
|
|
55
|
+
if (uniek.length === 0) return { url, ingevuld: [], resterend: [] }
|
|
56
|
+
let uit = url
|
|
57
|
+
const ingevuld = []
|
|
58
|
+
const resterend = []
|
|
59
|
+
for (const naam of uniek) {
|
|
60
|
+
const waarde = pathParams?.[naam]
|
|
61
|
+
if (waarde === undefined || waarde === null || String(waarde) === '') {
|
|
62
|
+
resterend.push(naam)
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
uit = uit.split(':' + naam).join(encodeURIComponent(String(waarde)))
|
|
66
|
+
ingevuld.push(naam)
|
|
67
|
+
}
|
|
68
|
+
return { url: uit, ingevuld, resterend }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Stap 1 tot 3 van de steekproefregel hierboven, puur rekenwerk, geen netwerk. */
|
|
72
|
+
export function kiesSteekproef(operators, aantal) {
|
|
73
|
+
const opPositie = [...operators].sort((a, b) => a.eersteOffset - b.eersteOffset)
|
|
74
|
+
if (aantal >= opPositie.length) return opPositie
|
|
75
|
+
const stap = opPositie.length / aantal
|
|
76
|
+
const uit = []
|
|
77
|
+
for (let i = 0; i < aantal; i++) uit.push(opPositie[Math.floor(i * stap)])
|
|
78
|
+
return uit
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Verdeelt een meting in emmers die NIET door elkaar lopen.
|
|
83
|
+
*
|
|
84
|
+
* Het verschil tussen "doet geen x402" en "wees mijn verzoek af voor het ooit bij
|
|
85
|
+
* de betaalmuur kwam" is echt, en een meting die beide als mislukking telt is met
|
|
86
|
+
* een enkele controle onderuit te halen. Een correcte implementatie geeft zijn
|
|
87
|
+
* 402 voor hij een lichaam valideert; niet iedereen doet dat.
|
|
88
|
+
*/
|
|
89
|
+
export function emmer(meting) {
|
|
90
|
+
const trede = hoogsteTrede(meting)
|
|
91
|
+
if (trede >= 0) return 'x402_ok'
|
|
92
|
+
const s = meting.bewijs?.status
|
|
93
|
+
if (s == null) return 'onbereikbaar'
|
|
94
|
+
if (s === 402) return 'status_402_onbruikbaar'
|
|
95
|
+
if (s === 401 || s === 403) return 'afgeschermd'
|
|
96
|
+
if (s === 404 || s === 410) return 'pad_weg'
|
|
97
|
+
if (s === 405) return 'methode_geweigerd'
|
|
98
|
+
if (s === 429) return 'tempo_geweigerd'
|
|
99
|
+
if (s >= 400 && s < 500) return 'verzoek_geweigerd'
|
|
100
|
+
if (s >= 500) return 'serverfout'
|
|
101
|
+
return 'geen_betaalmuur'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function meetSteekproef(gekozen, { pauzeMs = 1200, fetchImpl = fetch } = {}) {
|
|
105
|
+
const uit = []
|
|
106
|
+
for (const o of gekozen) {
|
|
107
|
+
const methode = o.eersteMethode ?? 'POST'
|
|
108
|
+
const sjab = vulSjabloon(o.eersteResource, o.eersteInvoer?.pathParams)
|
|
109
|
+
const m = await meetEindpunt(sjab.url, { methode, fetchImpl, lichaam: o.eersteInvoer?.body ?? null })
|
|
110
|
+
uit.push({
|
|
111
|
+
host: o.host,
|
|
112
|
+
url: sjab.url,
|
|
113
|
+
urlInIndex: o.eersteResource,
|
|
114
|
+
sjabloonIngevuld: sjab.ingevuld,
|
|
115
|
+
sjabloonNietIngevuld: sjab.resterend,
|
|
116
|
+
lichaamUitIndex: o.eersteInvoer?.body != null,
|
|
117
|
+
indexPositie: o.eersteOffset,
|
|
118
|
+
registratiesVanDezeHost: o.aantalRegistraties,
|
|
119
|
+
gedeclareerdeMethode: o.eersteMethode,
|
|
120
|
+
gebruikteMethode: methode,
|
|
121
|
+
status: m.bewijs?.status ?? null,
|
|
122
|
+
hoogsteTrede: hoogsteTrede(m),
|
|
123
|
+
emmer: emmer(m),
|
|
124
|
+
treden: m.treden,
|
|
125
|
+
bewijs: m.bewijs,
|
|
126
|
+
})
|
|
127
|
+
if (pauzeMs) await slaap(pauzeMs)
|
|
128
|
+
}
|
|
129
|
+
return uit
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (process.argv[1]?.endsWith('x402-index-liveness.mjs')) {
|
|
133
|
+
const censusPad = process.argv[2]
|
|
134
|
+
const aantal = Number(process.argv[3] ?? 50)
|
|
135
|
+
const uitPad = process.argv[4]
|
|
136
|
+
if (!censusPad) {
|
|
137
|
+
console.log('usage: node src/x402-index-liveness.mjs <census.json> [sample size] [out.json]')
|
|
138
|
+
process.exitCode = 2
|
|
139
|
+
} else {
|
|
140
|
+
const census = JSON.parse(readFileSync(censusPad, 'utf8'))
|
|
141
|
+
const gekozen = kiesSteekproef(census.operators, aantal)
|
|
142
|
+
|
|
143
|
+
console.log('--- controls first, because a sweep where everything fails proves nothing ---')
|
|
144
|
+
const controles = []
|
|
145
|
+
for (const c of [POSITIEVE_CONTROLE, NEGATIEVE_CONTROLE]) {
|
|
146
|
+
const m = await meetEindpunt(c.url, { methode: c.methode })
|
|
147
|
+
const t = hoogsteTrede(m)
|
|
148
|
+
const ok = t === c.verwacht
|
|
149
|
+
controles.push({ ...c, gemeten: t, emmer: emmer(m), status: m.bewijs?.status ?? null, ok })
|
|
150
|
+
console.log((ok ? ' ok ' : ' FAIL ') + c.url + ' expected L' + c.verwacht + ', measured L' + t)
|
|
151
|
+
await slaap(1200)
|
|
152
|
+
}
|
|
153
|
+
const controlesGoed = controles.every((c) => c.ok)
|
|
154
|
+
if (!controlesGoed) {
|
|
155
|
+
console.log('')
|
|
156
|
+
console.log('The harness did not behave as expected on the controls. Treat the numbers below')
|
|
157
|
+
console.log('as invalid until that is explained. A measurement you cannot trust on a known')
|
|
158
|
+
console.log('case tells you nothing about the unknown ones.')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.log('')
|
|
162
|
+
console.log('--- sample: ' + gekozen.length + ' of ' + census.operators.length + ' hosts ---')
|
|
163
|
+
const metingen = await meetSteekproef(gekozen)
|
|
164
|
+
|
|
165
|
+
const tel = new Map()
|
|
166
|
+
for (const m of metingen) tel.set(m.emmer, (tel.get(m.emmer) ?? 0) + 1)
|
|
167
|
+
for (const m of metingen) {
|
|
168
|
+
console.log(
|
|
169
|
+
(m.hoogsteTrede >= 0 ? 'L' + m.hoogsteTrede : '--') +
|
|
170
|
+
' ' + String(m.status ?? '---').padStart(3) +
|
|
171
|
+
' ' + m.emmer.padEnd(22) +
|
|
172
|
+
' ' + m.host
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
console.log('')
|
|
176
|
+
for (const [k, v] of [...tel].sort((a, b) => b[1] - a[1])) {
|
|
177
|
+
console.log(' ' + String(v).padStart(4) + ' ' + k + ' (' + ((v / metingen.length) * 100).toFixed(1) + '%)')
|
|
178
|
+
}
|
|
179
|
+
const ok = metingen.filter((m) => m.hoogsteTrede >= 0).length
|
|
180
|
+
console.log('')
|
|
181
|
+
console.log(' ' + ok + ' of ' + metingen.length + ' sampled hosts answer the way x402 says (level 0)')
|
|
182
|
+
console.log(' levels 1 to 5 are not reported here: the census found 0 of ' + census.operators.length +
|
|
183
|
+
' hosts declaring the offer-receipt extension, so there is no population to measure')
|
|
184
|
+
|
|
185
|
+
if (uitPad) {
|
|
186
|
+
mkdirSync(dirname(uitPad), { recursive: true })
|
|
187
|
+
writeFileSync(uitPad, JSON.stringify({
|
|
188
|
+
bron: census.bron,
|
|
189
|
+
steekproefregel: 'per host the resource with the lowest index position; hosts sorted by that position ascending; every nth taken so the sample spans the whole registration order; the method each party declares in the index',
|
|
190
|
+
hostsInCensus: census.operators.length,
|
|
191
|
+
steekproefGrootte: metingen.length,
|
|
192
|
+
controles,
|
|
193
|
+
controlesGoed,
|
|
194
|
+
emmers: Object.fromEntries(tel),
|
|
195
|
+
metingen,
|
|
196
|
+
}, null, 2))
|
|
197
|
+
console.log('')
|
|
198
|
+
console.log('written: ' + uitPad)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|