@muretai/agent-entry 1.6.0 → 1.6.2

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.
Files changed (2) hide show
  1. package/README.md +194 -27
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -104,14 +104,19 @@ answers questions and hands off nothing, is a signed claim you cannot keep.
104
104
  | option | default | what it does |
105
105
  |---|---|---|
106
106
  | `skills` | `[]` | the menu above — what a visitor learns before knocking |
107
- | `openDoor` | `true` | publishes `muretai.open_door`: the field that tells a visiting agent it may message you with no introduction |
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
108
  | `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
- | `anonRatePerMin` | `30` | anonymous replies per minute, entry-wide. Signed senders are not bound by it: they are attributable and already in your ledger |
109
+ | `anonRatePerMin` | `30` | anonymous replies per minute, entry-wide |
110
+ | `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 |
111
+ | `signedRatePerMinTotal` | `600` | signed replies per minute for the **whole entry**. Free identity defeats per-identity metering by definition, so only the aggregate resists a flood |
112
+ | `guest` | `false` | put the door on a path of its own and leave `GET /` alone entirely — for a site that is keeping its front page. A `GET` on a guest mount answers **405 with `Allow: POST, OPTIONS`**, never 404: the address is signed into a public card, and hiding a published address conceals nothing |
110
113
  | `maxAccounts` | `50000` | how many accounts the in-process ledger holds |
111
114
  | `domains` | none | the domains this entry speaks for (see below) |
112
115
  | `basePath` | from `baseUrl` | the path this entry answers at, derived rather than set beside it |
