@muretai/agent-entry 1.8.0 → 1.10.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
@@ -105,6 +105,7 @@ answers questions and hands off nothing, is a signed claim you cannot keep.
105
105
  |---|---|---|
106
106
  | `skills` | `[]` | the menu above — what a visitor learns before knocking |
107
107
  | `openDoor` | `true` | publishes `agentEntry.open_door`: the field that tells a visiting agent it may message you with no introduction. The same fact is emitted under the older `muretai.open_door` spelling beside it — read either, write the neutral one |
108
+ | `prefer` | unset | your own order of the ways into your site, published verbatim as `agentEntry.prefer` (spec AE-30): an array of `"page"`, `"card"`, `"mcp"` or `{kind, when}` with `when` one of `person`, `alone`, `key`, `no-key`, `token`, `browser` — e.g. `[{kind:'page', when:'no-key'}, 'card']` says "read on the page if you hold no key; otherwise the door". A visiting agent reads it against what it has on hand. An invalid list refuses to start rather than publish an order you did not write; unset publishes no key at all |
108
109
  | `anonymousLane` | `false` | also answer **unsigned** inquiries. They create no account row, and the lane is capped entry-wide — an unauthenticated caller must never become an unmetered signing oracle |
109
110
  | `anonRatePerMin` | `30` | anonymous replies per minute, entry-wide |
110
111
  | `signedRatePerMin` | `60` | signed replies per minute **per account**, ON by default. Attribution is not scarcity: a `did:key` costs nothing to mint, so being in your ledger was never a bound |
@@ -114,7 +115,7 @@ answers questions and hands off nothing, is a signed claim you cannot keep.
114
115
  | `domains` | none | the domains this entry speaks for (see below) |
115
116
  | `basePath` | from `baseUrl` | the path this entry answers at, derived rather than set beside it |
116
117
  | `wbaVerifiers` | none | a JWKS document (`{"keys":[…]}`) of Ed25519 keys whose holders this entry should **recognise** on inbound signed requests (Web Bot Auth / RFC 9421 — see *Who is knocking*). Recognition only adds `env.wba_did` and a visit count; it never changes a verdict |
117
- | `observer` | none | called once per message with the same envelope your responder gets, plus `stage`, `identified` and `ua_family`, **after** the verdict — for counting, logging, analytics. It cannot matter: its return is discarded, a throw is swallowed, a promise is never awaited, so a slow or broken watcher cannot delay or change one byte of the signed reply. See [Counting visits](#counting-visits-without-handing-over-your-customer-list) |
118
+ | `observer` | none | called once per message with the same envelope your responder gets, plus `stage`, `identified`, `ua_family` and `client_class`, **after** the verdict — for counting, logging, analytics. It cannot matter: its return is discarded, a throw is swallowed, a promise is never awaited, so a slow or broken watcher cannot delay or change one byte of the signed reply. See [Counting visits](#counting-visits-without-handing-over-your-customer-list) |
118
119
  | `howToUrl` | none | a page a keyless visitor is pointed at as a worked example. **Empty means omitted** — the refusal already teaches the whole recipe without it, and a reference implementation must not stamp somebody else's docs host into every door built from it. Only set it to a URL you operate, and only after checking it resolves |
119
120
  | `name`, `description`, `version` | — | the card's own words. `description` is the line a person reads in a directory listing — and the right place to say what you record about visitors, since it is fetched **before** the knock |
120
121
 
@@ -131,12 +132,21 @@ door counts it:
131
132
  ```js
132
133
  entry.stats()
133
134
  // { gptbot: { card_get: 12, signed_post: 3 },
134
- // browser: { notice_get: 5 } }
135
+ // browser: { notice_get: 5, card_get: 2 } }
136
+
137
+ entry.clientStats()
138
+ // { 'declared-agent': { card_get: 12, signed_post: 3 },
139
+ // 'human-like': { notice_get: 5 },
140
+ // 'stealth-agent': { card_get: 2 } }
135
141
  ```
136
142
 
137
143
  Each request's `User-Agent` is classified into a fixed family (`claude-user`,
138
144
  `claudebot`, `gptbot`, `openai`, `perplexity`, `google-extended`, `muretai-node`,
139
- `curl`, `browser`, `none`/`other`) and counted by stage. In-process state like the
145
+ `camoufox`, `playwright`, `puppeteer`, `selenium`, `headless-chrome`,
146
+ `curl`, `browser`, `none`/`other`) and counted by stage. `clientStats()` then
147
+ folds those families into four owner-facing classes — `declared-agent`,
148
+ `named-tool`, `stealth-agent`, `human-like` — so a Firefox-looking fetch of the
149
+ card is counted as a stealth agent, not as a person. In-process state like the
140
150
  ledger — read it, log it, ship it to your analytics; it is never served on the wire.
