@ghostlytools/cli 1.2.4 → 1.3.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 (100) hide show
  1. package/README.md +25 -9
  2. package/dist/assets/api-Dq2QXQDk.js +4172 -0
  3. package/dist/assets/api-Dq2QXQDk.js.map +1 -0
  4. package/dist/assets/api-TiB5QSG9.js +3 -0
  5. package/dist/assets/apiKit-CA2x5GJF.js +200 -0
  6. package/dist/assets/apiKit-CA2x5GJF.js.map +1 -0
  7. package/dist/assets/apps-BxV9i_Y4.js +786 -0
  8. package/dist/assets/apps-BxV9i_Y4.js.map +1 -0
  9. package/dist/assets/apps-C-T8jeji.js +2 -0
  10. package/dist/assets/{arkade-CdnKGDbg.js → arkade-C_qDmHnm.js} +2 -2
  11. package/dist/assets/{arkade-CdnKGDbg.js.map → arkade-C_qDmHnm.js.map} +1 -1
  12. package/dist/assets/backup-Bugnn3Ue.js +2 -0
  13. package/dist/assets/backup-CnMN2oUK.js +731 -0
  14. package/dist/assets/backup-CnMN2oUK.js.map +1 -0
  15. package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
  16. package/dist/assets/bitcoind-CZchoh43.js +349 -0
  17. package/dist/assets/bitcoind-CZchoh43.js.map +1 -0
  18. package/dist/assets/bitcoindProvider-DGw3xBSa.js +97 -0
  19. package/dist/assets/bitcoindProvider-DGw3xBSa.js.map +1 -0
  20. package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
  21. package/dist/assets/buttons-Bj7Vf7Pn.js +2 -0
  22. package/dist/assets/buttons-CvNBzh2S.js +496 -0
  23. package/dist/assets/buttons-CvNBzh2S.js.map +1 -0
  24. package/dist/assets/{endpoint-pgFkR2AS.js → endpoint-BOTFMpfS.js} +56 -18
  25. package/dist/assets/endpoint-BOTFMpfS.js.map +1 -0
  26. package/dist/assets/envelope-WjingYSc.js +90 -0
  27. package/dist/assets/envelope-WjingYSc.js.map +1 -0
  28. package/dist/assets/errors-DO5z6h_l.js +105 -0
  29. package/dist/assets/errors-DO5z6h_l.js.map +1 -0
  30. package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -1
  31. package/dist/assets/{fileBytes-Dbifu5I5.js → fileBytes-CfSXjTFa.js} +2 -2
  32. package/dist/assets/{fileBytes-Dbifu5I5.js.map → fileBytes-CfSXjTFa.js.map} +1 -1
  33. package/dist/assets/fileBytes-CgbibpbV.js +2 -0
  34. package/dist/assets/{fileBytes-BcYZhNgF.js → fileBytes-Di7VZtmU.js} +6 -19
  35. package/dist/assets/fileBytes-Di7VZtmU.js.map +1 -0
  36. package/dist/assets/fileBytesIdb-CFP-DSkZ.js +2 -0
  37. package/dist/assets/{fileBytesIdb-DzSaqI0A.js → fileBytesIdb-D5u6H8X_.js} +4 -4
  38. package/dist/assets/{fileBytesIdb-DzSaqI0A.js.map → fileBytesIdb-D5u6H8X_.js.map} +1 -1
  39. package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js → fileBytesOpfs-Dd1N4ERH.js} +3 -3
  40. package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js.map → fileBytesOpfs-Dd1N4ERH.js.map} +1 -1
  41. package/dist/assets/groupFiles-IEUW3DQz.js +3202 -0
  42. package/dist/assets/groupFiles-IEUW3DQz.js.map +1 -0
  43. package/dist/assets/host-CWt-jtqz.js +4673 -0
  44. package/dist/assets/host-CWt-jtqz.js.map +1 -0
  45. package/dist/assets/hyperdhtRelay-BT_wRxab.js +21 -0
  46. package/dist/assets/hyperdhtRelay-BT_wRxab.js.map +1 -0
  47. package/dist/assets/{hyperdhtRelay-C1p0nkle.js → hyperdhtRelay-CPYwSLLS.js} +25 -10
  48. package/dist/assets/hyperdhtRelay-CPYwSLLS.js.map +1 -0
  49. package/dist/assets/{idb-CTF6LF5B.js → idb-B5L8wuOm.js} +7 -5
  50. package/dist/assets/idb-B5L8wuOm.js.map +1 -0
  51. package/dist/assets/irohWeb-4oChIOIp.js +544 -0
  52. package/dist/assets/irohWeb-4oChIOIp.js.map +1 -0
  53. package/dist/assets/{joinTrace-CO8mlhNO.js → joinTrace-CjRDFy85.js} +2 -7
  54. package/dist/assets/{joinTrace-CO8mlhNO.js.map → joinTrace-CjRDFy85.js.map} +1 -1
  55. package/dist/assets/joinTrace-D-fnt2p3.js +2 -0
  56. package/dist/assets/main-DbqcdShN.js +4409 -0
  57. package/dist/assets/main-DbqcdShN.js.map +1 -0
  58. package/dist/assets/{mainline-BH6s8HVi.js → mainline-ZkiOnevs.js} +11 -6
  59. package/dist/assets/mainline-ZkiOnevs.js.map +1 -0
  60. package/dist/assets/mints-BrlY773j.js +74 -0
  61. package/dist/assets/mints-BrlY773j.js.map +1 -0
  62. package/dist/assets/mints-CqTdA4Gk.js +2 -0
  63. package/dist/assets/networkReason-CetKkTPo.js +154 -0
  64. package/dist/assets/networkReason-CetKkTPo.js.map +1 -0
  65. package/dist/assets/nwc-CgkqyvsE.js +612 -0
  66. package/dist/assets/nwc-CgkqyvsE.js.map +1 -0
  67. package/dist/assets/nwcProvider-4eVz_8HP.js +77 -0
  68. package/dist/assets/nwcProvider-4eVz_8HP.js.map +1 -0
  69. package/dist/assets/privateFolder-Bk9C9oFv.js +38 -0
  70. package/dist/assets/privateFolder-Bk9C9oFv.js.map +1 -0
  71. package/dist/assets/profiles-BHc9pEYg.js +139 -0
  72. package/dist/assets/profiles-BHc9pEYg.js.map +1 -0
  73. package/dist/assets/registry-DlAKO0Fy.js +3793 -0
  74. package/dist/assets/registry-DlAKO0Fy.js.map +1 -0
  75. package/dist/assets/registry-OBJBnHVQ.js +2 -0
  76. package/dist/assets/resolve-CouHhCpJ.js +591 -0
  77. package/dist/assets/resolve-CouHhCpJ.js.map +1 -0
  78. package/dist/assets/{restoredRows-C_84q0Pd.js → restoredRows-Db3oUTko.js} +2 -2
  79. package/dist/assets/{restoredRows-C_84q0Pd.js.map → restoredRows-Db3oUTko.js.map} +1 -1
  80. package/dist/assets/{server-0zfNpABQ.js → server-Ss0gJ-nw.js} +1275 -1404
  81. package/dist/assets/server-Ss0gJ-nw.js.map +1 -0
  82. package/dist/assets/{src-CbLkHfz8.js → src-DDzSERyc.js} +882 -134
  83. package/dist/assets/src-DDzSERyc.js.map +1 -0
  84. package/dist/assets/turnRefs-B87dtrHM.js +323 -0
  85. package/dist/assets/turnRefs-B87dtrHM.js.map +1 -0
  86. package/dist/assets/types-C4_KafL_.js +122 -0
  87. package/dist/assets/types-C4_KafL_.js.map +1 -0
  88. package/dist/assets/{usdt-Qf4UuygS.js → usdt-Fj8CQO2z.js} +3 -3
  89. package/dist/assets/{usdt-Qf4UuygS.js.map → usdt-Fj8CQO2z.js.map} +1 -1
  90. package/dist/ghostly.mjs +4 -22077
  91. package/dist/ghostly.mjs.map +1 -1
  92. package/package.json +1 -1
  93. package/dist/assets/endpoint-pgFkR2AS.js.map +0 -1
  94. package/dist/assets/fileBytes-BcYZhNgF.js.map +0 -1
  95. package/dist/assets/hyperdhtRelay-C1p0nkle.js.map +0 -1
  96. package/dist/assets/idb-CTF6LF5B.js.map +0 -1
  97. package/dist/assets/mainline-BH6s8HVi.js.map +0 -1
  98. package/dist/assets/rolldown-runtime-8H4AJuhK.js +0 -14
  99. package/dist/assets/server-0zfNpABQ.js.map +0 -1
  100. package/dist/assets/src-CbLkHfz8.js.map +0 -1
@@ -515,27 +515,31 @@ function signRelayPayload(identity, dnsPacket, seq) {
515
515
  new DataView(timestamp.buffer).setBigUint64(0, seq);
516
516
  return concatBytes$1(signature, timestamp, dnsPacket);
517
517
  }
