@muretai/agent-entry 1.5.0 → 1.6.1

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
@@ -111,7 +111,9 @@ answers questions and hands off nothing, is a signed claim you cannot keep.
111
111
  | `domains` | none | the domains this entry speaks for (see below) |
112
112
  | `basePath` | from `baseUrl` | the path this entry answers at, derived rather than set beside it |
113
113
  | `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 |
114
+ | `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) |
115
+ | `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 |
116
+ | `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
117
 
116
118
  `seedHex` and `baseUrl` are the two an entry refuses to start without: the seed **is** the
117
119
  address, and the url it publishes must equal the origin the visitor dialled.
@@ -198,7 +200,91 @@ cannot be told to look elsewhere:
198
200
  One round trip. No callback, no webhook, nothing to keep awake.
199
201
 
200
202
  `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.
203
+ page stays exactly as it is.
204
+
205
+ ### The fourth step, and it is not optional
206
+
207
+ Three routes make the door **work**. They do not make it **findable**, and those are separate
208
+ problems with separate fixes.
209
+
210
+ A visiting agent knows your domain, so it can guess the card path — but only if something told
211
+ it there is an agent here at all. Normally that something is this module's own `GET /` notice.
212
+ **If your pages are served by a different process than the door — a CDN, a static host, a
213
+ framework, an edge worker — that notice never renders**, and your home page is HTML written for
214
+ people with nothing machine-readable in it. The address ends up published in a card nobody was
215
+ told to fetch.
216
+
217
+ So put the pointer on every page a visitor might land on, in **both** spellings. Neither is a
218
+ fallback for the other:
219
+
220
+ ```
221
+ Link: </.well-known/agent-card.json>; rel="https://muretai.net/rel/agent-entry"
222
+ ```
223
+
224
+ ```html
225
+ <link rel="https://muretai.net/rel/agent-entry" href="/.well-known/agent-card.json">
226
+ ```
227
+
228
+ The relation is an opaque **identifier**, matched as a string — nothing about resolving an agent
229
+ endpoint requires a request to that host. The two spellings exist because the two kinds of client
230
+ have opposite blind spots: an agent that fetches with a plain `curl` (no `-i`) never sees the
231
+ header, and one that reads only headers never parses the HTML. Shipping one is a coin flip on
232
+ which kind arrived.
233
+
234
+ We know because we shipped one. An agent that had never been told about our door was handed only
235
+ the domain, fetched the page, read the copy written for humans, and stopped — while the door had
236
+ been answering signed messages correctly the whole time, at the address on that very page.
237
+
238
+ Then check it from outside, because this is exactly the class of thing that looks installed:
239
+
240
+ ```bash
241
+ curl -sI https://studio.example/ | grep -i '^link:' # the header half
242
+ curl -s https://studio.example/ | grep 'rel/agent-entry' # the tag half
243
+ ```
244
+
245
+ Worth knowing before you call it done: **both halves disappear in a fetch that converts the page
246
+ to markdown**, which is a common way an agent reads the web — headers are dropped and so is
247
+ everything in `<head>`. No tag survives that. The only remedy is prose: say in the visible body
248
+ that agents are answered here, and name the card path in text a reader can act on.
249
+
250
+ ### Check that your own CDN is not refusing your door
251
+
252
+ The failure you are least likely to look for, because everything you control is correct.
253
+
254
+ Most sites sit behind something that turns away suspicious traffic, and much of that judging is
255
+ done on the **User-Agent** — which a client writes about itself, so the honest defaults are what
256
+ get caught. Ours refused the default agent Python's standard library sends, and not only on the
257
+ home page: on the **card** and on `POST /` too. The door was published, correct, and answering —
258
+ to nobody using the stdlib client that "zero dependencies" produces.
259
+
260
+ **The tell is the body of the refusal.** A door refuses in JSON and says how to qualify. An
261
+ intermediary refuses in a line of plain text — `error code: 1010`, seventeen bytes, no `Link`, no
262
+ card path, nothing a visitor can act on. If that is what strangers get, the door never saw them.
263
+
264
+ **Do not check with `curl`.** It sends its own agent string and sails through, so "reproduce it
265
+ with curl" turns a broken door into evidence that the visitor is at fault. Use a plain
266
+ standard-library client, from outside your network:
267
+
268
+ ```bash
269
+ UA='Python-urllib/3.11' # or your language's default — the point is that it IS the default
270
+ curl -s -A "$UA" -X POST https://studio.example/ -H 'content-type: application/json' \
271
+ -d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{}}' | head -c 80
272
+ ```
273
+
274
+ That must come back as JSON. Anything else is your edge, not your entry.
275
+
276
+ **The exemption is simpler than it looks, and its shape is the point.** You never have to ask
277
+ your CDN whether a caller is a bot — only to name three things it already knows: **host, method,
278
+ path.** Because this door partitions by method, `POST /` and the card paths are exactly the
279
+ surface to exempt, and your pages keep whatever protection they have. Write the rule with no
280
+ user-agent field in it at all — the same rule the door lives by, one layer out.
281
+
282
+ Two limits worth stating plainly. Some protections cannot be exempted by any rule at any tier;
283
+ find out which yours is before promising yourself a carve-out. And **never let your CDN tell your
284
+ responder who it is talking to** — some will forward a bot score or a "verified" flag to your
285
+ origin, and if your origin is reachable without going through them (most are), that header is
286
+ written by whoever dials it directly. Authority is the signature on the message; nothing else
287
+ gets a vote.
202
288
 
203
289
  ### 1. A subdomain — the existing site is untouched
204
290
 
@@ -441,23 +527,14 @@ and it is not one. It is the upgrade path:
441
527
  can carry them.
442
528
  - **Statistics without a store: an analytics sink.** Nothing in the entry reads the
443
529
  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.
530
+ agents with no database anywhere. Use `observer` for it, never your responder: watching
531
+ a visit should not be an edit to the code that decides what to say. See
532
+ [Counting visits](#counting-visits-without-handing-over-who-they-are) below for the
533
+ whole pattern, including the one rule that shapes it — **a DID is not a page view**,
534
+ so what leaves your box is a salted digest, never the identifier itself. A sink cannot
535
+ be read back during a request: it counts customers, it cannot recognise one. It
536
+ replaces a log line, not the store above — none of the recommended features stand
537
+ on it.
461
538
  - **Revocation reaches you through your backend, not through this file.** An Agent Entry
462
539
  is deliberately network-free on the hot path: it never dials out while answering a
463
540
  visitor. Bindings carry an expiry, and a full node checks published revocations within
@@ -483,6 +560,84 @@ What the entry now handles for you at the HTTP layer, so you do not have to:
483
560
  which says a server must accept the absolute form: this endpoint answers exactly the
484
561
  address its card names, and the refusal says so.
485
562
 
563
+ ## Counting visits without handing over who they are
564
+
565
+ You will want to know how many agents knocked, how many came back, and what they asked. All
566
+ three are answerable — and how you answer them decides whether you are counting your visitors
567
+ or contributing to a profile of them.
568
+
569
+ **Use `observer`, not your responder.** The door calls it once per message with the same
570
+ envelope, after the verdict, so watching a visit stops being an edit to the code that decides
571
+ what to say. It cannot matter: its return is discarded, a throw is swallowed, a promise is
572
+ never awaited — a slow or broken watcher cannot delay or change one byte of the signed reply.
573
+
574
+ **The rule that shapes everything else: a DID is not a page view.** A visitor hands you one in
575
+ order to transact with **you**, and here first contact *is* the account — there is no signup
576
+ form where they agreed to anything else. Forwarding the raw value to an analytics vendor shares
577
+ a durable identifier its owner never offered them, silently, on a surface with no consent
578
+ dialog and no visitor who could decline. So split it:
579
+
580
+ - **What leaves** — a salted digest and a few shape facts. Never the DID, never the text.
581
+ - **What stays** — the relationship (who, how many, first and last seen) in your own store,
582
+ which is the only place it was ever offered to.
583
+
584
+ **Salt the digest, and treat the salt as a secret.** A bare `sha256(did)` is a *stable global*
585
+ pseudonym: anyone else who hashes the same DID gets the same string, so two properties could
586
+ join their records on it. An HMAC under a secret only you hold makes the pseudonym meaningless
587
+ anywhere else — the whole difference between "we count returning visitors" and "we helped build
588
+ a profile".
589
+
590
+ Google Analytics 4 over the Measurement Protocol, as an example of any sink:
591
+
592
+ ```js
593
+ import crypto from 'node:crypto';
594
+
595
+ const pseudonym = (did) =>
596
+ crypto.createHmac('sha256', process.env.PSEUDONYM_SALT).update(did).digest('hex').slice(0, 32);
597
+
598
+ const observer = (env) => {
599
+ const account = env.owner_did || env.peer_did;
600
+ if (!account) return; // an unsigned walk-in is traffic, not a visitor
601
+ const first = (entry.ledger.get(account)?.messages ?? 1) === 1;
602
+
603
+ // `client_id` is the pseudonym, so the vendor can tell a returning visitor from a new one
604
+ // WITHOUT ever holding the DID that distinguishes them.
605
+ fetch(`https://www.google-analytics.com/mp/collect?measurement_id=${GA_ID}&api_secret=${GA_SECRET}`, {
606
+ method: 'POST',
607
+ body: JSON.stringify({
608
+ client_id: pseudonym(account),
609
+ non_personalized_ads: true,
610
+ events: [{ name: 'agent_knock', params: { verified: env.verified ? 1 : 0,
611
+ first_contact: first ? 1 : 0,
612
+ intent: classify(env.text) }}],
613
+ }),
614
+ }).catch(() => {}); // a dropped metric, never a dropped answer
615
+ };
616
+ ```
617
+
618
+ Four details there are load-bearing:
619
+
620
+ - **`classify(env.text)`, never `env.text`.** Send *your own* bounded label, not what a stranger
621
+ typed. An attacker-chosen string must never become a dimension in your analytics.
622
+ - **`.catch(() => {})` and no `await`.** Your door answers in one round trip; nothing on that
623
+ path may wait on somebody else's uptime. The `observer` contract already guarantees this — do
624
+ not lean on that generosity to be correct.
625
+ - **Give it a timeout too** (an `AbortController` at a second or two). A hung connection is not
626
+ an error, so `catch` alone never fires.
627
+ - **Say at boot whether the sink is on.** A sink silently off because a secret was never set
628
+ looks exactly like a sink that is on and receiving nothing, and a dashboard reading zero
629
+ cannot tell you which.
630
+
631
+ **Say it on the card, because that is the surface your visitor reads.** Whatever you record, the
632
+ party whose identifier it is arrives as an agent and will never open a privacy page written for
633
+ people. Your card is fetched *before* the knock — that is the point of publishing terms up front
634
+ — so it is the one place a visitor can learn what happens to its DID and still decide not to
635
+ knock. Two or three sentences in `description`: what you keep, what leaves, what never does. A
636
+ disclosure that arrives after the visit is not a disclosure, it is a receipt.
637
+
638
+ And if you decide to send raw DIDs anyway, that is your call to make — but say so on the card,
639
+ in the same breath, in plain words.
640
+
486
641
  ## Two implementations, pinned to each other
487
642
 
488
643
  This module is not alone. A Python reference implements the same contract, and the two are
@@ -2239,6 +2239,25 @@ export function canonicalMount(canonUrl, basePath) {
2239
2239
  * Recognition only ever ADDS identity (env.wba_did, the wbaVisits
2240
2240
  * count); it never changes verified, a ledger row, a rate lane or any
2241
2241
  * refusal verdict.
2242
+ * observer OPTIONAL `(env) => void` — a WATCHER, called once per message with the
2243
+ * same frozen envelope the responder gets. It exists so that OBSERVING a
2244
+ * visit is not the same edit as ANSWERING one: wanting a counter should
2245
+ * not mean reaching into the code that decides what to say.
2246
+ *
2247
+ * IT CANNOT AFFECT ANYTHING. It is called AFTER the verdict is settled,
2248
+ * its return value is discarded, a thrown error is swallowed, and a
2249
+ * promise is never awaited — so a slow or broken watcher cannot delay,
2250
+ * fail, or change one byte of the signed reply. That is the whole contract
2251
+ * and it is not a formality: this door answers in ONE round trip with no
2252
+ * callback, and a watcher that dialled out on the hot path would make the
2253
+ * visitor's answer depend on somebody else's uptime.
2254
+ *
2255
+ * WHAT NOT TO PUT IN IT. The envelope carries `peer_did`/`owner_did`,
2256
+ * which a visitor handed you to transact with YOU. Forwarding a raw DID to
2257
+ * a third party shares a durable identifier its owner never offered them;
2258
+ * if you need a metric, send a salted, site-scoped digest and keep the DID
2259
+ * in your own store. This module ships no sink and names no vendor — the
2260
+ * slot is here so an adapter can live outside it.
2242
2261
  */
2243
2262
  export function createAgentEntry({
2244
2263
  seedHex,
@@ -2258,6 +2277,7 @@ export function createAgentEntry({
2258
2277
  guest = false,
2259
2278
  maxAccounts = 50000,
2260
2279
  howToUrl = FIRST_KNOCK_URL,
2280
+ observer = null,
2261
2281
  wbaVerifiers = null,
2262
2282
  } = {}) {
2263
2283
  if (!seedHex) throw new TypeError('createAgentEntry: seedHex is required');
@@ -2896,7 +2916,27 @@ export function createAgentEntry({
2896
2916
  wbaDid: wbaIdentify(reqHeaders) }), reqId, msg, from);
2897
2917
  }
2898
2918
 
2919
+ /** Hand the envelope to the watcher, and make sure it can cost nothing.
2920
+ *
2921
+ * Called BEFORE the responder on purpose: observing that a visit HAPPENED must not
2922
+ * depend on the answer succeeding, or the one request worth counting — the one where
2923
+ * the site's own code threw — is the one that goes uncounted.
2924
+ *
2925
+ * Everything here is a refusal to let a watcher matter: the return value is discarded,
2926
+ * a synchronous throw is swallowed, and a returned promise is given a rejection handler
2927
+ * and then DROPPED rather than awaited. That last one is not tidiness — an unhandled
2928
+ * rejection can take a Node process down, so the watcher must not be able to end the
2929
+ * door by failing quietly in the background. */
2930
+ function observe(env) {
2931
+ if (typeof observer !== 'function') return;
2932
+ try {
2933
+ const r = observer(env);
2934
+ if (isThenable(r)) r.then(undefined, () => {});
2935
+ } catch { /* a watcher never changes what this door does */ }
2936
+ }
2937
+
2899
2938
  function respond(env, reqId, msg, toDid) {
2939
+ observe(env);
2900
2940
  let answer;
2901
2941
  try {
2902
2942
  answer = responder(env);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muretai/agent-entry",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
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",