agent-receipt-verify 0.0.0-stage → 0.2.2

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 (40) hide show
  1. package/CONFORMANCE.md +110 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +39 -0
  4. package/README.md +273 -2
  5. package/bench/eip3009-beleid.mjs +113 -0
  6. package/bench/robustness.mjs +264 -0
  7. package/bin/cli.mjs +95 -0
  8. package/examples/receipt-live-base-sepolia-2.json +21 -0
  9. package/examples/receipt-live-base-sepolia-3.json +21 -0
  10. package/examples/receipt-live-base-sepolia.json +21 -0
  11. package/package.json +61 -4
  12. package/profiles/profile.schema.json +115 -0
  13. package/profiles/x402-deployed-ladder-v1.json +178 -0
  14. package/src/eip3009-autorisatie.mjs +228 -0
  15. package/src/inam-countersigned.mjs +162 -0
  16. package/src/mcp-server.mjs +262 -0
  17. package/src/profiel.mjs +145 -0
  18. package/src/x402-conformance-check.mjs +194 -0
  19. package/src/x402-conformance-treden.mjs +246 -0
  20. package/src/x402-discovery-census.mjs +247 -0
  21. package/src/x402-index-liveness.mjs +201 -0
  22. package/src/x402-link-onchain.mjs +280 -0
  23. package/src/x402-metingen-bundel.mjs +96 -0
  24. package/src/x402-offer-receipt.mjs +84 -0
  25. package/src/x402-poortwachters.mjs +241 -0
  26. package/tools/bundel-bouw.mjs +126 -0
  27. package/tools/pack-inhoud.mjs +96 -0
  28. package/vectors/inam/BRON-README.md +38 -0
  29. package/vectors/inam/canonical-vectors.json +122 -0
  30. package/vectors/inam/vectors.json +96 -0
  31. package/vectors/kanon/amount-insufficient-001.json +52 -0
  32. package/vectors/kanon/cross-chain-replay-001.json +54 -0
  33. package/vectors/kanon/cross-contract-replay-001.json +54 -0
  34. package/vectors/kanon/domain-name-mismatch-001.json +53 -0
  35. package/vectors/kanon/expired-001.json +51 -0
  36. package/vectors/kanon/network-mismatch-001.json +52 -0
  37. package/vectors/kanon/nonce-replay-001.json +56 -0
  38. package/vectors/kanon/not-yet-valid-001.json +51 -0
  39. package/vectors/kanon/sig-malleable-high-s-001.json +54 -0
  40. package/vectors/kanon/valid-baseline-001.json +52 -0
