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.
- package/CONFORMANCE.md +110 -0
- package/LICENSE +201 -0
- package/NOTICE +39 -0
- package/README.md +273 -2
- package/bench/eip3009-beleid.mjs +113 -0
- package/bench/robustness.mjs +264 -0
- package/bin/cli.mjs +95 -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 +61 -4
- package/profiles/profile.schema.json +115 -0
- package/profiles/x402-deployed-ladder-v1.json +178 -0
- package/src/eip3009-autorisatie.mjs +228 -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/tools/pack-inhoud.mjs +96 -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,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
|
+
}
|
package/src/profiel.mjs
ADDED
|
@@ -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
|
+
}
|