@muretai/agent-entry 1.6.2 → 1.7.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
@@ -114,7 +114,7 @@ answers questions and hands off nothing, is a signed claim you cannot keep.
114
114
  | `domains` | none | the domains this entry speaks for (see below) |
115
115
  | `basePath` | from `baseUrl` | the path this entry answers at, derived rather than set beside it |
116
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 |
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) |
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-your-customer-list) |
118
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
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 |
120
120
 
@@ -138,10 +138,12 @@ Each request's `User-Agent` is classified into a fixed family (`claude-user`,
138
138
  `claudebot`, `gptbot`, `openai`, `perplexity`, `google-extended`, `muretai-node`,
139
139
  `curl`, `browser`, `none`/`other`) and counted by stage. In-process state like the
140
140
  ledger — read it, log it, ship it to your analytics; it is never served on the wire.
141
- An AI-agent family also gets one nudge: `GET /` answers it with
142
- `Link: </.well-known/agent-card.json>; rel="service-desc"` (RFC 8631), so a crawler
143
- that landed on prose is handed the machine-readable door. The body stays
144
- byte-identical for every caller.
141
+ Every caller also gets one nudge: `GET /` answers with a single `Link:` field carrying
142
+ two relations — `rel="service-desc"` (RFC 8631) first, then the door pointer
143
+ `rel="https://muretai.net/rel/agent-entry"` — so a crawler that landed on prose is
144
+ handed the machine-readable door, whatever its `User-Agent` claims. The body stays
145
+ byte-identical for every caller, and so does the header: classification feeds the
146
+ counters above and never a byte on the wire.
145
147
 
146
148
  One rule holds this together, enforced by the contract suite rather than promised:
147
149
  **a User-Agent never affects `verified`, an account row, a rate limit, or any
@@ -534,8 +536,8 @@ and it is not one. It is the upgrade path:
534
536
  ledger back to gate, greet or rate-limit, so a fire-and-forget sink records visiting
535
537
  agents with no database anywhere. Use `observer` for it, never your responder: watching
536
538
  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
+ [Counting visits](#counting-visits-without-handing-over-your-customer-list) below for the
540
+ whole pattern, including the one rule that shapes it — **a DID is not a cookie**,
539
541
  so what leaves your box is a salted digest, never the identifier itself. A sink cannot
540
542
  be read back during a request: it counts customers, it cannot recognise one. It
541
543
  replaces a log line, not the store above — none of the recommended features stand
@@ -565,7 +567,7 @@ What the entry now handles for you at the HTTP layer, so you do not have to:
565
567
  which says a server must accept the absolute form: this endpoint answers exactly the
566
568
  address its card names, and the refusal says so.
567
569
 
568
- ## Counting visits without handing over who they are
570
+ ## Counting visits without handing over your customer list
569
571
 
570
572
  You will want to know how many agents knocked, how many came back, and what they asked. All
571
573
  three are answerable — and how you answer them decides whether you are counting your visitors
@@ -576,11 +578,18 @@ envelope, after the verdict, so watching a visit stops being an edit to the code
576
578
  what to say. It cannot matter: its return is discarded, a throw is swallowed, a promise is
577
579
  never awaited — a slow or broken watcher cannot delay or change one byte of the signed reply.
578
580
 
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:
581
+ **The rule that shapes everything else: a DID is not a cookie, and it is not a throwaway
582
+ either.** Nobody imposed it the visitor read your card *before* knocking, and an owner who
583
+ wanted this conversation kept apart would have sent a different agent, because an owner runs
584
+ several and each is a distinct agent with its own lasting identity. But the one that did knock
585
+ means to keep the key it used: that is how it gets recognised, introduced and trusted anywhere on
586
+ the network, so it is closer to a professional's name than to a tracking cookie.
587
+
588
+ Which is exactly why the raw value should not travel onward. **The DID is genuinely durable, and
589
+ you were given it so that YOU could reach them again.** Widen that purpose and nothing happens to
590
+ you legally, which is the part worth understanding: the owner simply stops sending that agent to
591
+ you. Silently, at no cost, and you never learn you lost them — not one data point, the whole
592
+ relationship. So split it:
584
593
 
585
594
  - **What leaves** — a salted digest and a few shape facts. Never the DID, never the text.
586
595
  - **What stays** — the relationship (who, how many, first and last seen) in your own store,
@@ -336,10 +336,12 @@ export const UA_FAMILIES = [
336
336
  ['mozilla', 'browser'],
337
337
  ];
338
338
 
339
- /** The families that read as an AI agent the ones the notice route signposts with a
340
- * `Link` header. `muretai-node` is deliberately absent: its Outbox already walks the
341
- * well-known card paths, so a signpost buys it nothing. Must match the same set in
342
- * examples/agent_entry_reference.py. */
339
+ /** The families that read as an AI agent. Since v1.11 this set steers NO wire byte
340
+ * the notice route's `Link` signpost is emitted for every caller (see `steerHeaders`
341
+ * and ISSUE(agent-entry-unconditional-link)) so it remains only as a published
342
+ * classification an operator's own observation code can lean on. `muretai-node` is
343
+ * deliberately absent: its Outbox already walks the well-known card paths. Must match
344
+ * the same set in examples/agent_entry_reference.py. */
343
345
  export const AI_AGENT_FAMILIES = new Set([
344
346
  'claude-user', 'claudebot', 'gptbot', 'openai', 'perplexity', 'google-extended',
345
347
  ]);
@@ -1706,7 +1708,7 @@ function jsonResponse(status, obj, extraHeaders = {}) {
1706
1708
  };
1707
1709
  }
