@muretai/agent-entry 1.1.0 → 1.2.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/README.md CHANGED
@@ -8,6 +8,9 @@ person replaces their phone, your site still knows it is them.
8
8
 
9
9
  One file. Zero dependencies. Node 20+.
10
10
 
11
+ Questions are welcome — mention [@muretaiai](https://x.com/muretaiai) on X, or
12
+ [open an issue](https://github.com/muretai/agent-entry/issues).
13
+
11
14
  ```js
12
15
  import { createAgentEntry } from '@muretai/agent-entry';
13
16
 
@@ -162,7 +165,15 @@ Tidied up for you: surrounding spaces, the case of the scheme and host, a defaul
162
165
 
163
166
  Refused, with the fix in the message: a scheme other than `http`/`https`, a missing host,
164
167
  `user@host`, a query string, a `#` fragment, non-ASCII characters, a stray tab or space, a
165
- backslash, `.` or `..` in the path, a broken `%` escape, and a port outside 1–65535.
168
+ backslash, `.` or `..` in the path **including their `%2e` spellings**, a broken `%` escape,
169
+ and a port outside 1–65535.
170
+
171
+ > **Upgrading from 1.1.x?** The `%2e` rule is new. A `baseUrl` like
172
+ > `https://shop.example/a/%2e%2e/support` used to start on the Python reference and now
173
+ > refuses on both — because a browser's URL parser removes those segments and Python's does
174
+ > not, so the address you publish and the address a visitor computes were already two
175
+ > different things. The refusal names the string to paste instead. **Check your `baseUrl`
176
+ > before you deploy:** this turns a running entry into one that will not boot.
166
177
 
167
178
  Two rules worth knowing before you pick a URL:
168
179
 
@@ -173,6 +184,87 @@ Two rules worth knowing before you pick a URL:
173
184
  parser punycodes a host and Python's does not, so the two implementations would otherwise
174
185
  sign different bytes for the same site.
175
186
 
187
+ ## One host, many agents
188
+
189
+ A domain can hold a **fleet** — a front desk, support, sales — each its own agent, its own
190
+ key, its own address, each contactable directly. Give each one a `baseUrl` that carries its
191
+ path:
192
+
193
+ ```js
194
+ createAgentEntry({ seedHex: SUPPORT_SEED, name: 'Support',
195
+ baseUrl: 'https://studio.example/support', responder });
196
+ ```
197
+
198
+ Every route then hangs off that path — `GET /support/.well-known/agent-card.json`, the
199
+ signed envelope beside it, and `POST /support` — and **the bare host is a 404 for that
200
+ entry**. On a shared host the bare host belongs to your site or to a neighbour, and an entry
201
+ that answered there would be answering for someone else.
202
+
203
+ The mount is **derived from `baseUrl`**, never configured beside it, so the address the
204
+ router answers on and the address the signed card claims are the same string by
205
+ construction. Two settings would let you spell them differently, and that produces the worst
206
+ error message this system has: every visitor fails with *"cannot prove that … owns …"* and
207
+ nothing says why.
208
+
209
+ A visitor handed `https://studio.example/support` reaches support and **only** support. If
210
+ sales re-served support's genuine, correctly-signed envelope at `/sales`, the visitor
211
+ refuses it — the signature is real, but the signed address says `/support` and the visitor
212
+ dialled `/sales`. That is what lets two agents share a hostname safely.
213
+
214
+ Routing a fleet with nginx — pass the prefix **through** (no trailing slash on `proxy_pass`)
215
+ so each entry sees the path its card claims:
216
+
217
+ ```nginx
218
+ location /support/ { proxy_pass http://127.0.0.1:8788; }
219
+ location = /support { proxy_pass http://127.0.0.1:8788; }
220
+ location /sales/ { proxy_pass http://127.0.0.1:8789; }
221
+ location = /sales { proxy_pass http://127.0.0.1:8789; }
222
+ ```
223
+
224
+ If your proxy **strips** the prefix instead (`proxy_pass http://127.0.0.1:8788/` — note the
225
+ trailing slash), pass `basePath: ''`. That is the one override, and it may only be `''` or
226
+ exactly the path `baseUrl` already names; anything else refuses at startup, because a third
227
+ spelling of your address is the thing this design exists to prevent.
228
+
229
+ Inside one Express app, use `req.originalUrl` — never `req.url`, which a mounted router has
230
+ already stripped:
231
+
232
+ ```js
233
+ const fwd = (entry) => async (req, res) => {
234
+ const r = await entry.handleRequestAsync(req.method, req.originalUrl, req.headers, req.body);
235
+ res.status(r.status).set(r.headers).send(r.body);
236
+ };
237
+ ```
238
+
239
+ ## Which domains this entry speaks for
240
+
241
+ An entry can name the domains it belongs to:
242
+
243
+ ```js
244
+ createAgentEntry({ seedHex, baseUrl: 'https://studio.example',
245
+ domains: ['studio.example'], responder });
246
+ ```
247
+
248
+ This is **one half of a two-sided proof**, and it is worth being clear about what each half
249
+ does. Your card says "I speak for studio.example". The domain says, in a
250
+ `/.well-known/did-configuration.json` it serves, "this DID speaks for me". A verifier accepts
251
+ the binding only when **both** halves agree — so neither a domain that lists a DID it does
252
+ not own, nor an agent that claims a domain it has never touched, proves anything alone. And
253
+ either side can withdraw: the domain owner deletes one line from a file they already control,
254
+ and that agent — and only that agent — stops verifying.
255
+
256
+ That is why a domain may name many agents. Revoking one is a one-line edit, not a migration.
257
+
258
+ Names are checked at startup: a bare host, at least two labels, ASCII only, an optional
259
+ `:port`, at most five of them. Anything else — a scheme, a path, a stray space, an empty
260
+ entry from a trailing comma — **refuses to start**. So does naming more than five, rather
261
+ than quietly publishing the first five: a claim that is usable and is not what you said is
262
+ worse than a refusal you can read.
263
+
264
+ Naming no domain is the default and publishes exactly what 1.1.x did.
265
+
266
+ Set it from the environment with `AGENT_ENTRY_DOMAINS=studio.example,support.studio.example`.
267
+
176
268
  ## Pairs with WebMCP: the tab conversation becomes a customer
177
269
 
178
270
  If your page already exposes [WebMCP](https://github.com/MiguelsPizza/WebMCP) tools, you have
@@ -231,18 +323,62 @@ the source:
231
323
  seconds; if your site needs that speed, put the check in the backend your `responder`
232
324
  calls.
233
325
 
326
+ What the entry now handles for you at the HTTP layer, so you do not have to:
327
+
328
+ - **A stranger always gets an HTTP response.** Never a silently closed socket, whatever they
329
+ send. A request that stalls gets `408`; past a connection ceiling a new one gets `503`.
330
+ - **Slow-drip connections cannot pile up.** Headers, body and idle keep-alives each have a
331
+ wall-clock bound. A socket timeout alone does not stop this: a caller sending one byte per
332
+ interval resets it forever, and the read only ends when it has everything it asked for.
333
+ - **Ambiguous framing is refused, not guessed.** A repeated `Content-Length`, a
334
+ `Content-Length` alongside `Transfer-Encoding`, or a length that is not plain digits is a
335
+ `400`. Those are the shapes that make a proxy and an origin disagree about where one
336
+ request ends and the next begins. Chunked bodies on their own are accepted and decoded,
337
+ bounded by the same limits, because a reverse proxy may legitimately re-frame a request.
338
+ - **The body must be real UTF-8.** Invalid bytes are refused rather than silently replaced,
339
+ so the two implementations cannot disagree about what you were sent.
340
+ - **The request target must be in origin form.** `POST /` — not
341
+ `POST https://elsewhere.example/`. This is a deliberate departure from RFC 9112 §3.2.2,
342
+ which says a server must accept the absolute form: this endpoint answers exactly the
343
+ address its card names, and the refusal says so.
344
+
234
345
  ## Two implementations, pinned to each other
235
346
 
236
- `examples/reference.py` is the Python reference. It is not a port the two are held
237
- byte-identical by an acceptance suite that runs the same attack battery against both,
238
- drives this module against `testdata/wire_vectors.json`, posts identical bytes to each,
239
- and requires identical verdicts. If you write a third implementation, that suite is the
240
- gate.
347
+ This module is not alone. A Python reference implements the same contract, and the two are
348
+ held to **identical verdicts** by an acceptance suite: it runs the same attack battery
349
+ against both, drives this module against `testdata/wire_vectors.json` byte for byte, posts
350
+ identical bytes to each over real sockets down to the HTTP framing — and requires the same
351
+ status, the same account outcome and the same signed reply from both. If you write a third
352
+ implementation, that suite is the gate.
241
353
 
242
354
  The bytes are the contract: every signed payload must match Python's canonical JSON
243
355
  exactly, or a signature is unverifiable and the only diagnostic anyone gets is
244
356
  "signature verification failed".
245
357
 
358
+ **What ships here, and what does not.** This repo is the **site side**: the door a website runs.
359
+ It is one file, it depends on nothing, and it carries everything it needs including its own
360
+ Ed25519.
361
+
362
+ The other implementation is a Python one, and it lives with Muretai core, where it is the
363
+ executable specification the acceptance suite drives. It is not published here on purpose. A
364
+ door needs a signer, a card, a binding verifier and a domain-name check; core's copy reaches
365
+ for a URL guard, a JWS minter and a release module that a door never touches, and shipping
366
+ those here would put the *visiting-agent* and *node* sides of the network into an artifact that
367
+ is only ever the site side.
368
+
369
+ The visiting side needs nothing from this package either: an agent already has a runtime — a
370
+ Muretai node, or whatever framework it runs on — and that is what knocks on your door.
371
+
372
+ So the two implementations share no code at all, by design. **What holds them to identical
373
+ verdicts is the acceptance suite, not a shared library** — which is the honest arrangement,
374
+ because a shared library would only ever have covered the parts they happen to share. The
375
+ suite posts identical bytes to both, down to the HTTP framing, and requires the same status,
376
+ the same account outcome and the same signed reply.
377
+
378
+ `testdata/wire_vectors.json` is the part of that gate you can run here: it pins the canonical
379
+ JSON, the signing payloads, the card envelopes and the did:key round-trips this module must
380
+ reproduce byte for byte.
381
+
246
382
  ## What this is part of
247
383
 
248
384
  [Muretai](https://muretai.com) is a network where AI agents that belong to *different
@@ -253,4 +389,13 @@ that has to stay awake.
253
389
  You do not need the rest of the network to use this file. It is useful on its own the
254
390
  moment an agent knocks.
255
391
 
392
+ ## Questions
393
+
394
+ Ask — there is no wrong question about this, and the answers usually improve the docs.
395
+
396
+ - **X:** [@muretaiai](https://x.com/muretaiai) — mention us, we read them
397
+ - **Issues:** [github.com/muretai/agent-entry/issues](https://github.com/muretai/agent-entry/issues)
398
+ - **Security:** please report privately first, at
399
+ [muretai.com/.well-known/security.txt](https://muretai.com/.well-known/security.txt)
400
+
256
401
  MIT.
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * **This is a usage SAMPLE, not part of core Muretai.** It adds nothing to the protocol:
6
6
  * it only wires the public primitive `createAgentEntry()` from
7
- * `muretai-agent-entry.mjs` to a demo booking desk. Copy it, gut the responder,
7
+ * `web/agent-entry/muretai-agent-entry.mjs` to a demo booking desk. Copy it, gut the responder,
8
8
  * point it at your backend — core stays byte-unchanged.
9
9
  *
10
10
  * Run it:
@@ -20,18 +20,43 @@
20
20
  * into the Agent Card and a visitor requires the card to name the
21
21
  * origin it dialled — behind a proxy or a tunnel, set this to the
22
22
  * public URL or every verification fails. Default http://127.0.0.1:<port>.
23
+ * It MAY include a path (https://example.com/support): the entry then
24
+ * answers THERE and 404s the bare host, so one hostname can hold
25
+ * several agents — a front desk, support, sales — each its own key.
26
+ * AGENT_ENTRY_DOMAINS comma-separated bare domains this entry speaks for, e.g.
27
+ * "example.com". Half a proof: the domain must ALSO serve a
28
+ * credential naming this DID at /.well-known/did-configuration.json,
29
+ * or a verifier correctly reports the agent withdrew its side.
30
+ * At most 5, and every segment must be a bare domain — a doubled or
31
+ * trailing comma REFUSES to start rather than quietly publishing
32
+ * one name fewer than you wrote.
23
33
  * AGENT_ENTRY_NAME public display name on the card
24
34
  * AGENT_ENTRY_HOST bind address (default 127.0.0.1 — set 0.0.0.0 only behind TLS)
25
35
  * AGENT_ENTRY_ANON "1" also accepts UNSIGNED walk-in inquiries (they mint no account)
26
36
  */
27
37
 
28
- import { createAgentEntry, newSeedHex, didFromSeedHex, AGENT_CARD_PATH }
38
+ import { createAgentEntry, newSeedHex, didFromSeedHex, trimOuter, AGENT_CARD_PATH }
29
39
  from '../muretai-agent-entry.mjs';
30
40
 
31
41
  const port = Number(process.env.AGENT_ENTRY_PORT || 8788);
32
42
  const host = process.env.AGENT_ENTRY_HOST || '127.0.0.1';
33
43
  const baseUrl = process.env.AGENT_ENTRY_BASE_URL || `http://127.0.0.1:${port}`;
34
44
  const name = process.env.AGENT_ENTRY_NAME || 'Example Studio';
45
+ // An absent (or blank) variable means NO domains at all, and the card then carries no
46
+ // `domains` key. Anything else is split on ',' and every segment is handed on AS WRITTEN:
47
+ // an EMPTY segment (`a,,b`, or a trailing comma) is REFUSED by createAgentEntry, never
48
+ // skipped. A name lost in an edit looks exactly like a harmless typo, and starting with
49
+ // fewer domains than the operator named is the same silent mismatch `canonicalBaseUrl`
50
+ // refuses one field over. Must match the split rule in examples/agent_entry_reference.py.
51
+ //
52
+ // "Blank" is `trimOuter` — the intersection `canonicalDomains` folds with — and NOT
53
+ // `trim()`, which is where the two runners drifted apart: `trim()` also removes U+FEFF and
54
+ // Python's `strip()` also removes \x1c-\x1f and U+0085, so the SAME variable got two
55
+ // verdicts. Measured: `AGENT_ENTRY_DOMAINS="\x1c"` started the Python runner with no
56
+ // domains and made this one exit 2; a BOM — what a paste out of a spreadsheet or a Windows
57
+ // .env carries — did exactly the reverse. One fold, one verdict.
58
+ const rawDomains = process.env.AGENT_ENTRY_DOMAINS || '';
59
+ const domains = trimOuter(rawDomains) ? rawDomains.split(',') : [];
35
60
 
36
61
  let seedHex = process.env.AGENT_ENTRY_SEED_HEX;
37
62
  if (!seedHex) {
@@ -47,7 +72,7 @@ if (!seedHex) {
47
72
  * both. In production: POST `env` to your app behind a bearer token and return its answer
48
73
  * (a string, or {text}). It may be async. Treat `env.text` as untrusted DATA. */
49
74
  function responder(env) {
50
- // Say out loud what just happened. A agent entry's whole claim is "the first signed message IS
75
+ // Say out loud what just happened. An agent entry's whole claim is "the first signed message IS
51
76
  // the account", and that is invisible if the ledger only lives in memory: an operator
52
77
  // watching this log is how you SEE a stranger's identity appear, and how you tell an
53
78
  // anonymous walk-in (no account) from a verified first contact (an account) at a glance.
@@ -76,21 +101,37 @@ function responder(env) {
76
101
  + 'tell you what is open.';
77
102
  }
78
103
 
79
- const entry = createAgentEntry({
80
- seedHex,
81
- name,
82
- baseUrl,
83
- description: 'Books photo shoots. Send a signed message; you get a signed answer.',
84
- responder,
85
- openDoor: true, // "you may contact me, no introduction"
86
- anonymousLane: process.env.AGENT_ENTRY_ANON === '1',
87
- });
104
+ // A bad AGENT_ENTRY_BASE_URL or AGENT_ENTRY_DOMAINS throws HERE, before the socket is
105
+ // bound: the entry never starts and never publishes a claim no visitor could use. The
106
+ // message names the value and what to paste instead, so print it plainly — a stack trace
107
+ // tells a site operator nothing.
108
+ let entry;
109
+ try {
110
+ entry = createAgentEntry({
111
+ seedHex,
112
+ name,
113
+ baseUrl,
114
+ domains,
115
+ description: 'Books photo shoots. Send a signed message; you get a signed answer.',
116
+ responder,
117
+ openDoor: true, // "you may contact me, no introduction"
118
+ anonymousLane: process.env.AGENT_ENTRY_ANON === '1',
119
+ });
120
+ } catch (err) {
121
+ console.error(err && err.message ? err.message : String(err));
122
+ process.exit(2);
123
+ }
88
124
 
89
125
  const server = entry.listen(port, host, () => {
90
- console.log(`${name} is agent-reachable on ${baseUrl}`);
126
+ console.log(`${name} is agent-reachable on ${entry.card.url}`);
91
127
  console.log(` DID: ${entry.did}`);
92
- console.log(` Card: ${baseUrl.replace(/\/+$/, '')}${AGENT_CARD_PATH}`);
93
- console.log(` Listening on ${host}:${port} — POST a signed message/send to /`);
128
+ console.log(` Card: ${entry.card.url}${AGENT_CARD_PATH}`);
129
+ if (entry.card.domains) {
130
+ console.log(` Speaking for: ${entry.card.domains.join(', ')} — each domain must serve a `
131
+ + 'credential naming this DID at /.well-known/did-configuration.json');
132
+ }
133
+ console.log(` Listening on ${host}:${port} — POST a signed message/send to `
134
+ + `${entry.mount || '/'}`);
94
135
  });
95
136
 
96
137
  // A port collision is the first thing anyone running this twice hits (a previous run that was
@@ -54,6 +54,12 @@ 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
+ /** How many domains one card may advertise (agent/domainstore.MAX_CARD_DOMAINS, and the
58
+ * same ceiling shared/protocol.build_agent_card applies to a node's card). Every name
59
+ * listed is an outbound HTTPS fetch this entry asks strangers to make, so the cap bounds
60
+ * the work an entry can push onto its visitors — not how many domains a site may own.
61
+ * Must match `MAX_CARD_DOMAINS` in examples/agent_entry_reference.py. */
62
+ export const MAX_CARD_DOMAINS = 5;
57
63
 
58
64
  export const AGENT_CARD_PATH = '/.well-known/agent-card.json';
59
65
  export const AGENT_CARD_PATH_LEGACY = '/.well-known/agent.json';
@@ -796,6 +802,20 @@ class RateBound {
796
802
  }
797
803
  }
798
804
 
805
+ /**
806
+ * An unpaired UTF-16 surrogate — a string with no UTF-8 encoding at all.
807
+ *
808
+ * `"\ud800"` is legal JSON and both parsers accept it, but Python's `.encode("utf-8")`
809
+ * RAISES on it while a JavaScript Buffer quietly substitutes U+FFFD. Measured: a one-shot
810
+ * POST carrying `"text": "\ud800"` from a stranger with no key killed the Python reference
811
+ * entry's request with no HTTP response, and was answered -32001 here — same bytes, two
812
+ * verdicts, one of them a dead socket. It is not text; the shape gate refuses it on both.
813
+ *
814
+ * Written without the `u` flag on purpose: in a Unicode-mode pattern these ranges are not
815
+ * matchable as isolated code units, which is exactly what has to be matched here.
816
+ */
817
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/;
818
+
799
819
  /**
800
820
  * The STRICT type check on the fields that end up inside the signed payload. Returns a
801
821
  * reason string, or null when the shape is acceptable.
@@ -809,6 +829,16 @@ class RateBound {
809
829
  * JavaScript renders it `1` and Python renders it `1.0`, so exactly one of them can verify
810
830
  * the signature — and we would then ECHO it into our own signed reply.
811
831
  *
832
+ * Also here, and for the same reason, are the ENVELOPE fields — `metadata` and the
833
+ * `from`/`to`/`sig` inside it. Step 4 below can only ask "is it there", and a wrongly-typed
834
+ * one answered that question WRONG on both twins in opposite directions: `metadata: "x"`
835
+ * read as an EMPTY envelope here (so -32001, or — with the anonymous lane on — a SIGNED
836
+ * ANONYMOUS REPLY, a malformed envelope silently downgraded to a walk-in) while the Python
837
+ * reference answered -32600; and `metadata.to = 1` survived Python's presence test and
838
+ * reached `to_did[:24]`, a TypeError that closed the socket with no HTTP response at all.
839
+ * A partial or malformed envelope is never an absent one — that rule is already written
840
+ * down for a stripped `sig` in docs/AGENT_ENTRY.md, and it holds for the type too.
841
+ *
812
842
  * -32600 (Invalid Request) for all of them: a wrongly-typed field is a malformed request,
813
843
  * not a failed signature. `examples/agent_entry_reference.py::_wire_shape_error` answers the
814
844
  * same code for the same input, case for case.
@@ -825,14 +855,49 @@ function wireShapeError(msg) {
825
855
  if ('text' in part && typeof part.text !== 'string') {
826
856
  return 'a text part\'s `text` must be a string';
827
857
  }
858
+ if (typeof part.text === 'string' && LONE_SURROGATE.test(part.text)) {
859
+ return 'a text part\'s `text` is not encodable UTF-8 (a lone surrogate)';
860
+ }
828
861
  }
829
862
  if (typeof msg.messageId !== 'string' || !msg.messageId) {
830
863
  return 'messageId must be a non-empty string';
831
864
  }
865
+ if (LONE_SURROGATE.test(msg.messageId)) {
866
+ return 'messageId is not encodable UTF-8 (a lone surrogate)';
867
+ }
832
868
  if (msg.contextId !== undefined && msg.contextId !== null
833
869
  && typeof msg.contextId !== 'string') {
834
870
  return 'contextId must be a string or null';
835
871
  }
872
+ if (typeof msg.contextId === 'string' && LONE_SURROGATE.test(msg.contextId)) {
873
+ return 'contextId is not encodable UTF-8 (a lone surrogate)';
874
+ }
875
+ // The ENVELOPE fields. `null` reads as ABSENT (the stripped-sig case the ladder answers
876
+ // -32001 for, and the shape of a walk-in on the anonymous lane); PRESENT-but-not-a-string
877
+ // is a malformed request and is never an absent envelope. `typeof null === 'object'` and
878
+ // an Array is an object too, so both are excluded explicitly.
879
+ const meta = msg.metadata;
880
+ if (meta !== undefined && meta !== null
881
+ && (typeof meta !== 'object' || Array.isArray(meta))) {
882
+ return 'metadata must be an object';
883
+ }
884
+ for (const field of ['from', 'to', 'sig']) {
885
+ const v = (meta ?? {})[field];
886
+ if (v === undefined || v === null) continue;
887
+ if (typeof v !== 'string') return `metadata.${field} must be a string`;
888
+ if (LONE_SURROGATE.test(v)) {
889
+ return `metadata.${field} is not encodable UTF-8 (a lone surrogate)`;
890
+ }
891
+ }
892
+ // LAST, because the Python reference reaches the equivalent refusal last: its shape gate
893
+ // runs and THEN `Message.from_a2a` raises. `kind: "message"` is what makes this an A2A
894
+ // Message rather than some other object that happens to carry a `parts` array, and
895
+ // `shared/protocol.py::from_a2a` has always required it — while this file had no check at
896
+ // all. Measured with a valid signature and a fresh timestamp: a message with `kind`
897
+ // removed was ACCEPTED here, BOOKED AN ACCOUNT and got a signed reply, where the reference
898
+ // answered -32600. A muretai NODE refuses the same bytes, so accepting them would also
899
+ // mean the entry tier and the node tier disagree about what an A2A message is.
900
+ if (msg.kind !== 'message') return 'not an A2A message object';
836
901
  return null;
837
902
  }
838
903
 
@@ -846,6 +911,61 @@ const CORS_HEADERS = {
846
911
  'Access-Control-Max-Age': '600',
847
912
  };
848
913
 
914
+ /** The refusal text for a request-target that is not in origin form. Shared with
915
+ * `ORIGIN_FORM_ONLY` in examples/agent_entry_reference.py so the two twins return the same
916
+ * diagnostic for the same request. */
917
+ const ORIGIN_FORM_ONLY = 'the request-target must be an origin-form path: this entry answers '
918
+ + 'exactly the address its card names, and that address has no other spelling';
919
+
920
+ /** Transport bounds. Node bounds the first three by DEFAULT (300 s / 60 s / 5 s) and the
921
+ * Python reference bounded NONE, which made it the only tier a stranger could wedge with no
922
+ * key and no request body — 40 trickle connections took it from 2 threads to 42 with 0
923
+ * responses. These are the numbers BOTH twins now use, spelled here so the two files can be
924
+ * read against each other: `AgentEntry.HEADER_TIMEOUT` / `BODY_BUDGET` /
925
+ * `KEEPALIVE_TIMEOUT` / `MAX_CONNECTIONS`. */
926
+ const HEADERS_TIMEOUT_MS = 20_000;
927
+ const REQUEST_TIMEOUT_MS = 20_000;
928
+ const KEEPALIVE_TIMEOUT_MS = 5_000;
929
+ const MAX_CONNECTIONS = 64;
930
+
931
+ /** What the (MAX_CONNECTIONS+1)-th connection is answered with, byte for byte the same
932
+ * response `_BoundedThreadingHTTPServer` writes in the Python reference. */
933
+ const OVERLOADED_BODY = '{"error":"too many concurrent connections"}';
934
+ const OVERLOADED_RESPONSE = 'HTTP/1.1 503 Service Unavailable\r\n'
935
+ + 'Content-Type: application/json; charset=utf-8\r\n'
936
+ + `Content-Length: ${OVERLOADED_BODY.length}\r\n`
937
+ + 'Connection: close\r\n\r\n' + OVERLOADED_BODY;
938
+
939
+ /** The stdlib's STRICT UTF-8 decoder: it THROWS on an invalid byte instead of substituting
940
+ * U+FFFD, which is what `Buffer.toString('utf8')` does and what let a body neither
941
+ * implementation could agree about reach the ladder. Node global since v11 — no dependency. */
942
+ const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
943
+
944
+ /**
945
+ * The JSON-RPC `id` we may ECHO, or null.
946
+ *
947
+ * JSON-RPC 2.0 says an id is a String, a Number or Null — never an object or an array — and
948
+ * this enforces exactly that, for a reason larger than pedantry: the id is the ONE field no
949
+ * signature covers and it is written straight back out, so whatever the two runtimes
950
+ * disagree about here becomes a disagreement about the whole response. Two measured cases,
951
+ * both closed by refusing the SHAPE rather than the instance:
952
+ * - `id = {"x":"\ud800"}`. `JSON.stringify` escapes the lone surrogate happily; Python's
953
+ * `p.dumps` RAISES, so the reference booked the account and then died in serialisation
954
+ * (HTTP 500, no reply, customer on the books) while this file answered 200 and signed.
955
+ * - a number outside ±2^53. `10**400` arrives here as `Infinity` and re-serialises as
956
+ * `null`, while Python echoes the bigint verbatim.
957
+ * `null` is what JSON-RPC allows for an unusable id, and it keeps the VERDICT — not the
958
+ * echo — as the thing the two implementations have to agree on.
959
+ * Mirrors `AgentEntry._safe_id` in examples/agent_entry_reference.py.
960
+ */
961
+ function safeId(id) {
962
+ if (typeof id === 'string') return LONE_SURROGATE.test(id) ? null : id;
963
+ if (typeof id === 'number') {
964
+ return (Number.isFinite(id) && Math.abs(id) <= 2 ** 53) ? id : null;
965
+ }
966
+ return null;
967
+ }
968
+
849
969
  function jsonResponse(status, obj, extraHeaders = {}) {
850
970
  const body = Buffer.from(JSON.stringify(obj), 'utf8');
851
971
  return {
@@ -883,12 +1003,30 @@ const HEX = new Set('0123456789abcdefABCDEF');
883
1003
  const HOST_OK = new Set('abcdefghijklmnopqrstuvwxyz0123456789.-_');
884
1004
  const DEFAULT_PORT = { http: 80, https: 443 };
885
1005
 
1006
+ /**
1007
+ * `'.'` or `'..'` if this path segment is a dot segment AS THE URL PARSERS SEE IT, else null.
1008
+ *
1009
+ * Raw `.` and `..` are the obvious spellings; `new URL()` ALSO removes `%2e`, `%2E` and every
1010
+ * mixture (`.%2e`, `%2e.`, `%2e%2e`), and Python's `urlsplit` removes none of them. A segment
1011
+ * test written against the raw text therefore lets exactly that family through: measured,
1012
+ * `https://shop.example/a/%2e%2e/support` started and published on the Python side, and was
1013
+ * refused here only by the `new URL()` tripwire at the bottom of canonicalBaseUrl — which
1014
+ * announced "a bug in this file, not in your input" for an input problem with a paste-able
1015
+ * fix. Decoding just this one escape (never the whole segment — `%41` must stay `%41`, it is
1016
+ * a different path) makes both implementations refuse the same family, for the right reason.
1017
+ */
1018
+ function dotSegment(seg) {
1019
+ const decoded = seg.replaceAll('%2e', '.').replaceAll('%2E', '.');
1020
+ return (decoded === '.' || decoded === '..') ? decoded : null;
1021
+ }
1022
+
886
1023
  /** Raise the one refusal shape, in the order an operator can act on at 2am: what they gave
887
1024
  * (JSON-quoted, so an invisible tab is VISIBLE), which rule in words, the fix as a string
888
- * they can paste, and one clause of why. `base_url is not publishable:` is the greppable
889
- * stem the Python twin shares. */
890
- function refuseBaseUrl(given, rule, why, fix) {
891
- const lines = [`base_url is not publishable: ${rule}`, ` given: ${JSON.stringify(given)}`];
1025
+ * they can paste, and one clause of why. `<field> is not publishable:` is the greppable
1026
+ * stem the Python twin shares — `base_url` for the address, `domains` for the names this
1027
+ * entry claims to speak for, `base_path` for the mount override. */
1028
+ function refuseBaseUrl(given, rule, why, fix, field = 'base_url') {
1029
+ const lines = [`${field} is not publishable: ${rule}`, ` given: ${JSON.stringify(given)}`];
892
1030
  if (fix) lines.push(` use: ${JSON.stringify(fix)}`);
893
1031
  lines.push(` ${why}`);
894
1032
  throw new TypeError(lines.join('\n'));
@@ -1045,17 +1183,19 @@ export function canonicalBaseUrl(baseUrl, { warn = true } = {}) {
1045
1183
  i += 1;
1046
1184
  }
1047
1185
  const parts = path.split('/');
1048
- if (parts.some((seg) => seg === '.' || seg === '..')) {
1186
+ if (parts.some((seg) => dotSegment(seg) !== null)) {
1049
1187
  const segs = []; // RFC 3986 remove_dot_segments, for the fix
1050
1188
  for (const seg of parts) {
1051
- if (seg === '.') continue;
1052
- if (seg === '..') { if (segs.length > 1) segs.pop(); continue; }
1189
+ const dot = dotSegment(seg);
1190
+ if (dot === '.') continue;
1191
+ if (dot === '..') { if (segs.length > 1) segs.pop(); continue; }
1053
1192
  segs.push(seg);
1054
1193
  }
1055
- refuseBaseUrl(s, "the path contains '.' or '..' segments.",
1056
- `${scheme}://${authority}${segs.join('/').replace(/\/+$/, '')}`,
1057
- 'JavaScript collapses these segments and Python does not, so the two Agent Entry '
1058
- + 'implementations would sign different bytes.');
1194
+ refuseBaseUrl(s, "the path contains '.' or '..' segments "
1195
+ + "(the percent-encoded spellings '%2e' and '%2E' count).",
1196
+ `${scheme}://${authority}${segs.join('/').replace(/\/+$/, '')}`,
1197
+ 'JavaScript collapses these segments — encoded ones included — and Python does not, '
1198
+ + 'so the two Agent Entry implementations would sign different bytes.');
1059
1199
  }
1060
1200
  }
1061
1201
 
@@ -1065,14 +1205,24 @@ export function canonicalBaseUrl(baseUrl, { warn = true } = {}) {
1065
1205
  // The tripwire. Not redundant with the hand-parse: it is what turns a future Node/WHATWG
1066
1206
  // change into a loud startup failure instead of silent byte drift. If this ever fires,
1067
1207
  // the hand-parse and the platform parser have diverged on an input the gate let through.
1208
+ //
1209
+ // It must NOT claim to know which of the two is at fault. It used to open with "a bug in
1210
+ // this file, not in your input" and send the operator to an issue tracker — and the input
1211
+ // that actually fired it was `https://shop.example/a/%2e%2e/support`, an encoded dot
1212
+ // segment, which is an input problem with a paste-able fix (now refused above, by name).
1213
+ // A tripwire sees a disagreement, not a culprit. Name both, input first.
1068
1214
  const probe = new URL(out);
1069
1215
  if (probe.origin !== originOut || probe.pathname !== (path || '/')
1070
1216
  || probe.search || probe.hash) {
1071
1217
  throw new TypeError(
1072
- `base_url canonicalisation is broken (a bug in this file, not in your input): `
1073
- + `${JSON.stringify(baseUrl)} -> ${JSON.stringify(out)}, which this runtime's URL `
1074
- + `parser reads as ${JSON.stringify(probe.origin + probe.pathname)}. `
1075
- + `Please report this at https://github.com/muretai/agent-entry/issues`);
1218
+ `base_url is not publishable: canonicalising it produces a value this runtime's URL `
1219
+ + `parser reads differently.\n`
1220
+ + ` given: ${JSON.stringify(baseUrl)}\n`
1221
+ + ` this file canonicalises it to ${JSON.stringify(out)}, which the URL parser reads `
1222
+ + `as ${JSON.stringify(probe.origin + probe.pathname)}.\n`
1223
+ + ` Check the address first — a spelling this gate does not know how to fold lands `
1224
+ + `here. If it is an ordinary http(s) URL with no unusual escaping, this is a bug in `
1225
+ + `this file: please report it at https://github.com/muretai/agent-entry/issues`);
1076
1226
  }
1077
1227
 
1078
1228
  if (warn) {
@@ -1098,6 +1248,217 @@ export function canonicalBaseUrl(baseUrl, { warn = true } = {}) {
1098
1248
  return out;
1099
1249
  }
1100
1250
 
1251
+ // ---------------------------------------------------------------- domains + the mount
1252
+
1253
+ /** The outer whitespace BOTH languages strip identically. Python's `str.strip()` also
1254
+ * removes \x1c-\x1f, U+0085 and U+00A0; JavaScript's `trim()` removes a different tail of
1255
+ * Unicode spaces. Folding only this intersection — and refusing every other character
1256
+ * outside 0x21..0x7E — is what stops the twins accepting different strings for the same
1257
+ * operator input. Must match `_OUTER_WS` in examples/agent_entry_reference.py. */
1258
+ const OUTER_WS = ' \t\n\r\f\v';
1259
+ /** Characters that betray a URL, an authority or whitespace smuggling where a bare domain
1260
+ * was expected (shared/domainbind._NOT_IN_DOMAIN). */
1261
+ const NOT_IN_DOMAIN = ['/', '?', '#', '@', '\\', ' ', '\t', '\r', '\n', '%', '[', ']'];
1262
+ const LDH = new Set('abcdefghijklmnopqrstuvwxyz0123456789-');
1263
+ /** RFC 1035 total length of a domain name, applied to the HOST only, plus the longest
1264
+ * legal ":<port>" for the raw-input bound (shared/domainbind.MAX_DOMAIN_LEN). */
1265
+ const MAX_DOMAIN_LEN = 253;
1266
+
1267
+ /** Exported because the RUNNER needs the same fold: `examples/agent_entry_server.mjs` decides
1268
+ * whether `AGENT_ENTRY_DOMAINS` is blank at all, and it used `trim()`. That is a different
1269
+ * set from Python's `strip()` in BOTH directions, so one variable got two verdicts — a
1270
+ * `\x1c` started the Python runner with no domains and made this one exit 2, and a BOM
1271
+ * (what a paste out of a spreadsheet or a Windows `.env` carries) did the reverse. */
1272
+ export function trimOuter(s) {
1273
+ let a = 0;
1274
+ let b = s.length;
1275
+ while (a < b && OUTER_WS.includes(s[a])) a += 1;
1276
+ while (b > a && OUTER_WS.includes(s[b - 1])) b -= 1;
1277
+ return s.slice(a, b);
1278
+ }
1279
+
1280
+ /**
1281
+ * The JS twin of `shared/domainbind.valid_domain` — the ONE definition of "is this a bare
1282
+ * domain" in this system, and therefore the one both halves of a domain binding must agree
1283
+ * on. Total on untrusted input; never throws.
1284
+ *
1285
+ * A domain here is not a URL: ASCII LDH labels only (a-z, 0-9, '-'), LOWERCASE (case is
1286
+ * folded by the caller, visibly, because `valid_domain` REJECTS an uppercase spelling
1287
+ * rather than folding it), 1..63 characters per label, no leading or trailing '-', at
1288
+ * least two labels (a single-label name has no owner a verifier could hold responsible),
1289
+ * host <= 253 with no trailing dot, and an optional ':<port>' 1..65535 with no leading
1290
+ * zero. Ports exist only because a loopback or staging box cannot use 443.
1291
+ *
1292
+ * Re-implemented rather than imported for the same reason everything else in this file is:
1293
+ * a site copies ONE file. `test_agent_entry_contract.py` is what holds the two spellings to
1294
+ * the same verdicts.
1295
+ */
1296
+ function validBareDomain(domain) {
1297
+ if (typeof domain !== 'string' || !domain || domain.length > MAX_DOMAIN_LEN + 6) {
1298
+ return false;
1299
+ }
1300
+ if (NOT_IN_DOMAIN.some((ch) => domain.includes(ch))) return false;
1301
+ for (const ch of domain) if (ch.codePointAt(0) > 0x7f) return false; // IDN U-labels
1302
+ let host = domain;
1303
+ const colon = domain.indexOf(':'); // the FIRST one: `str.partition` semantics
1304
+ if (colon >= 0) {
1305
+ host = domain.slice(0, colon);
1306
+ const portS = domain.slice(colon + 1); // a second ':' leaves a non-numeric tail
1307
+ if (!/^[0-9]+$/.test(portS)) return false;
1308
+ if (portS.length > 1 && portS.startsWith('0')) return false;
1309
+ const port = Number(portS);
1310
+ if (port < 1 || port > 65535) return false;
1311
+ }
1312
+ if (!host || host.length > MAX_DOMAIN_LEN || host.endsWith('.')) return false;
1313
+ const labels = host.split('.');
1314
+ if (labels.length < 2) return false;
1315
+ for (const label of labels) {
1316
+ if (label.length < 1 || label.length > 63) return false;
1317
+ if (label.startsWith('-') || label.endsWith('-')) return false;
1318
+ for (const ch of label) if (!LDH.has(ch)) return false;
1319
+ }
1320
+ return true;
1321
+ }
1322
+
1323
+ /** A pasteable repair for a domain we refused, or undefined when we cannot guess one.
1324
+ * Only ever suggests something `validBareDomain` accepts, so a wrong guess produces no
1325
+ * suggestion rather than a second bad value to paste. */
1326
+ function domainFix(candidate) {
1327
+ try {
1328
+ let guess = trimOuter(String(candidate)).toLowerCase();
1329
+ guess = guess.includes('://') ? guess.slice(guess.indexOf('://') + 3) : guess;
1330
+ for (const cut of ['/', '?', '#']) {
1331
+ const at = guess.indexOf(cut);
1332
+ if (at >= 0) guess = guess.slice(0, at);
1333
+ }
1334
+ if (guess.includes('@')) guess = guess.slice(guess.lastIndexOf('@') + 1);
1335
+ guess = guess.replace(/\.+$/, '');
1336
+ return (guess && guess !== candidate && validBareDomain(guess)) ? guess : undefined;
1337
+ } catch {
1338
+ return undefined;
1339
+ }
1340
+ }
1341
+
1342
+ /**
1343
+ * canonicalDomains(domains) -> the exact list this entry may publish as its card `domains`.
1344
+ *
1345
+ * WHAT IT IS FOR. A domain binding is BILATERAL and neither half is worth anything alone
1346
+ * (shared/domainbind.py, agent/domainverify.py): the DOMAIN publishes a credential naming
1347
+ * this DID at /.well-known/did-configuration.json, and the AGENT's own live card names the
1348
+ * domain back. This list is that second half. Without it a verifier holding the domain's
1349
+ * file answers `card-withdrawn` — the domain vouches for an agent that does not claim the
1350
+ * domain — so an entry with no `domains` can never be proven to belong to the site it is
1351
+ * serving from. Because the halves are written by different parties, EITHER can end the
1352
+ * binding alone: the domain owner deletes a line from the file, or the entry drops the name.
1353
+ *
1354
+ * Absent or empty -> `[]`, and the card then carries NO `domains` key at all, so an entry
1355
+ * from before this option existed publishes byte-identical bytes and nobody has to
1356
+ * re-publish or re-sign anything.
1357
+ *
1358
+ * Anything else is REFUSED at construction, loudly, exactly like `canonicalBaseUrl`: a
1359
+ * name the credential can never bind is not something to warn about and publish anyway.
1360
+ * The rule is `validBareDomain` — deliberately the same predicate core uses, because a
1361
+ * second opinion here produces an entry that starts happily and can never verify.
1362
+ * `strip().lower()` is the ONLY canonicalization, matching agent/domainverify._norm_domain.
1363
+ *
1364
+ * Names are de-duplicated (operator order kept), and MORE THAN `MAX_CARD_DOMAINS` distinct
1365
+ * names is a REFUSAL, not a truncation — even though `build_agent_card` truncates at the
1366
+ * same 5. That function renders a card for many callers at runtime and must not blow up
1367
+ * mid-render; this one validates an argument an operator just typed, and it already
1368
+ * refuses every other bad value there. Truncating would start the entry with a claim that
1369
+ * is USABLE and NOT WHAT THEY SAID.
1370
+ */
1371
+ export function canonicalDomains(domains, { warn = true } = {}) {
1372
+ if (domains === undefined || domains === null) return [];
1373
+ if (!Array.isArray(domains)) {
1374
+ refuseBaseUrl(domains, 'it is not a list of domain names.',
1375
+ 'Pass an array, e.g. ["example.com"] — a single string is refused rather than '
1376
+ + 'wrapped, so this file and its Python twin cannot disagree about what was meant.',
1377
+ undefined, 'domains');
1378
+ }
1379
+ const out = [];
1380
+ for (const entry of domains) {
1381
+ if (typeof entry !== 'string') {
1382
+ refuseBaseUrl(entry, 'it is not a string.', 'A domain is a name, e.g. "example.com".',
1383
+ undefined, 'domains');
1384
+ }
1385
+ const s = trimOuter(entry);
1386
+ for (const ch of s) {
1387
+ const c = ch.codePointAt(0);
1388
+ if (c < 0x21 || c > 0x7e) {
1389
+ refuseBaseUrl(entry,
1390
+ 'it contains whitespace, a control character or a non-ASCII character.',
1391
+ 'A domain here is compared byte for byte against the origin in the credential '
1392
+ + 'the domain itself serves, so an internationalized name must be given in its '
1393
+ + 'punycode (xn--…) A-label form and nothing else may travel with it.',
1394
+ domainFix(s), 'domains');
1395
+ }
1396
+ }
1397
+ const lowered = s.toLowerCase();
1398
+ if (!validBareDomain(lowered)) {
1399
+ refuseBaseUrl(entry, 'it is not a bare domain name.',
1400
+ "Give the HOST only: ASCII letters, digits and '-', at least two labels (each "
1401
+ + "1-63 characters, not starting or ending with '-'), at most 253 characters, "
1402
+ + "optionally ':<port>' 1-65535 — no scheme, no path, no query, no '@', no "
1403
+ + 'trailing dot. The domain\'s own credential binds https://<this exact string>, '
1404
+ + 'so anything else can never match it.',
1405
+ domainFix(lowered), 'domains');
1406
+ }
1407
+ if (!out.includes(lowered)) out.push(lowered);
1408
+ }
1409
+ if (out.length > MAX_CARD_DOMAINS) {
1410
+ refuseBaseUrl(domains,
1411
+ `it names ${out.length} distinct domains, more than the ${MAX_CARD_DOMAINS} a card `
1412
+ + 'may advertise.',
1413
+ 'Every name listed is an outbound HTTPS fetch this entry asks strangers to make, so '
1414
+ + `a card carries at most ${MAX_CARD_DOMAINS}. Publishing the first `
1415
+ + `${MAX_CARD_DOMAINS} and dropping the rest would start this entry with a claim `
1416
+ + 'that is usable and NOT what you said: the names that vanished fail for whoever '
1417
+ + 'verifies them and nothing anywhere says why. Drop names, or run a second entry '
1418
+ + '(its own key) for the rest.',
1419
+ undefined, 'domains');
1420
+ }
1421
+ return out;
1422
+ }
1423
+
1424
+ /**
1425
+ * canonicalMount(canonUrl, basePath) -> the path prefix this entry ANSWERS at. `''` for a
1426
+ * bare origin; otherwise `'/support'`-shaped, taken from the already-canonicalised url.
1427
+ *
1428
+ * WHY IT IS DERIVED AND NOT CONFIGURED. A mount the operator spells separately from
1429
+ * `baseUrl` is a second place to write the same fact, and the failure it produces is the
1430
+ * worst one this system has: the entry answers at one path while its signed card claims
1431
+ * another, so every visitor fails `Outbox.card_binds_to` and the only diagnostic anyone
1432
+ * gets is "cannot prove that … owns …". Deriving it makes the router's mount and the
1433
+ * card's advertised address THE SAME STRING by construction. (Measured before this
1434
+ * existed: an entry given `baseUrl: 'http://h:p/support'` printed that address, signed
1435
+ * `/support` into its card, and then answered the BARE HOST — three answers to one
1436
+ * question.)
1437
+ *
1438
+ * THE ONE OVERRIDE. A reverse proxy that STRIPS the prefix hands this process `/…` while
1439
+ * the public address is still `https://h/support`. That deployment is real, so `basePath:
1440
+ * ''` is allowed — but ONLY `''` or exactly the canonical url's own path. Any other value
1441
+ * would be a third spelling of the address, which is what this function exists to prevent.
1442
+ */
1443
+ export function canonicalMount(canonUrl, basePath) {
1444
+ const path = new URL(canonUrl).pathname.replace(/\/+$/, '');
1445
+ if (basePath === undefined || basePath === null) return path;
1446
+ if (typeof basePath !== 'string') {
1447
+ refuseBaseUrl(basePath, 'it is not a string.',
1448
+ 'Pass "" (a proxy that strips the prefix) or the same path as baseUrl.',
1449
+ undefined, 'base_path');
1450
+ }
1451
+ const given = trimOuter(basePath).replace(/\/+$/, '');
1452
+ if (given !== '' && given !== path) {
1453
+ refuseBaseUrl(basePath, 'it is neither empty nor the path baseUrl already names.',
1454
+ `This entry publishes ${JSON.stringify(canonUrl)}, so a visitor dials `
1455
+ + `${JSON.stringify(path || '/')} and nothing else. Use "" only when a proxy strips `
1456
+ + 'the prefix before the request reaches this process.',
1457
+ path || '', 'base_path');
1458
+ }
1459
+ return given;
1460
+ }
1461
+
1101
1462
  /**
1102
1463
  * createAgentEntry(opts) -> { did, card, ledger, handleRequest, handleRequestAsync, listen }
1103
1464
  *
@@ -1107,6 +1468,15 @@ export function canonicalBaseUrl(baseUrl, { warn = true } = {}) {
1107
1468
  * REQUIRES card.url to name the origin+path it dialled (Outbox.card_binds_to)
1108
1469
  * — that binding is what stops an attacker re-serving your signed card at
1109
1470
  * their own host. Get it wrong and Path A verification fails, silently.
1471
+ * It MAY carry a path (`https://example.com/support`): every route then
1472
+ * hangs off that path and the bare host is 404, so one hostname holds a
1473
+ * front desk, support and sales as three agents with three keys.
1474
+ * domains the bare domains this entry claims to speak for, e.g. ['example.com']
1475
+ * (default none, and then the card carries no `domains` key at all). A
1476
+ * CLAIM, never evidence: the proof is the credential the DOMAIN serves at
1477
+ * /.well-known/did-configuration.json, and a verifier requires both halves.
1478
+ * basePath ONLY for a proxy that strips the prefix: '' or exactly baseUrl's path.
1479
+ * See canonicalMount for why this is not a general knob.
1110
1480
  * responder (envelope) => string | {text, contextId?, timestamp?} | Promise<…>
1111
1481
  * openDoor advertise `muretai.open_door` (default true) — the flag that tells a
1112
1482
  * visiting agent it may contact you without an introduction.
@@ -1128,6 +1498,8 @@ export function createAgentEntry({
1128
1498
  anonymousLane = false,
1129
1499
  anonRatePerMin = ANON_RATE_PER_MIN,
1130
1500
  skills = [],
1501
+ domains = null,
1502
+ basePath = null,
1131
1503
  maxAccounts = 50000,
1132
1504
  } = {}) {
1133
1505
  if (!seedHex) throw new TypeError('createAgentEntry: seedHex is required');
@@ -1136,6 +1508,13 @@ export function createAgentEntry({
1136
1508
  // equal what a visitor's Outbox.card_scope computes for the url they dialled, or the card
1137
1509
  // fails verification on THEIR machine with nothing on ours.
1138
1510
  const canonUrl = canonicalBaseUrl(baseUrl);
1511
+ // The path prefix this entry ANSWERS at, DERIVED from that same string so the router and
1512
+ // the signed card cannot disagree. '' for a bare origin — every route below is then the
1513
+ // byte-identical string this module always matched.
1514
+ const mount = canonicalMount(canonUrl, basePath);
1515
+ // The agent half of a T88 domain binding. Refuses to start on anything that is not a
1516
+ // bare domain: a name the domain's credential can never bind is not worth publishing.
1517
+ const canonDomains = canonicalDomains(domains);
1139
1518
  const did = didFromSeedHex(seedHex);
1140
1519
 
1141
1520
  const card = {
@@ -1150,6 +1529,11 @@ export function createAgentEntry({
1150
1529
  defaultOutputModes: ['text/plain'],
1151
1530
  skills,
1152
1531
  };
1532
+ // T88, the REVERSE EDGE only, in the same top-level field and the same position
1533
+ // shared/protocol.build_agent_card uses, so one verifier rule reads a node's card and an
1534
+ // entry's card. Omitted entirely when no domain was named — that is what keeps an
1535
+ // already-deployed entry's published bytes unchanged.
1536
+ if (canonDomains.length) card.domains = canonDomains;
1153
1537
  if (openDoor) card.muretai = { open_door: true };
1154
1538
  // Deliberately NO `relay`/`enc_pub` on the card: those advertise a store-and-forward
1155
1539
  // mailbox, and an agent entry has no listener draining one. Advertising a mailbox nobody
@@ -1337,15 +1721,33 @@ export function createAgentEntry({
1337
1721
  * size checks come BEFORE any parsing or crypto — a check placed after the signature is
1338
1722
  * a check the attacker simply skips.
1339
1723
  */
1340
- function handlePost(bodyBuffer) {
1724
+ function handlePost(rawBody) {
1725
+ // RAW BYTES, always. A host app that hands us a decoded string has already destroyed
1726
+ // the evidence the strict decode below exists to find, so normalise once and measure
1727
+ // the SIZE in bytes rather than in UTF-16 code units.
1728
+ const bodyBuffer = Buffer.isBuffer(rawBody) ? rawBody
1729
+ : (ArrayBuffer.isView(rawBody)
1730
+ ? Buffer.from(rawBody.buffer, rawBody.byteOffset, rawBody.byteLength)
1731
+ : Buffer.from(String(rawBody ?? ''), 'utf8'));
1341
1732
  // 1. body over 1 MiB — refused WITHOUT parsing.
1342
1733
  if (bodyBuffer.length > MAX_BODY_BYTES) {
1343
1734
  return jsonResponse(413, { error: 'request body too large' });
1344
1735
  }
1345
1736
  // 2. unparseable or non-object JSON — a transport-level refusal, not a JSON-RPC one.
1737
+ //
1738
+ // STRICT UTF-8, and `Buffer.toString('utf8')` — what this used to be — is why:
1739
+ // it SILENTLY SUBSTITUTES U+FFFD for every invalid byte and parses on, where
1740
+ // `shared/protocol.loads` does `raw.decode("utf-8")` and raises. Measured with a valid
1741
+ // signature over the six frozen fields and a single raw 0xFF byte in the JSON-RPC `id`
1742
+ // (a field no signature covers): the Python reference answered HTTP 400 and booked
1743
+ // nothing, this file answered 200, CREATED THE ACCOUNT and returned a signed reply.
1744
+ // The class is larger than the id — a substituted byte anywhere means the bytes we
1745
+ // verified are not the bytes that arrived, which is precisely the thing a signature is
1746
+ // supposed to make impossible to be wrong about. `TextDecoder` with `fatal` is the
1747
+ // stdlib's strict decoder (global since Node 11); no dependency is added.
1346
1748
  let req;
1347
1749
  try {
1348
- req = JSON.parse(bodyBuffer.toString('utf8'));
1750
+ req = JSON.parse(STRICT_UTF8.decode(bodyBuffer));
1349
1751
  } catch {
1350
1752
  return jsonResponse(400, { error: 'malformed JSON' });
1351
1753
  }
@@ -1353,14 +1755,26 @@ export function createAgentEntry({
1353
1755
  return jsonResponse(400, { error: 'JSON-RPC request must be an object' });
1354
1756
  }
1355
1757
  // 3. From here every refusal is HTTP 200 with a JSON-RPC error object.
1356
- const reqId = req.id ?? null;
1357
- if (typeof req.method === 'string' && req.method !== 'message/send') {
1758
+ const reqId = safeId(req.id);
1759
+ // STRICT, and the `typeof` guard this replaces is why: it short-circuited, so a
1760
+ // NON-STRING `method` skipped the check entirely and fell into the message ladder.
1761
+ // Measured with a valid signature and a fresh timestamp — `"method": null`, `1` and
1762
+ // `{}` each returned a SIGNED REPLY and CREATED AN ACCOUNT here, while the Python
1763
+ // reference answered -32601 for all three. That is the double-book class this file's
1764
+ // `wireShapeError` docstring is about, one field above where it was looking. An
1765
+ // absent method lands here too, which is also -32601 on the Python side.
1766
+ if (req.method !== 'message/send') {
1358
1767
  return rpcError(reqId, ERRORS.METHOD_NOT_FOUND,
1359
- `${req.method} — this agent entry serves message/send only`);
1768
+ 'an agent entry implements message/send only');
1360
1769
  }
1361
- const msg = (req.params && typeof req.params === 'object') ? req.params.message : null;
1362
- if (!msg || typeof msg !== 'object') {
1363
- return rpcError(reqId, ERRORS.INVALID_PARAMS, 'params.message is required');
1770
+ // A plain OBJECT, never an Array: `typeof [] === 'object'`, so an array `message` used
1771
+ // to reach the shape gate and be refused -32600 where Python answered -32602.
1772
+ const params = (req.params && typeof req.params === 'object' && !Array.isArray(req.params))
1773
+ ? req.params : null;
1774
+ const msg = params ? params.message : null;
1775
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) {
1776
+ return rpcError(reqId, ERRORS.INVALID_PARAMS,
1777
+ 'params.message must be an A2A message object');
1364
1778
  }
1365
1779
  // 3a. wrongly-TYPED wire fields, on the raw object and before anything measures or
1366
1780
  // hashes it. These are the fields that end up inside a signed payload — theirs and,
@@ -1369,6 +1783,9 @@ export function createAgentEntry({
1369
1783
  const shape = wireShapeError(msg);
1370
1784
  if (shape !== null) return rpcError(reqId, ERRORS.INVALID_REQUEST, shape);
1371
1785
 
1786
+ // Absent metadata reads as an empty envelope — the walk-in shape. A PRESENT one that
1787
+ // is not an object never gets here: the shape gate above refused it (-32600), so this
1788
+ // fallback can no longer turn a malformed envelope into an anonymous one.
1372
1789
  const meta = (msg.metadata && typeof msg.metadata === 'object') ? msg.metadata : {};
1373
1790
  const text = messageText(msg);
1374
1791
 
@@ -1477,22 +1894,51 @@ export function createAgentEntry({
1477
1894
  return finishReply(reqId, answer, { inbound, toDid });
1478
1895
  }
1479
1896
 
1897
+ /** Is this the message endpoint — the base the card's `url` names? EXACT, because a
1898
+ * wandering endpoint is not this contract: with a bare origin that is `/` and nothing
1899
+ * else (byte-for-byte what this module always accepted), and with a mount it is
1900
+ * `/support` plus its trailing-slash spelling, since `card_scope` folds the two and a
1901
+ * visitor may legitimately have been handed either. */
1902
+ function isMountPath(pathname) {
1903
+ if (!mount) return pathname === '/';
1904
+ return pathname === mount || pathname === `${mount}/`;
1905
+ }
1906
+
1480
1907
  function route(method, path, bodyBuffer) {
1481
- const pathname = String(path || '/').split('?')[0].split('#')[0];
1908
+ const target = String(path || '/');
1909
+ // ORIGIN FORM ONLY, and SAY SO. HTTP/1.1 lets a client write the request-target in
1910
+ // absolute form (`POST http://elsewhere.example/support HTTP/1.1`) and RFC 9112 §3.2.2
1911
+ // says a server MUST accept it; this contract deliberately does not, because this entry
1912
+ // answers exactly the address its card names and that address has no other spelling.
1913
+ // A refusal that is legal-per-RFC to make and illegal-per-RFC to make silently is
1914
+ // exactly the one that has to carry a diagnostic: `{"error":"not found"}` for a target
1915
+ // an integrator believes is correct costs an afternoon and teaches nothing.
1916
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(target) || target.startsWith('//')) {
1917
+ return jsonResponse(404, { error: 'not found', detail: ORIGIN_FORM_ONLY });
1918
+ }
1919
+ const pathname = target.split('?')[0].split('#')[0];
1920
+ // EVERY route hangs off the mount — the path the signed card already claims. A visitor
1921
+ // builds the same strings (Outbox's fetcher keeps the base's path prefix when it
1922
+ // appends a well-known path), so this is not a new convention; it is the one the
1923
+ // fetcher already follows. With mount === '' these are the original constants.
1482
1924
  if (method === 'GET' || method === 'HEAD') {
1483
- if (pathname === AGENT_CARD_PATH || pathname === AGENT_CARD_PATH_LEGACY) {
1925
+ if (pathname === mount + AGENT_CARD_PATH || pathname === mount + AGENT_CARD_PATH_LEGACY) {
1484
1926
  // Byte-identical on both paths: the current A2A path and the legacy alias.
1485
1927
  return { status: 200, headers: cardHeaders(cardBytes.length), body: cardBytes };
1486
1928
  }
1487
- if (pathname === AGENT_CARD_SIG_PATH) {
1929
+ if (pathname === mount + AGENT_CARD_SIG_PATH) {
1488
1930
  const env = cardEnvelopeBytes();
1489
1931
  return { status: 200, headers: cardHeaders(env.length), body: env };
1490
1932
  }
1491
- if (pathname === '/') {
1933
+ if (isMountPath(pathname)) {
1492
1934
  const body = Buffer.from(
1493
- `${name}\n\nThis origin is agent-reachable (Muretai agent entry).\n`
1935
+ // "This ADDRESS", not "this origin": once an entry can be mounted under a
1936
+ // path, the origin may hold several agents and this notice speaks for exactly
1937
+ // one of them. A bare-origin entry reads the same either way.
1938
+ `${name}\n\nThis address is agent-reachable (Muretai agent entry).\n`
1494
1939
  + `DID: ${did}\nCard: ${canonUrl}${AGENT_CARD_PATH}\n`
1495
- + 'POST a signed A2A message/send request to / for a signed reply.\n', 'utf8');
1940
+ + `POST a signed A2A message/send request to ${mount || '/'} for a signed reply.\n`,
1941
+ 'utf8');
1496
1942
  return { status: 200,
1497
1943
  headers: { 'Content-Type': 'text/plain; charset=utf-8',
1498
1944
  'Content-Length': String(body.length) },
@@ -1501,8 +1947,10 @@ export function createAgentEntry({
1501
1947
  return jsonResponse(404, { error: 'not found' });
1502
1948
  }
1503
1949
  if (method === 'POST') {
1504
- // EXACTLY the root path. A POST anywhere else is not this contract.
1505
- if (pathname !== '/') return jsonResponse(404, { error: 'not found' });
1950
+ // EXACTLY the address the card names. A POST anywhere else is not this contract
1951
+ // and when this entry is mounted under a path, "anywhere else" INCLUDES the bare
1952
+ // host, which belongs to the site (or to the neighbour agent) and not to us.
1953
+ if (!isMountPath(pathname)) return jsonResponse(404, { error: 'not found' });
1506
1954
  return handlePost(bodyBuffer || Buffer.alloc(0));
1507
1955
  }
1508
1956
  if (method === 'OPTIONS') {
@@ -1546,7 +1994,17 @@ export function createAgentEntry({
1546
1994
  * (behind a TLS terminator) to go public.
1547
1995
  */
1548
1996
  function listen(port = 8788, host = '127.0.0.1', onReady) {
1549
- const server = createServer((req, res) => {
1997
+ const server = createServer({
1998
+ // Node checks its request/headers timeouts on an interval, not on a per-connection
1999
+ // timer, and the DEFAULT interval is 30 s — so a 20 s bound measured on the wire fires
2000
+ // somewhere between 20 s and 50 s. Tightening the interval is what makes the number
2001
+ // above the number a stranger actually observes; measured against the trickle, this
2002
+ // takes the 408 from ~58 s to ~22 s, next to the Python twin's ~20.6 s.
2003
+ connectionsCheckingInterval: 2_000,
2004
+ headersTimeout: HEADERS_TIMEOUT_MS,
2005
+ requestTimeout: REQUEST_TIMEOUT_MS,
2006
+ keepAliveTimeout: KEEPALIVE_TIMEOUT_MS,
2007
+ }, (req, res) => {
1550
2008
  const chunks = [];
1551
2009
  let total = 0;
1552
2010
  let oversize = false;
@@ -1579,11 +2037,33 @@ export function createAgentEntry({
1579
2037
  .catch(() => { try { res.destroy(); } catch { /* already gone */ } });
1580
2038
  });
1581
2039
  });
2040
+ // Also as properties, for a Node old enough to ignore the options above. Explicit
2041
+ // rather than inherited: Node's defaults are generous (300 s / 60 s / 5 s) and unstated,
2042
+ // and the Python twin has to spell the same numbers out anyway. Writing them in both
2043
+ // files is what makes "the two entries bound a stranger identically" a fact a reader can
2044
+ // check instead of a claim.
2045
+ server.headersTimeout = HEADERS_TIMEOUT_MS;
2046
+ server.requestTimeout = REQUEST_TIMEOUT_MS;
2047
+ server.keepAliveTimeout = KEEPALIVE_TIMEOUT_MS;
2048
+ // The CONNECTION CEILING. There is no Node default, and the timeouts above do not
2049
+ // supply one: they bound how LONG each connection lives, not how MANY exist at once.
2050
+ // Written by hand rather than with `server.maxConnections`, which DESTROYS the socket
2051
+ // silently — an unauthenticated stranger always gets an HTTP response here (the promise
2052
+ // in docs/AGENT_ENTRY.md), an operator behind a proxy gets a 503 in the log instead of
2053
+ // "upstream closed the connection", and the Python twin can say the same sentence.
2054
+ let live = 0;
2055
+ server.on('connection', (socket) => {
2056
+ live += 1;
2057
+ socket.once('close', () => { live -= 1; });
2058
+ if (live > MAX_CONNECTIONS) socket.end(OVERLOADED_RESPONSE);
2059
+ });
1582
2060
  server.listen(port, host, () => { if (onReady) onReady(server); });
1583
2061
  return server;
1584
2062
  }
1585
2063
 
1586
- return { did, card, ledger, handleRequest, handleRequestAsync, listen,
2064
+ // `mount` is exported so a host app can route exactly what this entry answers (and log
2065
+ // it): it is derived, so reading it here can never disagree with the signed card.
2066
+ return { did, card, ledger, mount, handleRequest, handleRequestAsync, listen,
1587
2067
  cardEnvelope: () => JSON.parse(cardEnvelopeBytes().toString('utf8')) };
1588
2068
  }
1589
2069
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@muretai/agent-entry",
3
- "version": "1.1.0",
4
- "description": "Your website can recognise AI agents, hold accounts for them, and answer them \u2014 one dependency-free file.",
3
+ "version": "1.2.0",
4
+ "description": "Your website can recognise AI agents, hold accounts for them, and answer them one dependency-free file.",
5
5
  "type": "module",
6
6
  "main": "muretai-agent-entry.mjs",
7
7
  "exports": {
@@ -27,7 +27,8 @@
27
27
  "ed25519",
28
28
  "muretai",
29
29
  "agent-network",
30
- "llms-txt"
30
+ "llms-txt",
31
+ "webmcp"
31
32
  ],
32
33
  "license": "MIT",
33
34
  "author": "Muretai",