@muretai/agent-entry 1.9.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>
@@ -43,6 +43,10 @@
43
43
  * https://example.com/agent) and answers NOTHING at `/` — no notice,
44
44
  * no OPTIONS, no POST. Point your proxy at the door path and the
45
45
  * well-known paths; the site keeps everything else, unchanged.
46
+ * AGENT_ENTRY_PREFER OPTIONAL: the site's own order of its ways in, as one JSON array
47
+ * (AE-30), e.g. '[{"kind":"page","when":"no-key"},"card"]' — "read on
48
+ * the page if you hold no key; otherwise the door". Published verbatim
49
+ * as `agentEntry.prefer`; an invalid list REFUSES to start.
46
50
  * AGENT_ENTRY_WBA_JWKS OPTIONAL: a JWKS document {"keys":[…]} as one JSON string —
47
51
  * the Web Bot Auth key directory (verified out of band) whose
48
52
  * holders this entry should RECOGNISE on inbound requests. Off
@@ -156,6 +160,10 @@ try {
156
160
  ...(process.env.AGENT_ENTRY_SIGNED_RATE_TOTAL
157
161
  ? { signedRatePerMinTotal: Number(process.env.AGENT_ENTRY_SIGNED_RATE_TOTAL) } : {}),
158
162
  guest: process.env.AGENT_ENTRY_GUEST === '1',
163
+ // AE-30: the site's own order of its ways in, a JSON array. Malformed JSON or an
164
+ // unknown kind/condition throws inside this try and the entry never starts — the same
165
+ // posture as a bad domain list: never publish a statement the operator did not make.
166
+ ...(process.env.AGENT_ENTRY_PREFER ? { prefer: JSON.parse(process.env.AGENT_ENTRY_PREFER) } : {}),
159
167
  wbaVerifiers,
160
168
  });