1708
1710
 
1709
- function rpcError(id, error, data) {
1711
+ function baseRpcError(id, error, data) {
1710
1712
  const err = { ...error };
1711
1713
  if (data) err.data = data;
1712
1714
  return jsonResponse(200, { jsonrpc: '2.0', id: id ?? null, error: err });
@@ -2394,6 +2396,18 @@ export function createAgentEntry({
2394
2396
  },
2395
2397
  };
2396
2398
  card.security = [{ [SIGNED_ENVELOPE_SCHEME]: [] }];
2399
+ // WHERE A 1.0-NATIVE READER FINDS THE ENDPOINT. A2A 1.0 removed the top-level `url` /
2400
+ // `preferredTransport` pair in favour of `supportedInterfaces`, so a client written
2401
+ // against 1.0 greps for exactly this field and, without it, learns nothing from this
2402
+ // card about where to POST. `url` is doorUrl — the address message/send is actually
2403
+ // POSTed to, the same string the securitySchemes `exampleRequest`'s `endpoint` names —
2404
+ // and deliberately NOT canonUrl, which at a bare origin differs from the door by the
2405
+ // trailing slash (canonUrl's bytes are pinned and signed; this entry is what carries
2406
+ // the canonical POST form). Appended last so every field an already-deployed entry
2407
+ // publishes keeps its bytes AND its position.
2408
+ card.supportedInterfaces = [
2409
+ { url: doorUrl, protocolBinding: 'JSONRPC', protocolVersion: PROTOCOL_VERSION },
2410
+ ];
2397
2411
 
2398
2412
  const cardBytes = Buffer.from(JSON.stringify(card), 'utf8'); // identical bytes on both paths
2399
2413
  // ACCOUNT DID -> {first_seen, last_seen, messages}. Keyed by the RESOLVED account (T102):
@@ -2479,10 +2493,32 @@ export function createAgentEntry({
2479
2493
  // five stage names — an attacker choosing UA strings cannot grow it.
2480
2494
  const uaStats = new Map();
2481
2495
 
2496
+ /** Count the stage, and tell the watcher about it.
2497
+ *
2498
+ * Every stage a visitor can reach already passes through here — `card_get`, `notice_get`,
2499
+ * `anon_post`, `signed_post`, `refused_post` — so this is where the observer learns HOW FAR
2500
+ * somebody got. It could previously see only the conversations: a site shipping visits to
2501
+ * analytics got the answered messages and nothing else, no card fetch, no notice read, no
2502
+ * keyless walk-in. The interesting shape of agent traffic is exactly that drop-off — how
2503
+ * many looked, how many tried, how many got in.
2504
+ *
2505
+ * Reported from HERE and not from a second place, so the watcher and `entry.stats()` can
2506
+ * never disagree about what happened, and a stage added later is reported without anyone
2507
+ * remembering to. `identified` rides along because "no DID at all" and "had a DID and was
2508
+ * refused" are different answers for a site deciding whether to open the anonymous lane.
2509
+ *
2510
+ * A GET carries no envelope, so the watcher is told what is true and nothing invented: no
2511
+ * DIDs, no text, `verified: false`. The POST stages are handed to `respond()` /
2512
+ * `observeRefusal()` instead, which know the envelope — one visit, one row, never two. */
2482
2513
  function tally(family, stage) {
2483
2514
  let row = uaStats.get(family);
2484
2515
  if (!row) { row = new Map(); uaStats.set(family, row); }
2485
2516
  row.set(stage, (row.get(stage) || 0) + 1);
2517
+ if (typeof observer !== 'function') return;
2518
+ if (stage === 'card_get' || stage === 'notice_get') {
2519
+ observe({ stage, identified: 0, verified: false, ua_family: family,
2520
+ peer_did: null, owner_did: null, wba_did: null, text: null });
2521
+ }
2486
2522
  }
2487
2523
 
2488
2524
  /** A plain JSON-able copy of the counters: { family: { stage: n } }. */
@@ -2495,25 +2531,32 @@ export function createAgentEntry({
2495
2531
  return out;
2496
2532
  }
2497
2533
 
2498
- /** The `Link` header the notice route carries. TWO relations with different audiences,
2499
- * in ONE header field (RFC 8288 allows several link-values in one field, and one field
2500
- * is what keeps the two twins' bytes identical through their single-header plumbing):
2534
+ /** The `Link` header the notice route carries the SAME one-field value for EVERY
2535
+ * caller. TWO relations in ONE header field (RFC 8288 allows several link-values in
2536
+ * one field, and one field is what keeps the two twins' bytes identical through their
2537
+ * single-header plumbing):
2501
2538
  *
2502
- * - the DOOR pointer, `rel="https://muretai.net/rel/agent-entry"`, for EVERY caller.
2503
- * This is the coexistence primitive (E4): an agent that fetched a page finds the
2539
+ * - `rel="service-desc"` (RFC 8631's registered relation for "service description …
2540
+ * primarily intended for consumption by machines"), FIRST. It was emitted only to
2541
+ * the UA families that read as an AI agent (the T119 signpost) until a
2542
+ * third-party scanner (agentcard.org, 2026-08-21) measured that conditioned
2543
+ * emission as invisible: its crawler is none of our families, so a working door
2544
+ * scored as publishing no service description at all — the revisit trigger
2545
+ * recorded in ISSUE(agent-entry-unconditional-link). A header conditioned on a
2546
+ * guess about the reader is invisible to exactly the readers the guess missed,
2547
+ * so it is now emitted for every caller.
2548
+ * - the DOOR pointer, `rel="https://muretai.net/rel/agent-entry"`. This is the
2549
+ * coexistence primitive (E4): an agent that fetched a page finds the
2504
2550
  * machine-readable door in the RESPONSE, with no HTML to parse and no prose to
2505
2551
  * read, and a browser ignores it — which is what lets a site keep its own front
2506
2552
  * page and add ONE header instead of migrating. An absolute URI because RFC 8288
2507
2553
  * §2.1.2 permits a bare token only for an IANA-registered relation.
2508
- * - `rel="service-desc"` (RFC 8631's registered relation for "service description …
2509
- * primarily intended for consumption by machines"), FIRST and only for the UA
2510
- * families that read as an AI agent — the T119 signpost, unchanged in meaning.
2511
2554
  *
2512
- * HEADER-ONLY on purpose: the notice BODY is byte-identical for every caller, so what
2513
- * the UA changes is still only this one additive relation and never a verdict. */
2514
- function steerHeaders(family) {
2555
+ * HEADER-ONLY on purpose: the notice BODY is byte-identical for every caller, and now
2556
+ * so is this header the UA family still steers observation (`tally`/`stats`), never
2557
+ * a byte on the wire. */
2558
+ function steerHeaders(family) { // `family` kept for observation symmetry, unread here
2515
2559
  const door = `<${mount}${AGENT_CARD_PATH}>; rel="${AGENT_ENTRY_REL}"`;
2516
- if (!AI_AGENT_FAMILIES.has(family)) return { Link: door };
2517
2560
  return { Link: `<${mount}${AGENT_CARD_PATH}>; rel="service-desc", ${door}` };
2518
2561
  }
2519
2562
 
@@ -2718,7 +2761,80 @@ export function createAgentEntry({
2718
2761
  * size checks come BEFORE any parsing or crypto — a check placed after the signature is
2719
2762
  * a check the attacker simply skips.
2720
2763
  */
2764
+ /** The code of the refusal this request produced, or null when it was answered. Set by the
2765
+ * entry-local `rpcError` below so the funnel can report it without re-parsing a Buffer. */
2766
+ let lastRefusal = null;
2767
+
2768
+ /** The stage `tally()` computed for the POST currently in flight, handed to whichever
2769
+ * observation point reports it. `tally()` runs AFTER the ladder returns, so the answered
2770
+ * case is observed before this is set — which is why `respond()` reads it lazily rather
2771
+ * than being passed it. */
2772
+ let pendingStage = null;
2773
+
2774
+ /** Shadows the module-level `rpcError` for the whole entry: same return value, and it
2775
+ * remembers the code on the way out. A local alias rather than seventeen edits, and rather
2776
+ * than a parameter every refusal site would have to remember to pass. */
2777
+ const rpcError = (id, error, data) => {
2778
+ lastRefusal = error && typeof error.code === 'number' ? error.code : null;
2779
+ return baseRpcError(id, error, data);
2780
+ };
2781
+
2782
+ /** Every POST outcome, observed once, at the single point they all funnel through.
2783
+ *
2784
+ * `respond()` observes the answered case with the full envelope. Everything else — the
2785
+ * keyless walk-in, the bad signature, the flood that hit a ceiling — returned an rpcError
2786
+ * and was never seen at all, so a door could count who it TALKED to and never who it TURNED
2787
+ * AWAY. For a site asking whether agents are arriving, the refusals are the signal: an agent
2788
+ * that could not get in is the one nobody hears from again. It also made the documented
2789
+ * contract false, since the guide says the observer runs once per message, after the verdict,
2790
+ * and a refusal IS a verdict.
2791
+ *
2792
+ * Structural rather than enumerated ON PURPOSE. Seventeen refusal sites would have been
2793
+ * seventeen chances to forget, and the eighteenth would be forgotten by construction.
2794
+ *
2795
+ * The code is read from `lastRefusal`, set by `rpcError` on its way out, rather than by
2796
+ * re-parsing the response — the body is already a Buffer by then, and a watcher must never
2797
+ * cost a JSON round-trip on the reply path.
2798
+ *
2799
+ * A refusal hands the watcher only what was actually established: `refused` carries the
2800
+ * JSON-RPC code and the DIDs are null, because a walk-in that named nobody named nobody. The
2801
+ * watcher still cannot matter — same swallowed throw, same discarded return. */
2721
2802
  function handlePost(rawBody, reqHeaders) {
2803
+ lastRefusal = null;
2804
+ // The stage a POST reaches is decided by whether it CARRIED a signature, which is knowable
2805
+ // from the request alone — so it is settled here, before the ladder answers, and read by
2806
+ // whichever observation point fires. `tally()` computes the same thing afterwards from the
2807
+ // finished reply, for the counters; the two agree because they ask the same question.
2808
+ pendingStage = postRequestStage(rawBody);
2809
+ const out = handlePostLadder(rawBody, reqHeaders);
2810
+ if (isThenable(out)) return out.then((o) => { observeRefusal(); return o; });
2811
+ observeRefusal();
2812
+ return out;
2813
+ }
2814
+
2815
+ /** The answered case is already observed inside `respond()`; this adds refusals only, so no
2816
+ * message is ever observed twice. */
2817
+ function observeRefusal() {
2818
+ if (typeof observer !== 'function' || lastRefusal === null) return;
2819
+ observe({ verified: false, refused: lastRefusal, stage: 'refused_post',
2820
+ identified: pendingStage === 'signed_post' ? 1 : 0,
2821
+ peer_did: null, owner_did: null, wba_did: null, text: null });
2822
+ }
2823
+
2824
+ /** Did this POST body carry a signature? That is the whole difference between a keyless
2825
+ * walk-in and an identified visitor, and it is answerable from the request without waiting
2826
+ * for the verdict — a bad signature is still a visitor who HAD a key. Parsed defensively:
2827
+ * anything unreadable is a walk-in, because it certainly did not present an identity. */
2828
+ function postRequestStage(rawBody) {
2829
+ try {
2830
+ const buf = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody || '');
2831
+ const req = JSON.parse(buf.toString('utf8'));
2832
+ const meta = req?.params?.message?.metadata;
2833
+ return (meta && typeof meta.sig === 'string' && meta.sig) ? 'signed_post' : 'anon_post';
2834
+ } catch { return 'anon_post'; }
2835
+ }
2836
+
2837
+ function handlePostLadder(rawBody, reqHeaders) {
2722
2838
  // RAW BYTES, always. A host app that hands us a decoded string has already destroyed
2723
2839
  // the evidence the strict decode below exists to find, so normalise once and measure
2724
2840
  // the SIZE in bytes rather than in UTF-16 code units.
@@ -2871,18 +2987,26 @@ export function createAgentEntry({
2871
2987
  if (!Number.isSafeInteger(ts) || Math.abs(nowEpoch() - ts) > CLOCK_WINDOW_S) {
2872
2988
  return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'timestamp out of range (clock skew or replay)');
2873
2989
  }
2874
- // 7. duplicate messageId inside the replay window. (Its type was settled by the shape
2875
- // gate: a non-string messageId never reaches here on either implementation.)
2990
+ // 7. the signature itself, under the key DERIVED FROM `from`. (`messageId`'s type was
2991
+ // settled by the shape gate: a non-string never reaches here on either implementation.)
2876
2992
  const messageId = msg.messageId;
2877
- if (!replay.checkAndRemember(messageId)) {
2878
- return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'duplicate messageId (replay) detected');
2879
- }
2880
- // 8. the signature itself, under the key DERIVED FROM `from`.
2881
2993
  const fields = { from, to, messageId, contextId: msg.contextId ?? null,
2882
2994
  timestamp: ts, text, sig };
2883
2995
  if (!verifyEnvelope(fields, { recipientDid: did })) {
2884
2996
  return rpcError(reqId, ERRORS.UNAUTHENTICATED, 'signature does not match');
2885
2997
  }
2998
+ // 8. duplicate messageId inside the replay window — AFTER the verify, and the ORDER is the
2999
+ // property. The table is state an authenticated sender depends on, so a caller who has
3000
+ // proved nothing must not write into it. Ahead of the verify a stranger could BURN an id
3001
+ // its real sender was about to use, and because the table is capped and evicts
3002
+ // oldest-first, flood past the cap to discard genuine entries and re-open real messages
3003
+ // to replay — invalid signatures are not rate-limited, so that flood is free. This is the
3004
+ // rule the signed lane's ceiling already follows one step below, for the same reason; it
3005
+ // was simply never applied here. The cost of the swap is one Ed25519 verify spent on a
3006
+ // replayed VALID message, which an attacker must first have obtained.
3007
+ if (!replay.checkAndRemember(messageId)) {
3008
+ return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'duplicate messageId (replay) detected');
3009
+ }
2886
3010
 
2887
3011
  // 9. T102 account layer. An OPTIONAL countersigned v2 binding collapses an owner's device
2888
3012
  // DIDs to ONE account; a present-but-INVALID binding fails closed with the SAME
@@ -2936,18 +3060,22 @@ export function createAgentEntry({
2936
3060
  }
2937
3061
 
2938
3062
  function respond(env, reqId, msg, toDid) {
2939
- observe(env);
3063
+ // The answered case: the envelope already says who this was, and the stage says how they
3064
+ // arrived. `identified` is read off the envelope rather than the stage, because the
3065
+ // anonymous lane answers a visitor who genuinely presented no DID.
3066
+ observe({ ...env, stage: pendingStage,
3067
+ identified: env && env.peer_did ? 1 : 0 });
2940
3068
  let answer;
2941
3069
  try {
2942
3070
  answer = responder(env);
2943
3071
  } catch (e) {
2944
- return rpcError(reqId, ERRORS.INTERNAL_ERROR, `responder failed: ${e && e.message}`);
3072
+ return rpcError(reqId, ERRORS.INTERNAL_ERROR, 'the site backend failed to answer');
2945
3073
  }
2946
3074
  const inbound = { contextId: msg.contextId ?? null, messageId: msg.messageId ?? null };
2947
3075
  if (isThenable(answer)) {
2948
3076
  return answer.then(
2949
3077
  (v) => finishReply(reqId, v, { inbound, toDid }),
2950
- (e) => rpcError(reqId, ERRORS.INTERNAL_ERROR, `responder failed: ${e && e.message}`));
3078
+ (e) => rpcError(reqId, ERRORS.INTERNAL_ERROR, 'the site backend failed to answer'));
2951
3079
  }
2952
3080
  return finishReply(reqId, answer, { inbound, toDid });
2953
3081
  }
@@ -3055,8 +3183,8 @@ export function createAgentEntry({
3055
3183
  return { status: 200,
3056
3184
  headers: { 'Content-Type': 'text/plain; charset=utf-8',
3057
3185
  'Content-Length': String(body.length),
3058
- // The ONE wire-visible thing observation adds: an AI-agent UA is pointed at
3059
- // the machine-readable door. The body above is byte-identical either way.
3186
+ // Every caller is pointed at the machine-readable door the same one-field
3187
+ // Link value for all of them (v1.11). The body above is byte-identical too.
3060
3188
  ...steerHeaders(family) },
3061
3189
  body };
3062
3190
  }
@@ -3083,6 +3211,24 @@ export function createAgentEntry({
3083
3211
  // and when this entry is mounted under a path, "anywhere else" INCLUDES the bare
3084
3212
  // host, which belongs to the site (or to the neighbour agent) and not to us.
3085
3213
  if (!isMountPath(pathname)) return jsonResponse(404, { error: 'not found' });
3214
+ // A QUERY STRING MEANS THE POST IS NOT OURS. The door's address is the signed
3215
+ // card's `url`, byte-exact: a base URL carrying a query is refused at startup, and
3216
+ // a visitor's walk drops any query it was handed before it POSTs the card's `url` —
3217
+ // so no conformant caller can arrive here, while a site's own query-multiplexed
3218
+ // traffic (`?wc-api=`, `?wc-ajax=`, `?rest_route=`) always does. Disjoint BY
3219
+ // CONSTRUCTION, which is what makes this a rule and not a heuristic. Measured:
3220
+ // WooCommerce Stripe delivers its only webhook to `/?wc-api=wc_stripe` (path `/`,
3221
+ // application/json); a bare-origin door that claimed it answered HTTP 200/-32601
3222
+ // echoing the event id, the sender recorded the event as delivered and never
3223
+ // retried — payment events lost SILENTLY. The answer is the unowned-path 404
3224
+ // above, same bytes, one meaning — the DECISION(non-door-post-answers-404-not-405)
3225
+ // non-disclosure again, because the caller most likely to land here is the site's
3226
+ // own webhook, which deserves the most anonymous answer, never a door verdict.
3227
+ const hashless = target.split('#')[0];
3228
+ const queryAt = hashless.indexOf('?');
3229
+ if (queryAt !== -1 && queryAt + 1 < hashless.length) {
3230
+ return jsonResponse(404, { error: 'not found' });
3231
+ }
3086
3232
  const buf = bodyBuffer || Buffer.alloc(0);
3087
3233
  const out = handlePost(buf, headers);
3088
3234
  // The stage is read off the finished answer, so an async responder tallies when it
@@ -3180,7 +3326,7 @@ export function createAgentEntry({
3180
3326
  const body = oversize ? oversizeSentinel() : Buffer.concat(chunks, total);
3181
3327
  Promise.resolve()
3182
3328
  .then(() => handleRequestAsync(req.method, req.url, req.headers, body))
3183
- .catch((e) => rpcError(null, ERRORS.INTERNAL_ERROR, String(e && e.message)))
3329
+ .catch(() => rpcError(null, ERRORS.INTERNAL_ERROR, 'the entry failed to answer'))
3184
3330
  .then(({ status, headers, body: out }) => {
3185
3331
  res.writeHead(status, headers);
3186
3332
  res.end(req.method === 'HEAD' ? undefined : out);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muretai/agent-entry",
3
- "version": "1.6.2",
3
+ "version": "1.7.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",