@muretai/agent-entry 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
@@ -0,0 +1,17 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 740 300" role="img">
3
+ <title>Keep WebMCP. Add a door beside it if the agent is alone.</title>
4
+ <rect width="740" height="300" fill="#fafafa"/>
5
+ <text x="24" y="26" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#666">A person in the tab keeps WebMCP. An agent alone can become a customer at the door beside it.</text>
6
+ <rect x="20" y="44" width="340" height="140" rx="10" fill="#111"/>
7
+ <text x="190" y="80" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="700" fill="#fff">A person is in the tab</text>
8
+ <text x="190" y="114" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#ddd">Keep WebMCP</text>
9
+ <text x="190" y="144" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#bbb">the page is theirs</text>
10
+ <rect x="380" y="44" width="340" height="140" rx="10" fill="#fff" stroke="#111"/>
11
+ <text x="550" y="80" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="700" fill="#111">The agent is alone</text>
12
+ <text x="550" y="114" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#555">Add a door beside WebMCP</text>
13
+ <text x="550" y="144" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#111">they can become a customer</text>
14
+ <rect x="20" y="200" width="700" height="80" rx="10" fill="#fff" stroke="#111"/>
15
+ <text x="370" y="232" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="14" font-weight="700" fill="#111">GET /.well-known/agent-card.json 200</text>
16
+ <text x="370" y="260" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="14" font-weight="700" fill="#111">POST / message/send 200 signed reply</text>
17
+ </svg>
@@ -0,0 +1,21 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 740 280" role="img" aria-labelledby="t d">
2
+ <title id="t">x402 teaches with HTTP 402; Agent Entry teaches on GET 200</title>
3
+ <desc id="d">x402: request the resource, receive HTTP 402 and PAYMENT-REQUIRED, retry with PAYMENT-SIGNATURE. Agent Entry: GET the Agent Card as 200 before the knock, then POST already correct, answered 200. A refusal is JSON-RPC on HTTP 200, not HTTP 402.</desc>
4
+ <rect width="740" height="280" fill="#fafafa"/>
5
+ <rect x="20" y="20" width="340" height="240" rx="10" fill="#fff" stroke="#111"/>
6
+ <text x="190" y="48" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="700" fill="#111">x402 (HTTP)</text>
7
+ <text x="190" y="80" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#333">1. ask for the resource</text>
8
+ <text x="190" y="108" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#333">2. HTTP 402 + PAYMENT-REQUIRED</text>
9
+ <text x="190" y="136" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#333">3. retry with PAYMENT-SIGNATURE</text>
10
+ <text x="190" y="164" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#333">4. 200 if paid</text>
11
+ <text x="190" y="204" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#666">The error is how the client learns</text>
12
+ <text x="190" y="224" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#666">what to do next.</text>
13
+ <rect x="380" y="20" width="340" height="240" rx="10" fill="#111"/>
14
+ <text x="550" y="48" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="700" fill="#fff">Agent Entry</text>
15
+ <text x="550" y="80" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#fff">1. GET agent-card.json (200)</text>
16
+ <text x="550" y="108" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#fff">2. read how to sign (AE-8)</text>
17
+ <text x="550" y="136" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#fff">3. POST message/send (200)</text>
18
+ <text x="550" y="164" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#fff">4. signed reply, same request</text>
19
+ <text x="550" y="204" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#ddd">The POST is not a retry after an error.</text>
20
+ <text x="550" y="224" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#ddd">A refusal is JSON-RPC on HTTP 200.</text>
21
+ </svg>
@@ -761,7 +761,7 @@ export function signEnvelope(seedHex, fields) {
761
761
  /** Question 1 ONLY: does `sig` verify under the key DERIVED FROM `from`, over the six
762
762
  * fields? Total and fail-closed — a malformed DID, bad base64, unrenderable number or
763
763
  * short signature all answer false rather than throwing. */
764
- export function verifyEnvelopeSignature(fields) {
764
+ export function verifyEnvelopeSignature(fields, opts = {}) {
765
765
  try {
766
766
  if (!fields || typeof fields !== 'object') return false;
767
767
  // `from` (the key) and `sig` must be there; `to` may be the EMPTY STRING — that is how
@@ -772,7 +772,11 @@ export function verifyEnvelopeSignature(fields) {
772
772
  assertEncodable(payload);
773
773
  const sig = Buffer.from(String(fields.sig), 'base64');
774
774
  if (sig.length !== 64) return false;
775
- return verifyBytes(publicKeyFromDid(fields.from), sig, Buffer.from(payload, 'utf8'));
775
+ // Payload `from` stays the root DID. `signerDid` is the verifying key when the
776
+ // sender enrolled a delegated op-key (T142 A2); omitted → `from` (the un-enrolled
777
+ // / this-door-reply case).
778
+ const signerDid = opts.signerDid || fields.from;
779
+ return verifyBytes(publicKeyFromDid(signerDid), sig, Buffer.from(payload, 'utf8'));
776
780
  } catch {
777
781
  return false;
778
782
  }
@@ -805,12 +809,82 @@ export function verifyEnvelope(fields, opts = {}) {
805
809
  const recipient = opts.recipientDid ?? opts.me ?? fields.recipientDid ?? null;
806
810
  if (typeof recipient !== 'string' || !recipient) return false;
807
811
  if (fields.to !== recipient) return false;
808
- return verifyEnvelopeSignature(fields);
812
+ return verifyEnvelopeSignature(fields, opts);
809
813
  } catch {
810
814
  return false;
811
815
  }
812
816
  }
813
817
 
818
+ // ================================================================ KeyState (inline op-key, T142 A2)
819
+ //
820
+ // A persisted muretai identity enrolls a genesis KeyState at birth and signs
821
+ // messages with a delegated op-key while `from` stays the root DID. This door
822
+ // used to verify under `from` only, which refused every default-enrolled
823
+ // visitor. Resolve the op-key from a valid inline KeyState (root-signed, pin
824
+ // to the claimed `from`); a missing or invalid record falls back to `from`.
825
+ // No directory, no pin store — first-contact, same as the Python twin.
826
+
827
+ const KEYSTATE_TYP = 'muretai/keystate/1';
828
+ // Lockstep with shared/keystate._FIELDS_V1 / _signed_names: presence of
829
+ // encPubPqHash (even "") selects the T142 list. A V1-only list made every
830
+ // default-enrolled visitor fail verify and fall back to the root DID, so
831
+ // the op-signed envelope was -32001 at this door only (Python twin accepted).
832
+ const KEYSTATE_FIELDS_V1 = [
833
+ 'typ', 'rootDid', 'epoch', 'rootKey', 'rootNextHash',
834
+ 'opDid', 'opNextHash', 'encPub', 'encNextHash',
835
+ 'guardiansHash', 'revokedOps', 'notBefore', 'notAfter', 'ts',
836
+ ];
837
+ function keystateSignedNames(ks) {
838
+ if (ks && Object.prototype.hasOwnProperty.call(ks, 'encPubPqHash')) {
839
+ return KEYSTATE_FIELDS_V1.concat(['encPubPqHash']);
840
+ }
841
+ return KEYSTATE_FIELDS_V1;
842
+ }
843
+ const MAX_KEYSTATE_EPOCH = 2147483647; // 2**31 - 1, shared/keystate.MAX_EPOCH
844
+
845
+ export function verifyKeystate(ks, expectedRootDid, now) {
846
+ try {
847
+ if (!ks || typeof ks !== 'object') return false;
848
+ if (ks.typ !== KEYSTATE_TYP) return false;
849
+ const rootDid = ks.rootDid;
850
+ const epoch = ks.epoch;
851
+ if (!Number.isInteger(epoch) || epoch < 0 || epoch > MAX_KEYSTATE_EPOCH) return false;
852
+ if (expectedRootDid != null && rootDid !== expectedRootDid) return false;
853
+ const didKey = publicKeyHexFromDid(rootDid);
854
+ if (ks.rootKey !== didKey) return false;
855
+ const sig = Buffer.from(String(ks.sig), 'base64');
856
+ if (sig.length !== 64) return false;
857
+ const payloadObj = {};
858
+ for (const k of keystateSignedNames(ks)) {
859
+ payloadObj[k] = ks[k] === undefined ? null : ks[k];
860
+ }
861
+ const payload = canonicalJSON(payloadObj);
862
+ const pub = Buffer.from(String(ks.rootKey), 'hex');
863
+ if (pub.length !== 32) return false;
864
+ if (!verifyBytes(pub, sig, Buffer.from(payload, 'utf8'))) return false;
865
+ if (now != null) {
866
+ const nb = ks.notBefore || 0;
867
+ const na = ks.notAfter;
868
+ if (now < nb) return false;
869
+ if (na != null && now > na) return false;
870
+ }
871
+ return true;
872
+ } catch {
873
+ return false;
874
+ }
875
+ }
876
+
877
+ export function resolveOpDid(rootDid, inlineKeystate, now) {
878
+ if (inlineKeystate && verifyKeystate(inlineKeystate, rootDid, now)) {
879
+ const burned = Array.isArray(inlineKeystate.revokedOps)
880
+ ? inlineKeystate.revokedOps : [];
881
+ const op = inlineKeystate.opDid || rootDid;
882
+ if (burned.includes(op)) return rootDid;
883
+ return op;
884
+ }
885
+ return rootDid;
886
+ }
887
+
814
888
  // ================================================================ Web Bot Auth (RFC 9421 subset, verify-only) — T107
815
889
  //
816
890
  // The INBOUND half only: did the holder of one of the keys this entry was GIVEN sign
@@ -1770,6 +1844,43 @@ function isThenable(v) {
1770
1844
  return v !== null && typeof v === 'object' && typeof v.then === 'function';
1771
1845
  }
1772
1846
 
1847
+ /**
1848
+ * `then1(x, f)` — apply `f` to `x`, awaiting `x` only if it is a promise.
1849
+ *
1850
+ * This is what lets ONE verification ladder serve both the synchronous in-memory state and
1851
+ * an asynchronous external `store` (T105), instead of the ladder being written twice and
1852
+ * drifting. Writing it twice was the alternative considered and rejected: the ladder is the
1853
+ * security-critical path, its steps are ORDER-DEPENDENT, and two copies of an ordered
1854
+ * security argument is how one of them silently stops matching the other.
1855
+ *
1856
+ * The sync branch is not an optimisation — it is the contract. With no store, every step
1857
+ * returns a plain value, the ladder returns a plain value, and `handleRequest` (the
1858
+ * synchronous entry point) keeps working exactly as it always has.
1859
+ */
1860
+ function then1(x, f) {
1861
+ return isThenable(x) ? x.then(f) : f(x);
1862
+ }
1863
+
1864
+ /**
1865
+ * Validate a caller-supplied `store` and return it unchanged.
1866
+ *
1867
+ * Checked ONCE at construction rather than per call, and it throws rather than filling in a
1868
+ * default: a store missing `seenMessage` would otherwise silently disable replay protection,
1869
+ * and the failure would appear as "the door works" until somebody replayed a message.
1870
+ */
1871
+ function asStore(store) {
1872
+ const REQUIRED = ['seenMessage', 'getAccount', 'putAccount', 'getDeviceOwner',
1873
+ 'putDeviceOwner'];
1874
+ const missing = REQUIRED.filter((m) => typeof store[m] !== 'function');
1875
+ if (missing.length) {
1876
+ throw new TypeError(
1877
+ `createAgentEntry: store is missing ${missing.join(', ')}. A store must implement all `
1878
+ + `of ${REQUIRED.join(', ')} — a partial store would disable a security rule silently `
1879
+ + 'rather than loudly.');
1880
+ }
1881
+ return store;
1882
+ }
1883
+
1773
1884
  // ---------------------------------------------------------------- baseUrl canonicalisation
1774
1885
 
1775
1886
  /** RFC 3986 `pchar` plus '/' — the only characters an accepted path may carry. Everything
@@ -2385,6 +2496,7 @@ export function createAgentEntry({
2385
2496
  howToUrl = FIRST_KNOCK_URL,
2386
2497
  observer = null,
2387
2498
  wbaVerifiers = null,
2499
+ store = null,
2388
2500
  } = {}) {
2389
2501
  if (!seedHex) throw new TypeError('createAgentEntry: seedHex is required');
2390
2502
  if (!baseUrl) throw new TypeError('createAgentEntry: baseUrl is required (it is signed into the card)');
@@ -2531,6 +2643,53 @@ export function createAgentEntry({
2531
2643
  // Unlike the ledger it is READ on every message, so only a real store can carry it.
2532
2644
  const deviceOwner = new Map();
2533
2645
  const replay = new ReplayGuard();
2646
+
2647
+ // ---------------------------------------------------------------- the state seam (T105)
2648
+ //
2649
+ // WHY. The three structures above live in CLOSURE MEMORY, and a serverless instance keeps
2650
+ // none of them between requests. The round-trip shape suits those platforms perfectly —
2651
+ // one signed POST in, one signed reply out, nothing to keep awake — so what blocks them is
2652
+ // not the shape but the STATE. Losing `replay` re-opens every message inside the freshness
2653
+ // window to replay; losing `deviceOwner` resets the no-re-ownership rule to
2654
+ // trust-on-first-use at every cold start.
2655
+ //
2656
+ // `store` is an OPTIONAL duck-typed object whose five methods may each return a value or a
2657
+ // promise:
2658
+ //
2659
+ // seenMessage(messageId, ttlSeconds) -> bool TRUE when the id was NEW (and is now
2660
+ // remembered). The test and the remember
2661
+ // MUST be atomic in the backing store.
2662
+ // getAccount(did) -> row | null
2663
+ // putAccount(did, row) -> void row === null DELETES the row.
2664
+ // getDeviceOwner(deviceDid)-> ownerDid | null
2665
+ // putDeviceOwner(dev, own) -> void
2666
+ //
2667
+ // Rate counters are deliberately NOT in this interface, and that is the approved design,
2668
+ // not an omission: a bound that costs a store write per request is its own denial of
2669
+ // service, and losing one fails open for a single minute — a bounded loss, unlike a lost
2670
+ // replay set or a lost pin.
2671
+ //
2672
+ // WITH NO STORE the default below is SYNCHRONOUS and wraps exactly the Maps above, so the
2673
+ // no-store path stays byte-identical and `handleRequest` (the sync entry point) keeps
2674
+ // working. With a store the message path returns a promise, which `route()` already
2675
+ // propagates and `handleRequestAsync` already awaits.
2676
+ const memoryStore = {
2677
+ seenMessage: (messageId) => replay.checkAndRemember(messageId),
2678
+ getAccount: (did) => ledger.get(did) ?? null,
2679
+ putAccount: (did, row) => {
2680
+ if (row === null) { ledger.delete(did); return; }
2681
+ ledger.set(did, row);
2682
+ while (ledger.size > maxAccounts) {
2683
+ const oldest = ledger.keys().next();
2684
+ if (oldest.done) break;
2685
+ ledger.delete(oldest.value);
2686
+ }
2687
+ },
2688
+ getDeviceOwner: (deviceDid) => deviceOwner.get(deviceDid) ?? null,
2689
+ putDeviceOwner: (deviceDid, ownerDid) => { deviceOwner.set(deviceDid, ownerDid); },
2690
+ };
2691
+ const state = store ? asStore(store) : memoryStore;
2692
+ const storeIsExternal = Boolean(store);
2534
2693
  const anonRate = new RateBound(anonRatePerMin);
2535
2694
  // A tier is ON unless its ceiling is a non-positive or non-finite number. Constructed
2536
2695
  // rather than clamped, so "disabled" is one absent object and never a bound of 0 — which
@@ -2726,25 +2885,24 @@ export function createAgentEntry({
2726
2885
  return sigEnvelope;
2727
2886
  }
2728
2887
 
2888
+ /** Record contact from an account. Returns the row (or a promise of it, with a store). */
2729
2889
  function noteContact(accountDid) {
2730
2890
  const now = nowEpoch();
2731
- const row = ledger.get(accountDid);
2732
- if (row) {
2733
- row.messages += 1;
2734
- row.last_seen = now;
2735
- return row;
2736
- }
2737
- // FIRST CONTACT IS ACCOUNT CREATION. There is no signup form: the sender proved control
2738
- // of a device key one line above, which is strictly more than an email link. The row is
2739
- // keyed by the ACCOUNT (the owner when bound), so sibling devices are one customer.
2740
- const fresh = { first_seen: now, last_seen: now, messages: 1 };
2741
- ledger.set(accountDid, fresh);
2742
- while (ledger.size > maxAccounts) {
2743
- const oldest = ledger.keys().next();
2744
- if (oldest.done) break;
2745
- ledger.delete(oldest.value);
2746
- }
2747
- return fresh;
2891
+ return then1(state.getAccount(accountDid), (row) => {
2892
+ if (row) {
2893
+ // A store hands back a COPY, so the row must be written home again. The in-memory
2894
+ // store hands back the live object and the write is a no-op re-set of the same
2895
+ // reference — one code path, correct for both.
2896
+ const updated = { ...row, messages: (row.messages || 0) + 1, last_seen: now };
2897
+ return then1(state.putAccount(accountDid, updated), () => updated);
2898
+ }
2899
+ // FIRST CONTACT IS ACCOUNT CREATION. There is no signup form: the sender proved
2900
+ // control of a device key one line above, which is strictly more than an email link.
2901
+ // The row is keyed by the ACCOUNT (the owner when bound), so sibling devices are one
2902
+ // customer.
2903
+ const fresh = { first_seen: now, last_seen: now, messages: 1 };
2904
+ return then1(state.putAccount(accountDid, fresh), () => fresh);
2905
+ });
2748
2906
  }
2749
2907
 
2750
2908
  /** When a device that ALREADY has an unbound ledger row first proves its owner, move that
@@ -2752,13 +2910,19 @@ export function createAgentEntry({
2752
2910
  * resolves to the device DID and must not merge, or stripping a binding would become a
2753
2911
  * way to read the owner's history. */
2754
2912
  function foldDeviceIntoOwner(deviceDid, ownerDid) {
2755
- const devRow = ledger.get(deviceDid);
2756
- if (!devRow) return;
2757
- ledger.delete(deviceDid);
2758
- const ownerRow = ledger.get(ownerDid);
2759
- if (!ownerRow) { ledger.set(ownerDid, devRow); return; }
2760
- ownerRow.messages += devRow.messages || 0;
2761
- ownerRow.first_seen = Math.min(ownerRow.first_seen, devRow.first_seen);
2913
+ return then1(state.getAccount(deviceDid), (devRow) => {
2914
+ if (!devRow) return undefined;
2915
+ return then1(state.putAccount(deviceDid, null), () =>
2916
+ then1(state.getAccount(ownerDid), (ownerRow) => {
2917
+ if (!ownerRow) return state.putAccount(ownerDid, devRow);
2918
+ const merged = {
2919
+ ...ownerRow,
2920
+ messages: (ownerRow.messages || 0) + (devRow.messages || 0),
2921
+ first_seen: Math.min(ownerRow.first_seen, devRow.first_seen),
2922
+ };
2923
+ return state.putAccount(ownerDid, merged);
2924
+ }));
2925
+ });
2762
2926
  }
2763
2927
 
2764
2928
  /**
@@ -2806,17 +2970,18 @@ export function createAgentEntry({
2806
2970
  if (!verifyDeviceBindingV2(binding, { now, expectedDeviceDid: from })) {
2807
2971
  return { ok: false, reason: 'device binding does not verify' };
2808
2972
  }
2809
- const pinned = deviceOwner.get(from);
2810
- if (pinned !== undefined && pinned !== rootDid) {
2811
- return { ok: false, reason:
2812
- 'device is already bound to a different owner (a device DID is never re-owned '
2813
- + '— a new owner means a new device key)' };
2814
- }
2815
- if (pinned === undefined) {
2816
- deviceOwner.set(from, rootDid);
2817
- foldDeviceIntoOwner(from, rootDid);
2818
- }
2819
- return { ok: true, account: rootDid };
2973
+ return then1(state.getDeviceOwner(from), (pinned) => {
2974
+ if (pinned !== null && pinned !== undefined && pinned !== rootDid) {
2975
+ return { ok: false, reason:
2976
+ 'device is already bound to a different owner (a device DID is never re-owned '
2977
+ + '— a new owner means a new device key)' };
2978
+ }
2979
+ if (pinned === null || pinned === undefined) {
2980
+ return then1(state.putDeviceOwner(from, rootDid), () =>
2981
+ then1(foldDeviceIntoOwner(from, rootDid), () => ({ ok: true, account: rootDid })));
2982
+ }
2983
+ return { ok: true, account: rootDid };
2984
+ });
2820
2985
  }
2821
2986
 
2822
2987
  /** The FROZEN backend-handoff shape (agent/webhookwake.py::_envelope). The site's own
@@ -3097,16 +3262,19 @@ export function createAgentEntry({
3097
3262
  `the anonymous lane is limited to ${anonRatePerMin} replies per minute — `
3098
3263
  + 'sign your message to lift the bound');
3099
3264
  }
3100
- if (!replay.checkAndRemember(msg.messageId)) {
3101
- return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'duplicate messageId (replay) detected');
3102
- }
3103
- // T107: the interesting case — an anonymous inquiry whose TRANSPORT a known key
3104
- // signed. `verified` STAYS false (the WBA signature covers @authority +
3105
- // signature-agent, not the text), no ledger row is minted (the header set is a
3106
- // bearer credential and replayable while it lives), and the anon rate bound
3107
- // above already applied. Identify, don't enrol.
3108
- return respond(backendEnvelope(msg, { verified: false, peerDid: null,
3109
- wbaDid: wbaIdentify(reqHeaders) }), reqId, msg, '');
3265
+ return then1(state.seenMessage(msg.messageId, REPLAY_TTL_S), (fresh) => {
3266
+ if (!fresh) {
3267
+ return rpcError(reqId, ERRORS.REPLAY_REJECTED,
3268
+ 'duplicate messageId (replay) detected');
3269
+ }
3270
+ // T107: the interesting case an anonymous inquiry whose TRANSPORT a known key
3271
+ // signed. `verified` STAYS false (the WBA signature covers @authority +
3272
+ // signature-agent, not the text), no ledger row is minted (the header set is a
3273
+ // bearer credential and replayable while it lives), and the anon rate bound
3274
+ // above already applied. Identify, don't enrol.
3275
+ return respond(backendEnvelope(msg, { verified: false, peerDid: null,
3276
+ wbaDid: wbaIdentify(reqHeaders) }), reqId, msg, '');
3277
+ });
3110
3278
  }
3111
3279
  // 5. addressed to someone else. Checked BEFORE decoding `from`, so a junk DID in a
3112
3280
  // misaddressed message never reaches the base58 decoder.
@@ -3131,12 +3299,13 @@ export function createAgentEntry({
3131
3299
  if (!Number.isSafeInteger(ts) || Math.abs(nowEpoch() - ts) > CLOCK_WINDOW_S) {
3132
3300
  return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'timestamp out of range (clock skew or replay)');
3133
3301
  }
3134
- // 7. the signature itself, under the key DERIVED FROM `from`. (`messageId`'s type was
3135
- // settled by the shape gate: a non-string never reaches here on either implementation.)
3302
+ // 7. the signature itself. Payload `from` is the root DID; the verifying key is
3303
+ // the delegated op-key when a valid inline KeyState is attached (T142 A2).
3136
3304
  const messageId = msg.messageId;
3137
3305
  const fields = { from, to, messageId, contextId: msg.contextId ?? null,
3138
3306
  timestamp: ts, text, sig };
3139
- if (!verifyEnvelope(fields, { recipientDid: did })) {
3307
+ const signerDid = resolveOpDid(from, meta.keystate, ts);
3308
+ if (!verifyEnvelope(fields, { recipientDid: did, signerDid })) {
3140
3309
  return rpcError(reqId, ERRORS.UNAUTHENTICATED, 'signature does not match');
3141
3310
  }
3142
3311
  // 8. duplicate messageId inside the replay window — AFTER the verify, and the ORDER is the
@@ -3148,40 +3317,54 @@ export function createAgentEntry({
3148
3317
  // rule the signed lane's ceiling already follows one step below, for the same reason; it
3149
3318
  // was simply never applied here. The cost of the swap is one Ed25519 verify spent on a
3150
3319
  // replayed VALID message, which an attacker must first have obtained.
3151
- if (!replay.checkAndRemember(messageId)) {
3152
- return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'duplicate messageId (replay) detected');
3153
- }
3154
-
3155
- // 9. T102 account layer. An OPTIONAL countersigned v2 binding collapses an owner's device
3156
- // DIDs to ONE account; a present-but-INVALID binding fails closed with the SAME
3157
- // UNAUTHENTICATED code (never a silent downgrade to unbound). Absent the device DID.
3158
- const acct = resolveAccount(meta.binding, from);
3159
- if (!acct.ok) return rpcError(reqId, ERRORS.UNAUTHENTICATED, acct.reason);
3160
- const account = acct.account;
3161
- const ownerDid = account !== from ? account : null;
3162
-
3163
- // 10. THE SIGNED LANE'S CEILING. Here and not earlier: before the signature a stranger
3164
- // could spend somebody else's budget by naming them, and before `resolveAccount` an
3165
- // owner's devices would each get their own. Here and not later: a refused flood must
3166
- // grow neither the ledger nor whatever the responder costs.
3167
- // PER-ACCOUNT FIRST, deliberately one loud peer is then stopped by ITS OWN window
3168
- // without drawing down the shared one, so it cannot starve everybody else on its way
3169
- // to being refused. Neither refusal names its ceiling: a published number is a
3170
- // calibration table telling a flood exactly how many keys to mint.
3171
- if (signedAccountRate && !signedAccountRate.allow(account)) {
3172
- return rpcError(reqId, ERRORS.RATE_LIMITED,
3173
- 'you are sending faster than this door answers slow down and retry');
3174
- }
3175
- if (signedTotalRate && !signedTotalRate.allow()) {
3176
- return rpcError(reqId, ERRORS.RATE_LIMITED,
3177
- 'this entry is at its ceiling right now retry shortly');
3178
- }
3179
-
3180
- noteContact(account);
3181
- // T107: `wba_did` may legitimately differ from `peer_did` (the transport signer vs
3182
- // the message signer) — both facts are honest, and the schema says which is which.
3183
- return respond(backendEnvelope(msg, { verified: true, peerDid: from, ownerDid,
3184
- wbaDid: wbaIdentify(reqHeaders) }), reqId, msg, from);
3320
+ return then1(state.seenMessage(messageId, REPLAY_TTL_S), (fresh) => {
3321
+ if (!fresh) {
3322
+ return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'duplicate messageId (replay) detected');
3323
+ }
3324
+
3325
+ // 9. T102 account layer. An OPTIONAL countersigned v2 binding collapses an owner's
3326
+ // device DIDs to ONE account; a present-but-INVALID binding fails closed with the
3327
+ // SAME UNAUTHENTICATED code (never a silent downgrade to unbound). Absent → the
3328
+ // device DID.
3329
+ return then1(resolveAccount(meta.binding, from), (acct) => {
3330
+ if (!acct.ok) return rpcError(reqId, ERRORS.UNAUTHENTICATED, acct.reason);
3331
+ const account = acct.account;
3332
+ const ownerDid = account !== from ? account : null;
3333
+
3334
+ // 10. THE SIGNED LANE'S CEILING. Here and not earlier: before the signature a
3335
+ // stranger could spend somebody else's budget by naming them, and before
3336
+ // `resolveAccount` an owner's devices would each get their own. Here and not
3337
+ // later: a refused flood must grow neither the ledger nor whatever the
3338
+ // responder costs.
3339
+ // PER-ACCOUNT FIRST, deliberately one loud peer is then stopped by ITS OWN
3340
+ // window without drawing down the shared one, so it cannot starve everybody
3341
+ // else on its way to being refused. Neither refusal names its ceiling: a
3342
+ // published number is a calibration table telling a flood exactly how many keys
3343
+ // to mint.
3344
+ //
3345
+ // These two bounds stay IN PROCESS even with an external store, and that is the
3346
+ // approved design rather than an omission: a ceiling that costs a store write
3347
+ // per request is its own denial of service, and losing a counter fails open for
3348
+ // one minute — bounded, unlike a lost replay set or a lost device pin. A
3349
+ // serverless deployment therefore gets its ceiling per instance; put a real one
3350
+ // at the edge if that matters.
3351
+ if (signedAccountRate && !signedAccountRate.allow(account)) {
3352
+ return rpcError(reqId, ERRORS.RATE_LIMITED,
3353
+ 'you are sending faster than this door answers — slow down and retry');
3354
+ }
3355
+ if (signedTotalRate && !signedTotalRate.allow()) {
3356
+ return rpcError(reqId, ERRORS.RATE_LIMITED,
3357
+ 'this entry is at its ceiling right now — retry shortly');
3358
+ }
3359
+
3360
+ return then1(noteContact(account), () =>
3361
+ // T107: `wba_did` may legitimately differ from `peer_did` (the transport signer
3362
+ // vs the message signer) — both facts are honest, and the schema says which is
3363
+ // which.
3364
+ respond(backendEnvelope(msg, { verified: true, peerDid: from, ownerDid,
3365
+ wbaDid: wbaIdentify(reqHeaders) }), reqId, msg, from));
3366
+ });
3367
+ });
3185
3368
  }
3186
3369
 
3187
3370
  /** Hand the envelope to the watcher, and make sure it can cost nothing.
@@ -3419,8 +3602,14 @@ export function createAgentEntry({
3419
3602
  function handleRequest(method, path, headers, bodyBuffer) {
3420
3603
  const out = route(method, path, bodyBuffer, headers);
3421
3604
  if (isThenable(out)) {
3422
- return rpcError(null, ERRORS.INTERNAL_ERROR,
3423
- 'responder is async serve this agent entry through listen()/handleRequestAsync()');
3605
+ // Two different causes, and an operator can only fix the one they are told about.
3606
+ // A `store` makes EVERY message path async by construction, so saying "responder is
3607
+ // async" to somebody who passed a synchronous responder and a KV store would send
3608
+ // them looking in the wrong place entirely.
3609
+ return rpcError(null, ERRORS.INTERNAL_ERROR, storeIsExternal
3610
+ ? 'this entry has an external store, so every message is answered asynchronously — '
3611
+ + 'serve it through listen()/handleRequestAsync()'
3612
+ : 'responder is async — serve this agent entry through listen()/handleRequestAsync()');
3424
3613
  }
3425
3614
  return out;
3426
3615
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@muretai/agent-entry",
3
- "version": "1.10.0",
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.",
3
+ "version": "1.11.0",
4
+ "description": "Agents already visit your site. Give them a way to become a customer: know who knocked, remember them next time, answer in the same request. Zero dependencies.",
5
5
  "type": "module",
6
6
  "main": "muretai-agent-entry.mjs",
7
7
  "exports": {
@@ -11,6 +11,8 @@
11
11
  "muretai-agent-entry.mjs",
12
12
  "conformance/",
13
13
  "examples/server.mjs",
14
+ "diagrams/",
15
+ "scripts/distill/",
14
16
  "LICENSE",
15
17
  "spec/",
16
18
  "README.md"
@@ -20,7 +22,10 @@
20
22
  },
21
23
  "scripts": {
22
24
  "test": "node conformance/run.mjs",
23
- "example": "node examples/server.mjs"
25
+ "example": "node examples/server.mjs",
26
+ "distill": "node scripts/distill/loop.mjs",
27
+ "distill:record": "node scripts/distill/record.mjs",
28
+ "distill:measure": "node scripts/distill/measure.mjs"
24
29
  },
25
30
  "keywords": [
26
31
  "ai-agent",
@@ -0,0 +1,32 @@
1
+ # Local feedback loop (this machine only)
2
+
3
+ Turn knocks you already answered into a **proposed** `skills[]` menu, and
4
+ measure whether that menu would have made the next visitor’s first knock
5
+ useful.
6
+
7
+ Nothing here uploads visitor text, DIDs, or traces. The Distiller is not
8
+ imported by `muretai-agent-entry.mjs`.
9
+
10
+ ```
11
+ npm run distill # fixtures → skills.json → holdout measure
12
+ npm run distill -- --m0 # also run the door conformance suite
13
+ npm run distill:record # print the observer snippet
14
+ ```
15
+
16
+ ## Record (optional)
17
+
18
+ `createAgentEntry({ observer: fileSink() })` appends POST outcomes to
19
+ `var/traces.jsonl` (gitignored). Card/notice GETs are dropped. DIDs are
20
+ not written. The door still discards the observer’s return — a full disk
21
+ cannot change a signed reply.
22
+
23
+ ## Admission
24
+
25
+ Publish the proposed menu onto *your* card only when:
26
+
27
+ 1. holdout first-knock useful rises
28
+ 2. emptying the Distiller kills that lift
29
+ 3. every proposed `examples[]` entry is answerable by *your* responder
30
+ 4. the door conformance suite is still green
31
+
32
+ This package never patches a stranger’s `skills[]`.
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Traces → generated/skills.json + rules.json.
4
+ * Empty Distiller: --empty (mutation). Never writes the running card.
5
+ */
6
+ import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
7
+ import { dirname, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { inferRules, loadCases, proposedSkills, readJsonl, tracesFromCases, respond } from './lib.mjs';
10
+
11
+ const HERE = dirname(fileURLToPath(import.meta.url));
12
+ const ROOT = resolve(HERE, '../..');
13
+ const OUT = resolve(ROOT, 'generated');
14
+
15
+ function localTraces(path) {
16
+ return readJsonl(path).map((row, i) => {
17
+ const text = row.text || row.doc?.text || '';
18
+ const env = respond(text);
19
+ return {
20
+ id: row.id || `local-${i + 1}`,
21
+ intent: /\b(book|reserve|slot)\b/i.test(text) ? 'book' : (/\b(price|cost|how much)\b/i.test(text) ? 'price' : 'other'),
22
+ text,
23
+ tag: env.tag,
24
+ environment_useful: env.useful,
25
+ };
26
+ });
27
+ }
28
+
29
+ const empty = process.argv.includes('--empty');
30
+ const tracesPath = process.env.AE_TRACES || resolve(ROOT, 'var/traces.jsonl');
31
+ const train = tracesFromCases(loadCases(), 'train');
32
+ const local = existsSync(tracesPath) ? localTraces(tracesPath) : [];
33
+ const rules = empty ? { empty: true, examples: [], evidence: [] } : inferRules([...train, ...local]);
34
+ const skills = proposedSkills(rules);
35
+
36
+ mkdirSync(OUT, { recursive: true });
37
+ writeFileSync(resolve(OUT, 'rules.json'), JSON.stringify(rules, null, 2) + '\n');
38
+ writeFileSync(resolve(OUT, 'skills.json'), JSON.stringify(skills, null, 2) + '\n');
39
+ writeFileSync(resolve(OUT, 'R.json'), JSON.stringify({
40
+ traces: train.length + local.length,
41
+ local: local.length,
42
+ empty,
43
+ book_require_day: Boolean(rules.book_require_day),
44
+ examples: rules.examples || [],
45
+ }, null, 2) + '\n');
46
+
47
+ process.stdout.write(
48
+ `distilled ${train.length} fixture + ${local.length} local traces`
49
+ + (empty ? ' (empty Distiller)' : '')
50
+ + ` → generated/skills.json (${skills.length} skill(s))\n`,
51
+ );