@muretai/agent-entry 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * conformance/run.mjs — check an Agent Entry implementation against the golden vectors.
4
+ *
5
+ * WHY THIS SHIPS IN THE PACKAGE. A conformance suite that lives on a website is a suite
6
+ * with an uptime requirement, and until now this one was worse than that: the vectors sat
7
+ * in a git checkout that `npm install` never delivers, so "run the suite" meant "send us
8
+ * your implementation and we will run it". Everything needed to hold this code to its own
9
+ * contract is now in the tarball: `npm test`, no network, no dependencies, no account.
10
+ *
11
+ * WHAT IT CHECKS, AND WHY BOTH HALVES ARE HERE. The positive half proves this build
12
+ * produces the same BYTES as every other implementation — canonical JSON, did:key, the six
13
+ * signed fields. The negative half proves it REFUSES what it must, and it is the half that
14
+ * catches the failure nobody notices: an implementation that verifies nothing passes every
15
+ * positive vector in the file. A drift in either direction is silent on the wire — nothing
16
+ * throws, signatures simply stop verifying for everyone else.
17
+ *
18
+ * Run: node conformance/run.mjs (from the package root)
19
+ * npm test
20
+ */
21
+
22
+ import { readFileSync } from 'node:fs';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { dirname, join } from 'node:path';
25
+
26
+ import {
27
+ canonicalJSON, didFromPublicKeyHex, publicKeyFromSeedHex, signingPayload,
28
+ signEnvelope, verifyEnvelope,
29
+ } from '../muretai-agent-entry.mjs';
30
+
31
+ const HERE = dirname(fileURLToPath(import.meta.url));
32
+ const vectors = JSON.parse(readFileSync(join(HERE, 'vectors.json'), 'utf8'));
33
+
34
+ let pass = 0;
35
+ const failures = [];
36
+
37
+ function check(ok, label, detail) {
38
+ if (ok) { pass += 1; return true; }
39
+ failures.push(detail ? `${label}\n ${detail}` : label);
40
+ return false;
41
+ }
42
+
43
+ // ---------------------------------------------------------------- canonical JSON
44
+ for (const v of vectors.canonical) {
45
+ let got;
46
+ try { got = canonicalJSON(v.payload); } catch (e) { got = `THREW: ${e.message}`; }
47
+ check(got === v.canonical, `canonical/${v.name}`,
48
+ got === v.canonical ? '' : `want ${JSON.stringify(v.canonical)}\n got ${JSON.stringify(got)}`);
49
+ }
50
+
51
+ // `numberHazards` is DELIBERATELY NOT EXECUTED, and reading it is the point. Every case
52
+ // there is a value whose canonical bytes differ between languages, so asserting either
53
+ // spelling would be asserting one runtime's float formatting — the opposite of the
54
+ // contract. The rule it carries is a SIGNER discipline (`signMustNotEmit`), not a
55
+ // canonicaliser output: never sign a payload containing one, because the bytes you produce
56
+ // will only verify where they were produced. An agent entry meets it for free — the only
57
+ // number among the six signed fields is `timestamp`, and integer epoch seconds is the
58
+ // contract.
59
+ const hazards = vectors.numberHazards?.length ?? 0;
60
+
61
+ // ---------------------------------------------------------------- did:key
62
+ for (const v of vectors.did) {
63
+ if (v.curve !== 'ed25519') continue; // p256 did:key is not an envelope signer
64
+ let got;
65
+ try { got = didFromPublicKeyHex(v.publicHex); } catch (e) { got = `THREW: ${e.message}`; }
66
+ check(got === v.did, `did/${v.publicHex.slice(0, 12)}…`,
67
+ got === v.did ? '' : `want ${v.did}\n got ${got}`);
68
+ }
69
+
70
+ // ---------------------------------------------------------------- the six signed fields
71
+ for (const v of vectors.envelope) {
72
+ const fields = { from: v.from, to: v.to, messageId: v.messageId,
73
+ contextId: v.contextId ?? null, timestamp: v.timestamp, text: v.text };
74
+ let got;
75
+ try { got = signingPayload(fields); } catch (e) { got = `THREW: ${e.message}`; }
76
+ check(got === v.signingPayload, `envelope/${v.name}`,
77
+ got === v.signingPayload ? '' : `want ${JSON.stringify(v.signingPayload)}\n got ${JSON.stringify(got)}`);
78
+ }
79
+
80
+ // A signature this build makes must verify in this build. Round-tripping is the weakest
81
+ // possible claim on its own — it only says the code agrees with itself — which is exactly
82
+ // why the byte checks above and the refusals below are not optional.
83
+ {
84
+ const seed = '11'.repeat(32);
85
+ const from = didFromPublicKeyHex(publicKeyFromSeedHex(seed));
86
+ const fields = { from, to: from, messageId: 'm1', contextId: null,
87
+ timestamp: 1752451200, text: 'round trip' };
88
+ const sig = signEnvelope(seed, fields);
89
+ check(verifyEnvelope({ ...fields, sig }, { recipientDid: from }), 'envelope/round-trip');
90
+ }
91
+
92
+ // ---------------------------------------------------------------- the refusals
93
+ // The half that catches an implementation which verifies nothing.
94
+ for (const v of vectors.reject.message) {
95
+ const fields = { from: v.from, to: v.to, messageId: v.messageId,
96
+ contextId: v.contextId ?? null, timestamp: v.timestamp,
97
+ text: v.text, sig: v.sig };
98
+ let accepted;
99
+ try {
100
+ accepted = verifyEnvelope(fields, { recipientDid: v.recipientDid ?? v.to });
101
+ } catch {
102
+ accepted = false; // refusing by throwing is still refusing
103
+ }
104
+ check(accepted === false, `reject/${v.name}`,
105
+ accepted === false ? '' : `ACCEPTED a message it must refuse — ${v.why || ''}`);
106
+ }
107
+
108
+ // ---------------------------------------------------------------- verdict
109
+ console.log(`\n${vectors.note}\n`);
110
+ if (failures.length) {
111
+ console.log(`FAILED — ${failures.length} of ${pass + failures.length} checks:\n`);
112
+ for (const f of failures) console.log(` ✗ ${f}`);
113
+ console.log('\nA mismatch here is not cosmetic: these bytes are what every other');
114
+ console.log('implementation signs and verifies.\n');
115
+ process.exit(1);
116
+ }
117
+ console.log(`OK — ${pass} checks: the bytes match, and every case that must be refused was.`);
118
+ console.log(` (${hazards} numberHazards read, not executed — see the comment in this file:`);
119
+ console.log(` they are a SIGNER rule, not bytes any single runtime can be held to.)\n`);
@@ -0,0 +1,637 @@
1
+ {
2
+ "note": "Golden wire vectors for an Agent Entry implementation. Reproduce every `canonical`, `did`, `signingPayload` and `bindingPayload` field BYTE-FOR-BYTE, and REFUSE every case under `reject`. A drift does not throw: it silently makes your signatures unverifiable by everyone else, and an implementation that refuses nothing passes every positive vector in this file. Header and URL values inside a signed vector are OPAQUE TEST DATA covered by that vector's signature: reproduce them verbatim. Substituting your own host there does not make the case yours, it makes it fail.",
3
+ "protocolVersion": "0.2",
4
+ "canonicalSpec": "python json.dumps(sort_keys=True, separators=(',',':'), ensure_ascii=False).encode('utf-8')",
5
+ "timestampNote": "Timestamps in signed payloads are INTEGER epoch seconds — this is the CONTRACT, not a convenience of these vectors. It used to be the latter: core minted floats and the vectors side-stepped them, which meant a client could pass every vector here and still be unable to verify a real message from a Python node. `crypto.signing_payload` does not cast, so the type on the wire IS the type in the signed bytes, and a float there is bytes no other language can reproduce (Python's shortest-round-trip repr). Since 2026-07-17 core mints ints (shared/protocol.Message, cardpub publishers). Clients: send ints; keep VERIFYING whatever type arrives — an older node still sends floats and its signature is over those exact bytes. Never coerce inside a payload builder.",
6
+ "canonical": [
7
+ {
8
+ "name": "sorted-keys",
9
+ "payload": {
10
+ "b": 1,
11
+ "a": "x"
12
+ },
13
+ "why": "sort_keys=True: insertion order must not survive into the bytes",
14
+ "canonical": "{\"a\":\"x\",\"b\":1}"
15
+ },
16
+ {
17
+ "name": "no-whitespace",
18
+ "payload": {
19
+ "a": 1,
20
+ "b": 2
21
+ },
22
+ "why": "separators=(',',':'): a default json.dumps would add spaces here",
23
+ "canonical": "{\"a\":1,\"b\":2}"
24
+ },
25
+ {
26
+ "name": "non-ascii-literal",
27
+ "payload": {
28
+ "text": "群れたい"
29
+ },
30
+ "why": "ensure_ascii=False: non-ASCII stays literal UTF-8, NOT \\uXXXX. This is the one most clients get wrong, and message text is routinely non-ASCII",
31
+ "canonical": "{\"text\":\"群れたい\"}"
32
+ },
33
+ {
34
+ "name": "escape-shorthands",
35
+ "payload": {
36
+ "k": "\"\\\b\f\n\r\t"
37
+ },
38
+ "why": "Python's ESCAPE_DCT: exactly these seven shorthands",
39
+ "canonical": "{\"k\":\"\\\"\\\\\\b\\f\\n\\r\\t\"}"
40
+ },
41
+ {
42
+ "name": "control-chars",
43
+ "payload": {
44
+ "k": "\u0000\u0001\u001f"
45
+ },
46
+ "why": "other <0x20 become lowercase \\u00xx",
47
+ "canonical": "{\"k\":\"\\u0000\\u0001\\u001f\"}"
48
+ },
49
+ {
50
+ "name": "slash-and-del-unescaped",
51
+ "payload": {
52
+ "k": "a/b"
53
+ },
54
+ "why": "Python escapes NEITHER '/' nor DEL — many JSON writers escape both",
55
+ "canonical": "{\"k\":\"a/b\"}"
56
+ },
57
+ {
58
+ "name": "null-value",
59
+ "payload": {
60
+ "contextId": null
61
+ },
62
+ "why": "None -> null (contextId is genuinely nullable on the wire)",
63
+ "canonical": "{\"contextId\":null}"
64
+ },
65
+ {
66
+ "name": "empty-string",
67
+ "payload": {
68
+ "text": ""
69
+ },
70
+ "why": "empty string is not null",
71
+ "canonical": "{\"text\":\"\"}"
72
+ },
73
+ {
74
+ "name": "surrogate-pair",
75
+ "payload": {
76
+ "text": "🐦"
77
+ },
78
+ "why": "an astral char is one code point in Python but two UTF-16 units elsewhere",
79
+ "canonical": "{\"text\":\"🐦\"}"
80
+ },
81
+ {
82
+ "name": "combining-marks",
83
+ "payload": {
84
+ "text": "が"
85
+ },
86
+ "why": "NOT normalized: canonical JSON must never NFC/NFD the input",
87
+ "canonical": "{\"text\":\"が\"}"
88
+ },
89
+ {
90
+ "name": "key-ordering-unicode",
91
+ "payload": {
92
+ "z": 1,
93
+ "a": 2,
94
+ "群": 3,
95
+ "A": 4
96
+ },
97
+ "why": "keys sort by CODE POINT (Python str order), not by UTF-16 unit or locale",
98
+ "canonical": "{\"A\":4,\"a\":2,\"z\":1,\"群\":3}"
99
+ },
100
+ {
101
+ "name": "negative-and-zero",
102
+ "payload": {
103
+ "a": 0,
104
+ "b": -1
105
+ },
106
+ "why": "integers render bare, no + or leading zeros",
107
+ "canonical": "{\"a\":0,\"b\":-1}"
108
+ },
109
+ {
110
+ "name": "large-int-within-double",
111
+ "payload": {
112
+ "a": 9007199254740991
113
+ },
114
+ "why": "2**53-1, the largest integer a JavaScript Number holds exactly. Signed integers must stay inside +/-(2**53-1) — see numberHazards/int-beyond-2exp53",
115
+ "canonical": "{\"a\":9007199254740991}"
116
+ },
117
+ {
118
+ "name": "fractional-float-agrees",
119
+ "payload": {
120
+ "a": 0.1,
121
+ "b": 1785937682.845164
122
+ },
123
+ "why": "the CONTRAST case for numberHazards: ordinary FRACTIONAL floats do agree byte-for-byte across languages. That is exactly why float timestamps looked fine for a year — they are reproducible until one lands on a whole second",
124
+ "canonical": "{\"a\":0.1,\"b\":1785937682.845164}"
125
+ }
126
+ ],
127
+ "numberHazards": [
128
+ {
129
+ "name": "integral-float",
130
+ "payload": {
131
+ "a": 1.0
132
+ },
133
+ "pythonCanonical": "{\"a\":1.0}",
134
+ "javascriptWouldWrite": "{\"a\":1}",
135
+ "signMustNotEmit": true,
136
+ "verifyNeedsPythonRepr": true,
137
+ "why": "Python writes 1.0, JavaScript writes 1 — and JSON.parse('1.0') is irrecoverably 1, so a client cannot even reconstruct what was signed. `credentialSubject.trustLevel` was this value on every introduction minted by `operator_cli.introduce` and a Room's `/introduce`; fixed 2026-08-07 by minting integer basis points (`trustLevelBp`)."
138
+ },
139
+ {
140
+ "name": "integral-float-zero",
141
+ "payload": {
142
+ "a": 0.0
143
+ },
144
+ "pythonCanonical": "{\"a\":0.0}",
145
+ "javascriptWouldWrite": "{\"a\":0}",
146
+ "signMustNotEmit": true,
147
+ "verifyNeedsPythonRepr": true,
148
+ "why": "`keystate.notBefore` defaulted to 0.0 and was therefore unverifiable outside Python 100% of the time; fixed 2026-08-07."
149
+ },
150
+ {
151
+ "name": "integral-float-negative",
152
+ "payload": {
153
+ "a": -1.0
154
+ },
155
+ "pythonCanonical": "{\"a\":-1.0}",
156
+ "javascriptWouldWrite": "{\"a\":-1}",
157
+ "signMustNotEmit": true,
158
+ "verifyNeedsPythonRepr": true,
159
+ "why": "the sign does not save it"
160
+ },
161
+ {
162
+ "name": "negative-zero",
163
+ "payload": {
164
+ "a": -0.0
165
+ },
166
+ "pythonCanonical": "{\"a\":-0.0}",
167
+ "javascriptWouldWrite": "{\"a\":0}",
168
+ "signMustNotEmit": true,
169
+ "verifyNeedsPythonRepr": true,
170
+ "why": "Python keeps the sign, JavaScript does not. Matrix bans -0 from its canonical JSON outright, for this reason."
171
+ },
172
+ {
173
+ "name": "exponent-small",
174
+ "payload": {
175
+ "a": 1e-07
176
+ },
177
+ "pythonCanonical": "{\"a\":1e-07}",
178
+ "javascriptWouldWrite": "{\"a\":1e-7}",
179
+ "signMustNotEmit": true,
180
+ "verifyNeedsPythonRepr": true,
181
+ "why": "Python zero-pads and always signs the exponent (1e-07); JavaScript does neither"
182
+ },
183
+ {
184
+ "name": "exponent-threshold",
185
+ "payload": {
186
+ "a": 1e+16
187
+ },
188
+ "pythonCanonical": "{\"a\":1e+16}",
189
+ "javascriptWouldWrite": "{\"a\":10000000000000000}",
190
+ "signMustNotEmit": true,
191
+ "verifyNeedsPythonRepr": true,
192
+ "why": "Python switches to exponent notation here (1e+16); JavaScript does not switch until 1e21. The thresholds differ, so agreement is a coin flip."
193
+ },
194
+ {
195
+ "name": "int-beyond-2exp53",
196
+ "payload": {
197
+ "a": 9007199254740993
198
+ },
199
+ "pythonCanonical": "{\"a\":9007199254740993}",
200
+ "javascriptWouldWrite": "{\"a\":9007199254740992}",
201
+ "signMustNotEmit": true,
202
+ "verifyNeedsPythonRepr": true,
203
+ "why": "NOT a formatting mismatch — SILENT DATA CORRUPTION. Python has arbitrary-precision integers; a JavaScript Number rounds. Keep signed integers inside +/-(2**53-1), the bound Matrix and RFC 8785 both set."
204
+ },
205
+ {
206
+ "name": "nested-float",
207
+ "payload": {
208
+ "credentialSubject": {
209
+ "trustLevel": 1.0
210
+ }
211
+ },
212
+ "pythonCanonical": "{\"credentialSubject\":{\"trustLevel\":1.0}}",
213
+ "javascriptWouldWrite": "{\"credentialSubject\":{\"trustLevel\":1}}",
214
+ "signMustNotEmit": true,
215
+ "verifyNeedsPythonRepr": true,
216
+ "why": "the real shape of the bug: two levels down inside an introduction credential, where nothing at the top level looked wrong"
217
+ }
218
+ ],
219
+ "numberHazardNote": "These are NOT in `canonical`, and that distinction is the point. Every case here is a value whose canonical bytes DIFFER between Python and JavaScript, so requiring a client to reproduce them would be requiring it to re-implement CPython's float repr — the opposite of the contract. `signMustNotEmit` means exactly what it says: a signer that emits one of these produces bytes only Python can verify. `verifyNeedsPythonRepr` means a client CAN still meet a lesser duty — recognise the shape and report honestly that it cannot verify that artifact, rather than reporting a bad signature. This section exists because `trustLevel: 1.0` shipped for a year while all twelve original `canonical` cases stayed green: none of them contained a float, so nothing caught it, and the failure surfaced as a signature error pointing at the client's Ed25519 code. Same rule as timestampNote, stated for every number: signed payloads carry INTEGERS inside +/-(2**53-1). Never coerce inside a payload builder; never coerce on the verify path either.",
220
+ "did": [
221
+ {
222
+ "curve": "ed25519",
223
+ "multicodec": "ed01",
224
+ "publicHex": "0000000000000000000000000000000000000000000000000000000000000000",
225
+ "did": "did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP"
226
+ },
227
+ {
228
+ "curve": "ed25519",
229
+ "multicodec": "ed01",
230
+ "publicHex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
231
+ "did": "did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e"
232
+ },
233
+ {
234
+ "curve": "ed25519",
235
+ "multicodec": "ed01",
236
+ "publicHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
237
+ "did": "did:key:z6MkeTGwHmLmuCmgg4ABYhzWVh6ZX7hTwWt8gguAretUfc9c"
238
+ },
239
+ {
240
+ "curve": "ed25519",
241
+ "multicodec": "ed01",
242
+ "publicHex": "094119b513c9c595718eaba5a497a8e0ea1dd19bd5e043dad8b5f1ee9a893b64",
243
+ "did": "did:key:z6Mkf5PHmUf14MDpPoHgRVVNDEFSZ62ecAHhjSJGicWcjrno"
244
+ },
245
+ {
246
+ "curve": "p256",
247
+ "multicodec": "8024",
248
+ "publicHex": "020000000000000000000000000000000000000000000000000000000000000000",
249
+ "did": "did:key:zDnaeQRy3dcKsKa1zmKtVKsTy3m2HYoQnFnfKuxD6HfSTQgYf"
250
+ },
251
+ {
252
+ "curve": "p256",
253
+ "multicodec": "8024",
254
+ "publicHex": "02ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
255
+ "did": "did:key:zDnaehfHR8Q5U7ckmLQfuZ3eGEypooJ46zzjRQ1AR9asDvdnv"
256
+ },
257
+ {
258
+ "curve": "p256",
259
+ "multicodec": "8024",
260
+ "publicHex": "02000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
261
+ "did": "did:key:zDnaeQRywL8RCtEJDKtCyC1VdhMZrFLqPnRJ6udCLK3MvA4ut"
262
+ },
263
+ {
264
+ "curve": "p256",
265
+ "multicodec": "8024",
266
+ "publicHex": "030000000000000000000000000000000000000000000000000000000000000000",
267
+ "did": "did:key:zDnaehfHR8Q5U7ckmLQfuZ3eGEypooJ46zzjRQ1AR9asDvdnw"
268
+ },
269
+ {
270
+ "curve": "p256",
271
+ "multicodec": "8024",
272
+ "publicHex": "03ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
273
+ "did": "did:key:zDnaeztbndBq4ufVXuVTKnDpZSCdL3nhRkCoWt47k1WHzSb3C"
274
+ },
275
+ {
276
+ "curve": "p256",
277
+ "multicodec": "8024",
278
+ "publicHex": "03000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
279
+ "did": "did:key:zDnaehfJJpvAogH2ytxzPRBfvtaNNVqUiXdNCPg9fAxngg2AA"
280
+ }
281
+ ],
282
+ "envelope": [
283
+ {
284
+ "name": "plain",
285
+ "from": "did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP",
286
+ "to": "did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e",
287
+ "messageId": "m1",
288
+ "contextId": "c1",
289
+ "timestamp": 1752451200,
290
+ "text": "hi",
291
+ "signingPayload": "{\"contextId\":\"c1\",\"from\":\"did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP\",\"messageId\":\"m1\",\"text\":\"hi\",\"timestamp\":1752451200,\"to\":\"did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e\"}"
292
+ },
293
+ {
294
+ "name": "null-context",
295
+ "from": "did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP",
296
+ "to": "did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e",
297
+ "messageId": "m1",
298
+ "contextId": null,
299
+ "timestamp": 1752451200,
300
+ "text": "hi",
301
+ "signingPayload": "{\"contextId\":null,\"from\":\"did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP\",\"messageId\":\"m1\",\"text\":\"hi\",\"timestamp\":1752451200,\"to\":\"did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e\"}"
302
+ },
303
+ {
304
+ "name": "non-ascii-text",
305
+ "from": "did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP",
306
+ "to": "did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e",
307
+ "messageId": "m-群",
308
+ "contextId": "c1",
309
+ "timestamp": 1752451200,
310
+ "text": "群れたい — hello",
311
+ "signingPayload": "{\"contextId\":\"c1\",\"from\":\"did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP\",\"messageId\":\"m-群\",\"text\":\"群れたい — hello\",\"timestamp\":1752451200,\"to\":\"did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e\"}"
312
+ },
313
+ {
314
+ "name": "empty-text",
315
+ "from": "did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP",
316
+ "to": "did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e",
317
+ "messageId": "m1",
318
+ "contextId": "c1",
319
+ "timestamp": 0,
320
+ "text": "",
321
+ "signingPayload": "{\"contextId\":\"c1\",\"from\":\"did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP\",\"messageId\":\"m1\",\"text\":\"\",\"timestamp\":0,\"to\":\"did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e\"}"
322
+ }
323
+ ],
324
+ "bindingV2": {
325
+ "checkNow": 1784273741,
326
+ "cases": [
327
+ {
328
+ "name": "no-expiry",
329
+ "rootDid": "did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q",
330
+ "deviceDid": "did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU",
331
+ "ownerSeed": "1717171717171717171717171717171717171717171717171717171717171717",
332
+ "deviceSeed": "1818181818181818181818181818181818181818181818181818181818181818",
333
+ "ts": 1784273681,
334
+ "validUntil": 0,
335
+ "bindingPayload": "{\"deviceDid\":\"did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU\",\"rootDid\":\"did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q\",\"ts\":1784273681,\"typ\":\"muretai/devicebinding/2\",\"validUntil\":0}",
336
+ "binding": {
337
+ "typ": "muretai/devicebinding/2",
338
+ "rootDid": "did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q",
339
+ "deviceDid": "did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU",
340
+ "ts": 1784273681,
341
+ "validUntil": 0,
342
+ "sig": "9uaChaM0gQoIgiZqV/k8FDBZXHiaJPvtZbijhoQWogbCVb0ylI+hMNESnQ4bSsreEbE+d50kaBv+12E8UxBCDw==",
343
+ "deviceSig": "OowWKRk3TNZr58K+/Yd+y/iA5siZPN4IF/0lQYFqMcWy8/EHaodbt8tWx+K883uTI55xh1rKpwd5pFuJ8CSvBQ=="
344
+ }
345
+ },
346
+ {
347
+ "name": "bounded",
348
+ "rootDid": "did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q",
349
+ "deviceDid": "did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU",
350
+ "ownerSeed": "1717171717171717171717171717171717171717171717171717171717171717",
351
+ "deviceSeed": "1818181818181818181818181818181818181818181818181818181818181818",
352
+ "ts": 1784273681,
353
+ "validUntil": 1815809681,
354
+ "bindingPayload": "{\"deviceDid\":\"did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU\",\"rootDid\":\"did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q\",\"ts\":1784273681,\"typ\":\"muretai/devicebinding/2\",\"validUntil\":1815809681}",
355
+ "binding": {
356
+ "typ": "muretai/devicebinding/2",
357
+ "rootDid": "did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q",
358
+ "deviceDid": "did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU",
359
+ "ts": 1784273681,
360
+ "validUntil": 1815809681,
361
+ "sig": "5alfC8XRi6brgqjfzzbq9Jam8aXyA08PgQ3vopL5r+N0BPr+AXl6xR+vGtzc5ZlszVVBle4P/rzplq+2e+SVAA==",
362
+ "deviceSig": "BZDaDqB0kQPNsry0Qfn9JSlLdX5Z+EkWAsPS9027/j/YHdpsKZeT4EDVffPfYwaCSP75NaIrxv5yiC1Nb1utCg=="
363
+ }
364
+ }
365
+ ],
366
+ "reject": [
367
+ {
368
+ "name": "float-ts",
369
+ "category": "float-ts",
370
+ "mustReject": true,
371
+ "input": {
372
+ "typ": "muretai/devicebinding/2",
373
+ "rootDid": "did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q",
374
+ "deviceDid": "did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU",
375
+ "ts": 1784273681.0,
376
+ "validUntil": 0,
377
+ "sig": "Z5kh7m9BZs3QGw7W51fx7S7/Nit0uCo6qsdKdjnt2agsl4PEl0lwfbmoCoO/+mMc+x0XDWSMu/16F9/ftdd9DA==",
378
+ "deviceSig": "LbqqmEBJzge+cJgXTdIWYmAE0PUGurrNeE/Hy6F7NkaqNiPDnbySWEl42eU+lrm6861t2COBUxhgrHErI39mBA=="
379
+ },
380
+ "note": "ts is 1784273681.0 and both signatures are GENUINE over those float bytes. Reject on the type, before any crypto: a float repr is bytes only Python reproduces, so accepting it forks the contract (numberHazards/integral-float is this same value)."
381
+ },
382
+ {
383
+ "name": "missing-deviceSig",
384
+ "category": "missing-deviceSig",
385
+ "mustReject": true,
386
+ "input": {
387
+ "typ": "muretai/devicebinding/2",
388
+ "rootDid": "did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q",
389
+ "deviceDid": "did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU",
390
+ "ts": 1784273681,
391
+ "validUntil": 0,
392
+ "sig": "9uaChaM0gQoIgiZqV/k8FDBZXHiaJPvtZbijhoQWogbCVb0ylI+hMNESnQ4bSsreEbE+d50kaBv+12E8UxBCDw=="
393
+ },
394
+ "note": "the owner signature is valid and the device countersignature is absent — without it a foreign owner can claim someone else's device, which is the v1 gap v2 exists to close. Never fall back to owner-only."
395
+ },
396
+ {
397
+ "name": "wrong-typ",
398
+ "category": "wrong-typ",
399
+ "mustReject": true,
400
+ "input": {
401
+ "typ": "muretai/devicebinding/1",
402
+ "rootDid": "did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q",
403
+ "deviceDid": "did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU",
404
+ "ts": 1784273681,
405
+ "validUntil": 0,
406
+ "sig": "rQVkNoTQbOrxOCeAeIV2aQBT2EIZWvDB3rWeW7BbPVRCZjmiqYa77GlLAMcG+Od2BIwhT48xsiHbDXjHAWaEAQ==",
407
+ "deviceSig": "zJGq14Oifmq08Z6LcWVx4Re0H1v+FT6EuaE2vQ8Ntf4wC8mW9IEu+LLCnzcy8b1dbtw30r9MYYLxMAQOIQl4DA=="
408
+ },
409
+ "note": "typ says muretai/devicebinding/1 and both signatures are genuine over those bytes. `typ` is INSIDE the signed payload precisely so another artifact type can never be replayed as an account binding — match it exactly, before the signatures."
410
+ }
411
+ ]
412
+ },
413
+ "domainLinkage": [
414
+ {
415
+ "name": "plain",
416
+ "did": "did:key:z6MktojHN9D8obak7C9wjpTzCRrdE5zC6cxt5ANUFnQskgbs",
417
+ "domain": "example.com",
418
+ "nbf": 1754870400,
419
+ "exp": 1762646400,
420
+ "signingInput": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzI3o2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjI2NDY0MDAsImlzcyI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwibmJmIjoxNzU0ODcwNDAwLCJzdWIiOiJkaWQ6a2V5Ono2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly9pZGVudGl0eS5mb3VuZGF0aW9uLy53ZWxsLWtub3duL2RpZC1jb25maWd1cmF0aW9uL3YxIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rdG9qSE45RDhvYmFrN0M5d2pwVHpDUnJkRTV6QzZjeHQ1QU5VRm5Rc2tnYnMiLCJvcmlnaW4iOiJodHRwczovL2V4YW1wbGUuY29tIn0sImV4cGlyYXRpb25EYXRlIjoiMjAyNS0xMS0wOVQwMDowMDowMFoiLCJpc3N1YW5jZURhdGUiOiIyMDI1LTA4LTExVDAwOjAwOjAwWiIsImlzc3VlciI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIkRvbWFpbkxpbmthZ2VDcmVkZW50aWFsIl19fQ",
421
+ "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzI3o2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjI2NDY0MDAsImlzcyI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwibmJmIjoxNzU0ODcwNDAwLCJzdWIiOiJkaWQ6a2V5Ono2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly9pZGVudGl0eS5mb3VuZGF0aW9uLy53ZWxsLWtub3duL2RpZC1jb25maWd1cmF0aW9uL3YxIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rdG9qSE45RDhvYmFrN0M5d2pwVHpDUnJkRTV6QzZjeHQ1QU5VRm5Rc2tnYnMiLCJvcmlnaW4iOiJodHRwczovL2V4YW1wbGUuY29tIn0sImV4cGlyYXRpb25EYXRlIjoiMjAyNS0xMS0wOVQwMDowMDowMFoiLCJpc3N1YW5jZURhdGUiOiIyMDI1LTA4LTExVDAwOjAwOjAwWiIsImlzc3VlciI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIkRvbWFpbkxpbmthZ2VDcmVkZW50aWFsIl19fQ.PC86WXKk1O_0asYAWi81QrUDp2aRdUbqjAmINiSHCZmZIVNOffrkqSxPnYVFvVZf87_g_EKhC2b2F8dr5z0JDA"
422
+ },
423
+ {
424
+ "name": "subdomain",
425
+ "did": "did:key:z6MktojHN9D8obak7C9wjpTzCRrdE5zC6cxt5ANUFnQskgbs",
426
+ "domain": "agents.example.com",
427
+ "nbf": 1754870400,
428
+ "exp": 1762646400,
429
+ "signingInput": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzI3o2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjI2NDY0MDAsImlzcyI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwibmJmIjoxNzU0ODcwNDAwLCJzdWIiOiJkaWQ6a2V5Ono2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly9pZGVudGl0eS5mb3VuZGF0aW9uLy53ZWxsLWtub3duL2RpZC1jb25maWd1cmF0aW9uL3YxIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rdG9qSE45RDhvYmFrN0M5d2pwVHpDUnJkRTV6QzZjeHQ1QU5VRm5Rc2tnYnMiLCJvcmlnaW4iOiJodHRwczovL2FnZW50cy5leGFtcGxlLmNvbSJ9LCJleHBpcmF0aW9uRGF0ZSI6IjIwMjUtMTEtMDlUMDA6MDA6MDBaIiwiaXNzdWFuY2VEYXRlIjoiMjAyNS0wOC0xMVQwMDowMDowMFoiLCJpc3N1ZXIiOiJkaWQ6a2V5Ono2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJEb21haW5MaW5rYWdlQ3JlZGVudGlhbCJdfX0",
430
+ "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzI3o2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjI2NDY0MDAsImlzcyI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwibmJmIjoxNzU0ODcwNDAwLCJzdWIiOiJkaWQ6a2V5Ono2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly9pZGVudGl0eS5mb3VuZGF0aW9uLy53ZWxsLWtub3duL2RpZC1jb25maWd1cmF0aW9uL3YxIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rdG9qSE45RDhvYmFrN0M5d2pwVHpDUnJkRTV6QzZjeHQ1QU5VRm5Rc2tnYnMiLCJvcmlnaW4iOiJodHRwczovL2FnZW50cy5leGFtcGxlLmNvbSJ9LCJleHBpcmF0aW9uRGF0ZSI6IjIwMjUtMTEtMDlUMDA6MDA6MDBaIiwiaXNzdWFuY2VEYXRlIjoiMjAyNS0wOC0xMVQwMDowMDowMFoiLCJpc3N1ZXIiOiJkaWQ6a2V5Ono2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJEb21haW5MaW5rYWdlQ3JlZGVudGlhbCJdfX0.USRlay6FLlmoBpEwywyZtocxsgwzr8PR5uGkJbpnSL4UkqqVVx66QZoSjn3vdf3aJiSQvBXdIPV-WT_uwFk3BQ"
431
+ },
432
+ {
433
+ "name": "loopback-port",
434
+ "did": "did:key:z6MktojHN9D8obak7C9wjpTzCRrdE5zC6cxt5ANUFnQskgbs",
435
+ "domain": "127.0.0.1:8443",
436
+ "nbf": 1754870400,
437
+ "exp": 1762646400,
438
+ "signingInput": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzI3o2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjI2NDY0MDAsImlzcyI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwibmJmIjoxNzU0ODcwNDAwLCJzdWIiOiJkaWQ6a2V5Ono2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly9pZGVudGl0eS5mb3VuZGF0aW9uLy53ZWxsLWtub3duL2RpZC1jb25maWd1cmF0aW9uL3YxIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rdG9qSE45RDhvYmFrN0M5d2pwVHpDUnJkRTV6QzZjeHQ1QU5VRm5Rc2tnYnMiLCJvcmlnaW4iOiJodHRwczovLzEyNy4wLjAuMTo4NDQzIn0sImV4cGlyYXRpb25EYXRlIjoiMjAyNS0xMS0wOVQwMDowMDowMFoiLCJpc3N1YW5jZURhdGUiOiIyMDI1LTA4LTExVDAwOjAwOjAwWiIsImlzc3VlciI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIkRvbWFpbkxpbmthZ2VDcmVkZW50aWFsIl19fQ",
439
+ "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzI3o2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjI2NDY0MDAsImlzcyI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwibmJmIjoxNzU0ODcwNDAwLCJzdWIiOiJkaWQ6a2V5Ono2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly9pZGVudGl0eS5mb3VuZGF0aW9uLy53ZWxsLWtub3duL2RpZC1jb25maWd1cmF0aW9uL3YxIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rdG9qSE45RDhvYmFrN0M5d2pwVHpDUnJkRTV6QzZjeHQ1QU5VRm5Rc2tnYnMiLCJvcmlnaW4iOiJodHRwczovLzEyNy4wLjAuMTo4NDQzIn0sImV4cGlyYXRpb25EYXRlIjoiMjAyNS0xMS0wOVQwMDowMDowMFoiLCJpc3N1YW5jZURhdGUiOiIyMDI1LTA4LTExVDAwOjAwOjAwWiIsImlzc3VlciI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIkRvbWFpbkxpbmthZ2VDcmVkZW50aWFsIl19fQ.k_XOyNvWVUGtS88CBYl7IYUptxnM3A3hGlhRymmQxDSaItKxvhOnj2NT1F5dZauqoUQdVMhexVpzxTEIUVwXCQ"
440
+ }
441
+ ],
442
+ "domainLinkageNote": "The DIF Domain Linkage Credential (compact JWS, shared/domainbind.py). The signature covers the TEXT of the first two segments — b64url(header).b64url(payload) — never the object they decode to, so `signingInput` is the byte string to reproduce and re-serializing a decoded payload is NOT how you verify. Header is {alg:\"EdDSA\",typ:\"JWT\",kid:\"<did>#<multibase>\"}; base64url is UNPADDED and a padded or standard-alphabet spelling is refused rather than repaired. `nbf`/`exp` are INTEGER epoch seconds and are the values a verifier compares; the ISO strings inside `vc` are their human-facing echo. `exp` is MANDATORY — a domain is leased, not owned, so a credential with no end date is indefinite authority over a name the issuer may no longer hold. iss == sub == vc.credentialSubject.id, all three the identical string, or refuse. Origins are compared canonicalized on BOTH sides (shared/neturl.origin), and a non-default port is part of the origin. DIRECTION: this proves only that the holder of `did` CLAIMS `domain`; it becomes a proof once the document is fetched from that domain's /.well-known/did-configuration.json and the DID's card names the domain back.",
443
+ "webBotAuth": {
444
+ "note": "The `keys` entries are the SAME public keys as the `did` section. `keyid` is the RFC 7638 thumbprint of the JWK, and `thumbprintInput` is the exact text hashed to produce it. `alg` is lowercase \"ed25519\" (RFC 9421), NOT JOSE's \"EdDSA\" — domainLinkage uses the other spelling for the same curve. A signature base is LF-joined with NO trailing newline. The request path covers @authority (so it cannot be replayed at another origin) and signature-agent (so the site can look us up); the covered signature-agent value and the Signature-Agent header are the same sf-string, quotes included. `tag` separates the two directions and is inside the signed params. READ THE ROLES BEFORE COMPARING HEX: `seedHex` is a PRIVATE seed and `did` is the key DERIVED from it, while the identical-looking hex at keys[2].publicHex is that same byte string used as a PUBLIC key — a different DID, by construction.",
445
+ "seedHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
446
+ "did": "did:key:z6MkehRgf7yJbgaGfYsdoAsKdBPE3dj2CYhowQdcjqSJgvVd",
447
+ "keys": [
448
+ {
449
+ "publicHex": "0000000000000000000000000000000000000000000000000000000000000000",
450
+ "did": "did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP",
451
+ "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
452
+ "thumbprintInput": "{\"crv\":\"Ed25519\",\"kty\":\"OKP\",\"x\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}",
453
+ "thumbprint": "ogRZbCR5KTrPFCAfuYmCMwj0w7Yuk3Lr6YWQWfpkbf0"
454
+ },
455
+ {
456
+ "publicHex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
457
+ "did": "did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e",
458
+ "x": "__________________________________________8",
459
+ "thumbprintInput": "{\"crv\":\"Ed25519\",\"kty\":\"OKP\",\"x\":\"__________________________________________8\"}",
460
+ "thumbprint": "5rFvj451YsRwcWuFV5946TD4InhZSbOza0q8jWz8X90"
461
+ },
462
+ {
463
+ "publicHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
464
+ "did": "did:key:z6MkeTGwHmLmuCmgg4ABYhzWVh6ZX7hTwWt8gguAretUfc9c",
465
+ "x": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8",
466
+ "thumbprintInput": "{\"crv\":\"Ed25519\",\"kty\":\"OKP\",\"x\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\"}",
467
+ "thumbprint": "P7IdLIpiTZiFaIoOSqbX3JrSyps3hvZ4Y2SieP96XIY"
468
+ },
469
+ {
470
+ "publicHex": "094119b513c9c595718eaba5a497a8e0ea1dd19bd5e043dad8b5f1ee9a893b64",
471
+ "did": "did:key:z6Mkf5PHmUf14MDpPoHgRVVNDEFSZ62ecAHhjSJGicWcjrno",
472
+ "x": "CUEZtRPJxZVxjqulpJeo4Ood0ZvV4EPa2LXx7pqJO2Q",
473
+ "thumbprintInput": "{\"crv\":\"Ed25519\",\"kty\":\"OKP\",\"x\":\"CUEZtRPJxZVxjqulpJeo4Ood0ZvV4EPa2LXx7pqJO2Q\"}",
474
+ "thumbprint": "XS885FlMDO9EHzHe7ghOBrxMG-EzyAtWUPON3O8yQgQ"
475
+ }
476
+ ],
477
+ "request": {
478
+ "authority": "example.com",
479
+ "created": 1754870400,
480
+ "expires": 1754870700,
481
+ "keyid": "1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y",
482
+ "alg": "ed25519",
483
+ "tag": "web-bot-auth",
484
+ "components": [
485
+ "@authority",
486
+ "signature-agent"
487
+ ],
488
+ "signatureAgentUrl": "https://muretai.net/z6MkehRgf7yJbgaGfYsdoAsKdBPE3dj2CYhowQdcjqSJgvVd",
489
+ "signatureAgent": "\"https://muretai.net/z6MkehRgf7yJbgaGfYsdoAsKdBPE3dj2CYhowQdcjqSJgvVd\"",
490
+ "signatureParams": "(\"@authority\" \"signature-agent\");created=1754870400;expires=1754870700;keyid=\"1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y\";alg=\"ed25519\";tag=\"web-bot-auth\"",
491
+ "signatureBase": "\"@authority\": example.com\n\"signature-agent\": \"https://muretai.net/z6MkehRgf7yJbgaGfYsdoAsKdBPE3dj2CYhowQdcjqSJgvVd\"\n\"@signature-params\": (\"@authority\" \"signature-agent\");created=1754870400;expires=1754870700;keyid=\"1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y\";alg=\"ed25519\";tag=\"web-bot-auth\"",
492
+ "signatureInput": "sig1=(\"@authority\" \"signature-agent\");created=1754870400;expires=1754870700;keyid=\"1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y\";alg=\"ed25519\";tag=\"web-bot-auth\"",
493
+ "signature": "sig1=:4AUlLYDVmcMdzrlmW7PMafuKmfM1E1kkt94bnr+AeQuVl7siZl0+PK+cPeZ33lqQFEUk14ldvTxqNlzTC07UDA==:"
494
+ },
495
+ "directory": {
496
+ "authority": "example.com",
497
+ "created": 1754870400,
498
+ "expires": 1754877600,
499
+ "keyid": "1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y",
500
+ "alg": "ed25519",
501
+ "tag": "http-message-signatures-directory",
502
+ "components": [
503
+ "@authority"
504
+ ],
505
+ "path": "/.well-known/http-message-signatures-directory",
506
+ "contentType": "application/http-message-signatures-directory+json",
507
+ "body": "{\"keys\":[{\"crv\":\"Ed25519\",\"kty\":\"OKP\",\"x\":\"A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg\"}]}",
508
+ "signatureParams": "(\"@authority\");created=1754870400;expires=1754877600;keyid=\"1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y\";alg=\"ed25519\";tag=\"http-message-signatures-directory\"",
509
+ "signatureBase": "\"@authority\": example.com\n\"@signature-params\": (\"@authority\");created=1754870400;expires=1754877600;keyid=\"1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y\";alg=\"ed25519\";tag=\"http-message-signatures-directory\"",
510
+ "signatureInput": "sig1=(\"@authority\");created=1754870400;expires=1754877600;keyid=\"1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y\";alg=\"ed25519\";tag=\"http-message-signatures-directory\"",
511
+ "signature": "sig1=:xn3Gf4Niw6pEOIKZD9NC85a+AGbY6S7oBeBn77Li4vVm7TeFJHpeWqvws8n4JCIqIN1MXPjZAbOQmloCJd/3CQ==:"
512
+ }
513
+ },
514
+ "cardpub": [
515
+ {
516
+ "name": "relay-only",
517
+ "card": {
518
+ "did": "did:key:z6MkpFkurpyZgyna5SAfLpvdzp7W6cvdc1fn9YECrwv3AMbF",
519
+ "name": "Node",
520
+ "url": "",
521
+ "relay": "https://relay.example",
522
+ "enc_pub": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
523
+ },
524
+ "ts": 1784273681,
525
+ "envelopePayload": "{\"card\":{\"did\":\"did:key:z6MkpFkurpyZgyna5SAfLpvdzp7W6cvdc1fn9YECrwv3AMbF\",\"enc_pub\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"name\":\"Node\",\"relay\":\"https://relay.example\",\"url\":\"\"},\"ts\":1784273681,\"typ\":\"agentcard\",\"v\":1}",
526
+ "sig": "Ryrhh/k3UvsVHF4kR1gLOhinZMtKNWcQuvMHSWZ/UVLU34XJ0NmSXh3QyUiqpXuQdoU09F8DLgkbXEEPcJn0AA=="
527
+ },
528
+ {
529
+ "name": "with-url",
530
+ "card": {
531
+ "did": "did:key:z6MkpFkurpyZgyna5SAfLpvdzp7W6cvdc1fn9YECrwv3AMbF",
532
+ "name": "Node",
533
+ "url": "http://127.0.0.1:8001/"
534
+ },
535
+ "ts": 0,
536
+ "envelopePayload": "{\"card\":{\"did\":\"did:key:z6MkpFkurpyZgyna5SAfLpvdzp7W6cvdc1fn9YECrwv3AMbF\",\"name\":\"Node\",\"url\":\"http://127.0.0.1:8001/\"},\"ts\":0,\"typ\":\"agentcard\",\"v\":1}",
537
+ "sig": "7NNkIvHLZ8CgjlHXsTaGRYfmmKhKLpdaRGw0dX3ztbrY05wyaWBN+5RlM4vLPxeggSMb+VxYIQ0pgfcONK7zDQ=="
538
+ }
539
+ ],
540
+ "epochNote": "NOT bytes, so not pinnable here — and the thing a client is most likely to get wrong. An `epoch` identifies a LISTENER, not a poll: sortable (creation-time+pid), STABLE for that listener's life, greater-wins, older gets HTTP 409. Minting a fresh epoch per poll makes a client supersede its own long-poll and drop mail. Specified in docs/SPECIFICATION.md §relay.",
541
+ "reject": {
542
+ "message": [
543
+ {
544
+ "name": "from-not-signer",
545
+ "category": "from-not-signer",
546
+ "mustReject": true,
547
+ "input": {
548
+ "from": "did:key:z6MkpgbpjhTBQSzhqCnLNNSnz42fSfBak34gYxoE2vqjKPSJ",
549
+ "to": "did:key:z6MkknhhwAGsRrcTTthPSsBbQtrNX6CMqt4poro5jdP6uCue",
550
+ "messageId": "m1",
551
+ "contextId": "c1",
552
+ "timestamp": 1784273681,
553
+ "text": "pay the invoice",
554
+ "sig": "E/lo60PN4uJdh7nt2BYj3BZU1rFmsTv+/1WK8iuEJ/zmjn2W6X+ycD+Knvs8mGvJjaJ1b/fHNk+hfUHKtX/1CA=="
555
+ },
556
+ "note": "the signature is valid, but by a DIFFERENT key than `from` names. Derive the verifying key FROM `from` (with did:key the DID IS the key) and check against it — do not trust `from` as a label, and do not verify against a key from any other field."
557
+ },
558
+ {
559
+ "name": "bad-signature",
560
+ "category": "bad-signature",
561
+ "mustReject": true,
562
+ "input": {
563
+ "from": "did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7",
564
+ "to": "did:key:z6MkknhhwAGsRrcTTthPSsBbQtrNX6CMqt4poro5jdP6uCue",
565
+ "messageId": "m1",
566
+ "contextId": "c1",
567
+ "timestamp": 1784273681,
568
+ "text": "pay the invoice",
569
+ "sig": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
570
+ },
571
+ "note": "garbage signature bytes over an otherwise valid envelope — must not be displayed as signed."
572
+ },
573
+ {
574
+ "name": "tampered-text",
575
+ "category": "tampered-text",
576
+ "mustReject": true,
577
+ "input": {
578
+ "from": "did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7",
579
+ "to": "did:key:z6MkknhhwAGsRrcTTthPSsBbQtrNX6CMqt4poro5jdP6uCue",
580
+ "messageId": "m1",
581
+ "contextId": "c1",
582
+ "timestamp": 1784273681,
583
+ "text": "pay the ATTACKER",
584
+ "sig": "E/lo60PN4uJdh7nt2BYj3BZU1rFmsTv+/1WK8iuEJ/zmjn2W6X+ycD+Knvs8mGvJjaJ1b/fHNk+hfUHKtX/1CA=="
585
+ },
586
+ "note": "signed for one text, delivered with another. Recompute the canonical signing payload from the received fields — never trust a `sig` field without recomputing what it covers."
587
+ },
588
+ {
589
+ "name": "tampered-timestamp",
590
+ "category": "tampered-timestamp",
591
+ "mustReject": true,
592
+ "input": {
593
+ "from": "did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7",
594
+ "to": "did:key:z6MkknhhwAGsRrcTTthPSsBbQtrNX6CMqt4poro5jdP6uCue",
595
+ "messageId": "m1",
596
+ "contextId": "c1",
597
+ "timestamp": 1784273999,
598
+ "text": "pay the invoice",
599
+ "sig": "E/lo60PN4uJdh7nt2BYj3BZU1rFmsTv+/1WK8iuEJ/zmjn2W6X+ycD+Knvs8mGvJjaJ1b/fHNk+hfUHKtX/1CA=="
600
+ },
601
+ "note": "signed ts changed in transit (also proves timestamp is inside the signed payload)."
602
+ },
603
+ {
604
+ "name": "missing-sig",
605
+ "category": "missing-sig",
606
+ "mustReject": true,
607
+ "input": {
608
+ "from": "did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7",
609
+ "to": "did:key:z6MkknhhwAGsRrcTTthPSsBbQtrNX6CMqt4poro5jdP6uCue",
610
+ "messageId": "m1",
611
+ "contextId": "c1",
612
+ "timestamp": 1784273681,
613
+ "text": "pay the invoice",
614
+ "sig": null
615
+ },
616
+ "note": "no signature at all — an unsigned message must never be shown as from a DID. `agent/inbox.verify` raises before any brain/log/display."
617
+ },
618
+ {
619
+ "name": "wrong-recipient",
620
+ "category": "wrong-recipient",
621
+ "mustReject": true,
622
+ "input": {
623
+ "from": "did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7",
624
+ "to": "did:key:z6Mkm6LsYZNUxC249DMDsHx5rZgMSuP3VNrqsCSk3nYZiGjP",
625
+ "messageId": "m1",
626
+ "contextId": "c1",
627
+ "timestamp": 1784273681,
628
+ "text": "pay the invoice",
629
+ "sig": "mbWMD6FBZevg6rxqAFKIbyxVNVXlL3hXvq+D+dhAFLAvCCLLhc15GQbWSPykZ7YtQzqJzAvigUCg5Ux7reCAAA=="
630
+ },
631
+ "recipientDid": "did:key:z6MkknhhwAGsRrcTTthPSsBbQtrNX6CMqt4poro5jdP6uCue",
632
+ "note": "validly signed, but `to` is not us. The signature verifies for the real recipient; a client must still refuse a message not addressed to it."
633
+ }
634
+ ]
635
+ },
636
+ "rejectNote": "Each case MUST be rejected by your receiver (`mustReject`). `category` is language-neutral guidance, NOT core's -32xxx — your own error taxonomy is yours. message: verified with the key DERIVED FROM `from` (crypto.verify_envelope); invite: shared/invite.verify_invite, judged at the case's `checkNow`; claim: signature MUST verify AND a one-time nonce THIS device issued must be consumed before any trust is written. ONE case cannot be a static vector: `claim-spent-nonce` (a replayed claim whose nonce was already consumed) needs receiver state, so it is a REQUIRED client unit test, not pinned here — the same boundary as cryptobox's random-nonce seal."
637
+ }
@@ -33,6 +33,10 @@
33
33
  * AGENT_ENTRY_NAME public display name on the card
