@muretai/agent-entry 1.3.0 → 1.4.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.
@@ -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
- * `web/agent-entry/muretai-agent-entry.mjs` to a demo booking desk. Copy it, gut the responder,
7
+ * `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:
@@ -33,6 +33,12 @@
33
33
  * AGENT_ENTRY_NAME public display name on the card
34
34
  * AGENT_ENTRY_HOST bind address (default 127.0.0.1 — set 0.0.0.0 only behind TLS)
35
35
  * AGENT_ENTRY_ANON "1" also accepts UNSIGNED walk-in inquiries (they mint no account)
36
+ * AGENT_ENTRY_GUEST "1" = GUEST MOUNT: coexist with a site that keeps its own front
37
+ * page. The entry serves its card paths and the POST door named by
38
+ * AGENT_ENTRY_BASE_URL (which must then carry that path, e.g.
39
+ * https://example.com/agent) and answers NOTHING at `/` — no notice,
40
+ * no OPTIONS, no POST. Point your proxy at the door path and the
41
+ * well-known paths; the site keeps everything else, unchanged.
36
42
  * AGENT_ENTRY_WBA_JWKS OPTIONAL: a JWKS document {"keys":[…]} as one JSON string —
37
43
  * the Web Bot Auth key directory (verified out of band) whose
38
44
  * holders this entry should RECOGNISE on inbound requests. Off
@@ -137,6 +143,7 @@ try {
137
143
  responder,
138
144
  openDoor: true, // "you may contact me, no introduction"
139
145
  anonymousLane: process.env.AGENT_ENTRY_ANON === '1',
146
+ guest: process.env.AGENT_ENTRY_GUEST === '1',
140
147
  wbaVerifiers,
141
148
  });
142
149
  } catch (err) {
@@ -154,6 +161,10 @@ const server = entry.listen(port, host, () => {
154
161
  }
155
162
  console.log(` Listening on ${host}:${port} — POST a signed message/send to `
156
163
  + `${entry.mount || '/'}`);
164
+ if (process.env.AGENT_ENTRY_GUEST === '1') {
165
+ console.log(` Guest mount: the site keeps GET / — this entry answers the card at `
166
+ + `${AGENT_CARD_PATH} (and under ${entry.mount}) and POST ${entry.mount} only`);
167
+ }
157
168
  });
158
169
 
159
170
  // The observation counters, surfaced the way the ledger is: on this runner's stdout,
@@ -65,6 +65,198 @@ export const AGENT_CARD_PATH = '/.well-known/agent-card.json';
65
65
  export const AGENT_CARD_PATH_LEGACY = '/.well-known/agent.json';
66
66
  export const AGENT_CARD_SIG_PATH = '/.well-known/agent-card.sig.json';
67
67
 
68
+ /** The name of the ONE way in this door accepts today — the card's `securitySchemes` key,
69
+ * its `type`, and the `scheme` of the refusal's `accepts[]` entry are all this string. The
70
+ * card and the refusal MUST name the same scheme or a visitor learns one thing from the
71
+ * menu and another from the door. Must match `SIGNED_ENVELOPE_SCHEME` in
72
+ * examples/agent_entry_reference.py. */
73
+ export const SIGNED_ENVELOPE_SCHEME = 'did-key-ed25519';
74
+
75
+ /** The stable link relation that names an agent door, emitted on the notice route (and the
76
+ * one line a SITE adds to its own front page to coexist with an entry — see
77
+ * docs/AGENT_ENTRY.md). An ABSOLUTE URI on purpose: RFC 8288 §2.1.2 allows a bare token
78
+ * only for an IANA-registered relation, so `rel="agent-entry"` would be non-conformant and
79
+ * a strict parser is entitled to drop it. Must match `AGENT_ENTRY_REL` in
80
+ * examples/agent_entry_reference.py. */
81
+ export const AGENT_ENTRY_REL = 'https://muretai.net/rel/agent-entry';
82
+
83
+ /** Where a keyless visitor is sent to learn how to mint an identity and sign. It rides in
84
+ * the card AND in the refusal, so an agent that has only one of the two still has the URL.
85
+ *
86
+ * EMPTY MEANS OMITTED, and that is the safe default. NEVER EMIT A URL THAT DOES NOT
87
+ * RESOLVE: a real third-party agent (2026-08-18 proof run) received the complete
88
+ * requirement object, printed every field of it, went straight to `howTo`, hit a 404 and
89
+ * stopped — "since the provided 'howTo' link is broken, I have no way to get this
90
+ * information" — while holding `identity`, `signedFields`, `canonicalization`,
91
+ * `signature`, `timestamp` and `recipient` in the object it had just printed. A dangling
92
+ * pointer OUT-COMPETES the data beside it and reads as terminal. So the field is emitted
93
+ * only when this constant is set, and it is set only AFTER the page is live: ship the page
94
+ * first, or ship no pointer. Verified live before this value was set (200 at the URL
95
+ * below, 404 at a control path under the same prefix); `entry-howto-resolves` in
96
+ * .claude/skills/ship-check/checks.py re-checks it on every ship report. Must match
97
+ * `FIRST_KNOCK_URL` in examples/agent_entry_reference.py. */
98
+ export const FIRST_KNOCK_URL = 'https://docs.muretai.com/guides/first-knock/';
99
+
100
+ /** `Allow:` per RESOURCE, not per server. RFC 9110 §10.2.1 makes `Allow` a statement about
101
+ * the target resource, and §15.5.6 REQUIRES it on a 405 — a generic list is a wrong answer
102
+ * to a right question, and on a guest mount it would also claim verbs on addresses the SITE
103
+ * owns. HEAD is listed wherever GET is (RFC 9110 §9.3.2 makes it mandatory alongside GET,
104
+ * and both twins have always answered it). Wherever `Allow` is emitted,
105
+ * `Access-Control-Allow-Methods` is set to the SAME value (`allowHeaders`) — the two are
106
+ * one fact for two readers, and a response carrying `Allow: POST, OPTIONS` beside the
107
+ * origin-wide `Access-Control-Allow-Methods: GET, POST, OPTIONS` contradicts itself in one
108
+ * message. Everywhere else (a card GET, a signed reply, a 404) the CORS default stands.
109
+ * Must match the `ALLOW_*` constants in examples/agent_entry_reference.py. */
110
+ export const ALLOW_CARD = 'GET, HEAD, OPTIONS';
111
+ /** A guest mount's door: the entry owns the POST and nothing else there. */
112
+ export const ALLOW_DOOR = 'POST, OPTIONS';
113
+ /** A site-owning mount: ONE address that is both the human notice (GET) and the door (POST),
114
+ * so the truthful Allow is the union — listing only the GET half would hide the very door
115
+ * the card names. */
116
+ export const ALLOW_MOUNT = 'GET, HEAD, POST, OPTIONS';
117
+
118
+ /**
119
+ * The machine-readable terms of THIS door, in one object used on BOTH surfaces: the card's
120
+ * `securitySchemes` entry (before the knock) and the `accepts[]` array of the no-envelope
121
+ * refusal (after it). One object, two surfaces, so the two can never drift apart — the
122
+ * property a visitor's test can assert without knowing what the scheme string is.
123
+ *
124
+ * The shape is x402's lesson turned into an identity vocabulary: a refusal that is the
125
+ * SPECIFICATION of what to do next, as an ARRAY so "sign", "arrive with an introduction"
126
+ * and later "pay" are siblings in one frame rather than three bespoke refusals.
127
+ *
128
+ * Everything a keyless visitor needs is here on purpose — the acceptance bar is that an
129
+ * agent holding ONLY this object, plus the crypto tooling on any machine, can mint a
130
+ * did:key, sign correctly and be answered on its NEXT post. That is why `recipient` is
131
+ * present (nobody can address a message without it) and why the canonicalization rule is
132
+ * spelled out rather than merely named.
133
+ *
134
+ * IT IS WRITTEN IN THE IMPERATIVE, and that is the fix a proof run bought (2026-08-18).
135
+ * Every field above was already correct and complete, and a real third-party agent read
136
+ * them, restated them accurately, and STOPPED: "since I don't have a way to generate
137
+ * cryptographic keys within this environment, I am stuck" — with `node` and `python3`
138
+ * both installed. A requirement object that describes a FORMAT tells a keyless visitor
139
+ * what it lacks; it does not tell it to ACT. So `instruction` leads with the sentence
140
+ * that unblocks a general-purpose agent — MAKE A KEY NOW, nobody's permission is needed,
141
+ * any crypto library will do — and every descriptive string below gained an imperative
142
+ * PREFIX. The old value of each is still a SUBSTRING of the new one, so a client that
143
+ * substring-matched on the format text keeps matching; no field was removed or renamed,
144
+ * and `howTo` is the only one whose presence is conditional (see FIRST_KNOCK_URL).
145
+ *
146
+ * IT TEACHES THE CARRIER AS WELL AS THE SIGNATURE, and that is the fix proof run 3 bought
147
+ * (2026-08-18, `exampleRequest`). The recipe was walked end to end — a third-party agent ran
148
+ * `identityCode`, minted a computed DID, signed the six canonical fields — and TWO runs were
149
+ * still refused, holding a correct key, a correct DID and correct signed bytes, on the A2A
150
+ * ENVELOPE: "not an A2A message object", "messageId must be a non-empty string". `in`
151
+ * ("params.message.metadata") plus six `signedFields` reads as "put these six in metadata",
152
+ * which is exactly what they built. One of them named the gap precisely: "the agent card is
153
+ * ambiguous on the precise nesting of messageId… This is a guess at a format, which goes
154
+ * against the rules." So the complete body a door accepts is emitted BESIDE `in` — the
155
+ * counter-example on the very next line of the object whose reading caused the mistake.
156
+ *
157
+ * `exampleRequest` IS A NESTED OBJECT, NOT A JSON STRING, and the choice is the same law
158
+ * again. A string of JSON arrives on the wire as `{\"jsonrpc\":\"2.0\",…}` — every quote
159
+ * escaped — and every agent in every proof run met this door through `curl`, i.e. reading
160
+ * the RAW response. An escaped document has to be UNESCAPED before it can be copied, which
161
+ * is one more transformation performed in the model's head, which is the precise failure
162
+ * mode base58-as-a-formula already cost two runs. A nested object IS the artifact: it
163
+ * renders as the thing to send, and `JSON.stringify(way.exampleRequest)` is a body. Nesting
164
+ * costs nothing here — the block already sits two levels deep under `securitySchemes`, and
165
+ * `signedFields` is already a non-string value, so no consumer needs a new capability.
166
+ *
167
+ * A copier fills FIVE `<…>` blanks (messageId, text, from, timestamp, sig); `to` is already
168
+ * this door's DID, `contextId` is already `null`, and `id`/`kind`/`role`/`parts` are already
169
+ * correct. `contextId: null` is deliberate and doubles as the answer to "what do I send when
170
+ * I have no conversation yet" — the same clause `canonicalization` now states in words.
171
+ *
172
+ * Must match `signed_envelope_requirement` in examples/agent_entry_reference.py field for
173
+ * field, string for string: the card and the refusal are one object, and two twins that
174
+ * teach a keyless visitor differently are two doors.
175
+ */
176
+ export function signedEnvelopeRequirement(did) {
177
+ const requirement = {
178
+ scheme: SIGNED_ENVELOPE_SCHEME,
179
+ instruction: 'Generate an Ed25519 keypair NOW, locally, and knock again with it. You '
180
+ + 'need no account, no registration and no permission from this site: any crypto '
181
+ + 'library on the machine you are already running on will do (node:crypto '
182
+ + 'generateKeyPairSync("ed25519"), Python cryptography or PyNaCl, Go crypto/ed25519, '
183
+ + 'libsodium). Then work down the fields below in order - derive your did:key '
184
+ + '(identity), build the canonical bytes over signedFields (canonicalization), sign '
185
+ + 'them (signature), address the message to recipient with a fresh timestamp - and '
186
+ + 'POST the same request again. Having no key yet is the normal starting state, not '
187
+ + 'a refusal: minting one is step 1, and it is a few lines of code. The COMPLETE '
188
+ + 'request body to POST is exampleRequest below: copy it and fill in its five <...> '
189
+ + 'blanks. The A2A envelope AROUND your signature is checked too, so send that shape '
190
+ + 'and do not improvise one.',
191
+ in: 'params.message.metadata',
192
+ // THE CARRIER, not just the signature. `in` tells you where the six envelope fields go
193
+ // and says nothing about the message that carries them, and two proof-run agents read it
194
+ // as the whole answer: they put the six in `metadata` and were refused on the A2A object
195
+ // itself ("not an A2A message object", "messageId must be a non-empty string") while
196
+ // holding a correct key and correct signed bytes. This is the body, whole, next to the
197
+ // line that misled them. A NESTED OBJECT on purpose (see the note above): a JSON string
198
+ // of JSON arrives escaped, and an agent reading a raw curl response would have to
199
+ // unescape it in its head before it could copy it.
200
+ exampleRequest: {
201
+ jsonrpc: '2.0',
202
+ id: 1,
203
+ method: 'message/send',
204
+ params: {
205
+ message: {
206
+ kind: 'message',
207
+ role: 'user',
208
+ messageId: '<a fresh unique string, e.g. a UUID>',
209
+ // Already filled in, and it is the answer to "what if I have no conversation
210
+ // yet": null. It is still one of the six signed fields; never omit it.
211
+ contextId: null,
212
+ parts: [{ kind: 'text', text: '<your message>' }],
213
+ metadata: {
214
+ from: '<your did:key>',
215
+ to: did,
216
+ timestamp: '<integer epoch seconds - a JSON number, not this string>',
217
+ sig: '<base64 signature over the canonical six fields>',
218
+ },
219
+ },
220
+ },
221
+ },
222
+ recipient: did,
223
+ identity: 'Derive your DID from the public key you just generated and send it as '
224
+ + 'metadata.from: did:key:z + base58btc(0xed01 || <32-byte Ed25519 public key>)',
225
+ // RUN this, do not write the address by hand. Measured 2026-08-18: a real agent minted a
226
+ // correct keypair, then "converted" the bytes in its head and produced the DOOR'S OWN DID
227
+ // with two characters changed — three times — and signed correct bytes under an address
228
+ // that was never its own. base58btc was the one step in this recipe stated as a formula
229
+ // instead of made runnable, and it is exactly the step a language model fabricates.
230
+ identityCode: 'Run this; do not encode base58btc by hand. node, no packages, `pub` = the '
231
+ + '32 raw public-key bytes: '
232
+ + "const A='123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; "
233
+ + 'let n=0n; for (const b of Buffer.concat([Buffer.from([0xed,0x01]),pub])) '
234
+ + 'n=n*256n+BigInt(b); '
235
+ + "let s=''; while (n>0n) { s=A[Number(n%58n)]+s; n/=58n; } "
236
+ + "const did='did:key:z'+s; // 0xed leads, so no leading-zero '1' case can arise",
237
+ signedFields: ['contextId', 'from', 'messageId', 'text', 'timestamp', 'to'],
238
+ // The trailing clause is proof run 3's third fix: the verifier has always signed
239
+ // `contextId ?? null`, and "exactly those six fields" never said what a visitor with no
240
+ // conversation yet is supposed to put there. One run spent an entire refusal deducing
241
+ // it. The previous value is still a PREFIX of this one, so a substring matcher on the
242
+ // format text keeps matching.
243
+ canonicalization: 'Build the bytes to sign as a JSON object of exactly those six '
244
+ + 'fields, keys sorted by Unicode code point, separators "," and ":", no whitespace, '
245
+ + 'non-ASCII literal, UTF-8. When you have no conversation yet, contextId is JSON '
246
+ + 'null - it is still one of the six and is still signed, so never omit it (see '
247
+ + 'exampleRequest, which already has it right)',
248
+ signature: 'Sign those bytes with your private key and set metadata.sig = base64 '
249
+ + '(standard alphabet, padded) of the 64-byte Ed25519 signature over those bytes',
250
+ timestamp: 'Set metadata.timestamp = integer epoch seconds, within 300 s of this '
251
+ + 'entry\'s clock',
252
+ };
253
+ // Emitted ONLY when it is known to resolve — an unresolvable pointer out-competes every
254
+ // field beside it (see FIRST_KNOCK_URL). Appended last so the object's other bytes and
255
+ // their positions do not move when a site turns the pointer off.
256
+ if (FIRST_KNOCK_URL) requirement.howTo = FIRST_KNOCK_URL;
257
+ return requirement;
258
+ }
259
+
68
260
  /** The User-Agent FAMILY table — OBSERVATION AND SIGNPOSTING, NEVER IDENTITY. A UA string
69
261
  * is written by the client, so nothing here may ever affect `verified`, a ledger row, a
70
262
  * rate lane or any refusal verdict (that is the Web Bot Auth / signed-envelope layer's
@@ -1355,7 +1547,9 @@ function wireShapeError(msg) {
1355
1547
 
1356
1548
  /** Every response carries these. An agent entry reads NO cookie, header credential or session —
1357
1549
  * 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. */
1550
+ * agent exactly what curl already had, and nothing more. Never add Allow-Credentials.
1551
+ * `Access-Control-Allow-Methods` is the ORIGIN-WIDE default and is overridden per resource
1552
+ * on any response that also carries `Allow` (see `allowHeaders`). */
1359
1553
  const CORS_HEADERS = {
1360
1554
  'Access-Control-Allow-Origin': '*',
1361
1555
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
@@ -1929,6 +2123,15 @@ export function canonicalMount(canonUrl, basePath) {
1929
2123
  * /.well-known/did-configuration.json, and a verifier requires both halves.
1930
2124
  * basePath ONLY for a proxy that strips the prefix: '' or exactly baseUrl's path.
1931
2125
  * See canonicalMount for why this is not a general knob.
2126
+ * guest GUEST MOUNT: the SITE keeps its own front page and this entry claims
2127
+ * only its card paths and the POST door (default false). It requires a
2128
+ * baseUrl WITH a path — the door — because a guest entry at a bare origin
2129
+ * would claim `/`, which is the one thing this mode exists to give back.
2130
+ * Three differences from the ordinary (site-owning) mount, and no others:
2131
+ * the GET notice is not served (the entry never shadows a page the site
2132
+ * owns), the card is ALSO served at the ORIGIN's well-known paths (where a
2133
+ * stranger's agent looks — RFC 8615), and `Allow` on the door names POST
2134
+ * only. `GET /` and `POST /` are not this entry's contract in this mode.
1932
2135
  * responder (envelope) => string | {text, contextId?, timestamp?} | Promise<…>
1933
2136
  * openDoor advertise `muretai.open_door` (default true) — the flag that tells a
1934
2137
  * visiting agent it may contact you without an introduction.
@@ -1959,6 +2162,7 @@ export function createAgentEntry({
1959
2162
  skills = [],
1960
2163
  domains = null,
1961
2164
  basePath = null,
2165
+ guest = false,
1962
2166
  maxAccounts = 50000,
1963
2167
  wbaVerifiers = null,
1964
2168
  } = {}) {
@@ -1972,10 +2176,27 @@ export function createAgentEntry({
1972
2176
  // the signed card cannot disagree. '' for a bare origin — every route below is then the
1973
2177
  // byte-identical string this module always matched.
1974
2178
  const mount = canonicalMount(canonUrl, basePath);
2179
+ const guestMount = Boolean(guest);
2180
+ // A guest entry with NO path would claim the origin — `isMountPath('/')` is the door and
2181
+ // the notice both — which is exactly the front page this mode exists to leave alone.
2182
+ // Refuse to start rather than take it: an operator who asked for coexistence and silently
2183
+ // got occupation finds out from their own home page.
2184
+ if (guestMount && !mount) {
2185
+ refuseBaseUrl(canonUrl, 'a guest mount needs a door path, and this url has none.',
2186
+ 'A guest entry leaves GET / to the site and answers at a path beside it, so the '
2187
+ + 'address it publishes must name that path — the mount, the card url and the POST '
2188
+ + 'door are then one string by construction. Give the door in baseUrl (the card names '
2189
+ + 'it, so a visitor that read the card posts to the right place with no other '
2190
+ + 'knowledge), or drop `guest` and let this entry own its origin.',
2191
+ `${canonUrl}/agent`);
2192
+ }
1975
2193
  // The agent half of a T88 domain binding. Refuses to start on anything that is not a
1976
2194
  // bare domain: a name the domain's credential can never bind is not worth publishing.
1977
2195
  const canonDomains = canonicalDomains(domains);
1978
2196
  const did = didFromSeedHex(seedHex);
2197
+ // The terms of this door, built ONCE: the card publishes it (E1, before the knock) and
2198
+ // the no-envelope refusal returns the same object (E2, after it).
2199
+ const requirement = signedEnvelopeRequirement(did);
1979
2200
 
1980
2201
  const card = {
1981
2202
  protocolVersion: PROTOCOL_VERSION,
@@ -1998,6 +2219,41 @@ export function createAgentEntry({
1998
2219
  // Deliberately NO `relay`/`enc_pub` on the card: those advertise a store-and-forward
1999
2220
  // mailbox, and an agent entry has no listener draining one. Advertising a mailbox nobody
2000
2221
  // reads is worse than advertising none — mail would queue at the relay forever.
2222
+ //
2223
+ // THE DOOR'S TERMS, IN STANDARD A2A SHAPE (E1). `securitySchemes` + `security` are the
2224
+ // fields A2A has for exactly this, and until now both were absent — so a card that
2225
+ // advertised a skill said nothing at all about HOW to call it, and the only way to learn
2226
+ // the requirement was to knock and be refused. That is the discovery step an HTTP-402
2227
+ // style protocol structurally cannot have and a card CAN; publishing it is what turns the
2228
+ // refusal below into a fallback instead of the only teacher.
2229
+ //
2230
+ // APPENDED LAST so every field an already-deployed entry publishes keeps its bytes AND
2231
+ // its position. `type` is deliberately NOT one of OpenAPI's five: `http` would claim an
2232
+ // `Authorization` header this door never reads, and a standard-but-wrong type makes a
2233
+ // conformant client do the wrong thing, where an unknown one makes it read the block
2234
+ // beside it. The scheme is named three times (key, `type`, `muretai.scheme`) and every
2235
+ // spelling is the same string the refusal returns.
2236
+ //
2237
+ // The description is IMPERATIVE for the same reason the requirement object is: a real
2238
+ // agent that started here read the terms, concluded correctly that it could not sign,
2239
+ // and stopped — "the card does not offer an alternative communication method". A card
2240
+ // that states a requirement and nothing about how to come to meet it is a locked door
2241
+ // with the lock documented. The worked-example clause appears only when the page it
2242
+ // names is known to resolve (see FIRST_KNOCK_URL); a 404 here would out-compete the
2243
+ // block beside it.
2244
+ card.securitySchemes = {
2245
+ [SIGNED_ENVELOPE_SCHEME]: {
2246
+ type: SIGNED_ENVELOPE_SCHEME,
2247
+ description: 'Generate an Ed25519 keypair if you do not have one - no account, no '
2248
+ + 'registration, any crypto library will do - then sign every message/send with '
2249
+ + 'the did:key you present as metadata.from. The step-by-step instruction, the '
2250
+ + 'signed fields, the canonical bytes and the address to send to are in `muretai` '
2251
+ + 'beside this line'
2252
+ + (FIRST_KNOCK_URL ? `; a worked example is at ${FIRST_KNOCK_URL}` : ''),
2253
+ muretai: requirement,
2254
+ },
2255
+ };
2256
+ card.security = [{ [SIGNED_ENVELOPE_SCHEME]: [] }];
2001
2257
 
2002
2258
  const cardBytes = Buffer.from(JSON.stringify(card), 'utf8'); // identical bytes on both paths
2003
2259
  // ACCOUNT DID -> {first_seen, last_seen, messages}. Keyed by the RESOLVED account (T102):
@@ -2091,15 +2347,26 @@ export function createAgentEntry({
2091
2347
  return out;
2092
2348
  }
2093
2349
 
2094
- /** The steering header for the notice route, or nothing. AI-agent families get a
2095
- * signpost to the machine-readable door; a browser or curl gets the same bytes it
2096
- * always got. HEADER-ONLY on purpose: the notice BODY is byte-identical for every
2097
- * caller, so observation stays invisible on the wire except for this one additive
2098
- * header and `rel="service-desc"` is RFC 8631's registered relation for exactly
2099
- * this ("service description primarily intended for consumption by machines"). */
2350
+ /** The `Link` header the notice route carries. TWO relations with different audiences,
2351
+ * in ONE header field (RFC 8288 allows several link-values in one field, and one field
2352
+ * is what keeps the two twins' bytes identical through their single-header plumbing):
2353
+ *
2354
+ * - the DOOR pointer, `rel="https://muretai.net/rel/agent-entry"`, for EVERY caller.
2355
+ * This is the coexistence primitive (E4): an agent that fetched a page finds the
2356
+ * machine-readable door in the RESPONSE, with no HTML to parse and no prose to
2357
+ * read, and a browser ignores it — which is what lets a site keep its own front
2358
+ * page and add ONE header instead of migrating. An absolute URI because RFC 8288
2359
+ * §2.1.2 permits a bare token only for an IANA-registered relation.
2360
+ * - `rel="service-desc"` (RFC 8631's registered relation for "service description …
2361
+ * primarily intended for consumption by machines"), FIRST and only for the UA
2362
+ * families that read as an AI agent — the T119 signpost, unchanged in meaning.
2363
+ *
2364
+ * HEADER-ONLY on purpose: the notice BODY is byte-identical for every caller, so what
2365
+ * the UA changes is still only this one additive relation and never a verdict. */
2100
2366
  function steerHeaders(family) {
2101
- if (!AI_AGENT_FAMILIES.has(family)) return {};
2102
- return { Link: `<${mount}${AGENT_CARD_PATH}>; rel="service-desc"` };
2367
+ const door = `<${mount}${AGENT_CARD_PATH}>; rel="${AGENT_ENTRY_REL}"`;
2368
+ if (!AI_AGENT_FAMILIES.has(family)) return { Link: door };
2369
+ return { Link: `<${mount}${AGENT_CARD_PATH}>; rel="service-desc", ${door}` };
2103
2370
  }
2104
2371
 
2105
2372
  /** Which stage a finished POST was, from OBSERVABLES only — the request bytes and the
@@ -2388,6 +2655,23 @@ export function createAgentEntry({
2388
2655
  if (!from || !to || !sig) {
2389
2656
  const bare = !from && !to && !sig;
2390
2657
  if (!(anonymousLane && bare)) {
2658
+ // THE REFUSAL TEACHES (E2) — but only the keyless walk-in. `data` keeps the human
2659
+ // string it always carried, under `detail`, and gains `accepts[]`: the ways in,
2660
+ // as an array, each naming its scheme. A visitor that has only this can mint a
2661
+ // did:key, sign the six fields and be answered on its next POST — which is the
2662
+ // whole point, because refusing an agent for not having a key it was never told
2663
+ // how to make is the error, not the key.
2664
+ //
2665
+ // A PARTIAL envelope (from/to present, sig stripped) gets the old refusal
2666
+ // unchanged. It is a DOWNGRADE ATTEMPT, not a walk-in: whoever sent it already
2667
+ // holds a key and already knows the shape, so there is nothing to teach and no
2668
+ // reason to hand a prober a machine-readable map of what to try next.
2669
+ if (bare) {
2670
+ return rpcError(reqId, ERRORS.UNAUTHENTICATED, {
2671
+ detail: 'missing signing envelope (from/to/sig)',
2672
+ accepts: [requirement],
2673
+ });
2674
+ }
2391
2675
  return rpcError(reqId, ERRORS.UNAUTHENTICATED, 'missing signing envelope (from/to/sig)');
2392
2676
  }
2393
2677
  // Anonymous: answer, signed by us, addressed to nobody. NO ledger row — an
@@ -2493,6 +2777,44 @@ export function createAgentEntry({
2493
2777
  return pathname === mount || pathname === `${mount}/`;
2494
2778
  }
2495
2779
 
2780
+ /** The GET routes this entry owns, built ONCE from the mount so a request path is a
2781
+ * lookup and never string arithmetic. On a GUEST mount the card is served at the
2782
+ * ORIGIN's well-known paths TOO — that is the only address a stranger's agent knows to
2783
+ * try (RFC 8615), and a door nobody can find is not a door. The card's `url` still names
2784
+ * the DOOR, which is ordinary A2A: the well-known location is where a card is
2785
+ * DISCOVERED, not the endpoint it describes. */
2786
+ const CARD_ROUTES = new Set([mount + AGENT_CARD_PATH, mount + AGENT_CARD_PATH_LEGACY]);
2787
+ const SIG_ROUTES = new Set([mount + AGENT_CARD_SIG_PATH]);
2788
+ if (guestMount) {
2789
+ CARD_ROUTES.add(AGENT_CARD_PATH).add(AGENT_CARD_PATH_LEGACY);
2790
+ SIG_ROUTES.add(AGENT_CARD_SIG_PATH);
2791
+ }
2792
+
2793
+ /** What `Allow:` may truthfully say about THIS path, or null when the entry does not own
2794
+ * it at all. RFC 9110 §10.2.1 makes `Allow` a statement about the target RESOURCE, so a
2795
+ * single server-wide list is a wrong answer to a right question — and on a guest mount
2796
+ * it would be worse than wrong: answering for `/` at all, even with a 204 and a header,
2797
+ * is speaking for the site's own front page, which is the one thing a guest mount must
2798
+ * never do. Whatever the GET route does for an address we do not own (404), OPTIONS and
2799
+ * the method table do the same. */
2800
+ function allowFor(pathname) {
2801
+ if (CARD_ROUTES.has(pathname) || SIG_ROUTES.has(pathname)) return ALLOW_CARD;
2802
+ if (isMountPath(pathname)) return guestMount ? ALLOW_DOOR : ALLOW_MOUNT;
2803
+ return null;
2804
+ }
2805
+
2806
+ /** The headers that state a resource's methods — BOTH spellings, always the same value.
2807
+ * `Allow` and `Access-Control-Allow-Methods` answer the same question for two different
2808
+ * readers, and a response that says `Allow: POST, OPTIONS` beside
2809
+ * `Access-Control-Allow-Methods: GET, POST, OPTIONS` contradicts itself in one message:
2810
+ * a browser-resident agent preflighting the guest door was told GET was on the menu at
2811
+ * the exact address whose GET is 405. `CORS_HEADERS` stays the origin-wide default
2812
+ * everywhere else (a card GET, a signed reply, a 404); it is narrowed only where the
2813
+ * resource's real method list is known, which is exactly where `Allow` is emitted. */
2814
+ function allowHeaders(allow) {
2815
+ return { Allow: allow, 'Access-Control-Allow-Methods': allow };
2816
+ }
2817
+
2496
2818
  function route(method, path, bodyBuffer, headers) {
2497
2819
  // Classified ONCE per request, used only to count and to signpost. Everything the
2498
2820
  // ladder decides is decided exactly as if this line did not exist.
@@ -2514,21 +2836,27 @@ export function createAgentEntry({
2514
2836
  // appends a well-known path), so this is not a new convention; it is the one the
2515
2837
  // fetcher already follows. With mount === '' these are the original constants.
2516
2838
  if (method === 'GET' || method === 'HEAD') {
2517
- if (pathname === mount + AGENT_CARD_PATH || pathname === mount + AGENT_CARD_PATH_LEGACY) {
2518
- // Byte-identical on both paths: the current A2A path and the legacy alias.
2839
+ if (CARD_ROUTES.has(pathname)) {
2840
+ // Byte-identical on every path: the current A2A path, the legacy alias, and (on a
2841
+ // guest mount) the origin's well-known copy of both. Which address a client
2842
+ // happened to fetch must never change what it believes about this DID.
2519
2843
  tally(family, 'card_get');
2520
2844
  // T107: identify (count), never enrol, never change a byte. Runs only after a
2521
2845
  // route MATCHED, so refused/404 paths never pay for crypto.
2522
2846
  wbaObserve(headers);
2523
2847
  return { status: 200, headers: cardHeaders(cardBytes.length), body: cardBytes };
2524
2848
  }
2525
- if (pathname === mount + AGENT_CARD_SIG_PATH) {
2849
+ if (SIG_ROUTES.has(pathname)) {
2526
2850
  const env = cardEnvelopeBytes();
2527
2851
  tally(family, 'card_get');
2528
2852
  wbaObserve(headers);
2529
2853
  return { status: 200, headers: cardHeaders(env.length), body: env };
2530
2854
  }
2531
- if (isMountPath(pathname)) {
2855
+ // The human notice — NOT served on a guest mount, where GET belongs to the site (E3).
2856
+ // Falling through to 404 is deliberate and is the same non-disclosure the POST route
2857
+ // already makes: an entry beside other agents does not confirm what lives at an
2858
+ // address it was not given.
2859
+ if (!guestMount && isMountPath(pathname)) {
2532
2860
  tally(family, 'notice_get');
2533
2861
  wbaObserve(headers);
2534
2862
  const body = Buffer.from(
@@ -2547,6 +2875,22 @@ export function createAgentEntry({
2547
2875
  ...steerHeaders(family) },
2548
2876
  body };
2549
2877
  }
2878
+ // A GUEST MOUNT'S DOOR ANSWERS GET WITH 405, NOT 404 (proof run 3, 2026-08-18).
2879
+ // Run A guessed the door path CORRECTLY, GET it, and was told nothing was there —
2880
+ // its one correct guess, refuted. This is NOT the
2881
+ // DECISION(non-door-post-answers-404-not-405) non-disclosure case, and the
2882
+ // difference is where the address came from: that decision protects a path a caller
2883
+ // GUESSED AT RANDOM, where a 405 would confirm a door it has no right to know about.
2884
+ // The door's address is PUBLISHED, in the card, signed, at a well-known path — so
2885
+ // hiding it from a GET conceals nothing from anyone and costs a visitor the one
2886
+ // thing it got right. `allowFor` is non-null here only for the guest door: card and
2887
+ // signature routes matched above, and a site-owning mount already returned its
2888
+ // notice. A path this entry does not own still falls through to 404, and a guest
2889
+ // mount still answers nothing it does not own.
2890
+ const getAllow = allowFor(pathname);
2891
+ if (getAllow !== null) {
2892
+ return jsonResponse(405, { error: 'method not allowed' }, allowHeaders(getAllow));
2893
+ }
2550
2894
  return jsonResponse(404, { error: 'not found' });
2551
2895
  }
2552
2896
  if (method === 'POST') {
@@ -2564,12 +2908,23 @@ export function createAgentEntry({
2564
2908
  tally(family, postStage(buf, out));
2565
2909
  return out;
2566
2910
  }
2911
+ // Everything below answers for a RESOURCE, so an address this entry does not own is a
2912
+ // 404 first — the same answer GET and POST give it. Before this, OPTIONS 204'd for
2913
+ // EVERY path (a guest mount would have spoken for the site's front page one verb over)
2914
+ // and the 405 carried no `Allow` at all, which RFC 9110 §15.5.6 REQUIRES.
2915
+ const allow = allowFor(pathname);
2916
+ if (allow === null) return jsonResponse(404, { error: 'not found' });
2567
2917
  if (method === 'OPTIONS') {
2918
+ // The CORS PREFLIGHT lands here: a browser-resident agent POSTing application/json
2919
+ // is not a simple request, so this answer is what decides whether the POST is ever
2920
+ // sent. BOTH method statements narrow to the resource: `Allow` and
2921
+ // `Access-Control-Allow-Methods` are the same fact for two readers, and this is the
2922
+ // one response where the browser reader acts on it.
2568
2923
  return { status: 204,
2569
- headers: { Allow: 'GET, POST, OPTIONS', 'Content-Length': '0', ...CORS_HEADERS },
2924
+ headers: { 'Content-Length': '0', ...CORS_HEADERS, ...allowHeaders(allow) },
2570
2925
  body: Buffer.alloc(0) };
2571
2926
  }
2572
- return jsonResponse(405, { error: 'method not allowed' });
2927
+ return jsonResponse(405, { error: 'method not allowed' }, allowHeaders(allow));
2573
2928
  }
2574
2929
 
2575
2930
  function cardHeaders(length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muretai/agent-entry",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Make your website answer AI agents: an A2A agent endpoint that verifies who is knocking, opens an account for them and replies signed, in one HTTP round trip. Zero dependencies. Pairs with llms.txt and WebMCP.",
5
5
  "type": "module",
6
6
  "main": "muretai-agent-entry.mjs",