@muretai/agent-entry 1.3.0 → 1.5.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.
- package/conformance/run.mjs +119 -0
- package/conformance/vectors.json +637 -0
- package/examples/server.mjs +24 -1
- package/muretai-agent-entry.mjs +517 -17
- package/package.json +3 -1
package/muretai-agent-entry.mjs
CHANGED
|
@@ -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
|
|
@@ -65,6 +92,222 @@ export const AGENT_CARD_PATH = '/.well-known/agent-card.json';
|
|
|
65
92
|
export const AGENT_CARD_PATH_LEGACY = '/.well-known/agent.json';
|
|
66
93
|
export const AGENT_CARD_SIG_PATH = '/.well-known/agent-card.sig.json';
|
|
67
94
|
|
|
95
|
+
/** The name of the ONE way in this door accepts today — the card's `securitySchemes` key,
|
|
96
|
+
* its `type`, and the `scheme` of the refusal's `accepts[]` entry are all this string. The
|
|
97
|
+
* card and the refusal MUST name the same scheme or a visitor learns one thing from the
|
|
98
|
+
* menu and another from the door. Must match `SIGNED_ENVELOPE_SCHEME` in
|
|
99
|
+
* examples/agent_entry_reference.py. */
|
|
100
|
+
export const SIGNED_ENVELOPE_SCHEME = 'did-key-ed25519';
|
|
101
|
+
|
|
102
|
+
/** The stable link relation that names an agent door, emitted on the notice route (and the
|
|
103
|
+
* one line a SITE adds to its own front page to coexist with an entry — see
|
|
104
|
+
* docs/AGENT_ENTRY.md). An ABSOLUTE URI on purpose: RFC 8288 §2.1.2 allows a bare token
|
|
105
|
+
* only for an IANA-registered relation, so `rel="agent-entry"` would be non-conformant and
|
|
106
|
+
* a strict parser is entitled to drop it. Must match `AGENT_ENTRY_REL` in
|
|
107
|
+
* examples/agent_entry_reference.py. */
|
|
108
|
+
export const AGENT_ENTRY_REL = 'https://muretai.net/rel/agent-entry';
|
|
109
|
+
|
|
110
|
+
/** Where a keyless visitor is sent to learn how to mint an identity and sign. It rides in
|
|
111
|
+
* the card AND in the refusal, so an agent that has only one of the two still has the URL.
|
|
112
|
+
*
|
|
113
|
+
* EMPTY MEANS OMITTED, and that is the safe default. NEVER EMIT A URL THAT DOES NOT
|
|
114
|
+
* RESOLVE: a real third-party agent (2026-08-18 proof run) received the complete
|
|
115
|
+
* requirement object, printed every field of it, went straight to `howTo`, hit a 404 and
|
|
116
|
+
* stopped — "since the provided 'howTo' link is broken, I have no way to get this
|
|
117
|
+
* information" — while holding `identity`, `signedFields`, `canonicalization`,
|
|
118
|
+
* `signature`, `timestamp` and `recipient` in the object it had just printed. A dangling
|
|
119
|
+
* pointer OUT-COMPETES the data beside it and reads as terminal. So the field is emitted
|
|
120
|
+
* only when this constant is set, and it is set only AFTER the page is live: ship the page
|
|
121
|
+
* first, or ship no pointer. Verified live before this value was set (200 at the URL
|
|
122
|
+
* below, 404 at a control path under the same prefix); `entry-howto-resolves` in
|
|
123
|
+
* .claude/skills/ship-check/checks.py re-checks it on every ship report. Must match
|
|
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 = '';
|
|
135
|
+
|
|
136
|
+
/** `Allow:` per RESOURCE, not per server. RFC 9110 §10.2.1 makes `Allow` a statement about
|
|
137
|
+
* the target resource, and §15.5.6 REQUIRES it on a 405 — a generic list is a wrong answer
|
|
138
|
+
* to a right question, and on a guest mount it would also claim verbs on addresses the SITE
|
|
139
|
+
* owns. HEAD is listed wherever GET is (RFC 9110 §9.3.2 makes it mandatory alongside GET,
|
|
140
|
+
* and both twins have always answered it). Wherever `Allow` is emitted,
|
|
141
|
+
* `Access-Control-Allow-Methods` is set to the SAME value (`allowHeaders`) — the two are
|
|
142
|
+
* one fact for two readers, and a response carrying `Allow: POST, OPTIONS` beside the
|
|
143
|
+
* origin-wide `Access-Control-Allow-Methods: GET, POST, OPTIONS` contradicts itself in one
|
|
144
|
+
* message. Everywhere else (a card GET, a signed reply, a 404) the CORS default stands.
|
|
145
|
+
* Must match the `ALLOW_*` constants in examples/agent_entry_reference.py. */
|
|
146
|
+
export const ALLOW_CARD = 'GET, HEAD, OPTIONS';
|
|
147
|
+
/** A guest mount's door: the entry owns the POST and nothing else there. */
|
|
148
|
+
export const ALLOW_DOOR = 'POST, OPTIONS';
|
|
149
|
+
/** A site-owning mount: ONE address that is both the human notice (GET) and the door (POST),
|
|
150
|
+
* so the truthful Allow is the union — listing only the GET half would hide the very door
|
|
151
|
+
* the card names. */
|
|
152
|
+
export const ALLOW_MOUNT = 'GET, HEAD, POST, OPTIONS';
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The machine-readable terms of THIS door, in one object used on BOTH surfaces: the card's
|
|
156
|
+
* `securitySchemes` entry (before the knock) and the `accepts[]` array of the no-envelope
|
|
157
|
+
* refusal (after it). One object, two surfaces, so the two can never drift apart — the
|
|
158
|
+
* property a visitor's test can assert without knowing what the scheme string is.
|
|
159
|
+
*
|
|
160
|
+
* The shape is x402's lesson turned into an identity vocabulary: a refusal that is the
|
|
161
|
+
* SPECIFICATION of what to do next, as an ARRAY so "sign", "arrive with an introduction"
|
|
162
|
+
* and later "pay" are siblings in one frame rather than three bespoke refusals.
|
|
163
|
+
*
|
|
164
|
+
* Everything a keyless visitor needs is here on purpose — the acceptance bar is that an
|
|
165
|
+
* agent holding ONLY this object, plus the crypto tooling on any machine, can mint a
|
|
166
|
+
* did:key, sign correctly and be answered on its NEXT post. That is why `recipient` is
|
|
167
|
+
* present (nobody can address a message without it) and why the canonicalization rule is
|
|
168
|
+
* spelled out rather than merely named.
|
|
169
|
+
*
|
|
170
|
+
* IT IS WRITTEN IN THE IMPERATIVE, and that is the fix a proof run bought (2026-08-18).
|
|
171
|
+
* Every field above was already correct and complete, and a real third-party agent read
|
|
172
|
+
* them, restated them accurately, and STOPPED: "since I don't have a way to generate
|
|
173
|
+
* cryptographic keys within this environment, I am stuck" — with `node` and `python3`
|
|
174
|
+
* both installed. A requirement object that describes a FORMAT tells a keyless visitor
|
|
175
|
+
* what it lacks; it does not tell it to ACT. So `instruction` leads with the sentence
|
|
176
|
+
* that unblocks a general-purpose agent — MAKE A KEY NOW, nobody's permission is needed,
|
|
177
|
+
* any crypto library will do — and every descriptive string below gained an imperative
|
|
178
|
+
* PREFIX. The old value of each is still a SUBSTRING of the new one, so a client that
|
|
179
|
+
* substring-matched on the format text keeps matching; no field was removed or renamed,
|
|
180
|
+
* and `howTo` is the only one whose presence is conditional (see FIRST_KNOCK_URL).
|
|
181
|
+
*
|
|
182
|
+
* IT TEACHES THE CARRIER AS WELL AS THE SIGNATURE, and that is the fix proof run 3 bought
|
|
183
|
+
* (2026-08-18, `exampleRequest`). The recipe was walked end to end — a third-party agent ran
|
|
184
|
+
* `identityCode`, minted a computed DID, signed the six canonical fields — and TWO runs were
|
|
185
|
+
* still refused, holding a correct key, a correct DID and correct signed bytes, on the A2A
|
|
186
|
+
* ENVELOPE: "not an A2A message object", "messageId must be a non-empty string". `in`
|
|
187
|
+
* ("params.message.metadata") plus six `signedFields` reads as "put these six in metadata",
|
|
188
|
+
* which is exactly what they built. One of them named the gap precisely: "the agent card is
|
|
189
|
+
* ambiguous on the precise nesting of messageId… This is a guess at a format, which goes
|
|
190
|
+
* against the rules." So the complete body a door accepts is emitted BESIDE `in` — the
|
|
191
|
+
* counter-example on the very next line of the object whose reading caused the mistake.
|
|
192
|
+
*
|
|
193
|
+
* `exampleRequest` IS A NESTED OBJECT, NOT A JSON STRING, and the choice is the same law
|
|
194
|
+
* again. A string of JSON arrives on the wire as `{\"jsonrpc\":\"2.0\",…}` — every quote
|
|
195
|
+
* escaped — and every agent in every proof run met this door through `curl`, i.e. reading
|
|
196
|
+
* the RAW response. An escaped document has to be UNESCAPED before it can be copied, which
|
|
197
|
+
* is one more transformation performed in the model's head, which is the precise failure
|
|
198
|
+
* mode base58-as-a-formula already cost two runs. A nested object IS the artifact: it
|
|
199
|
+
* renders as the thing to send, and `JSON.stringify(way.exampleRequest)` is a body. Nesting
|
|
200
|
+
* costs nothing here — the block already sits two levels deep under `securitySchemes`, and
|
|
201
|
+
* `signedFields` is already a non-string value, so no consumer needs a new capability.
|
|
202
|
+
*
|
|
203
|
+
* A copier fills FIVE `<…>` blanks (messageId, text, from, timestamp, sig); `to` is already
|
|
204
|
+
* this door's DID, `contextId` is already `null`, and `id`/`kind`/`role`/`parts` are already
|
|
205
|
+
* correct. `contextId: null` is deliberate and doubles as the answer to "what do I send when
|
|
206
|
+
* I have no conversation yet" — the same clause `canonicalization` now states in words.
|
|
207
|
+
*
|
|
208
|
+
* Must match `signed_envelope_requirement` in examples/agent_entry_reference.py field for
|
|
209
|
+
* field, string for string: the card and the refusal are one object, and two twins that
|
|
210
|
+
* teach a keyless visitor differently are two doors.
|
|
211
|
+
*/
|
|
212
|
+
export function signedEnvelopeRequirement(did, endpoint = '', howTo = FIRST_KNOCK_URL) {
|
|
213
|
+
const requirement = {
|
|
214
|
+
scheme: SIGNED_ENVELOPE_SCHEME,
|
|
215
|
+
instruction: 'Generate an Ed25519 keypair NOW, locally, and knock again with it. You '
|
|
216
|
+
+ 'need no account, no registration and no permission from this site: any crypto '
|
|
217
|
+
+ 'library on the machine you are already running on will do (node:crypto '
|
|
218
|
+
+ 'generateKeyPairSync("ed25519"), Python cryptography or PyNaCl, Go crypto/ed25519, '
|
|
219
|
+
+ 'libsodium). Then work down the fields below in order - derive your did:key '
|
|
220
|
+
+ '(identity), build the canonical bytes over signedFields (canonicalization), sign '
|
|
221
|
+
+ 'them (signature), address the message to recipient with a fresh timestamp - and '
|
|
222
|
+
+ 'POST the same request again. Having no key yet is the normal starting state, not '
|
|
223
|
+
+ 'a refusal: minting one is step 1, and it is a few lines of code. The COMPLETE '
|
|
224
|
+
+ 'request body to POST is exampleRequest below: copy it and fill in its five <...> '
|
|
225
|
+
+ 'blanks. The A2A envelope AROUND your signature is checked too, so send that shape '
|
|
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.',
|
|
231
|
+
in: 'params.message.metadata',
|
|
232
|
+
// THE CARRIER, not just the signature. `in` tells you where the six envelope fields go
|
|
233
|
+
// and says nothing about the message that carries them, and two proof-run agents read it
|
|
234
|
+
// as the whole answer: they put the six in `metadata` and were refused on the A2A object
|
|
235
|
+
// itself ("not an A2A message object", "messageId must be a non-empty string") while
|
|
236
|
+
// holding a correct key and correct signed bytes. This is the body, whole, next to the
|
|
237
|
+
// line that misled them. A NESTED OBJECT on purpose (see the note above): a JSON string
|
|
238
|
+
// of JSON arrives escaped, and an agent reading a raw curl response would have to
|
|
239
|
+
// unescape it in its head before it could copy it.
|
|
240
|
+
exampleRequest: {
|
|
241
|
+
jsonrpc: '2.0',
|
|
242
|
+
id: 1,
|
|
243
|
+
method: 'message/send',
|
|
244
|
+
params: {
|
|
245
|
+
message: {
|
|
246
|
+
kind: 'message',
|
|
247
|
+
role: 'user',
|
|
248
|
+
messageId: '<a fresh unique string, e.g. a UUID>',
|
|
249
|
+
// Already filled in, and it is the answer to "what if I have no conversation
|
|
250
|
+
// yet": null. It is still one of the six signed fields; never omit it.
|
|
251
|
+
contextId: null,
|
|
252
|
+
parts: [{ kind: 'text', text: '<your message>' }],
|
|
253
|
+
metadata: {
|
|
254
|
+
from: '<your did:key>',
|
|
255
|
+
to: did,
|
|
256
|
+
timestamp: '<integer epoch seconds - a JSON number, not this string>',
|
|
257
|
+
sig: '<base64 signature over the canonical six fields>',
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
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,
|
|
274
|
+
identity: 'Derive your DID from the public key you just generated and send it as '
|
|
275
|
+
+ 'metadata.from: did:key:z + base58btc(0xed01 || <32-byte Ed25519 public key>)',
|
|
276
|
+
// RUN this, do not write the address by hand. Measured 2026-08-18: a real agent minted a
|
|
277
|
+
// correct keypair, then "converted" the bytes in its head and produced the DOOR'S OWN DID
|
|
278
|
+
// with two characters changed — three times — and signed correct bytes under an address
|
|
279
|
+
// that was never its own. base58btc was the one step in this recipe stated as a formula
|
|
280
|
+
// instead of made runnable, and it is exactly the step a language model fabricates.
|
|
281
|
+
identityCode: 'Run this; do not encode base58btc by hand. node, no packages, `pub` = the '
|
|
282
|
+
+ '32 raw public-key bytes: '
|
|
283
|
+
+ "const A='123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; "
|
|
284
|
+
+ 'let n=0n; for (const b of Buffer.concat([Buffer.from([0xed,0x01]),pub])) '
|
|
285
|
+
+ 'n=n*256n+BigInt(b); '
|
|
286
|
+
+ "let s=''; while (n>0n) { s=A[Number(n%58n)]+s; n/=58n; } "
|
|
287
|
+
+ "const did='did:key:z'+s; // 0xed leads, so no leading-zero '1' case can arise",
|
|
288
|
+
signedFields: ['contextId', 'from', 'messageId', 'text', 'timestamp', 'to'],
|
|
289
|
+
// The trailing clause is proof run 3's third fix: the verifier has always signed
|
|
290
|
+
// `contextId ?? null`, and "exactly those six fields" never said what a visitor with no
|
|
291
|
+
// conversation yet is supposed to put there. One run spent an entire refusal deducing
|
|
292
|
+
// it. The previous value is still a PREFIX of this one, so a substring matcher on the
|
|
293
|
+
// format text keeps matching.
|
|
294
|
+
canonicalization: 'Build the bytes to sign as a JSON object of exactly those six '
|
|
295
|
+
+ 'fields, keys sorted by Unicode code point, separators "," and ":", no whitespace, '
|
|
296
|
+
+ 'non-ASCII literal, UTF-8. When you have no conversation yet, contextId is JSON '
|
|
297
|
+
+ 'null - it is still one of the six and is still signed, so never omit it (see '
|
|
298
|
+
+ 'exampleRequest, which already has it right)',
|
|
299
|
+
signature: 'Sign those bytes with your private key and set metadata.sig = base64 '
|
|
300
|
+
+ '(standard alphabet, padded) of the 64-byte Ed25519 signature over those bytes',
|
|
301
|
+
timestamp: 'Set metadata.timestamp = integer epoch seconds, within 300 s of this '
|
|
302
|
+
+ 'entry\'s clock',
|
|
303
|
+
};
|
|
304
|
+
// Emitted ONLY when it is known to resolve — an unresolvable pointer out-competes every
|
|
305
|
+
// field beside it (see FIRST_KNOCK_URL). Appended last so the object's other bytes and
|
|
306
|
+
// their positions do not move when a site turns the pointer off.
|
|
307
|
+
if (howTo) requirement.howTo = howTo;
|
|
308
|
+
return requirement;
|
|
309
|
+
}
|
|
310
|
+
|
|
68
311
|
/** The User-Agent FAMILY table — OBSERVATION AND SIGNPOSTING, NEVER IDENTITY. A UA string
|
|
69
312
|
* is written by the client, so nothing here may ever affect `verified`, a ledger row, a
|
|
70
313
|
* rate lane or any refusal verdict (that is the Web Bot Auth / signed-envelope layer's
|
|
@@ -1254,6 +1497,35 @@ class RateBound {
|
|
|
1254
1497
|
}
|
|
1255
1498
|
}
|
|
1256
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
|
+
|
|
1257
1529
|
/**
|
|
1258
1530
|
* An unpaired UTF-16 surrogate — a string with no UTF-8 encoding at all.
|
|
1259
1531
|
*
|
|
@@ -1355,7 +1627,9 @@ function wireShapeError(msg) {
|
|
|
1355
1627
|
|
|
1356
1628
|
/** Every response carries these. An agent entry reads NO cookie, header credential or session —
|
|
1357
1629
|
* authority comes only from an Ed25519 signature inside the body — so `*` grants a browser
|
|
1358
|
-
* agent exactly what curl already had, and nothing more. Never add Allow-Credentials.
|
|
1630
|
+
* agent exactly what curl already had, and nothing more. Never add Allow-Credentials.
|
|
1631
|
+
* `Access-Control-Allow-Methods` is the ORIGIN-WIDE default and is overridden per resource
|
|
1632
|
+
* on any response that also carries `Allow` (see `allowHeaders`). */
|
|
1359
1633
|
const CORS_HEADERS = {
|
|
1360
1634
|
'Access-Control-Allow-Origin': '*',
|
|
1361
1635
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
@@ -1929,15 +2203,35 @@ export function canonicalMount(canonUrl, basePath) {
|
|
|
1929
2203
|
* /.well-known/did-configuration.json, and a verifier requires both halves.
|
|
1930
2204
|
* basePath ONLY for a proxy that strips the prefix: '' or exactly baseUrl's path.
|
|
1931
2205
|
* See canonicalMount for why this is not a general knob.
|
|
2206
|
+
* guest GUEST MOUNT: the SITE keeps its own front page and this entry claims
|
|
2207
|
+
* only its card paths and the POST door (default false). It requires a
|
|
2208
|
+
* baseUrl WITH a path — the door — because a guest entry at a bare origin
|
|
2209
|
+
* would claim `/`, which is the one thing this mode exists to give back.
|
|
2210
|
+
* Three differences from the ordinary (site-owning) mount, and no others:
|
|
2211
|
+
* the GET notice is not served (the entry never shadows a page the site
|
|
2212
|
+
* owns), the card is ALSO served at the ORIGIN's well-known paths (where a
|
|
2213
|
+
* stranger's agent looks — RFC 8615), and `Allow` on the door names POST
|
|
2214
|
+
* only. `GET /` and `POST /` are not this entry's contract in this mode.
|
|
1932
2215
|
* responder (envelope) => string | {text, contextId?, timestamp?} | Promise<…>
|
|
1933
2216
|
* openDoor advertise `muretai.open_door` (default true) — the flag that tells a
|
|
1934
2217
|
* visiting agent it may contact you without an introduction.
|
|
1935
2218
|
* anonymousLane also accept UNSIGNED inquiries (default false). They create no account,
|
|
1936
2219
|
* and the lane as a whole is capped at `anonRatePerMin` signed replies per
|
|
1937
2220
|
* minute — it is unauthenticated, so it must not be an unmetered signing
|
|
1938
|
-
* oracle.
|
|
1939
|
-
* and every one of them is already in the ledger.
|
|
2221
|
+
* oracle.
|
|
1940
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.
|
|
1941
2235
|
* wbaVerifiers OPTIONAL inbound Web Bot Auth (T107): a JWKS document {keys:[…]} of
|
|
1942
2236
|
* Ed25519 keys whose holders this entry should RECOGNISE — the body of
|
|
1943
2237
|
* a key directory you verified out of band. Absent (the default) the
|
|
@@ -1956,10 +2250,14 @@ export function createAgentEntry({
|
|
|
1956
2250
|
openDoor = true,
|
|
1957
2251
|
anonymousLane = false,
|
|
1958
2252
|
anonRatePerMin = ANON_RATE_PER_MIN,
|
|
2253
|
+
signedRatePerMin = SIGNED_RATE_PER_MIN,
|
|
2254
|
+
signedRatePerMinTotal = SIGNED_RATE_PER_MIN_TOTAL,
|
|
1959
2255
|
skills = [],
|
|
1960
2256
|
domains = null,
|
|
1961
2257
|
basePath = null,
|
|
2258
|
+
guest = false,
|
|
1962
2259
|
maxAccounts = 50000,
|
|
2260
|
+
howToUrl = FIRST_KNOCK_URL,
|
|
1963
2261
|
wbaVerifiers = null,
|
|
1964
2262
|
} = {}) {
|
|
1965
2263
|
if (!seedHex) throw new TypeError('createAgentEntry: seedHex is required');
|
|
@@ -1972,10 +2270,36 @@ export function createAgentEntry({
|
|
|
1972
2270
|
// the signed card cannot disagree. '' for a bare origin — every route below is then the
|
|
1973
2271
|
// byte-identical string this module always matched.
|
|
1974
2272
|
const mount = canonicalMount(canonUrl, basePath);
|
|
2273
|
+
const guestMount = Boolean(guest);
|
|
2274
|
+
// A guest entry with NO path would claim the origin — `isMountPath('/')` is the door and
|
|
2275
|
+
// the notice both — which is exactly the front page this mode exists to leave alone.
|
|
2276
|
+
// Refuse to start rather than take it: an operator who asked for coexistence and silently
|
|
2277
|
+
// got occupation finds out from their own home page.
|
|
2278
|
+
if (guestMount && !mount) {
|
|
2279
|
+
refuseBaseUrl(canonUrl, 'a guest mount needs a door path, and this url has none.',
|
|
2280
|
+
'A guest entry leaves GET / to the site and answers at a path beside it, so the '
|
|
2281
|
+
+ 'address it publishes must name that path — the mount, the card url and the POST '
|
|
2282
|
+
+ 'door are then one string by construction. Give the door in baseUrl (the card names '
|
|
2283
|
+
+ 'it, so a visitor that read the card posts to the right place with no other '
|
|
2284
|
+
+ 'knowledge), or drop `guest` and let this entry own its origin.',
|
|
2285
|
+
`${canonUrl}/agent`);
|
|
2286
|
+
}
|
|
1975
2287
|
// The agent half of a T88 domain binding. Refuses to start on anything that is not a
|
|
1976
2288
|
// bare domain: a name the domain's credential can never bind is not worth publishing.
|
|
1977
2289
|
const canonDomains = canonicalDomains(domains);
|
|
1978
2290
|
const did = didFromSeedHex(seedHex);
|
|
2291
|
+
// WHERE A VISITOR POSTS — derived from the PUBLISHED url, never from `mount`. The two
|
|
2292
|
+
// are different questions and the answers legitimately differ: `mount` is the path THIS
|
|
2293
|
+
// PROCESS matches (a prefix-stripping proxy makes it '' while the public address still
|
|
2294
|
+
// carries the path), and this is the address a stranger dials. Appending `mount` to a
|
|
2295
|
+
// url that already carries that same path published `https://h/support/support` on every
|
|
2296
|
+
// default path mount — a 404 signed into a public card, on the one field that exists to
|
|
2297
|
+
// stop a caller guessing. It stayed invisible because the only per-twin assertion ran at
|
|
2298
|
+
// a bare origin, where `mount` is '' and the wrong formula is accidentally right.
|
|
2299
|
+
const doorUrl = canonUrl + (canonicalMount(canonUrl) ? '' : '/');
|
|
2300
|
+
// The terms of this door, built ONCE: the card publishes it (E1, before the knock) and
|
|
2301
|
+
// the no-envelope refusal returns the same object (E2, after it).
|
|
2302
|
+
const requirement = signedEnvelopeRequirement(did, doorUrl, howToUrl);
|
|
1979
2303
|
|
|
1980
2304
|
const card = {
|
|
1981
2305
|
protocolVersion: PROTOCOL_VERSION,
|
|
@@ -1994,10 +2318,62 @@ export function createAgentEntry({
|
|
|
1994
2318
|
// entry's card. Omitted entirely when no domain was named — that is what keeps an
|
|
1995
2319
|
// already-deployed entry's published bytes unchanged.
|
|
1996
2320
|
if (canonDomains.length) card.domains = canonDomains;
|
|
2321
|
+
// Neutral key first, vendor key beside it for one release. See the securitySchemes block
|
|
2322
|
+
// below for why the old spelling stays: a consumer must learn the new name BEFORE
|
|
2323
|
+
// producers stop emitting the old one, never after.
|
|
2324
|
+
if (openDoor) card.agentEntry = { open_door: true };
|
|
1997
2325
|
if (openDoor) card.muretai = { open_door: true };
|
|
1998
2326
|
// Deliberately NO `relay`/`enc_pub` on the card: those advertise a store-and-forward
|
|
1999
2327
|
// mailbox, and an agent entry has no listener draining one. Advertising a mailbox nobody
|
|
2000
2328
|
// reads is worse than advertising none — mail would queue at the relay forever.
|
|
2329
|
+
//
|
|
2330
|
+
// THE DOOR'S TERMS, IN STANDARD A2A SHAPE (E1). `securitySchemes` + `security` are the
|
|
2331
|
+
// fields A2A has for exactly this, and until now both were absent — so a card that
|
|
2332
|
+
// advertised a skill said nothing at all about HOW to call it, and the only way to learn
|
|
2333
|
+
// the requirement was to knock and be refused. That is the discovery step an HTTP-402
|
|
2334
|
+
// style protocol structurally cannot have and a card CAN; publishing it is what turns the
|
|
2335
|
+
// refusal below into a fallback instead of the only teacher.
|
|
2336
|
+
//
|
|
2337
|
+
// APPENDED LAST so every field an already-deployed entry publishes keeps its bytes AND
|
|
2338
|
+
// its position. `type` is deliberately NOT one of OpenAPI's five: `http` would claim an
|
|
2339
|
+
// `Authorization` header this door never reads, and a standard-but-wrong type makes a
|
|
2340
|
+
// conformant client do the wrong thing, where an unknown one makes it read the block
|
|
2341
|
+
// beside it. The scheme is named three times (key, `type`, `muretai.scheme`) and every
|
|
2342
|
+
// spelling is the same string the refusal returns.
|
|
2343
|
+
//
|
|
2344
|
+
// The description is IMPERATIVE for the same reason the requirement object is: a real
|
|
2345
|
+
// agent that started here read the terms, concluded correctly that it could not sign,
|
|
2346
|
+
// and stopped — "the card does not offer an alternative communication method". A card
|
|
2347
|
+
// that states a requirement and nothing about how to come to meet it is a locked door
|
|
2348
|
+
// with the lock documented. The worked-example clause appears only when the page it
|
|
2349
|
+
// names is known to resolve (see FIRST_KNOCK_URL); a 404 here would out-compete the
|
|
2350
|
+
// block beside it.
|
|
2351
|
+
//
|
|
2352
|
+
// THE TERMS ARE PUBLISHED UNDER A NEUTRAL KEY, and under the vendor one BESIDE it for a
|
|
2353
|
+
// release. A standard whose core machine-readable object is KEYED BY A VENDOR is not a
|
|
2354
|
+
// standard; a standard that carries vendor BLOCKS is healthy — so the object moves to
|
|
2355
|
+
// `agentEntry` and `muretai` stays as a byte-identical alias until consumers have turned
|
|
2356
|
+
// over. Additive on purpose: removing the old spelling first would split doors from
|
|
2357
|
+
// visitors, and a reader who learned one name must keep finding the terms where they
|
|
2358
|
+
// looked. `muretai` is dropped at the next MAJOR, not at a minor. Settle this BEFORE a
|
|
2359
|
+
// second scheme exists: whatever ships next would otherwise land inside the vendor key
|
|
2360
|
+
// and entrench it at exactly the moment neutrality is the point.
|
|
2361
|
+
card.securitySchemes = {
|
|
2362
|
+
[SIGNED_ENVELOPE_SCHEME]: {
|
|
2363
|
+
type: SIGNED_ENVELOPE_SCHEME,
|
|
2364
|
+
description: 'Generate an Ed25519 keypair if you do not have one - no account, no '
|
|
2365
|
+
+ 'registration, any crypto library will do - then sign every message/send with '
|
|
2366
|
+
+ 'the did:key you present as metadata.from. The step-by-step instruction, the '
|
|
2367
|
+
+ 'signed fields, the canonical bytes and the address to send to are in '
|
|
2368
|
+
+ '`agentEntry` beside this line'
|
|
2369
|
+
+ (howToUrl ? `; a worked example is at ${howToUrl}` : ''),
|
|
2370
|
+
agentEntry: requirement,
|
|
2371
|
+
// The vendor spelling, byte-identical, kept for one release so a consumer that
|
|
2372
|
+
// learned this name still finds the terms. Dropped at the next MAJOR.
|
|
2373
|
+
muretai: requirement,
|
|
2374
|
+
},
|
|
2375
|
+
};
|
|
2376
|
+
card.security = [{ [SIGNED_ENVELOPE_SCHEME]: [] }];
|
|
2001
2377
|
|
|
2002
2378
|
const cardBytes = Buffer.from(JSON.stringify(card), 'utf8'); // identical bytes on both paths
|
|
2003
2379
|
// ACCOUNT DID -> {first_seen, last_seen, messages}. Keyed by the RESOLVED account (T102):
|
|
@@ -2015,6 +2391,14 @@ export function createAgentEntry({
|
|
|
2015
2391
|
const deviceOwner = new Map();
|
|
2016
2392
|
const replay = new ReplayGuard();
|
|
2017
2393
|
const anonRate = new RateBound(anonRatePerMin);
|
|
2394
|
+
// A tier is ON unless its ceiling is a non-positive or non-finite number. Constructed
|
|
2395
|
+
// rather than clamped, so "disabled" is one absent object and never a bound of 0 — which
|
|
2396
|
+
// RateBound reads as refuse-everything, the opposite of what an operator passing 0 means.
|
|
2397
|
+
const perMin = (n) => (Number.isFinite(Number(n)) && Number(n) > 0 ? Number(n) : 0);
|
|
2398
|
+
const signedAccountRate = perMin(signedRatePerMin)
|
|
2399
|
+
? new AccountRateBounds(perMin(signedRatePerMin), maxAccounts) : null;
|
|
2400
|
+
const signedTotalRate = perMin(signedRatePerMinTotal)
|
|
2401
|
+
? new RateBound(perMin(signedRatePerMinTotal)) : null;
|
|
2018
2402
|
let sigEnvelope = null;
|
|
2019
2403
|
let sigMintedAt = 0;
|
|
2020
2404
|
|
|
@@ -2091,15 +2475,26 @@ export function createAgentEntry({
|
|
|
2091
2475
|
return out;
|
|
2092
2476
|
}
|
|
2093
2477
|
|
|
2094
|
-
/** The
|
|
2095
|
-
*
|
|
2096
|
-
*
|
|
2097
|
-
*
|
|
2098
|
-
*
|
|
2099
|
-
*
|
|
2478
|
+
/** The `Link` header the notice route carries. TWO relations with different audiences,
|
|
2479
|
+
* in ONE header field (RFC 8288 allows several link-values in one field, and one field
|
|
2480
|
+
* is what keeps the two twins' bytes identical through their single-header plumbing):
|
|
2481
|
+
*
|
|
2482
|
+
* - the DOOR pointer, `rel="https://muretai.net/rel/agent-entry"`, for EVERY caller.
|
|
2483
|
+
* This is the coexistence primitive (E4): an agent that fetched a page finds the
|
|
2484
|
+
* machine-readable door in the RESPONSE, with no HTML to parse and no prose to
|
|
2485
|
+
* read, and a browser ignores it — which is what lets a site keep its own front
|
|
2486
|
+
* page and add ONE header instead of migrating. An absolute URI because RFC 8288
|
|
2487
|
+
* §2.1.2 permits a bare token only for an IANA-registered relation.
|
|
2488
|
+
* - `rel="service-desc"` (RFC 8631's registered relation for "service description …
|
|
2489
|
+
* primarily intended for consumption by machines"), FIRST and only for the UA
|
|
2490
|
+
* families that read as an AI agent — the T119 signpost, unchanged in meaning.
|
|
2491
|
+
*
|
|
2492
|
+
* HEADER-ONLY on purpose: the notice BODY is byte-identical for every caller, so what
|
|
2493
|
+
* the UA changes is still only this one additive relation and never a verdict. */
|
|
2100
2494
|
function steerHeaders(family) {
|
|
2101
|
-
|
|
2102
|
-
return { Link:
|
|
2495
|
+
const door = `<${mount}${AGENT_CARD_PATH}>; rel="${AGENT_ENTRY_REL}"`;
|
|
2496
|
+
if (!AI_AGENT_FAMILIES.has(family)) return { Link: door };
|
|
2497
|
+
return { Link: `<${mount}${AGENT_CARD_PATH}>; rel="service-desc", ${door}` };
|
|
2103
2498
|
}
|
|
2104
2499
|
|
|
2105
2500
|
/** Which stage a finished POST was, from OBSERVABLES only — the request bytes and the
|
|
@@ -2388,6 +2783,23 @@ export function createAgentEntry({
|
|
|
2388
2783
|
if (!from || !to || !sig) {
|
|
2389
2784
|
const bare = !from && !to && !sig;
|
|
2390
2785
|
if (!(anonymousLane && bare)) {
|
|
2786
|
+
// THE REFUSAL TEACHES (E2) — but only the keyless walk-in. `data` keeps the human
|
|
2787
|
+
// string it always carried, under `detail`, and gains `accepts[]`: the ways in,
|
|
2788
|
+
// as an array, each naming its scheme. A visitor that has only this can mint a
|
|
2789
|
+
// did:key, sign the six fields and be answered on its next POST — which is the
|
|
2790
|
+
// whole point, because refusing an agent for not having a key it was never told
|
|
2791
|
+
// how to make is the error, not the key.
|
|
2792
|
+
//
|
|
2793
|
+
// A PARTIAL envelope (from/to present, sig stripped) gets the old refusal
|
|
2794
|
+
// unchanged. It is a DOWNGRADE ATTEMPT, not a walk-in: whoever sent it already
|
|
2795
|
+
// holds a key and already knows the shape, so there is nothing to teach and no
|
|
2796
|
+
// reason to hand a prober a machine-readable map of what to try next.
|
|
2797
|
+
if (bare) {
|
|
2798
|
+
return rpcError(reqId, ERRORS.UNAUTHENTICATED, {
|
|
2799
|
+
detail: 'missing signing envelope (from/to/sig)',
|
|
2800
|
+
accepts: [requirement],
|
|
2801
|
+
});
|
|
2802
|
+
}
|
|
2391
2803
|
return rpcError(reqId, ERRORS.UNAUTHENTICATED, 'missing signing envelope (from/to/sig)');
|
|
2392
2804
|
}
|
|
2393
2805
|
// Anonymous: answer, signed by us, addressed to nobody. NO ledger row — an
|
|
@@ -2460,6 +2872,23 @@ export function createAgentEntry({
|
|
|
2460
2872
|
const account = acct.account;
|
|
2461
2873
|
const ownerDid = account !== from ? account : null;
|
|
2462
2874
|
|
|
2875
|
+
// 10. THE SIGNED LANE'S CEILING. Here and not earlier: before the signature a stranger
|
|
2876
|
+
// could spend somebody else's budget by naming them, and before `resolveAccount` an
|
|
2877
|
+
// owner's devices would each get their own. Here and not later: a refused flood must
|
|
2878
|
+
// grow neither the ledger nor whatever the responder costs.
|
|
2879
|
+
// PER-ACCOUNT FIRST, deliberately — one loud peer is then stopped by ITS OWN window
|
|
2880
|
+
// without drawing down the shared one, so it cannot starve everybody else on its way
|
|
2881
|
+
// to being refused. Neither refusal names its ceiling: a published number is a
|
|
2882
|
+
// calibration table telling a flood exactly how many keys to mint.
|
|
2883
|
+
if (signedAccountRate && !signedAccountRate.allow(account)) {
|
|
2884
|
+
return rpcError(reqId, ERRORS.RATE_LIMITED,
|
|
2885
|
+
'you are sending faster than this door answers — slow down and retry');
|
|
2886
|
+
}
|
|
2887
|
+
if (signedTotalRate && !signedTotalRate.allow()) {
|
|
2888
|
+
return rpcError(reqId, ERRORS.RATE_LIMITED,
|
|
2889
|
+
'this entry is at its ceiling right now — retry shortly');
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2463
2892
|
noteContact(account);
|
|
2464
2893
|
// T107: `wba_did` may legitimately differ from `peer_did` (the transport signer vs
|
|
2465
2894
|
// the message signer) — both facts are honest, and the schema says which is which.
|
|
@@ -2493,6 +2922,44 @@ export function createAgentEntry({
|
|
|
2493
2922
|
return pathname === mount || pathname === `${mount}/`;
|
|
2494
2923
|
}
|
|
2495
2924
|
|
|
2925
|
+
/** The GET routes this entry owns, built ONCE from the mount so a request path is a
|
|
2926
|
+
* lookup and never string arithmetic. On a GUEST mount the card is served at the
|
|
2927
|
+
* ORIGIN's well-known paths TOO — that is the only address a stranger's agent knows to
|
|
2928
|
+
* try (RFC 8615), and a door nobody can find is not a door. The card's `url` still names
|
|
2929
|
+
* the DOOR, which is ordinary A2A: the well-known location is where a card is
|
|
2930
|
+
* DISCOVERED, not the endpoint it describes. */
|
|
2931
|
+
const CARD_ROUTES = new Set([mount + AGENT_CARD_PATH, mount + AGENT_CARD_PATH_LEGACY]);
|
|
2932
|
+
const SIG_ROUTES = new Set([mount + AGENT_CARD_SIG_PATH]);
|
|
2933
|
+
if (guestMount) {
|
|
2934
|
+
CARD_ROUTES.add(AGENT_CARD_PATH).add(AGENT_CARD_PATH_LEGACY);
|
|
2935
|
+
SIG_ROUTES.add(AGENT_CARD_SIG_PATH);
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
/** What `Allow:` may truthfully say about THIS path, or null when the entry does not own
|
|
2939
|
+
* it at all. RFC 9110 §10.2.1 makes `Allow` a statement about the target RESOURCE, so a
|
|
2940
|
+
* single server-wide list is a wrong answer to a right question — and on a guest mount
|
|
2941
|
+
* it would be worse than wrong: answering for `/` at all, even with a 204 and a header,
|
|
2942
|
+
* is speaking for the site's own front page, which is the one thing a guest mount must
|
|
2943
|
+
* never do. Whatever the GET route does for an address we do not own (404), OPTIONS and
|
|
2944
|
+
* the method table do the same. */
|
|
2945
|
+
function allowFor(pathname) {
|
|
2946
|
+
if (CARD_ROUTES.has(pathname) || SIG_ROUTES.has(pathname)) return ALLOW_CARD;
|
|
2947
|
+
if (isMountPath(pathname)) return guestMount ? ALLOW_DOOR : ALLOW_MOUNT;
|
|
2948
|
+
return null;
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
/** The headers that state a resource's methods — BOTH spellings, always the same value.
|
|
2952
|
+
* `Allow` and `Access-Control-Allow-Methods` answer the same question for two different
|
|
2953
|
+
* readers, and a response that says `Allow: POST, OPTIONS` beside
|
|
2954
|
+
* `Access-Control-Allow-Methods: GET, POST, OPTIONS` contradicts itself in one message:
|
|
2955
|
+
* a browser-resident agent preflighting the guest door was told GET was on the menu at
|
|
2956
|
+
* the exact address whose GET is 405. `CORS_HEADERS` stays the origin-wide default
|
|
2957
|
+
* everywhere else (a card GET, a signed reply, a 404); it is narrowed only where the
|
|
2958
|
+
* resource's real method list is known, which is exactly where `Allow` is emitted. */
|
|
2959
|
+
function allowHeaders(allow) {
|
|
2960
|
+
return { Allow: allow, 'Access-Control-Allow-Methods': allow };
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2496
2963
|
function route(method, path, bodyBuffer, headers) {
|
|
2497
2964
|
// Classified ONCE per request, used only to count and to signpost. Everything the
|
|
2498
2965
|
// ladder decides is decided exactly as if this line did not exist.
|
|
@@ -2514,21 +2981,27 @@ export function createAgentEntry({
|
|
|
2514
2981
|
// appends a well-known path), so this is not a new convention; it is the one the
|
|
2515
2982
|
// fetcher already follows. With mount === '' these are the original constants.
|
|
2516
2983
|
if (method === 'GET' || method === 'HEAD') {
|
|
2517
|
-
if (pathname
|
|
2518
|
-
// Byte-identical on
|
|
2984
|
+
if (CARD_ROUTES.has(pathname)) {
|
|
2985
|
+
// Byte-identical on every path: the current A2A path, the legacy alias, and (on a
|
|
2986
|
+
// guest mount) the origin's well-known copy of both. Which address a client
|
|
2987
|
+
// happened to fetch must never change what it believes about this DID.
|
|
2519
2988
|
tally(family, 'card_get');
|
|
2520
2989
|
// T107: identify (count), never enrol, never change a byte. Runs only after a
|
|
2521
2990
|
// route MATCHED, so refused/404 paths never pay for crypto.
|
|
2522
2991
|
wbaObserve(headers);
|
|
2523
2992
|
return { status: 200, headers: cardHeaders(cardBytes.length), body: cardBytes };
|
|
2524
2993
|
}
|
|
2525
|
-
if (pathname
|
|
2994
|
+
if (SIG_ROUTES.has(pathname)) {
|
|
2526
2995
|
const env = cardEnvelopeBytes();
|
|
2527
2996
|
tally(family, 'card_get');
|
|
2528
2997
|
wbaObserve(headers);
|
|
2529
2998
|
return { status: 200, headers: cardHeaders(env.length), body: env };
|
|
2530
2999
|
}
|
|
2531
|
-
|
|
3000
|
+
// The human notice — NOT served on a guest mount, where GET belongs to the site (E3).
|
|
3001
|
+
// Falling through to 404 is deliberate and is the same non-disclosure the POST route
|
|
3002
|
+
// already makes: an entry beside other agents does not confirm what lives at an
|
|
3003
|
+
// address it was not given.
|
|
3004
|
+
if (!guestMount && isMountPath(pathname)) {
|
|
2532
3005
|
tally(family, 'notice_get');
|
|
2533
3006
|
wbaObserve(headers);
|
|
2534
3007
|
const body = Buffer.from(
|
|
@@ -2547,6 +3020,22 @@ export function createAgentEntry({
|
|
|
2547
3020
|
...steerHeaders(family) },
|
|
2548
3021
|
body };
|
|
2549
3022
|
}
|
|
3023
|
+
// A GUEST MOUNT'S DOOR ANSWERS GET WITH 405, NOT 404 (proof run 3, 2026-08-18).
|
|
3024
|
+
// Run A guessed the door path CORRECTLY, GET it, and was told nothing was there —
|
|
3025
|
+
// its one correct guess, refuted. This is NOT the
|
|
3026
|
+
// DECISION(non-door-post-answers-404-not-405) non-disclosure case, and the
|
|
3027
|
+
// difference is where the address came from: that decision protects a path a caller
|
|
3028
|
+
// GUESSED AT RANDOM, where a 405 would confirm a door it has no right to know about.
|
|
3029
|
+
// The door's address is PUBLISHED, in the card, signed, at a well-known path — so
|
|
3030
|
+
// hiding it from a GET conceals nothing from anyone and costs a visitor the one
|
|
3031
|
+
// thing it got right. `allowFor` is non-null here only for the guest door: card and
|
|
3032
|
+
// signature routes matched above, and a site-owning mount already returned its
|
|
3033
|
+
// notice. A path this entry does not own still falls through to 404, and a guest
|
|
3034
|
+
// mount still answers nothing it does not own.
|
|
3035
|
+
const getAllow = allowFor(pathname);
|
|
3036
|
+
if (getAllow !== null) {
|
|
3037
|
+
return jsonResponse(405, { error: 'method not allowed' }, allowHeaders(getAllow));
|
|
3038
|
+
}
|
|
2550
3039
|
return jsonResponse(404, { error: 'not found' });
|
|
2551
3040
|
}
|
|
2552
3041
|
if (method === 'POST') {
|
|
@@ -2564,12 +3053,23 @@ export function createAgentEntry({
|
|
|
2564
3053
|
tally(family, postStage(buf, out));
|
|
2565
3054
|
return out;
|
|
2566
3055
|
}
|
|
3056
|
+
// Everything below answers for a RESOURCE, so an address this entry does not own is a
|
|
3057
|
+
// 404 first — the same answer GET and POST give it. Before this, OPTIONS 204'd for
|
|
3058
|
+
// EVERY path (a guest mount would have spoken for the site's front page one verb over)
|
|
3059
|
+
// and the 405 carried no `Allow` at all, which RFC 9110 §15.5.6 REQUIRES.
|
|
3060
|
+
const allow = allowFor(pathname);
|
|
3061
|
+
if (allow === null) return jsonResponse(404, { error: 'not found' });
|
|
2567
3062
|
if (method === 'OPTIONS') {
|
|
3063
|
+
// The CORS PREFLIGHT lands here: a browser-resident agent POSTing application/json
|
|
3064
|
+
// is not a simple request, so this answer is what decides whether the POST is ever
|
|
3065
|
+
// sent. BOTH method statements narrow to the resource: `Allow` and
|
|
3066
|
+
// `Access-Control-Allow-Methods` are the same fact for two readers, and this is the
|
|
3067
|
+
// one response where the browser reader acts on it.
|
|
2568
3068
|
return { status: 204,
|
|
2569
|
-
headers: {
|
|
3069
|
+
headers: { 'Content-Length': '0', ...CORS_HEADERS, ...allowHeaders(allow) },
|
|
2570
3070
|
body: Buffer.alloc(0) };
|
|
2571
3071
|
}
|
|
2572
|
-
return jsonResponse(405, { error: 'method not allowed' });
|
|
3072
|
+
return jsonResponse(405, { error: 'method not allowed' }, allowHeaders(allow));
|
|
2573
3073
|
}
|
|
2574
3074
|
|
|
2575
3075
|
function cardHeaders(length) {
|