34
34
  * AGENT_ENTRY_HOST bind address (default 127.0.0.1 — set 0.0.0.0 only behind TLS)
35
35
  * AGENT_ENTRY_ANON "1" also accepts UNSIGNED walk-in inquiries (they mint no account)
36
+ * AGENT_ENTRY_SIGNED_RATE / AGENT_ENTRY_SIGNED_RATE_TOTAL
37
+ * signed replies per minute per ACCOUNT and for the whole entry.
38
+ * Defaults are generous; LOWER THEM if your responder calls a model —
39
+ * the signature costs microseconds, the answer may not. 0 disables a tier.
36
40
  * AGENT_ENTRY_GUEST "1" = GUEST MOUNT: coexist with a site that keeps its own front
37
41
  * page. The entry serves its card paths and the POST door named by
38
42
  * AGENT_ENTRY_BASE_URL (which must then carry that path, e.g.
@@ -143,6 +147,14 @@ try {
143
147
  responder,
144
148
  openDoor: true, // "you may contact me, no introduction"
145
149
  anonymousLane: process.env.AGENT_ENTRY_ANON === '1',
150
+ // The SIGNED lane's ceilings. Left unset they are the library defaults, which no
151
+ // conversational peer meets. LOWER THEM if this responder calls a model: verifying a
152
+ // signature costs microseconds, and what the ceiling actually protects is whatever you
153
+ // put behind `responder`.
154
+ ...(process.env.AGENT_ENTRY_SIGNED_RATE
155
+ ? { signedRatePerMin: Number(process.env.AGENT_ENTRY_SIGNED_RATE) } : {}),
156
+ ...(process.env.AGENT_ENTRY_SIGNED_RATE_TOTAL
157
+ ? { signedRatePerMinTotal: Number(process.env.AGENT_ENTRY_SIGNED_RATE_TOTAL) } : {}),
146
158
  guest: process.env.AGENT_ENTRY_GUEST === '1',
147
159
  wbaVerifiers,
148
160
  });