518
- /** Checks a relay payload's size and signature. Returns its sequence number and DNS packet, not decoded. */
519
- function openRelayPayload(pubKeyZ32, payload) {
518
+ /** A relay payload's parts, its size checked: the signature, the sequence number and the DNS packet (not decoded). */
519
+ function splitRelayPayload(payload) {
520
520
  if (payload.length < 84) throw new Error("Relay payload too short");
521
521
  if (payload.length > 1072) throw new PacketTooLargeError(payload.length);
522
+ return {
523
+ signature: payload.subarray(0, SIGNATURE_LENGTH),
524
+ seq: new DataView(payload.buffer, payload.byteOffset + SIGNATURE_LENGTH, TIMESTAMP_LENGTH).getBigUint64(0),
525
+ dnsPacket: payload.subarray(72)
526
+ };
527
+ }
528
+ /** Checks a relay payload's size and signature. Returns its sequence number and DNS packet, not decoded. */
529
+ function openRelayPayload(pubKeyZ32, payload) {
530
+ const { signature, seq, dnsPacket } = splitRelayPayload(payload);
522
531
  const publicKey = publicKeyFromZ32(pubKeyZ32);
523
- const signature = payload.subarray(0, SIGNATURE_LENGTH);
524
- const seq = new DataView(payload.buffer, payload.byteOffset + SIGNATURE_LENGTH, TIMESTAMP_LENGTH).getBigUint64(0);
525
- const dnsPacket = payload.subarray(72);
526
532
  if (!verify(signature, signable(seq, dnsPacket), publicKey)) throw new Error("Invalid signature");
527
533
  return {
528
534
  seq,
529
535
  dnsPacket
530
536
  };
531
537
  }
532
- /** Verifies and decodes a relay payload. Throws if the signature does not match. */
533
- function parseRelayPayload(pubKeyZ32, payload) {
534
- const { seq: timestampMicros, dnsPacket } = openRelayPayload(pubKeyZ32, payload);
538
+ function decodeRelayPayload(pubKeyZ32, seq, dnsPacket) {
535
539
  const suffix = `.${pubKeyZ32}`;
536
540
  return {
537
541
  pubKeyZ32,
538
- timestampMicros,
542
+ timestampMicros: seq,
539
543
  records: decodeTxtPacket(dnsPacket).map((r) => ({
540
544
  label: r.name.endsWith(suffix) ? r.name.slice(0, -suffix.length) : r.name,
541
545
  value: r.value,
@@ -543,6 +547,20 @@ function parseRelayPayload(pubKeyZ32, payload) {
543
547
  }))
544
548
  };
545
549
  }
550
+ /** Verifies and decodes a relay payload. Throws if the signature does not match. */
551
+ function parseRelayPayload(pubKeyZ32, payload) {
552
+ const { seq, dnsPacket } = openRelayPayload(pubKeyZ32, payload);
553
+ return decodeRelayPayload(pubKeyZ32, seq, dnsPacket);
554
+ }
555
+ /**
556
+ * Decodes a payload whose signature this process made or already checked (one it is putting), without checking it
557
+ * again: an Ed25519 verify costs about 1 ms, and a CLI daemon at its relay budget checked its own packets ~500 times a
558
+ * minute. Never for bytes read from elsewhere: `parseRelayPayload` is.
559
+ */
560
+ function readOwnRelayPayload(pubKeyZ32, payload) {
561
+ const { seq, dnsPacket } = splitRelayPayload(payload);
562
+ return decodeRelayPayload(pubKeyZ32, seq, dnsPacket);
563
+ }
546
564
  /** How far ahead of this clock a packet's time may be and still count as now: clocks drift, not by this much. */
547
565
  var PKARR_FUTURE_SKEW_MS = 6e5;
548
566
  /**
@@ -700,9 +718,13 @@ var RelayBreaker = class {
700
718
  this.circuits.delete(relay);
701
719
  if (recovered) this.options.onRecover?.(relay);
702
720
  }
703
- /** The relay failed a request; a probe that fails trips it again at once. */
721
+ /**
722
+ * The relay failed a request; a probe that fails trips it again at once. While it is left alone and no probe is out,
723
+ * a failure is a request sent before it tripped (a start burst): already counted in, it trips nothing again.
724
+ */
704
725
  failure(relay, kind, reason) {
705
726
  const circuit = this.circuit(relay);
727
+ if (!circuit.probing && circuit.openUntil > this.now()) return;
706
728
  circuit.failures++;
707
729
  circuit.kind = kind;
708
730
  circuit.reason = reason;
@@ -832,6 +854,8 @@ function currentRelays(relays) {
832
854
  * only thing that peer is waiting for) would go out only once the polling slowed down, half a minute later.
833
855
  */
834
856
  var WRITE_FIRST_MS = 5e3;
857
+ /** The most of a relay's minute the chats' reserve takes, however many chats need it: the rest stays for everything else. */
858
+ var CHAT_RESERVE_MOST_SHARE = 5 / 6;
835
859
  /**
836
860
  * A 1:1 chat's signaling (a new offer or answer, and its reads for the answer to its offer, `signal`) may go over a
837
861
  * relay's minute by this fraction of it: 6 on a relay of 30, 12 on one of 60, 1 on relay.pkarr.org's 5 (it allows 10),
@@ -1167,14 +1191,22 @@ var RelayTransport = class {
1167
1191
  }
1168
1192
  /**
1169
1193
  * Signs the records, dated by `nextTimestamp`, and hands the payload to `put` (the relays; the CLI adds the DHT).
1194
+ * When every relay's budget (or rate limit) holds this asker back, it throws that wait before signing anything
1195
+ * (`publishWait`): a daemon at its budget signed about 500 packets a minute that never went out. `elsewhere`: `put`
1196
+ * also takes the packet where no budget holds it back (the CLI's DHT), so it is signed whatever the relays say.
1170
1197
  * `put` calls `conflict` for a relay that answered 409: it holds a packet under this key dated at or after this one,
1171
1198
  * which this client has not read (an inviter's warm packet under the key it gave its contact, from a clock ahead of
1172
1199
  * this one). That packet is read from that relay, and the records go again dated just past it, once. Without this
1173
1200
  * the packet was refused until this device's clock passed the other one's, however long that took, and on a relay
1174
1201
  * that did take it, readers still preferred the later-dated empty packet of another relay.
1175
1202
  */
1176
- async publishDated(identity, records, put, options = {}, again = false) {
1177
- const key = identity.pubKeyZ32, timestamp = this.nextTimestamp(key);
1203
+ async publishDated(identity, records, put, options = {}, again = false, elsewhere = false) {
1204
+ const key = identity.pubKeyZ32;
1205
+ if (!again && !elsewhere) {
1206
+ const held = this.publishWait(key, asker(options, true));
1207
+ if (held) throw new DiscoveryBudgetError(held.wait, `Publish held back on every relay: ${held.reasons.join("; ")}`);
1208
+ }
1209
+ const timestamp = this.nextTimestamp(key);
1178
1210
  let redated;
1179
1211
  const conflict = (relay) => {
1180
1212
  if (again || redated) return;
@@ -1186,7 +1218,7 @@ var RelayTransport = class {
1186
1218
  relay
1187
1219
  });
1188
1220
  if (this.lastTimestamp.get(key) !== timestamp) return true;
1189
- await this.publishDated(identity, records, put, options, true);
1221
+ await this.publishDated(identity, records, put, options, true, elsewhere);
1190
1222
  return true;
1191
1223
  })();
1192
1224
  redated.catch(() => {});
@@ -1198,6 +1230,46 @@ var RelayTransport = class {
1198
1230
  if (!pending || !await pending) throw error;
1199
1231
  }
1200
1232
  }
1233
+ /**
1234
+ * How long until some relay takes a publish of this asker, and why each holds it back; null when one would now. Walks the relays as `put` does,
1235
+ * without asking any: a relay the budget holds back gets its refused `take` (the asker's write then waits first in
1236
+ * line, as when `put` is refused), one rate limiting or throttling this address is a wait, one left alone for failing
1237
+ * is none. Null too when a relay fails another way (its network cooling down): the publish then goes, and fails, as
1238
+ * before, and when no relay is held back at all.
1239
+ */
1240
+ publishWait(pubKeyZ32, who) {
1241
+ const probe = this.breaker.allDownProbe(this.relays);
1242
+ let wait = Infinity;
1243
+ const held = [], reasons = [];
1244
+ for (const relay of this.relays) {
1245
+ if (relay !== probe && this.networkCoolingDown(relay, "PUT")) return null;
1246
+ const limited = this.rateLimitedFor(relay);
1247
+ if (limited > 0) {
1248
+ wait = Math.min(wait, limited);
1249
+ reasons.push(`${relay}: Discovery relay is cooling down after a 429; retry shortly`);
1250
+ continue;
1251
+ }
1252
+ const blocked = relay === probe ? 0 : this.breaker.blockedFor(relay);
1253
+ if (blocked > 0) {
1254
+ if (this.breaker.blockedKind(relay) === "throttled") {
1255
+ wait = Math.min(wait, blocked);
1256
+ reasons.push(`${relay}: Discovery relay is throttling this address; retry shortly`);
1257
+ } else reasons.push(`${relay} is left alone after failing`);
1258
+ continue;
1259
+ }
1260
+ if (this.heldFor(relay, who, Date.now(), pubKeyZ32) <= 0) return null;
1261
+ held.push(relay);
1262
+ const left = this.heldFor(relay, who);
1263
+ wait = Math.min(wait, left);
1264
+ reasons.push(`${relay}: discovery budget reached; retry in ${Math.ceil(left / 1e3)} s`);
1265
+ }
1266
+ if (wait === Infinity) return null;
1267
+ for (const relay of held) this.take(relay, who, pubKeyZ32);
1268
+ return {
1269
+ wait,
1270
+ reasons
1271
+ };
1272
+ }
1201
1273
  /** One relay's packet for a key, read now: its time, kept for `nextTimestamp`. Null when it has none or does not answer. */
1202
1274
  async heldAt(relay, pubKeyZ32, who) {
1203
1275
  if (!this.take(relay, who, pubKeyZ32)) return null;
@@ -1217,10 +1289,22 @@ var RelayTransport = class {
1217
1289
  sawTimestamp(pubKeyZ32, timestamp) {
1218
1290
  if (timestamp > (this.seenTimestamp.get(pubKeyZ32) ?? 0n)) this.seenTimestamp.set(pubKeyZ32, timestamp);
1219
1291
  }
1220
- /** Puts a payload signed elsewhere (a did:dht document), byte for byte, on every relay. `conflict`: as `publishDated` gives it. */
1292
+ /**
1293
+ * Puts a payload `publishDated` just signed, dated `timestamp`, on every relay. Its signature is not checked: it was
1294
+ * made here (`publishPayload` checks one signed elsewhere, to read its sequence number). `conflict`: as `publishDated`
1295
+ * gives it.
1296
+ */
1297
+ async putSigned(pubKeyZ32, payload, timestamp, options = {}, conflict) {
1298
+ if (this.relays.length === 0) throw new Error("No Pkarr relays configured");
1299
+ await this.putEverywhere(pubKeyZ32, payload, timestamp, options, conflict);
1300
+ }
1301
+ /**
1302
+ * Puts a payload signed elsewhere (a did:dht document), byte for byte, on every relay; one signed here says its date
1303
+ * (`signedAt`) and is not checked again. `conflict`: as `publishDated` gives it.
1304
+ */
1221
1305
  async publishPayload(pubKeyZ32, payload, options = {}, conflict) {
1222
1306
  if (this.relays.length === 0) throw new Error("No Pkarr relays configured");
1223
- const { seq } = openRelayPayload(pubKeyZ32, payload);
1307
+ const seq = options.signedAt ?? openRelayPayload(pubKeyZ32, payload).seq;
1224
1308
  await this.putEverywhere(pubKeyZ32, payload, seq, options, conflict);
1225
1309
  }
1226
1310
  /**
@@ -1261,7 +1345,7 @@ var RelayTransport = class {
1261
1345
  accepted = true;
1262
1346
  noWriteWaits();
1263
1347
  try {
1264
- this.written.set(pubKeyZ32, parseRelayPayload(pubKeyZ32, payload));
1348
+ this.written.set(pubKeyZ32, readOwnRelayPayload(pubKeyZ32, payload));
1265
1349
  } catch {}
1266
1350
  resolve();
1267
1351
  }, () => {});
@@ -1723,9 +1807,9 @@ var RelayTransport = class {
1723
1807
  if (rationed.length >= most) wait = Math.max(wait, rationed[rationed.length - most] + GROUP_RATION_WINDOW_MS - now);
1724
1808
  if (this.readsAhead(key, now) > 0) wait = Math.max(wait, TURN_WAIT_MS);
1725
1809
  }
1726
- if (who.group) {
1810
+ if (who.group || who.background) {
1727
1811
  const reserved = this.chatReservedFor(relay, now);
1728
- if (reserved > 0) wait = Math.max(wait, Math.min(reserved, over(recent, limit - this.reserveOf(relay))));
1812
+ if (reserved > 0) wait = Math.max(wait, Math.min(reserved, over(recent, limit - this.chatReserveOf(relay, now))));
1729
1813
  }
1730
1814
  const writer = firstWriter(who);
1731
1815
  const waiting = (lane) => {
@@ -1835,6 +1919,12 @@ var RelayTransport = class {
1835
1919
  if (share === void 0) return this.perMinute;
1836
1920
  return this.perMinuteGiven ? Math.min(this.perMinute, share) : share;
1837
1921
  }
1922
+ /** What the chats that need the relays keep on this relay (`CHAT_RESERVE_PER_CHAT`), never less than `reserveOf`. */
1923
+ chatReserveOf(relay, now) {
1924
+ let needing = 0;
1925
+ for (const need of this.chatNeeds.values()) if (now - Math.max(need.urgentAt, need.refused.get(relay) ?? -Infinity) < 6e4) needing++;
1926
+ return Math.max(this.reserveOf(relay), Math.min(needing * 3, Math.floor(this.limitOf(relay) * CHAT_RESERVE_MOST_SHARE)));
1927
+ }
1838
1928
  /** What groups leave a chat on this relay: `CHAT_RESERVE`, or a third of a smaller budget. */
1839
1929
  reserveOf(relay) {
1840
1930
  return Math.min(10, Math.floor(this.limitOf(relay) / 3));
@@ -3060,6 +3150,12 @@ var OwnInviteError = class extends Error {
3060
3150
  this.name = "OwnInviteError";
3061
3151
  }
3062
3152
  };
3153
+ /** The refusal an error (or an error's message, as an RPC hands it over) carries, or null when it is another error. */
3154
+ function ownInviteRefusal(error) {
3155
+ if (error instanceof OwnInviteError) return { linkId: error.linkId };
3156
+ const match = (error instanceof Error ? error.message : typeof error === "string" ? error : "").match(/^own-invite(?: (\S+))?: /);
3157
+ return match ? { linkId: match[1] ?? null } : null;
3158
+ }
3063
3159
  /** The `ghostly1z…` code of a device invite. */
3064
3160
  function encodeDeviceInvite(invite) {
3065
3161
  const fields = [
@@ -5092,6 +5188,7 @@ function wrapDataChannel(dc) {
5092
5188
  close() {
5093
5189
  dc.close();
5094
5190
  },
5191
+ binary: true,
5095
5192
  onMessage: null,
5096
5193
  onClose: null
5097
5194
  };
@@ -5103,11 +5200,31 @@ function wrapDataChannel(dc) {
5103
5200
  dc.addEventListener("close", () => channel.onClose?.());
5104
5201
  return channel;
5105
5202
  }
5203
+ /**
5204
+ * Resolves once the channel has room for a body's next chunk; undefined when it has now. A channel with a send budget
5205
+ * (native and iroh ones throw past 120 KiB) gives a body a quarter of it, as files/3 keeps to, so a ping or a message
5206
+ * still fits beside it; others keep under the high water mark.
5207
+ */
5208
+ function bodyRoom(channel, isCancelled) {
5209
+ if (!channel.sendBudget) return channel.bufferedAmount > LIMITS.sendHighWaterMark ? channel.drained() : void 0;
5210
+ const line = channel.sendBudget / 4;
5211
+ if (channel.bufferedAmount < line) return;
5212
+ return (async () => {
5213
+ while (!isCancelled() && channel.bufferedAmount >= line) {
5214
+ await channel.drained().catch(() => {});
5215
+ if (!isCancelled() && channel.bufferedAmount >= line) await new Promise((resolve) => setTimeout(resolve, 20));
5216
+ }
5217
+ })();
5218
+ }
5106
5219
  /** Writes a body as chunks, honouring backpressure. Always ends with an END chunk. */
5107
5220
  async function sendBody(channel, kind, id, body, isCancelled = () => false) {
5108
5221
  for await (const part of body) for (let offset = 0; offset < part.length; offset += MAX_CHUNK_PAYLOAD_BYTES) {
5109
5222
  if (isCancelled()) return;
5110
- if (channel.bufferedAmount > LIMITS.sendHighWaterMark) await channel.drained();
5223
+ const room = bodyRoom(channel, isCancelled);
5224
+ if (room) {
5225
+ await room;
5226
+ if (isCancelled()) return;
5227
+ }
5111
5228
  const payload = part.subarray(offset, offset + MAX_CHUNK_PAYLOAD_BYTES);
5112
5229
  channel.send(encodeChunk({
5113
5230
  kind,
@@ -5701,6 +5818,19 @@ var STALLED_LOOK_MS = 15e3;
5701
5818
  * #1441, on a loaded host), and past it the race's DHT dial has brought the chat back anyway.
5702
5819
  */
5703
5820
  var DEPARTED_READ_MS = EXPECT_PEER_MS;
5821
+ /** A chat whose contact was never seen (an invite just sent) keeps looking at the active pace this long. */
5822
+ var AWAITING_PEER_MS = 6e5;
5823
+ /**
5824
+ * An invite this side made that nobody used, once no longer awaited (`AWAITING_PEER_MS`), is read less often the older
5825
+ * it is: a fifth of its age between reads, up to this. Read at the background pace instead, 15 unused invites (their
5826
+ * keys and their mailboxes) asked the relays for 60 reads a minute for as long as the app ran: the relays' minute went
5827
+ * to invites nobody may ever open, and a join on one was noticed 90 s later (bug hunt 1013-1642, 2026-10-13).
5828
+ */
5829
+ var UNUSED_INVITE_WAIT_MAX_MS = 18e4;
5830
+ /** The wait between reads of an unused invite of age `age` (`UNUSED_INVITE_WAIT_MAX_MS`), never shorter than the pace's `interval`. */
5831
+ function unusedInviteWait(age, interval) {
5832
+ return Math.max(interval, Math.min(UNUSED_INVITE_WAIT_MAX_MS, Math.round(age / 5)));
5833
+ }
5704
5834
  var PUBLISH_RETRY_MS = 4e3;
5705
5835
  /**
5706
5836
  * A packet the relays' request budget held back goes again when the budget frees a request, and at least this often
@@ -5756,6 +5886,8 @@ var LinkSession = class {
5756
5886
  startedAt = Date.now();
5757
5887
  /** From when a peer never seen is awaited (`AWAITING_PEER_MS`): never later than this start, whatever the clock said when the chat was made. */
5758
5888
  awaitingSince;
5889
+ /** This side's invite, nobody used yet (`LinkSessionOptions.invite`). */
5890
+ invite;
5759
5891
  intervals;
5760
5892
  fastPollUntil = 0;
5761
5893
  /** An offer to a saved contact is out: from then on its fast window looks less often (`OFFER_FAST_MS`); 0 for none. */
@@ -5811,6 +5943,7 @@ var LinkSession = class {
5811
5943
  this.events = options.events ?? {};
5812
5944
  this.firstPublish = options.firstPublish ?? "at-start";
5813
5945
  this.awaitingSince = Math.min(options.awaitingSince ?? this.startedAt, this.startedAt);
5946
+ this.invite = options.invite ?? (() => false);
5814
5947
  }
5815
5948
  get peerPresence() {
5816
5949
  return this.presence;
@@ -5984,8 +6117,8 @@ var LinkSession = class {
5984
6117
  async setRtcSignal(signal, reportFailure = false) {
5985
6118
  if (this.rtcSignal === signal) return;
5986
6119
  this.rtcSignal = signal;
5987
- if (reportFailure) await this.publish();
5988
- else await this.publish().catch(() => {});
6120
+ if (reportFailure) await this.publish(signal === null);
6121
+ else await this.publish(signal === null).catch(() => {});
5989
6122
  if (signal) this.pollNow();
5990
6123
  }
5991
6124
  /**
@@ -6058,6 +6191,7 @@ var LinkSession = class {
6058
6191
  const stepping = this.fastStepsAfter ? Date.now() - this.fastStepsAfter : -1;
6059
6192
  if (pace === "fast" && stepping >= 0) return Math.min(4 * interval, Math.max(2 * interval, stepping));
6060
6193
  if (pace === "fast" || pace === "connected" || since < 0) return interval;
6194
+ if (pace === "background" && !this.presence.online && this.invite()) return unusedInviteWait(Date.now() - this.awaitingSince, interval);
6061
6195
  return Math.min(interval, Math.max(2 * this.intervals.fast, since));
6062
6196
  }
6063
6197
  /**
@@ -6069,13 +6203,17 @@ var LinkSession = class {
6069
6203
  watching() {
6070
6204
  return Date.now() < this.watchUntil && this.rtcSignal === null && !(this.presence.online && this.presence.lastPacketAt > this.watchFrom);
6071
6205
  }
6206
+ /** A contact never seen, whose invite was sent lately (`AWAITING_PEER_MS`): its join is due. */
6207
+ awaitingPeer() {
6208
+ return this.presence.lastPacketAt === 0 && Date.now() - this.awaitingSince < 6e5;
6209
+ }
6072
6210
  /** How urgently this link looks right now. */
6073
6211
  pace() {
6074
6212
  if (Date.now() < this.stalledUntil) return "fast";
6075
6213
  if (this.connected) return "connected";
6076
6214
  if (Date.now() < this.fastPollUntil) return "fast";
6077
6215
  if (Date.now() < this.watchUntil) return "active";
6078
- if (!this.active && this.presence.lastPacketAt === 0 && Date.now() - this.awaitingSince < 6e5) return "active";
6216
+ if (!this.active && this.awaitingPeer()) return "active";
6079
6217
  if (!this.active) return "background";
6080
6218
  return Date.now() - this.lastActivity > 6e4 ? "idle" : "active";
6081
6219
  }
@@ -6132,7 +6270,9 @@ var LinkSession = class {
6132
6270
  }, this.encKey);
6133
6271
  const started = Date.now();
6134
6272
  try {
6135
- const options = rtcSignal && rtcSignal !== this.rtcSignalOut ? { signal: true } : this.watching() ? { watch: true } : upkeep && this.connected ? { background: true } : void 0;
6273
+ const awaited = this.connected || this.presence.online || this.sentBuffer.length > 0 || this.callSignal !== null || this.awaitingPeer();
6274
+ const takesBack = !rtcSignal && this.rtcSignalOut !== null;
6275
+ const options = rtcSignal && rtcSignal !== this.rtcSignalOut ? { signal: true } : this.watching() ? { watch: true } : upkeep && (this.connected || takesBack) || !rtcSignal && !awaited ? { background: true } : void 0;
6136
6276
  await this.transport.publish(this.identity, built.records, options);
6137
6277
  } catch (error) {
6138
6278
  const ms = Date.now() - started, waiting = isDiscoveryBudgetError(error);
@@ -6437,7 +6577,9 @@ var ICE_GATHERING_TIMEOUT_MS = 5e3;
6437
6577
  * settle); where they do not, every offer of a first pairing waited the full 5 s, twice when the inviter offered too
6438
6578
  * (One's e2e runs: 13 offers of 89 at 5 s, 9 more at 1.3-3 s, 2026-10-05). An offer without one still connects to most
6439
6579
  * contacts (their checks learn this side's address from its own); behind a restricted NAT on the contact's side it may
6440
- * not, and the next offer of the chat then waits as long as it takes (`fullGather`).
6580
+ * not, and the next offer of the chat then waits as long as it takes (`fullGather`). An answer is bounded the same way:
6581
+ * it waited the full 5 s, and two CLI daemons whose STUN server swallowed every request went live 10 s after the offer
6582
+ * rather than 2 s (calls bug hunt, 2026-10-12).
6441
6583
  */
6442
6584
  var OFFER_HOST_GATHER_MS = 2e3;
6443
6585
  /**
@@ -6447,6 +6589,14 @@ var OFFER_HOST_GATHER_MS = 2e3;
6447
6589
  var GATHER_STALL_MS = 3e3;
6448
6590
  /** How long a connection may stay `disconnected` before it is given up. */
6449
6591
  var DISCONNECT_GRACE_MS = 12e3;
6592
+ /**
6593
+ * The same race a moment later: setRemoteDescription resolves, and the connection closes right after it (8 ms on a
6594
+ * loaded core). A connection that ends within this long of its answer going in was refused too: no network fails one
6595
+ * that soon (ICE gives up after tens of seconds), so it says nothing about direct connections (`DirectEvidence`).
6596
+ * Taken for a network that lets none through, an edge between two CLIs left WebRTC for the run, and a member whose
6597
+ * app has WebRTC alone (one from before native group links) was never reached again (groupCompat, 2026-10-12).
6598
+ */
6599
+ var ANSWER_REFUSED_AFTER_MS = 1e3;
6450
6600
  /** An offer this close to the end of the offerer's attempt is not answered again: the new answer would come too late. */
6451
6601
  var REANSWER_MARGIN_MS = 15e3;
6452
6602
  /**
@@ -6478,12 +6628,13 @@ var DataLink = class {
6478
6628
  pc = null;
6479
6629
  myOfferTs = 0;
6480
6630
  /**
6481
- * An offer of this side's that went without a public candidate came to nothing (it was answered, and no path
6482
- * connected): the next one waits for its STUN servers as long as it takes (`ICE_GATHERING_TIMEOUT_MS`).
6631
+ * An offer or answer of this side's that went without a public candidate came to nothing (both descriptions were
6632
+ * exchanged, and no path connected): the next one waits for its STUN servers as long as it takes
6633
+ * (`ICE_GATHERING_TIMEOUT_MS`).
6483
6634
  */
6484
6635
  fullGather = false;
6485
- /** The offer out has no public candidate (as it went: the connection's description gains any that come later). */
6486
- offerLacksPublic = false;
6636
+ /** The offer or answer out has no public candidate (as it went: the connection's description gains any that come later). */
6637
+ sentLacksPublic = false;
6487
6638
  lastSignalTs = 0;
6488
6639
  /** The peer's description as this connection was given it: Chrome shows it as `remoteDescription` only once applied. */
6489
6640
  remoteSdp = null;
@@ -6529,7 +6680,7 @@ var DataLink = class {
6529
6680
  this.options.setFastPoll(true, true);
6530
6681
  }
6531
6682
  this.myOfferTs = Date.now();
6532
- this.offerLacksPublic = !PUBLIC_CANDIDATE.test(pc.localDescription?.sdp ?? "");
6683
+ this.sentLacksPublic = !PUBLIC_CANDIDATE.test(pc.localDescription?.sdp ?? "");
6533
6684
  const signal = {
6534
6685
  t: "o",
6535
6686
  ts: this.myOfferTs,
@@ -6635,9 +6786,12 @@ var DataLink = class {
6635
6786
  this.reset();
6636
6787
  if (evidence) this.options.onDirect?.(evidence);
6637
6788
  }
6638
- /** This side's offer went with no public candidate, was answered, and came to nothing: the next offer gathers for as long as it takes. */
6789
+ /**
6790
+ * This side's offer went with no public candidate and was answered, or its answer went with none, and it came to
6791
+ * nothing: the next offer or answer gathers for as long as it takes.
6792
+ */
6639
6793
  noteNoPublic(ended) {
6640
- if (ended && this.myOfferTs && this.offerLacksPublic && this.state === "connecting") this.fullGather = true;
6794
+ if (ended && (this.myOfferTs || this.answered) && this.sentLacksPublic && this.state === "connecting") this.fullGather = true;
6641
6795
  }
6642
6796
  /**
6643
6797
  * What this attempt says about direct connections if it ends now without opening: nothing unless both descriptions
@@ -6652,17 +6806,22 @@ var DataLink = class {
6652
6806
  /**
6653
6807
  * The attempt on this connection failed (it timed out, ICE failed or closed, its channel closed). An answer the
6654
6808
  * offerer may not have read yet is made again for the same offer while it still stands (`REANSWERS`); anything
6655
- * else goes back to idle.
6809
+ * else goes back to idle. `byConnection`: the connection said so itself, rather than a timer of this link's.
6656
6810
  */
6657
- failed() {
6811
+ failed(byConnection = false) {
6658
6812
  if (this.state === "offering" && this.remoteSdp) {
6659
6813
  this.answerRefused("failed");
6660
6814
  return;
6661
6815
  }
6816
+ if (byConnection && this.state === "connecting" && this.myOfferTs && Date.now() - this.connectingSince < ANSWER_REFUSED_AFTER_MS) {
6817
+ this.answerRefused("failed");
6818
+ return;
6819
+ }
6662
6820
  const answered = this.state === "connecting" ? this.answered : null;
6663
6821
  const left = answered ? CONNECT_TIMEOUT_MS - (Date.now() - answered.at) : 0;
6664
- const evidence = this.directEvidence();
6665
- this.noteNoPublic(true);
6822
+ const givenUp = !!answered && this.options.offerStanding?.(answered.offer.ts) === false;
6823
+ const evidence = givenUp ? null : this.directEvidence();
6824
+ this.noteNoPublic(!givenUp);
6666
6825
  if (answered && answered.again < 2 && left > REANSWER_MARGIN_MS && this.options.offerStanding?.(answered.offer.ts)) {
6667
6826
  answered.again++;
6668
6827
  traceLink(this.options.myPubKeyZ32, "reanswer", {
@@ -6703,6 +6862,7 @@ var DataLink = class {
6703
6862
  await pc.setLocalDescription(await pc.createAnswer());
6704
6863
  });
6705
6864
  if (!pc) return;
6865
+ this.sentLacksPublic = !PUBLIC_CANDIDATE.test(pc.localDescription?.sdp ?? "");
6706
6866
  const signal = {
6707
6867
  t: "a",
6708
6868
  ts: Date.now(),
@@ -6732,7 +6892,7 @@ var DataLink = class {
6732
6892
  await waitForIceGathering(pc, ICE_GATHERING_TIMEOUT_MS, {
6733
6893
  stallMs: GATHER_STALL_MS,
6734
6894
  settleMs: 150,
6735
- ...offer && !this.fullGather ? { hostBoundMs: OFFER_HOST_GATHER_MS } : {}
6895
+ ...!this.fullGather ? { hostBoundMs: OFFER_HOST_GATHER_MS } : {}
6736
6896
  });
6737
6897
  if (this.pc !== pc) return null;
6738
6898
  const ms = Date.now() - gathering;
@@ -6779,7 +6939,7 @@ var DataLink = class {
6779
6939
  tracePair(this.options.myPubKeyZ32, pc);
6780
6940
  });
6781
6941
  dc.addEventListener("close", () => {
6782
- if (this.pc === pc) this.failed();
6942
+ if (this.pc === pc) this.failed(true);
6783
6943
  });
6784
6944
  pc.addEventListener("connectionstatechange", () => {
6785
6945
  if (this.pc !== pc) return;
@@ -6788,7 +6948,7 @@ var DataLink = class {
6788
6948
  if (pc.connectionState === "connected") this.options.onDisconnected?.(false);
6789
6949
  }
6790
6950
  this.disconnectTimer = null;
6791
- if (pc.connectionState === "failed" || pc.connectionState === "closed") this.failed();
6951
+ if (pc.connectionState === "failed" || pc.connectionState === "closed") this.failed(true);
6792
6952
  else if (pc.connectionState === "disconnected") {
6793
6953
  if (this.state === "open") this.options.onDisconnected?.(true);
6794
6954
  this.disconnectTimer = setTimeout(() => {
@@ -8705,11 +8865,6 @@ var VIDEO_LIMITS = {
8705
8865
  /** Pixels on either side (8K and a bit). */
8706
8866
  maxEdge: 16384
8707
8867
  };
8708
- /**
8709
- * Video a player may be handed as it is. None of these runs anything in the page's origin, so received bytes
8710
- * of these types are served with their type (see `safeBlobType`): WebKit will not play what is typed as bytes.
8711
- */
8712
- var PLAYABLE_VIDEO = /^video\/(mp4|webm|quicktime|ogg|x-m4v)$/;
8713
8868
  /** Whether a file is a video, by its type: shown in a video bubble whether or not it came with a description. */
8714
8869
  function isVideoMime(mime) {
8715
8870
  return mime.split(";")[0].trim().toLowerCase().startsWith("video/");
@@ -8903,18 +9058,6 @@ function formatFileSize(bytes) {
8903
9058
  }
8904
9059
  return `${value.toFixed(1)} ${units[unit]}`;
8905
9060
  }
8906
- /** Raster images a UI may show inline. SVG is left out on purpose: it can carry scripts. */
8907
- var PREVIEWABLE_IMAGE = /^image\/(png|jpe?g|gif|webp)$/;
8908
- /**
8909
- * The type received bytes are served with. The peer picks the announced type,
8910
- * and a blob: URL typed text/html or image/svg+xml would run in the app's
8911
- * origin, so anything but a previewable image or playable audio or video is opaque bytes.
8912
- * Audio and video keep their type because WebKit will not play what is typed as bytes.
8913
- */
8914
- function safeBlobType(mime) {
8915
- const clean = sanitizeMime(mime);
8916
- return PREVIEWABLE_IMAGE.test(clean) || PLAYABLE_AUDIO.test(clean) || PLAYABLE_VIDEO.test(clean) ? clean : "application/octet-stream";
8917
- }
8918
9061
  var FileTransfers = class {
8919
9062
  channel;
8920
9063
  events;
@@ -9355,6 +9498,7 @@ var DhtDelivery = class DhtDelivery {
9355
9498
  fastUntil = 0;
9356
9499
  live = false;
9357
9500
  active = false;
9501
+ startedAt = Date.now();
9358
9502
  /** The next read was asked for (a refresh, a fresh packet of the contact): it is not a background one. */
9359
9503
  urgent = false;
9360
9504
  /** Reads left that are signaling: the contact is leaving DHT only, and the live link waits on them (`expect`). */
@@ -9426,6 +9570,14 @@ var DhtDelivery = class DhtDelivery {
9426
9570
  };
9427
9571
  }
9428
9572
  /**
9573
+ * This side joined, and its inviter never answered (`DhtDeliveryState.unanswered`): no envelope of its newer than the
9574
+ * one that pinned it, none sealed to this side, no text confirmed, never live (as `sealedToAnother` asks too).
9575
+ */
9576
+ get unanswered() {
9577
+ const pinnedAt = this.state.unanswered;
9578
+ return pinnedAt !== void 0 && this.state.peerSequence <= pinnedAt && !this.state.peerPinned && !this.state.confirmed && !this.state.wasLive && !this.live;
9579
+ }
9580
+ /**
9429
9581
  * Something signed by a participation key other than the pinned one came in over the chat's invite-derived keys (this
9430
9582
  * mailbox, the link's signals, a connection to an endpoint whose address a copy of the invite could read): ignored,
9431
9583
  * and said in the view as a passive warning.
@@ -10134,6 +10286,7 @@ var DhtDelivery = class DhtDelivery {
10134
10286
  }
10135
10287
  if (message !== null && (!Array.isArray(message) || message.length !== 3 || typeof message[0] !== "string" || !ID$2.test(message[0]) || !Number.isSafeInteger(message[1]) || message[1] <= 0 || message[1] > issued + 3e4 || typeof message[2] !== "string" || utf8Encode(message[2]).length > 256 || expires - issued > 3e5)) return "none";
10136
10288
  if (receipt !== null && (typeof receipt !== "string" || !ID$2.test(receipt))) return "none";
10289
+ const unanswered = !this.options.credentials.peerKey && !!this.options.credentials.expectedPeerKey && !sender && !this.live;
10137
10290
  await this.options.pin(author);
10138
10291
  this.options.credentials.peerKey = author;
10139
10292
  this.options.credentials.requireSignedSignals = true;
@@ -10184,6 +10337,7 @@ var DhtDelivery = class DhtDelivery {
10184
10337
  receipt: nextReceipt,
10185
10338
  confirmed,
10186
10339
  ...peerPinned && { peerPinned },
10340
+ ...unanswered && { unanswered: sequence },
10187
10341
  pending: confirmed && this.state.pending?.message[0] === confirmed ? void 0 : this.state.pending,
10188
10342
  ...newest > (this.state.reactionsTaken ?? 0) && { reactionsTaken: newest }
10189
10343
  });
@@ -10298,11 +10452,23 @@ var DhtDelivery = class DhtDelivery {
10298
10452
  get pollMs() {
10299
10453
  if (this.options.pollMs) return this.options.pollMs;
10300
10454
  const now = Date.now();
10455
+ const unused = this.unusedInviteAge(now);
10456
+ if (unused !== void 0 && !this.active && now >= this.fastUntil) return unusedInviteWait(unused, this.mode === "dht" ? DHT_POLL_MS : STREAM_POLL_MS);
10301
10457
  if (this.mode === "dht" || this.state.peerMode === "dht" && now < this.leavingUntil || now < this.fastUntil) return DHT_POLL_MS;
10302
10458
  if (this.live) return LIVE_POLL_MS;
10303
10459
  if (this.state.pending && this.state.pending.expires > now) return DHT_POLL_MS;
10304
10460
  return this.active ? ACTIVE_DHT_POLL_MS : STREAM_POLL_MS;
10305
10461
  }
10462
+ /**
10463
+ * How long ago this side's invite was made (or joined), while nobody used it (or its inviter never answered,
10464
+ * `unanswered`) and it is no longer awaited (`AWAITING_PEER_MS`).
10465
+ */
10466
+ unusedInviteAge(now) {
10467
+ const madeAt = this.options.inviteMadeAt;
10468
+ if (madeAt === void 0 || (this.options.credentials.peerKey || this.state.peerSequence) && !this.unanswered) return void 0;
10469
+ const age = now - Math.min(madeAt, this.startedAt);
10470
+ return age >= 6e5 ? age : void 0;
10471
+ }
10306
10472
  schedule() {
10307
10473
  if (!this.running || this.ticking) return;
10308
10474
  if (this.timer) clearTimeout(this.timer);
@@ -13431,6 +13597,8 @@ function readManifest(meta, loopback = false) {
13431
13597
  */
13432
13598
  var OFFER_EXTENSIONS = ["ping/1"];
13433
13599
  var MAX_HANDSHAKE_BYTES = 4096;
13600
+ /** One frame from the peer, at most (UTF-16 units for text, bytes for binary): a bigger one breaks the protocol. */
13601
+ var SESSION_FRAME_MAX = 61440;
13434
13602
  /**
13435
13603
  * Frames received and not handled yet, at most: their count and their size together. Frames are handled one at a time,
13436
13604
  * each awaited (a group frame is decrypted, checked and stored before the next), and the peer sends at the network's
@@ -13441,7 +13609,7 @@ var MAX_HANDSHAKE_BYTES = 4096;
13441
13609
  */
13442
13610
  var SESSION_RECEIVE_PENDING = {
13443
13611
  frames: 1024,
13444
- bytes: 3932160
13612
+ bytes: 64 * SESSION_FRAME_MAX
13445
13613
  };
13446
13614
  var KEY = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/;
13447
13615
  var NONCE$1 = /^[A-Za-z0-9_-]{43}$/;
@@ -13792,6 +13960,9 @@ var PairedHttp = class {
13792
13960
  return channel.bufferedAmount;
13793
13961
  },
13794
13962
  drained: () => channel.drained(),
13963
+ get sendBudget() {
13964
+ return channel.sendBudget;
13965
+ },
13795
13966
  close: () => {},
13796
13967
  onMessage: null,
13797
13968
  onClose: null
@@ -13836,6 +14007,11 @@ var SERVICES_CAPABILITY = "services/1";
13836
14007
  * confirmed byte, checked by digest (`pf-offer` … frames, `chatFiles.ts`). Without it, files/2 and 100 MiB.
13837
14008
  */
13838
14009
  var FILES_CAPABILITY = "files/3";
14010
+ /**
14011
+ * A file's bytes as binary messages (WISP 501 § files/4): beside `files/3`, on a session whose channel carries binary
14012
+ * messages, a chunk travels as it is (`encodeFileData`, `chatFiles.ts`) and not as base64url text in a `pf-data` frame.
14013
+ */
14014
+ var FILES_BINARY_CAPABILITY = "files/4";
13839
14015
  /** The typing indicator: `paired-typing` frames on this session, 1:1 chats only (WISP 401 § Typing, `pairedTyping.ts`). */
13840
14016
  var TYPING_CAPABILITY = "typing/1";
13841
14017
  /** Reactions to messages: `paired-reaction` frames on this session, 1:1 chats only (WISP 401 § Reactions, `reactions.ts`). */
@@ -13875,6 +14051,7 @@ var KNOWN_SESSION_CAPABILITIES = [
13875
14051
  CALLS_CAPABILITY,
13876
14052
  SERVICES_CAPABILITY,
13877
14053
  FILES_CAPABILITY,
14054
+ FILES_BINARY_CAPABILITY,
13878
14055
  TYPING_CAPABILITY,
13879
14056
  REACTIONS_CAPABILITY,
13880
14057
  EDIT_CAPABILITY,
@@ -13971,6 +14148,8 @@ var SessionCapabilities = class {
13971
14148
  * be matched across a person's chats; it travels only inside the encrypted session.
13972
14149
  */
13973
14150
  var APP_FRAME = "paired-app";
14151
+ /** The largest `d`, as compact UTF-8 JSON. The session's 60 KiB cap on any frame still holds above it. */
14152
+ var APP_DATA_MAX_BYTES = 32768;
13974
14153
  var DOMAIN = "ghostly-apps/1";
13975
14154
  var APP_ID = /^[A-Za-z0-9_-]{22}$/;
13976
14155
  /** Semantic versioning 2.0.0, as a manifest's `version` is. */
@@ -14810,6 +14989,11 @@ var FILE_LIMITS = {
14810
14989
  chunkBytes: 16384,
14811
14990
  /** Bytes a sender has sent and not seen confirmed: throughput is this per round trip. */
14812
14991
  windowBytes: 1048576,
14992
+ /**
14993
+ * Bytes all of a chat's files have sent and not seen confirmed, together. Three files' windows at once were 3 MiB, and
14994
+ * as base64 text past what a session's receive queue holds (`SESSION_RECEIVE_PENDING`, 3.75 MiB).
14995
+ */
14996
+ chatWindowBytes: 2097152,
14813
14997
  /** A receiver asks its person before taking a file larger than this. */
14814
14998
  askAboveBytes: 26214400,
14815
14999
  /** Files taken without asking, per contact, while stored. Past it, every file is asked about. */
@@ -14878,6 +15062,37 @@ function transferStalled(record, movedAt, restored, now) {
14878
15062
  }
14879
15063
  var key = (direction, id) => `${direction}:${id}`;
14880
15064
  var isInt$1 = (value, max = Number.MAX_SAFE_INTEGER) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= max;
15065
+ var FILE_DATA_OFFSET_BYTES = 8;
15066
+ /** The binary message for the bytes of file `id` at `offset`. Throws on what no receiver would take. */
15067
+ function encodeFileData(id, offset, data) {
15068
+ if (!ID.test(id) || !isInt$1(offset) || !data.length || data.length > FILE_LIMITS.chunkBytes) throw new Error("Invalid file data");
15069
+ const head = 2 + id.length;
15070
+ const out = new Uint8Array(head + FILE_DATA_OFFSET_BYTES + data.length);
15071
+ out[0] = 4;
15072
+ out[1] = id.length;
15073
+ for (let i = 0; i < id.length; i++) out[2 + i] = id.charCodeAt(i);
15074
+ const view = new DataView(out.buffer);
15075
+ view.setUint32(head, Math.floor(offset / 2 ** 32));
15076
+ view.setUint32(head + 4, offset % 2 ** 32);
15077
+ out.set(data, head + FILE_DATA_OFFSET_BYTES);
15078
+ return out;
15079
+ }
15080
+ /** What a binary message from the contact says, or null when it is not a well-formed file data message. `data` is a view of `message`. */
15081
+ function decodeFileData(message) {
15082
+ if (message.length < 2 || message[0] !== 4) return null;
15083
+ const head = 2 + message[1], body = head + FILE_DATA_OFFSET_BYTES;
15084
+ if (message.length <= body || message.length - body > FILE_LIMITS.chunkBytes) return null;
15085
+ const id = String.fromCharCode(...message.subarray(2, head));
15086
+ if (!ID.test(id)) return null;
15087
+ const view = new DataView(message.buffer, message.byteOffset, message.byteLength);
15088
+ const high = view.getUint32(head);
15089
+ if (high > 2097151) return null;
15090
+ return {
15091
+ id,
15092
+ offset: high * 2 ** 32 + view.getUint32(head + 4),
15093
+ data: message.subarray(body)
15094
+ };
15095
+ }
14881
15096
  /** What a refusal means to the sender's person. */
14882
15097
  function refusalText(why, room) {
14883
15098
  switch (why) {
@@ -14914,6 +15129,10 @@ function refusalText(why, room) {
14914
15129
  var ChatFiles = class {
14915
15130
  host;
14916
15131
  entries = /* @__PURE__ */ new Map();
15132
+ /** Files sending that may have bytes not confirmed yet: what `inFlight` adds up (the rest are left out as it goes). */
15133
+ moving = /* @__PURE__ */ new Set();
15134
+ /** Files that stopped sending for the chat's window alone: they go on once another's bytes are confirmed. */
15135
+ held = /* @__PURE__ */ new Set();
14917
15136
  attached = false;
14918
15137
  peerRoomValue = null;
14919
15138
  constructor(host) {
@@ -14973,6 +15192,15 @@ var ChatFiles = class {
14973
15192
  send(frame) {
14974
15193
  return this.attached && this.host.send(frame);
14975
15194
  }
15195
+ sendData(id, offset, data) {
15196
+ if (!this.host.sendData) return this.send({
15197
+ t: "pf-data",
15198
+ id,
15199
+ offset,
15200
+ data: toBase64Url(data)
15201
+ });
15202
+ return this.attached && this.host.sendData(id, offset, data);
15203
+ }
14976
15204
  /** A session that agreed files/3 is open: say what fits here, then offer again what is not finished. */
14977
15205
  attach() {
14978
15206
  this.attached = true;
@@ -14991,6 +15219,8 @@ var ChatFiles = class {
14991
15219
  if (!this.attached) return;
14992
15220
  this.attached = false;
14993
15221
  this.peerRoomValue = null;
15222
+ this.moving.clear();
15223
+ this.held.clear();
14994
15224
  for (const entry of this.entries.values()) {
14995
15225
  if (entry.out) {
14996
15226
  entry.out.generation++;
@@ -15114,10 +15344,34 @@ var ChatFiles = class {
15114
15344
  const at = offset - out.block.start;
15115
15345
  return out.block.bytes.subarray(at, at + length);
15116
15346
  }
15117
- shouldPump(entry) {
15347
+ /** Whether this file's own window lets it send more. */
15348
+ fileMayPump(entry) {
15118
15349
  const { record } = entry, out = entry.out;
15119
15350
  return this.attached && record.state === "active" && out.next < record.file.size && out.next - record.confirmed < FILE_LIMITS.windowBytes;
15120
15351
  }
15352
+ shouldPump(entry) {
15353
+ return this.fileMayPump(entry) && this.inFlight() < FILE_LIMITS.chatWindowBytes;
15354
+ }
15355
+ /** Bytes this chat's files sent and have not seen confirmed. */
15356
+ inFlight() {
15357
+ let bytes = 0;
15358
+ for (const entry of this.moving) {
15359
+ const out = entry.out, { record } = entry;
15360
+ if (!out || record.state !== "active" || out.next <= record.confirmed) {
15361
+ this.moving.delete(entry);
15362
+ continue;
15363
+ }
15364
+ bytes += out.next - record.confirmed;
15365
+ }
15366
+ return bytes;
15367
+ }
15368
+ /** Bytes were confirmed, or a file stopped sending: the files the chat's window held go on, before the one that freed it. */
15369
+ wakeHeld() {
15370
+ for (const entry of [...this.held]) {
15371
+ this.held.delete(entry);
15372
+ this.pump(entry);
15373
+ }
15374
+ }
15121
15375
  /**
15122
15376
  * Whether the next `length` bytes go. Within the file's size they always do; past it the receiver is having parts
15123
15377
  * sent again, which nothing it says bounds, so this side does.
@@ -15148,17 +15402,13 @@ var ChatFiles = class {
15148
15402
  this.end(entry, "failed", "Your contact asked for the same part of this file too many times");
15149
15403
  break;
15150
15404
  }
15151
- if (!this.send({
15152
- t: "pf-data",
15153
- id: entry.record.id,
15154
- offset,
15155
- data: toBase64Url(chunk)
15156
- })) {
15405
+ if (!this.sendData(entry.record.id, offset, chunk)) {
15157
15406
  refused = true;
15158
15407
  break;
15159
15408
  }
15160
15409
  out.next = offset + chunk.length;
15161
15410
  out.sent += chunk.length;
15411
+ this.moving.add(entry);
15162
15412
  }
15163
15413
  } catch (error) {
15164
15414
  if (generation === out.generation) {
@@ -15173,7 +15423,7 @@ var ChatFiles = class {
15173
15423
  if (generation === out.generation && this.shouldPump(entry)) {
15174
15424
  if (refused) out.wake = setTimeout(() => void this.pump(entry), FILE_LIMITS.sendRetryMs);
15175
15425
  else this.pump(entry);
15176
- }
15426
+ } else if (generation === out.generation && this.fileMayPump(entry)) this.held.add(entry);
15177
15427
  }
15178
15428
  }
15179
15429
  async sendSum(entry) {
@@ -15205,7 +15455,7 @@ var ChatFiles = class {
15205
15455
  return [...this.entries.values()].filter((e) => e.record.direction === "in" && e.record.state === "asking").length;
15206
15456
  }
15207
15457
  activeIncoming(except) {
15208
- return [...this.entries.values()].filter((e) => e !== except && e.record.direction === "in" && (e.record.state === "active" || e.record.state === "verifying")).length;
15458
+ return [...this.entries.values()].filter((e) => e !== except && e.record.direction === "in" && (e.record.state === "active" || e.record.state === "verifying" || e.in?.starting)).length;
15209
15459
  }
15210
15460
  /**
15211
15461
  * Removes what this side stored of an incoming file that ends here. Through its target, opened now if need be: after
@@ -15232,9 +15482,15 @@ var ChatFiles = class {
15232
15482
  }
15233
15483
  return incoming.target;
15234
15484
  }
15235
- /** An accepted file goes when a session is open and fewer than three others arrive at once. */
15485
+ /**
15486
+ * An accepted file goes when a session is open and fewer than three others arrive at once. One whose target is still
15487
+ * being opened counts among the three: counted only once active, every file offered or queued meanwhile went too, and
15488
+ * the windows of ten files and more at once overflowed the session's receive queue.
15489
+ */
15236
15490
  startIncoming(entry) {
15237
15491
  if (!this.attached || transferEnded(entry.record) || entry.record.state === "asking" || entry.record.state === "paused" && entry.record.pausedBy === "me") return;
15492
+ const incoming = entry.in;
15493
+ if (incoming.starting) return;
15238
15494
  if (this.activeIncoming(entry) >= FILE_LIMITS.maxActiveIncoming) {
15239
15495
  if (entry.record.state !== "queued") {
15240
15496
  entry.record.state = "queued";
@@ -15247,9 +15503,13 @@ var ChatFiles = class {
15247
15503
  });
15248
15504
  return;
15249
15505
  }
15506
+ incoming.starting = true;
15250
15507
  this.target(entry).then(() => {
15251
- if (transferEnded(entry.record) || !this.attached) return;
15252
- if (entry.record.state === "paused" && entry.record.pausedBy === "me") return;
15508
+ incoming.starting = false;
15509
+ if (transferEnded(entry.record) || !this.attached || entry.record.state === "paused" && entry.record.pausedBy === "me") {
15510
+ this.startNext();
15511
+ return;
15512
+ }
15253
15513
  entry.record.state = "active";
15254
15514
  entry.record.pausedBy = void 0;
15255
15515
  entry.in.gapAt = void 0;
@@ -15262,12 +15522,14 @@ var ChatFiles = class {
15262
15522
  entry.in.heardAt = this.now();
15263
15523
  this.armNudge(entry);
15264
15524
  }, (error) => {
15525
+ incoming.starting = false;
15265
15526
  this.end(entry, "failed", `Could not store the file: ${error instanceof Error ? error.message : String(error)}`);
15266
15527
  this.send({
15267
15528
  t: "pf-refuse",
15268
15529
  id: entry.record.id,
15269
15530
  why: "no-room"
15270
15531
  });
15532
+ this.startNext();
15271
15533
  });
15272
15534
  }
15273
15535
  /** The next file waiting its turn, once one ends. */
@@ -15506,7 +15768,9 @@ var ChatFiles = class {
15506
15768
  if (record.state !== "active" || !incoming.target) return;
15507
15769
  incoming.heardAt = this.now();
15508
15770
  const { offset, data } = frame;
15509
- if (!isInt$1(offset) || typeof data !== "string" || !data.length || data.length > Math.ceil(FILE_LIMITS.chunkBytes * 4 / 3) || !DATA.test(data)) return;
15771
+ const binary = data instanceof Uint8Array;
15772
+ if (!isInt$1(offset) || !(binary || typeof data === "string") || !data.length) return;
15773
+ if (!binary && (data.length > Math.ceil(FILE_LIMITS.chunkBytes * 4 / 3) || !DATA.test(data))) return;
15510
15774
  if (offset < incoming.written) {
15511
15775
  if (incoming.stored === incoming.written) this.send({
15512
15776
  t: "pf-got",
@@ -15526,7 +15790,7 @@ var ChatFiles = class {
15526
15790
  }
15527
15791
  return;
15528
15792
  }
15529
- const bytes = fromBase64Url(data);
15793
+ const bytes = binary ? data : fromBase64Url(data);
15530
15794
  if (!bytes.length || bytes.length > FILE_LIMITS.chunkBytes || offset + bytes.length > record.file.size) return;
15531
15795
  incoming.written += bytes.length;
15532
15796
  const target = incoming.target;
@@ -15711,6 +15975,7 @@ var ChatFiles = class {
15711
15975
  record.state = "active";
15712
15976
  record.pausedBy = record.waitingFor = void 0;
15713
15977
  this.changed(outgoing);
15978
+ this.wakeHeld();
15714
15979
  if (record.confirmed === record.file.size) this.sendSum(outgoing);
15715
15980
  else {
15716
15981
  this.armStall(outgoing);
@@ -15730,11 +15995,13 @@ var ChatFiles = class {
15730
15995
  record.waitingFor = frame.why === "busy" ? "busy" : "consent";
15731
15996
  }
15732
15997
  this.changed(outgoing);
15998
+ this.wakeHeld();
15733
15999
  return;
15734
16000
  case "pf-got":
15735
16001
  if (!isInt$1(frame.offset) || frame.offset <= record.confirmed || frame.offset > out.next) return;
15736
16002
  record.confirmed = frame.offset;
15737
16003
  this.changed(outgoing, true);
16004
+ this.wakeHeld();
15738
16005
  if (record.confirmed === record.file.size) this.sendSum(outgoing);
15739
16006
  else {
15740
16007
  this.armStall(outgoing);
@@ -15764,6 +16031,8 @@ var ChatFiles = class {
15764
16031
  clearTimeout(entry.out.wake);
15765
16032
  entry.out.block = void 0;
15766
16033
  entry.out.source = void 0;
16034
+ this.moving.delete(entry);
16035
+ this.held.delete(entry);
15767
16036
  }
15768
16037
  if (entry.in) {
15769
16038
  const incoming = entry.in;
@@ -15773,6 +16042,7 @@ var ChatFiles = class {
15773
16042
  });
15774
16043
  }
15775
16044
  this.changed(entry);
16045
+ if (entry.out) this.wakeHeld();
15776
16046
  }
15777
16047
  entry(direction, id) {
15778
16048
  const entry = this.entries.get(key(direction, id));
@@ -15821,6 +16091,7 @@ var ChatFiles = class {
15821
16091
  this.startNext();
15822
16092
  }
15823
16093
  this.changed(entry);
16094
+ if (entry.out) this.wakeHeld();
15824
16095
  }
15825
16096
  resume(direction, id) {
15826
16097
  const entry = this.entry(direction, id), { record } = entry;
@@ -16439,6 +16710,8 @@ var CarriedTransport = class {
16439
16710
  /** My last packet while the edge is down: what a member that just became reachable is handed. */
16440
16711
  mine = null;
16441
16712
  lastTimestamp = 0n;
16713
+ /** The date each packet `stamp` signed carries: the relays' transport takes it rather than check the signature again. */
16714
+ dated = /* @__PURE__ */ new WeakMap();
16442
16715
  /** My newest packet, wherever it went: what the edge hands the member over itself once it is ready (`linkReady`). */
16443
16716
  newest = null;
16444
16717
  /** The newest packet of the member's that came through a carrier, and the timestamp of the one a read last returned. */
@@ -16518,7 +16791,9 @@ var CarriedTransport = class {
16518
16791
  stamp(identity, records) {
16519
16792
  const now = BigInt(Date.now()) * 1000n;
16520
16793
  this.lastTimestamp = now > this.lastTimestamp ? now : this.lastTimestamp + 1n;
16521
- return createRelayPayload(identity, records, this.lastTimestamp);
16794
+ const payload = createRelayPayload(identity, records, this.lastTimestamp);
16795
+ this.dated.set(payload, this.lastTimestamp);
16796
+ return payload;
16522
16797
  }
16523
16798
  /**
16524
16799
  * The relays get the very packet members carried or the edge itself handed over, byte for byte, where the transport
@@ -16528,7 +16803,11 @@ var CarriedTransport = class {
16528
16803
  * its restart and then wait 40 s for (7 of 16 goodbyes on the admin in the member-back runs, 1 of 16 on dev, 2026-10-03).
16529
16804
  */
16530
16805
  toRelays(identity, records, payload, options) {
16531
- return this.inner.publishPayload ? this.inner.publishPayload(identity.pubKeyZ32, payload, options) : this.inner.publish(identity, records, options);
16806
+ const signedAt = this.dated.get(payload);
16807
+ return this.inner.publishPayload ? this.inner.publishPayload(identity.pubKeyZ32, payload, signedAt === void 0 ? options : {
16808
+ ...options,
16809
+ signedAt
16810
+ }) : this.inner.publish(identity, records, options);
16532
16811
  }
16533
16812
  /**
16534
16813
  * The packet the relays just took, handed to the member over the edge itself while it is up. An edge up reads nothing
@@ -16673,8 +16952,12 @@ var GROUP_FILE_LIMITS = {
16673
16952
  heardAhead: 256,
16674
16953
  /** Message ids in one `group-have`. */
16675
16954
  haveIds: 32,
16676
- /** A private group's `group-have` goes on an edge at most this often; a community's, from a member, every `communityHaveEveryMs`. */
16677
- haveEveryMs: 1e4,
16955
+ /**
16956
+ * `group-have` frames a private group's edge takes in `haveWindowMs`: what is to say is gathered for a second, then
16957
+ * goes. A community's, from a member, goes every `communityHaveEveryMs`.
16958
+ */
16959
+ haveFrames: 10,
16960
+ haveWindowMs: 1e4,
16678
16961
  communityHaveEveryMs: 6e4,
16679
16962
  /**
16680
16963
  * How long a want waits for an offer before the next holder is asked; a transfer that does not move for as long is
@@ -17078,6 +17361,14 @@ var PACKET_TRANSPORTS_FAST_ATTEMPTS = 3;
17078
17361
  var DHT_PIN_GRACE_MS = 1e4;
17079
17362
  /** Back after a restart (`resume`), dials wait this long at most for the native endpoint that carried the chat to start. */
17080
17363
  var RESUME_WAIT_MS = 1e4;
17364
+ /**
17365
+ * A dial of the contact's that meets such a session waits this long for that session to answer a ping. It answers when
17366
+ * the dials crossed (the contact holds this side's dial too); it does not when the contact's app ended since and is
17367
+ * back: its dial then takes over. Refused outright, that dial left the contact waiting for the dead session's end, 16.6
17368
+ * to 18.9 s (CLI daemons over HyperDHT, the contact killed 1 to 3 s after this side's knock, 5 of 5). A round trip is
17369
+ * 7 ms on loopback and under 0.5 s over a relayed Iroh.
17370
+ */
17371
+ var CROSSED_PROBE_MS = 1500;
17081
17372
  /** A WebRTC attempt under way, not yet open: what a member's packet saying it has no WebRTC ends (`peerPacketTransports`). */
17082
17373
  var WEBRTC_ATTEMPT = /* @__PURE__ */ new Set([
17083
17374
  "offering",
@@ -17199,6 +17490,24 @@ var HANDLED_WAIT_MS = 1e4;
17199
17490
  */
17200
17491
  var WAKE_LOST_MS = 1e4;
17201
17492
  /**
17493
+ * What files/3 may hold on a session's channel (WISP 501, Backpressure): a quarter of its send budget (native and iroh
17494
+ * channels throw past 120 KiB), or the high water mark of a WebRTC channel, which has none.
17495
+ */
17496
+ function filesCap(channel) {
17497
+ return channel.sendBudget ? channel.sendBudget / 4 : LIMITS.sendHighWaterMark;
17498
+ }
17499
+ /**
17500
+ * How long after files last wrote on a channel, or last waited for room on it, an app's data keeps its own quarter above
17501
+ * their line (WISP 1200 § In a chat): a file waiting for `pf-got` holds its share meanwhile.
17502
+ */
17503
+ var FILES_IN_USE_MS = 2e3;
17504
+ /**
17505
+ * How far an app's data may get ahead of a file's bytes while the file waits for room on the channel (WISP 1200 § In a
17506
+ * chat): a frame goes only while the app has sent less than this more than the file since. A small frame (a Chess move)
17507
+ * still goes at once; one streaming at its limit gets about half the link, and the file the other half.
17508
+ */
17509
+ var APP_AHEAD_OF_FILES = 16384;
17510
+ /**
17202
17511
  * A `paired-message` frame. The preview (`pv`) is left out when the frame would pass `MAX_PAIRED_MESSAGE_FRAME`
17203
17512
  * with it: the text matters, the card does not. A reply (`r`, WISP 401 § Replies) is a few hundred bytes at most
17204
17513
  * and always goes, and so does a forwarded message's hop count (`fw`, WISP 401 § Forwards) and a status card (`sc`,
@@ -17228,6 +17537,16 @@ function pairedMessageFrame(id, ts, m, preview, reply, forwarded, card) {
17228
17537
  });
17229
17538
  return withPreview.length <= 57344 ? withPreview : plain;
17230
17539
  }
17540
+ /** A text whose `paired-message` frame would pass what a session takes: it never arrives, and is refused at send. */
17541
+ var PAIRED_FRAME_TOO_LARGE = "Message too long once encoded. Shorten it or send it as a file.";
17542
+ /**
17543
+ * The `paired-message` frame of this text fits a session (`SESSION_FRAME_MAX`, UTF-16 units of the JSON). Within 16 KiB
17544
+ * of UTF-8 a text can still pass it: JSON writes each control character as six (`\u0001`). The preview is left out,
17545
+ * as the frame drops one that does not fit.
17546
+ */
17547
+ function pairedMessageFits(ts, m, reply, forwarded, card) {
17548
+ return pairedMessageFrame("A".repeat(22), ts, m, void 0, reply, forwarded, card).length <= SESSION_FRAME_MAX;
17549
+ }
17231
17550
  /**
17232
17551
  * A native descriptor from the contact's capability record against the one known (a session's, or an older record's).
17233
17552
  * The record carries only how to dial, never an address: the Iroh endpoint id and the relay it is homed on, the
@@ -17546,6 +17865,9 @@ var GhostLink = class {
17546
17865
  });
17547
17866
  options.events?.onPairingProgress?.(progress);
17548
17867
  }) : null;
17868
+ const invite = !!options.pairingProgress && !!options.pairing && !options.pairing.credentials.peerKey;
17869
+ let unanswered = options.dht?.state?.unanswered !== void 0;
17870
+ const backsOff = invite || unanswered;
17549
17871
  this.dht = options.params.profile && options.pairing && options.dht ? new DhtDelivery({
17550
17872
  params: options.params,
17551
17873
  mode: this.deliveryMode,
@@ -17555,6 +17877,7 @@ var GhostLink = class {
17555
17877
  save: options.dht.save,
17556
17878
  pollMs: options.dht.pollMs,
17557
17879
  firstControlAfterMs: options.dht.firstControlAfterMs,
17880
+ ...backsOff && { inviteMadeAt: options.madeAt ?? Date.now() },
17558
17881
  capsRev: options.dht.capsRev,
17559
17882
  peerCapsRev: options.dht.peerCapsRev,
17560
17883
  peerAcceptsText: options.dht.peerAcceptsText,
@@ -17570,6 +17893,7 @@ var GhostLink = class {
17570
17893
  },
17571
17894
  pin: async (key) => {
17572
17895
  await options.pairing.pinPeer(key, true);
17896
+ if (invite) this.session.lookNow();
17573
17897
  if (this.isDataLinkOpen || !this.tracker || this.tracker.done) return;
17574
17898
  if (this.deliveryMode === "dht") {
17575
17899
  this.tracker.onDht("chosen");
@@ -17612,6 +17936,9 @@ var GhostLink = class {
17612
17936
  },
17613
17937
  changed: (view) => {
17614
17938
  options.events?.onDhtDelivery?.(view);
17939
+ const stillUnanswered = this.dht.unanswered;
17940
+ if (unanswered && !stillUnanswered) this.session.lookNow();
17941
+ unanswered = stillUnanswered;
17615
17942
  if (view.inviteTaken) this.tracker?.failed("taken", false, INVITE_TAKEN);
17616
17943
  else this.tracker?.untaken();
17617
17944
  this.streamBlockChanged();
@@ -17640,6 +17967,7 @@ var GhostLink = class {
17640
17967
  lastSeenTimestamp: options.lastSeenTimestamp,
17641
17968
  pollIntervals: options.pollIntervals,
17642
17969
  awaitingSince: options.madeAt,
17970
+ ...backsOff && { invite: () => !options.pairing.credentials.peerKey || !!this.dht?.unanswered },
17643
17971
  getServices: options.getServices,
17644
17972
  firstPublish: options.firstPublish ?? (this.tracker && options.pairingProgress?.role === "joiner" && options.rtcAvailable !== false || this.resuming ? "after-first-poll" : "at-start"),
17645
17973
  events: {
@@ -18152,11 +18480,11 @@ var GhostLink = class {
18152
18480
  }
18153
18481
  start() {
18154
18482
  if (this.saidTransports) this.publishPacketTransports();
18155
- if (this.deliveryMode !== "dht") this.session.start();
18156
18483
  if (this.resuming && this.deliveryMode !== "dht") {
18157
18484
  this.session.expectPeer();
18158
18485
  this.resumeWaitUntil = Date.now() + RESUME_WAIT_MS;
18159
18486
  }
18487
+ if (this.deliveryMode !== "dht") this.session.start();
18160
18488
  if (this.tracker && !this.tracker.done && this.options.pairingProgress?.role === "joiner") {
18161
18489
  this.dht?.expect();
18162
18490
  this.dialEarly();
@@ -18278,6 +18606,7 @@ var GhostLink = class {
18278
18606
  disconnect() {
18279
18607
  this.closeRetired();
18280
18608
  this.dropCrossed();
18609
+ this.dropCrossedProbe();
18281
18610
  this.dropBlockedDial();
18282
18611
  this.switcher.stop();
18283
18612
  this.cancelCandidate();
@@ -19173,9 +19502,30 @@ var GhostLink = class {
19173
19502
  epoch,
19174
19503
  rest
19175
19504
  };
19505
+ if (!this.peerTransports.includes("webrtc/1")) {
19506
+ traceLink(this.myPubKeyZ32, "offer-unanswerable", { transports: rest });
19507
+ this.clearRace();
19508
+ this.raceTimer = setTimeout(() => {
19509
+ this.raceTimer = null;
19510
+ this.endUnanswerableOffer(epoch);
19511
+ }, 0);
19512
+ return;
19513
+ }
19176
19514
  traceLink(this.myPubKeyZ32, "race-standing", { transports: rest });
19177
19515
  this.scheduleRace(epoch, made.at);
19178
19516
  }
19517
+ /** The offer ends (`raceStandingOffer`), and with it going idle what is ranked after WebRTC is dialled (`dialAfterRtc`). */
19518
+ endUnanswerableOffer(epoch) {
19519
+ if (epoch !== this.connectionEpoch || this.channel || this.stopped || !this.offerOut || this.afterRtc?.epoch !== epoch) return;
19520
+ if (this.dialing) {
19521
+ this.raceTimer = setTimeout(() => {
19522
+ this.raceTimer = null;
19523
+ this.endUnanswerableOffer(epoch);
19524
+ }, RACE_RETRY_MS);
19525
+ return;
19526
+ }
19527
+ this.dataLink.close();
19528
+ }
19179
19529
  /**
19180
19530
  * Back after an absence, this side answers an offer the contact made while it was away (it dials whatever its key):
19181
19531
  * the contact looks for that answer at the pace of an offer out a while (every 8 s on the relays), and its attempt may
@@ -19284,6 +19634,18 @@ var GhostLink = class {
19284
19634
  if (!this.options.params.profile || this.options.packetTransports) return true;
19285
19635
  return this.transportOffer().some((t) => t === "webrtc/1" ? !this.peerTransports || this.peerTransports.includes(t) : this.canDial(t) && !!this.peerTransports?.includes(t));
19286
19636
  }
19637
+ /**
19638
+ * Neither side can dial the other until something changes, past a first pairing: what the contact runs is known, and
19639
+ * no transport both run has WebRTC on both sides or an endpoint up on both (this side's, and the one its record
19640
+ * describes). A chat that gave its listener up to another (`yieldEndpoint`), or whose contact did, is so. It dials
19641
+ * again when that changes (a listener taken back, `registerEndpoint`; the record describing one, `learnPeerTransports`),
19642
+ * and reads at its own pace meanwhile: looks every 20 s for a dial with nothing to try, and the contact's fast looks
19643
+ * for an offer that could not come, read its key 2.5x to 4x as often as the background pace, for good (bug hunt,
19644
+ * 2026-10-12). A first pairing keeps both: the records are what it is reading for.
19645
+ */
19646
+ nothingToDialEitherWay() {
19647
+ return !(!!this.tracker && !this.tracker.done && this.tracker.progress.stage !== "on-dht") && !!this.peerTransports && !this.anythingToDial();
19648
+ }
19287
19649
  clearRace() {
19288
19650
  if (this.raceTimer) clearTimeout(this.raceTimer);
19289
19651
  this.raceTimer = null;
@@ -19346,6 +19708,13 @@ var GhostLink = class {
19346
19708
  this.attemptEnded();
19347
19709
  this.rejectWaiters(new GhostlyHttpError("unreachable", "Could not connect to the peer"));
19348
19710
  }
19711
+ /**
19712
+ * This side does not dial on its own: the contact's app does (the lower key), and none of what makes this side dial
19713
+ * anyway holds (a first pairing's role, a restart, WebRTC set aside). Its own dial, chosen by hand, is not retried.
19714
+ */
19715
+ awaitsContactDial() {
19716
+ return !(!!this.tracker && !this.tracker.done && this.tracker.progress.stage !== "on-dht" ? this.options.pairingProgress?.role : void 0) && this.myPubKeyZ32 > this.options.params.peerPubKeyZ32 && !this.resuming && !this.dialsPastRtc();
19717
+ }
19349
19718
  /** A transport of the attempt under way did not connect. */
19350
19719
  attemptFailed(transport, error) {
19351
19720
  if (this.isDataLinkOpen) return;
@@ -19368,7 +19737,7 @@ var GhostLink = class {
19368
19737
  side: attempt.side,
19369
19738
  failed: attempt.failed,
19370
19739
  ...!attempt.failed.length && reason ? { reason } : {},
19371
- ...attempt.side === "dialled" ? { retryAt: Math.max(Date.now(), this.lastAutoConnectAt + this.dialWait()) } : {}
19740
+ ...attempt.side === "dialled" && !this.awaitsContactDial() ? { retryAt: Math.max(Date.now(), this.lastAutoConnectAt + this.dialWait()) } : {}
19372
19741
  };
19373
19742
  traceLink(this.myPubKeyZ32, "attempt-ended", {
19374
19743
  side: attempt.side,
@@ -19407,9 +19776,11 @@ var GhostLink = class {
19407
19776
  if (this.pairedPending.size >= 128) this.pairedPending.delete(this.pairedPending.keys().next().value);
19408
19777
  const id = stableId ?? toBase64Url(randomBytes(16));
19409
19778
  if (!/^[A-Za-z0-9_-]{22}$/.test(id)) return "Invalid message ID";
19779
+ const frame = pairedMessageFrame(id, timestamp, trimmed, preview, reply, forwarded, card);
19780
+ if (frame.length > 61440) return PAIRED_FRAME_TOO_LARGE;
19410
19781
  this.pairedPending.set(id, timestamp);
19411
19782
  try {
19412
- this.channel.send(pairedMessageFrame(id, timestamp, trimmed, preview, reply, forwarded, card));
19783
+ this.channel.send(frame);
19413
19784
  return null;
19414
19785
  } catch {
19415
19786
  this.pairedPending.delete(id);
@@ -19575,15 +19946,14 @@ var GhostLink = class {
19575
19946
  if (presence.lastPacketAt === this.departed.packet && Date.now() < this.departed.until) return;
19576
19947
  this.departed = null;
19577
19948
  }
19578
- const role = !!this.tracker && !this.tracker.done && this.tracker.progress.stage !== "on-dht" ? this.options.pairingProgress?.role : void 0;
19579
- if (role === "inviter") {
19949
+ if ((!!this.tracker && !this.tracker.done && this.tracker.progress.stage !== "on-dht" ? this.options.pairingProgress?.role : void 0) === "inviter") {
19580
19950
  this.peerSeenAt ||= Date.now();
19581
19951
  if (Date.now() - this.peerSeenAt < 2500 && !this.nothingToAwait()) {
19582
19952
  this.session.expectPeer();
19583
19953
  return;
19584
19954
  }
19585
- } else if (role !== "joiner" && this.myPubKeyZ32 > this.options.params.peerPubKeyZ32 && !this.resuming && !this.dialsPastRtc()) {
19586
- if (Date.now() - presenceSeenAt(presence) < 3e4 && presence.lastPacketAt !== this.offerAwaitedFor) {
19955
+ } else if (this.awaitsContactDial()) {
19956
+ if (Date.now() - presenceSeenAt(presence) < 3e4 && presence.lastPacketAt !== this.offerAwaitedFor && !this.nothingToDialEitherWay()) {
19587
19957
  this.offerAwaitedFor = presence.lastPacketAt;
19588
19958
  this.session.expectPeer();
19589
19959
  }
@@ -19661,6 +20031,7 @@ var GhostLink = class {
19661
20031
  this.retryLook = null;
19662
20032
  const from = this.lastAutoConnectAt;
19663
20033
  if (!from || this.stopped || this.leaving || this.streamBlocked || this.keyStopped || !this.options.autoConnect) return;
20034
+ if (this.nothingToDialEitherWay()) return;
19664
20035
  this.retryLook = setTimeout(() => {
19665
20036
  this.retryLook = null;
19666
20037
  if (this.lastAutoConnectAt !== from || this.stopped || this.leaving || this.yielding || this.streamBlocked || this.keyStopped || this.channel || this.dialing || this.dataLink.state !== "idle") return;
@@ -19920,6 +20291,7 @@ var GhostLink = class {
19920
20291
  if (this.options.callsSupport) offered.push(CALLS_CAPABILITY);
19921
20292
  if (this.options.servicesSupport) offered.push(SERVICES_CAPABILITY);
19922
20293
  if (this.options.largeFilesSupport) offered.push(FILES_CAPABILITY);
20294
+ if (this.options.largeFilesSupport && this.options.binaryFilesSupport && this.channel?.binary) offered.push(FILES_BINARY_CAPABILITY);
19923
20295
  if (this.options.typingSupport) offered.push(TYPING_CAPABILITY);
19924
20296
  if (this.options.reactionsSupport) offered.push(REACTIONS_CAPABILITY);
19925
20297
  if (this.options.editSupport) offered.push(EDIT_CAPABILITY);
@@ -19956,6 +20328,10 @@ var GhostLink = class {
19956
20328
  get supportsLargeFiles() {
19957
20329
  return this.isDataLinkOpen && this.sessionCapabilities.agreed("files/3");
19958
20330
  }
20331
+ /** Both sides offer `files/4` beside `files/3` on the open session: a file's bytes go as binary messages. */
20332
+ get supportsBinaryFiles() {
20333
+ return this.supportsLargeFiles && this.sessionCapabilities.agreed("files/4");
20334
+ }
19959
20335
  /** Both sides offer `typing/1` on the open session: typing can be said and shown. */
19960
20336
  get supportsTyping() {
19961
20337
  return this.isDataLinkOpen && this.sessionCapabilities.agreed("typing/1");
@@ -20119,21 +20495,33 @@ var GhostLink = class {
20119
20495
  }
20120
20496
  /**
20121
20497
  * One data frame of an app open on both sides, on the live session only. An error when it did not go: nothing is
20122
- * kept to send later.
20498
+ * kept to send later. While the channel already holds a quarter of what it may queue (its send budget, or the high
20499
+ * water mark of a WebRTC channel), the frame is refused with `too-fast`, so the chat's messages and pings never wait
20500
+ * behind an app's data on a link slower than it sends (WISP 1200 § In a chat). While a file goes, that quarter sits
20501
+ * above what files may hold (`filesWritable`): files keep the channel at their own line for the whole transfer. While
20502
+ * the file waits for room, the app gets no further than `APP_AHEAD_OF_FILES` ahead of it, so it cannot refill the
20503
+ * channel each time it drains to files' line and keep the file from moving.
20123
20504
  */
20124
20505
  sendAppData(app, data) {
20125
20506
  if (!this.apps.open.has(app)) return "not-open";
20126
20507
  if (appDataBytes(data) > 32768) return "too-large";
20127
20508
  if (!this.channel || !this.isDataLinkOpen) return "offline";
20128
20509
  if (!this.supportsApps) return "peer-closed";
20510
+ const use = this.filesUse?.channel === this.channel ? this.filesUse : null;
20511
+ const files = use && (use.waiting > 0 || Date.now() - use.at < FILES_IN_USE_MS) ? use : null;
20512
+ if (use && !files) use.appAhead = 0;
20513
+ if (this.channel.bufferedAmount >= (files ? filesCap(this.channel) : 0) + (this.channel.sendBudget ?? LIMITS.sendHighWaterMark) / 4) return "too-fast";
20514
+ if (files && files.waiting > 0 && files.appAhead >= APP_AHEAD_OF_FILES) return "too-fast";
20129
20515
  const refused = this.apps.canSend(app, data);
20130
20516
  if (refused) return refused;
20517
+ const frame = JSON.stringify(appDataFrame(app, data));
20131
20518
  try {
20132
- this.channel.send(JSON.stringify(appDataFrame(app, data)));
20133
- return null;
20519
+ this.channel.send(frame);
20134
20520
  } catch {
20135
20521
  return "offline";
20136
20522
  }
20523
+ if (files) files.appAhead = Math.min(files.appAhead + frame.length, APP_AHEAD_OF_FILES + APP_DATA_MAX_BYTES);
20524
+ return null;
20137
20525
  }
20138
20526
  /** What the contact had open is gone (`apps/1` is no longer agreed, or this link stopped): each app hears it now. */
20139
20527
  appsEnded() {
@@ -20176,19 +20564,64 @@ var GhostLink = class {
20176
20564
  }
20177
20565
  }
20178
20566
  /**
20567
+ * A file's bytes to the contact, on the open session: one binary message while both sides agreed `files/4` on it,
20568
+ * the `pf-data` frame with them as base64url text otherwise (WISP 501 § files/4). The form is chosen for each chunk,
20569
+ * so a transfer follows the session it is on. False when files/3 cannot flow now, or the channel has no room.
20570
+ */
20571
+ sendFileData(id, offset, data) {
20572
+ if (!this.supportsLargeFiles || !this.channel) return false;
20573
+ try {
20574
+ const frame = this.supportsBinaryFiles ? encodeFileData(id, offset, data) : JSON.stringify({
20575
+ t: "pf-data",
20576
+ id,
20577
+ offset,
20578
+ data: toBase64Url(data)
20579
+ });
20580
+ this.channel.send(frame);
20581
+ const use = this.filesUsing(this.channel);
20582
+ use.at = Date.now();
20583
+ use.appAhead = Math.max(0, use.appAhead - (typeof frame === "string" ? frame.length : frame.byteLength));
20584
+ return true;
20585
+ } catch {
20586
+ return false;
20587
+ }
20588
+ }
20589
+ /**
20590
+ * Files on a channel: when they last wrote on it, how many wait for room, and how many bytes an app's data has sent
20591
+ * on it beyond theirs since. An app's data gets room above theirs.
20592
+ */
20593
+ filesUse = null;
20594
+ filesUsing(channel) {
20595
+ if (this.filesUse?.channel !== channel) this.filesUse = {
20596
+ channel,
20597
+ at: 0,
20598
+ waiting: 0,
20599
+ appAhead: 0
20600
+ };
20601
+ return this.filesUse;
20602
+ }
20603
+ /**
20179
20604
  * Resolves once the open session can take another files/3 data frame; undefined when it can now. A channel with a send budget (native and
20180
20605
  * iroh ones throw past 120 KiB) gives files a quarter of it, so a ping, a message or any other frame still fits
20181
- * while a file goes; others keep under the high water mark, as files/2 does.
20606
+ * while a file goes; others keep under the high water mark, as files/2 does. An app's data gets a quarter above that
20607
+ * line meanwhile (`sendAppData`).
20182
20608
  */
20183
20609
  filesWritable() {
20184
20610
  const channel = this.channel;
20185
20611
  if (!channel) return;
20186
- const cap = channel.sendBudget ? channel.sendBudget / 4 : LIMITS.sendHighWaterMark;
20612
+ const cap = filesCap(channel);
20187
20613
  if (channel.bufferedAmount < cap) return;
20614
+ const use = this.filesUsing(channel);
20615
+ use.waiting++;
20188
20616
  return (async () => {
20189
- while (this.channel === channel && channel.bufferedAmount >= cap) {
20190
- await channel.drained().catch(() => {});
20191
- if (this.channel === channel && channel.bufferedAmount >= cap) await new Promise((resolve) => setTimeout(resolve, 20));
20617
+ try {
20618
+ while (this.channel === channel && channel.bufferedAmount >= cap) {
20619
+ await channel.drained().catch(() => {});
20620
+ if (this.channel === channel && channel.bufferedAmount >= cap) await new Promise((resolve) => setTimeout(resolve, 20));
20621
+ }
20622
+ } finally {
20623
+ use.waiting--;
20624
+ use.at = Date.now();
20192
20625
  }
20193
20626
  })();
20194
20627
  }
@@ -20486,13 +20919,20 @@ var GhostLink = class {
20486
20919
  }
20487
20920
  const held = this.channel;
20488
20921
  if (held && this.activeBinding && !this.dialedIn.has(held) && Date.now() - this.channelSince < 1e4 && this.myPubKeyZ32 < this.options.params.peerPubKeyZ32) {
20489
- traceLink(this.myPubKeyZ32, "dial-crossed", {
20490
- transport: binding.transport,
20491
- held: true
20492
- });
20493
- channel.close();
20922
+ if (this.paired?.state.status !== "ready") {
20923
+ traceLink(this.myPubKeyZ32, "dial-crossed", {
20924
+ transport: binding.transport,
20925
+ held: true
20926
+ });
20927
+ channel.close();
20928
+ return;
20929
+ }
20930
+ this.probeCrossed(held, channel, binding);
20494
20931
  return;
20495
20932
  }
20933
+ this.replaceWith(channel, binding);
20934
+ }
20935
+ replaceWith(channel, binding) {
20496
20936
  traceLink(this.myPubKeyZ32, "replacing", {
20497
20937
  transport: binding.transport,
20498
20938
  held: this.paired?.state.transport
@@ -20503,6 +20943,62 @@ var GhostLink = class {
20503
20943
  });
20504
20944
  }
20505
20945
  /**
20946
+ * The contact dialled in while this side's own fresh dial holds the chat (`CROSSED_FRESH_MS`): the dials crossed, or
20947
+ * the contact's app ended since that dial and is back. The held session is pinged; heard from within
20948
+ * `CROSSED_PROBE_MS`, the contact holds this side's dial too and its own closes. Not heard from, the contact's dial
20949
+ * takes over.
20950
+ */
20951
+ crossedProbe = null;
20952
+ /** Frames heard on the open session, counted from its start: whether the contact answered `probeCrossed`. */
20953
+ framesHeard = 0;
20954
+ probeCrossed(held, channel, binding) {
20955
+ this.dropCrossedProbe();
20956
+ traceLink(this.myPubKeyZ32, "dial-crossed", {
20957
+ transport: binding.transport,
20958
+ held: true,
20959
+ probe: true
20960
+ });
20961
+ const heard = this.framesHeard;
20962
+ try {
20963
+ this.sendPing(held, false);
20964
+ } catch {}
20965
+ const timer = setTimeout(() => {
20966
+ if (this.crossedProbe?.channel !== channel) return;
20967
+ this.crossedProbe = null;
20968
+ if (this.stopped || this.leaving || this.candidate) {
20969
+ channel.close();
20970
+ return;
20971
+ }
20972
+ if (!this.channel) {
20973
+ this.attach(channel, binding);
20974
+ return;
20975
+ }
20976
+ if (this.channel === held && this.framesHeard !== heard) {
20977
+ channel.close();
20978
+ return;
20979
+ }
20980
+ traceLink(this.myPubKeyZ32, "crossed-held-silent", { transport: binding.transport });
20981
+ this.replaceWith(channel, binding);
20982
+ }, CROSSED_PROBE_MS);
20983
+ channel.onClose = () => {
20984
+ if (this.crossedProbe?.channel === channel) {
20985
+ clearTimeout(timer);
20986
+ this.crossedProbe = null;
20987
+ }
20988
+ };
20989
+ this.crossedProbe = {
20990
+ channel,
20991
+ timer
20992
+ };
20993
+ }
20994
+ dropCrossedProbe() {
20995
+ const probe = this.crossedProbe;
20996
+ if (!probe) return;
20997
+ this.crossedProbe = null;
20998
+ clearTimeout(probe.timer);
20999
+ probe.channel.close();
21000
+ }
21001
+ /**
20506
21002
  * Connections that came in on this side's native endpoints. Their address was in the capability record, which before
20507
21003
  * the pin anyone holding a copy of the invite could read: a key other than the pinned one on such a connection proves
20508
21004
  * no more than on the DHT, so it closes that connection and nothing else.
@@ -20697,7 +21193,18 @@ var GhostLink = class {
20697
21193
  if (this.channel !== channel) return;
20698
21194
  this.unansweredPings = 0;
20699
21195
  this.heardFromPeer();
20700
- if (typeof data !== "string" || data.length > 61440) return;
21196
+ this.framesHeard++;
21197
+ if (typeof data !== "string") {
21198
+ const chunk = this.supportsBinaryFiles ? decodeFileData(data) : null;
21199
+ if (!chunk) return;
21200
+ this.activityAt = Date.now();
21201
+ await this.options.events?.onFilesFrame?.({
21202
+ t: "pf-data",
21203
+ ...chunk
21204
+ });
21205
+ return;
21206
+ }
21207
+ if (data.length > 61440) return;
20701
21208
  let frame;
20702
21209
  try {
20703
21210
  frame = JSON.parse(data);
@@ -25454,6 +25961,7 @@ function readFirstPlace(o, epoch) {
25454
25961
  /** One of my own frames, among mine: `e:n`. */
25455
25962
  var ownKey = (f) => `${f.e}:${f.n}`;
25456
25963
  /** What a frame weighs in a log, roughly: its boxes plus the fixed fields around them. */
25964
+ var frameKey$1 = (f) => `${f.s}:${f.e}:${f.n}`;
25457
25965
  /** What a kept frame counts for against `GROUP_LIMITS.relayBytes`. */
25458
25966
  var frameBytes = (f) => f.c.length + (f.m?.c.length ?? 0) + (f.r?.c.length ?? 0) + (f.sc?.c.length ?? 0) + (f.fl?.c.length ?? 0) + 400;
25459
25967
  /** Only the fields an edit frame has, as `clean` does for a message. */
@@ -25574,8 +26082,15 @@ var GroupSession = class {
25574
26082
  pendingMeta;
25575
26083
  /** While `handle` runs: the frames it took that a hub passes on (`handle`'s result). */
25576
26084
  passOn = null;
26085
+ /** The frames kept to hand on that the app holds with its messages, oldest first; undefined when the state holds them all. */
26086
+ onRows;
25577
26087
  /** Set while a save of what received messages changed is due (`persistSoon`). */
25578
26088
  saveTimer;
26089
+ /**
26090
+ * The frames of members who left that each member was handed (`frameKey`), so an app that does not take them (from
26091
+ * before revision 2026-10-13, and never marks them seen) is not handed them again at every sync. In memory only.
26092
+ */
26093
+ leaversHanded = /* @__PURE__ */ new Map();
25579
26094
  /** Syncs answered per member, a few a minute (`GROUP_LIMITS.syncAnswers`). In memory only. */
25580
26095
  syncsAnswered = /* @__PURE__ */ new Map();
25581
26096
  /** Catch-up answers still going out, a slice at a time (`handOut`). */
@@ -25588,10 +26103,21 @@ var GroupSession = class {
25588
26103
  provisional;
25589
26104
  /** When my chain last moved (a commit made or applied), by `hooks.clock`: frames are said again once it has settled. */
25590
26105
  chainMovedAt;
25591
- constructor(state, hooks) {
26106
+ /**
26107
+ * `onRows`: the frames the app holds with the messages it stored (`GroupIncomingMessage.frame`), oldest first. Given
26108
+ * (empty at first), the app is where frames are kept: the saved state carries none of them, and a message received
26109
+ * no longer writes them all again. Absent: they are kept in the state, as before.
26110
+ */
26111
+ constructor(state, hooks, onRows) {
25592
26112
  this.hooks = hooks;
25593
26113
  this.state = state;
25594
26114
  this.identity = identityFromSeedB64(state.seedB64);
26115
+ if (onRows) {
26116
+ const held = new Set(onRows.map(frameKey$1));
26117
+ if (state.relay?.length) state.relay = state.relay.filter((f) => !held.has(frameKey$1(f)));
26118
+ this.onRows = state.status === "active" ? onRows.filter((f) => this.handsOn(f.s, f.e)) : [];
26119
+ this.trimRelay();
26120
+ }
25595
26121
  this.answers = new CatchUpAnswers({
25596
26122
  send: (to, frame) => hooks.send(to, frame),
25597
26123
  ...hooks.handled && { handled: (to) => hooks.handled(to) },
@@ -26003,9 +26529,13 @@ var GroupSession = class {
26003
26529
  this.hooks.changed();
26004
26530
  return { secret };
26005
26531
  }
26006
- /** Someone out of the roster is not handed on any more, whatever they sent while in it (WISP 902 § Catch-up). */
26532
+ /**
26533
+ * Someone removed is not handed on any more, whatever they sent while in the roster; one who left still is, for the
26534
+ * epochs before its leave (WISP 902 § Catch-up).
26535
+ */
26007
26536
  pruneRelay() {
26008
- if (this.state.relay?.length) this.state.relay = this.state.relay.filter((f) => rosterHas(this.roster, f.s));
26537
+ if (this.state.relay?.length) this.state.relay = this.state.relay.filter((f) => this.handsOn(f.s, f.e));
26538
+ if (this.onRows?.length) this.onRows = this.onRows.filter((f) => this.handsOn(f.s, f.e));
26009
26539
  if (this.state.relayEdits?.length) this.state.relayEdits = this.state.relayEdits.filter((f) => rosterHas(this.roster, f.s));
26010
26540
  }
26011
26541
  /** Another member's edit, kept to hand on: the latest per message; the oldest go once the log is full. False: one as new was kept. */
@@ -26021,10 +26551,21 @@ var GroupSession = class {
26021
26551
  }
26022
26552
  /** A frame of another member, kept to hand on; the oldest go once the log is full. */
26023
26553
  keep(frame) {
26024
- const log = this.state.relay ??= [];
26025
- log.push(frame);
26026
- let bytes = log.reduce((sum, f) => sum + frameBytes(f), 0);
26027
- while (log.length > GROUP_LIMITS.relay || bytes > GROUP_LIMITS.relayBytes) bytes -= frameBytes(log.shift());
26554
+ if (this.onRows) {
26555
+ const key = frameKey$1(frame);
26556
+ if (this.onRows.some((f) => frameKey$1(f) === key)) return;
26557
+ this.onRows.push(frame);
26558
+ } else (this.state.relay ??= []).push(frame);
26559
+ this.trimRelay();
26560
+ }
26561
+ /** The frames kept to hand on, oldest first: the state's, then the ones the app holds with its messages. */
26562
+ kept() {
26563
+ return this.onRows ? [...this.state.relay ?? [], ...this.onRows] : this.state.relay ?? [];
26564
+ }
26565
+ trimRelay() {
26566
+ const old = this.state.relay ?? [], rows = this.onRows ?? [];
26567
+ let bytes = old.reduce((sum, f) => sum + frameBytes(f), 0) + rows.reduce((sum, f) => sum + frameBytes(f), 0);
26568
+ while (old.length + rows.length > GROUP_LIMITS.relay || bytes > GROUP_LIMITS.relayBytes) bytes -= frameBytes((old.length ? old : rows).shift());
26028
26569
  }
26029
26570
  pruneSecrets() {
26030
26571
  const epochs = Object.keys(this.state.secrets).map(Number).sort((a, b) => a - b);
@@ -26060,6 +26601,7 @@ var GroupSession = class {
26060
26601
  this.state.secrets = {};
26061
26602
  this.state.sent = [];
26062
26603
  this.state.relay = [];
26604
+ if (this.onRows) this.onRows = [];
26063
26605
  this.state.relayEdits = [];
26064
26606
  this.state.alone = [];
26065
26607
  this.waiting = [];
@@ -26548,7 +27090,8 @@ var GroupSession = class {
26548
27090
  }
26549
27091
  /**
26550
27092
  * A message, from its author's edge or handed on by another member. Handed on, it is taken only while both are in
26551
- * the roster (someone removed is neither heard from nor handed on, #300), and its boxes only with the author's `xs`.
27093
+ * the roster (someone removed is neither heard from nor handed on, #300), or from a member in it when its author left
27094
+ * after it (`handsOn`), and its boxes only with the author's `xs`.
26552
27095
  */
26553
27096
  async receiveMessage(from, raw) {
26554
27097
  if (!isMessageFrame$1(raw)) return;
@@ -26557,7 +27100,7 @@ var GroupSession = class {
26557
27100
  return;
26558
27101
  }
26559
27102
  const relayed = raw.s !== from;
26560
- if (relayed && (!rosterHas(this.roster, from) || !rosterHas(this.roster, raw.s))) return;
27103
+ if (relayed && (!rosterHas(this.roster, from) || !this.handsOn(raw.s, raw.e))) return;
26561
27104
  if (!verify(fromBase64Url(raw.sig), messageSigned$1(raw), publicKeyFromZ32(raw.s))) return;
26562
27105
  if (raw.e > this.epoch) {
26563
27106
  this.park(from, raw);
@@ -26595,10 +27138,11 @@ var GroupSession = class {
26595
27138
  n: first.n
26596
27139
  };
26597
27140
  if (firstFrame && rosterHas(this.state.chain[first.e]?.m ?? [], this.myKey) && this.isDuplicate(firstFrame)) {
27141
+ await this.hooks.kept?.(groupMessageId(raw.s, first.e, first.n), clean(raw));
26598
27142
  this.markSeen(raw);
26599
27143
  this.keep(clean(raw));
26600
27144
  this.persistSoon();
26601
- if (rosterHas(this.roster, raw.s)) this.took(clean(raw));
27145
+ if (this.handsOn(raw.s, raw.e)) this.took(clean(raw));
26602
27146
  return;
26603
27147
  }
26604
27148
  const id = first && !this.provisional.has(own) ? groupMessageId(raw.s, first.e, first.n) : own;
@@ -26635,7 +27179,7 @@ var GroupSession = class {
26635
27179
  if (firstFrame && rosterHas(this.state.chain[firstFrame.e]?.m ?? [], this.myKey)) this.markSeen(firstFrame);
26636
27180
  this.keep(clean(raw));
26637
27181
  this.persistSoon();
26638
- if (rosterHas(this.roster, raw.s)) this.took(clean(raw));
27182
+ if (this.handsOn(raw.s, raw.e)) this.took(clean(raw));
26639
27183
  }
26640
27184
  /**
26641
27185
  * An edit of a member's own message, from its author's edge or handed on by another member: believed only while its
@@ -26669,7 +27213,7 @@ var GroupSession = class {
26669
27213
  const k = validMentions(body.m, body.text, rosterAdmin(commit.m) === raw.s && this.admin === raw.s);
26670
27214
  const sc = body.sc === void 0 ? void 0 : readStatusCard(body.sc);
26671
27215
  if (!validEditNumber(raw.v) && !sc) return;
26672
- const copy = (this.state.relay ?? []).find((f) => f.s === raw.s && f.e === raw.e && f.n === raw.n);
27216
+ const copy = this.kept().find((f) => f.s === raw.s && f.e === raw.e && f.n === raw.n);
26673
27217
  const first = copy && readFirstPlace(copy.o, copy.e);
26674
27218
  await this.hooks.edit?.({
26675
27219
  id: first ? groupMessageId(raw.s, first.e, first.n) : groupMessageId(raw.s, raw.e, raw.n),
@@ -26830,6 +27374,16 @@ var GroupSession = class {
26830
27374
  await this.receiveCommit(from, next);
26831
27375
  }
26832
27376
  }
27377
+ /**
27378
+ * Whether a frame of `author` under `epoch` is taken from another member, kept and handed on: while its author is in
27379
+ * the roster, or when it left (its removal marked `left`, and it is not back in) and the frame is from before that.
27380
+ */
27381
+ handsOn(author, epoch) {
27382
+ if (rosterHas(this.roster, author)) return true;
27383
+ const chain = this.state.chain;
27384
+ for (let e = chain.length - 1; e > 0; e--) if (chain[e].k === "remove" && chain[e].s === author) return !!this.state.leftMarks?.[e] && epoch < e;
27385
+ return false;
27386
+ }
26833
27387
  /** Keeps the admin's mark on a commit that took out a member who left (`GroupCommitFrame.left`) when it holds; true when new. */
26834
27388
  takeLeftMark(commit, mark) {
26835
27389
  if (commit.k !== "remove" || typeof mark !== "string" || mark.length !== 86 || !B64$1.test(mark) || this.state.leftMarks?.[commit.e]) return false;
@@ -26924,7 +27478,15 @@ var GroupSession = class {
26924
27478
  });
26925
27479
  const lacks = missingIn(frame, from);
26926
27480
  const asked = new Set(Array.isArray(frame.ask) ? frame.ask.filter((k) => typeof k === "string" && k !== from && k !== this.myKey && rosterHas(this.roster, k)) : []);
26927
- const kept = asked.size ? (this.state.relay ?? []).filter((f) => asked.has(f.s) && this.state.chain[f.e] && rosterHas(this.state.chain[f.e].m, from) && lacks(f)) : [];
27481
+ const handed = this.leaversHanded.get(from) ?? /* @__PURE__ */ new Set();
27482
+ const kept = !asked.size && !this.state.leftMarks ? [] : this.kept().filter((f) => {
27483
+ if (!this.state.chain[f.e] || !rosterHas(this.state.chain[f.e].m, from) || !lacks(f)) return false;
27484
+ if (asked.has(f.s)) return true;
27485
+ if (rosterHas(this.roster, f.s) || handed.has(frameKey$1(f)) || !this.handsOn(f.s, f.e)) return false;
27486
+ handed.add(frameKey$1(f));
27487
+ return true;
27488
+ });
27489
+ if (handed.size) this.leaversHanded.set(from, handed);
26928
27490
  answer.push(...asWritten(this.ownLacked(from, frame), kept));
26929
27491
  for (const author of asked) {
26930
27492
  const edits = (this.state.relayEdits ?? []).filter((f) => f.s === author && this.state.chain[f.e] && rosterHas(this.state.chain[f.e].m, from));
@@ -27320,6 +27882,8 @@ var COMMUNITY_LIMITS = {
27320
27882
  /** Commits in one welcome or chain piece. */
27321
27883
  chainPiece: 100,
27322
27884
  pendingLeaves: 64,
27885
+ /** Leaves whose mark (`group-left`) is kept, and the epochs one names. */
27886
+ leftMarks: 64,
27323
27887
  /**
27324
27888
  * Syncs of one member answered in `syncWindowMs`: each answer may carry the chain's tail, secrets and the whole
27325
27889
  * store. A member catching up asks every five seconds at most, and every member syncs every thirty; one asking more
@@ -27436,6 +28000,14 @@ var leaveStatement = (g, s) => utf8Encode(JSON.stringify([
27436
28000
  g,
27437
28001
  s
27438
28002
  ]));
28003
+ /** What the member who committed a leave (`h`) had heard of the leaver then: `e:h16` → its highest number there. */
28004
+ var leftStatement = (g, h, hi) => utf8Encode(JSON.stringify([
28005
+ "ghostly-group/2 left",
28006
+ g,
28007
+ h,
28008
+ Object.entries(hi).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
28009
+ ]));
28010
+ var LEFT_EPOCH = /^\d{1,15}:[a-f0-9]{16}$/;
27439
28011
  /** The secret of an epoch that adds, re-roles or re-links: from the previous one and the commit. */
27440
28012
  function derivedSecret(previous, untaggedHash) {
27441
28013
  return hkdf(sha256, previous, utf8Encode(untaggedHash), utf8Encode("ghostly-group/2 derived"), 32);
@@ -27899,13 +28471,58 @@ var CommunitySession = class {
27899
28471
  return -1;
27900
28472
  }
27901
28473
  /**
27902
- * Someone the chain removed (not one who left) and who is not back in: nothing it signs is heard any more, even for
27903
- * an epoch it was in. What one who left wrote while in the group is still taken and handed on, for the epochs it was
27904
- * in (WISP 903 § Leaving and removal): its goodbye reaches the members who were away.
28474
+ * Whether a frame of someone the chain took out is still heard. A member removed is heard no more, even for an epoch
28475
+ * it was in. What one who left wrote while in the group is taken and handed on, its goodbye included, up to what the
28476
+ * member who committed the leave had heard of it then (its `group-left` mark), never past it: the leaver still holds
28477
+ * those epochs' secrets, and anything it signs later is not heard (WISP 903 § Leaving and removal). No mark here
28478
+ * (one not come yet, or a committer's app before it) and nothing of it is heard.
27905
28479
  */
27906
- wasExpelled(key) {
28480
+ heardAfterOut(f) {
28481
+ const i = this.outIndex(f.s);
28482
+ if (i < 0) return true;
28483
+ const commit = this.state.chain[i];
28484
+ if (commit.k !== "leave") return false;
28485
+ const high = this.state.left?.[communityCommitHash(commit)]?.hi[seenKey(f.e, f.h)];
28486
+ return high !== void 0 && f.n <= high;
28487
+ }
28488
+ /** The mark of a leave on my branch, as a frame to hand on (see `heardAfterOut`). */
28489
+ leftFrame(key) {
27907
28490
  const i = this.outIndex(key);
27908
- return i >= 0 && this.state.chain[i].k === "remove";
28491
+ if (i < 0 || this.state.chain[i].k !== "leave") return void 0;
28492
+ const h = communityCommitHash(this.state.chain[i]), mark = this.state.left?.[h];
28493
+ return mark && {
28494
+ t: "group-left",
28495
+ v: 2,
28496
+ g: this.id,
28497
+ h,
28498
+ hi: mark.hi,
28499
+ sig: mark.sig
28500
+ };
28501
+ }
28502
+ keepLeft(h, mark) {
28503
+ const left = { ...this.state.left ?? {} };
28504
+ delete left[h];
28505
+ left[h] = mark;
28506
+ const keys = Object.keys(left);
28507
+ for (const old of keys.slice(0, Math.max(0, keys.length - COMMUNITY_LIMITS.leftMarks))) delete left[old];
28508
+ this.state.left = left;
28509
+ }
28510
+ /** A leave's mark, from the member who committed it: kept when it is that member's, for a leave I hold. */
28511
+ receiveLeft(frame) {
28512
+ const { h, hi, sig } = frame;
28513
+ if (typeof h !== "string" || !HASH.test(h) || typeof sig !== "string" || !SIG.test(sig) || !isObject$2(hi)) return false;
28514
+ const entries = Object.entries(hi);
28515
+ if (entries.length > COMMUNITY_LIMITS.leftMarks || entries.some(([k, n]) => !LEFT_EPOCH.test(k) || !Number.isSafeInteger(n) || n < 0)) return false;
28516
+ const commit = this.known.get(h);
28517
+ if (!commit || commit.k !== "leave" || this.state.left?.[h]?.sig === sig) return false;
28518
+ const marks = hi;
28519
+ if (!verify(fromBase64Url(sig), leftStatement(this.id, h, marks), publicKeyFromZ32(commit.by))) return false;
28520
+ this.keepLeft(h, {
28521
+ hi: marks,
28522
+ sig
28523
+ });
28524
+ this.persistSoon();
28525
+ return true;
27909
28526
  }
27910
28527
  /**
27911
28528
  * What someone the chain took out is told when it comes back (WISP 903 § Leaving and removal): the commits from where
@@ -28507,10 +29124,11 @@ var CommunitySession = class {
28507
29124
  continue;
28508
29125
  }
28509
29126
  try {
28510
- await this.commit("leave", {
29127
+ const { hash } = await this.commit("leave", {
28511
29128
  s: request.s,
28512
29129
  ls: request.ls
28513
29130
  }, now);
29131
+ this.markLeft(hash, request.s);
28514
29132
  done++;
28515
29133
  } catch {}
28516
29134
  this.state.pendingLeaves = this.state.pendingLeaves.filter((r) => r !== request);
@@ -28519,6 +29137,25 @@ var CommunitySession = class {
28519
29137
  return done;
28520
29138
  });
28521
29139
  }
29140
+ /** Signs and says how far I had heard a member whose leave I just committed (`group-left`). */
29141
+ markLeft(h, leaver) {
29142
+ const hi = {};
29143
+ const heard = Object.entries(this.state.seen[leaver] ?? {}).filter(([k]) => LEFT_EPOCH.test(k)).slice(-COMMUNITY_LIMITS.leftMarks);
29144
+ for (const [k, entry] of heard) hi[k] = entry.high;
29145
+ const sig = toBase64Url(sign(leftStatement(this.id, h, hi), this.identity.seed));
29146
+ this.keepLeft(h, {
29147
+ hi,
29148
+ sig
29149
+ });
29150
+ this.hooks.broadcast({
29151
+ t: "group-left",
29152
+ v: 2,
29153
+ g: this.id,
29154
+ h,
29155
+ hi,
29156
+ sig
29157
+ });
29158
+ }
28522
29159
  out(status, reason) {
28523
29160
  this.state.status = status;
28524
29161
  this.state.statusReason = reason;
@@ -28780,8 +29417,13 @@ var CommunitySession = class {
28780
29417
  messageIdOf(f) {
28781
29418
  return f.s === this.myKey && this.resealedFrom.get(frameKey(f)) || communityMessageId(f.s, f.e, f.h, f.n);
28782
29419
  }
28783
- /** Where I am and what I have, for the member at the other end of an edge that just opened. */
28784
- syncFrame() {
29420
+ /**
29421
+ * Where I am and what I have, for the member at the other end of an edge that just opened. `to`: that member, now
29422
+ * asked: my answer to its own sync asks nothing more for a while. A second sync went, and its answer took the place of
29423
+ * what was left of the first from its start: a member back after 256 messages was handed 288 (2026-10-14).
29424
+ */
29425
+ syncFrame(to) {
29426
+ if (to) this.asked.set(to, this.hooks.clock?.() ?? Date.now());
28785
29427
  const have = {};
28786
29428
  const recent = new Set(this.state.store.map((f) => seenKey(f.e, f.h)));
28787
29429
  for (const [sender, byKey] of Object.entries(this.state.seen)) for (const [key, entry] of Object.entries(byKey)) {
@@ -28842,6 +29484,7 @@ var CommunitySession = class {
28842
29484
  return false;
28843
29485
  case "group-meta": return this.receiveMeta(from, raw);
28844
29486
  case "group-leave": return this.receiveLeave(frame);
29487
+ case "group-left": return this.receiveLeft(frame);
28845
29488
  case "group-entry": {
28846
29489
  if (frame.to !== this.myKey || !isSealed(frame.s) || frame.x !== this.currentEntryKey()) return false;
28847
29490
  const seed = openSecret(this.identity.seed, this.myKey, frame.s, entryAad(this.id, this.myKey));
@@ -28870,7 +29513,7 @@ var CommunitySession = class {
28870
29513
  return false;
28871
29514
  }
28872
29515
  if (!verify(fromBase64Url(raw.sig), messageSigned(raw), publicKeyFromZ32(raw.s))) return false;
28873
- if (this.wasExpelled(raw.s)) return false;
29516
+ if (!this.heardAfterOut(raw)) return false;
28874
29517
  const found = this.commitByShort(raw.e, raw.h);
28875
29518
  if (!found) {
28876
29519
  this.park(from, raw);
@@ -29110,16 +29753,22 @@ var CommunitySession = class {
29110
29753
  const have = frame.have && typeof frame.have === "object" ? frame.have : {};
29111
29754
  const handed = /* @__PURE__ */ new Set();
29112
29755
  const unheard = new Set(this.state.unheard ?? []);
29756
+ const marked = /* @__PURE__ */ new Set();
29113
29757
  for (const stored of this.state.store) {
29114
29758
  if (stored.s === from) continue;
29115
29759
  const high = have[stored.s]?.[seenKey(stored.e, stored.h)];
29116
29760
  if (Number.isSafeInteger(high) && high >= stored.n) continue;
29117
- if (this.wasExpelled(stored.s)) continue;
29761
+ if (!this.heardAfterOut(stored)) continue;
29118
29762
  const found = this.commitByShort(stored.e, stored.h);
29119
29763
  if (!found) continue;
29120
29764
  const reads = rosterHas(this.rosterAt(found.hash) ?? [], from), mine = stored.s === this.myKey && unheard.has(frameKey(stored));
29121
29765
  if (!reads && !(mine && rosterHas(this.roster, from))) continue;
29122
29766
  if (!this.mainIndex.has(found.hash)) this.handSide(from, found.hash, handed, answer);
29767
+ if (!rosterHas(this.roster, stored.s) && !marked.has(stored.s)) {
29768
+ marked.add(stored.s);
29769
+ const left = this.leftFrame(stored.s);
29770
+ if (left) answer.push(left);
29771
+ }
29123
29772
  answer.push(stored);
29124
29773
  if (mine && reads) unheard.delete(frameKey(stored));
29125
29774
  }
@@ -29781,8 +30430,63 @@ function appDigest(manifestBytes) {
29781
30430
  return toBase64Url(sha256(manifestBytes));
29782
30431
  }
29783
30432
  var u32 = (b, at) => (b[at] << 24 >>> 0) + (b[at + 1] << 16) + (b[at + 2] << 8) + b[at + 3];
29784
- /** Reads and checks a bundle (the order of the checks is part of the format: see above). */
29785
- function readAppBundle(bytes) {
30433
+ /**
30434
+ * Lets the thread go for one turn: what waits (input, drawing, timers, port messages, IndexedDB answers) runs, then the
30435
+ * caller goes on. A message to itself, queued behind the page's other tasks. Never `scheduler.yield()`: in Chromium its
30436
+ * continuation outranks every normal task, so a paced read of 4096 revocations held the page's timers, its apps' broker
30437
+ * ports and IndexedDB for the whole 1.8 s. Never `setTimeout(0)` but as the last resort: from the fifth nested timer on
30438
+ * the browser waits at least 4 ms (HTML's clamp), which made that read take 60% longer than the work, and a hidden tab
30439
+ * runs timers once a second.
30440
+ */
30441
+ function yieldThread() {
30442
+ if (typeof MessageChannel === "function") return new Promise((resolve) => {
30443
+ const channel = new MessageChannel();
30444
+ channel.port1.onmessage = () => {
30445
+ channel.port1.close();
30446
+ resolve();
30447
+ };
30448
+ channel.port2.postMessage(null);
30449
+ });
30450
+ return new Promise((resolve) => setTimeout(resolve, 0));
30451
+ }
30452
+ /**
30453
+ * A pause for a loop of short steps: `await pause()` after each step lets the thread go once `sliceMs` of work has
30454
+ * run since the last time, and answers at once otherwise.
30455
+ */
30456
+ function pacer(sliceMs) {
30457
+ let since = performance.now();
30458
+ return () => {
30459
+ if (performance.now() - since < sliceMs) return void 0;
30460
+ return yieldThread().then(() => {
30461
+ since = performance.now();
30462
+ });
30463
+ };
30464
+ }
30465
+ /** `sha256Paced` hashes up to this much at once: under a millisecond of the thread. */
30466
+ var HASH_AT_ONCE = 65536;
30467
+ /** Without `crypto.subtle`, `sha256Paced` hashes this much (a few ms), then lets the thread go. */
30468
+ var HASH_SLICE = 262144;
30469
+ /** `readAppBundleAsync` hashes files for this long at most before it lets the thread go, however small each file. */
30470
+ var HASH_SLICE_MS = 5;
30471
+ /**
30472
+ * SHA-256 of bytes that may be a whole bundle, for a client whose engine shares its page's thread: `sha256` in
30473
+ * JavaScript takes about 12 ms per MiB on a desktop CPU, so 16 MiB in one run is a page that draws nothing and takes no
30474
+ * input for 200 ms. With `crypto.subtle` the bytes are hashed off the thread; without it (a page not served over
30475
+ * HTTPS), a slice at a time with a pause between slices. The bytes must not change until it answers.
30476
+ */
30477
+ async function sha256Paced(bytes) {
30478
+ if (bytes.length <= HASH_AT_ONCE) return sha256(bytes);
30479
+ const subtle = globalThis.crypto?.subtle;
30480
+ const away = subtle ? await subtle.digest("SHA-256", bytes).catch(() => null) : null;
30481
+ if (away) return new Uint8Array(away);
30482
+ const hash = sha256.create();
30483
+ for (let at = 0; at < bytes.length; at += HASH_SLICE) {
30484
+ if (at) await yieldThread();
30485
+ hash.update(bytes.subarray(at, at + HASH_SLICE));
30486
+ }
30487
+ return hash.digest();
30488
+ }
30489
+ function readAppBundleHead(bytes) {
29786
30490
  if (bytes.length > APP_BUNDLE_LIMITS.bundleBytes) return {
29787
30491
  ok: false,
29788
30492
  reason: "too-large"
@@ -29841,17 +30545,18 @@ function readAppBundle(bytes) {
29841
30545
  };
29842
30546
  const signed = verifyAppSignature(APP_PREFIXES.app, manifestBytes, statement.signature, manifest.publisher);
29843
30547
  if (!signed.ok) return signed;
29844
- const files = /* @__PURE__ */ new Map();
29845
- for (const f of manifest.files) {
29846
- const content = bytes.subarray(at, at + f.size);
29847
- at += f.size;
29848
- if (toBase64Url(sha256(content)) !== f.sha256) return {
29849
- ok: false,
29850
- reason: "file-hash",
29851
- detail: f.path
29852
- };
29853
- files.set(f.path, content);
29854
- }
30548
+ return {
30549
+ ok: true,
30550
+ head: {
30551
+ manifest,
30552
+ manifestBytes,
30553
+ signature: statement.signature,
30554
+ filesAt: at
30555
+ }
30556
+ };
30557
+ }
30558
+ /** The last check, once every file's hash held: the icon. */
30559
+ function closeAppBundle({ manifest, manifestBytes, signature }, files) {
29855
30560
  const icon = files.get(APP_ICON_PATH);
29856
30561
  if (icon) {
29857
30562
  const size = imageSize(icon);
@@ -29872,11 +30577,54 @@ function readAppBundle(bytes) {
29872
30577
  manifest,
29873
30578
  manifestBytes,
29874
30579
  digest: appDigest(manifestBytes),
29875
- signature: statement.signature,
30580
+ signature,
29876
30581
  files
29877
30582
  }
29878
30583
  };
29879
30584
  }
30585
+ /** Reads and checks a bundle (the order of the checks is part of the format: see above). */
30586
+ function readAppBundle(bytes) {
30587
+ const read = readAppBundleHead(bytes);
30588
+ if (!read.ok) return read;
30589
+ const files = /* @__PURE__ */ new Map();
30590
+ let at = read.head.filesAt;
30591
+ for (const f of read.head.manifest.files) {
30592
+ const content = bytes.subarray(at, at + f.size);
30593
+ at += f.size;
30594
+ if (toBase64Url(sha256(content)) !== f.sha256) return {
30595
+ ok: false,
30596
+ reason: "file-hash",
30597
+ detail: f.path
30598
+ };
30599
+ files.set(f.path, content);
30600
+ }
30601
+ return closeAppBundle(read.head, files);
30602
+ }
30603
+ /**
30604
+ * `readAppBundle` for a client whose engine shares its page's thread: the same checks in the same order, with the same
30605
+ * refusals, and each file hashed by `sha256Paced`, one after the other, with the thread let go between files every
30606
+ * `HASH_SLICE_MS` (64 files of 64 KiB, each hashed at once, held it for 50 ms). The bytes must not change until it
30607
+ * answers.
30608
+ */
30609
+ async function readAppBundleAsync(bytes) {
30610
+ const read = readAppBundleHead(bytes);
30611
+ if (!read.ok) return read;
30612
+ const files = /* @__PURE__ */ new Map();
30613
+ const pause = pacer(HASH_SLICE_MS);
30614
+ let at = read.head.filesAt;
30615
+ for (const f of read.head.manifest.files) {
30616
+ const content = bytes.subarray(at, at + f.size);
30617
+ at += f.size;
30618
+ if (toBase64Url(await sha256Paced(content)) !== f.sha256) return {
30619
+ ok: false,
30620
+ reason: "file-hash",
30621
+ detail: f.path
30622
+ };
30623
+ files.set(f.path, content);
30624
+ await pause();
30625
+ }
30626
+ return closeAppBundle(read.head, files);
30627
+ }
29880
30628
  /** Puts a bundle's parts together, checking nothing: for builders, and for tests that need a broken bundle. */
29881
30629
  function assembleAppBundle(manifestBytes, statementBytes, files) {
29882
30630
  const total = MAGIC.length + 8 + manifestBytes.length + statementBytes.length + files.reduce((s, f) => s + f.length, 0);
@@ -30238,6 +30986,6 @@ function appStoreDecision(held, read) {
30238
30986
  return read.digest === held.digest ? "same" : "equivocation";
30239
30987
  }
30240
30988
  //#endregion
30241
- export { parseCommunityEdit as $, receivedTimestamp as $a, identityFromSeed as $c, DHT_FILE_TOO_LARGE as $i, readGroupHave as $n, HANDOFF_PAKE as $o, vapidKeysMatch as $r, verifyEnrollProof as $s, signDidDhtPacket as $t, encodeCommunityLink as A, reactionEmoji as Aa, signTurnPacket as Ac, DHT_TEXT_CAPABILITY as Ai, resolveLightningDestination as An, webLocalFetch as Ao, FILE_LIMITS as Ar, readHandoffTurnFrame as As, DidError as At, GroupWakeLimiter as B, PR_CHECKS as Ba, normalizeRelayUrl as Bc, identityRevocationValue as Bi, INVITE_TAKEN as Bn, extractParamsFromSdp as Bo, mayPin as Br, ENROLL_DONE as Bs, didWebHost as Bt, openGroupTyping as C, readForwarded as Ca, classifyTurnRead as Cc, normalizeDomain as Ci, normalizePubkyKey as Cn, DHT_POLL_INTERVALS as Co, pickHubs as Cr, newHandoffId as Cs, parseAtprotoDidDocument as Ct, GROUP_READ_NOTE_COMMUNITY as D, ReactionWindow as Da, firstDeviceSetSecret as Dc, txtValue as Di, homeserverWebEndpoint as Dn, GhostlyHttpError as Do, readLobby as Dr, readHandoffHave as Ds, DID_DOCUMENT_MAX_BYTES as Dt, CommunitySession as E, REACTION_LIMITS as Ea, TURN_MAX as Ec, recordsName as Ei, pubkyProofPath as En, presenceSeenAt as Eo, readBeaconHead as Er, readHandoffDone as Es, verifyAtprotoRecordProof as Et, knockIdentity as F, pairedReplyAuthor as Fa, turnStatedVerifier as Fc, IDENTITY_REVOCATION_LABEL as Fi, findEndpoint as Fn, RTC_CONFIG as Fo, TypingSender as Fr, readSetUpdate as Fs, didSignedMessages as Ft, GROUP_REACTED_FRAME as G, cardLine as Ga, withRequestOptions as Gc, lastSharedWithMe as Gi, GROUP_FILE_LIMITS as Gn, LEGACY_SERVICES as Go, WAKE_CALL_INTERVAL_MS as Gr, enrollCancelReason as Gs, signingKeys as Gt, groupWakes as H, RUN_RESULTS as Ha, traceLink as Hc, newIdentityBinding as Hi, pairedMessageFrame as Hn, signalHasVideo as Ho, pinIsNewer as Hr, ENROLL_HELLO as Hs, parseDid as Ht, knockRecords as I, readReply as Ia, turnVerifierMark as Ic, IdentityCheckUnavailable as Ii, isFederationId as In, answersOffer as Io, sanitizeTypingStatus as Ir, setAckFrame as Is, didWebDocumentUrl as It, frameBytes as J, sameButtonText as Ja, createRelayPayload as Jc, validExternalKey as Ji, GROUP_WANT_NO_FRAME as Jn, HANDOFF_CANCEL as Jo, newWakeToken as Jr, enrollGrantFrame as Js, didDhtDocument as Jt, GROUP_READ_NOTE as K, cardUrl as Ka, PacketTooLargeError as Kc, PeerProofs as Ki, GROUP_HAVE_FRAME as Kn, serviceIdFromName as Ko, WakeLimiter as Kr, enrollDigits as Ks, staticDidDocument as Kt, mergeKnocks as L, replySnippet as La, DEFAULT_RELAYS as Lc, IdentityExchange as Li, parseCashuRequestPayload as Ln, buildSdpFromSignal as Lo, typingActivity as Lr, setUpdateFrame as Ls, didWebFile as Lt, decodeGroupEntryLink as M, readReaction as Ma, turnInstance as Mc, dialDescriptors as Mi, ENDPOINT as Mn, decodeControl as Mo, transferStalled as Mr, sealHandoffPiece as Ms, checkDidSignature as Mt, encodeGroupEntryLink as N, validReactionNumber as Na, turnKeys as Nc, IDENTITY_MAX_EVIDENCE as Ni, cashuRequestPayload as Nn, encodeControl as No, TYPING_KINDS as Nr, SET_ACK as Ns, didGhostlyServiceEntry as Nt, communityHasFrame as O, nextReactionNumber as Oa, nextTurnPosition as Oc, wellKnownUrl as Oi, pubkyHomeserverOf as On, formatLocalTarget as Oo, shouldBeHub as Or, readHandoffHello as Os, DID_FILE_MAX_BYTES as Ot, entryParams as P, wireReaction as Pa, turnName as Pc, IDENTITY_MAX_VALIDITY as Pi, fedimintRequestPayload as Pn, CALL_SIGNAL_MAX_AGE_MS as Po, TYPING_REFRESH_MS as Pr, SET_UPDATE as Ps, didJwsAlg as Pt, carryMentions as Q, heardTime as Qa, createIdentity as Qc, irohRelayUrl as Qi, readGroupFileMeta as Qn, HANDOFF_OFFER as Qo, checkPushEndpoint as Qr, readEnrollHello as Qs, resolveDidDht as Qt, readKnocks as R, BUTTON_ID as Ra, RelayTransport as Rc, boundVerifiedIdentity as Ri, parseFedimintRequestPayload as Rn, callRtcConfig as Ro, GROUP_PIN_FRAME as Rr, signTombstone as Rs, didWebFileNames as Rt, groupTypingEpoch as S, forwardedAgain as Sa, TURN_SETTLE_MS as Sc, nip05RootKey as Si, newPubkyProofFolder as Sn, DirectPathWatch as So, newerHead as Sr, handoffVersions as Ss, normalizeAtprotoHandle as St, COMMUNITY_LIMITS as T, GROUP_REACTION_FRAME as Ta, TURN_LAST_SEQUENCE as Tc, recordsFromWellKnown as Ti, pubkyProofFile as Tn, RELAY_POLL_INTERVALS as To, readBeacon as Tr, readHandoffCancel as Ts, AtprotoProofError as Tt, parseGroupWakeFrame as U, STATUS_CARD_LIMITS as Ua, budgetRetryMs as Uc, receivedIdentityStatus as Ui, engineError as Un, emptyLinkRecords as Uo, pinNumberHolds as Ur, ENROLL_PROOF as Us, parseDidDocument as Ut, groupWakeFrame as V, PR_STATES as Va, setLinkTraceSink as Vc, identityStatement as Vi, LIVENESS_PING_MS as Vn, parseCallSignal as Vo, nextPinNumber as Vr, ENROLL_GRANT as Vs, normalizeDid as Vt, GROUP_LIMITS as W, buttonLabelClash as Wa, isDiscoveryBudgetError as Wc, revokesIdentity as Wi, engineText as Wn, HTTP_SERVICE_PROTO as Wo, readPin as Wr, enrollCancelFrame as Ws, parseDidSignaturePaste as Wt, GROUP_EDIT_FRAME as X, arrivalKey as Xa, openRelayPayload as Xc, automaticTransport as Xi, groupFileFallback as Xn, HANDOFF_HAVE as Xo, relayRequest as Xr, enrollProofFrame as Xs, encodeDidDhtDeactivated as Xt, COMMUNITY_EDIT_FRAME as Y, statusCardText as Ya, newerPacket as Yc, TRANSPORTS as Yi, fetchesByItself as Yn, HANDOFF_DONE as Yo, parseWakeFrame as Yr, enrollHelloFrame as Ys, didDhtFromPublicKey as Yt, GROUP_EDIT_TEXT_BYTES as Z, claimedTime as Za, parseRelayPayload as Zc, irohDescriptor as Zi, isGroupMessageId as Zn, HANDOFF_HELLO as Zo, wakeRequest as Zr, readEnrollGrant as Zs, encodeDidDhtPacket as Zt, checkAppManifest as _, EDIT_BUFFER_MS as _a, newDeviceSetSecret as _c, dnsProofName as _i, sparkInvoiceDetails as _n, MEMBER_KEY as _o, freshHubs as _r, handoffOfferFrame as _s, atprotoDidMethod as _t, planAppUpdate as a, readImageMeta as aa, decodeInviteCode as ac, STATUS_CARD_CAPABILITY as ai, bytesEqual as al, SEPOLIA_TEST_USDT_FAUCET as an, checkAppRevokeStatement as ao, SIGNAL_HOLD_MS as ar, handoffAttemptAllowed as as, parseSshPublicKey as at, GROUP_TYPING_FRAME as b, dhtEditId as ba, TURN_PUT_WINDOW_MS as bc, encodeDnsQuery as bi, isPubkyKey as bn, verifyCommitSignature as bo, mergeBeacon as br, handoffRetryAfter as bs, checkAtprotoProofRecord as bt, readAppStore as c, sanitizeFileName as ca, inviteOwnership as cc, HOLD_DATED_BACK_MS as ci, toBase64Url as cl, WALLET_NETWORKS as cn, readAppRevocation as co, readGroupSignal as cr, handoffBusyFrame as cs, sshSignCommand as ct, APP_BUNDLE_LIMITS as d, VOICE_LIMITS as da, HANDOFF_CAPABILITY as dc, HoldRefusedError as di, utf8Encode as dl, formatPaymentAmount as dn, signAppRevocation as do, meshHubs as dr, handoffContextProof as ds, BitcoinAddressError as dt, DHT_MESSAGE_TTL as ea, createDeviceInvite as ec, chatAppId as ei, identityFromSeedB64 as el, ETHEREUM_USDT as en, shownTime as eo, readGroupWant as er, HANDOFF_PIECE_BYTES as es, mentionsMember as et, APP_BUNDLE_MAGIC as f, baseMime as fa, deviceEchoFrame as fc, isHoldUrl as fi, validatePaymentTarget as fn, canonicalJson as fo, pickMeshHubs as fr, handoffDataFrame as fs, decodeAnyBitcoinAddress as ft, assembleAppBundle as g, parseVideoMeta as ga, devicePingFrame as gc, decodeDnsResponse as gi, sparkAddressKind as gn, MAX_GROUP_CHAIN as go, doorHubs as gr, handoffManifestFrames as gs, ATPROTO_PROOF_SCOPE as gt, appViewOf as h, voicePeaksOf as ha, deviceLinkPairing as hc, DOMAIN_FILE_MAX_BYTES as hi, SPARK_PROVIDER as hn, GROUP_MEMBER_CAP as ho, beaconRecords as hr, handoffHelloFrame as hs, ATPROTO_PROOF_COLLECTION as ht, checkAppBeforeRun as i, parseImageMeta as ia, createChatInvite as ic, EDIT_CAPABILITY as ii, sanitizeAvatar as il, SEPOLIA_TEST_USDT as in, appRef as io, SIGNAL_FRESH_MS as ir, filePartName as is, SshSigError as it, KNOCK_TTL_MS as j, reactionIsNewer as ja, signTurnRelease as jc, capsDescriptors as ji, decodeBolt11 as jn, LIMITS as jo, transferEnded as jr, readHandoffVerified as js, checkDidJws as jt, decodeCommunityLink as k, queueReaction as ka, readTurnPacket as kc, CapsExchange as ki, requestLnurlInvoice as kn, parseLocalTarget as ko, ChatFiles as kr, readHandoffPake as ks, DID_JWS_MAX_LENGTH as kt, readAppStoreSteps as l, sanitizeMime as la, DEVICES_CAPABILITY as lc, HOLD_LIMITS as li, toZ32 as ll, assertTokenUnits as ln, readAppRevocations as lo, MESH_HUBS as lr, handoffCancelFrame as ls, verifySshSignature as lt, appDigest as m, parseVoiceMeta as ma, deviceKeyZ32 as mc, readManifest as mi, SPARK_NETWORKS as mn, readCanonicalJson as mo, beaconKeys as mr, handoffEphemeral as ms, ATPROTO_FULL_SCOPE as mt, appStoreDecision as n, DHT_FILE_CAPABILITY as na, readDeviceInvite as nc, APPS_CAPABILITY as ni, verify as nl, ONCHAIN_PROVIDER as nn, APP_STATEMENT_LIMITS as no, CarriedTransport as nr, HANDOFF_TIMINGS as ns, SSHSIG_MAX_ARMOR as nt, APP_STORE_LIMITS as o, formatFileSize as oa, encodeInviteCode as oc, UPGRADE_CAPABILITY as oi, fromBase64Url as ol, TEST_USDT_FAUCET_AMOUNT as on, isAppKey as oo, SIGNAL_REPLY_MS as or, handoffAttemptFailed as os, parseSshSignature as ot, APP_ICON_PATH as p, fileMessageText as pa, deviceEchoNonce as pc, newMailbox as pi, walletNetworkOf as pn, canonicalJsonBytes as po, COMMUNITY_TOPOLOGY as pr, handoffDigest as ps, ATPROTO_DECLARED_SCOPE as pt, GroupSession as q, checkStatusCard as qa, RELAY_PAYLOAD_MAX_BYTES as qc, emptyProofLedger as qi, GROUP_WANT_FRAME as qn, HANDOFF_ACK as qo, deviceWakeRequest as qr, enrollDoneFrame as qs, strictBase64Url as qt, appUpdateDecision as r, dhtFileId as ra, OwnInviteError as rc, BUTTONS_CAPABILITY as ri, MAX_AVATAR_LENGTH as rl, PaymentPreflightError as rn, appFingerprint as ro, GROUP_SIGNAL_FRAME as rr, HANDOFF_VERIFIED as rs, SSHSIG_NAMESPACE as rt, checkAppStoreIndex as s, safeBlobType as sa, inviteLink as sc, HOLD_CAPABILITY as si, randomBytes as sl, USDT_PUBLIC_RPC as sn, isAppRef as so, groupSignalFrame as sr, handoffAttemptSucceeded as ss, sameSshKey as st, appRevocationFor as t, peekDhtMailbox as ta, deviceInviteJoinerParams as tc, isAppVersion as ti, publicKeyFromZ32 as tl, EVM_TEST_CHAINS as tn, APP_PREFIXES as to, readGroupWantNo as tr, HANDOFF_REQUEST as ts, groupName as tt, signAppStore as u, PLAYABLE_AUDIO as ua, ENROLL_CAPABILITY as uc, HoldKeys as ui, utf8Decode as ul, assertWholeSats as un, signAppObject as uo, mayBeHub as ur, handoffContext as us, verifyBitcoinMessage as ut, isAppUrl as v, EDIT_RATE_WINDOW_MS as va, seedSigner as vc, domainTxtRecord as vi, isBitcoinAddress as vn, commitHash as vo, lobbyKeys as vr, handoffPakeFrame as vs, atprotoProofRecord as vt, sealGroupTyping as w, COMMUNITY_REACTION_FRAME as wa, turnPutSummary as wc, recordsFromTxt as wi, pubkyProofCapability as wn, EXPECT_PEER_MS as wo, rankHubs as wr, readHandoffAck as ws, shortAtprotoDid as wt, GroupTypingBoard as x, parseLinkPreview as xa, TURN_RAISE_READ_TIMEOUT_MS as xc, isPublicAddress as xi, isPubkyProofFolder as xn, edgeParams as xo, mergeLobby as xr, handoffStreamKey as xs, isAtprotoDid as xt, readAppBundle as y, RateWindow as ya, webCryptoSigner as yc, domainWellKnownFile as yi, PUBKY_PROOF_MAX_BYTES as yn, rosterHas as yo, lobbyRecords as yr, handoffReleaseFrame as ys, atprotoProofRkey as yt, GROUP_WAKE_FRAME as z, ITEM_STATES as za, currentRelays as zc, emptyIdentityLedger as zi, GhostLink as zn, callSignalHeardAt as zo, PIN_LIMITS as zr, verifySetUpdate as zs, didWebFileUrl as zt };
30989
+ export { COMMUNITY_EDIT_FRAME as $, cardUrl as $a, budgetRetryMs as $c, PeerProofs as $i, GROUP_WANT_FRAME as $n, HTTP_SERVICE_PROTO as $o, deviceWakeRequest as $r, enrollCancelFrame as $s, didDhtFromPublicKey as $t, CommunitySession as A, COMMUNITY_REACTION_FRAME as Aa, TURN_PUT_WINDOW_MS as Ac, normalizeDomain as Ai, pubkyProofPath as An, DirectPathWatch as Ao, rankHubs as Ar, handoffStreamKey as As, verifyAtprotoRecordProof as At, knockRecords as B, wireReaction as Ba, signTurnPacket as Bc, IDENTITY_MAX_EVIDENCE as Bi, isFederationId as Bn, LIMITS as Bo, TYPING_REFRESH_MS as Br, readHandoffVerified as Bs, didWebDocumentUrl as Bt, sha256Paced as C, EDIT_BUFFER_MS as Ca, deviceEchoNonce as Cc, decodeDnsResponse as Ci, PUBKY_PROOF_MAX_BYTES as Cn, GROUP_MEMBER_CAP as Co, freshHubs as Cr, handoffEphemeral as Cs, atprotoProofRkey as Ct, openGroupTyping as D, parseLinkPreview as Da, newDeviceSetSecret as Dc, encodeDnsQuery as Di, normalizePubkyKey as Dn, rosterHas as Do, mergeLobby as Dr, handoffPakeFrame as Ds, parseAtprotoDidDocument as Dt, groupTypingEpoch as E, dhtEditId as Ea, devicePingFrame as Ec, domainWellKnownFile as Ei, newPubkyProofFolder as En, commitHash as Eo, mergeBeacon as Er, handoffOfferFrame as Es, normalizeAtprotoHandle as Et, KNOCK_TTL_MS as F, queueReaction as Fa, TURN_LAST_SEQUENCE as Fc, wellKnownUrl as Fi, decodeBolt11 as Fn, presenceSeenAt as Fo, ChatFiles as Fr, readHandoffDone as Fs, checkDidJws as Ft, groupWakeFrame as G, ITEM_STATES as Ga, turnStatedVerifier as Gc, boundVerifiedIdentity as Gi, LIVENESS_PING_MS as Gn, answersOffer as Go, PIN_LIMITS as Gr, setAckFrame as Gs, normalizeDid as Gt, readKnocks as H, readReply as Ha, turnInstance as Hc, IDENTITY_REVOCATION_LABEL as Hi, parseFedimintRequestPayload as Hn, encodeControl as Ho, sanitizeTypingStatus as Hr, SET_ACK as Hs, didWebFileNames as Ht, decodeGroupEntryLink as I, reactionEmoji as Ia, TURN_MAX as Ic, CapsExchange as Ii, ENDPOINT as In, GhostlyHttpError as Io, FILE_LIMITS as Ir, readHandoffHave as Is, checkDidSignature as It, GROUP_LIMITS as J, RUN_RESULTS as Ja, RelayTransport as Jc, identityStatement as Ji, pairedMessageFrame as Jn, callSignalHeardAt as Jo, pinIsNewer as Jr, verifySetUpdate as Js, parseDidSignaturePaste as Jt, groupWakes as K, PR_CHECKS as Ka, turnVerifierMark as Kc, emptyIdentityLedger as Ki, PAIRED_FRAME_TOO_LARGE as Kn, buildSdpFromSignal as Ko, mayPin as Kr, setUpdateFrame as Ks, parseDid as Kt, encodeGroupEntryLink as L, reactionIsNewer as La, firstDeviceSetSecret as Lc, DHT_TEXT_CAPABILITY as Li, cashuRequestPayload as Ln, formatLocalTarget as Lo, transferEnded as Lr, readHandoffHello as Ls, didGhostlyServiceEntry as Lt, communityHasFrame as M, REACTION_LIMITS as Ma, TURN_SETTLE_MS as Mc, recordsFromWellKnown as Mi, pubkyHomeserverOf as Mn, DHT_POLL_INTERVALS as Mo, readBeaconHead as Mr, newHandoffId as Ms, DID_FILE_MAX_BYTES as Mt, decodeCommunityLink as N, ReactionWindow as Na, classifyTurnRead as Nc, recordsName as Ni, requestLnurlInvoice as Nn, EXPECT_PEER_MS as No, readLobby as Nr, readHandoffAck as Ns, DID_JWS_MAX_LENGTH as Nt, sealGroupTyping as O, forwardedAgain as Oa, seedSigner as Oc, isPublicAddress as Oi, pubkyProofCapability as On, verifyCommitSignature as Oo, newerHead as Or, handoffReleaseFrame as Os, shortAtprotoDid as Ot, encodeCommunityLink as P, nextReactionNumber as Pa, turnPutSummary as Pc, txtValue as Pi, resolveLightningDestination as Pn, RELAY_POLL_INTERVALS as Po, shouldBeHub as Pr, readHandoffCancel as Ps, DidError as Pt, frameBytes as Q, cardLine as Qa, traceLink as Qc, lastSharedWithMe as Qi, GROUP_HAVE_FRAME as Qn, emptyLinkRecords as Qo, WakeLimiter as Qr, ENROLL_PROOF as Qs, didDhtDocument as Qt, entryParams as R, readReaction as Ra, nextTurnPosition as Rc, capsDescriptors as Ri, fedimintRequestPayload as Rn, parseLocalTarget as Ro, transferStalled as Rr, readHandoffPake as Rs, didJwsAlg as Rt, readAppBundleAsync as S, parseVideoMeta as Sa, deviceEchoFrame as Sc, DOMAIN_FILE_MAX_BYTES as Si, isBitcoinAddress as Sn, readCanonicalJson as So, doorHubs as Sr, handoffDigest as Ss, atprotoProofRecord as St, GroupTypingBoard as T, RateWindow as Ta, deviceLinkPairing as Tc, domainTxtRecord as Ti, isPubkyProofFolder as Tn, MEMBER_KEY as To, lobbyRecords as Tr, handoffManifestFrames as Ts, isAtprotoDid as Tt, GROUP_WAKE_FRAME as U, replySnippet as Ua, turnKeys as Uc, IdentityCheckUnavailable as Ui, GhostLink as Un, CALL_SIGNAL_MAX_AGE_MS as Uo, typingActivity as Ur, SET_UPDATE as Us, didWebFileUrl as Ut, mergeKnocks as V, pairedReplyAuthor as Va, signTurnRelease as Vc, IDENTITY_MAX_VALIDITY as Vi, parseCashuRequestPayload as Vn, decodeControl as Vo, TypingSender as Vr, sealHandoffPiece as Vs, didWebFile as Vt, GroupWakeLimiter as W, BUTTON_ID as Wa, turnName as Wc, IdentityExchange as Wi, INVITE_TAKEN as Wn, RTC_CONFIG as Wo, GROUP_PIN_FRAME as Wr, readSetUpdate as Ws, didWebHost as Wt, GROUP_READ_NOTE as X, appCardId as Xa, normalizeRelayUrl as Xc, receivedIdentityStatus as Xi, engineText as Xn, parseCallSignal as Xo, readPin as Xr, ENROLL_GRANT as Xs, staticDidDocument as Xt, GROUP_REACTED_FRAME as Y, STATUS_CARD_LIMITS as Ya, currentRelays as Yc, newIdentityBinding as Yi, engineError as Yn, extractParamsFromSdp as Yo, pinNumberHolds as Yr, ENROLL_DONE as Ys, signingKeys as Yt, GroupSession as Z, buttonLabelClash as Za, setLinkTraceSink as Zc, revokesIdentity as Zi, GROUP_FILE_LIMITS as Zn, signalHasVideo as Zo, WAKE_CALL_INTERVAL_MS as Zr, ENROLL_HELLO as Zs, strictBase64Url as Zt, assembleAppBundle as _, VOICE_LIMITS as _a, inviteOwnership as _c, HoldKeys as _i, randomBytes as _l, walletNetworkOf as _n, readAppRevocations as _o, meshHubs as _r, handoffBusyFrame as _s, ATPROTO_DECLARED_SCOPE as _t, planAppUpdate as a, irohRelayUrl as aa, enrollProofFrame as ac, vapidKeysMatch as ai, newerPacket as al, EVM_TEST_CHAINS as an, heardTime as ao, readGroupHave as ar, HANDOFF_HAVE as as, groupName as at, pacer as b, parseVoiceMeta as ba, ENROLL_CAPABILITY as bc, newMailbox as bi, utf8Decode as bl, sparkAddressKind as bn, canonicalJson as bo, beaconKeys as br, handoffContextProof as bs, ATPROTO_PROOF_SCOPE as bt, readAppStore as c, peekDhtMailbox as ca, verifyEnrollProof as cc, isAppVersion as ci, createIdentity as cl, SEPOLIA_TEST_USDT as cn, APP_PREFIXES as co, CarriedTransport as cr, HANDOFF_PAKE as cs, SshSigError as ct, APP_BUNDLE_LIMITS as d, parseImageMeta as da, readDeviceInvite as dc, EDIT_CAPABILITY as di, publicKeyFromZ32 as dl, USDT_PUBLIC_RPC as dn, appRef as do, SIGNAL_HOLD_MS as dr, HANDOFF_TIMINGS as ds, sameSshKey as dt, emptyProofLedger as ea, enrollCancelReason as ec, newWakeToken as ei, isDiscoveryBudgetError as el, encodeDidDhtDeactivated as en, checkStatusCard as eo, GROUP_WANT_NO_FRAME as er, LEGACY_SERVICES as es, GROUP_EDIT_FRAME as et, APP_BUNDLE_MAGIC as f, readImageMeta as fa, OwnInviteError as fc, STATUS_CARD_CAPABILITY as fi, verify as fl, WALLET_NETWORKS as fn, checkAppRevokeStatement as fo, SIGNAL_REPLY_MS as fr, HANDOFF_VERIFIED as fs, sshSignCommand as ft, appViewOf as g, PLAYABLE_AUDIO as ga, inviteLink as gc, HOLD_LIMITS as gi, fromBase64Url as gl, validatePaymentTarget as gn, readAppRevocation as go, mayBeHub as gr, handoffAttemptSucceeded as gs, decodeAnyBitcoinAddress as gt, appDigest as h, sanitizeMime as ha, encodeInviteCode as hc, HOLD_DATED_BACK_MS as hi, bytesEqual as hl, formatPaymentAmount as hn, isAppRef as ho, MESH_HUBS as hr, handoffAttemptFailed as hs, BitcoinAddressError as ht, checkAppBeforeRun as i, irohDescriptor as ia, enrollHelloFrame as ic, checkPushEndpoint as ii, createRelayPayload as il, ETHEREUM_USDT as in, claimedTime as io, readGroupFileMeta as ir, HANDOFF_DONE as is, mentionsMember as it, GROUP_READ_NOTE_COMMUNITY as j, GROUP_REACTION_FRAME as ja, TURN_RAISE_READ_TIMEOUT_MS as jc, recordsFromTxt as ji, homeserverWebEndpoint as jn, AWAITING_PEER_MS as jo, readBeacon as jr, handoffVersions as js, DID_DOCUMENT_MAX_BYTES as jt, COMMUNITY_LIMITS as k, readForwarded as ka, webCryptoSigner as kc, nip05RootKey as ki, pubkyProofFile as kn, edgeParams as ko, pickHubs as kr, handoffRetryAfter as ks, AtprotoProofError as kt, readAppStoreSteps as l, DHT_FILE_CAPABILITY as la, createDeviceInvite as lc, APPS_CAPABILITY as li, identityFromSeed as ll, SEPOLIA_TEST_USDT_FAUCET as ln, APP_STATEMENT_LIMITS as lo, GROUP_SIGNAL_FRAME as lr, HANDOFF_PIECE_BYTES as ls, parseSshPublicKey as lt, APP_PERMISSIONS as m, sanitizeFileName as ma, decodeInviteCode as mc, HOLD_CAPABILITY as mi, sanitizeAvatar as ml, assertWholeSats as mn, isAppKey as mo, readGroupSignal as mr, handoffAttemptAllowed as ms, verifyBitcoinMessage as mt, appStoreDecision as n, TRANSPORTS as na, enrollDoneFrame as nc, relayRequest as ni, PacketTooLargeError as nl, resolveDidDht as nn, statusCardText as no, groupFileFallback as nr, HANDOFF_ACK as ns, carryMentions as nt, APP_STORE_LIMITS as o, DHT_FILE_TOO_LARGE as oa, readEnrollGrant as oc, APP_DATA_MAX_BYTES as oi, openRelayPayload as ol, ONCHAIN_PROVIDER as on, receivedTimestamp as oo, readGroupWant as or, HANDOFF_HELLO as os, SSHSIG_MAX_ARMOR as ot, APP_ICON_PATH as p, formatFileSize as pa, createChatInvite as pc, UPGRADE_CAPABILITY as pi, MAX_AVATAR_LENGTH as pl, assertTokenUnits as pn, isAppHash as po, groupSignalFrame as pr, filePartName as ps, verifySshSignature as pt, parseGroupWakeFrame as q, PR_STATES as qa, DEFAULT_RELAYS as qc, identityRevocationValue as qi, pairedMessageFits as qn, callRtcConfig as qo, nextPinNumber as qr, signTombstone as qs, parseDidDocument as qt, appUpdateDecision as r, automaticTransport as ra, enrollGrantFrame as rc, wakeRequest as ri, RELAY_PAYLOAD_MAX_BYTES as rl, signDidDhtPacket as rn, arrivalKey as ro, isGroupMessageId as rr, HANDOFF_CANCEL as rs, parseCommunityEdit as rt, checkAppStoreIndex as s, DHT_MESSAGE_TTL as sa, readEnrollHello as sc, chatAppId as si, parseRelayPayload as sl, PaymentPreflightError as sn, shownTime as so, readGroupWantNo as sr, HANDOFF_OFFER as ss, SSHSIG_NAMESPACE as st, appRevocationFor as t, validExternalKey as ta, enrollDigits as tc, parseWakeFrame as ti, withRequestOptions as tl, encodeDidDhtPacket as tn, sameButtonText as to, fetchesByItself as tr, serviceIdFromName as ts, GROUP_EDIT_TEXT_BYTES as tt, signAppStore as u, dhtFileId as ua, deviceInviteJoinerParams as uc, BUTTONS_CAPABILITY as ui, identityFromSeedB64 as ul, TEST_USDT_FAUCET_AMOUNT as un, appFingerprint as uo, SIGNAL_FRESH_MS as ur, HANDOFF_REQUEST as us, parseSshSignature as ut, checkAppManifest as v, baseMime as va, ownInviteRefusal as vc, HoldRefusedError as vi, toBase64Url as vl, SPARK_NETWORKS as vn, signAppObject as vo, pickMeshHubs as vr, handoffCancelFrame as vs, ATPROTO_FULL_SCOPE as vt, GROUP_TYPING_FRAME as w, EDIT_RATE_WINDOW_MS as wa, deviceKeyZ32 as wc, dnsProofName as wi, isPubkyKey as wn, MAX_GROUP_CHAIN as wo, lobbyKeys as wr, handoffHelloFrame as ws, checkAtprotoProofRecord as wt, readAppBundle as x, voicePeaksOf as xa, HANDOFF_CAPABILITY as xc, readManifest as xi, utf8Encode as xl, sparkInvoiceDetails as xn, canonicalJsonBytes as xo, beaconRecords as xr, handoffDataFrame as xs, atprotoDidMethod as xt, isAppUrl as y, fileMessageText as ya, DEVICES_CAPABILITY as yc, isHoldUrl as yi, toZ32 as yl, SPARK_PROVIDER as yn, signAppRevocation as yo, COMMUNITY_TOPOLOGY as yr, handoffContext as ys, ATPROTO_PROOF_COLLECTION as yt, knockIdentity as z, validReactionNumber as za, readTurnPacket as zc, dialDescriptors as zi, findEndpoint as zn, webLocalFetch as zo, TYPING_KINDS as zr, readHandoffTurnFrame as zs, didSignedMessages as zt };
30242
30990
 
30243
- //# sourceMappingURL=src-CbLkHfz8.js.map
30991
+ //# sourceMappingURL=src-DDzSERyc.js.map