@@ -0,0 +1,162 @@
1
+ // Onafhankelijke verificatie van INAM-tegengetekende bonnetjes.
2
+ //
3
+ // Geschreven tegen hun SPEC.md (§4.2 content-addressing, §4.3 lifecycle, §15 over
4
+ // HTTP) en hun README, NIET tegen hun verify.mjs. Dat is het hele punt: als twee
5
+ // implementaties die elkaars code niet hebben gezien dezelfde uitspraak doen over
6
+ // dezelfde vectoren, is dat interop-bewijs. Eén implementatie die zichzelf
7
+ // bevestigt, bewijst niets.
8
+ //
9
+ // Read-only, geen netwerk, geen sleutels van onszelf.
10
+
11
+ import { createHash, createPublicKey, verify as edVerify } from 'node:crypto'
12
+ import { readFileSync } from 'node:fs'
13
+
14
+ const DIRECT = process.argv[1]?.endsWith('inam-countersigned.mjs')
15
+ const VECTOREN = process.argv[2]
16
+ if (DIRECT && !VECTOREN) { console.error('usage: node src/inam-countersigned.mjs <vectors.json>'); process.exit(2) }
17
+
18
+ // ── bouwstenen ───────────────────────────────────────────────────────────────
19
+
20
+ // §4.2: "recursive key-sorted, whitespace-free JSON serializer"
21
+ export function canoniek(x) {
22
+ if (x === null || typeof x !== 'object') return JSON.stringify(x)
23
+ if (Array.isArray(x)) return '[' + x.map(canoniek).join(',') + ']'
24
+ return '{' + Object.keys(x).sort().map(k => JSON.stringify(k) + ':' + canoniek(x[k])).join(',') + '}'
25
+ }
26
+
27
+ const sha256hex = s => createHash('sha256').update(s, 'utf8').digest('hex')
28
+
29
+ // base58btc, zelf geschreven zodat we geen code van hen of van een pakket delen
30
+ const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
31
+ export function base58decode(s) {
32
+ let getal = 0n
33
+ for (const teken of s) {
34
+ const i = B58.indexOf(teken)
35
+ if (i < 0) throw new Error('geen base58: ' + teken)
36
+ getal = getal * 58n + BigInt(i)
37
+ }
38
+ const bytes = []
39
+ while (getal > 0n) { bytes.unshift(Number(getal & 255n)); getal >>= 8n }
40
+ for (const teken of s) { if (teken === '1') bytes.unshift(0); else break }
41
+ return Buffer.from(bytes)
42
+ }
43
+
44
+ // did:key met multicodec 0xed01 = Ed25519 publieke sleutel
45
+ export function didNaarSleutel(did) {
46
+ const deel = did.replace(/^did:key:/, '').split('#')[0]
47
+ if (!deel.startsWith('z')) throw new Error('verwacht base58btc (z...)')
48
+ const ruw = base58decode(deel.slice(1))
49
+ if (ruw[0] !== 0xed || ruw[1] !== 0x01) throw new Error('geen ed25519-multicodec')
50
+ const sleutel = ruw.subarray(2)
51
+ if (sleutel.length !== 32) throw new Error('verkeerde sleutellengte ' + sleutel.length)
52
+ // minimale SPKI-DER-omhulling rond een rauwe ed25519-sleutel
53
+ const der = Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), sleutel])
54
+ return createPublicKey({ key: der, format: 'der', type: 'spki' })
55
+ }
56
+
57
+ export function ed25519Klopt(did, inhoud, handtekeningB64) {
58
+ try {
59
+ const sig = Buffer.from(handtekeningB64, handtekeningB64.includes('-') || handtekeningB64.includes('_') ? 'base64url' : 'base64')
60
+ return edVerify(null, Buffer.from(inhoud, 'utf8'), didNaarSleutel(did), sig)
61
+ } catch { return false }
62
+ }
63
+
64
+ let keccak256
65
+ try {
66
+ // Een BARE import, en bewust geen pad op iemands schijf. Dit stond hier als
67
+ // file:///C:/Code/... en dat werkte alleen op de machine van de schrijver: voor
68
+ // iedere vreemde viel controle 1 stil weg en kreeg hij zonder het te weten een
69
+ // zwakkere uitslag dan wij. Dat is het tegenovergestelde van een onafhankelijke
70
+ // verifieerder. ethers staat nu als optionele afhankelijkheid in package.json.
71
+ const ethers = await import('ethers')
72
+ keccak256 = b => ethers.keccak256(b)
73
+ } catch {
74
+ keccak256 = null
75
+ }
76
+
77
+ // ── de acht controles, zoals hun README ze beschrijft ────────────────────────
78
+
79
+ export function toetsGeval(geval, vectoren) {
80
+ const mislukt = []
81
+ const bestand = geval.feedbackFile
82
+ const f = JSON.parse(bestand)
83
+ const r = f?.inam?.receipt ?? {}
84
+ const ev = geval.onchainEvent ?? {}
85
+ const obs = vectoren.observed ?? {}
86
+
87
+ // 1. keccak256 van de bestandsbytes is de feedbackHash uit het event
88
+ if (keccak256) {
89
+ const berekend = keccak256(Buffer.from(bestand, 'utf8'))
90
+ if (String(berekend).toLowerCase() !== String(ev.feedbackHash).toLowerCase()) mislukt.push('feedbackHashMatchesFile')
91
+ }
92
+
93
+ // 2. §4.2: receiptId = sha256 over canoniek van zeven velden
94
+ const zeven = {}
95
+ for (const k of ['jobId', 'agentA', 'agentB', 'task', 'result', 'settlement', 'verification']) {
96
+ if (r[k] !== undefined) zeven[k] = r[k]
97
+ }
98
+ if ('sha256:' + sha256hex(canoniek(zeven)) !== r.receiptId) mislukt.push('receiptIdRecomputes')
99
+
100
+ // 3+4. §4.3: beide partijen tekenen DEZELFDE canonieke inhoud, alles behalve
101
+ // signatures, status en dispute
102
+ const teTekenen = { ...r }
103
+ delete teTekenen.signatures; delete teTekenen.status; delete teTekenen.dispute
104
+ const inhoud = canoniek(teTekenen)
105
+ const sigs = r.signatures ?? {}
106
+ const didB = r.agentB?.did ?? r.agentB?.id ?? vectoren.parties?.provider?.did
107
+ const didA = r.agentA?.did ?? r.agentA?.id ?? vectoren.parties?.requester?.did
108
+ const sigB = sigs.agentB?.signature ?? sigs.agentB ?? sigs.worker
109
+ const sigA = sigs.agentA?.signature ?? sigs.agentA ?? sigs.requester
110
+ if (!sigB || !ed25519Klopt(didB, inhoud, sigB)) mislukt.push('workerSignature')
111
+ if (!sigA || !ed25519Klopt(didA, inhoud, sigA)) mislukt.push('requesterSignature')
112
+
113
+ // 5. waarde hoort bij de uitkomst: 100 / 50 / 0
114
+ const uitkomst = r.result?.outcome ?? r.result?.status
115
+ const hoort = { success: 100, partial: 50, failed: 0 }[uitkomst]
116
+ const waarde = ev.value ?? f.value
117
+ if (hoort !== undefined && Number(waarde) !== hoort) mislukt.push('valueMatchesOutcome')
118
+
119
+ // 6. het bestand noemt het adres dat het event verstuurde
120
+ const inBestand = String(f.clientAddress ?? '').split(':').pop().toLowerCase()
121
+ if (inBestand !== String(ev.clientAddress ?? '').toLowerCase()) mislukt.push('fileClientAddressMatchesSender')
122
+
123
+ // 7. dat adres is de wallet waarvan de requester controle bewees
124
+ const bewezen = String(vectoren.parties?.requester?.erc8004Address ?? '').toLowerCase()
125
+ if (String(ev.clientAddress ?? '').toLowerCase() !== bewezen) mislukt.push('senderIsRequesterWallet')
126
+
127
+ // 8+9. §15: hashes over de bytes die daadwerkelijk over de lijn gingen
128
+ const u = new URL(obs.request?.url ?? 'https://x/')
129
+ const verzoekBytes = `${obs.request?.method} ${u.pathname}${u.search}\n${obs.request?.body ?? ''}`
130
+ if ('sha256:' + sha256hex(verzoekBytes) !== r.task?.specHash) mislukt.push('specHashMatchesObservedBytes')
131
+ if ('sha256:' + sha256hex(obs.response?.body ?? '') !== r.result?.outputHash) mislukt.push('outputHashMatchesObservedBytes')
132
+
133
+ return { accepted: mislukt.length === 0, failedChecks: mislukt }
134
+ }
135
+
136
+ // ── draaien en vergelijken met wat de vectoren zelf verwachten ───────────────
137
+ //
138
+ // Alleen bij directe aanroep. Importeren levert de functies, niets anders.
139
+ if (DIRECT) {
140
+
141
+ const v = JSON.parse(readFileSync(VECTOREN, 'utf8'))
142
+ console.log(`suite: ${v.suite}`)
143
+ console.log(`keccak256 beschikbaar: ${keccak256 ? 'ja' : 'NEE (controle 1 overgeslagen)'}\n`)
144
+
145
+ let gelijk = 0, afwijkend = 0
146
+ for (const geval of v.cases) {
147
+ const onze = toetsGeval(geval, v)
148
+ const hunne = geval.expected
149
+ const zelfdeOordeel = onze.accepted === hunne.accepted
150
+ const onzeSet = [...onze.failedChecks].sort().join(',')
151
+ const hunSet = [...(hunne.failedChecks ?? [])].sort().join(',')
152
+ const zelfdeChecks = onzeSet === hunSet
153
+ const merk = zelfdeOordeel && zelfdeChecks ? 'GELIJK ' : (zelfdeOordeel ? 'deels ' : 'AFWIJKEND')
154
+ if (zelfdeOordeel && zelfdeChecks) gelijk++; else afwijkend++
155
+ console.log(`${merk} ${geval.id}`)
156
+ console.log(` zij : accepted=${hunne.accepted} [${hunSet || '-'}]`)
157
+ console.log(` wij : accepted=${onze.accepted} [${onzeSet || '-'}]`)
158
+ }
159
+ console.log(`\n${gelijk} van ${v.cases.length} volledig gelijk, ${afwijkend} afwijkend`)
160
+ process.exit(afwijkend === 0 ? 0 : 1)
161
+
162
+ }
@@ -0,0 +1,262 @@
1
+ // Een MCP-server, zodat een agent deze controle AANROEPT in plaats van hem te moeten
2
+ // aanbevelen.
3
+ //
4
+ // WAAROM DIT BESTAAT, en het is gemeten en niet bedacht. Op 2026-10-17 classificeerde
5
+ // de gratis laag 32 marktsignalen over agent-betalingen: 14 benoemen een concreet
6
+ // probleem en alle 14 staan aan de KOPERSKANT. Niet een enkel verkopersprobleem. De
7
+ // titels zeggen het zelf: "5 Ways Your Paid Endpoint Gets Drained", "your agent still
8
+ // needs a bill", en "Your AI Agent Paid a Supplier Twice. Can You Prove What It Did?"
9
+ // In dezelfde ronde bleken er in de MCP-directories al zeven partijen DATA te
10
+ // verkopen over x402 en geen enkele die de betaallaag zelf controleert.
11
+ //
12
+ // Dat is de hele reden voor deze twee gereedschappen: het voor en het na van die ene
13
+ // pijn. Voor je betaalt, klopt dit eindpunt. Na je betaling, hoort dit bonnetje bij
14
+ // precies een echte betaling.
15
+ //
16
+ // GEEN AFHANKELIJKHEDEN, EN DAT IS EEN KEUZE EN GEEN LUIHEID. Dit protocol is
17
+ // newline-gescheiden JSON-RPC over stdio, dat is een paar regels. De officiele
18
+ // bibliotheek erbij halen zou betekenen dat een verifieerder wiens hele waarde
19
+ // onafhankelijkheid is, een boom afhankelijkheden meeneemt om te zeggen of een
20
+ // handtekening klopt. De vormen hieronder zijn overgenomen uit de officiele sdk die
21
+ // in ons andere project staat, niet uit het hoofd: protocolversie, de tools-vorm en
22
+ // de content-vorm komen daar letterlijk uit.
23
+ //
24
+ // DE VALKUIL DIE EEN MCP-SERVER STIL KAPOT MAAKT: alles wat naar stdout gaat en geen
25
+ // JSON-RPC-frame is, breekt de verbinding. Daarom gaat ELKE melding naar stderr en
26
+ // schrijft alleen `stuur()` naar stdout.
27
+ //
28
+ // GEBRUIK (in een MCP-client):
29
+ // npx github:holistis/agent-receipt-verify mcp
30
+ //
31
+ // WAT DIT NIET DOET: het oordeelt over de VORM van een eindpunt en een bonnetje, niet
32
+ // over de eerlijkheid van de partij erachter. Een eindpunt dat alles goed doet kan je
33
+ // alsnog rommel verkopen. Zie CONFORMANCE.md voor wat met opzet niet gemeten wordt.
34
+
35
+ import { meetEindpunt, hoogsteTrede } from './x402-conformance-check.mjs'
36
+ import { TREDEN, PROFIEL } from './x402-conformance-treden.mjs'
37
+ import { maakRpc, linkBonnetje } from './x402-link-onchain.mjs'
38
+
39
+ const PROTOCOL = '2025-11-25'
40
+ const ONDERSTEUND = [PROTOCOL, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']
41
+
42
+ const NAAM = 'agent-receipt-verify'
43
+ const VERSIE = '0.2.0'
44
+
45
+ // Een publieke rpc per netwerk, zodat de koper niet eerst een sleutel hoeft te regelen
46
+ // om zijn eigen bonnetje te kunnen natrekken. Een netwerk dat hier niet staat wordt
47
+ // EERLIJK als niet-ondersteund gemeld en nooit stil op een ander netwerk gezocht.
48
+ const RPC_PER_NETWERK = {
49
+ 'eip155:84532': 'https://sepolia.base.org',
50
+ 'eip155:8453': 'https://mainnet.base.org',
51
+ 'base-sepolia': 'https://sepolia.base.org',
52
+ base: 'https://mainnet.base.org',
53
+ }
54
+
55
+ const log = (...a) => process.stderr.write('[' + NAAM + '] ' + a.join(' ') + '\n')
56
+
57
+ const GEREEDSCHAP = [
58
+ {
59
+ name: 'check_x402_endpoint',
60
+ description:
61
+ 'Check a paid x402 endpoint BEFORE paying it. Sends exactly one request, never pays, and reports which levels of ' +
62
+ 'the published conformance ladder it reaches: does it ask for payment the way x402 says, does it declare the ' +
63
+ 'offer-receipt extension, and does the signature on its offer actually verify. Levels that need a real settlement ' +
64
+ 'are reported as not measured, never as failed.',
65
+ inputSchema: {
66
+ type: 'object',
67
+ properties: {
68
+ url: { type: 'string', description: 'The paid endpoint to check, for example https://api.example.com/paid/thing' },
69
+ method: { type: 'string', description: 'HTTP method the endpoint expects. Defaults to POST. Use GET for a GET-only route.' },
70
+ },
71
+ required: ['url'],
72
+ },
73
+ },
74
+ {
75
+ name: 'link_receipt_to_settlement',
76
+ description:
77
+ 'Check AFTER paying that a version 2 offer-receipt belongs to exactly one real on-chain settlement. Takes the ' +
78
+ 'signed receipt payload plus the authorization salt the seller returned outside it, and resolves the salted ' +
79
+ 'commitment against live chain state. Answers linked, not linked, or could-not-check, and never guesses between them.',
80
+ inputSchema: {
81
+ type: 'object',
82
+ properties: {
83
+ payload: { type: 'object', description: 'The signed receipt payload, the object with version, network, payer and authorizationCommitment' },
84
+ salt: { type: 'string', description: 'The authorizationSalt from extensions["offer-receipt"].info, which is deliberately not inside the signed payload' },
85
+ asset: { type: 'string', description: 'The token contract from the accepts entry that was paid, for example the USDC address' },
86
+ rpcUrl: { type: 'string', description: 'Optional. An RPC endpoint to use instead of the public default for that network.' },
87
+ },
88
+ required: ['payload', 'salt', 'asset'],
89
+ },
90
+ },
91
+ ]
92
+
93
+ function tekst(s, fout = false) {
94
+ return { content: [{ type: 'text', text: s }], ...(fout ? { isError: true } : {}) }
95
+ }
96
+
97
+ async function doeCheckEndpoint(args) {
98
+ const url = typeof args?.url === 'string' ? args.url.trim() : ''
99
+ if (!/^https?:\/\//.test(url)) return tekst('url must be an absolute http or https address', true)
100
+ const methode = typeof args?.method === 'string' && /^(GET|POST|PUT|PATCH|DELETE)$/i.test(args.method)
101
+ ? args.method.toUpperCase()
102
+ : 'POST'
103
+
104
+ const m = await meetEindpunt(url, { methode })
105
+ const hoogste = hoogsteTrede(m)
106
+ const regels = [
107
+ (hoogste >= 0 ? 'Reaches level ' + hoogste : 'Reaches no level') + ' of the conformance ladder.',
108
+ 'Checked with a single ' + methode + ' request. Nothing was paid.',
109
+ '',
110
+ ]
111
+ for (const t of TREDEN) {
112
+ const staat = m.treden[t.sleutel]
113
+ if (!staat) continue
114
+ const merk = staat === 'HAALT' ? 'reached ' : staat === 'HAALT_NIET' ? 'NOT reached' : 'not measured'
115
+ regels.push('L' + t.nummer + ' ' + merk + ' ' + t.naam)
116
+ if (m.bewijs[t.sleutel]) regels.push(' ' + m.bewijs[t.sleutel])
117
+ }
118
+ if (m.bewijs.t2afwijking) regels.push('', 'note on the key: ' + m.bewijs.t2afwijking)
119
+ // De uitslag draagt nu ook WAAR de ladder vandaan komt. Een cijfer zonder
120
+ // herkomst is een mening, en een trede die tegen een nog niet samengevoegd
121
+ // voorstel meet, hoort dat zelf te zeggen in plaats van het aan de lezer te laten.
122
+ const voorstelTreden = TREDEN.filter((t) => t.citeertVoorstel).map((t) => 'L' + t.nummer)
123
+ regels.push(
124
+ '',
125
+ 'Ladder: profile ' + PROFIEL.profile + ' v' + PROFIEL.version + ' of ' + PROFIEL.published +
126
+ ', which names the clause behind every level: profiles/x402-deployed-ladder-v1.json',
127
+ )
128
+ if (voorstelTreden.length) {
129
+ regels.push(
130
+ voorstelTreden.join(' and ') + ' are measured against sections 5.6 and later of the offer-receipt ' +
131
+ 'extension, which exist only in pull request 3827 and are not merged. Their numbering can still move.',
132
+ )
133
+ }
134
+ regels.push(
135
+ 'Levels 3 to 5 cannot be observed without paying, so they are reported as not measured rather than absent.',
136
+ 'The ladder and what it deliberately does not measure: https://github.com/holistis/agent-receipt-verify/blob/main/CONFORMANCE.md',
137
+ 'This judges the form of an endpoint, not the honesty of the party behind it.',
138
+ )
139
+ return tekst(regels.join('\n'))
140
+ }
141
+
142
+ async function doeLinkReceipt(args) {
143
+ const payload = args?.payload
144
+ const salt = typeof args?.salt === 'string' ? args.salt.trim() : ''
145
+ const asset = typeof args?.asset === 'string' ? args.asset.trim() : ''
146
+ if (!payload || typeof payload !== 'object') return tekst('payload must be the signed receipt object', true)
147
+ if (!/^0x[0-9a-fA-F]{64}$/.test(salt)) return tekst('salt must be a 32 byte hex string starting with 0x', true)
148
+ if (!/^0x[0-9a-fA-F]{40}$/.test(asset)) return tekst('asset must be a 20 byte hex address starting with 0x', true)
149
+
150
+ const netwerk = String(payload.network ?? '')
151
+ const rpcUrl = typeof args?.rpcUrl === 'string' && args.rpcUrl ? args.rpcUrl : RPC_PER_NETWERK[netwerk]
152
+ if (!rpcUrl) {
153
+ return tekst(
154
+ 'could not check: no public RPC is configured for network "' + netwerk + '".\n' +
155
+ 'Pass rpcUrl explicitly. This is reported as could-not-check, not as not-linked, because those are different answers.',
156
+ true,
157
+ )
158
+ }
159
+
160
+ try {
161
+ const uit = await linkBonnetje(maakRpc(rpcUrl), { payload, salt, asset })
162
+ const regels = []
163
+ if (uit.linked) {
164
+ regels.push('LINKED. This receipt opens to exactly one on-chain authorization.')
165
+ regels.push('nonce ' + uit.identifier)
166
+ if (uit.afrekening?.tx) regels.push('transaction ' + uit.afrekening.tx)
167
+ if (uit.afrekening?.blok) regels.push('block ' + uit.afrekening.blok)
168
+ } else {
169
+ // Niet-gelinkt heeft bij deze controle altijd een REDEN mee, en die reden IS
170
+ // het antwoord. Nul kandidaten en twee kandidaten zijn allebei niet-gelinkt en
171
+ // bewust niet waarschijnlijk-gelinkt: fail-closed, zie 5.6.4 stap 3.
172
+ regels.push('NOT LINKED. ' + (uit.reden ?? 'the commitment did not open to exactly one authorization'))
173
+ regels.push('Two candidates opening the same commitment reads as not linked on purpose, not as probably linked.')
174
+ regels.push('A receipt signed before its settlement was mined can also read this way; retry later or widen the window.')
175
+ }
176
+ if (uit.venster) {
177
+ regels.push(
178
+ '',
179
+ 'searched blocks ' + uit.venster.vanBlok + ' to ' + uit.venster.totBlok +
180
+ ' (' + uit.venster.kandidaten + ' candidate authorizations, ' + uit.venster.aanroepen + ' rpc calls)',
181
+ )
182
+ }
183
+ regels.push('checked against ' + rpcUrl + ' for network ' + netwerk + '.')
184
+ return tekst(regels.join('\n'))
185
+ } catch (e) {
186
+ return tekst('could not check: ' + String(e.message).slice(0, 180) + '\nThis is a failure of the check, not a verdict on the receipt.', true)
187
+ }
188
+ }
189
+
190
+ async function behandel(bericht) {
191
+ const { id, method, params } = bericht
192
+
193
+ if (method === 'initialize') {
194
+ const gevraagd = params?.protocolVersion
195
+ return {
196
+ protocolVersion: ONDERSTEUND.includes(gevraagd) ? gevraagd : PROTOCOL,
197
+ capabilities: { tools: {} },
198
+ serverInfo: { name: NAAM, version: VERSIE },
199
+ }
200
+ }
201
+ if (method === 'ping') return {}
202
+ if (method === 'tools/list') return { tools: GEREEDSCHAP }
203
+ if (method === 'tools/call') {
204
+ const naam = params?.name
205
+ const args = params?.arguments ?? {}
206
+ if (naam === 'check_x402_endpoint') return await doeCheckEndpoint(args)
207
+ if (naam === 'link_receipt_to_settlement') return await doeLinkReceipt(args)
208
+ // Een onbekend gereedschap is een fout van de aanroeper en hoort als JSON-RPC-fout
209
+ // terug, niet als een tekst die op een uitslag lijkt.
210
+ const fout = new Error('unknown tool: ' + String(naam))
211
+ fout.code = -32602
212
+ throw fout
213
+ }
214
+ const fout = new Error('method not found: ' + String(method))
215
+ fout.code = -32601
216
+ throw fout
217
+ }
218
+
219
+ function stuur(obj) {
220
+ process.stdout.write(JSON.stringify(obj) + '\n')
221
+ }
222
+
223
+ export function maakVerwerker() {
224
+ let buffer = ''
225
+ return async function verwerk(brok) {
226
+ buffer += brok
227
+ let grens
228
+ while ((grens = buffer.indexOf('\n')) >= 0) {
229
+ const regel = buffer.slice(0, grens).trim()
230
+ buffer = buffer.slice(grens + 1)
231
+ if (!regel) continue
232
+ let bericht
233
+ try {
234
+ bericht = JSON.parse(regel)
235
+ } catch {
236
+ stuur({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } })
237
+ continue
238
+ }
239
+ // Een notificatie heeft geen id en krijgt per specificatie GEEN antwoord. Wie
240
+ // daar toch op antwoordt, laat sommige clients vastlopen.
241
+ if (bericht.id === undefined || bericht.id === null) {
242
+ if (bericht.method === 'notifications/initialized') log('client is ready')
243
+ continue
244
+ }
245
+ try {
246
+ stuur({ jsonrpc: '2.0', id: bericht.id, result: await behandel(bericht) })
247
+ } catch (e) {
248
+ stuur({ jsonrpc: '2.0', id: bericht.id, error: { code: e.code ?? -32603, message: String(e.message).slice(0, 200) } })
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ if (process.argv[1]?.endsWith('mcp-server.mjs')) {
255
+ log('listening on stdio, protocol ' + PROTOCOL + ', ' + GEREEDSCHAP.length + ' tools')
256
+ const verwerk = maakVerwerker()
257
+ process.stdin.setEncoding('utf8')
258
+ process.stdin.on('data', (b) => {
259
+ verwerk(b).catch((e) => log('handler error: ' + e.message))
260
+ })
261
+ process.stdin.on('end', () => process.exit(0))
262
+ }
@@ -0,0 +1,145 @@
1
+ // Het profiel inlezen en toetsen, zodat de ladder één waarheid heeft.
2
+ //
3
+ // WAAROM DIT BESTAAT. Onze ladder stond als data in een JavaScript-bestand. Dat is
4
+ // prima voor ons en onbruikbaar voor iedereen anders: een tweede implementatie kan
5
+ // een .mjs niet inlezen, en dus bleef onze meetlat opgesloten in onze eigen code.
6
+ // Precies die opsluiting is wat x402#3396 als probleem benoemt: gereedschappen
7
+ // coderen de regels in code, dus ze lopen uiteen en zijn het oneens over wat een
8
+ // gebrek is. Vanaf nu staat de ladder in profiles/x402-deployed-ladder-v1.json en
9
+ // leest deze code dat bestand. Verandert de ladder, dan verandert het bestand, en
10
+ // dan ziet een vreemde dat aan de versie en de datum.
11
+ //
12
+ // WAAROM EIGEN TOETSING EN GEEN JSON-SCHEMA-BIBLIOTHEEK. Dit project heeft geen
13
+ // verplichte afhankelijkheden en dat is een eigenschap, niet een gemis: een
14
+ // verifieerder die zelf een halve boom aan pakketten binnentrekt, vraagt vertrouwen
15
+ // in plaats van het te geven. Het schema in profiles/profile.schema.json is er voor
16
+ // een buitenstaander die wél een validator heeft. De controles hieronder dekken
17
+ // bovendien iets wat geen JSON-schema kan uitdrukken: de verbanden TUSSEN velden.
18
+
19
+ import fs from 'node:fs'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ const pad = (rel) => fileURLToPath(new URL(rel, import.meta.url))
23
+
24
+ export const PROFIEL_PAD = pad('../profiles/x402-deployed-ladder-v1.json')
25
+
26
+ /**
27
+ * De verbanden die een JSON-schema niet kan uitdrukken, en die allemaal een keer
28
+ * een echte fout zouden zijn geweest als niemand ze had gecontroleerd.
29
+ *
30
+ * Geeft een lijst klachten terug. Leeg is goed. Gooit niet: de aanroeper beslist of
31
+ * een kapot profiel een harde fout is (bij ons: ja, zie onderaan dit bestand).
32
+ */
33
+ export function toetsProfiel(p) {
34
+ const klachten = []
35
+ const zeg = (m) => klachten.push(m)
36
+
37
+ if (!p || typeof p !== 'object') return ['het profiel is geen object']
38
+ for (const veld of ['profile', 'version', 'published', 'intent', 'outcomes', 'specs', 'checks']) {
39
+ if (p[veld] === undefined) zeg(`verplicht veld ontbreekt: ${veld}`)
40
+ }
41
+ if (!Array.isArray(p.checks) || p.checks.length === 0) return klachten.concat('checks is leeg of geen lijst')
42
+
43
+ // Vier uitkomsten, niet drie. Drie is de fout die wij zelf maakten: dan valt
44
+ // "wij hebben het niet geprobeerd" samen met "wij konden niet beslissen", en een
45
+ // lezer kan een gat in de meter niet onderscheiden van een gebrek in het eindpunt.
46
+ const verwachteUitkomsten = ['pass', 'fail', 'skip', 'inconclusive']
47
+ const gegeven = Object.keys(p.outcomes ?? {}).sort()
48
+ if (gegeven.join(',') !== verwachteUitkomsten.slice().sort().join(',')) {
49
+ zeg(`outcomes moet exact ${verwachteUitkomsten.join(', ')} zijn, gevonden: ${gegeven.join(', ') || 'niets'}`)
50
+ }
51
+
52
+ // Elke gepinde spec moet compleet zijn, anders is een clausuleverwijzing een
53
+ // verwijzing naar wat er vandaag staat.
54
+ for (const [naam, s] of Object.entries(p.specs ?? {})) {
55
+ for (const veld of ['repo', 'path', 'commit', 'state']) {
56
+ if (!s?.[veld]) zeg(`spec ${naam} mist ${veld}`)
57
+ }
58
+ if (s?.state && !['merged on main', 'proposed, not merged'].includes(s.state)) {
59
+ zeg(`spec ${naam} heeft een onbekende state: ${s.state}`)
60
+ }
61
+ // Een voorstel zonder PR-nummer is niet terug te vinden door een vreemde.
62
+ if (s?.state === 'proposed, not merged' && typeof s.pullRequest !== 'number') {
63
+ zeg(`spec ${naam} is een voorstel maar noemt geen pullRequest`)
64
+ }
65
+ }
66
+
67
+ const ids = new Set(), keys = new Set(), niveaus = []
68
+ for (const c of p.checks) {
69
+ if (ids.has(c.id)) zeg(`dubbele check-id: ${c.id}`)
70
+ ids.add(c.id)
71
+ if (keys.has(c.key)) zeg(`dubbele check-key: ${c.key}`)
72
+ keys.add(c.key)
73
+ niveaus.push(c.level)
74
+
75
+ if (!Array.isArray(c.clause) || c.clause.length === 0) zeg(`${c.id} heeft geen clausuleverwijzing`)
76
+ for (const cl of c.clause ?? []) {
77
+ if (!p.specs?.[cl.spec]) zeg(`${c.id} verwijst naar een onbekende spec: ${cl.spec}`)
78
+ if (!cl.section || String(cl.section).length < 3) zeg(`${c.id} heeft een clausule zonder sectie`)
79
+ }
80
+ for (const rv of c.relatedVectors ?? []) {
81
+ if (!p.relatedWork?.[rv.set]) zeg(`${c.id} verwijst naar een onbekende vectorset: ${rv.set}`)
82
+ }
83
+ if (!['skip', 'inconclusive'].includes(c.whenNotObservable)) {
84
+ zeg(`${c.id} heeft een onbekende whenNotObservable: ${c.whenNotObservable}`)
85
+ }
86
+ if (!c.assertion?.evaluator || !c.assertion?.describes) zeg(`${c.id} mist evaluator of describes`)
87
+ if (!['MUST', 'SHOULD'].includes(c.normative)) zeg(`${c.id} heeft een onbekende normative: ${c.normative}`)
88
+ if (!c.input || typeof c.input.pays !== 'boolean') zeg(`${c.id} zegt niet of hij betaalt`)
89
+
90
+ // DE INVARIANT DIE HET MEEST UITMAAKT. Een passief profiel mag een check die
91
+ // een echte afrekening nodig heeft nooit als fail rapporteren: dat zou een
92
+ // eindpunt afstraffen voor iets dat wij bewust niet hebben geprobeerd. Precies
93
+ // deze regel staat als zin in onze CONFORMANCE.md en was tot nu toe nergens
94
+ // mechanisch afgedwongen.
95
+ if (c.input?.pays === true && c.whenNotObservable !== 'skip') {
96
+ zeg(`${c.id} heeft een afrekening nodig maar valt niet terug op skip, dus een passieve run zou hem kunnen afkeuren`)
97
+ }
98
+ if (c.input?.pays === false && c.whenNotObservable === 'skip') {
99
+ zeg(`${c.id} heeft geen afrekening nodig, dus skip is hier de verkeerde terugval: dit hoort inconclusive te zijn`)
100
+ }
101
+ }
102
+
103
+ // Niveaus moeten 0 tot n zijn zonder gat: de hoogste-trede-regel loopt van onder
104
+ // naar boven en stopt bij de eerste die niet haalt. Een gat zou die lus stil laten
105
+ // stoppen op een trede die niemand heeft gemeten.
106
+ const gesorteerd = niveaus.slice().sort((a, b) => a - b)
107
+ for (let i = 0; i < gesorteerd.length; i++) {
108
+ if (gesorteerd[i] !== i) { zeg(`niveaus lopen niet van 0 op zonder gat: ${gesorteerd.join(', ')}`); break }
109
+ }
110
+ for (let i = 1; i < niveaus.length; i++) {
111
+ if (niveaus[i] <= niveaus[i - 1]) { zeg('checks staan niet op oplopend niveau in het bestand'); break }
112
+ }
113
+
114
+ return klachten
115
+ }
116
+
117
+ /** Laadt en toetst het profiel. Gooit bij een klacht, want een kapot profiel levert stil verkeerde metingen. */
118
+ export function laadProfiel(bestand = PROFIEL_PAD) {
119
+ const p = JSON.parse(fs.readFileSync(bestand, 'utf8'))
120
+ const klachten = toetsProfiel(p)
121
+ if (klachten.length) throw new Error('profiel niet in orde:\n ' + klachten.join('\n '))
122
+ return p
123
+ }
124
+
125
+ /**
126
+ * De treden zoals de rest van deze codebase ze al jaren verwacht, nu AFGELEID uit
127
+ * het profiel in plaats van ernaast geschreven. Bewust dezelfde veldnamen, zodat
128
+ * geen enkele aanroeper hoeft te veranderen en de omzetting dus aantoonbaar niets
129
+ * aan het gedrag verandert.
130
+ */
131
+ export function tredenUitProfiel(p) {
132
+ return p.checks.map((c) => ({
133
+ sleutel: c.key,
134
+ nummer: c.level,
135
+ naam: c.name,
136
+ eis: c.requirement,
137
+ meetbaarZonderTeBetalen: c.input.pays === false,
138
+ // Nieuw en alleen toegevoegd: waar deze trede vandaan komt, en of dat een
139
+ // voorstel is. Daarmee kan een uitslag eerlijk zeggen tegen wat hij meet.
140
+ profielId: c.id,
141
+ clausule: c.clause.map((cl) => `${p.specs[cl.spec].path} §${cl.section}`),
142
+ citeertVoorstel: c.clause.some((cl) => p.specs[cl.spec].state === 'proposed, not merged'),
143
+ terugvalAlsNietTeZien: c.whenNotObservable,
144
+ }))
145
+ }