113
116
  | `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 |
114
- | `name`, `description`, `version` | | the card's own words. `description` is the line a person reads in a directory listing |
117
+ | `observer` | none | called once per message with the same envelope your responder gets, **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-who-they-are) |
118
+ | `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
+ | `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 |
115
120
 
116
121
  `seedHex` and `baseUrl` are the two an entry refuses to start without: the seed **is** the
117
122
  address, and the url it publishes must equal the origin the visitor dialled.
@@ -198,7 +203,91 @@ cannot be told to look elsewhere:
198
203
  One round trip. No callback, no webhook, nothing to keep awake.
199
204
 
200
205
  `POST /` is exact — a POST anywhere else is 404. But **`GET /` is not taken**, so your home
201
- page stays exactly as it is. A site gives up three routes and nothing else.
206
+ page stays exactly as it is.
207
+
208
+ ### The fourth step, and it is not optional
209
+
210
+ Three routes make the door **work**. They do not make it **findable**, and those are separate
211
+ problems with separate fixes.
212
+
213
+ A visiting agent knows your domain, so it can guess the card path — but only if something told
214
+ it there is an agent here at all. Normally that something is this module's own `GET /` notice.
215
+ **If your pages are served by a different process than the door — a CDN, a static host, a
216
+ framework, an edge worker — that notice never renders**, and your home page is HTML written for
217
+ people with nothing machine-readable in it. The address ends up published in a card nobody was
218
+ told to fetch.
219
+
220
+ So put the pointer on every page a visitor might land on, in **both** spellings. Neither is a
221
+ fallback for the other:
222
+
223
+ ```
224
+ Link: </.well-known/agent-card.json>; rel="https://muretai.net/rel/agent-entry"
225
+ ```
226
+
227
+ ```html
228
+ <link rel="https://muretai.net/rel/agent-entry" href="/.well-known/agent-card.json">
229
+ ```
230
+
231
+ The relation is an opaque **identifier**, matched as a string — nothing about resolving an agent
232
+ endpoint requires a request to that host. The two spellings exist because the two kinds of client
233
+ have opposite blind spots: an agent that fetches with a plain `curl` (no `-i`) never sees the
234
+ header, and one that reads only headers never parses the HTML. Shipping one is a coin flip on
235
+ which kind arrived.
236
+
237
+ We know because we shipped one. An agent that had never been told about our door was handed only
238
+ the domain, fetched the page, read the copy written for humans, and stopped — while the door had
239
+ been answering signed messages correctly the whole time, at the address on that very page.
240
+
241
+ Then check it from outside, because this is exactly the class of thing that looks installed:
242
+
243
+ ```bash
244
+ curl -sI https://studio.example/ | grep -i '^link:' # the header half
245
+ curl -s https://studio.example/ | grep 'rel/agent-entry' # the tag half
246
+ ```
247
+
248
+ Worth knowing before you call it done: **both halves disappear in a fetch that converts the page
249
+ to markdown**, which is a common way an agent reads the web — headers are dropped and so is
250
+ everything in `<head>`. No tag survives that. The only remedy is prose: say in the visible body
251
+ that agents are answered here, and name the card path in text a reader can act on.
252
+
253
+ ### Check that your own CDN is not refusing your door
254
+
255
+ The failure you are least likely to look for, because everything you control is correct.
256
+
257
+ Most sites sit behind something that turns away suspicious traffic, and much of that judging is
258
+ done on the **User-Agent** — which a client writes about itself, so the honest defaults are what
259
+ get caught. Ours refused the default agent Python's standard library sends, and not only on the
260
+ home page: on the **card** and on `POST /` too. The door was published, correct, and answering —
261
+ to nobody using the stdlib client that "zero dependencies" produces.
262
+
263
+ **The tell is the body of the refusal.** A door refuses in JSON and says how to qualify. An
264
+ intermediary refuses in a line of plain text — `error code: 1010`, seventeen bytes, no `Link`, no
265
+ card path, nothing a visitor can act on. If that is what strangers get, the door never saw them.
266
+
267
+ **Do not check with `curl`.** It sends its own agent string and sails through, so "reproduce it
268
+ with curl" turns a broken door into evidence that the visitor is at fault. Use a plain
269
+ standard-library client, from outside your network:
270
+
271
+ ```bash
272
+ UA='Python-urllib/3.11' # or your language's default — the point is that it IS the default
273
+ curl -s -A "$UA" -X POST https://studio.example/ -H 'content-type: application/json' \
274
+ -d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{}}' | head -c 80
275
+ ```
276
+
277
+ That must come back as JSON. Anything else is your edge, not your entry.
278
+
279
+ **The exemption is simpler than it looks, and its shape is the point.** You never have to ask
280
+ your CDN whether a caller is a bot — only to name three things it already knows: **host, method,
281
+ path.** Because this door partitions by method, `POST /` and the card paths are exactly the
282
+ surface to exempt, and your pages keep whatever protection they have. Write the rule with no
283
+ user-agent field in it at all — the same rule the door lives by, one layer out.
284
+
285
+ Two limits worth stating plainly. Some protections cannot be exempted by any rule at any tier;
286
+ find out which yours is before promising yourself a carve-out. And **never let your CDN tell your
287
+ responder who it is talking to** — some will forward a bot score or a "verified" flag to your
288
+ origin, and if your origin is reachable without going through them (most are), that header is
289
+ written by whoever dials it directly. Authority is the signature on the message; nothing else
290
+ gets a vote.
202
291
 
203
292
  ### 1. A subdomain — the existing site is untouched
204
293
 
@@ -412,8 +501,10 @@ navigator.modelContext.registerTool({
412
501
  ```
413
502
 
414
503
  `MY_DID` is the DID your Agent Entry prints at startup — **the same one**, from the same seed.
415
- That is the only rule when running both: a mismatch trips the visitor's impersonation guard,
416
- which is what it is there for.
504
+ That is the only rule when running both, and **nothing here enforces it** this package never
505
+ sees your in-page handoff. A mismatch is a site publishing two different identities for one
506
+ origin, which a careful visitor may notice and a careless one will not, so treat it as your
507
+ invariant to keep rather than a guard you are behind.
417
508
 
418
509
  What the shop gets out of it: the moment that signed message arrives, an account exists. No
419
510
  signup form, no password, nothing to reset — the sender's key is the account. Come back
@@ -441,23 +532,14 @@ and it is not one. It is the upgrade path:
441
532
  can carry them.
442
533
  - **Statistics without a store: an analytics sink.** Nothing in the entry reads the