141
151
  Every caller also gets one nudge: `GET /` answers with a single `Link:` field carrying
142
152
  two relations — `rel="service-desc"` (RFC 8631) first, then the door pointer
@@ -154,6 +164,15 @@ five stages now carry it, refusals included, so "which clients got in and which
154
164
  is one query instead of two half-answers. Nothing else moved: no wire byte, no verdict, no ledger
155
165
  row, no rate lane, and `stats()` is unchanged.
156
166
 
167
+ **Since 1.9.0 a Firefox-looking card fetch is not a human.** `stats()` still files it under
168
+ `browser` — Camoufox's published shape is a clean Firefox UA, and that is the point of
169
+ stealth. `clientStats()` splits it: a browser that only opened the notice is `human-like`;
170
+ the same UA on the card or the door is `stealth-agent`; a leaking automation token
171
+ (`playwright`, `camoufox`, …) is `named-tool`. Export `bodySignpost()` and put that
172
+ `<a>` in the page body, because a snapshot client never sees the header or the
173
+ `<head>` tag. Still observation only — the same POST with or without that UA is the
174
+ same refusal.
175
+
157
176
  One rule holds this together, enforced by the contract suite rather than promised:
158
177
  **a User-Agent never affects `verified`, an account row, a rate limit, or any
159
178
  refusal.** A UA string is written by the client; a door that trusted it would be a
@@ -228,8 +247,8 @@ framework, an edge worker — that notice never renders**, and your home page is
228
247
  people with nothing machine-readable in it. The address ends up published in a card nobody was
229
248
  told to fetch.
230
249
 
231
- So put the pointer on every page a visitor might land on, in **both** spellings. Neither is a
232
- fallback for the other:
250
+ So put the pointer on every page a visitor might land on, in **all three** spellings. None is a
251
+ fallback for the others:
233
252
 
234
253
  ```
235
254
  Link: </.well-known/agent-card.json>; rel="https://muretai.net/rel/agent-entry"
@@ -237,13 +256,17 @@ Link: </.well-known/agent-card.json>; rel="https://muretai.net/rel/agent-entry"
237
256
 
238
257
  ```html
239
258
  <link rel="https://muretai.net/rel/agent-entry" href="/.well-known/agent-card.json">
259
+ <a href="/.well-known/agent-card.json" rel="https://muretai.net/rel/agent-entry">This site answers agents at /.well-known/agent-card.json</a>
240
260
  ```
241
261
 
242
262
  The relation is an opaque **identifier**, matched as a string — nothing about resolving an agent
243
- endpoint requires a request to that host. The two spellings exist because the two kinds of client
263
+ endpoint requires a request to that host. The three spellings exist because three kinds of client
244
264
  have opposite blind spots: an agent that fetches with a plain `curl` (no `-i`) never sees the
245
- header, and one that reads only headers never parses the HTML. Shipping one is a coin flip on
246
- which kind arrived.
265
+ header; one that reads only headers never parses the HTML; and a snapshot / ARIA client
266
+ (Camofox, Playwright accessibility dumps) sees only `<body>`, so the `<link>` in `<head>`
267
+ vanishes too. Shipping one is a coin flip on which kind arrived. The module exports
268
+ `bodySignpost()` so the body `<a>` is one function call, not a string you have to keep in
269
+ sync with the relation URI.
247
270
 
248
271
  We know because we shipped one. An agent that had never been told about our door was handed only
249
272
  the domain, fetched the page, read the copy written for humans, and stopped — while the door had
@@ -253,13 +276,13 @@ Then check it from outside, because this is exactly the class of thing that look
253
276
 
254
277
  ```bash
255
278
  curl -sI https://studio.example/ | grep -i '^link:' # the header half
