@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 +22 -13
- package/muretai-agent-entry.mjs +175 -29
- package/package.json +1 -1
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-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
that landed on prose is
|
|
144
|
-
|
|
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-
|
|
538
|
-
whole pattern, including the one rule that shapes it — **a DID is not a
|
|
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
|
|
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
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
a
|
|
583
|
-
|
|
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,
|
package/muretai-agent-entry.mjs
CHANGED
|
@@ -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
|
|
340
|
-
* `Link`
|
|
341
|
-
*
|
|
342
|
-
*
|
|
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
|
|
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
|
|
2499
|
-
* in ONE header field (RFC 8288 allows several link-values in
|
|
2500
|
-
* is what keeps the two twins' bytes identical through their
|
|
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
|
-
* -
|
|
2503
|
-
*
|
|
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,
|
|
2513
|
-
*
|
|
2514
|
-
|
|
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.
|
|
2875
|
-
// gate: a non-string
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
//
|
|
3059
|
-
//
|
|
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((
|
|
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.
|
|
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",
|