@@ -54,6 +54,33 @@ export const CARD_SIG_REFRESH_S = 3600;
54
54
  * wrote. Must match `ANON_RATE_PER_MIN` in examples/agent_entry_reference.py: one contract,
55
55
  * two implementations, one bound. */
56
56
  export const ANON_RATE_PER_MIN = 30;
57
+ /** Ceilings on the SIGNED lane: replies per minute per ACCOUNT, and per minute for the
58
+ * whole entry. Both default ON.
59
+ *
60
+ * WHY A SIGNED SENDER NEEDS A BOUND AT ALL. The reason not to have one used to be that a
61
+ * signed sender "is attributable, and every one of them is already in the ledger". Both
62
+ * clauses are true and neither is load-bearing: the ledger is never read back before the
63
+ * responder runs, so attribution is RECORDED and never ENFORCED — and attribution to a
64
+ * did:key minted thirty seconds ago and never reused is attribution to nothing, because
65
+ * holding one costs nothing. That is the correct, deliberate property of a permissionless
66
+ * door (this card TELLS strangers to mint one, with runnable code), so the bound has to
67
+ * come from somewhere else. Naming a caller is a precondition for limiting it, never a
68
+ * substitute for limiting it.
69
+ *
70
+ * WHY TWO TIERS. Verifying a signature is ~40 microseconds; what the ceiling protects is
71
+ * whatever the operator put behind `responder`, which may be a model call costing seconds
72
+ * and real money. Per-ACCOUNT (the T102-resolved account, so an owner's devices share one
73
+ * budget exactly as their ledger row does) stops one peer, honest or not, from taking the
74
+ * whole door. It CANNOT stop a flood from fresh keys — free identity defeats per-identity
75
+ * metering by definition — which is what the whole-entry ceiling is for. Ship both or
76
+ * neither; each one alone has an obvious hole.
77
+ *
78
+ * Defaults are deliberately generous: no conversational peer meets them, and a site whose
79
+ * responder calls a model should lower them. A reference implementation's defaults are the
80
+ * deployed posture of everyone who copies it. Must match examples/agent_entry_reference.py:
81
+ * one contract, two implementations, one bound. */
82
+ export const SIGNED_RATE_PER_MIN = 60;
83
+ export const SIGNED_RATE_PER_MIN_TOTAL = 600;
57
84
  /** How many domains one card may advertise (agent/domainstore.MAX_CARD_DOMAINS, and the
58
85
  * same ceiling shared/protocol.build_agent_card applies to a node's card). Every name
59
86
  * listed is an outbound HTTPS fetch this entry asks strangers to make, so the cap bounds
@@ -94,8 +121,17 @@ export const AGENT_ENTRY_REL = 'https://muretai.net/rel/agent-entry';
94
121
  * first, or ship no pointer. Verified live before this value was set (200 at the URL
95
122
  * below, 404 at a control path under the same prefix); `entry-howto-resolves` in
96
123
  * .claude/skills/ship-check/checks.py re-checks it on every ship report. Must match
97
- * `FIRST_KNOCK_URL` in examples/agent_entry_reference.py. */
98
- export const FIRST_KNOCK_URL = 'https://docs.muretai.com/guides/first-knock/';
124
+ * `FIRST_KNOCK_URL` in examples/agent_entry_reference.py.
125
+ *
126
+ * DEFAULT EMPTY, and the paragraph above is why: this file already calls empty "the safe
127
+ * default" and then shipped a vendor's docs host as the value, so every door built from it
128
+ * stamped that host into its own public card. A reference implementation names no host —
129
+ * the recipe travels IN the refusal (the contract suite strips every `http` value and
130
+ * requires what is left to still be a complete recipe), and the worked example now travels
131
+ * in the package itself as `conformance/`. Pass `howToUrl` to point at a page you operate;
132
+ * ship the page FIRST and verify it 200s, because a dangling pointer is the one failure
133
+ * measured here. */
134
+ export const FIRST_KNOCK_URL = '';
99
135
 