256
- curl -s https://studio.example/ | grep 'rel/agent-entry' # the tag half
279
+ curl -s https://studio.example/ | grep 'rel/agent-entry' # the tag half AND the body <a>
257
280
  ```
258
281
 
259
- Worth knowing before you call it done: **both halves disappear in a fetch that converts the page
260
- to markdown**, which is a common way an agent reads the web headers are dropped and so is
261
- everything in `<head>`. No tag survives that. The only remedy is prose: say in the visible body
262
- that agents are answered here, and name the card path in text a reader can act on.
282
+ Worth knowing before you call it done: **the header and the `<head>` tag disappear in a fetch
283
+ that converts the page to markdown, and in an accessibility snapshot that only sees `<body>`**
284
+ a common way an agent browser reads the web. The body `<a>` is the spelling those clients
285
+ can still see.
263
286
 
264
287
  ### Check that your own CDN is not refusing your door
265
288
 
@@ -359,7 +382,8 @@ node examples/server.mjs # prints its DID and card URL
359
382
  Environment: `AGENT_ENTRY_SEED_HEX` (generated and printed if absent — **persist it, it is
360
383
  your site's identity**), `AGENT_ENTRY_PORT` (8788), `AGENT_ENTRY_BASE_URL`,
361
384
  `AGENT_ENTRY_NAME`, `AGENT_ENTRY_ANON` (`1` also accepts unsigned inquiries, which create
362
- no account).
385
+ no account), `AGENT_ENTRY_PREFER` (your order of the ways in, as one JSON array — see
386
+ `prefer` above; an invalid list refuses to start).
363
387
 
364
388
  ## What `baseUrl` may be
365
389
 
@@ -43,6 +43,10 @@
43
43
  * https://example.com/agent) and answers NOTHING at `/` — no notice,
44
44
  * no OPTIONS, no POST. Point your proxy at the door path and the
45
45
  * well-known paths; the site keeps everything else, unchanged.
46
+ * AGENT_ENTRY_PREFER OPTIONAL: the site's own order of its ways in, as one JSON array
47
+ * (AE-30), e.g. '[{"kind":"page","when":"no-key"},"card"]' — "read on
48
+ * the page if you hold no key; otherwise the door". Published verbatim
49
+ * as `agentEntry.prefer`; an invalid list REFUSES to start.
46
50
  * AGENT_ENTRY_WBA_JWKS OPTIONAL: a JWKS document {"keys":[…]} as one JSON string —
47
51
  * the Web Bot Auth key directory (verified out of band) whose
48
52
  * holders this entry should RECOGNISE on inbound requests. Off
@@ -156,6 +160,10 @@ try {
156
160
  ...(process.env.AGENT_ENTRY_SIGNED_RATE_TOTAL
157
161
  ? { signedRatePerMinTotal: Number(process.env.AGENT_ENTRY_SIGNED_RATE_TOTAL) } : {}),
158
162
  guest: process.env.AGENT_ENTRY_GUEST === '1',
163
+ // AE-30: the site's own order of its ways in, a JSON array. Malformed JSON or an
164
+ // unknown kind/condition throws inside this try and the entry never starts — the same
165
+ // posture as a bad domain list: never publish a statement the operator did not make.
166
+ ...(process.env.AGENT_ENTRY_PREFER ? { prefer: JSON.parse(process.env.AGENT_ENTRY_PREFER) } : {}),
159
167
  wbaVerifiers,
160
168
  });
161
169
  } catch (err) {
@@ -183,17 +191,24 @@ const server = entry.listen(port, host, () => {
183
191
  // printed only when they changed. `[ua]` is greppable; the shape is entry.stats()
184
192
  // verbatim ({family: {stage: n}}). `unref()` so the timer never holds the process open.
185
193
  let lastStats = '';
194
+ let lastClient = '';
186
195
  setInterval(() => {
187
196
  // Sorted keys at every level so the line is stable run to run (and diffable against
188
197
  // the Python runner's `json.dumps(..., sort_keys=True)` spelling of the same shape).
189
- const line = JSON.stringify(entry.stats(), (k, v) =>
198
+ const stable = (obj) => JSON.stringify(obj, (k, v) =>
190
199
  (v && typeof v === 'object' && !Array.isArray(v))
191
200
  ? Object.fromEntries(Object.keys(v).sort().map((key) => [key, v[key]]))
192
201
  : v);
202
+ const line = stable(entry.stats());
193
203
  if (line !== '{}' && line !== lastStats) {
194
204
  console.log(`[ua] ${line}`);
195
205
  lastStats = line;
196
206
  }
207
+ const clientLine = stable(entry.clientStats());
208
+ if (clientLine !== '{}' && clientLine !== lastClient) {
209
+ console.log(`[client] ${clientLine}`);
210
+ lastClient = clientLine;
211
+ }
197
212
  }, 60_000).unref();
198
213
 
199
214
  // A port collision is the first thing anyone running this twice hits (a previous run that was
@@ -107,6 +107,17 @@ export const SIGNED_ENVELOPE_SCHEME = 'did-key-ed25519';
107
107
  * examples/agent_entry_reference.py. */
108
108
  export const AGENT_ENTRY_REL = 'https://muretai.net/rel/agent-entry';
109
109
 
110
+ /** The body `<a>` a site puts on its own front page so a snapshot / ARIA / `a[href]`
111
+ * client (Camofox, Playwright accessibility dumps, markdown converters) can still find
112
+ * the door. `<link rel>` in `<head>` and the HTTP `Link` header both vanish in those
113
+ * views; an in-body anchor with this relation does not. `href` is the card path this
114
+ * entry actually answers (mount-prefixed when the entry sits under a path). Must match
115
+ * `body_signpost` in examples/agent_entry_reference.py. */
116
+ export function bodySignpost(href = AGENT_CARD_PATH) {
117
+ const path = typeof href === 'string' && href ? href : AGENT_CARD_PATH;
118
+ return `<a href="${path}" rel="${AGENT_ENTRY_REL}">This site answers agents at ${path}</a>`;
119
+ }
120
+
110
121
  /** Where a keyless visitor is sent to learn how to mint an identity and sign. It rides in
111
122
  * the card AND in the refusal, so an agent that has only one of the two still has the URL.
112
123
  *
@@ -332,6 +343,16 @@ export const UA_FAMILIES = [
332
343
  ['perplexity', 'perplexity'],
333
344
  ['google-extended', 'google-extended'],
334
345
  ['muretai-node', 'muretai-node'],
346
+ // Leaking automation UAs. Must sit BEFORE `mozilla`: Camoufox/Playwright Firefox UAs
347
+ // start with "Mozilla/5.0 …" and a needle after that would never fire. A patched
348
+ // Camoufox sends a clean Firefox UA and classifies as `browser` — that is the
349
+ // stealth-agent case `clientClass` exists for, not a miss in this table.
350
+ ['camoufox', 'camoufox'],
351
+ ['camofox', 'camoufox'],
352
+ ['playwright', 'playwright'],
353
+ ['puppeteer', 'puppeteer'],
354
+ ['selenium', 'selenium'],
355
+ ['headlesschrome', 'headless-chrome'],
335
356
  ['curl', 'curl'],
336
357
  ['mozilla', 'browser'],
337
358
  ];
@@ -346,6 +367,20 @@ export const AI_AGENT_FAMILIES = new Set([
346
367
  'claude-user', 'claudebot', 'gptbot', 'openai', 'perplexity', 'google-extended',
347
368
  ]);
348
369
 
370
+ /** Automation / HTTP-tool families that named themselves in the UA. OBSERVATION ONLY —
371
+ * same rule as the rest of this table: never identity, never a verdict. Must match
372
+ * `NAMED_TOOL_FAMILIES` in examples/agent_entry_reference.py. */
373
+ export const NAMED_TOOL_FAMILIES = new Set([
374
+ 'camoufox', 'playwright', 'puppeteer', 'selenium', 'headless-chrome', 'curl',
375
+ ]);
376
+
377
+ /** The four owner-facing traffic classes `clientClass` may return. Bounded on purpose:
378
+ * an attacker-chosen UA must never become a class name. Must match
379
+ * `CLIENT_CLASSES` in examples/agent_entry_reference.py. */
380
+ export const CLIENT_CLASSES = new Set([
381
+ 'declared-agent', 'named-tool', 'stealth-agent', 'human-like',
382
+ ]);
383
+
349
384
  /** ASCII-only lowercase fold. NOT `toLowerCase()`: Unicode casing is runtime- and
350
385
  * locale-shaped (the Turkish-I class of surprise), and no needle in the table needs it —
351
386
  * folding only A-Z is what makes the same UA string classify identically in both twins. */
@@ -359,7 +394,7 @@ function asciiLower(s) {
359
394
  }
360
395
 
361
396
  /** UA string -> family. Absent/empty/non-string -> 'none'; no needle matched -> 'other'.
362
- * Total on untrusted input, and the RETURN VALUE is always one of the twelve fixed
397
+ * Total on untrusted input, and the RETURN VALUE is always one of the fixed
363
398
  * family names — never a substring of the input (bounded stats keyspace). */
364
399
  export function uaFamily(ua) {
365
400
  if (typeof ua !== 'string' || !ua) return 'none';
@@ -370,6 +405,23 @@ export function uaFamily(ua) {
370
405
  return 'other';
371
406
  }
372
407
 
408
+ /** Owner-facing traffic class. OBSERVATION ONLY — never `verified`, never a ledger row,
409
+ * never a rate lane, never a refusal. The split an operator asked for: tell agent
410
+ * traffic from human traffic WITHOUT treating a spoofable UA as a credential.
411
+ *
412
+ * Camoufox / patched Playwright send a clean Firefox UA, so `uaFamily` says `browser`.
413
+ * That is not a human reading the homepage. A human Firefox almost never GETs the
414
+ * agent card or POSTs the door; a stealth agent that found the door does. The one
415
+ * human-shaped case at the door is `browser` + `notice_get` (someone opened the
416
+ * address as a document on a site-owning mount). Must match `client_class` in
417
+ * examples/agent_entry_reference.py. */
418
+ export function clientClass(family, stage) {
419
+ if (AI_AGENT_FAMILIES.has(family) || family === 'muretai-node') return 'declared-agent';
420
+ if (NAMED_TOOL_FAMILIES.has(family)) return 'named-tool';
421
+ if (family === 'browser' && stage === 'notice_get') return 'human-like';
422
+ return 'stealth-agent';
423
+ }
424
+
373
425
  /** The FIRST User-Agent value out of a headers mapping, or null. Case-insensitive key
374
426
  * scan so an in-process host can pass any casing; Node's own `req.headers` already
375
427
  * lowercases keys and keeps only the FIRST user-agent of a duplicated pair — the Python
@@ -2096,6 +2148,43 @@ function domainFix(candidate) {
2096
2148
  * refuses every other bad value there. Truncating would start the entry with a claim that
2097
2149
  * is USABLE and NOT WHAT THEY SAID.
2098
2150
  */
2151
+ /** The kinds a visitor can take into a site, and the conditions a site may attach. Kept
2152
+ * identical to the visitor side (Agent Web Router `parsePrefer`): a kind or condition one
2153
+ * side knows and the other does not is a declaration one side silently drops. */
2154
+ export const PREFER_KINDS = ['page', 'card', 'mcp'];
2155
+ export const PREFER_WHEN = ['person', 'alone', 'key', 'no-key', 'token', 'browser'];
2156
+
2157
+ /**
2158
+ * The exact `agentEntry.prefer` this entry may publish, or a TypeError (AE-30).
2159
+ *
2160
+ * The site's own order of its ways in — "read on the page if you have no key, then the
2161
+ * door", say. VALIDATED, NEVER REWRITTEN: this goes on a SIGNED card, and a card that says
2162
+ * something the operator did not write is a worse card than none, so an unknown kind, an
2163
+ * unknown condition or a stray key refuses the whole declaration instead of trimming it —
2164
+ * the same posture as `canonicalDomains`. `null`/`undefined` means "not configured", and
2165
+ * then no `prefer` key is published at all, which is what keeps an already-deployed
2166
+ * entry's bytes unchanged.
2167
+ */
2168
+ export function validatePrefer(prefer) {
2169
+ if (prefer == null) return null;
2170
+ if (!Array.isArray(prefer) || prefer.length === 0) {
2171
+ throw new TypeError('agentEntry.prefer must be a non-empty array of "page" | "card" | "mcp" or {kind, when}');
2172
+ }
2173
+ for (const e of prefer) {
2174
+ if (typeof e === 'string') {
2175
+ if (!PREFER_KINDS.includes(e)) throw new TypeError(`agentEntry.prefer: unknown kind ${JSON.stringify(e)}`);
2176
+ continue;
2177
+ }
2178
+ if (!e || typeof e !== 'object' || Array.isArray(e)) throw new TypeError('agentEntry.prefer: an entry must be a kind or {kind, when}');
2179
+ const keys = Object.keys(e);
2180
+ if (!PREFER_KINDS.includes(e.kind)) throw new TypeError(`agentEntry.prefer: unknown kind ${JSON.stringify(e.kind)}`);
2181
+ if ('when' in e && !PREFER_WHEN.includes(e.when)) throw new TypeError(`agentEntry.prefer: unknown condition ${JSON.stringify(e.when)}`);
2182
+ const stray = keys.filter((k) => k !== 'kind' && k !== 'when');
2183
+ if (stray.length) throw new TypeError(`agentEntry.prefer: unexpected key(s) ${stray.join(', ')}`);
2184
+ }
2185
+ return prefer;
2186
+ }
2187
+
2099
2188
  export function canonicalDomains(domains, { warn = true } = {}) {
2100
2189
  if (domains === undefined || domains === null) return [];
2101
2190
  if (!Array.isArray(domains)) {
@@ -2217,6 +2306,11 @@ export function canonicalMount(canonUrl, basePath) {
2217
2306
  * responder (envelope) => string | {text, contextId?, timestamp?} | Promise<…>
2218
2307
  * openDoor advertise `muretai.open_door` (default true) — the flag that tells a
2219
2308
  * visiting agent it may contact you without an introduction.
2309
+ * prefer OPTIONAL: the site's own order of its ways in, published as
2310
+ * `agentEntry.prefer` (AE-30) — e.g. `[{kind:'page', when:'no-key'}, 'card']`
2311
+ * says "read on the page if you hold no key; otherwise the door". Validated
2312
+ * by `validatePrefer`; an invalid list throws, so the entry never starts
2313
+ * with a statement the operator did not make. Absent = no key published.
2220
2314
  * anonymousLane also accept UNSIGNED inquiries (default false). They create no account,
2221
2315
  * and the lane as a whole is capped at `anonRatePerMin` signed replies per
2222
2316
  * minute — it is unauthenticated, so it must not be an unmetered signing
@@ -2254,11 +2348,14 @@ export function canonicalMount(canonUrl, basePath) {
2254
2348
  * callback, and a watcher that dialled out on the hot path would make the
2255
2349
  * visitor's answer depend on somebody else's uptime.
2256
2350
  *
2257
- * WHAT IT IS TOLD. Every stage reports `stage`, `identified` and
2351
+ * WHAT IT IS TOLD. Every stage reports `stage`, `identified`,
2258
2352
  * `ua_family` — the door's own bounded classification of the client, one
2259
2353
  * of the fixed `UA_FAMILIES` names and NEVER a substring of what the caller
2260
- * sent, so a stranger cannot write its own label into your metrics. The
2261
- * POST stages add the envelope on top.
2354
+ * sent, so a stranger cannot write its own label into your metrics — and
2355
+ * `client_class`, the four-way split (`declared-agent` / `named-tool` /
2356
+ * `stealth-agent` / `human-like`) that tells an operator whether a
2357
+ * Firefox-looking knock is a stealth agent at the door or a human who
2358
+ * opened the notice. The POST stages add the envelope on top.
2262
2359
  *
2263
2360
  * WHAT NOT TO PUT IN IT. The envelope carries `peer_did`/`owner_did`,
2264
2361
  * which a visitor handed you to transact with YOU. Forwarding a raw DID to
@@ -2275,6 +2372,7 @@ export function createAgentEntry({
2275
2372
  version = '1',
2276
2373
  responder = () => 'Thanks — a human will follow up.',
2277
2374
  openDoor = true,
2375
+ prefer = null,
2278
2376
  anonymousLane = false,
2279
2377
  anonRatePerMin = ANON_RATE_PER_MIN,
2280
2378
  signedRatePerMin = SIGNED_RATE_PER_MIN,
@@ -2349,7 +2447,10 @@ export function createAgentEntry({
2349
2447
  // Neutral key first, vendor key beside it for one release. See the securitySchemes block
2350
2448
  // below for why the old spelling stays: a consumer must learn the new name BEFORE
2351
2449
  // producers stop emitting the old one, never after.
2352
- if (openDoor) card.agentEntry = { open_door: true };
2450
+ // AE-30: the site's order rides on the NEUTRAL key only; the alias stays `open_door`
2451
+ // alone, so an old consumer that compares the two aliases byte for byte keeps passing.
2452
+ const canonPrefer = validatePrefer(prefer);
2453
+ if (openDoor) card.agentEntry = { open_door: true, ...(canonPrefer ? { prefer: canonPrefer } : {}) };
2353
2454
  if (openDoor) card.muretai = { open_door: true };
2354
2455
  // Deliberately NO `relay`/`enc_pub` on the card: those advertise a store-and-forward
2355
2456
  // mailbox, and an agent entry has no listener draining one. Advertising a mailbox nobody
@@ -2498,6 +2599,7 @@ export function createAgentEntry({
2498
2599
  // composition to any stranger). Keyspace bounded by the fixed UA_FAMILIES table times
2499
2600
  // five stage names — an attacker choosing UA strings cannot grow it.
2500
2601
  const uaStats = new Map();
2602
+ const clientStatsMap = new Map();
2501
2603
 
2502
2604
  /** Count the stage, and tell the watcher about it.
2503
2605
  *
@@ -2522,9 +2624,14 @@ export function createAgentEntry({
2522
2624
  let row = uaStats.get(family);
2523
2625
  if (!row) { row = new Map(); uaStats.set(family, row); }
2524
2626
  row.set(stage, (row.get(stage) || 0) + 1);
2627
+ const cls = clientClass(family, stage);
2628
+ let crow = clientStatsMap.get(cls);
2629
+ if (!crow) { crow = new Map(); clientStatsMap.set(cls, crow); }
2630
+ crow.set(stage, (crow.get(stage) || 0) + 1);
2525
2631
  if (typeof observer !== 'function') return;
2526
2632
  if (stage === 'card_get' || stage === 'notice_get') {
2527
2633
  observe({ stage, identified: 0, verified: false, ua_family: family,
2634
+ client_class: cls,
2528
2635
  peer_did: null, owner_did: null, wba_did: null, text: null });
2529
2636
  }
2530
2637
  }
@@ -2539,6 +2646,17 @@ export function createAgentEntry({
2539
2646
  return out;
2540
2647
  }
2541
2648
 
2649
+ /** A plain JSON-able copy of the traffic-class counters: { class: { stage: n } }.
2650
+ * Same contract as `stats()`: in-process only, never on the wire, bounded keyspace. */
2651
+ function clientStats() {
2652
+ const out = {};
2653
+ for (const [cls, row] of clientStatsMap) {
2654
+ out[cls] = {};
2655
+ for (const [stage, n] of row) out[cls][stage] = n;
2656
+ }
2657
+ return out;
2658
+ }
2659
+
2542
2660
  /** The `Link` header the notice route carries — the SAME one-field value for EVERY
2543
2661
  * caller. TWO relations in ONE header field (RFC 8288 allows several link-values in
2544
2662
  * one field, and one field is what keeps the two twins' bytes identical through their
@@ -2843,6 +2961,7 @@ export function createAgentEntry({
2843
2961
  if (typeof observer !== 'function' || lastRefusal === null) return;
2844
2962
  observe({ verified: false, refused: lastRefusal, stage: 'refused_post',
2845
2963
  identified: pendingStage === 'signed_post' ? 1 : 0, ua_family: pendingFamily,
2964
+ client_class: clientClass(pendingFamily, 'refused_post'),
2846
2965
  peer_did: null, owner_did: null, wba_did: null, text: null });
2847
2966
  }
2848
2967
 
@@ -3089,6 +3208,7 @@ export function createAgentEntry({
3089
3208
  // arrived. `identified` is read off the envelope rather than the stage, because the
3090
3209
  // anonymous lane answers a visitor who genuinely presented no DID.
3091
3210
  observe({ ...env, stage: pendingStage, ua_family: pendingFamily,
3211
+ client_class: clientClass(pendingFamily, pendingStage),
3092
3212
  identified: env && env.peer_did ? 1 : 0 });
3093
3213
  let answer;
3094
3214
  try {
@@ -3236,6 +3356,24 @@ export function createAgentEntry({
3236
3356
  // and when this entry is mounted under a path, "anywhere else" INCLUDES the bare
3237
3357
  // host, which belongs to the site (or to the neighbour agent) and not to us.
3238
3358
  if (!isMountPath(pathname)) return jsonResponse(404, { error: 'not found' });
3359
+ // A QUERY STRING MEANS THE POST IS NOT OURS. The door's address is the signed
3360
+ // card's `url`, byte-exact: a base URL carrying a query is refused at startup, and
3361
+ // a visitor's walk drops any query it was handed before it POSTs the card's `url` —
3362
+ // so no conformant caller can arrive here, while a site's own query-multiplexed
3363
+ // traffic (`?wc-api=`, `?wc-ajax=`, `?rest_route=`) always does. Disjoint BY
3364
+ // CONSTRUCTION, which is what makes this a rule and not a heuristic. Measured:
3365
+ // WooCommerce Stripe delivers its only webhook to `/?wc-api=wc_stripe` (path `/`,
3366
+ // application/json); a bare-origin door that claimed it answered HTTP 200/-32601
3367
+ // echoing the event id, the sender recorded the event as delivered and never
3368
+ // retried — payment events lost SILENTLY. The answer is the unowned-path 404
3369
+ // above, same bytes, one meaning — the DECISION(non-door-post-answers-404-not-405)
3370
+ // non-disclosure again, because the caller most likely to land here is the site's
3371
+ // own webhook, which deserves the most anonymous answer, never a door verdict.
3372
+ const hashless = target.split('#')[0];
3373
+ const queryAt = hashless.indexOf('?');
3374
+ if (queryAt !== -1 && queryAt + 1 < hashless.length) {
3375
+ return jsonResponse(404, { error: 'not found' });
3376
+ }
3239
3377
  const buf = bodyBuffer || Buffer.alloc(0);
3240
3378
  const out = handlePost(buf, headers);
3241
3379
  // The stage is read off the finished answer, so an async responder tallies when it
@@ -3367,9 +3505,10 @@ export function createAgentEntry({
3367
3505
 
3368
3506
  // `mount` is exported so a host app can route exactly what this entry answers (and log
3369
3507
  // it): it is derived, so reading it here can never disagree with the signed card.
3370
- // `stats` is the owner-facing UA-family counters and `wbaVisits` the DID->count of
3371
- // WBA-verified fetches both in-process only, like `ledger`.
3372
- return { did, card, ledger, mount, stats, wbaVisits,
3508
+ // `stats` is the owner-facing UA-family counters, `clientStats` the four-way
3509
+ // traffic-class split, and `wbaVisits` the DID->count of WBA-verified fetches
3510
+ // all in-process only, like `ledger`.
3511
+ return { did, card, ledger, mount, stats, clientStats, wbaVisits,
3373
3512
  handleRequest, handleRequestAsync, listen,
3374
3513
  cardEnvelope: () => JSON.parse(cardEnvelopeBytes().toString('utf8')) };
3375
3514
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muretai/agent-entry",
3
- "version": "1.8.0",
3
+ "version": "1.10.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",
package/spec/v1.md CHANGED
@@ -396,6 +396,25 @@ higher rate than the signed lane's aggregate.
396
396
  > **Observation.** With the lane on, drive unsigned requests above the bound; assert refusal. An
397
397
  > unauthenticated caller must never become an unmetered signing oracle.
398
398
 
399
+ **AE-30.** An entry **MAY** publish `agentEntry.prefer`: the site's own order of its ways in
400
+ for a visiting agent. It is an array whose entries are a kind — `"page"`, `"card"`, `"mcp"` —
401
+ or an object `{"kind": <kind>, "when": <condition>}` with `when` one of `person`, `alone`,
402
+ `key`, `no-key`, `token`, `browser`, read by the visitor against what it has on hand. When
403
+ published it **MUST** appear under the neutral `agentEntry` key exactly as the operator
404
+ declared it (validated, not rewritten), and **MUST** appear identically in the signed
405
+ envelope's inner card. The legacy `muretai` alias **MUST NOT** carry it. When not configured,
406
+ the card **MUST NOT** carry a `prefer` key. A declaration that fails validation **MUST** make
407
+ the entry refuse to start rather than publish a corrected or partial order.
408
+
409
+ > **Observation.** Start an entry with a declaration; `GET` the card and the envelope; assert
410
+ > `agentEntry.prefer` equals the declaration in both and `muretai` has no `prefer`. Start one
411
+ > without; assert no `prefer` key. Start one with `["teleport"]`; assert it exits non-zero and
412
+ > binds nothing. Why refuse rather than fix: a signed card is the origin's statement, and a
413
+ > statement the operator did not make is a worse card than none — the same posture as a bad
414
+ > `domains` list. Why the visitor's conditions live here: which way in a stranger should try
415
+ > first is the site's design (read on the page, become a counterparty later — or knock first),
416
+ > and the card is the one place the site can say so that a page script cannot rewrite.
417
+
399
418
  ---
400
419
 
401
420
  ## 5. Relationship to other specifications
@@ -629,6 +648,7 @@ be written is a requirement that does not belong in §4.
629
648
  | AE-27 | MUST | size caps | oversized body and text |
630
649
  | AE-28 | MUST | aggregate reply ceiling, refused with `-32004` | drive above the ceiling |
631
650
  | AE-29 | MUST | unsigned lane bounded entry-wide | drive the anonymous lane |
651
+ | AE-30 | MAY / MUST | `agentEntry.prefer` verbatim on card + envelope, or absent; invalid refuses to start | `GET` both, compare; start with a bad list → exit ≠ 0 |
632
652
 
633
653
  ---
634
654