@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 +38 -14
- package/examples/server.mjs +16 -1
- package/muretai-agent-entry.mjs +147 -8
- package/package.json +1 -1
- package/spec/v1.md +20 -0
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 `
|
|
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
|
-
`
|
|
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 **
|
|
232
|
-
fallback for the
|
|
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
|
|
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
|
|
246
|
-
|
|
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: **
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
package/examples/server.mjs
CHANGED
|
@@ -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
|
|
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
|
package/muretai-agent-entry.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
2261
|
-
*
|
|
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
|
-
|
|
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
|
|
3371
|
-
//
|
|
3372
|
-
|
|
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.
|
|
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
|
|