@weaveprotocol/cli 0.2.0 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +17 -0
  2. package/dist/weave.mjs +742 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -215,3 +215,20 @@ space anywhere leaves it everywhere.
215
215
 
216
216
  - **WebRTC on the node.** Browsers reach it over WebSocket. `node-datachannel`
217
217
  plugs into the same transport seam if measurement says it is needed.
218
+ - **Publishing.** `weave-protocol-cli` on npm (or a built JS package), and
219
+ trying the Bun binary with `node-datachannel`.
220
+ - **Hosting, before it's offered to anyone.**
221
+ - A load test with 1,000 spaces, with metrics. Check the pricing against those numbers, and decide on TURN from them.
222
+ - A real Stripe test-mode run end to end, and a decision on Stripe Tax or a merchant of record.
223
+ - A breach plan.
224
+ - **The pay page.**
225
+ - A real Base Sepolia wallet payment.
226
+ - A Lightning route (BTCPay), and gasless USDC (EIP-3009).
227
+ - An optional email for reminders: SMTP, with double opt-in.
228
+ - **TURN.** Run coturn with `use-auth-secret` and quotas, and set
229
+ `TURN_SECRET` and `TURN_URLS` on the relay and the node. Then test calls
230
+ across real networks, behind a strict NAT, in Safari, and on a phone
231
+ switching apps.
232
+
233
+ What the protocol still has planned is in [the spec](../docs/spec/README.md),
234
+ under **Planned** in each part.
package/dist/weave.mjs CHANGED
@@ -2499,7 +2499,7 @@ function ecdsa(Point, hash2, ecdsaOpts = {}) {
2499
2499
  const sig = drbg(seed, k2sig);
2500
2500
  return sig.toBytes(opts2.format);
2501
2501
  }