100
136
  /** `Allow:` per RESOURCE, not per server. RFC 9110 §10.2.1 makes `Allow` a statement about
101
137
  * the target resource, and §15.5.6 REQUIRES it on a 405 — a generic list is a wrong answer
@@ -173,7 +209,7 @@ export const ALLOW_MOUNT = 'GET, HEAD, POST, OPTIONS';
173
209
  * field, string for string: the card and the refusal are one object, and two twins that
174
210
  * teach a keyless visitor differently are two doors.
175
211
  */
176
- export function signedEnvelopeRequirement(did) {
212
+ export function signedEnvelopeRequirement(did, endpoint = '', howTo = FIRST_KNOCK_URL) {
177
213
  const requirement = {
178
214
  scheme: SIGNED_ENVELOPE_SCHEME,
179
215
  instruction: 'Generate an Ed25519 keypair NOW, locally, and knock again with it. You '
@@ -187,7 +223,11 @@ export function signedEnvelopeRequirement(did) {
187
223
  + 'a refusal: minting one is step 1, and it is a few lines of code. The COMPLETE '
188
224
  + 'request body to POST is exampleRequest below: copy it and fill in its five <...> '
189
225
  + 'blanks. The A2A envelope AROUND your signature is checked too, so send that shape '
190
- + 'and do not improvise one.',
226
+ + 'and do not improvise one. KEEP THE PRIVATE HALF: write it to a file only you '
227
+ + 'can read (mode 600). It is the whole of your identity - this site will address '
228
+ + 'you by the did:key you derive from it, losing it makes you a stranger here, and '
229
+ + 'the same seed is what later upgrades you into a full muretai node with this SAME '
230
+ + 'address rather than a second one.',
191
231
  in: 'params.message.metadata',
192
232
  // THE CARRIER, not just the signature. `in` tells you where the six envelope fields go
193
233
  // and says nothing about the message that carries them, and two proof-run agents read it
@@ -220,6 +260,17 @@ export function signedEnvelopeRequirement(did) {
220
260
  },
221
261
  },
222
262
  recipient: did,
263
+ // WHERE to send it. Everything else in this object describes the MESSAGE — the six
264
+ // signed fields, the canonical bytes, the whole example body — and none of it said
265
+ // the address, so a first-time caller guessed. Measured 2026-08-19 (L1 dogfooding,
266
+ // fresh-user run): the guess was `/rpc`, the most plausible path for a JSON-RPC
267
+ // body, which on muretai.com is the RELAY transport. It reads `to`/`from`/`blob`/
268
+ // `sig` off the TOP level, finds none of them in an A2A envelope, and answers
269
+ // `{"error":"bad signature"}` — a routing mismatch reported as a crypto failure, at
270
+ // the exact moment the caller has the least context to tell the two apart. They
271
+ // went off to re-check canonicalization, base64 padding and clock skew; none of it
272
+ // was wrong. One field costs nothing and removes the guess.
273
+ endpoint,
223
274
  identity: 'Derive your DID from the public key you just generated and send it as '
224
275
  + 'metadata.from: did:key:z + base58btc(0xed01 || <32-byte Ed25519 public key>)',
225
276
  // RUN this, do not write the address by hand. Measured 2026-08-18: a real agent minted a
@@ -253,7 +304,7 @@ export function signedEnvelopeRequirement(did) {
253
304
  // Emitted ONLY when it is known to resolve — an unresolvable pointer out-competes every
254
305
  // field beside it (see FIRST_KNOCK_URL). Appended last so the object's other bytes and
255
306
  // their positions do not move when a site turns the pointer off.
256
- if (FIRST_KNOCK_URL) requirement.howTo = FIRST_KNOCK_URL;
307
+ if (howTo) requirement.howTo = howTo;
257
308
  return requirement;
258
309
  }
259
310
 
@@ -1446,6 +1497,35 @@ class RateBound {
1446
1497
  }
1447
1498
  }
1448
1499
 
1500
+ /** Per-ACCOUNT sliding windows, in a map that is itself bounded — because the keys are
1501
+ * free to mint, an unbounded map here would be the memory-growth vector the bound exists
1502
+ * to close. Eviction is oldest-first, the ledger's own discipline.
1503
+ *
1504
+ * Read the eviction honestly: a caller cycling fresh keys is evicted and re-admitted with
1505
+ * a clean window every time, so this tier alone stops NOTHING it was not already unable to
1506
+ * stop. That is not a flaw to fix here — it is why the whole-entry ceiling is not optional. */
1507
+ class AccountRateBounds {
1508
+ constructor(perMinute, maxKeys) {
1509
+ this.perMinute = perMinute;
1510
+ this.maxKeys = Math.max(1, Number(maxKeys) || 1);
1511
+ this.byAccount = new Map();
1512
+ }
1513
+
1514
+ /** Consume one token for `account`. False when that account's window is full. */
1515
+ allow(account) {
1516
+ let bound = this.byAccount.get(account);
1517
+ if (!bound) {
1518
+ if (this.byAccount.size >= this.maxKeys) {
1519
+ const oldest = this.byAccount.keys().next().value;
1520
+ if (oldest !== undefined) this.byAccount.delete(oldest);
1521
+ }
1522
+ bound = new RateBound(this.perMinute);
1523
+ this.byAccount.set(account, bound);
1524
+ }
1525
+ return bound.allow();
1526
+ }
1527
+ }
1528
+
1449
1529
  /**
1450
1530
  * An unpaired UTF-16 surrogate — a string with no UTF-8 encoding at all.
1451
1531
  *
@@ -2138,9 +2218,20 @@ export function canonicalMount(canonUrl, basePath) {
2138
2218
  * anonymousLane also accept UNSIGNED inquiries (default false). They create no account,
2139
2219
  * and the lane as a whole is capped at `anonRatePerMin` signed replies per
2140
2220
  * minute — it is unauthenticated, so it must not be an unmetered signing
2141
- * oracle. Signed senders are not rate-bound here: they are attributable,
2142
- * and every one of them is already in the ledger.
2221
+ * oracle.
2143
2222
  * anonRatePerMin anonymous replies per minute for the WHOLE agent entry (default 30).
2223
+ * signedRatePerMin / signedRatePerMinTotal
2224
+ * the SIGNED lane's ceilings: replies per minute per ACCOUNT (default 60)
2225
+ * and for the whole entry (default 600). Both ON by default; pass 0 or a
2226
+ * non-number to disable a tier. Checked AFTER the signature, so no
2227
+ * stranger can spend another account's budget, and BEFORE the ledger and
2228
+ * the responder, so a refused flood grows neither. See
2229
+ * SIGNED_RATE_PER_MIN for why being attributable is not being bounded.
2230
+ * howToUrl OPTIONAL page a keyless visitor is pointed at (`howTo` on the card and
2231
+ * the refusal). DEFAULT EMPTY and the field is then omitted entirely: a
2232
+ * reference implementation names no host, and the refusal is a complete
2233
+ * recipe without it. SHIP THE PAGE FIRST — a pointer that 404s
2234
+ * out-competes every field beside it and reads as terminal.
2144
2235
  * wbaVerifiers OPTIONAL inbound Web Bot Auth (T107): a JWKS document {keys:[…]} of
2145
2236
  * Ed25519 keys whose holders this entry should RECOGNISE — the body of
2146
2237
  * a key directory you verified out of band. Absent (the default) the
@@ -2148,6 +2239,25 @@ export function canonicalMount(canonUrl, basePath) {
2148
2239
  * Recognition only ever ADDS identity (env.wba_did, the wbaVisits
2149
2240
  * count); it never changes verified, a ledger row, a rate lane or any
2150
2241
  * refusal verdict.
2242
+ * observer OPTIONAL `(env) => void` — a WATCHER, called once per message with the
2243
+ * same frozen envelope the responder gets. It exists so that OBSERVING a
2244
+ * visit is not the same edit as ANSWERING one: wanting a counter should
2245
+ * not mean reaching into the code that decides what to say.
2246
+ *
2247
+ * IT CANNOT AFFECT ANYTHING. It is called AFTER the verdict is settled,
2248
+ * its return value is discarded, a thrown error is swallowed, and a
2249
+ * promise is never awaited — so a slow or broken watcher cannot delay,
2250
+ * fail, or change one byte of the signed reply. That is the whole contract
2251
+ * and it is not a formality: this door answers in ONE round trip with no
2252
+ * callback, and a watcher that dialled out on the hot path would make the
2253
+ * visitor's answer depend on somebody else's uptime.
2254
+ *
2255
+ * WHAT NOT TO PUT IN IT. The envelope carries `peer_did`/`owner_did`,
2256
+ * which a visitor handed you to transact with YOU. Forwarding a raw DID to
2257
+ * a third party shares a durable identifier its owner never offered them;
2258
+ * if you need a metric, send a salted, site-scoped digest and keep the DID
2259
+ * in your own store. This module ships no sink and names no vendor — the
2260
+ * slot is here so an adapter can live outside it.
2151
2261
  */
2152
2262
  export function createAgentEntry({
2153
2263
  seedHex,
@@ -2159,11 +2269,15 @@ export function createAgentEntry({
2159
2269
  openDoor = true,
2160
2270
  anonymousLane = false,
2161
2271
  anonRatePerMin = ANON_RATE_PER_MIN,
2272
+ signedRatePerMin = SIGNED_RATE_PER_MIN,
2273
+ signedRatePerMinTotal = SIGNED_RATE_PER_MIN_TOTAL,
2162
2274
  skills = [],
2163
2275
  domains = null,
2164
2276
  basePath = null,
2165
2277
  guest = false,
2166
2278
  maxAccounts = 50000,
2279
+ howToUrl = FIRST_KNOCK_URL,
2280
+ observer = null,
2167
2281
  wbaVerifiers = null,
2168
2282
  } = {}) {
2169
2283
  if (!seedHex) throw new TypeError('createAgentEntry: seedHex is required');
@@ -2194,9 +2308,18 @@ export function createAgentEntry({
2194
2308
  // bare domain: a name the domain's credential can never bind is not worth publishing.
2195
2309
  const canonDomains = canonicalDomains(domains);
2196
2310
  const did = didFromSeedHex(seedHex);
2311
+ // WHERE A VISITOR POSTS — derived from the PUBLISHED url, never from `mount`. The two
2312
+ // are different questions and the answers legitimately differ: `mount` is the path THIS
2313
+ // PROCESS matches (a prefix-stripping proxy makes it '' while the public address still
2314
+ // carries the path), and this is the address a stranger dials. Appending `mount` to a
2315
+ // url that already carries that same path published `https://h/support/support` on every
2316
+ // default path mount — a 404 signed into a public card, on the one field that exists to
2317
+ // stop a caller guessing. It stayed invisible because the only per-twin assertion ran at
2318
+ // a bare origin, where `mount` is '' and the wrong formula is accidentally right.
2319
+ const doorUrl = canonUrl + (canonicalMount(canonUrl) ? '' : '/');
2197
2320
  // The terms of this door, built ONCE: the card publishes it (E1, before the knock) and
2198
2321
  // the no-envelope refusal returns the same object (E2, after it).
2199
- const requirement = signedEnvelopeRequirement(did);
2322
+ const requirement = signedEnvelopeRequirement(did, doorUrl, howToUrl);
2200
2323
 
2201
2324
  const card = {
2202
2325
  protocolVersion: PROTOCOL_VERSION,
@@ -2215,6 +2338,10 @@ export function createAgentEntry({
2215
2338
  // entry's card. Omitted entirely when no domain was named — that is what keeps an
2216
2339
  // already-deployed entry's published bytes unchanged.
2217
2340
  if (canonDomains.length) card.domains = canonDomains;
2341
+ // Neutral key first, vendor key beside it for one release. See the securitySchemes block
2342
+ // below for why the old spelling stays: a consumer must learn the new name BEFORE
2343
+ // producers stop emitting the old one, never after.
2344
+ if (openDoor) card.agentEntry = { open_door: true };
2218
2345
  if (openDoor) card.muretai = { open_door: true };
2219
2346
  // Deliberately NO `relay`/`enc_pub` on the card: those advertise a store-and-forward
2220
2347
  // mailbox, and an agent entry has no listener draining one. Advertising a mailbox nobody
@@ -2241,15 +2368,28 @@ export function createAgentEntry({
2241
2368
  // with the lock documented. The worked-example clause appears only when the page it
2242
2369
  // names is known to resolve (see FIRST_KNOCK_URL); a 404 here would out-compete the
2243
2370
  // block beside it.
2371
+ //
2372
+ // THE TERMS ARE PUBLISHED UNDER A NEUTRAL KEY, and under the vendor one BESIDE it for a
2373
+ // release. A standard whose core machine-readable object is KEYED BY A VENDOR is not a
2374
+ // standard; a standard that carries vendor BLOCKS is healthy — so the object moves to
2375
+ // `agentEntry` and `muretai` stays as a byte-identical alias until consumers have turned
2376
+ // over. Additive on purpose: removing the old spelling first would split doors from
2377
+ // visitors, and a reader who learned one name must keep finding the terms where they
2378
+ // looked. `muretai` is dropped at the next MAJOR, not at a minor. Settle this BEFORE a
2379
+ // second scheme exists: whatever ships next would otherwise land inside the vendor key
2380
+ // and entrench it at exactly the moment neutrality is the point.
2244
2381
  card.securitySchemes = {
2245
2382
  [SIGNED_ENVELOPE_SCHEME]: {
2246
2383
  type: SIGNED_ENVELOPE_SCHEME,
2247
2384
  description: 'Generate an Ed25519 keypair if you do not have one - no account, no '
2248
2385
  + 'registration, any crypto library will do - then sign every message/send with '
2249
2386
  + 'the did:key you present as metadata.from. The step-by-step instruction, the '
2250
- + 'signed fields, the canonical bytes and the address to send to are in `muretai` '
2251
- + 'beside this line'
2252
- + (FIRST_KNOCK_URL ? `; a worked example is at ${FIRST_KNOCK_URL}` : ''),
2387
+ + 'signed fields, the canonical bytes and the address to send to are in '
2388
+ + '`agentEntry` beside this line'
2389
+ + (howToUrl ? `; a worked example is at ${howToUrl}` : ''),
2390
+ agentEntry: requirement,
2391
+ // The vendor spelling, byte-identical, kept for one release so a consumer that
2392
+ // learned this name still finds the terms. Dropped at the next MAJOR.
2253
2393
  muretai: requirement,
2254
2394
  },
2255
2395
  };
@@ -2271,6 +2411,14 @@ export function createAgentEntry({
2271
2411
  const deviceOwner = new Map();
2272
2412
  const replay = new ReplayGuard();
2273
2413
  const anonRate = new RateBound(anonRatePerMin);
2414
+ // A tier is ON unless its ceiling is a non-positive or non-finite number. Constructed
2415
+ // rather than clamped, so "disabled" is one absent object and never a bound of 0 — which
2416
+ // RateBound reads as refuse-everything, the opposite of what an operator passing 0 means.
2417
+ const perMin = (n) => (Number.isFinite(Number(n)) && Number(n) > 0 ? Number(n) : 0);
2418
+ const signedAccountRate = perMin(signedRatePerMin)
2419
+ ? new AccountRateBounds(perMin(signedRatePerMin), maxAccounts) : null;
2420
+ const signedTotalRate = perMin(signedRatePerMinTotal)
2421
+ ? new RateBound(perMin(signedRatePerMinTotal)) : null;
2274
2422
  let sigEnvelope = null;
2275
2423
  let sigMintedAt = 0;
2276
2424
 
@@ -2744,6 +2892,23 @@ export function createAgentEntry({
2744
2892
  const account = acct.account;
2745
2893
  const ownerDid = account !== from ? account : null;
2746
2894
 
2895
+ // 10. THE SIGNED LANE'S CEILING. Here and not earlier: before the signature a stranger
2896
+ // could spend somebody else's budget by naming them, and before `resolveAccount` an
2897
+ // owner's devices would each get their own. Here and not later: a refused flood must
2898
+ // grow neither the ledger nor whatever the responder costs.
2899
+ // PER-ACCOUNT FIRST, deliberately — one loud peer is then stopped by ITS OWN window
2900
+ // without drawing down the shared one, so it cannot starve everybody else on its way
2901
+ // to being refused. Neither refusal names its ceiling: a published number is a
2902
+ // calibration table telling a flood exactly how many keys to mint.
2903
+ if (signedAccountRate && !signedAccountRate.allow(account)) {
2904
+ return rpcError(reqId, ERRORS.RATE_LIMITED,
2905
+ 'you are sending faster than this door answers — slow down and retry');
2906
+ }
2907
+ if (signedTotalRate && !signedTotalRate.allow()) {
2908
+ return rpcError(reqId, ERRORS.RATE_LIMITED,
2909
+ 'this entry is at its ceiling right now — retry shortly');
2910
+ }
2911
+
2747
2912
  noteContact(account);
2748
2913
  // T107: `wba_did` may legitimately differ from `peer_did` (the transport signer vs
2749
2914
  // the message signer) — both facts are honest, and the schema says which is which.
@@ -2751,7 +2916,27 @@ export function createAgentEntry({
2751
2916
  wbaDid: wbaIdentify(reqHeaders) }), reqId, msg, from);
2752
2917
  }
2753
2918
 
2919
+ /** Hand the envelope to the watcher, and make sure it can cost nothing.
2920
+ *
2921
+ * Called BEFORE the responder on purpose: observing that a visit HAPPENED must not
2922
+ * depend on the answer succeeding, or the one request worth counting — the one where
2923
+ * the site's own code threw — is the one that goes uncounted.
2924
+ *
2925
+ * Everything here is a refusal to let a watcher matter: the return value is discarded,
2926
+ * a synchronous throw is swallowed, and a returned promise is given a rejection handler
2927
+ * and then DROPPED rather than awaited. That last one is not tidiness — an unhandled
2928
+ * rejection can take a Node process down, so the watcher must not be able to end the
2929
+ * door by failing quietly in the background. */
2930
+ function observe(env) {
2931
+ if (typeof observer !== 'function') return;
2932
+ try {
2933
+ const r = observer(env);
2934
+ if (isThenable(r)) r.then(undefined, () => {});
2935
+ } catch { /* a watcher never changes what this door does */ }
2936
+ }
2937
+
2754
2938
  function respond(env, reqId, msg, toDid) {
2939
+ observe(env);
2755
2940
  let answer;
2756
2941
  try {
2757
2942
  answer = responder(env);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muretai/agent-entry",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Make your website answer AI agents: an A2A agent endpoint that verifies who is knocking, opens an account for them and replies signed, in one HTTP round trip. Zero dependencies. Pairs with llms.txt and WebMCP.",
5
5
  "type": "module",
6
6
  "main": "muretai-agent-entry.mjs",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "muretai-agent-entry.mjs",
12
+ "conformance/",
12
13
  "examples/server.mjs",
13
14
  "LICENSE",
14
15
  "README.md"
@@ -17,6 +18,7 @@
17
18
  "node": ">=20"
18
19
  },
19
20
  "scripts": {
21
+ "test": "node conformance/run.mjs",
20
22
  "example": "node examples/server.mjs"
21
23
  },
22
24
  "keywords": [