443
534
  ledger back to gate, greet or rate-limit, so a fire-and-forget sink records visiting
444
- agents with no database anywhere. Your `responder` is handed the account DID; Google
445
- Analytics 4 over the Measurement Protocol is one `fetch` inside it:
446
-
447
- ```js
448
- fetch('https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXXXXX'
449
- + '&api_secret=' + process.env.GA_API_SECRET, {
450
- method: 'POST',
451
- body: JSON.stringify({ client_id: env.owner_did || env.peer_did,
452
- events: [{ name: 'agent_contact' }] }),
453
- }).catch(() => {}); // analytics must never block a reply
454
- ```
455
-
456
- Keyed by `client_id`, GA tells new from returning visitors by itself — and a DID is a
457
- public key, not personal data, though the record then lives with a third party, which
458
- is your call. A sink cannot be read back during a request: it counts customers, it
459
- cannot recognise one. It replaces a log line, not the store above — none of the
460
- recommended features stand on it.
535
+ agents with no database anywhere. Use `observer` for it, never your responder: watching
536
+ a visit should not be an edit to the code that decides what to say. See
537
+ [Counting visits](#counting-visits-without-handing-over-who-they-are) below for the
538
+ whole pattern, including the one rule that shapes it — **a DID is not a page view**,
539
+ so what leaves your box is a salted digest, never the identifier itself. A sink cannot
540
+ be read back during a request: it counts customers, it cannot recognise one. It
541
+ replaces a log line, not the store above — none of the recommended features stand
542
+ on it.
461
543
  - **Revocation reaches you through your backend, not through this file.** An Agent Entry
462
544
  is deliberately network-free on the hot path: it never dials out while answering a
463
545
  visitor. Bindings carry an expiry, and a full node checks published revocations within
@@ -483,11 +565,89 @@ What the entry now handles for you at the HTTP layer, so you do not have to:
483
565
  which says a server must accept the absolute form: this endpoint answers exactly the
484
566
  address its card names, and the refusal says so.
485
567
 
568
+ ## Counting visits without handing over who they are
569
+
570
+ You will want to know how many agents knocked, how many came back, and what they asked. All
571
+ three are answerable — and how you answer them decides whether you are counting your visitors
572
+ or contributing to a profile of them.
573
+
574
+ **Use `observer`, not your responder.** The door calls it once per message with the same
575
+ envelope, after the verdict, so watching a visit stops being an edit to the code that decides
576
+ what to say. It cannot matter: its return is discarded, a throw is swallowed, a promise is
577
+ never awaited — a slow or broken watcher cannot delay or change one byte of the signed reply.
578
+
579
+ **The rule that shapes everything else: a DID is not a page view.** A visitor hands you one in
580
+ order to transact with **you**, and here first contact *is* the account — there is no signup
581
+ form where they agreed to anything else. Forwarding the raw value to an analytics vendor shares
582
+ a durable identifier its owner never offered them, silently, on a surface with no consent
583
+ dialog and no visitor who could decline. So split it:
584
+
585
+ - **What leaves** — a salted digest and a few shape facts. Never the DID, never the text.
586
+ - **What stays** — the relationship (who, how many, first and last seen) in your own store,
587
+ which is the only place it was ever offered to.
588
+
589
+ **Salt the digest, and treat the salt as a secret.** A bare `sha256(did)` is a *stable global*
590
+ pseudonym: anyone else who hashes the same DID gets the same string, so two properties could
591
+ join their records on it. An HMAC under a secret only you hold makes the pseudonym meaningless
592
+ anywhere else — the whole difference between "we count returning visitors" and "we helped build
593
+ a profile".
594
+
595
+ Google Analytics 4 over the Measurement Protocol, as an example of any sink:
596
+
597
+ ```js
598
+ import crypto from 'node:crypto';
599
+
600
+ const pseudonym = (did) =>
601
+ crypto.createHmac('sha256', process.env.PSEUDONYM_SALT).update(did).digest('hex').slice(0, 32);
602
+
603
+ const observer = (env) => {
604
+ const account = env.owner_did || env.peer_did;
605
+ if (!account) return; // an unsigned walk-in is traffic, not a visitor
606
+ const first = (entry.ledger.get(account)?.messages ?? 1) === 1;
607
+
608
+ // `client_id` is the pseudonym, so the vendor can tell a returning visitor from a new one
609
+ // WITHOUT ever holding the DID that distinguishes them.
610
+ fetch(`https://www.google-analytics.com/mp/collect?measurement_id=${GA_ID}&api_secret=${GA_SECRET}`, {
611
+ method: 'POST',
612
+ body: JSON.stringify({
613
+ client_id: pseudonym(account),
614
+ non_personalized_ads: true,
615
+ events: [{ name: 'agent_knock', params: { verified: env.verified ? 1 : 0,
616
+ first_contact: first ? 1 : 0,
617
+ intent: classify(env.text) }}],
618
+ }),
619
+ }).catch(() => {}); // a dropped metric, never a dropped answer
620
+ };
621
+ ```
622
+
623
+ Four details there are load-bearing:
624
+
625
+ - **`classify(env.text)`, never `env.text`.** Send *your own* bounded label, not what a stranger
626
+ typed. An attacker-chosen string must never become a dimension in your analytics.
627
+ - **`.catch(() => {})` and no `await`.** Your door answers in one round trip; nothing on that
628
+ path may wait on somebody else's uptime. The `observer` contract already guarantees this — do
629
+ not lean on that generosity to be correct.
630
+ - **Give it a timeout too** (an `AbortController` at a second or two). A hung connection is not
631
+ an error, so `catch` alone never fires.
632
+ - **Say at boot whether the sink is on.** A sink silently off because a secret was never set
633
+ looks exactly like a sink that is on and receiving nothing, and a dashboard reading zero
634
+ cannot tell you which.
635
+
636
+ **Say it on the card, because that is the surface your visitor reads.** Whatever you record, the
637
+ party whose identifier it is arrives as an agent and will never open a privacy page written for
638
+ people. Your card is fetched *before* the knock — that is the point of publishing terms up front
639
+ — so it is the one place a visitor can learn what happens to its DID and still decide not to
640
+ knock. Two or three sentences in `description`: what you keep, what leaves, what never does. A
641
+ disclosure that arrives after the visit is not a disclosure, it is a receipt.
642
+
643
+ And if you decide to send raw DIDs anyway, that is your call to make — but say so on the card,
644
+ in the same breath, in plain words.
645
+
486
646
  ## Two implementations, pinned to each other
487
647
 
488
648
  This module is not alone. A Python reference implements the same contract, and the two are
489
649
  held to **identical verdicts** by an acceptance suite: it runs the same attack battery
490
- against both, drives this module against `testdata/wire_vectors.json` byte for byte, posts
650
+ against both, drives this module against the same wire vectors byte for byte, posts
491
651
  identical bytes to each over real sockets — down to the HTTP framing — and requires the same
492
652
  status, the same account outcome and the same signed reply from both. If you write a third
493
653
  implementation, that suite is the gate.
@@ -516,9 +676,16 @@ because a shared library would only ever have covered the parts they happen to s
516
676
  suite posts identical bytes to both, down to the HTTP framing, and requires the same status,
517
677
  the same account outcome and the same signed reply.
518
678
 
519
- `testdata/wire_vectors.json` is the part of that gate you can run here: it pins the canonical
520
- JSON, the signing payloads, the card envelopes and the did:key round-trips this module must
521
- reproduce byte for byte.
679
+ **The part of that gate you can run here ships in this package.** `npm test` executes
680
+ `conformance/run.mjs` against `conformance/vectors.json` the canonical JSON, the signing
681
+ payloads, the card envelopes and the `did:key` round-trips this module must reproduce byte
682
+ for byte. No network, no checkout of ours, nothing to ask us for:
683
+
684
+ ```bash
685
+ npm test
686
+ ```
687
+
688
+ Write a third implementation and point it at the same vectors.
522
689
 
523
690
  ## What this is part of
524
691
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muretai/agent-entry",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
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",