161
169
  } catch (err) {
@@ -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
@@ -2148,6 +2259,43 @@ function domainFix(candidate) {
2148
2259
  * refuses every other bad value there. Truncating would start the entry with a claim that
2149
2260
  * is USABLE and NOT WHAT THEY SAID.
2150
2261
  */
2262
+ /** The kinds a visitor can take into a site, and the conditions a site may attach. Kept
2263
+ * identical to the visitor side (Agent Web Router `parsePrefer`): a kind or condition one
2264
+ * side knows and the other does not is a declaration one side silently drops. */
2265
+ export const PREFER_KINDS = ['page', 'card', 'mcp'];
2266
+ export const PREFER_WHEN = ['person', 'alone', 'key', 'no-key', 'token', 'browser'];
2267
+
2268
+ /**
2269
+ * The exact `agentEntry.prefer` this entry may publish, or a TypeError (AE-30).
2270
+ *
2271
+ * The site's own order of its ways in — "read on the page if you have no key, then the
2272
+ * door", say. VALIDATED, NEVER REWRITTEN: this goes on a SIGNED card, and a card that says
2273
+ * something the operator did not write is a worse card than none, so an unknown kind, an
2274
+ * unknown condition or a stray key refuses the whole declaration instead of trimming it —
2275
+ * the same posture as `canonicalDomains`. `null`/`undefined` means "not configured", and
2276
+ * then no `prefer` key is published at all, which is what keeps an already-deployed
2277
+ * entry's bytes unchanged.
2278
+ */
2279
+ export function validatePrefer(prefer) {
2280
+ if (prefer == null) return null;
2281
+ if (!Array.isArray(prefer) || prefer.length === 0) {
2282
+ throw new TypeError('agentEntry.prefer must be a non-empty array of "page" | "card" | "mcp" or {kind, when}');
2283
+ }
2284
+ for (const e of prefer) {
2285
+ if (typeof e === 'string') {
2286
+ if (!PREFER_KINDS.includes(e)) throw new TypeError(`agentEntry.prefer: unknown kind ${JSON.stringify(e)}`);
2287
+ continue;
2288
+ }
2289
+ if (!e || typeof e !== 'object' || Array.isArray(e)) throw new TypeError('agentEntry.prefer: an entry must be a kind or {kind, when}');
2290
+ const keys = Object.keys(e);
2291
+ if (!PREFER_KINDS.includes(e.kind)) throw new TypeError(`agentEntry.prefer: unknown kind ${JSON.stringify(e.kind)}`);
2292
+ if ('when' in e && !PREFER_WHEN.includes(e.when)) throw new TypeError(`agentEntry.prefer: unknown condition ${JSON.stringify(e.when)}`);
2293
+ const stray = keys.filter((k) => k !== 'kind' && k !== 'when');
2294
+ if (stray.length) throw new TypeError(`agentEntry.prefer: unexpected key(s) ${stray.join(', ')}`);
2295
+ }
2296
+ return prefer;
2297
+ }
2298
+
2151
2299
  export function canonicalDomains(domains, { warn = true } = {}) {
2152
2300
  if (domains === undefined || domains === null) return [];
2153
2301
  if (!Array.isArray(domains)) {
@@ -2269,6 +2417,11 @@ export function canonicalMount(canonUrl, basePath) {
2269
2417
  * responder (envelope) => string | {text, contextId?, timestamp?} | Promise<…>
2270
2418
  * openDoor advertise `muretai.open_door` (default true) — the flag that tells a
2271
2419
  * visiting agent it may contact you without an introduction.
2420
+ * prefer OPTIONAL: the site's own order of its ways in, published as
2421
+ * `agentEntry.prefer` (AE-30) — e.g. `[{kind:'page', when:'no-key'}, 'card']`
2422
+ * says "read on the page if you hold no key; otherwise the door". Validated
2423
+ * by `validatePrefer`; an invalid list throws, so the entry never starts
2424
+ * with a statement the operator did not make. Absent = no key published.
2272
2425
  * anonymousLane also accept UNSIGNED inquiries (default false). They create no account,
2273
2426
  * and the lane as a whole is capped at `anonRatePerMin` signed replies per
2274
2427
  * minute — it is unauthenticated, so it must not be an unmetered signing
@@ -2330,6 +2483,7 @@ export function createAgentEntry({
2330
2483
  version = '1',
2331
2484
  responder = () => 'Thanks — a human will follow up.',
2332
2485
  openDoor = true,
2486
+ prefer = null,
2333
2487
  anonymousLane = false,
2334
2488
  anonRatePerMin = ANON_RATE_PER_MIN,
2335
2489
  signedRatePerMin = SIGNED_RATE_PER_MIN,
@@ -2342,6 +2496,7 @@ export function createAgentEntry({
2342
2496
  howToUrl = FIRST_KNOCK_URL,
2343
2497
  observer = null,
2344
2498
  wbaVerifiers = null,
2499
+ store = null,
2345
2500
  } = {}) {
2346
2501
  if (!seedHex) throw new TypeError('createAgentEntry: seedHex is required');
2347
2502
  if (!baseUrl) throw new TypeError('createAgentEntry: baseUrl is required (it is signed into the card)');
@@ -2404,7 +2559,10 @@ export function createAgentEntry({
2404
2559
  // Neutral key first, vendor key beside it for one release. See the securitySchemes block
2405
2560
  // below for why the old spelling stays: a consumer must learn the new name BEFORE
2406
2561
  // producers stop emitting the old one, never after.
2407
- if (openDoor) card.agentEntry = { open_door: true };
2562
+ // AE-30: the site's order rides on the NEUTRAL key only; the alias stays `open_door`
2563
+ // alone, so an old consumer that compares the two aliases byte for byte keeps passing.
2564
+ const canonPrefer = validatePrefer(prefer);
2565
+ if (openDoor) card.agentEntry = { open_door: true, ...(canonPrefer ? { prefer: canonPrefer } : {}) };
2408
2566
  if (openDoor) card.muretai = { open_door: true };
2409
2567
  // Deliberately NO `relay`/`enc_pub` on the card: those advertise a store-and-forward
2410
2568
  // mailbox, and an agent entry has no listener draining one. Advertising a mailbox nobody
@@ -2485,6 +2643,53 @@ export function createAgentEntry({
2485
2643
  // Unlike the ledger it is READ on every message, so only a real store can carry it.
2486
2644
  const deviceOwner = new Map();
2487
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);
2488
2693
  const anonRate = new RateBound(anonRatePerMin);
2489
2694
  // A tier is ON unless its ceiling is a non-positive or non-finite number. Constructed
2490
2695
  // rather than clamped, so "disabled" is one absent object and never a bound of 0 — which
@@ -2680,25 +2885,24 @@ export function createAgentEntry({
2680
2885
  return sigEnvelope;
2681
2886
  }
2682
2887
 
2888
+ /** Record contact from an account. Returns the row (or a promise of it, with a store). */
2683
2889
  function noteContact(accountDid) {
2684
2890
  const now = nowEpoch();
2685
- const row = ledger.get(accountDid);
2686
- if (row) {
2687
- row.messages += 1;
2688
- row.last_seen = now;
2689
- return row;
2690
- }
2691
- // FIRST CONTACT IS ACCOUNT CREATION. There is no signup form: the sender proved control
2692
- // of a device key one line above, which is strictly more than an email link. The row is
2693
- // keyed by the ACCOUNT (the owner when bound), so sibling devices are one customer.
2694
- const fresh = { first_seen: now, last_seen: now, messages: 1 };
2695
- ledger.set(accountDid, fresh);
2696
- while (ledger.size > maxAccounts) {
2697
- const oldest = ledger.keys().next();
2698
- if (oldest.done) break;
2699
- ledger.delete(oldest.value);
2700
- }
2701
- 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
+ });
2702
2906
  }
2703
2907
 
2704
2908
  /** When a device that ALREADY has an unbound ledger row first proves its owner, move that
@@ -2706,13 +2910,19 @@ export function createAgentEntry({
2706
2910
  * resolves to the device DID and must not merge, or stripping a binding would become a
2707
2911
  * way to read the owner's history. */
2708
2912
  function foldDeviceIntoOwner(deviceDid, ownerDid) {
2709
- const devRow = ledger.get(deviceDid);
2710
- if (!devRow) return;
2711
- ledger.delete(deviceDid);
2712
- const ownerRow = ledger.get(ownerDid);
2713
- if (!ownerRow) { ledger.set(ownerDid, devRow); return; }
2714
- ownerRow.messages += devRow.messages || 0;
2715
- 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
+ });
2716
2926
  }
2717
2927
 
2718
2928
  /**
@@ -2760,17 +2970,18 @@ export function createAgentEntry({
2760
2970
  if (!verifyDeviceBindingV2(binding, { now, expectedDeviceDid: from })) {
2761
2971
  return { ok: false, reason: 'device binding does not verify' };
2762
2972
  }
2763
- const pinned = deviceOwner.get(from);
2764
- if (pinned !== undefined && pinned !== rootDid) {
2765
- return { ok: false, reason:
2766
- 'device is already bound to a different owner (a device DID is never re-owned '
2767
- + '— a new owner means a new device key)' };
2768
- }
2769
- if (pinned === undefined) {
2770
- deviceOwner.set(from, rootDid);
2771
- foldDeviceIntoOwner(from, rootDid);
2772
- }
2773
- 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
+ });
2774
2985
  }
2775
2986
 
2776
2987
  /** The FROZEN backend-handoff shape (agent/webhookwake.py::_envelope). The site's own
@@ -3051,16 +3262,19 @@ export function createAgentEntry({
3051
3262
  `the anonymous lane is limited to ${anonRatePerMin} replies per minute — `
3052
3263
  + 'sign your message to lift the bound');
3053
3264
  }
3054
- if (!replay.checkAndRemember(msg.messageId)) {
3055
- return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'duplicate messageId (replay) detected');
3056
- }
3057
- // T107: the interesting case — an anonymous inquiry whose TRANSPORT a known key
3058
- // signed. `verified` STAYS false (the WBA signature covers @authority +
3059
- // signature-agent, not the text), no ledger row is minted (the header set is a
3060
- // bearer credential and replayable while it lives), and the anon rate bound
3061
- // above already applied. Identify, don't enrol.
3062
- return respond(backendEnvelope(msg, { verified: false, peerDid: null,
3063
- 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
+ });
3064
3278
  }
3065
3279
  // 5. addressed to someone else. Checked BEFORE decoding `from`, so a junk DID in a
3066
3280
  // misaddressed message never reaches the base58 decoder.
@@ -3085,12 +3299,13 @@ export function createAgentEntry({
3085
3299
  if (!Number.isSafeInteger(ts) || Math.abs(nowEpoch() - ts) > CLOCK_WINDOW_S) {
3086
3300
  return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'timestamp out of range (clock skew or replay)');
3087
3301
  }
3088
- // 7. the signature itself, under the key DERIVED FROM `from`. (`messageId`'s type was
3089
- // 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).
3090
3304
  const messageId = msg.messageId;
3091
3305
  const fields = { from, to, messageId, contextId: msg.contextId ?? null,
3092
3306
  timestamp: ts, text, sig };
3093
- if (!verifyEnvelope(fields, { recipientDid: did })) {
3307
+ const signerDid = resolveOpDid(from, meta.keystate, ts);
3308
+ if (!verifyEnvelope(fields, { recipientDid: did, signerDid })) {
3094
3309
  return rpcError(reqId, ERRORS.UNAUTHENTICATED, 'signature does not match');
3095
3310
  }
3096
3311
  // 8. duplicate messageId inside the replay window — AFTER the verify, and the ORDER is the
@@ -3102,40 +3317,54 @@ export function createAgentEntry({
3102
3317
  // rule the signed lane's ceiling already follows one step below, for the same reason; it
3103
3318
  // was simply never applied here. The cost of the swap is one Ed25519 verify spent on a
3104
3319
  // replayed VALID message, which an attacker must first have obtained.
3105
- if (!replay.checkAndRemember(messageId)) {
3106
- return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'duplicate messageId (replay) detected');
3107
- }
3108
-
3109
- // 9. T102 account layer. An OPTIONAL countersigned v2 binding collapses an owner's device
3110
- // DIDs to ONE account; a present-but-INVALID binding fails closed with the SAME
3111
- // UNAUTHENTICATED code (never a silent downgrade to unbound). Absent the device DID.
3112
- const acct = resolveAccount(meta.binding, from);
3113
- if (!acct.ok) return rpcError(reqId, ERRORS.UNAUTHENTICATED, acct.reason);
3114
- const account = acct.account;
3115
- const ownerDid = account !== from ? account : null;
3116
-
3117
- // 10. THE SIGNED LANE'S CEILING. Here and not earlier: before the signature a stranger
3118
- // could spend somebody else's budget by naming them, and before `resolveAccount` an
3119
- // owner's devices would each get their own. Here and not later: a refused flood must
3120
- // grow neither the ledger nor whatever the responder costs.
3121
- // PER-ACCOUNT FIRST, deliberately one loud peer is then stopped by ITS OWN window
3122
- // without drawing down the shared one, so it cannot starve everybody else on its way
3123
- // to being refused. Neither refusal names its ceiling: a published number is a
3124
- // calibration table telling a flood exactly how many keys to mint.
3125
- if (signedAccountRate && !signedAccountRate.allow(account)) {
3126
- return rpcError(reqId, ERRORS.RATE_LIMITED,
3127
- 'you are sending faster than this door answers slow down and retry');
3128
- }
3129
- if (signedTotalRate && !signedTotalRate.allow()) {
3130
- return rpcError(reqId, ERRORS.RATE_LIMITED,
3131
- 'this entry is at its ceiling right now retry shortly');
3132
- }
3133
-
3134
- noteContact(account);
3135
- // T107: `wba_did` may legitimately differ from `peer_did` (the transport signer vs
3136
- // the message signer) — both facts are honest, and the schema says which is which.
3137
- return respond(backendEnvelope(msg, { verified: true, peerDid: from, ownerDid,
3138
- 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
+ });
3139
3368
  }
3140
3369
 
3141
3370
  /** Hand the envelope to the watcher, and make sure it can cost nothing.
@@ -3373,8 +3602,14 @@ export function createAgentEntry({
3373
3602
  function handleRequest(method, path, headers, bodyBuffer) {
3374
3603
  const out = route(method, path, bodyBuffer, headers);
3375
3604
  if (isThenable(out)) {
3376
- return rpcError(null, ERRORS.INTERNAL_ERROR,
3377
- '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()');
3378
3613
  }
3379
3614
  return out;
3380
3615
  }