2502
- function verify(signature, message, publicKey, opts2 = {}) {
2502
+ function verify2(signature, message, publicKey, opts2 = {}) {
2503
2503
  const { lowS, prehash, format: format2 } = validateSigOpts(opts2, defaultSigOpts);
2504
2504
  publicKey = abytes2(publicKey, void 0, "publicKey");
2505
2505
  message = validateMsgAndHash(message, prehash);
@@ -2547,7 +2547,7 @@ function ecdsa(Point, hash2, ecdsaOpts = {}) {
2547
2547
  lengths,
2548
2548
  Point,
2549
2549
  sign,
2550
- verify,
2550
+ verify: verify2,
2551
2551
  recoverPublicKey,
2552
2552
  Signature,
2553
2553
  hash: hash_
@@ -3610,6 +3610,8 @@ function withWrap(vault, wrap) {
3610
3610
  var CONTACT_KEY_INFO = "weave/p256-contact-key/v1";
3611
3611
  var SEAL_INFO = "weave/contact-seal/v1";
3612
3612
  var MEMBER_KEY_INFO = "weave/p256-member-key/v1";
3613
+ var DOOR_KEY_INFO = "weave/p256-door-key/v1";
3614
+ var DOOR_SIGN_KEY_INFO = "weave/p256-door-sign-key/v1";
3613
3615
  var P256_SEED_BYTES2 = 48;
3614
3616
  var POINT_BYTES = 65;
3615
3617
  var IV_BYTES2 = 12;
@@ -3628,6 +3630,39 @@ async function deriveContactKeyBytes(seed) {
3628
3630
  async function deriveMemberKeyBytes(accountKey, spaceId) {
3629
3631
  return p256.utils.randomSecretKey(await hkdf2(accountKey, `${MEMBER_KEY_INFO}|${spaceId}`, P256_SEED_BYTES2));
3630
3632
  }
3633
+ async function deriveDoorKeyBytes(contactKey, doorId) {
3634
+ return p256.utils.randomSecretKey(await hkdf2(contactKey, `${DOOR_KEY_INFO}|${doorId}`, P256_SEED_BYTES2));
3635
+ }
3636
+ async function deriveDoorSignKeyBytes(contactKey, doorId) {
3637
+ return p256.utils.randomSecretKey(await hkdf2(contactKey, `${DOOR_SIGN_KEY_INFO}|${doorId}`, P256_SEED_BYTES2));
3638
+ }
3639
+ async function signWithScalar(secret, data) {
3640
+ const point = p256.getPublicKey(secret, false);
3641
+ const key3 = await globalThis.crypto.subtle.importKey(
3642
+ "jwk",
3643
+ {
3644
+ kty: "EC",
3645
+ crv: "P-256",
3646
+ x: base64UrlEncode(point.subarray(1, 33)),
3647
+ y: base64UrlEncode(point.subarray(33, 65)),
3648
+ d: base64UrlEncode(secret),
3649
+ ext: false
3650
+ },
3651
+ { name: "ECDSA", namedCurve: "P-256" },
3652
+ false,
3653
+ ["sign"]
3654
+ );
3655
+ return base64UrlEncode(new Uint8Array(await globalThis.crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key3, data)));
3656
+ }
3657
+ async function verifyWithPoint(publicKey, data, signature) {
3658
+ try {
3659
+ const point = p256.Point.fromBytes(base64UrlDecode(publicKey)).toBytes(false);
3660
+ const key3 = await globalThis.crypto.subtle.importKey("raw", point, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
3661
+ return await globalThis.crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key3, base64UrlDecode(signature), data);
3662
+ } catch {
3663
+ return false;
3664
+ }
3665
+ }
3631
3666
  function contactPublicKey(secret) {
3632
3667
  return base64UrlEncode(p256.getPublicKey(secret, true));
3633
3668
  }
@@ -10392,7 +10427,8 @@ var contact = typed()({
10392
10427
  name: { type: "string", maxLength: 200, description: "What you call them" },
10393
10428
  space: { type: "string", maxLength: 256, description: "The id of your space for two" },
10394
10429
  note: { type: "string", maxLength: 2e3 },
10395
- blocked: { type: "boolean", description: "Their contact requests are hidden, in every space" }
10430
+ blocked: { type: "boolean", description: "Their contact requests are hidden, in every space" },
10431
+ door: { type: "string", maxLength: 64, description: "The door they knocked on, while your answer is still to be written in your space for two" }
10396
10432
  },
10397
10433
  required: ["did", "name"]
10398
10434
  },
@@ -10412,6 +10448,273 @@ var contactRequest = typed()({
10412
10448
  },
10413
10449
  rules: { edit: "creator", delete: "creator" }
10414
10450
  });
10451
+ var door = typed()({
10452
+ name: "std.door",
10453
+ title: "Door",
10454
+ description: "A way for people you share no space with to ask to become your contact.",
10455
+ schema: {
10456
+ type: "object",
10457
+ properties: {
10458
+ id: { type: "string", minLength: 16, maxLength: 64, description: "Random; the door key is derived from it" },
10459
+ label: { type: "string", maxLength: 64, description: "What you call this door \u2014 only you see it" },
10460
+ name: { type: "string", maxLength: 64, description: "The name its code gives, shown to whoever knocks" },
10461
+ relays: { type: "array", items: { type: "string", maxLength: 200 }, minItems: 1, maxItems: 3, description: "Whose mailboxes hold its knocks" }
10462
+ },
10463
+ required: ["id", "relays"]
10464
+ },
10465
+ rules: { onePer: ["id"] }
10466
+ });
10467
+ var knock = typed()({
10468
+ name: "std.knock",
10469
+ title: "Knock",
10470
+ description: "A knock left on a door, waiting for an answer.",
10471
+ schema: {
10472
+ type: "object",
10473
+ properties: {
10474
+ space: { type: "string", maxLength: 256, description: "The space for two it invites them to" },
10475
+ name: { type: "string", maxLength: 64, description: "The name their door code gave" },
10476
+ door: { type: "string", maxLength: 64, description: "The door key knocked on" },
10477
+ sign: { type: "string", maxLength: 64, description: "The door's signing key, which their answer must be signed with" },
10478
+ invite: { type: "string", maxLength: 8e3, description: "The invite sent, to close once they answer" }
10479
+ },
10480
+ required: ["space", "name", "door", "sign", "invite"]
10481
+ },
10482
+ rules: { onePer: ["space"] }
10483
+ });
10484
+ var knockAnswer = typed()({
10485
+ name: "std.knock-answer",
10486
+ title: "Knock answer",
10487
+ description: "Proof that the account which joined is the one behind the door knocked on.",
10488
+ schema: {
10489
+ type: "object",
10490
+ properties: {
10491
+ sig: { type: "string", maxLength: 200, description: 'The door signing key over "weave/knock-answer/v1|<space>|<account>"' }
10492
+ },
10493
+ required: ["sig"]
10494
+ },
10495
+ rules: { edit: "creator", delete: "creator" }
10496
+ });
10497
+
10498
+ // ../src/doors/doors.ts
10499
+ var MAX_DOOR_RELAYS = 3;
10500
+ var KNOCK_TTL_SECONDS = 14 * 24 * 3600;
10501
+ var KNOCK_DROP_WINDOW_SECONDS = 600;
10502
+ var MAX_NAME = 64;
10503
+ var MAX_NOTE = 2e3;
10504
+ var MAX_INVITE = 6e3;
10505
+ var MAX_PROOF = 4096;
10506
+ function clip(text, max) {
10507
+ const chars = Array.from(text);
10508
+ return chars.length <= max ? text : chars.slice(0, max).join("");
10509
+ }
10510
+ function encodeDoorCode(code) {
10511
+ const problem = checkDoorCode({ v: 1, ...code });
10512
+ if (problem) throw new Error(problem);
10513
+ return base64UrlEncode(
10514
+ utf8Encode(canonicalize({ v: 1, key: code.key, sign: code.sign, relays: [...code.relays], ...code.name ? { name: code.name } : {} }))
10515
+ );
10516
+ }
10517
+ function parseDoorCode(text) {
10518
+ const trimmed = text.trim();
10519
+ const found = /(?:^|[#?&])door=([A-Za-z0-9_-]+)/.exec(trimmed);
10520
+ const raw = found ? found[1] : trimmed;
10521
+ let parsed;
10522
+ try {
10523
+ parsed = JSON.parse(utf8Decode(base64UrlDecode(raw)));
10524
+ } catch {
10525
+ throw new Error("That is not a door code \u2014 it may be cut short.");
10526
+ }
10527
+ const problem = checkDoorCode(parsed);
10528
+ if (problem) throw new Error(`That door code doesn't work: ${problem}`);
10529
+ const code = parsed;
10530
+ return Object.freeze({ v: 1, key: code.key, sign: code.sign, relays: Object.freeze([...code.relays]), ...code.name ? { name: code.name } : {} });
10531
+ }
10532
+ function checkDoorCode(value) {
10533
+ const code = value;
10534
+ if (!code || typeof code !== "object" || code.v !== 1) return "it is not a version 1 door";
10535
+ if (!isContactPublicKey(code.key)) return "its key is not a P-256 public key";
10536
+ if (!isContactPublicKey(code.sign) || code.sign === code.key) return "its signing key is not a P-256 public key of its own";
10537
+ if (!Array.isArray(code.relays) || code.relays.length === 0 || code.relays.length > MAX_DOOR_RELAYS) {
10538
+ return `it names 1\u2013${MAX_DOOR_RELAYS} relays`;
10539
+ }
10540
+ const relays = checkRelays(code.relays);
10541
+ if (relays) return relays;
10542
+ if (code.name !== void 0 && (typeof code.name !== "string" || Array.from(code.name).length > MAX_NAME)) return `its name is text of at most ${MAX_NAME} characters`;
10543
+ return null;
10544
+ }
10545
+ async function doorTopic(sign) {
10546
+ return base64UrlEncode(await sha2562(utf8Encode(`weave/door-topic/v1|${sign}`)));
10547
+ }
10548
+ var purgeMessage = (topic, nonce, ids) => utf8Encode(`weave/door-purge/v1|${topic}|${nonce}|${ids ? [...ids].sort().join(",") : "*"}`);
10549
+ function signPurge(signKey, topic, nonce, ids) {
10550
+ return signWithScalar(signKey, purgeMessage(topic, nonce, ids));
10551
+ }
10552
+ var answerMessage = (pairSpace, did) => utf8Encode(`weave/knock-answer/v1|${pairSpace}|${did}`);
10553
+ function signAnswer(signKey, pairSpace, did) {
10554
+ return signWithScalar(signKey, answerMessage(pairSpace, did));
10555
+ }
10556
+ function checkAnswer(sign, pairSpace, did, signature) {
10557
+ return verifyWithPoint(sign, answerMessage(pairSpace, did), signature);
10558
+ }
10559
+ async function knockId(blob) {
10560
+ return base64UrlEncode(await sha2562(utf8Encode(blob)));
10561
+ }
10562
+ var knockContext = (door2) => `weave/knock/v1|${door2}`;
10563
+ async function sealKnock(door2, knock2, session, provider) {
10564
+ const note = knock2.note ? clip(knock2.note.trim(), MAX_NOTE) : void 0;
10565
+ const body = {
10566
+ v: 1,
10567
+ door: door2,
10568
+ from: knock2.from,
10569
+ name: clip(knock2.name.trim(), MAX_NAME) || "Someone",
10570
+ invite: knock2.invite,
10571
+ ...note ? { note } : {},
10572
+ at: Math.floor(Date.now() / 1e3),
10573
+ session: session.did,
10574
+ proof: session.proof
10575
+ };
10576
+ const sig = base64UrlEncode(await provider.sign(session.key, utf8Encode(canonicalize(body))));
10577
+ return sealFor(door2, { body, sig }, knockContext(door2));
10578
+ }
10579
+ async function openKnock(doorKey, blob, receivedAt, provider) {
10580
+ const door2 = contactPublicKey(doorKey);
10581
+ const opened = await openSealed((await contactKeyPair(doorKey)).privateKey, blob, knockContext(door2));
10582
+ const body = opened?.body;
10583
+ if (!body || typeof opened.sig !== "string") return null;
10584
+ if (body.v !== 1 || body.door !== door2) return null;
10585
+ if (typeof body.from !== "string" || typeof body.session !== "string" || typeof body.name !== "string") return null;
10586
+ if (Array.from(body.name).length > MAX_NAME) return null;
10587
+ if (typeof body.invite !== "string" || body.invite.length > MAX_INVITE) return null;
10588
+ if (typeof body.proof !== "string" || body.proof.length > MAX_PROOF) return null;
10589
+ if (body.note !== void 0 && (typeof body.note !== "string" || Array.from(body.note).length > MAX_NOTE)) return null;
10590
+ if (!Number.isSafeInteger(body.at) || !Number.isFinite(receivedAt)) return null;
10591
+ const now = Math.floor(Date.now() / 1e3);
10592
+ if (body.at > now + UCAN_CLOCK_SKEW_SECONDS || body.at < now - KNOCK_TTL_SECONDS) return null;
10593
+ const dropped = Math.floor(receivedAt / 1e3);
10594
+ if (body.at > dropped + UCAN_CLOCK_SKEW_SECONDS || body.at < dropped - KNOCK_DROP_WINDOW_SECONDS) return null;
10595
+ let signed = false;
10596
+ try {
10597
+ const key3 = await provider.importPublicKey(didToPublicKey(body.session).publicKeyBytes);
10598
+ signed = await provider.verify(key3, base64UrlDecode(opened.sig), utf8Encode(canonicalize(body)));
10599
+ } catch {
10600
+ return null;
10601
+ }
10602
+ if (!signed) return null;
10603
+ if (isAgentNote(body.proof)) return null;
10604
+ const chain = await resolveDelegationRoot(body.proof, () => null, provider, { at: body.at }).catch(() => null);
10605
+ if (!chain?.valid || chain.audience !== body.session || chain.rootDid !== body.from) return null;
10606
+ if (!chain.capabilities.some((capability) => capability.with === "*" && (capability.can === "expression/*" || capability.can === "*"))) return null;
10607
+ let invited;
10608
+ try {
10609
+ invited = parseSpaceInvite(body.invite);
10610
+ } catch {
10611
+ return null;
10612
+ }
10613
+ if (invited.space.creator !== body.from || invited.space.visibility !== "private" || !invited.key) return null;
10614
+ if (await checkSpace(invited.space) !== null) return null;
10615
+ return Object.freeze({
10616
+ from: body.from,
10617
+ name: clip(body.name, MAX_NAME) || "Someone",
10618
+ ...body.note ? { note: body.note } : {},
10619
+ invite: body.invite,
10620
+ pairSpace: invited.space.id,
10621
+ at: body.at * 1e3
10622
+ });
10623
+ }
10624
+
10625
+ // ../src/network/mailbox.ts
10626
+ var MAX_PAGES = 8;
10627
+ function createMailboxClient(options = {}) {
10628
+ const timeoutMs = options.timeoutMs ?? 8e3;
10629
+ function ask(relay, message, pick) {
10630
+ return talk(relay, (send2) => send2(message), async (answer) => pick(answer));
10631
+ }
10632
+ function talk(relay, start, hear) {
10633
+ const Socket = options.WebSocket ?? globalThis.WebSocket;
10634
+ if (!Socket) return Promise.reject(new Error("No WebSocket here to reach a relay with"));
10635
+ return new Promise((resolve, reject) => {
10636
+ let settled = false;
10637
+ const ws = new Socket(relay);
10638
+ const finish = (error, value) => {
10639
+ if (settled) return;
10640
+ settled = true;
10641
+ clearTimeout(timer);
10642
+ try {
10643
+ ws.close();
10644
+ } catch {
10645
+ }
10646
+ if (error) reject(error);
10647
+ else resolve(value);
10648
+ };
10649
+ const timer = setTimeout(() => finish(new Error(`${relay} did not answer`)), timeoutMs);
10650
+ const say = (message) => ws.send(JSON.stringify(message));
10651
+ ws.onopen = () => start(say);
10652
+ ws.onerror = () => finish(new Error(`Could not reach ${relay}`));
10653
+ ws.onclose = () => finish(new Error(`${relay} closed the connection`));
10654
+ ws.onmessage = (event) => {
10655
+ let answer;
10656
+ try {
10657
+ answer = JSON.parse(String(event.data));
10658
+ } catch {
10659
+ return;
10660
+ }
10661
+ if (!answer || typeof answer !== "object") return;
10662
+ hear(answer, say).then(
10663
+ (value) => {
10664
+ if (value !== void 0) finish(null, value);
10665
+ },
10666
+ (error) => finish(error instanceof Error ? error : new Error(String(error)))
10667
+ );
10668
+ };
10669
+ });
10670
+ }
10671
+ const client = {
10672
+ drop(relay, topic, blob, ttlSeconds) {
10673
+ return ask(relay, { type: "drop", topic, blob, ...ttlSeconds ? { ttl: ttlSeconds } : {} }, (answer) => {
10674
+ if (answer.topic !== topic) return void 0;
10675
+ if (answer.type === "dropped" && typeof answer.id === "string") return answer.id;
10676
+ if (answer.type === "refused") throw new Error(`${relay} refused the knock: ${String(answer.reason ?? "no reason given")}`);
10677
+ return void 0;
10678
+ });
10679
+ },
10680
+ async fetch(relay, topic, after = 0) {
10681
+ const found = [];
10682
+ let from = after;
10683
+ for (let page = 0; page < MAX_PAGES; page++) {
10684
+ const { items, more } = await ask(
10685
+ relay,
10686
+ { type: "fetch", topic, after: from },
10687
+ (answer) => answer.type === "mail" && answer.topic === topic && Array.isArray(answer.items) ? { items: answer.items.filter(isMailItem), more: answer.more === true } : void 0
10688
+ );
10689
+ found.push(...items);
10690
+ if (!more || items.length === 0) break;
10691
+ from = Math.max(...items.map((item) => item.seq));
10692
+ }
10693
+ return found;
10694
+ },
10695
+ purge(relay, topic, sign, ids, signChallenge) {
10696
+ return talk(
10697
+ relay,
10698
+ (send2) => send2({ type: "challenge" }),
10699
+ async (answer, send2) => {
10700
+ if (answer.type === "challenge" && typeof answer.nonce === "string") {
10701
+ send2({ type: "purge", topic, sign, ...ids ? { ids: [...ids] } : {}, sig: await signChallenge(answer.nonce) });
10702
+ return void 0;
10703
+ }
10704
+ if (answer.topic !== topic) return void 0;
10705
+ if (answer.type === "purged" && typeof answer.count === "number") return answer.count;
10706
+ if (answer.type === "refused") throw new Error(`${relay} refused to clear the door: ${String(answer.reason ?? "no reason given")}`);
10707
+ return void 0;
10708
+ }
10709
+ );
10710
+ }
10711
+ };
10712
+ return Object.freeze(client);
10713
+ }
10714
+ function isMailItem(value) {
10715
+ const item = value;
10716
+ return !!item && Number.isSafeInteger(item.seq) && typeof item.id === "string" && typeof item.at === "number" && typeof item.blob === "string";
10717
+ }
10415
10718
 
10416
10719
  // ../src/space/notify.ts
10417
10720
  var NOTIFY_COLLECTION = "sys.notify";
@@ -11425,11 +11728,13 @@ async function createNode(config) {
11425
11728
  if (!contact2.did.startsWith("did:")) throw new Error("A contact needs their account DID");
11426
11729
  const name = contact2.name.trim().slice(0, 200);
11427
11730
  if (!name) throw new Error("A contact needs a name");
11731
+ const pending = (await contactRecords()).find((record) => record.body.did === contact2.did)?.body?.door;
11428
11732
  return writeContact({
11429
11733
  did: contact2.did,
11430
11734
  name,
11431
11735
  ...contact2.space ? { space: contact2.space } : {},
11432
- ...contact2.note ? { note: contact2.note.slice(0, 2e3) } : {}
11736
+ ...contact2.note ? { note: contact2.note.slice(0, 2e3) } : {},
11737
+ ...pending ? { door: pending } : {}
11433
11738
  });
11434
11739
  },
11435
11740
  async remove(did) {
@@ -11505,6 +11810,253 @@ async function createNode(config) {
11505
11810
  return [...seen].filter((account2) => account2 !== config.signer.did && account2 !== did).sort();
11506
11811
  }
11507
11812
  });
11813
+ const mailbox = config.mailbox ?? createMailboxClient();
11814
+ const knockCache = /* @__PURE__ */ new Map();
11815
+ const DAY_MS = 24 * 3600 * 1e3;
11816
+ function requireContactKey(what) {
11817
+ if (agentSession) throw new Error(`An agent can't use doors. ${what} is the person's to do.`);
11818
+ if (!config.contactKey) throw new Error(`${what} needs your contact key. Connect to your account home again, and allow contacts.`);
11819
+ return config.contactKey;
11820
+ }
11821
+ async function doorKeys(id) {
11822
+ const contactKey = requireContactKey("A door");
11823
+ const key3 = await deriveDoorKeyBytes(contactKey, id);
11824
+ const sign = await deriveDoorSignKeyBytes(contactKey, id);
11825
+ const signPublic = contactPublicKey(sign);
11826
+ return { key: key3, sign, publicKey: contactPublicKey(key3), signPublic, topic: await doorTopic(signPublic) };
11827
+ }
11828
+ async function doorRecords() {
11829
+ if (!contactsSpaceId) return [];
11830
+ return (await (await contactsRuntime()).list({ collection: door.name })).filter(
11831
+ (record) => record.verified && record.root === config.signer.did && typeof record.body?.id === "string" && Array.isArray(record.body.relays)
11832
+ );
11833
+ }
11834
+ async function doorView(record) {
11835
+ const body = record.body;
11836
+ const keys = await doorKeys(body.id);
11837
+ return Object.freeze({
11838
+ id: body.id,
11839
+ ...body.label ? { label: body.label } : {},
11840
+ ...body.name ? { name: body.name } : {},
11841
+ key: keys.publicKey,
11842
+ sign: keys.signPublic,
11843
+ relays: Object.freeze([...body.relays]),
11844
+ code: encodeDoorCode({ key: keys.publicKey, sign: keys.signPublic, relays: body.relays, ...body.name ? { name: body.name } : {} }),
11845
+ createdAt: record.createdAt
11846
+ });
11847
+ }
11848
+ async function purgeDoor(door2, ids) {
11849
+ const keys = await doorKeys(door2.id);
11850
+ const done = await Promise.allSettled(
11851
+ door2.relays.map((relay) => mailbox.purge(relay, keys.topic, keys.signPublic, ids, (nonce) => signPurge(keys.sign, keys.topic, nonce, ids)))
11852
+ );
11853
+ return done.filter((result) => result.status === "fulfilled").length;
11854
+ }
11855
+ async function sentRecords() {
11856
+ if (!contactsSpaceId) return [];
11857
+ return (await (await contactsRuntime()).list({ collection: knock.name })).filter(
11858
+ (record) => record.verified && record.root === config.signer.did && typeof record.body?.space === "string" && typeof record.body.sign === "string" && typeof record.body.invite === "string"
11859
+ );
11860
+ }
11861
+ async function settleSent() {
11862
+ for (const record of await sentRecords()) {
11863
+ const { space: space2, name, sign, invite } = record.body;
11864
+ if (!await registry.get(space2)) {
11865
+ await (await contactsRuntime()).remove(record.key);
11866
+ continue;
11867
+ }
11868
+ const open2 = await runtime(space2);
11869
+ const { members } = await open2.access();
11870
+ let answered = null;
11871
+ for (const answer of await open2.list({ collection: knockAnswer.name })) {
11872
+ const did = answer.root;
11873
+ if (!answer.verified || !did || did === config.signer.did || answer.createdBy !== did || typeof answer.body?.sig !== "string") continue;
11874
+ if (!members.some((member) => member.did === did)) continue;
11875
+ if (await checkAnswer(sign, space2, did, answer.body.sig)) {
11876
+ answered = did;
11877
+ break;
11878
+ }
11879
+ }
11880
+ if (answered) {
11881
+ await spaces.closeInvite(space2, invite).catch(() => {
11882
+ });
11883
+ const given = (await open2.profiles()).find((profile) => profile.did === answered)?.name;
11884
+ await writeContact({ did: answered, name: given ?? name, space: space2 });
11885
+ await (await contactsRuntime()).remove(record.key);
11886
+ } else if (Date.now() - Date.parse(record.createdAt) > KNOCK_TTL_SECONDS * 1e3 + DAY_MS) {
11887
+ await spaces.leave(space2).catch(() => {
11888
+ });
11889
+ await (await contactsRuntime()).remove(record.key);
11890
+ }
11891
+ }
11892
+ }
11893
+ async function answerAccepted() {
11894
+ for (const record of await contactRecords()) {
11895
+ const { did, space: space2, door: door2 } = record.body;
11896
+ if (typeof door2 !== "string" || typeof space2 !== "string") continue;
11897
+ if (!await registry.get(space2)) continue;
11898
+ const open2 = await runtime(space2);
11899
+ if ((await open2.access()).role === null) continue;
11900
+ const { sign } = await doorKeys(door2);
11901
+ try {
11902
+ await open2.put(knockAnswer.name, { sig: await signAnswer(sign, space2, config.signer.did) });
11903
+ } catch {
11904
+ continue;
11905
+ }
11906
+ const { door: _answered, ...rest } = record.body;
11907
+ await writeContact({ ...rest, did });
11908
+ }
11909
+ }
11910
+ async function readDoors() {
11911
+ const now = Date.now();
11912
+ for (const [id, cached] of knockCache) if (cached.until <= now) knockCache.delete(id);
11913
+ const found = [];
11914
+ for (const record of await doorRecords()) {
11915
+ const { id: doorId, relays } = record.body;
11916
+ const keys = await doorKeys(doorId);
11917
+ const held = /* @__PURE__ */ new Map();
11918
+ for (const result of await Promise.allSettled(relays.map((relay) => mailbox.fetch(relay, keys.topic)))) {
11919
+ if (result.status !== "fulfilled") continue;
11920
+ for (const item of result.value) {
11921
+ const id = await knockId(item.blob);
11922
+ const seen = held.get(id);
11923
+ if (!seen || item.at < seen.at) held.set(id, { blob: item.blob, at: item.at });
11924
+ }
11925
+ }
11926
+ for (const [id, item] of held) {
11927
+ let cached = knockCache.get(id);
11928
+ if (!cached) {
11929
+ cached = { opened: await openKnock(keys.key, item.blob, item.at, provider), door: doorId, until: item.at + KNOCK_TTL_SECONDS * 1e3 };
11930
+ knockCache.set(id, cached);
11931
+ }
11932
+ if (cached.opened) found.push({ id, door: doorId, opened: cached.opened });
11933
+ }
11934
+ }
11935
+ return found;
11936
+ }
11937
+ const doors = Object.freeze({
11938
+ async list() {
11939
+ if (!config.contactKey || agentSession) return [];
11940
+ return Promise.all((await doorRecords()).map(doorView));
11941
+ },
11942
+ async open(options = {}) {
11943
+ requireContactKey("Opening a door");
11944
+ if (!contactsSpaceId) await contactsRuntime();
11945
+ const relays = [...options.relays ?? (config.network?.relays ?? []).filter((url) => checkRelays([url]) === null)].slice(0, MAX_DOOR_RELAYS);
11946
+ if (relays.length === 0) throw new Error("A door needs a relay to hold its knocks, and this node has none.");
11947
+ const name = clip((options.name ?? await ownName() ?? "").trim(), 64);
11948
+ const label = options.label ? clip(options.label.trim(), 64) : "";
11949
+ const id = base64UrlEncode(globalThis.crypto.getRandomValues(new Uint8Array(16)));
11950
+ const keys = await doorKeys(id);
11951
+ encodeDoorCode({ key: keys.publicKey, sign: keys.signPublic, relays, ...name ? { name } : {} });
11952
+ const open2 = await contactsRuntime();
11953
+ await ensureDefined(open2, door);
11954
+ return doorView(await open2.put(door.name, { id, relays, ...name ? { name } : {}, ...label ? { label } : {} }));
11955
+ },
11956
+ async close(id) {
11957
+ const found = (await doorRecords()).find((record) => record.body.id === id);
11958
+ if (found) await (await contactsRuntime()).remove(found.key);
11959
+ for (const [knock2, cached] of knockCache) if (cached.door === id) knockCache.delete(knock2);
11960
+ },
11961
+ async clear(id) {
11962
+ requireContactKey("Clearing a door");
11963
+ const found = (await doorRecords()).find((record) => record.body.id === id);
11964
+ if (!found) throw new Error("There is no such door");
11965
+ if (await purgeDoor(found.body, null) === 0) throw new Error("None of the door's relays could be reached to clear it.");
11966
+ for (const [knock2, cached] of knockCache) if (cached.door === id) knockCache.delete(knock2);
11967
+ },
11968
+ async knock(code, options = {}) {
11969
+ requireContactKey("Knocking on a door");
11970
+ requireEverywhere("Knocking on a door");
11971
+ const door2 = parseDoorCode(code);
11972
+ for (const mine2 of await doors.list()) if (mine2.key === door2.key) throw new Error("That is one of your own doors");
11973
+ if (!contactsSpaceId) await contactsRuntime();
11974
+ const mine = await ownName() ?? "Someone";
11975
+ const theirs = door2.name ?? "Contact";
11976
+ const pair = await spaces.create({ name: `${clip(mine, 64)} & ${theirs}`, visibility: "private", ...team });
11977
+ let invite;
11978
+ try {
11979
+ await ensureDefined(await runtime(pair.id), knockAnswer);
11980
+ invite = await spaces.invite(pair.id, { role: "editor" });
11981
+ const blob = await sealKnock(
11982
+ door2.key,
11983
+ { from: config.signer.did, name: mine, invite, ...options.note ? { note: options.note } : {} },
11984
+ { did: session.did, key: session.key, proof: session.proof() },
11985
+ provider
11986
+ );
11987
+ const topic = await doorTopic(door2.sign);
11988
+ const left = await Promise.allSettled(door2.relays.map((relay) => mailbox.drop(relay, topic, blob, KNOCK_TTL_SECONDS)));
11989
+ if (!left.some((result) => result.status === "fulfilled")) {
11990
+ const reasons = left.map((result) => result.status === "rejected" ? String(result.reason?.message ?? result.reason) : "").filter(Boolean);
11991
+ throw new Error(`None of their door's relays took the knock. ${reasons.join("; ")}`);
11992
+ }
11993
+ } catch (error) {
11994
+ await spaces.leave(pair.id).catch(() => {
11995
+ });
11996
+ throw error;
11997
+ }
11998
+ const open2 = await contactsRuntime();
11999
+ await ensureDefined(open2, knock);
12000
+ await open2.put(knock.name, { space: pair.id, name: theirs, door: door2.key, sign: door2.sign, invite });
12001
+ return { space: pair.id };
12002
+ },
12003
+ async knocks() {
12004
+ requireContactKey("Reading knocks");
12005
+ await settleSent().catch(() => {
12006
+ });
12007
+ await answerAccepted().catch(() => {
12008
+ });
12009
+ const blocked = new Set((await contactRecords()).filter((record) => record.body.blocked === true).map((record) => record.body.did));
12010
+ const found = [];
12011
+ for (const { id, door: door2, opened } of await readDoors()) {
12012
+ if (opened.from === config.signer.did || blocked.has(opened.from)) continue;
12013
+ if (await registry.get(opened.pairSpace)) continue;
12014
+ found.push(
12015
+ Object.freeze({
12016
+ id,
12017
+ door: door2,
12018
+ from: opened.from,
12019
+ name: opened.name,
12020
+ ...opened.note ? { note: opened.note } : {},
12021
+ pairSpace: opened.pairSpace,
12022
+ at: new Date(opened.at).toISOString()
12023
+ })
12024
+ );
12025
+ }
12026
+ return found.sort((a, b) => b.at.localeCompare(a.at));
12027
+ },
12028
+ async sent() {
12029
+ await settleSent().catch(() => {
12030
+ });
12031
+ return (await sentRecords()).map((record) => Object.freeze({ space: record.body.space, name: record.body.name, at: record.createdAt }));
12032
+ },
12033
+ async accept(id) {
12034
+ requireContactKey("Opening the door to someone");
12035
+ requireEverywhere("Opening the door to someone");
12036
+ if (!knockCache.get(id)?.opened) await readDoors();
12037
+ const cached = knockCache.get(id);
12038
+ const knocked = cached?.opened;
12039
+ if (!cached || !knocked) throw new Error("That knock is gone: it expired, or the door was closed or cleared.");
12040
+ await spaces.join(knocked.invite);
12041
+ const known = await contacts.get(knocked.from);
12042
+ const contact2 = await writeContact({ did: knocked.from, name: known?.name ?? knocked.name, space: knocked.pairSpace, door: cached.door });
12043
+ await answerAccepted().catch(() => {
12044
+ });
12045
+ const door2 = (await doorRecords()).find((record) => record.body.id === cached.door);
12046
+ if (door2) await purgeDoor(door2.body, [id]).catch(() => 0);
12047
+ knockCache.delete(id);
12048
+ return contact2;
12049
+ },
12050
+ async dismiss(id) {
12051
+ requireContactKey("Dismissing a knock");
12052
+ if (!knockCache.has(id)) await readDoors();
12053
+ const cached = knockCache.get(id);
12054
+ if (!cached) return;
12055
+ const door2 = (await doorRecords()).find((record) => record.body.id === cached.door);
12056
+ if (door2) await purgeDoor(door2.body, [id]);
12057
+ knockCache.delete(id);
12058
+ }
12059
+ });
11508
12060
  const collections = Object.freeze({
11509
12061
  async list(spaceId) {
11510
12062
  return (await runtime(spaceId)).collections();
@@ -11721,6 +12273,18 @@ async function createNode(config) {
11721
12273
  accept: person("accept contact requests"),
11722
12274
  others: person("look inside a contact's space")
11723
12275
  }),
12276
+ // Doors are the person's: an agent has none and knocks on none.
12277
+ doors: Object.freeze({
12278
+ list: async () => [],
12279
+ open: person("open a door"),
12280
+ close: person("close a door"),
12281
+ knock: person("knock on anyone's door"),
12282
+ clear: person("clear a door"),
12283
+ knocks: person("read knocks"),
12284
+ sent: person("look at knocks"),
12285
+ accept: person("open the door to anyone"),
12286
+ dismiss: person("dismiss a knock")
12287
+ }),
11724
12288
  delegation: () => note,
11725
12289
  iceServers: node.iceServers,
11726
12290
  delegate: person("pass its access on"),
@@ -11744,6 +12308,7 @@ async function createNode(config) {
11744
12308
  hosting,
11745
12309
  notifications,
11746
12310
  contacts,
12311
+ doors,
11747
12312
  asAgent,
11748
12313
  delegation: () => current,
11749
12314
  iceServers: async () => mesh ? mesh.iceServers() : config.network?.iceServers ?? DEFAULT_ICE_SERVERS,
@@ -13390,7 +13955,7 @@ import { createServer } from "node:http";
13390
13955
  import { WebSocketServer } from "ws";
13391
13956
 
13392
13957
  // ../server/relay.mjs
13393
- import { createHmac, randomBytes as randomBytes4 } from "node:crypto";
13958
+ import { createHash, createHmac, createPublicKey, randomBytes as randomBytes4, verify } from "node:crypto";
13394
13959
  var MAX_MESSAGE_BYTES = 64 * 1024;
13395
13960
  var MAX_CONNECTIONS = 5e3;
13396
13961
  var MAX_CONNECTIONS_PER_IP = 32;
@@ -13404,6 +13969,32 @@ var RATE_BURST = 100;
13404
13969
  var HEARTBEAT_MS = 15e3;
13405
13970
  var MAX_BUFFERED_BYTES = 1024 * 1024;
13406
13971
  var MAX_TURN_ADDRESSES = 2e4;
13972
+ var MAX_BLOB_LENGTH = 12e3;
13973
+ var TOPIC_PATTERN = /^[A-Za-z0-9_-]{43}$/;
13974
+ var MAX_MAIL_PER_TOPIC = 64;
13975
+ var RESERVED_PER_TOPIC = 2;
13976
+ var DROP_WINDOW_MS = 36e5;
13977
+ var MAILBOX_LIMITS = Object.freeze({
13978
+ /** Topics held at once */
13979
+ maxTopics: 5e4,
13980
+ /** Everything held at once, in blob characters */
13981
+ maxChars: 64 * 1024 * 1024,
13982
+ /**
13983
+ * Past `maxChars`, a reserve only topics holding fewer than two knocks may
13984
+ * use: a mailbox someone filled still takes a knock or two on every door.
13985
+ */
13986
+ reserveChars: 8 * 1024 * 1024,
13987
+ /** How long a knock is kept. A client may ask for less, never more. */
13988
+ ttlSeconds: 14 * 24 * 3600,
13989
+ /** Drops one address may make into one topic per hour: filling a door takes many addresses */
13990
+ dropsPerNetworkPerTopic: 4,
13991
+ /** Drops one address may make in all, per hour: filling the mailbox takes very many */
13992
+ dropsPerNetwork: 30
13993
+ });
13994
+ var PURGE_SPKI_PREFIX = Buffer.from("3039301306072a8648ce3d020106082a8648ce3d030107032200", "hex");
13995
+ var MAX_WATCHES_PER_SOCKET = 32;
13996
+ var MAX_PAGE_ITEMS = 16;
13997
+ var MAX_PAGE_CHARS = 48 * 1024;
13407
13998
  var ROUTED = /* @__PURE__ */ new Set(["offer", "answer", "candidate"]);
13408
13999
  var DID_PATTERN = /^did:key:z[1-9A-HJ-NP-Za-km-z]+$/;
13409
14000
  var CLOSE_DID_TAKEN = 4009;
@@ -13415,19 +14006,25 @@ function turnFromEnv(env) {
13415
14006
  }
13416
14007
  function createRelay(options = {}) {
13417
14008
  const { turn } = options;
14009
+ const limits = { ...MAILBOX_LIMITS, ...options.mailbox };
13418
14010
  const log = options.log ?? (() => {
13419
14011
  });
13420
14012
  const rooms = /* @__PURE__ */ new Map();
13421
14013
  const clients = /* @__PURE__ */ new Set();
13422
14014
  const perIp = /* @__PURE__ */ new Map();
13423
14015
  const turnByNetwork = /* @__PURE__ */ new Map();
14016
+ const mailbox = /* @__PURE__ */ new Map();
14017
+ const watchers = /* @__PURE__ */ new Map();
14018
+ const dropsBy = /* @__PURE__ */ new Map();
14019
+ let mailChars = 0;
14020
+ let mailSeq = 0;
13424
14021
  function canEnter(room) {
13425
14022
  if (typeof room !== "string" || room.length === 0 || room.length > MAX_ROOM_LENGTH) return false;
13426
14023
  const peers = rooms.get(room);
13427
14024
  return peers ? peers.size < MAX_PEERS_PER_ROOM : rooms.size < MAX_ROOMS;
13428
14025
  }
13429
14026
  function accept(ws, legacyRoom, ip) {
13430
- const client = { ws, legacyRoom, ip, did: null, rooms: /* @__PURE__ */ new Set(), alive: true, tokens: RATE_BURST, refilled: Date.now() };
14027
+ const client = { ws, legacyRoom, ip, did: null, rooms: /* @__PURE__ */ new Set(), watching: /* @__PURE__ */ new Set(), nonce: null, alive: true, tokens: RATE_BURST, refilled: Date.now() };
13431
14028
  clients.add(client);
13432
14029
  perIp.set(ip, (perIp.get(ip) ?? 0) + 1);
13433
14030
  ws.on("pong", () => client.alive = true);
@@ -13447,6 +14044,7 @@ function createRelay(options = {}) {
13447
14044
  if (left > 0) perIp.set(ip, left);
13448
14045
  else perIp.delete(ip);
13449
14046
  for (const room of [...client.rooms]) leave(client, room);
14047
+ for (const topic of [...client.watching]) unwatch(client, topic);
13450
14048
  };
13451
14049
  ws.on("close", drop);
13452
14050
  ws.on("error", () => ws.terminate());
@@ -13477,6 +14075,10 @@ function createRelay(options = {}) {
13477
14075
  log(`peer joined room ${short(room)} (${rooms.get(room).size} present)`);
13478
14076
  return;
13479
14077
  }
14078
+ if (message.type === "drop" || message.type === "fetch" || message.type === "unwatch" || message.type === "challenge" || message.type === "purge") {
14079
+ handleMail(client, message);
14080
+ return;
14081
+ }
13480
14082
  if (message.type === "ice") {
13481
14083
  if (client.did) offerTurn(client);
13482
14084
  return;
@@ -13494,6 +14096,137 @@ function createRelay(options = {}) {
13494
14096
  }
13495
14097
  }
13496
14098
  }
14099
+ function handleMail(client, message) {
14100
+ if (message.type === "challenge") {
14101
+ client.nonce = randomBytes4(18).toString("base64url");
14102
+ send2(client, JSON.stringify({ type: "challenge", nonce: client.nonce }));
14103
+ return;
14104
+ }
14105
+ const { topic } = message;
14106
+ if (typeof topic !== "string" || !TOPIC_PATTERN.test(topic)) return;
14107
+ if (message.type === "unwatch") {
14108
+ unwatch(client, topic);
14109
+ return;
14110
+ }
14111
+ if (message.type === "purge") {
14112
+ purge(client, message);
14113
+ return;
14114
+ }
14115
+ if (message.type === "fetch") {
14116
+ const after = Number.isSafeInteger(message.after) ? message.after : 0;
14117
+ if (message.watch === true && !client.watching.has(topic) && client.watching.size < MAX_WATCHES_PER_SOCKET) {
14118
+ client.watching.add(topic);
14119
+ if (!watchers.has(topic)) watchers.set(topic, /* @__PURE__ */ new Set());
14120
+ watchers.get(topic).add(client);
14121
+ }
14122
+ const now2 = Date.now();
14123
+ const items = [];
14124
+ let chars = 0;
14125
+ let more = false;
14126
+ for (const item2 of mailbox.get(topic) ?? []) {
14127
+ if (item2.seq <= after || item2.expires <= now2) continue;
14128
+ if (items.length >= MAX_PAGE_ITEMS || chars + item2.blob.length > MAX_PAGE_CHARS) {
14129
+ more = true;
14130
+ break;
14131
+ }
14132
+ items.push(mailItem(item2));
14133
+ chars += item2.blob.length;
14134
+ }
14135
+ send2(client, JSON.stringify({ type: "mail", topic, items, more }));
14136
+ return;
14137
+ }
14138
+ const refuse = (reason) => send2(client, JSON.stringify({ type: "refused", topic, reason }));
14139
+ const { blob } = message;
14140
+ if (typeof blob !== "string" || blob.length === 0 || blob.length > MAX_BLOB_LENGTH || !/^[A-Za-z0-9_-]+$/.test(blob)) {
14141
+ refuse("A knock is base64url, at most 12000 characters");
14142
+ return;
14143
+ }
14144
+ const id = createHash("sha256").update(blob).digest("base64url");
14145
+ const held = mailbox.get(topic) ?? [];
14146
+ if (held.some((item2) => item2.id === id)) {
14147
+ send2(client, JSON.stringify({ type: "dropped", topic, id }));
14148
+ return;
14149
+ }
14150
+ const now = Date.now();
14151
+ const network = networkOf(client.ip);
14152
+ const here = countOf(`${network}|${topic}`, now);
14153
+ const everywhere = countOf(network, now);
14154
+ if (here >= limits.dropsPerNetworkPerTopic) return refuse("Too many knocks on this door from here; try later");
14155
+ if (everywhere >= limits.dropsPerNetwork) return refuse("Too many knocks from here; try later");
14156
+ if (held.length >= MAX_MAIL_PER_TOPIC) return refuse("This door is full");
14157
+ if (!mailbox.has(topic) && mailbox.size >= limits.maxTopics) return refuse("The mailbox is full");
14158
+ const room = held.length < RESERVED_PER_TOPIC ? limits.maxChars + limits.reserveChars : limits.maxChars;
14159
+ if (mailChars + blob.length > room) return refuse("The mailbox is full");
14160
+ const ttl = Number.isFinite(message.ttl) && message.ttl > 0 ? Math.min(message.ttl, limits.ttlSeconds) : limits.ttlSeconds;
14161
+ const item = { seq: ++mailSeq, id, at: now, expires: now + ttl * 1e3, blob };
14162
+ held.push(item);
14163
+ mailbox.set(topic, held);
14164
+ mailChars += blob.length;
14165
+ counted(`${network}|${topic}`, now);
14166
+ counted(network, now);
14167
+ send2(client, JSON.stringify({ type: "dropped", topic, id }));
14168
+ const news = JSON.stringify({ type: "mail", topic, items: [mailItem(item)], more: false });
14169
+ for (const watcher of watchers.get(topic) ?? []) {
14170
+ if (watcher !== client) send2(watcher, news);
14171
+ }
14172
+ }
14173
+ const mailItem = ({ seq, id, at, blob }) => ({ seq, id, at, blob });
14174
+ function countOf(key3, now) {
14175
+ const recent = dropsBy.get(key3);
14176
+ return recent && now - recent.since < DROP_WINDOW_MS ? recent.count : 0;
14177
+ }
14178
+ function counted(key3, now) {
14179
+ const recent = dropsBy.get(key3);
14180
+ if (recent && now - recent.since < DROP_WINDOW_MS) recent.count += 1;
14181
+ else dropsBy.set(key3, { count: 1, since: now });
14182
+ }
14183
+ function purge(client, message) {
14184
+ const { topic, sign, sig } = message;
14185
+ const refuse = (reason) => send2(client, JSON.stringify({ type: "refused", topic, reason }));
14186
+ const nonce = client.nonce;
14187
+ client.nonce = null;
14188
+ const ids = message.ids === void 0 ? null : message.ids;
14189
+ if (!nonce) return refuse("Ask for a challenge first");
14190
+ if (typeof sign !== "string" || !/^[A-Za-z0-9_-]{44}$/.test(sign) || typeof sig !== "string" || sig.length > 200) return refuse("Not a purge");
14191
+ if (ids !== null && (!Array.isArray(ids) || ids.length > MAX_MAIL_PER_TOPIC || !ids.every((id) => typeof id === "string" && id.length <= 64))) {
14192
+ return refuse("Not a purge");
14193
+ }
14194
+ if (createHash("sha256").update(`weave/door-topic/v1|${sign}`).digest("base64url") !== topic) return refuse("That key is not this door\u2019s");
14195
+ let good = false;
14196
+ try {
14197
+ const key3 = createPublicKey({ key: Buffer.concat([PURGE_SPKI_PREFIX, Buffer.from(sign, "base64url")]), format: "der", type: "spki" });
14198
+ const signed = `weave/door-purge/v1|${topic}|${nonce}|${ids ? [...ids].sort().join(",") : "*"}`;
14199
+ good = verify("sha256", Buffer.from(signed), { key: key3, dsaEncoding: "ieee-p1363" }, Buffer.from(sig, "base64url"));
14200
+ } catch {
14201
+ good = false;
14202
+ }
14203
+ if (!good) return refuse("The signature does not check out");
14204
+ const held = mailbox.get(topic) ?? [];
14205
+ const gone = ids === null ? held : held.filter((item) => ids.includes(item.id));
14206
+ for (const item of gone) mailChars -= item.blob.length;
14207
+ const kept = ids === null ? [] : held.filter((item) => !ids.includes(item.id));
14208
+ if (kept.length === 0) mailbox.delete(topic);
14209
+ else mailbox.set(topic, kept);
14210
+ send2(client, JSON.stringify({ type: "purged", topic, count: gone.length }));
14211
+ }
14212
+ function unwatch(client, topic) {
14213
+ client.watching.delete(topic);
14214
+ const watching = watchers.get(topic);
14215
+ watching?.delete(client);
14216
+ if (watching?.size === 0) watchers.delete(topic);
14217
+ }
14218
+ function sweepMail() {
14219
+ const now = Date.now();
14220
+ for (const [topic, held] of mailbox) {
14221
+ const kept = held.filter((item) => item.expires > now);
14222
+ for (const item of held) if (item.expires <= now) mailChars -= item.blob.length;
14223
+ if (kept.length === 0) mailbox.delete(topic);
14224
+ else if (kept.length !== held.length) mailbox.set(topic, kept);
14225
+ }
14226
+ for (const [key3, recent] of dropsBy) {
14227
+ if (now - recent.since >= DROP_WINDOW_MS) dropsBy.delete(key3);
14228
+ }
14229
+ }
13497
14230
  function offerTurn(client) {
13498
14231
  if (!turn) return;
13499
14232
  const network = networkOf(client.ip);
@@ -13551,6 +14284,7 @@ function createRelay(options = {}) {
13551
14284
  for (const [network, held] of turnByNetwork) {
13552
14285
  if (held.expires <= now) turnByNetwork.delete(network);
13553
14286
  }
14287
+ sweepMail();
13554
14288
  for (const client of clients) {
13555
14289
  if (!client.alive) {
13556
14290
  client.ws.terminate();
@@ -14831,7 +15565,7 @@ var ASK = "agent-link:ask";
14831
15565
  var HEARD = "agent-link:heard";
14832
15566
  var ANSWER = "agent-link:answer";
14833
15567
  var DONE = "agent-link:done";
14834
- var MAX_NAME = 80;
15568
+ var MAX_NAME2 = 80;
14835
15569
  function readAgentCode(input) {
14836
15570
  const match = /wv_([A-Za-z0-9_-]{22})(?![A-Za-z0-9_-])/.exec(input);
14837
15571
  const secret = match ? base64UrlDecode(match[1]) : null;
@@ -14898,7 +15632,7 @@ async function acceptAgentLink(params) {
14898
15632
  done();
14899
15633
  };
14900
15634
  net.on("peer-connected", (peer) => {
14901
- void seal2({ did: params.did, name: params.name.slice(0, MAX_NAME) }, key3).then((payload) => {
15635
+ void seal2({ did: params.did, name: params.name.slice(0, MAX_NAME2) }, key3).then((payload) => {
14902
15636
  net.send(peer.did, { type: ASK, from: params.did, payload });
14903
15637
  });
14904
15638
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weaveprotocol/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "The Weave protocol from a terminal: manage spaces, run an always-on node, serve MCP to agents",
5
5
  "type": "module",
6
6
  "bin": {