agent-receipt-verify 0.2.6 → 0.2.8

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 (24) hide show
  1. package/bench/methodefout.mjs +204 -0
  2. package/bin/cli.mjs +6 -0
  3. package/package.json +5 -3
  4. package/src/attempt-identity.mjs +278 -0
  5. package/src/x402-conformance-check.mjs +9 -1
  6. package/vectors/attempt-identity/all-paths-agree.json +126 -0
  7. package/vectors/attempt-identity/changed-amount-announced.json +86 -0
  8. package/vectors/attempt-identity/changed-amount-no-event.json +82 -0
  9. package/vectors/attempt-identity/changed-amount-silent-collapse.json +81 -0
  10. package/vectors/attempt-identity/client-supplied-key-unverified.json +29 -0
  11. package/vectors/attempt-identity/countersignature-matches.json +28 -0
  12. package/vectors/attempt-identity/countersigned-despite-mismatch.json +29 -0
  13. package/vectors/attempt-identity/derive-once-honoured.json +122 -0
  14. package/vectors/attempt-identity/idempotency-key-bound.json +28 -0
  15. package/vectors/attempt-identity/key-order-changes-identity.json +63 -0
  16. package/vectors/attempt-identity/key-order-stable-identity.json +62 -0
  17. package/vectors/attempt-identity/nothing-recorded-not-measured.json +21 -0
  18. package/vectors/attempt-identity/outcome-folded-into-identity.json +35 -0
  19. package/vectors/attempt-identity/outcome-kept-out-of-identity.json +34 -0
  20. package/vectors/attempt-identity/paths-disagree.json +81 -0
  21. package/vectors/attempt-identity/reference-canonicalized-first.json +28 -0
  22. package/vectors/attempt-identity/reference-over-transmitted.json +26 -0
  23. package/vectors/attempt-identity/settle-rederives-downstream.json +103 -0
  24. package/vectors/attempt-identity/settled-despite-mismatch.json +29 -0
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * METHODEFOUT-BENCHMARK -- meet de foutmarge van DEZE meter tegen de echte markt.
4
+ *
5
+ * WAAROM DIT BESTAAT, en het is een eigen fout die het nodig maakte. Deze verifieerder
6
+ * sondeert standaard met POST. Een betaalde route die alleen GET bedient antwoordt
7
+ * daarop 405, of 404 bij veel webraamwerken, en dat las de meter als "dit eindpunt vraagt
8
+ * niet om betaling zoals x402 voorschrijft". Gemeten op 18 oktober 2026, op zestig unieke
9
+ * hosts uit de CDP-index: van de 57 die ECHT om betaling vragen keurde de meter er 36 af.
10
+ * 63,2 procent. Dat bleef onopgemerkt omdat er alleen tegen eigen testvectoren getoetst
11
+ * werd, en testvectoren bevatten precies de vorm die de schrijver al in gedachten had.
12
+ *
13
+ * Een meter die zijn eigen foutmarge tegen de werkelijkheid niet meet, meet niets.
14
+ *
15
+ * DIT GEBRUIKT DE ECHTE METER, niet een nagebouwde sonde. Een eerste versie van dit
16
+ * bestand bouwde het verzoek zelf na, en dat is precies de fout die het wil opsporen: dan
17
+ * benchmark je een replica die met de echte code uit elkaar loopt. Daarom importeert dit
18
+ * `meetEindpunt` uit src.
19
+ *
20
+ * GEDRAGSREGELS, hard, want dit raakt eindpunten van anderen:
21
+ * - de meter doet zelf een POST en hoogstens een herhaling; dit voegt er alleen bij een
22
+ * AFKEURING nog een GET aan toe om te zien of de afkeuring terecht was
23
+ * - een host maar een keer, anders weegt een operator met honderd paden honderd keer
24
+ * - nooit betalen, nooit fuzzen, nooit herhalen om iets af te dwingen
25
+ * - rust tussen de hosts
26
+ *
27
+ * GEBRUIK:
28
+ * node bench/methodefout.mjs --urls <bestand.json> een array van resource-urls
29
+ * node bench/methodefout.mjs --urls - < bronnen.json hetzelfde, van stdin
30
+ * node bench/methodefout.mjs --urls x.json --uit uitslag.jsonl
31
+ *
32
+ * De lijst met bronnen komt van buiten dit bestand, want die vergt indextoegang en een
33
+ * sleutel, en een benchmark hoort geen geheimen te hoeven lezen.
34
+ *
35
+ * EXITCODES: 0 gemeten, 1 er is een RESTFOUT (de meter keurt nog hosts af die wel om
36
+ * betaling vragen), 2 de meting kon niet draaien.
37
+ */
38
+
39
+ import fs from 'node:fs'
40
+ import { meetEindpunt, hoogsteTrede } from '../src/x402-conformance-check.mjs'
41
+
42
+ const UA = 'x402-conformance-check/1 (read-only, github.com/holistis/agent-receipt-verify)'
43
+ const rust = (ms) => new Promise((r) => setTimeout(r, ms))
44
+
45
+ function vlag(naam, standaard = null) {
46
+ const v = process.argv.find((a) => a === naam || a.startsWith(naam + '='))
47
+ if (!v) return standaard
48
+ if (v.includes('=')) return v.slice(naam.length + 1)
49
+ return process.argv[process.argv.indexOf(v) + 1] ?? standaard
50
+ }
51
+
52
+ /** Een host maar EEN keer, anders weegt een operator met honderd paden honderd keer. */
53
+ export function eenPerHost(urls) {
54
+ const perHost = new Map()
55
+ for (const u of urls) {
56
+ try {
57
+ const h = new URL(u).host
58
+ if (!perHost.has(h)) perHost.set(h, u)
59
+ } catch {}
60
+ }
61
+ return [...perHost.values()]
62
+ }
63
+
64
+ /**
65
+ * Antwoordt dit eindpunt op GET WEL met 402? Dit wordt alleen gevraagd als de meter het
66
+ * eindpunt AFKEURDE, om te zien of die afkeuring terecht was. Een afkeuring die wij niet
67
+ * natrekken, telt stil als een eigenschap van de markt in plaats van als onze restfout.
68
+ */
69
+ async function tweedeKansOpGet(url, fetchImpl) {
70
+ try {
71
+ const r = await fetchImpl(url, {
72
+ method: 'GET',
73
+ headers: { 'User-Agent': UA, Accept: 'application/json' },
74
+ signal: AbortSignal.timeout(12000),
75
+ })
76
+ return r.status === 402
77
+ } catch {
78
+ return false
79
+ }
80
+ }
81
+
82
+ export async function benchmark(urls, { fetchImpl = fetch, pauzeMs = 500 } = {}) {
83
+ const t = {
84
+ gemeten: 0,
85
+ haaltL0: 0,
86
+ // Hoeveel hosts de meter alleen haalt DOORDAT hij een herhaling doet. Dit is wat de
87
+ // reparatie oplevert, en het is tegelijk de omvang van de oude fout.
88
+ gereddenDoorHerhaling: 0,
89
+ // De restfout: de meter keurt af, maar het eindpunt antwoordt wel 402 op GET. Dit
90
+ // hoort nul te zijn. Staat hij boven nul, dan is er nog een vorm die wij missen.
91
+ restfout: 0,
92
+ terechtAfgekeurd: 0,
93
+ ongemeten: 0,
94
+ }
95
+ const gered = []
96
+ const rest = []
97
+ for (const u of urls) {
98
+ t.gemeten++
99
+ const m = await meetEindpunt(u, { fetchImpl })
100
+ const trede = hoogsteTrede(m)
101
+ if (m.treden.t0 === 'ONGEMETEN') {
102
+ t.ongemeten++
103
+ } else if (trede >= 0) {
104
+ t.haaltL0++
105
+ if (m.bewijs.methode_opnieuw) {
106
+ t.gereddenDoorHerhaling++
107
+ gered.push({ url: u, eerste_status: m.bewijs.methode_opnieuw.eerste_status, tweede: m.bewijs.methode_opnieuw.tweede })
108
+ }
109
+ } else {
110
+ await rust(Math.min(300, pauzeMs))
111
+ if (await tweedeKansOpGet(u, fetchImpl)) {
112
+ t.restfout++
113
+ rest.push({ url: u, bewijs: m.bewijs.t0 })
114
+ } else {
115
+ t.terechtAfgekeurd++
116
+ }
117
+ }
118
+ if (pauzeMs) await rust(pauzeMs)
119
+ }
120
+ const vraagtEchtOmBetaling = t.haaltL0 + t.restfout
121
+ return {
122
+ ...t,
123
+ vraagtEchtOmBetaling,
124
+ restfoutPct: vraagtEchtOmBetaling > 0 ? Number(((t.restfout / vraagtEchtOmBetaling) * 100).toFixed(1)) : null,
125
+ zonderHerhalingFoutPct:
126
+ vraagtEchtOmBetaling > 0
127
+ ? Number((((t.gereddenDoorHerhaling + t.restfout) / vraagtEchtOmBetaling) * 100).toFixed(1))
128
+ : null,
129
+ gered,
130
+ rest,
131
+ }
132
+ }
133
+
134
+ if (process.argv[1]?.endsWith('methodefout.mjs')) {
135
+ const bron = vlag('--urls')
136
+ if (!bron) {
137
+ console.error('geef een bestand met resource-urls: --urls bronnen.json (of - voor stdin)')
138
+ process.exit(2)
139
+ }
140
+ let ruw
141
+ try {
142
+ ruw = bron === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(bron, 'utf8')
143
+ } catch (e) {
144
+ console.error('kan de bronlijst niet lezen: ' + e.message)
145
+ process.exit(2)
146
+ }
147
+ let lijst
148
+ try {
149
+ const j = JSON.parse(ruw)
150
+ lijst = Array.isArray(j) ? j : (j.urls ?? j.resources ?? [])
151
+ } catch {
152
+ lijst = ruw.split(/\r?\n/).map((s) => s.trim()).filter((s) => /^https?:\/\//.test(s))
153
+ }
154
+ const hosts = eenPerHost(lijst.filter((u) => typeof u === 'string'))
155
+ const aantal = Number(vlag('--aantal', String(hosts.length)))
156
+ const teMeten = hosts.slice(0, aantal)
157
+ if (teMeten.length === 0) {
158
+ console.error('geen bruikbare urls, dus NIETS gemeten. Dit is geen uitslag van nul fouten.')
159
+ process.exit(2)
160
+ }
161
+
162
+ console.log('urls in de lijst ' + lijst.length + ', unieke hosts ' + hosts.length + ', gemeten ' + teMeten.length)
163
+ const u = await benchmark(teMeten)
164
+ console.log('')
165
+ console.log(' haalt trede 0 : ' + u.haaltL0)
166
+ console.log(' waarvan alleen door de methode-herhaling : ' + u.gereddenDoorHerhaling)
167
+ console.log(' RESTFOUT, afgekeurd maar antwoordt 402 : ' + u.restfout)
168
+ console.log(' terecht afgekeurd : ' + u.terechtAfgekeurd)
169
+ console.log(' niet bereikbaar, dus ongemeten : ' + u.ongemeten)
170
+ console.log('')
171
+ console.log(' van de ' + u.vraagtEchtOmBetaling + ' hosts die echt om betaling vragen:')
172
+ console.log(' foutmarge NU : ' + u.restfoutPct + ' procent')
173
+ console.log(' foutmarge ZONDER de herhaling: ' + u.zonderHerhalingFoutPct + ' procent')
174
+ if (u.rest.length) {
175
+ console.log('')
176
+ console.log(' nog steeds fout, en dit is het werk dat overblijft:')
177
+ for (const r of u.rest.slice(0, 12)) console.log(' ' + r.url + ' ' + r.bewijs)
178
+ }
179
+
180
+ const uit = vlag('--uit')
181
+ if (uit) {
182
+ const regel = {
183
+ gemeten_op: new Date().toISOString(),
184
+ gemeten: u.gemeten,
185
+ haalt_l0: u.haaltL0,
186
+ gered_door_herhaling: u.gereddenDoorHerhaling,
187
+ restfout: u.restfout,
188
+ terecht_afgekeurd: u.terechtAfgekeurd,
189
+ ongemeten: u.ongemeten,
190
+ vraagt_echt_om_betaling: u.vraagtEchtOmBetaling,
191
+ restfout_pct: u.restfoutPct,
192
+ zonder_herhaling_fout_pct: u.zonderHerhalingFoutPct,
193
+ rest: u.rest.slice(0, 10).map((r) => r.url),
194
+ }
195
+ fs.appendFileSync(uit, JSON.stringify(regel) + '\n')
196
+ console.log('')
197
+ console.log('regel bijgeschreven in ' + uit)
198
+ }
199
+
200
+ // Een restfout boven nul is een echte, open fout van deze meter. Die hoort een
201
+ // exitcode te geven, anders leest een groene run als "alles klopt" terwijl er hosts
202
+ // afgekeurd worden die wel om betaling vragen.
203
+ if (u.restfout > 0) process.exitCode = 1
204
+ }
package/bin/cli.mjs CHANGED
@@ -75,6 +75,11 @@ function gebruik() {
75
75
  console.log(' that one settlement? Eleven run offline, the chain-reading one is')
76
76
  console.log(' marked and skipped rather than failed.')
77
77
  console.log('')
78
+ console.log(' npx -y agent-receipt-verify attempt-identity')
79
+ console.log(' nineteen vectors for the question the other layers assume: are these')
80
+ console.log(' records about the SAME attempt? One acceptance and at least one refusal')
81
+ console.log(' per normative clause of x402 spec section 5.5. Offline.')
82
+ console.log('')
78
83
  console.log('What this does NOT do: it judges the SHAPE of a receipt, not the honesty of')
79
84
  console.log('the party that issued it. See CONFORMANCE.md for what is deliberately left')
80
85
  console.log('unmeasured.')
@@ -92,6 +97,7 @@ const onderdelen = {
92
97
  kanon: '../src/eip3009-autorisatie.mjs',
93
98
  beleid: '../bench/eip3009-beleid.mjs',
94
99
  'link-vectors': '../src/settlement-link-vectors.mjs',
100
+ 'attempt-identity': '../src/attempt-identity.mjs',
95
101
  'verify-delivery': '../src/verify-delivery.mjs',
96
102
  }
97
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-receipt-verify",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Checks x402 endpoint conformance before an agent pays, and links the receipt to its settlement.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -8,7 +8,7 @@
8
8
  "node": ">=20"
9
9
  },
10
10
  "scripts": {
11
- "test": "node test/interop.mjs && node test/canonical.mjs && node test/link-onchain.mjs && node test/profiel.mjs && node test/eip3009.mjs && node test/conformance.mjs && node test/jws-offer.mjs && node test/census.mjs && node test/poortwachters.mjs && node test/metingen-bundel.mjs && node test/mcp-server.mjs && node test/cli.mjs && node test/bundel.mjs && node test/settlement-link-vectors.mjs && node test/naar-kanon-vorm.mjs && node test/verify-delivery.mjs && node test/acceptance-binding.mjs",
11
+ "test": "node test/interop.mjs && node test/canonical.mjs && node test/link-onchain.mjs && node test/profiel.mjs && node test/eip3009.mjs && node test/conformance.mjs && node test/jws-offer.mjs && node test/census.mjs && node test/poortwachters.mjs && node test/metingen-bundel.mjs && node test/mcp-server.mjs && node test/cli.mjs && node test/bundel.mjs && node test/settlement-link-vectors.mjs && node test/naar-kanon-vorm.mjs && node test/verify-delivery.mjs && node test/acceptance-binding.mjs && node test/methodefout-bench.mjs && node test/attempt-identity.mjs",
12
12
  "link-example": "node src/x402-link-onchain.mjs --receipt=examples/receipt-live-base-sepolia.json && node src/x402-link-onchain.mjs --receipt=examples/receipt-live-base-sepolia-2.json && node src/x402-link-onchain.mjs --receipt=examples/receipt-live-base-sepolia-3.json",
13
13
  "conformance": "node src/x402-conformance-check.mjs",
14
14
  "bench": "node bench/robustness.mjs",
@@ -22,7 +22,9 @@
22
22
  "bench-eip3009": "node bench/eip3009-beleid.mjs",
23
23
  "link-vectors": "node src/settlement-link-vectors.mjs",
24
24
  "verify-delivery": "node src/verify-delivery.mjs",
25
- "kanon-vorm": "node tools/naar-kanon-vorm.mjs"
25
+ "kanon-vorm": "node tools/naar-kanon-vorm.mjs",
26
+ "bench-methodefout": "node bench/methodefout.mjs",
27
+ "attempt-identity": "node src/attempt-identity.mjs"
26
28
  },
27
29
  "optionalDependencies": {
28
30
  "ethers": "^6.17.0"
@@ -0,0 +1,278 @@
1
+ // Toetst de poging-identiteit: hoort dit alles bij DEZELFDE poging.
2
+ //
3
+ // WAAROM DIT BESTAAT. In erc-8004#99 kwamen op 18 oktober 2026 vier bewijslagen op tafel
4
+ // (vooraf-veiligheid, vooraf-oordeel, betaalbewijs na afrekening, en onafhankelijke
5
+ // werk-acceptatie), en de schrijver van de spec stelde vast dat geen van die vier de
6
+ // vraag eronder beantwoordt: welke van deze bewijsstukken gaan eigenlijk over dezelfde
7
+ // POGING. Hij schrijft die regel in x402#3776 paragraaf 5.5, en die paragraaf sluit af
8
+ // met een verwijzing naar "the normative conformance tests ... for the required negative
9
+ // cases". Die testen bestonden nog niet. Dit is dat.
10
+ //
11
+ // WAT HIER GEMETEN WORDT, en het is bewust alleen de INVARIANT. Niet of een facilitator
12
+ // goed rekent, niet of een handtekening klopt, maar of alle paden van een verzoek
13
+ // dezelfde identiteit opleveren en of een veranderd verzoek een andere identiteit krijgt.
14
+ // Dat is precies wat 5.5 eist en niets meer.
15
+ //
16
+ // WAT HIER MET OPZET NIET GEMETEN WORDT. Wij halen geen enkel pad zelf op. Een vector is
17
+ // een vastgelegde reeks WAARNEMINGEN per pad, en deze module toetst de invariant over die
18
+ // reeks. Een facilitator laten sonderen zou betekenen dat wij zijn betaalpaden aanroepen,
19
+ // en dat is niet aan ons.
20
+ //
21
+ // DRIE UITKOMSTEN, NOOIT TWEE: PASS, FAIL, NOT_MEASURED. Een clausule waarover de vector
22
+ // geen gegevens draagt is niet geslaagd en niet gefaald, hij is niet gemeten. Dat
23
+ // onderscheid is dezelfde correctie die de Kanon-auteur op 17 oktober 2026 overnam voor
24
+ // zijn eigen controleur, en de spec-auteur een dag later voor de zijne.
25
+
26
+ import { createHash } from 'node:crypto'
27
+
28
+ /**
29
+ * De normatieve clausules van paragraaf 5.5, met het letterlijke citaat erbij.
30
+ *
31
+ * Het citaat staat hier zodat een lezer kan nazien dat wij toetsen wat er STAAT en niet
32
+ * wat wij ervan maakten. Verandert de tekst, dan hoort dit mee te veranderen en is het
33
+ * verschil zichtbaar in de diff in plaats van verstopt in een oordeel.
34
+ */
35
+ export const CLAUSULES = [
36
+ {
37
+ id: 'derive-once-at-ingress',
38
+ citaat:
39
+ 'MUST be derived once, at ingress, from the complete request snapshot as received by the facilitator, ' +
40
+ 'and MUST NOT be re-derived or partially re-derived downstream',
41
+ },
42
+ {
43
+ id: 'all-paths-same-artifact',
44
+ citaat:
45
+ '/settle, refusal handling, replay-lookup, and challenge-quote responses all resolve the same artifact value',
46
+ },
47
+ {
48
+ id: 'changed-request-different-artifact',
49
+ citaat:
50
+ 'A request whose amount, query, or body changes between paths MUST produce a different artifact, and that ' +
51
+ 'change MUST surface as a settlement or announcement event, never as a silent divergence',
52
+ },
53
+ {
54
+ id: 'countersignature-recompute-or-refuse',
55
+ citaat:
56
+ 'that counterparty MUST independently recompute the fingerprint from the bytes it observed, and MUST refuse ' +
57
+ 'to sign if the recomputed value differs ... on mismatch the interaction MUST NOT settle',
58
+ },
59
+ {
60
+ id: 'own-identity-from-decoded-fields',
61
+ citaat:
62
+ "an attempt's own identity ... MUST be derived from named decoded fields and MUST NOT be derived from wire " +
63
+ 'bytes, an attempt is the same attempt whether it arrived with a different key order or different whitespace',
64
+ },
65
+ {
66
+ id: 'reference-over-transmitted-bytes',
67
+ citaat:
68
+ 'that reference is taken over the artifact as transmitted, because the signature covers those bytes, ' +
69
+ 'canonicalizing them first would verify a different object than the one that was signed',
70
+ },
71
+ {
72
+ id: 'outcome-not-folded-into-identity',
73
+ citaat:
74
+ 'Outcome payloads produced later in the attempt ... are not folded into the identity artifact, an ' +
75
+ 'implementation MUST NOT extend identity derivation with values that did not exist at ingress',
76
+ },
77
+ {
78
+ id: 'name-independent-but-bound',
79
+ citaat:
80
+ 'An implementation that already uses Idempotency-Key satisfies it by binding that key to the ingress snapshot',
81
+ },
82
+ ]
83
+
84
+ export const UITKOMSTEN = ['PASS', 'FAIL', 'NOT_MEASURED']
85
+
86
+ // Een minimale canonicalisatie: sleutels gesorteerd, geen witruimte. Genoeg om "dezelfde
87
+ // velden, andere sleutelvolgorde" te herkennen, en met opzet NIET uitgegeven als een
88
+ // volledige RFC8785-implementatie: die heeft eigen regels voor getallen, en een halve
89
+ // implementatie onder een bekende naam is erger dan een eigen kleine functie onder een
90
+ // eigen naam.
91
+ export function sorteerStabiel(w) {
92
+ if (Array.isArray(w)) return w.map(sorteerStabiel)
93
+ if (w && typeof w === 'object') {
94
+ const uit = {}
95
+ for (const k of Object.keys(w).sort()) uit[k] = sorteerStabiel(w[k])
96
+ return uit
97
+ }
98
+ return w
99
+ }
100
+
101
+ export const velddigest = (o) => createHash('sha256').update(JSON.stringify(sorteerStabiel(o))).digest('hex')
102
+ export const bytedigest = (s) => createHash('sha256').update(String(s), 'utf8').digest('hex')
103
+
104
+ const kaal = (w) => (typeof w === 'string' ? w.trim().toLowerCase().replace(/^0x/, '') : null)
105
+ const zelfde = (a, b) => kaal(a) !== null && kaal(a) === kaal(b)
106
+
107
+ const PAD_NAMEN = ['challenge-quote', 'settle', 'refusal', 'replay-lookup']
108
+
109
+ /** Eén clausule, één oordeel. Geen gegevens betekent NOT_MEASURED en nooit FAIL. */
110
+ function toetsClausule(id, inv) {
111
+ const niet = (reden) => ({ clausule: id, uitkomst: 'NOT_MEASURED', reden })
112
+ const faal = (code, detail) => ({ clausule: id, uitkomst: 'FAIL', reason_code: code, detail })
113
+ const goed = (detail) => ({ clausule: id, uitkomst: 'PASS', detail })
114
+
115
+ if (id === 'derive-once-at-ingress') {
116
+ const ingress = inv.ingress
117
+ const paden = Array.isArray(inv.paths) ? inv.paths : []
118
+ if (!ingress?.identity || paden.length === 0) return niet('geen ingress-identiteit of geen paden vastgelegd')
119
+ // Een pad dat de identiteit opnieuw afleidt uit iets dat NIET in de ingress-momentopname
120
+ // zat, is de overtreding. De vector legt dat vast als het veld `derived_from`.
121
+ for (const p of paden) {
122
+ if (p.derived_from && p.derived_from !== 'ingress-snapshot') {
123
+ return faal('IDENTITY_REDERIVED_DOWNSTREAM', `pad ${p.path} leidde opnieuw af uit ${p.derived_from}`)
124
+ }
125
+ }
126
+ return goed(`${paden.length} paden, allemaal uit de ingress-momentopname`)
127
+ }
128
+
129
+ if (id === 'all-paths-same-artifact') {
130
+ const paden = Array.isArray(inv.paths) ? inv.paths : []
131
+ const metSnapshot = paden.filter((p) => p.identity)
132
+ if (metSnapshot.length < 2) return niet('minder dan twee paden met een identiteit')
133
+ // Alleen paden met EEN GELIJK verzoek horen dezelfde identiteit te hebben. Een pad
134
+ // met een gewijzigd verzoek valt onder de volgende clausule, niet onder deze.
135
+ const basis = inv.ingress?.snapshot ? velddigest(inv.ingress.snapshot) : null
136
+ const gelijkVerzoek = metSnapshot.filter((p) => !p.snapshot || !basis || velddigest(p.snapshot) === basis)
137
+ if (gelijkVerzoek.length < 2) return niet('minder dan twee paden met een ongewijzigd verzoek')
138
+ const eerste = gelijkVerzoek[0]
139
+ for (const p of gelijkVerzoek.slice(1)) {
140
+ if (!zelfde(p.identity, eerste.identity)) {
141
+ return faal('PATHS_DISAGREE_ON_IDENTITY', `${eerste.path} en ${p.path} geven een andere identiteit`)
142
+ }
143
+ }
144
+ const gedekt = gelijkVerzoek.map((p) => p.path).filter((p) => PAD_NAMEN.includes(p))
145
+ return goed(`${gelijkVerzoek.length} paden eens, waarvan benoemd in de spec: ${gedekt.join(', ') || 'geen'}`)
146
+ }
147
+
148
+ if (id === 'changed-request-different-artifact') {
149
+ const paden = Array.isArray(inv.paths) ? inv.paths : []
150
+ const basis = inv.ingress?.snapshot
151
+ if (!basis) return niet('geen ingress-momentopname om tegen af te zetten')
152
+ const gewijzigd = paden.filter((p) => p.snapshot && velddigest(p.snapshot) !== velddigest(basis))
153
+ if (gewijzigd.length === 0) return niet('geen pad met een gewijzigd verzoek vastgelegd')
154
+ for (const p of gewijzigd) {
155
+ if (zelfde(p.identity, inv.ingress.identity)) {
156
+ return faal('CHANGED_REQUEST_SAME_IDENTITY', `pad ${p.path} wijzigde het verzoek en hield dezelfde identiteit`)
157
+ }
158
+ const events = Array.isArray(inv.events) ? inv.events : []
159
+ const erkend = events.some((e) => zelfde(e.identity, p.identity))
160
+ if (!erkend) {
161
+ return faal('CHANGED_REQUEST_NO_EVENT', `pad ${p.path} kreeg een andere identiteit zonder dat er een gebeurtenis bij hoort`)
162
+ }
163
+ }
164
+ return goed(`${gewijzigd.length} gewijzigd(e) verzoek(en), elk met eigen identiteit en een gebeurtenis`)
165
+ }
166
+
167
+ if (id === 'countersignature-recompute-or-refuse') {
168
+ const c = inv.countersignature
169
+ if (!c) return niet('geen medeondertekening vastgelegd')
170
+ if (c.recomputed === undefined || c.ingress_value === undefined) return niet('geen herberekende of ingress-waarde')
171
+ const past = zelfde(c.recomputed, c.ingress_value)
172
+ if (!past && c.signed === true) return faal('COUNTERSIGNED_DESPITE_MISMATCH', 'de herberekende waarde week af en er is toch ondertekend')
173
+ if (!past && c.settled === true) return faal('SETTLED_DESPITE_MISMATCH', 'de herberekende waarde week af en er is toch afgerekend')
174
+ if (past && c.signed === false) return goed('de waarden kloppen en er is niet ondertekend, dat is toegestaan maar ongebruikelijk')
175
+ return goed(past ? 'herberekend en gelijk, dus ondertekenen mag' : 'herberekend en afwijkend, dus geweigerd en niet afgerekend')
176
+ }
177
+
178
+ if (id === 'own-identity-from-decoded-fields') {
179
+ const v = inv.wire_variants
180
+ if (!Array.isArray(v) || v.length < 2) return niet('minder dan twee draadvarianten vastgelegd')
181
+ // Dezelfde velden, andere sleutelvolgorde of witruimte. De identiteit MOET gelijk
182
+ // blijven; verschilt hij, dan is hij uit de bytes afgeleid.
183
+ const digests = new Set(v.map((x) => velddigest(x.decoded)))
184
+ if (digests.size !== 1) return niet('de varianten hebben niet dezelfde gedecodeerde velden')
185
+ const ids = new Set(v.map((x) => kaal(x.identity)))
186
+ if (ids.size !== 1) {
187
+ return faal('IDENTITY_DERIVED_FROM_WIRE_BYTES', 'dezelfde velden in een andere sleutelvolgorde gaven een andere identiteit')
188
+ }
189
+ return goed(`${v.length} draadvarianten, dezelfde identiteit`)
190
+ }
191
+
192
+ if (id === 'reference-over-transmitted-bytes') {
193
+ const r = inv.signed_artifact_reference
194
+ if (!r || r.transmitted === undefined || r.reference_hash === undefined) return niet('geen verwijzing naar een ondertekend stuk')
195
+ // Wij rekenen beide kanten ZELF uit in plaats van een label te geloven.
196
+ const overBytes = bytedigest(r.transmitted)
197
+ let overVelden = null
198
+ try {
199
+ overVelden = velddigest(JSON.parse(r.transmitted))
200
+ } catch {
201
+ overVelden = null
202
+ }
203
+ if (zelfde(r.reference_hash, overBytes)) return goed('de verwijzing is over de verzonden bytes genomen')
204
+ if (overVelden && zelfde(r.reference_hash, overVelden)) {
205
+ return faal('REFERENCE_CANONICALIZED_BEFORE_HASHING', 'de verwijzing is over een gecanonicaliseerde vorm genomen, dus over een ander object dan er ondertekend is')
206
+ }
207
+ return faal('REFERENCE_HASH_UNEXPLAINED', 'de verwijzing komt noch met de verzonden bytes noch met een gecanonicaliseerde vorm overeen')
208
+ }
209
+
210
+ if (id === 'outcome-not-folded-into-identity') {
211
+ const o = inv.outcome_variants
212
+ if (!Array.isArray(o) || o.length < 2) return niet('minder dan twee uitkomst-varianten vastgelegd')
213
+ const ids = new Set(o.map((x) => kaal(x.identity)))
214
+ if (ids.size !== 1) {
215
+ return faal('OUTCOME_FOLDED_INTO_IDENTITY', 'de identiteit verschilt tussen volledige en gedeeltelijke levering, dus uitkomstdata zit erin')
216
+ }
217
+ return goed(`${o.length} leveringsvarianten, dezelfde identiteit`)
218
+ }
219
+
220
+ if (id === 'name-independent-but-bound') {
221
+ const k = inv.identity_field
222
+ if (!k || k.bound_to === undefined) return niet('geen identiteitsveld met een binding vastgelegd')
223
+ if (k.bound_to !== 'ingress-snapshot') {
224
+ return faal('IDENTITY_NOT_BOUND_TO_SNAPSHOT', `het veld ${k.name ?? 'onbekend'} is gebonden aan ${k.bound_to} in plaats van aan de ingress-momentopname`)
225
+ }
226
+ if (k.client_supplied === true && k.verified_against_snapshot !== true) {
227
+ return faal('CLIENT_SUPPLIED_IDENTITY_NOT_VERIFIED', 'de client levert de identiteit aan en die wordt niet tegen de momentopname getoetst')
228
+ }
229
+ return goed(`${k.name ?? 'het identiteitsveld'} is aan de ingress-momentopname gebonden`)
230
+ }
231
+
232
+ return niet('onbekende clausule')
233
+ }
234
+
235
+ /**
236
+ * Toetst alle clausules waarover de vector gegevens draagt.
237
+ *
238
+ * Het eindoordeel is FAIL zodra EEN clausule faalt, want 5.5 is een invariant en niet een
239
+ * cijferlijst. Zonder enige meetbare clausule is het NOT_MEASURED, nooit PASS: een vector
240
+ * zonder gegevens mag nooit als bewijs van naleving tellen.
241
+ */
242
+ export function toetsPogingIdentiteit(invoer) {
243
+ const inv = invoer && typeof invoer === 'object' ? invoer : {}
244
+ const perClausule = CLAUSULES.map((c) => toetsClausule(c.id, inv))
245
+ const gefaald = perClausule.filter((r) => r.uitkomst === 'FAIL')
246
+ const geslaagd = perClausule.filter((r) => r.uitkomst === 'PASS')
247
+ const uitkomst = gefaald.length > 0 ? 'FAIL' : geslaagd.length > 0 ? 'PASS' : 'NOT_MEASURED'
248
+ return {
249
+ outcome: uitkomst,
250
+ reason_code: gefaald[0]?.reason_code ?? null,
251
+ detail: gefaald[0]?.detail ?? null,
252
+ gemeten: geslaagd.length + gefaald.length,
253
+ niet_gemeten: perClausule.length - geslaagd.length - gefaald.length,
254
+ per_clausule: perClausule,
255
+ }
256
+ }
257
+
258
+ // Een ingang, zodat dit geen bestand is dat iemand moet vinden maar een regel die hij kan
259
+ // draaien. Zonder sleutel, zonder account, zonder netwerk.
260
+ if (process.argv[1]?.endsWith('attempt-identity.mjs')) {
261
+ const fs = await import('node:fs')
262
+ const path = await import('node:path')
263
+ const { fileURLToPath } = await import('node:url')
264
+ const map = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'vectors', 'attempt-identity')
265
+ const bestanden = fs.readdirSync(map).filter((b) => b.endsWith('.json')).sort()
266
+ let past = 0
267
+ for (const b of bestanden) {
268
+ const v = JSON.parse(fs.readFileSync(path.join(map, b), 'utf8'))
269
+ const u = toetsPogingIdentiteit(v.input)
270
+ const ok = u.outcome === v.expected.outcome && (v.expected.reason_code === undefined || u.reason_code === v.expected.reason_code)
271
+ if (ok) past++
272
+ console.log((ok ? ' ok ' : ' FAIL ') + b.replace('.json', '').padEnd(40) + u.outcome + (u.reason_code ? ' / ' + u.reason_code : ''))
273
+ }
274
+ console.log('')
275
+ console.log(past + ' of ' + bestanden.length + ' vectors behave as recorded, across ' + CLAUSULES.length + ' normative clauses of x402 spec section 5.5.')
276
+ console.log('Every clause carries one acceptance and at least one refusal. No account, no key, no network.')
277
+ if (past !== bestanden.length) process.exitCode = 1
278
+ }
@@ -90,7 +90,15 @@ export async function meetEindpunt(url, { fetchImpl = fetch, methode = 'POST', l
90
90
  // De herhaling blijft bewust smal: alleen 404 en 405, alleen als wij NIET met GET
91
91
  // sondeerden, en precies een keer. Elke 404 blind herhalen zou het verkeer naar elk
92
92
  // dood adres verdubbelen, en dat is niet aan ons om te doen.
93
- if (resp.status === 405 || resp.status === 404) {
93
+ // GEMETEN, EN DAARNA VERBREED. Na de 404 en 405 bleef er een restfout van 3,4 procent
94
+ // staan op zestig echte hosts, en die bestond uit twee andere codes die hetzelfde
95
+ // zeggen: cheaptokens.ai/api/buy gaf 400 op onze POST met een leeg lichaam en 402 op
96
+ // GET, en www.getbags.app/api/v1 gaf 406 op onze Accept-kop en 402 op GET. Alle vier
97
+ // de codes melden iets over ONS VERZOEK en niets over de bron, dus alle vier verdienen
98
+ // precies een tweede poging. De verzameling staat hier met naam zodat een vijfde code
99
+ // een gemeten beslissing vergt en niet stil meegroeit.
100
+ const VRAAG_OPNIEUW_BIJ = new Set([400, 404, 405, 406])
101
+ if (VRAAG_OPNIEUW_BIJ.has(resp.status)) {
94
102
  const allow = resp.headers.get('allow') || resp.headers.get('Allow') || ''
95
103
  const genoemd = allow
96
104
  .split(',')