@ghostlytools/cli 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +18 -7
  2. package/dist/assets/api-BiaX6l6f.js +3 -0
  3. package/dist/assets/api-CrxVxtY1.js +4071 -0
  4. package/dist/assets/api-CrxVxtY1.js.map +1 -0
  5. package/dist/assets/apiKit-CajiDxdK.js +196 -0
  6. package/dist/assets/apiKit-CajiDxdK.js.map +1 -0
  7. package/dist/assets/apps-CbfiCSIw.js +787 -0
  8. package/dist/assets/apps-CbfiCSIw.js.map +1 -0
  9. package/dist/assets/apps-t23gkc28.js +2 -0
  10. package/dist/assets/{arkade-B-ALutmU.js → arkade-D4BVJFEQ.js} +2 -2
  11. package/dist/assets/{arkade-B-ALutmU.js.map → arkade-D4BVJFEQ.js.map} +1 -1
  12. package/dist/assets/backup-BAMi2lAq.js +731 -0
  13. package/dist/assets/backup-BAMi2lAq.js.map +1 -0
  14. package/dist/assets/backup-Cg52WeQi.js +2 -0
  15. package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
  16. package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
  17. package/dist/assets/buttons-CWl2yNJu.js +2 -0
  18. package/dist/assets/buttons-CX1pKB0j.js +497 -0
  19. package/dist/assets/buttons-CX1pKB0j.js.map +1 -0
  20. package/dist/assets/{endpoint-pgFkR2AS.js → endpoint-Dn7H8qE1.js} +14 -8
  21. package/dist/assets/endpoint-Dn7H8qE1.js.map +1 -0
  22. package/dist/assets/envelope-BvU0fLEt.js +90 -0
  23. package/dist/assets/envelope-BvU0fLEt.js.map +1 -0
  24. package/dist/assets/errors-my6VJR_0.js +77 -0
  25. package/dist/assets/errors-my6VJR_0.js.map +1 -0
  26. package/dist/assets/{fileBytes-Dbifu5I5.js → fileBytes-CfSXjTFa.js} +2 -2
  27. package/dist/assets/{fileBytes-Dbifu5I5.js.map → fileBytes-CfSXjTFa.js.map} +1 -1
  28. package/dist/assets/fileBytes-CgbibpbV.js +2 -0
  29. package/dist/assets/{fileBytes-BcYZhNgF.js → fileBytes-Di7VZtmU.js} +6 -19
  30. package/dist/assets/fileBytes-Di7VZtmU.js.map +1 -0
  31. package/dist/assets/fileBytesIdb-CFP-DSkZ.js +2 -0
  32. package/dist/assets/{fileBytesIdb-DzSaqI0A.js → fileBytesIdb-D5u6H8X_.js} +4 -4
  33. package/dist/assets/{fileBytesIdb-DzSaqI0A.js.map → fileBytesIdb-D5u6H8X_.js.map} +1 -1
  34. package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js → fileBytesOpfs-Dd1N4ERH.js} +3 -3
  35. package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js.map → fileBytesOpfs-Dd1N4ERH.js.map} +1 -1
  36. package/dist/assets/groupFiles-BNTSDaoq.js +7685 -0
  37. package/dist/assets/groupFiles-BNTSDaoq.js.map +1 -0
  38. package/dist/assets/groupFiles-DBbjOLuD.js +2088 -0
  39. package/dist/assets/groupFiles-DBbjOLuD.js.map +1 -0
  40. package/dist/assets/host-Cmd205Dc.js +4648 -0
  41. package/dist/assets/host-Cmd205Dc.js.map +1 -0
  42. package/dist/assets/hyperdhtRelay-BT_wRxab.js +21 -0
  43. package/dist/assets/hyperdhtRelay-BT_wRxab.js.map +1 -0
  44. package/dist/assets/{hyperdhtRelay-hKC9DpIH.js → hyperdhtRelay-CEmBpT0Q.js} +26 -10
  45. package/dist/assets/hyperdhtRelay-CEmBpT0Q.js.map +1 -0
  46. package/dist/assets/{idb-CTF6LF5B.js → idb-B5L8wuOm.js} +7 -5
  47. package/dist/assets/idb-B5L8wuOm.js.map +1 -0
  48. package/dist/assets/irohWeb--YyEA-hX.js +545 -0
  49. package/dist/assets/irohWeb--YyEA-hX.js.map +1 -0
  50. package/dist/assets/{joinTrace-CO8mlhNO.js → joinTrace-CjRDFy85.js} +2 -7
  51. package/dist/assets/{joinTrace-CO8mlhNO.js.map → joinTrace-CjRDFy85.js.map} +1 -1
  52. package/dist/assets/joinTrace-D-fnt2p3.js +2 -0
  53. package/dist/assets/main-BzNN6bkx.js +4302 -0
  54. package/dist/assets/main-BzNN6bkx.js.map +1 -0
  55. package/dist/assets/{mainline-DriRu_hb.js → mainline-D5D5C-jT.js} +14 -6
  56. package/dist/assets/mainline-D5D5C-jT.js.map +1 -0
  57. package/dist/assets/mints-BrlY773j.js +74 -0
  58. package/dist/assets/mints-BrlY773j.js.map +1 -0
  59. package/dist/assets/mints-CqTdA4Gk.js +2 -0
  60. package/dist/assets/networkReason-DxZWHw6v.js +154 -0
  61. package/dist/assets/networkReason-DxZWHw6v.js.map +1 -0
  62. package/dist/assets/privateFolder-Bk9C9oFv.js +38 -0
  63. package/dist/assets/privateFolder-Bk9C9oFv.js.map +1 -0
  64. package/dist/assets/profiles-B6F4oa9K.js +139 -0
  65. package/dist/assets/profiles-B6F4oa9K.js.map +1 -0
  66. package/dist/assets/{restoredRows-C_84q0Pd.js → restoredRows-Db3oUTko.js} +2 -2
  67. package/dist/assets/{restoredRows-C_84q0Pd.js.map → restoredRows-Db3oUTko.js.map} +1 -1
  68. package/dist/assets/{server-DHMXMwNn.js → server-CBCEYKMN.js} +1290 -277
  69. package/dist/assets/server-CBCEYKMN.js.map +1 -0
  70. package/dist/assets/{src-_FzJkeAJ.js → src-DhfGgAUu.js} +1068 -2266
  71. package/dist/assets/src-DhfGgAUu.js.map +1 -0
  72. package/dist/assets/turnRefs-ChEfgbSX.js +323 -0
  73. package/dist/assets/turnRefs-ChEfgbSX.js.map +1 -0
  74. package/dist/assets/{usdt-D0lO2pRR.js → usdt-C1Xao2xH.js} +3 -3
  75. package/dist/assets/{usdt-D0lO2pRR.js.map → usdt-C1Xao2xH.js.map} +1 -1
  76. package/dist/ghostly.mjs +4 -21992
  77. package/dist/ghostly.mjs.map +1 -1
  78. package/package.json +1 -1
  79. package/dist/assets/endpoint-pgFkR2AS.js.map +0 -1
  80. package/dist/assets/fileBytes-BcYZhNgF.js.map +0 -1
  81. package/dist/assets/hyperdhtRelay-hKC9DpIH.js.map +0 -1
  82. package/dist/assets/idb-CTF6LF5B.js.map +0 -1
  83. package/dist/assets/mainline-DriRu_hb.js.map +0 -1
  84. package/dist/assets/rolldown-runtime-8H4AJuhK.js +0 -14
  85. package/dist/assets/server-DHMXMwNn.js.map +0 -1
  86. package/dist/assets/src-_FzJkeAJ.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { At as fromBase64, B as readStatusCard, C as downsamplePeaks, Ct as hasVisible, D as parseVideoMeta, Dt as trimEndOf, Et as stripHidden, Ft as toBase64Url, H as statusCardBytes, I as cardEditNumber, It as toZ32, Lt as utf8Decode, Mt as fromZ32, N as STATUS_CARD_LIMITS, Nt as randomBytes, O as BUTTON_ID, Ot as bytesEqual, Pt as toBase64, Rt as utf8Encode, St as wrapDataChannel, T as parseVoiceMeta, Tt as sanitizeNick, X as APP_PREFIXES, Y as APP_NAME, _t as LIMITS, bt as encodeControl, c as readGroupFileMeta, ct as readAppSignature, dt as verifyAppRevocation, et as appRefPublisher, f as imageSize, ft as verifyAppSignature, g as formatFileSize, gt as CHUNK_KIND, h as FileTransfers, ht as readCanonicalJson, it as isAppRef, jt as fromBase64Url, kt as concatBytes$1, lt as signAppObject, mt as canonicalJsonBytes, nt as isAppHash, ot as readAppRevocationShape, p as parseImageMeta, rt as isAppKey, t as GROUP_FILE_LIMITS, v as sanitizeFileName, vt as decodeChunk, wt as sanitizeDisplayText, xt as sendBody, y as sanitizeMime, yt as decodeControl } from "./groupFiles-DBbjOLuD.js";
1
2
  import { xsalsa20poly1305 } from "@noble/ciphers/salsa.js";
2
3
  import { ed25519, x25519 } from "@noble/curves/ed25519.js";
3
4
  import { hkdf } from "@noble/hashes/hkdf.js";
@@ -12,165 +13,6 @@ import { p256, p384, p521 } from "@noble/curves/nist.js";
12
13
  import { gcm } from "@noble/ciphers/aes.js";
13
14
  import { ripemd160 } from "@noble/hashes/legacy.js";
14
15
  import { bytesToNumberBE } from "@noble/curves/utils.js";
15
- //#region ../core/src/bytes.ts
16
- var textEncoder$1 = new TextEncoder();
17
- var textDecoder$1 = new TextDecoder("utf-8", { fatal: true });
18
- function utf8Encode(str) {
19
- return textEncoder$1.encode(str);
20
- }
21
- function utf8Decode(bytes) {
22
- return textDecoder$1.decode(bytes);
23
- }
24
- function concatBytes$1(...parts) {
25
- let length = 0;
26
- for (const p of parts) length += p.length;
27
- const out = new Uint8Array(length);
28
- let offset = 0;
29
- for (const p of parts) {
30
- out.set(p, offset);
31
- offset += p.length;
32
- }
33
- return out;
34
- }
35
- function bytesEqual(a, b) {
36
- if (a.length !== b.length) return false;
37
- let diff = 0;
38
- for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
39
- return diff === 0;
40
- }
41
- /** `Uint8Array.prototype.toBase64` (Node 25+, Chrome 140+, Firefox 133+, Safari 18.2+): ~200x the JS below. */
42
- var nativeToBase64 = Uint8Array.prototype.toBase64;
43
- var tableOf = (chars) => Uint8Array.from(chars, (c) => c.charCodeAt(0));
44
- var BASE64_TABLE = tableOf("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/");
45
- var BASE64URL_TABLE = tableOf("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_");
46
- /**
47
- * Base64 where the runtime has no native encoder (Node 22 and 24, older WebViews): the characters go into bytes and
48
- * become one string at the end. Every file chunk is encoded (files/3 `pf-data`), so this is on a transfer's path.
49
- */
50
- function encodeBase64(bytes, table, pad) {
51
- const out = new Uint8Array(Math.ceil(bytes.length / 3) * 4);
52
- let o = 0, i = 0;
53
- for (; i + 2 < bytes.length; i += 3) {
54
- const n = bytes[i] << 16 | bytes[i + 1] << 8 | bytes[i + 2];
55
- out[o++] = table[n >>> 18];
56
- out[o++] = table[n >>> 12 & 63];
57
- out[o++] = table[n >>> 6 & 63];
58
- out[o++] = table[n & 63];
59
- }
60
- const rest = bytes.length - i;
61
- if (rest) {
62
- const n = bytes[i] << 16 | (rest === 2 ? bytes[i + 1] << 8 : 0);
63
- out[o++] = table[n >>> 18];
64
- out[o++] = table[n >>> 12 & 63];
65
- if (rest === 2) out[o++] = table[n >>> 6 & 63];
66
- else if (pad) out[o++] = 61;
67
- if (pad) out[o++] = 61;
68
- }
69
- return textDecoder$1.decode(out.subarray(0, o));
70
- }
71
- function toBase64(bytes) {
72
- return nativeToBase64 ? nativeToBase64.call(bytes) : encodeBase64(bytes, BASE64_TABLE, true);
73
- }
74
- function fromBase64(str) {
75
- const binary = atob(str);
76
- const bytes = new Uint8Array(binary.length);
77
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
78
- return bytes;
79
- }
80
- function toBase64Url(bytes) {
81
- return nativeToBase64 ? nativeToBase64.call(bytes, {
82
- alphabet: "base64url",
83
- omitPadding: true
84
- }) : encodeBase64(bytes, BASE64URL_TABLE, false);
85
- }
86
- function fromBase64Url(str) {
87
- let b = str.replace(/-/g, "+").replace(/_/g, "/");
88
- while (b.length % 4) b += "=";
89
- return fromBase64(b);
90
- }
91
- var Z32_ALPHABET = "ybndrfg8ejkmcpqxot1uwisza345h769";
92
- function toZ32(bytes) {
93
- let out = "";
94
- let bits = 0;
95
- let value = 0;
96
- for (const byte of bytes) {
97
- value = value << 8 | byte;
98
- bits += 8;
99
- while (bits >= 5) {
100
- out += Z32_ALPHABET[value >>> bits - 5 & 31];
101
- bits -= 5;
102
- }
103
- value &= (1 << bits) - 1;
104
- }
105
- if (bits > 0) out += Z32_ALPHABET[value << 5 - bits & 31];
106
- return out;
107
- }
108
- function fromZ32(str) {
109
- const out = new Uint8Array(Math.floor(str.length * 5 / 8));
110
- let bits = 0;
111
- let value = 0;
112
- let index = 0;
113
- for (const char of str) {
114
- const v = Z32_ALPHABET.indexOf(char);
115
- if (v < 0) throw new Error(`Invalid z-base-32 character: ${char}`);
116
- value = value << 5 | v;
117
- bits += 5;
118
- if (bits >= 8) {
119
- out[index++] = value >>> bits - 8 & 255;
120
- bits -= 8;
121
- }
122
- value &= (1 << bits) - 1;
123
- }
124
- return out;
125
- }
126
- function randomBytes(length) {
127
- const bytes = new Uint8Array(length);
128
- crypto.getRandomValues(bytes);
129
- return bytes;
130
- }
131
- /**
132
- * Invisible and direction-changing characters. Unlike a file name, display
133
- * text may legitimately hold emoji and Indic or Arabic joiners, so the two
134
- * joiners stay and the rest of Cf goes: bidi overrides and isolates reorder
135
- * what follows them, and the others hide text or let two different names
136
- * render identically.
137
- */
138
- var HIDDEN = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
139
- /** Zero-width non-joiner and zero-width joiner. */
140
- var KEEP = /[\u200c\u200d]/;
141
- /** `text` without its invisible and direction-changing characters, at any length: for display text with a cut of its own. */
142
- function stripHidden(text) {
143
- return text.replace(HIDDEN, (char) => KEEP.test(char) ? char : "");
144
- }
145
- /**
146
- * What draws nothing when it stands alone and `HIDDEN` leaves in, since text needs it beside something else: the
147
- * joiners, the fillers, the variation selectors and the other default-ignorable code points, combining marks, white
148
- * space and the blank Braille pattern.
149
- */
150
- var UNSEEN = /^[\p{Default_Ignorable_Code_Point}\p{M}\p{White_Space}\u2800]*$/u;
151
- /** Whether `text` holds a character that shows by itself: a name of joiners, fillers or marks alone has none. */
152
- function hasVisible(text) {
153
- return !UNSEEN.test(text);
154
- }
155
- /** Undefined when nothing visible is left, so callers can fall back. */
156
- function sanitizeDisplayText(text, max) {
157
- const clean = [...stripHidden(text)].slice(0, max).join("").trim();
158
- return hasVisible(clean) ? clean : void 0;
159
- }
160
- /** A nickname as it arrived from a peer, ready to show. */
161
- function sanitizeNick(nick) {
162
- return typeof nick === "string" ? sanitizeDisplayText(nick, 64) : void 0;
163
- }
164
- /**
165
- * `text` without the characters of `chars` it ends with, in one pass from the end. A `/[…]+$/` replace would retry
166
- * from every character of a run that does not reach the end.
167
- */
168
- function trimEndOf(text, chars) {
169
- let end = text.length;
170
- while (end > 0 && chars.includes(text[end - 1])) end--;
171
- return end === text.length ? text : text.slice(0, end);
172
- }
173
- //#endregion
174
16
  //#region ../core/src/avatar.ts
175
17
  /**
176
18
  * A profile picture as it travels between paired peers: a small square JPEG in a data URL. The picture
@@ -181,7 +23,7 @@ function trimEndOf(text, chars) {
181
23
  var MAX_AVATAR_LENGTH = 48e3;
182
24
  var PREFIX$1 = "data:image/jpeg;base64,";
183
25
  /** Width and height from a JPEG's frame header, or undefined when there is none before the image data. */
184
- function jpegSize$1(bytes) {
26
+ function jpegSize(bytes) {
185
27
  if (bytes.length < 4 || bytes[0] !== 255 || bytes[1] !== 216) return;
186
28
  let i = 2;
187
29
  while (i + 4 <= bytes.length) {
@@ -216,7 +58,7 @@ function sanitizeAvatar(value) {
216
58
  }
217
59
  const bytes = new Uint8Array(binary.length);
218
60
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
219
- const size = jpegSize$1(bytes);
61
+ const size = jpegSize(bytes);
220
62
  if (!size || size.width < 1 || size.height < 1 || size.width > 512 || size.height > 512) return;
221
63
  return value;
222
64
  }
@@ -234,7 +76,8 @@ function generateEncryptionKey() {
234
76
  function encrypt(plaintext, key) {
235
77
  if (key.length !== KEY_LENGTH) throw new Error("Invalid key length: expected 32 bytes");
236
78
  const nonce = randomBytes(NONCE_LENGTH$3);
237
- return toBase64(concatBytes$1(nonce, xsalsa20poly1305(key, nonce).encrypt(utf8Encode(plaintext))));
79
+ const box = xsalsa20poly1305(key, nonce).encrypt(utf8Encode(plaintext));
80
+ return toBase64(concatBytes$1(nonce, box));
238
81
  }
239
82
  /** The length of what `encrypt` returns for a plaintext of this many UTF-8 bytes: base64 of nonce, box and 16-byte tag. */
240
83
  function sealedLength(plaintextBytes) {
@@ -515,27 +358,31 @@ function signRelayPayload(identity, dnsPacket, seq) {
515
358
  new DataView(timestamp.buffer).setBigUint64(0, seq);
516
359
  return concatBytes$1(signature, timestamp, dnsPacket);
517
360
  }
518
- /** Checks a relay payload's size and signature. Returns its sequence number and DNS packet, not decoded. */
519
- function openRelayPayload(pubKeyZ32, payload) {
361
+ /** A relay payload's parts, its size checked: the signature, the sequence number and the DNS packet (not decoded). */
362
+ function splitRelayPayload(payload) {
520
363
  if (payload.length < 84) throw new Error("Relay payload too short");
521
364
  if (payload.length > 1072) throw new PacketTooLargeError(payload.length);
365
+ return {
366
+ signature: payload.subarray(0, SIGNATURE_LENGTH),
367
+ seq: new DataView(payload.buffer, payload.byteOffset + SIGNATURE_LENGTH, TIMESTAMP_LENGTH).getBigUint64(0),
368
+ dnsPacket: payload.subarray(72)
369
+ };
370
+ }
371
+ /** Checks a relay payload's size and signature. Returns its sequence number and DNS packet, not decoded. */
372
+ function openRelayPayload(pubKeyZ32, payload) {
373
+ const { signature, seq, dnsPacket } = splitRelayPayload(payload);
522
374
  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
375
  if (!verify(signature, signable(seq, dnsPacket), publicKey)) throw new Error("Invalid signature");
527
376
  return {
528
377
  seq,
529
378
  dnsPacket
530
379
  };
531
380
  }
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);
381
+ function decodeRelayPayload(pubKeyZ32, seq, dnsPacket) {
535
382
  const suffix = `.${pubKeyZ32}`;
536
383
  return {
537
384
  pubKeyZ32,
538
- timestampMicros,
385
+ timestampMicros: seq,
539
386
  records: decodeTxtPacket(dnsPacket).map((r) => ({
540
387
  label: r.name.endsWith(suffix) ? r.name.slice(0, -suffix.length) : r.name,
541
388
  value: r.value,
@@ -543,6 +390,20 @@ function parseRelayPayload(pubKeyZ32, payload) {
543
390
  }))
544
391
  };
545
392
  }
393
+ /** Verifies and decodes a relay payload. Throws if the signature does not match. */
394
+ function parseRelayPayload(pubKeyZ32, payload) {
395
+ const { seq, dnsPacket } = openRelayPayload(pubKeyZ32, payload);
396
+ return decodeRelayPayload(pubKeyZ32, seq, dnsPacket);
397
+ }
398
+ /**
399
+ * Decodes a payload whose signature this process made or already checked (one it is putting), without checking it
400
+ * again: an Ed25519 verify costs about 1 ms, and a CLI daemon at its relay budget checked its own packets ~500 times a
401
+ * minute. Never for bytes read from elsewhere: `parseRelayPayload` is.
402
+ */
403
+ function readOwnRelayPayload(pubKeyZ32, payload) {
404
+ const { seq, dnsPacket } = splitRelayPayload(payload);
405
+ return decodeRelayPayload(pubKeyZ32, seq, dnsPacket);
406
+ }
546
407
  /** How far ahead of this clock a packet's time may be and still count as now: clocks drift, not by this much. */
547
408
  var PKARR_FUTURE_SKEW_MS = 6e5;
548
409
  /**
@@ -584,6 +445,7 @@ function withRequestOptions(transport, extra) {
584
445
  if (transport.subscribe) wrapped.subscribe = (listener) => transport.subscribe(listener);
585
446
  if (transport.networkChanged) wrapped.networkChanged = () => transport.networkChanged();
586
447
  if (transport.readAnsweredAt) wrapped.readAnsweredAt = (pubKeyZ32) => transport.readAnsweredAt(pubKeyZ32);
448
+ if (transport.readHeldBackFor) wrapped.readHeldBackFor = (pubKeyZ32) => transport.readHeldBackFor(pubKeyZ32);
587
449
  if (transport.onServerTime) wrapped.onServerTime = (listener) => transport.onServerTime(listener);
588
450
  if (transport.configure) wrapped.configure = (options) => transport.configure(options);
589
451
  if (transport.turnRead) wrapped.turnRead = (pubKeyZ32, options) => transport.turnRead(pubKeyZ32, options);
@@ -798,6 +660,11 @@ var isGroupSignal = (who) => who.group && !who.background && (who.signal || isWa
798
660
  var watchLane = (who) => `${+who.urgent}${+who.background}`;
799
661
  /** A group's read that is not a background one: an edge's, a private group's knock poll (`GROUP_RATION_WINDOW_MS`). */
800
662
  var isGroupRead = (who) => who.group && !who.write && !who.background;
663
+ /**
664
+ * A read that takes its turn per key with the others (`READ_TURN_MS`): a background one, and a 1:1 chat's at any pace.
665
+ * Not a community door's bell.
666
+ */
667
+ var isTurnRead = (who) => !who.write && !who.door && (who.background || !who.group);
801
668
  /** A write that goes first once the budget frees a request (see `WRITE_FIRST_MS`): a chat's or a group's, never a background one. */
802
669
  var firstWriter = (who) => !who.write || who.background ? null : who.group ? "group" : "chat";
803
670
  /**
@@ -826,6 +693,8 @@ function currentRelays(relays) {
826
693
  * only thing that peer is waiting for) would go out only once the polling slowed down, half a minute later.
827
694
  */
828
695
  var WRITE_FIRST_MS = 5e3;
696
+ /** The most of a relay's minute the chats' reserve takes, however many chats need it: the rest stays for everything else. */
697
+ var CHAT_RESERVE_MOST_SHARE = 5 / 6;
829
698
  /**
830
699
  * 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
831
700
  * 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),
@@ -871,6 +740,29 @@ var WATCH_SHARE = 1 / 3;
871
740
  * waits, the others wait too (`WATCH_SHARE`, `GROUP_RATION_WINDOW_MS`). One that stops asking loses it.
872
741
  */
873
742
  var WATCH_TURN_MS = 5e3;
743
+ /**
744
+ * Background reads, and a 1:1 chat's at any pace, take turns per key the same way: while a key read longer ago asked
745
+ * within this long and was not read since, a read of another key waits (a chat's, at a pace that is not the background
746
+ * one, only for another chat's key). A saved chat that is not live reads its contact every 30 s in the
747
+ * background, and one the budget refused read again only at its next poll. A request frees 60 s after the one that took
748
+ * it, which is the poll after next of the chat that took it: the same chats took every request that freed. A daemon with
749
+ * 30 chats and 5 groups, back after a restart while its groups signaled, read some contacts once in 10 min, and those
750
+ * chats stayed down although both sides were online and offering (CLI bug hunt, 2026-10-12). A link's background read
751
+ * the budget refused reads again when it frees a request (`LinkSession`), so the key whose turn it is is there to take it.
752
+ * A chat not live after a restart looks fast while its offer is out, and those reads took no turn: the budget answered
753
+ * them from the packet read before, every 2 to 8 s for 10 minutes, while the chats that were live already, the groups'
754
+ * edges and the writes took every request that freed. A quarter of a daemon's 30 chats read their contact once in 10
755
+ * minutes, their contacts online and offering, and showed them offline at 6 minutes (CLI bug hunt, 2026-10-13).
756
+ */
757
+ var READ_TURN_MS = 5e3;
758
+ /**
759
+ * A 1:1 chat's read the budget held back this long without a break is starved: it goes before the writes that wait
760
+ * (`WRITE_FIRST_MS`) and past the background share, within the minute, the key held back longest first. A daemon back
761
+ * after a restart spends its whole minute on writes (each chat's and edge's offer, its packets after the attempt), which
762
+ * go first, and requests free in bursts as the minute's first requests age out: every burst went to the writes that
763
+ * waited, and the reads of the chats that were not in an attempt never had their turn (CLI restart lab, 2026-10-13).
764
+ */
765
+ var READ_STARVED_MS = 6e4;
874
766
  /** How long a read that let an older one go first waits before it asks again. */
875
767
  var TURN_WAIT_MS = 1e3;
876
768
  /**
@@ -990,6 +882,8 @@ var RelayTransport = class {
990
882
  * it is under the watch share (`WATCH_SHARE`) and while the groups' reads are rationed (`GROUP_RATION_WINDOW_MS`).
991
883
  */
992
884
  readTurns = /* @__PURE__ */ new Map();
885
+ /** Per key read in turn (`isTurnRead`), when a read of it last went out and when it last asked (`READ_TURN_MS`). */
886
+ keyTurns = /* @__PURE__ */ new Map();
993
887
  /** Groups' reads (not background ones) on each relay in the last `GROUP_RATION_WINDOW_MS`. */
994
888
  spentGroupReads = /* @__PURE__ */ new Map();
995
889
  /** When a group's read last found each relay's minute full: its reads there are rationed for `GROUP_RATION_MS`. */
@@ -1009,6 +903,11 @@ var RelayTransport = class {
1009
903
  /** Per relay and key, the newest packet that relay held back for its budget while another relay took it (`catchUpLater`). */
1010
904
  catchUp = /* @__PURE__ */ new Map();
1011
905
  catchUpTimer = null;
906
+ /**
907
+ * Per key whose reads the budget held back since its last read went out: when a request frees for it
908
+ * (`readHeldBackFor`), how many times in a row, and since when (`READ_STARVED_MS`).
909
+ */
910
+ heldBack = /* @__PURE__ */ new Map();
1012
911
  /** When the relays last answered a read of each key, for `FRESH_READ_MS`. */
1013
912
  readAt = /* @__PURE__ */ new Map();
1014
913
  freshReadMs;
@@ -1131,14 +1030,22 @@ var RelayTransport = class {
1131
1030
  }
1132
1031
  /**
1133
1032
  * Signs the records, dated by `nextTimestamp`, and hands the payload to `put` (the relays; the CLI adds the DHT).
1033
+ * When every relay's budget (or rate limit) holds this asker back, it throws that wait before signing anything
1034
+ * (`publishWait`): a daemon at its budget signed about 500 packets a minute that never went out. `elsewhere`: `put`
1035
+ * also takes the packet where no budget holds it back (the CLI's DHT), so it is signed whatever the relays say.
1134
1036
  * `put` calls `conflict` for a relay that answered 409: it holds a packet under this key dated at or after this one,
1135
1037
  * which this client has not read (an inviter's warm packet under the key it gave its contact, from a clock ahead of
1136
1038
  * this one). That packet is read from that relay, and the records go again dated just past it, once. Without this
1137
1039
  * the packet was refused until this device's clock passed the other one's, however long that took, and on a relay
1138
1040
  * that did take it, readers still preferred the later-dated empty packet of another relay.
1139
1041
  */
1140
- async publishDated(identity, records, put, options = {}, again = false) {
1141
- const key = identity.pubKeyZ32, timestamp = this.nextTimestamp(key);
1042
+ async publishDated(identity, records, put, options = {}, again = false, elsewhere = false) {
1043
+ const key = identity.pubKeyZ32;
1044
+ if (!again && !elsewhere) {
1045
+ const held = this.publishWait(key, asker(options, true));
1046
+ if (held) throw new DiscoveryBudgetError(held.wait, `Publish held back on every relay: ${held.reasons.join("; ")}`);
1047
+ }
1048
+ const timestamp = this.nextTimestamp(key);
1142
1049
  let redated;
1143
1050
  const conflict = (relay) => {
1144
1051
  if (again || redated) return;
@@ -1150,7 +1057,7 @@ var RelayTransport = class {
1150
1057
  relay
1151
1058
  });
1152
1059
  if (this.lastTimestamp.get(key) !== timestamp) return true;
1153
- await this.publishDated(identity, records, put, options, true);
1060
+ await this.publishDated(identity, records, put, options, true, elsewhere);
1154
1061
  return true;
1155
1062
  })();
1156
1063
  redated.catch(() => {});
@@ -1162,6 +1069,46 @@ var RelayTransport = class {
1162
1069
  if (!pending || !await pending) throw error;
1163
1070
  }
1164
1071
  }
1072
+ /**
1073
+ * 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,
1074
+ * without asking any: a relay the budget holds back gets its refused `take` (the asker's write then waits first in
1075
+ * line, as when `put` is refused), one rate limiting or throttling this address is a wait, one left alone for failing
1076
+ * is none. Null too when a relay fails another way (its network cooling down): the publish then goes, and fails, as
1077
+ * before, and when no relay is held back at all.
1078
+ */
1079
+ publishWait(pubKeyZ32, who) {
1080
+ const probe = this.breaker.allDownProbe(this.relays);
1081
+ let wait = Infinity;
1082
+ const held = [], reasons = [];
1083
+ for (const relay of this.relays) {
1084
+ if (relay !== probe && this.networkCoolingDown(relay, "PUT")) return null;
1085
+ const limited = this.rateLimitedFor(relay);
1086
+ if (limited > 0) {
1087
+ wait = Math.min(wait, limited);
1088
+ reasons.push(`${relay}: Discovery relay is cooling down after a 429; retry shortly`);
1089
+ continue;
1090
+ }
1091
+ const blocked = relay === probe ? 0 : this.breaker.blockedFor(relay);
1092
+ if (blocked > 0) {
1093
+ if (this.breaker.blockedKind(relay) === "throttled") {
1094
+ wait = Math.min(wait, blocked);
1095
+ reasons.push(`${relay}: Discovery relay is throttling this address; retry shortly`);
1096
+ } else reasons.push(`${relay} is left alone after failing`);
1097
+ continue;
1098
+ }
1099
+ if (this.heldFor(relay, who, Date.now(), pubKeyZ32) <= 0) return null;
1100
+ held.push(relay);
1101
+ const left = this.heldFor(relay, who);
1102
+ wait = Math.min(wait, left);
1103
+ reasons.push(`${relay}: discovery budget reached; retry in ${Math.ceil(left / 1e3)} s`);
1104
+ }
1105
+ if (wait === Infinity) return null;
1106
+ for (const relay of held) this.take(relay, who, pubKeyZ32);
1107
+ return {
1108
+ wait,
1109
+ reasons
1110
+ };
1111
+ }
1165
1112
  /** One relay's packet for a key, read now: its time, kept for `nextTimestamp`. Null when it has none or does not answer. */
1166
1113
  async heldAt(relay, pubKeyZ32, who) {
1167
1114
  if (!this.take(relay, who, pubKeyZ32)) return null;
@@ -1181,10 +1128,22 @@ var RelayTransport = class {
1181
1128
  sawTimestamp(pubKeyZ32, timestamp) {
1182
1129
  if (timestamp > (this.seenTimestamp.get(pubKeyZ32) ?? 0n)) this.seenTimestamp.set(pubKeyZ32, timestamp);
1183
1130
  }
1184
- /** Puts a payload signed elsewhere (a did:dht document), byte for byte, on every relay. `conflict`: as `publishDated` gives it. */
1131
+ /**
1132
+ * Puts a payload `publishDated` just signed, dated `timestamp`, on every relay. Its signature is not checked: it was
1133
+ * made here (`publishPayload` checks one signed elsewhere, to read its sequence number). `conflict`: as `publishDated`
1134
+ * gives it.
1135
+ */
1136
+ async putSigned(pubKeyZ32, payload, timestamp, options = {}, conflict) {
1137
+ if (this.relays.length === 0) throw new Error("No Pkarr relays configured");
1138
+ await this.putEverywhere(pubKeyZ32, payload, timestamp, options, conflict);
1139
+ }
1140
+ /**
1141
+ * Puts a payload signed elsewhere (a did:dht document), byte for byte, on every relay; one signed here says its date
1142
+ * (`signedAt`) and is not checked again. `conflict`: as `publishDated` gives it.
1143
+ */
1185
1144
  async publishPayload(pubKeyZ32, payload, options = {}, conflict) {
1186
1145
  if (this.relays.length === 0) throw new Error("No Pkarr relays configured");
1187
- const { seq } = openRelayPayload(pubKeyZ32, payload);
1146
+ const seq = options.signedAt ?? openRelayPayload(pubKeyZ32, payload).seq;
1188
1147
  await this.putEverywhere(pubKeyZ32, payload, seq, options, conflict);
1189
1148
  }
1190
1149
  /**
@@ -1225,7 +1184,7 @@ var RelayTransport = class {
1225
1184
  accepted = true;
1226
1185
  noWriteWaits();
1227
1186
  try {
1228
- this.written.set(pubKeyZ32, parseRelayPayload(pubKeyZ32, payload));
1187
+ this.written.set(pubKeyZ32, readOwnRelayPayload(pubKeyZ32, payload));
1229
1188
  } catch {}
1230
1189
  resolve();
1231
1190
  }, () => {});
@@ -1398,8 +1357,14 @@ var RelayTransport = class {
1398
1357
  }
1399
1358
  const resting = this.relays.every((r) => this.isCoolingDown(r, "GET") || this.breaker.blockedFor(r) > 0 || this.heldFor(r, who, Date.now(), pubKeyZ32) > 0);
1400
1359
  const known = down ? this.newest.get(pubKeyZ32) : newerPacket(this.newest.get(pubKeyZ32), this.written.get(pubKeyZ32));
1401
- if (resting && known) return known;
1402
- if (!down && budgetWait < Infinity) throw new DiscoveryBudgetError(budgetWait);
1360
+ if (resting && known) {
1361
+ if (!down && budgetWait < Infinity) this.heldBackUntil(pubKeyZ32, Date.now() + budgetWait);
1362
+ return known;
1363
+ }
1364
+ if (!down && budgetWait < Infinity) {
1365
+ this.heldBackUntil(pubKeyZ32, Date.now() + budgetWait);
1366
+ throw new DiscoveryBudgetError(budgetWait);
1367
+ }
1403
1368
  throw new Error("No Pkarr relay reachable");
1404
1369
  }
1405
1370
  /** The order a key's read asks the relays in: in turn per key (see `resolve`), relays found slow lately last. */
@@ -1466,9 +1431,25 @@ var RelayTransport = class {
1466
1431
  readAnsweredAt(pubKeyZ32) {
1467
1432
  return this.readAt.get(pubKeyZ32);
1468
1433
  }
1434
+ readHeldBackFor(pubKeyZ32) {
1435
+ const held = this.heldBack.get(pubKeyZ32);
1436
+ const left = held === void 0 ? 0 : held.until - Date.now();
1437
+ return left > 0 ? left : void 0;
1438
+ }
1439
+ heldBackUntil(pubKeyZ32, until) {
1440
+ const held = this.heldBack.get(pubKeyZ32), times = (held?.times ?? 0) + 1;
1441
+ this.heldBack.delete(pubKeyZ32);
1442
+ this.heldBack.set(pubKeyZ32, {
1443
+ until,
1444
+ times,
1445
+ since: held?.since ?? Date.now()
1446
+ });
1447
+ if (this.heldBack.size > READ_TURNS_KEPT) this.heldBack.delete(this.heldBack.keys().next().value);
1448
+ }
1469
1449
  /** The relays answered a read of this key now; answers older than `FRESH_READ_MS` are dropped as the list grows. */
1470
1450
  answeredRead(pubKeyZ32) {
1471
1451
  const now = Date.now();
1452
+ this.heldBack.delete(pubKeyZ32);
1472
1453
  this.readAt.delete(pubKeyZ32);
1473
1454
  this.readAt.set(pubKeyZ32, now);
1474
1455
  if (this.readAt.size > 64) for (const [key, at] of this.readAt) {
@@ -1584,6 +1565,7 @@ var RelayTransport = class {
1584
1565
  if (isChat(who)) this.chatNeed(key).urgentAt = now;
1585
1566
  }
1586
1567
  if (isGroupRead(who)) this.readAsked(key, who, now);
1568
+ if (isTurnRead(who)) this.turnAsked(key, who, now);
1587
1569
  if (this.heldFor(relay, who, now, key) > 0) {
1588
1570
  if (isGroupRead(who) && (this.spent.get(relay)?.length ?? 0) >= this.limitOf(relay)) this.groupsFullAt.set(relay, now);
1589
1571
  if (isChat(who)) this.chatNeed(key).refused.set(relay, now);
@@ -1607,6 +1589,12 @@ var RelayTransport = class {
1607
1589
  lane: watchLane(who)
1608
1590
  });
1609
1591
  }
1592
+ if (isTurnRead(who)) this.keyTurns.set(key, {
1593
+ servedAt: now,
1594
+ askedAt: now,
1595
+ waiting: false,
1596
+ who
1597
+ });
1610
1598
  return true;
1611
1599
  }
1612
1600
  /**
@@ -1618,7 +1606,7 @@ var RelayTransport = class {
1618
1606
  * signaling goes over the limit by its allowance (`SIGNALING_ALLOWANCE_SHARE`), and so do a few of its reads of a
1619
1607
  * contact that just went away (`DEPARTED_READS`).
1620
1608
  */
1621
- heldFor(relay, who, now = Date.now(), key) {
1609
+ heldFor(relay, who, now = Date.now(), key, turns = true) {
1622
1610
  const recent = (this.spent.get(relay) ?? []).filter((at) => now - at < 6e4);
1623
1611
  const recentBackground = (this.spentBackground.get(relay) ?? []).filter((at) => now - at < 6e4);
1624
1612
  this.spent.set(relay, recent);
@@ -1628,10 +1616,11 @@ var RelayTransport = class {
1628
1616
  const departed = isDepartedRead(who) && key !== void 0 && recent.length >= limit && this.departedReads(key, now) < 6;
1629
1617
  const share = isChatSignal(who) || departed ? SIGNALING_ALLOWANCE_SHARE : isGroupSignal(who) ? GROUP_SIGNALING_ALLOWANCE_SHARE : 0;
1630
1618
  let wait = over(recent, limit + (share ? Math.max(0, Math.floor(limit * share) - (who.write ? 0 : 1)) : 0));
1619
+ const starved = key !== void 0 && this.starved(key, who, now);
1631
1620
  const groupOver = (this.spentGroupOver.get(relay) ?? []).filter((at) => now - at < GROUP_OVER_PACE_MS);
1632
1621
  this.spentGroupOver.set(relay, groupOver);
1633
1622
  if (isGroupSignal(who) && !who.write && recent.length >= limit && groupOver.length > 0) wait = Math.max(wait, groupOver[groupOver.length - 1] + GROUP_OVER_PACE_MS - now);
1634
- if (who.background) {
1623
+ if (who.background && !starved) {
1635
1624
  wait = Math.max(wait, over(recentBackground, this.backgroundPerMinute));
1636
1625
  const signaling = this.urgentAt + SIGNALING_WINDOW_MS - now;
1637
1626
  if (signaling > 0 && !who.door) wait = Math.max(wait, Math.min(signaling, over(recentBackground, this.backgroundWhileSignaling)));
@@ -1657,17 +1646,18 @@ var RelayTransport = class {
1657
1646
  if (rationed.length >= most) wait = Math.max(wait, rationed[rationed.length - most] + GROUP_RATION_WINDOW_MS - now);
1658
1647
  if (this.readsAhead(key, now) > 0) wait = Math.max(wait, TURN_WAIT_MS);
1659
1648
  }
1660
- if (who.group) {
1649
+ if (who.group || who.background) {
1661
1650
  const reserved = this.chatReservedFor(relay, now);
1662
- if (reserved > 0) wait = Math.max(wait, Math.min(reserved, over(recent, limit - this.reserveOf(relay))));
1651
+ if (reserved > 0) wait = Math.max(wait, Math.min(reserved, over(recent, limit - this.chatReserveOf(relay, now))));
1663
1652
  }
1664
1653
  const writer = firstWriter(who);
1665
1654
  const waiting = (lane) => {
1666
1655
  const at = this.writeWaiting.get(`${lane} ${relay}`);
1667
1656
  return at !== void 0 && now - at < 5e3 ? at + WRITE_FIRST_MS - now : 0;
1668
1657
  };
1669
- if (writer !== "chat" && !departed) wait = Math.max(wait, waiting("chat"));
1670
- if (writer === null && who.group && !pastLimit || who.background) wait = Math.max(wait, waiting("group"));
1658
+ if (writer !== "chat" && !departed && !starved) wait = Math.max(wait, waiting("chat"));
1659
+ if ((writer === null && who.group && !pastLimit || who.background) && !starved) wait = Math.max(wait, waiting("group"));
1660
+ if (turns && wait <= 0 && isTurnRead(who) && this.turnAhead(relay, key, who, now)) wait = TURN_WAIT_MS;
1671
1661
  return Math.max(wait, 0);
1672
1662
  }
1673
1663
  /** A group's read of `key` asks: it waits for its turn from now on, unless it is taken (`readsAhead`). */
@@ -1697,6 +1687,39 @@ var RelayTransport = class {
1697
1687
  }
1698
1688
  return ahead;
1699
1689
  }
1690
+ /** A read of `key` that takes turns asks: it waits for its turn from now on, unless it is taken (`turnAhead`). */
1691
+ turnAsked(key, who, now) {
1692
+ for (const [other, turn] of this.keyTurns) if (now - Math.max(turn.askedAt, turn.servedAt) >= 6e4) this.keyTurns.delete(other);
1693
+ this.keyTurns.set(key, {
1694
+ servedAt: this.keyTurns.get(key)?.servedAt ?? -Infinity,
1695
+ askedAt: now,
1696
+ waiting: true,
1697
+ who
1698
+ });
1699
+ }
1700
+ /**
1701
+ * Whether a key read in turn longer ago than `key`, still asking, would take this relay's next request: one read in the
1702
+ * background for a background read, and a chat's that is starved (`READ_STARVED_MS`) for any. One asks again after
1703
+ * the budget held it back (a link's poll does, a one-off look does not), and keeps asking within `READ_TURN_MS` of its
1704
+ * last ask, or of when it was told the budget frees a request for it (`readHeldBackFor`).
1705
+ * One the budget holds back on its own account (a group's read, for the chats' reserve) keeps no turn: a group key
1706
+ * waiting for the chats held every chat's read back (CLI lab, 2026-10-13).
1707
+ */
1708
+ turnAhead(relay, key, who, now) {
1709
+ const mine = key === void 0 ? -Infinity : this.keyTurns.get(key)?.servedAt ?? -Infinity;
1710
+ for (const [other, turn] of this.keyTurns) {
1711
+ const held = this.heldBack.get(other);
1712
+ if (other === key || !turn.waiting || turn.servedAt >= mine || !held || held.times < 2) continue;
1713
+ if (!(who.background && turn.who.background) && !this.starved(other, turn.who, now)) continue;
1714
+ if (now - Math.max(turn.askedAt, held.until) < READ_TURN_MS && this.heldFor(relay, turn.who, now, other, false) === 0) return true;
1715
+ }
1716
+ return false;
1717
+ }
1718
+ /** A 1:1 chat's read of `key` the budget has held back for `READ_STARVED_MS` without a break. */
1719
+ starved(key, who, now) {
1720
+ const since = this.heldBack.get(key)?.since;
1721
+ return !who.group && !who.write && !who.door && since !== void 0 && now - since >= READ_STARVED_MS;
1722
+ }
1700
1723
  /** This contact's departed reads past the limit in the last minute (`DEPARTED_READS`). */
1701
1724
  departedReads(key, now) {
1702
1725
  const reads = (this.departedOver.get(key) ?? []).filter((at) => now - at < 6e4);
@@ -1735,6 +1758,12 @@ var RelayTransport = class {
1735
1758
  if (share === void 0) return this.perMinute;
1736
1759
  return this.perMinuteGiven ? Math.min(this.perMinute, share) : share;
1737
1760
  }
1761
+ /** What the chats that need the relays keep on this relay (`CHAT_RESERVE_PER_CHAT`), never less than `reserveOf`. */
1762
+ chatReserveOf(relay, now) {
1763
+ let needing = 0;
1764
+ for (const need of this.chatNeeds.values()) if (now - Math.max(need.urgentAt, need.refused.get(relay) ?? -Infinity) < 6e4) needing++;
1765
+ return Math.max(this.reserveOf(relay), Math.min(needing * 3, Math.floor(this.limitOf(relay) * CHAT_RESERVE_MOST_SHARE)));
1766
+ }
1738
1767
  /** What groups leave a chat on this relay: `CHAT_RESERVE`, or a third of a smaller budget. */
1739
1768
  reserveOf(relay) {
1740
1769
  return Math.min(10, Math.floor(this.limitOf(relay) / 3));
@@ -3467,9 +3496,9 @@ var HANDOFF_CANCEL_REASONS = [
3467
3496
  "stalled"
3468
3497
  ];
3469
3498
  var KEY$3 = /^[A-Za-z0-9_-]{43}$/;
3470
- var DIGEST$2 = KEY$3;
3499
+ var DIGEST$1 = KEY$3;
3471
3500
  var SIGNATURE = /^[A-Za-z0-9_-]{86}$/;
3472
- var ID$5 = /^[A-Za-z0-9_-]{22}$/;
3501
+ var ID$4 = /^[A-Za-z0-9_-]{22}$/;
3473
3502
  var B64$4 = /^[A-Za-z0-9_-]*$/;
3474
3503
  var HEX = /^[0-9a-f]{64}$/;
3475
3504
  var VERSION_TEXT = /^[0-9A-Za-z.+_-]{1,40}$/;
@@ -3483,9 +3512,9 @@ var isHandoffPartName = (name) => typeof name === "string" && PART.test(name);
3483
3512
  /** The part a file is: `file/<its SHA-256, base64url>`. */
3484
3513
  var filePartName = (sha256B64) => `file/${sha256B64}`;
3485
3514
  /** The digest a file part names, or null for any other part. */
3486
- var filePartDigest = (name) => name.startsWith("file/") && DIGEST$2.test(name.slice(5)) ? name.slice(5) : null;
3515
+ var filePartDigest = (name) => name.startsWith("file/") && DIGEST$1.test(name.slice(5)) ? name.slice(5) : null;
3487
3516
  function isPart(value) {
3488
- return Array.isArray(value) && value.length === 3 && isHandoffPartName(value[0]) && isCount$1(value[1], MAX_PART) && isText$2(value[2], DIGEST$2) && (filePartDigest(value[0]) === null || filePartDigest(value[0]) === value[2]);
3517
+ return Array.isArray(value) && value.length === 3 && isHandoffPartName(value[0]) && isCount$1(value[1], MAX_PART) && isText$2(value[2], DIGEST$1) && (filePartDigest(value[0]) === null || filePartDigest(value[0]) === value[2]);
3489
3518
  }
3490
3519
  /** Names compared as UTF-8 byte strings, as `H` sorts them. */
3491
3520
  function compareNames(a, b) {
@@ -3607,7 +3636,7 @@ function readHandoffHello(frame) {
3607
3636
  if (typeof frame.room !== "number" || !Number.isSafeInteger(frame.room) || frame.room < -1) return null;
3608
3637
  if (typeof frame.metered !== "boolean") return null;
3609
3638
  if (frame.later !== void 0 && !isCount$1(frame.later)) return null;
3610
- if (frame.id !== void 0 && !isText$2(frame.id, ID$5)) return null;
3639
+ if (frame.id !== void 0 && !isText$2(frame.id, ID$4)) return null;
3611
3640
  const kind = DEVICE_KINDS.includes(frame.kind) ? frame.kind : "web";
3612
3641
  return {
3613
3642
  v: 1,
@@ -3633,7 +3662,7 @@ var handoffOfferFrame = (turn, id, bytes) => ({
3633
3662
  });
3634
3663
  function readHandoffTurnFrame(frame) {
3635
3664
  if (frame.t !== "handoff-request" && frame.t !== "handoff-offer") return null;
3636
- if (!isCount$1(frame.turn, TURN_MAX) || !isText$2(frame.id, ID$5)) return null;
3665
+ if (!isCount$1(frame.turn, TURN_MAX) || !isText$2(frame.id, ID$4)) return null;
3637
3666
  if (frame.t === "handoff-offer") return isCount$1(frame.bytes) ? {
3638
3667
  turn: frame.turn,
3639
3668
  id: frame.id,
@@ -3676,7 +3705,7 @@ function readHandoffPake(frame) {
3676
3705
  };
3677
3706
  }
3678
3707
  function readHandoffHave(frame) {
3679
- if (frame.t !== "handoff-have" || !Array.isArray(frame.d) || frame.d.length > 300 || !frame.d.every((d) => isText$2(d, DIGEST$2))) return null;
3708
+ if (frame.t !== "handoff-have" || !Array.isArray(frame.d) || frame.d.length > 300 || !frame.d.every((d) => isText$2(d, DIGEST$1))) return null;
3680
3709
  const p = {};
3681
3710
  if (frame.p !== void 0) {
3682
3711
  if (!frame.p || typeof frame.p !== "object" || Array.isArray(frame.p) || Object.keys(frame.p).length > 300) return null;
@@ -3732,7 +3761,7 @@ function readHandoffAck(frame) {
3732
3761
  }
3733
3762
  /** `H` of a `handoff-verified` that B's key signed for this turn address and turn, or null. */
3734
3763
  function readHandoffVerified(frame, turnAddress, turn, takerKey) {
3735
- if (frame.t !== "handoff-verified" || !isText$2(frame.h, DIGEST$2) || !isText$2(frame.s, SIGNATURE)) return null;
3764
+ if (frame.t !== "handoff-verified" || !isText$2(frame.h, DIGEST$1) || !isText$2(frame.s, SIGNATURE)) return null;
3736
3765
  const h = fromBase64Url(frame.h);
3737
3766
  try {
3738
3767
  return verify(fromBase64Url(frame.s), handoffVerifiedMessage(turnAddress, turn, h), takerKey) ? h : null;
@@ -3800,7 +3829,7 @@ var handoffAttemptSucceeded = () => ({
3800
3829
  /** Seconds until a locked-out device may try again (for `handoff-busy`). */
3801
3830
  var handoffRetryAfter = (attempts, now) => attempts?.until !== void 0 && attempts.until > now ? Math.ceil((attempts.until - now) / 1e3) : 0;
3802
3831
  var HTTP_SERVICE_PROTO = "ghostly-http/1";
3803
- var MAX_NAME_LENGTH$1 = 48;
3832
+ var MAX_NAME_LENGTH = 48;
3804
3833
  var MAX_META_ENTRIES = 8;
3805
3834
  var MAX_META_LENGTH = 64;
3806
3835
  var SERVICE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
@@ -3881,7 +3910,7 @@ function servicesFromWire(wire) {
3881
3910
  id: e.i,
3882
3911
  type: e.t
3883
3912
  };
3884
- const name = cleanString(e.n, MAX_NAME_LENGTH$1);
3913
+ const name = cleanString(e.n, MAX_NAME_LENGTH);
3885
3914
  const proto = cleanString(e.p, MAX_META_LENGTH);
3886
3915
  if (name) ad.name = name;
3887
3916
  if (proto) ad.proto = proto;
@@ -4727,304 +4756,6 @@ function buildDataSdp(signal) {
4727
4756
  return lines.join("\r\n") + "\r\n";
4728
4757
  }
4729
4758
  //#endregion
4730
- //#region ../core/src/frames.ts
4731
- /**
4732
- * Framing on the `ghostly/1` DataChannel (ordered, reliable).
4733
- *
4734
- * - Text messages are control frames: compact JSON with a `t` discriminator,
4735
- * the same shape Ghostly already uses for `_msgs` and `_call`.
4736
- * - Binary messages are body chunks that belong to a stream opened by a
4737
- * control frame: `<kind u8><stream id u32 BE><flags u8><payload>`.
4738
- *
4739
- * Everything here handles data from the remote peer and must never trust it.
4740
- */
4741
- var LIMITS = {
4742
- /** Largest control frame accepted, headers included. */
4743
- maxControlFrameBytes: 61440,
4744
- /** Longest payment endpoint value (a Cashu token, an invoice) a payment frame may carry; a longer one is dropped. */
4745
- maxPaymentEndpointChars: 32768,
4746
- /** Largest DataChannel message sent. 16 KiB is safe across WebRTC stacks. */
4747
- maxChunkMessageBytes: 16384,
4748
- maxRequestBodyBytes: 8388608,
4749
- maxResponseBodyBytes: 67108864,
4750
- maxHeaderCount: 128,
4751
- maxHeaderValueBytes: 16384,
4752
- maxPathBytes: 8192,
4753
- /** Requests a host serves at once for one peer. */
4754
- maxConcurrentRequestsPerPeer: 32,
4755
- /** Requests a client keeps in flight; the rest wait in a queue. */
4756
- maxClientInFlight: 16,
4757
- maxChatMessageBytes: 16384,
4758
- maxFileBytes: 104857600,
4759
- /** Files a peer may be sending us at once. */
4760
- maxIncomingFilesPerPeer: 3,
4761
- /** Received bytes kept for one peer, in flight included. Past it new files are refused. */
4762
- maxStoredIncomingBytesPerPeer: 524288e3,
4763
- requestTimeoutMs: 6e4,
4764
- bodyIdleTimeoutMs: 3e4,
4765
- /** Stop writing to the channel above this many buffered bytes. */
4766
- sendHighWaterMark: 1048576,
4767
- sendLowWaterMark: 262144
4768
- };
4769
- var CHUNK_KIND = {
4770
- requestBody: 1,
4771
- responseBody: 2,
4772
- fileBody: 3
4773
- };
4774
- var CHUNK_HEADER_BYTES = 6;
4775
- var MAX_CHUNK_PAYLOAD_BYTES = LIMITS.maxChunkMessageBytes - CHUNK_HEADER_BYTES;
4776
- function encodeControl(frame) {
4777
- const json = JSON.stringify(frame);
4778
- if (utf8Encode(json).length > LIMITS.maxControlFrameBytes) throw new Error("Control frame too large");
4779
- return json;
4780
- }
4781
- function isHeaderList(value) {
4782
- if (!Array.isArray(value) || value.length > LIMITS.maxHeaderCount) return false;
4783
- return value.every((h) => Array.isArray(h) && h.length === 2 && typeof h[0] === "string" && typeof h[1] === "string" && h[0].length <= 256 && h[1].length <= LIMITS.maxHeaderValueBytes);
4784
- }
4785
- function isStreamId(value) {
4786
- return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 4294967295;
4787
- }
4788
- function isPayId(value) {
4789
- return typeof value === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(value);
4790
- }
4791
- /** Decimal text, as in Paykit's Payment Amount: no floats on the wire. */
4792
- function isAmount(value) {
4793
- return typeof value === "string" && /^(0|[1-9]\d{0,15})(\.\d{1,8})?$/.test(value);
4794
- }
4795
- function isUnit(value) {
4796
- return typeof value === "string" && /^[a-z0-9]{1,12}$/.test(value);
4797
- }
4798
- function isEndpoint(value) {
4799
- return Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && value[0].length <= 64 && /^[a-z0-9][a-z0-9-]*(?:\/[1-9][0-9]*)?$/.test(value[0]) && typeof value[1] === "string" && value[1].length <= LIMITS.maxPaymentEndpointChars;
4800
- }
4801
- /** Returns null for anything malformed; callers drop the frame. */
4802
- function decodeControl(text) {
4803
- if (text.length > LIMITS.maxControlFrameBytes || utf8Encode(text).length > LIMITS.maxControlFrameBytes) return null;
4804
- let raw;
4805
- try {
4806
- raw = JSON.parse(text);
4807
- } catch {
4808
- return null;
4809
- }
4810
- if (!raw || typeof raw !== "object") return null;
4811
- const f = raw;
4812
- switch (f.t) {
4813
- case "hello":
4814
- if (typeof f.v !== "number") return null;
4815
- return {
4816
- t: "hello",
4817
- v: f.v,
4818
- svc: f.svc,
4819
- nick: sanitizeNick(f.nick)
4820
- };
4821
- case "m":
4822
- if (typeof f.ts !== "number" || typeof f.m !== "string") return null;
4823
- if (utf8Encode(f.m).length > LIMITS.maxChatMessageBytes) return null;
4824
- return {
4825
- t: "m",
4826
- ts: f.ts,
4827
- m: f.m
4828
- };
4829
- case "call":
4830
- if (typeof f.s !== "string" || utf8Encode(f.s).length > LIMITS.maxChatMessageBytes) return null;
4831
- return {
4832
- t: "call",
4833
- s: f.s
4834
- };
4835
- case "svc": return {
4836
- t: "svc",
4837
- svc: f.svc
4838
- };
4839
- case "req":
4840
- if (!isStreamId(f.id) || typeof f.s !== "string" || typeof f.m !== "string") return null;
4841
- if (typeof f.p !== "string" || f.p.length > LIMITS.maxPathBytes || !isHeaderList(f.h)) return null;
4842
- return {
4843
- t: "req",
4844
- id: f.id,
4845
- s: f.s,
4846
- m: f.m,
4847
- p: f.p,
4848
- h: f.h,
4849
- b: f.b === true
4850
- };
4851
- case "res":
4852
- if (!isStreamId(f.id) || typeof f.st !== "number" || !Number.isInteger(f.st)) return null;
4853
- if (f.st < 100 || f.st > 599 || !isHeaderList(f.h)) return null;
4854
- return {
4855
- t: "res",
4856
- id: f.id,
4857
- st: f.st,
4858
- h: f.h,
4859
- b: f.b === true
4860
- };
4861
- case "file":
4862
- if (!isStreamId(f.id) || typeof f.f !== "string" || typeof f.ts !== "number") return null;
4863
- if (typeof f.n !== "string" || typeof f.m !== "string") return null;
4864
- if (typeof f.s !== "number" || !Number.isSafeInteger(f.s) || f.s < 0) return null;
4865
- return {
4866
- t: "file",
4867
- id: f.id,
4868
- f: f.f,
4869
- ts: f.ts,
4870
- n: f.n,
4871
- s: f.s,
4872
- m: f.m
4873
- };
4874
- case "pay-req":
4875
- case "pay": {
4876
- if (!isPayId(f.id) || typeof f.ts !== "number" || !isAmount(f.v) || !isUnit(f.u)) return null;
4877
- const memo = typeof f.memo === "string" ? f.memo.slice(0, 140) : void 0;
4878
- if (f.t === "pay-req") {
4879
- if (!Array.isArray(f.e) || f.e.length === 0 || f.e.length > 8 || !f.e.every(isEndpoint)) return null;
4880
- return {
4881
- t: "pay-req",
4882
- id: f.id,
4883
- ts: f.ts,
4884
- v: f.v,
4885
- u: f.u,
4886
- memo,
4887
- e: f.e,
4888
- a: isPayId(f.a) ? f.a : void 0,
4889
- n: f.n === "mainnet" || f.n === "testnet" ? f.n : void 0
4890
- };
4891
- }
4892
- if (!isEndpoint(f.e) || f.rid !== void 0 && !isPayId(f.rid)) return null;
4893
- return {
4894
- t: "pay",
4895
- id: f.id,
4896
- ts: f.ts,
4897
- rid: f.rid,
4898
- v: f.v,
4899
- u: f.u,
4900
- memo,
4901
- e: f.e
4902
- };
4903
- }
4904
- case "pay-ask":
4905
- if (!isPayId(f.id) || typeof f.ts !== "number" || !isAmount(f.v) || !isUnit(f.u)) return null;
4906
- if (f.m !== "arkade" && f.m !== "usdt" && f.m !== "bark" && f.m !== "bitcoin" && f.m !== "fedimint" && f.m !== "spark") return null;
4907
- return {
4908
- t: "pay-ask",
4909
- id: f.id,
4910
- ts: f.ts,
4911
- v: f.v,
4912
- u: f.u,
4913
- m: f.m,
4914
- memo: typeof f.memo === "string" ? f.memo.slice(0, 140) : void 0,
4915
- n: f.n === "mainnet" || f.n === "testnet" ? f.n : void 0
4916
- };
4917
- case "pay-res":
4918
- if (!isPayId(f.id) || typeof f.ok !== "boolean") return null;
4919
- if (f.v !== void 0 && !isAmount(f.v)) return null;
4920
- return {
4921
- t: "pay-res",
4922
- id: f.id,
4923
- ok: f.ok,
4924
- v: f.v,
4925
- err: typeof f.err === "string" ? f.err.slice(0, 200) : void 0,
4926
- ...f.c === true && !f.ok ? { c: true } : {}
4927
- };
4928
- case "rst":
4929
- if (!isStreamId(f.id) || f.d !== "q" && f.d !== "s" && f.d !== "f") return null;
4930
- return {
4931
- t: "rst",
4932
- id: f.id,
4933
- d: f.d,
4934
- e: typeof f.e === "string" ? f.e.slice(0, 256) : ""
4935
- };
4936
- case "ping":
4937
- case "pong":
4938
- if (typeof f.ts !== "number") return null;
4939
- return {
4940
- t: f.t,
4941
- ts: f.ts
4942
- };
4943
- default: return null;
4944
- }
4945
- }
4946
- function encodeChunk(chunk) {
4947
- if (chunk.payload.length > MAX_CHUNK_PAYLOAD_BYTES) throw new Error("Chunk payload too large");
4948
- const out = new Uint8Array(CHUNK_HEADER_BYTES + chunk.payload.length);
4949
- const view = new DataView(out.buffer);
4950
- view.setUint8(0, chunk.kind);
4951
- view.setUint32(1, chunk.id);
4952
- view.setUint8(5, chunk.end ? 1 : 0);
4953
- out.set(chunk.payload, CHUNK_HEADER_BYTES);
4954
- return out;
4955
- }
4956
- function decodeChunk(data) {
4957
- if (data.length < CHUNK_HEADER_BYTES || data.length > LIMITS.maxChunkMessageBytes) return null;
4958
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
4959
- const kind = view.getUint8(0);
4960
- if (kind !== CHUNK_KIND.requestBody && kind !== CHUNK_KIND.responseBody && kind !== CHUNK_KIND.fileBody) return null;
4961
- return {
4962
- kind,
4963
- id: view.getUint32(1),
4964
- end: (view.getUint8(5) & 1) !== 0,
4965
- payload: data.subarray(CHUNK_HEADER_BYTES)
4966
- };
4967
- }
4968
- function wrapDataChannel(dc) {
4969
- dc.binaryType = "arraybuffer";
4970
- dc.bufferedAmountLowThreshold = LIMITS.sendLowWaterMark;
4971
- const channel = {
4972
- send(data) {
4973
- if (dc.readyState !== "open") throw new Error("Data link is not open");
4974
- if (typeof data === "string") dc.send(data);
4975
- else dc.send(data);
4976
- },
4977
- get bufferedAmount() {
4978
- return dc.bufferedAmount;
4979
- },
4980
- drained() {
4981
- if (dc.bufferedAmount <= LIMITS.sendLowWaterMark || dc.readyState !== "open") return Promise.resolve();
4982
- return new Promise((resolve) => {
4983
- const done = () => {
4984
- dc.removeEventListener("bufferedamountlow", done);
4985
- dc.removeEventListener("close", done);
4986
- resolve();
4987
- };
4988
- dc.addEventListener("bufferedamountlow", done);
4989
- dc.addEventListener("close", done);
4990
- });
4991
- },
4992
- close() {
4993
- dc.close();
4994
- },
4995
- onMessage: null,
4996
- onClose: null
4997
- };
4998
- dc.addEventListener("message", (event) => {
4999
- const data = event.data;
5000
- if (typeof data === "string") channel.onMessage?.(data);
5001
- else if (data instanceof ArrayBuffer) channel.onMessage?.(new Uint8Array(data));
5002
- });
5003
- dc.addEventListener("close", () => channel.onClose?.());
5004
- return channel;
5005
- }
5006
- /** Writes a body as chunks, honouring backpressure. Always ends with an END chunk. */
5007
- async function sendBody(channel, kind, id, body, isCancelled = () => false) {
5008
- for await (const part of body) for (let offset = 0; offset < part.length; offset += MAX_CHUNK_PAYLOAD_BYTES) {
5009
- if (isCancelled()) return;
5010
- if (channel.bufferedAmount > LIMITS.sendHighWaterMark) await channel.drained();
5011
- const payload = part.subarray(offset, offset + MAX_CHUNK_PAYLOAD_BYTES);
5012
- channel.send(encodeChunk({
5013
- kind,
5014
- id,
5015
- end: false,
5016
- payload
5017
- }));
5018
- }
5019
- if (isCancelled()) return;
5020
- channel.send(encodeChunk({
5021
- kind,
5022
- id,
5023
- end: true,
5024
- payload: /* @__PURE__ */ new Uint8Array(0)
5025
- }));
5026
- }
5027
- //#endregion
5028
4759
  //#region ../core/src/http.ts
5029
4760
  var LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
5030
4761
  "localhost",
@@ -5601,6 +5332,19 @@ var STALLED_LOOK_MS = 15e3;
5601
5332
  * #1441, on a loaded host), and past it the race's DHT dial has brought the chat back anyway.
5602
5333
  */
5603
5334
  var DEPARTED_READ_MS = EXPECT_PEER_MS;
5335
+ /** A chat whose contact was never seen (an invite just sent) keeps looking at the active pace this long. */
5336
+ var AWAITING_PEER_MS = 6e5;
5337
+ /**
5338
+ * An invite this side made that nobody used, once no longer awaited (`AWAITING_PEER_MS`), is read less often the older
5339
+ * it is: a fifth of its age between reads, up to this. Read at the background pace instead, 15 unused invites (their
5340
+ * keys and their mailboxes) asked the relays for 60 reads a minute for as long as the app ran: the relays' minute went
5341
+ * to invites nobody may ever open, and a join on one was noticed 90 s later (bug hunt 1013-1642, 2026-10-13).
5342
+ */
5343
+ var UNUSED_INVITE_WAIT_MAX_MS = 18e4;
5344
+ /** The wait between reads of an unused invite of age `age` (`UNUSED_INVITE_WAIT_MAX_MS`), never shorter than the pace's `interval`. */
5345
+ function unusedInviteWait(age, interval) {
5346
+ return Math.max(interval, Math.min(UNUSED_INVITE_WAIT_MAX_MS, Math.round(age / 5)));
5347
+ }
5604
5348
  var PUBLISH_RETRY_MS = 4e3;
5605
5349
  /**
5606
5350
  * A packet the relays' request budget held back goes again when the budget frees a request, and at least this often
@@ -5656,6 +5400,8 @@ var LinkSession = class {
5656
5400
  startedAt = Date.now();
5657
5401
  /** 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. */
5658
5402
  awaitingSince;
5403
+ /** This side's invite, nobody used yet (`LinkSessionOptions.invite`). */
5404
+ invite;
5659
5405
  intervals;
5660
5406
  fastPollUntil = 0;
5661
5407
  /** An offer to a saved contact is out: from then on its fast window looks less often (`OFFER_FAST_MS`); 0 for none. */
@@ -5711,6 +5457,7 @@ var LinkSession = class {
5711
5457
  this.events = options.events ?? {};
5712
5458
  this.firstPublish = options.firstPublish ?? "at-start";
5713
5459
  this.awaitingSince = Math.min(options.awaitingSince ?? this.startedAt, this.startedAt);
5460
+ this.invite = options.invite ?? (() => false);
5714
5461
  }
5715
5462
  get peerPresence() {
5716
5463
  return this.presence;
@@ -5884,8 +5631,8 @@ var LinkSession = class {
5884
5631
  async setRtcSignal(signal, reportFailure = false) {
5885
5632
  if (this.rtcSignal === signal) return;
5886
5633
  this.rtcSignal = signal;
5887
- if (reportFailure) await this.publish();
5888
- else await this.publish().catch(() => {});
5634
+ if (reportFailure) await this.publish(signal === null);
5635
+ else await this.publish(signal === null).catch(() => {});
5889
5636
  if (signal) this.pollNow();
5890
5637
  }
5891
5638
  /**
@@ -5958,6 +5705,7 @@ var LinkSession = class {
5958
5705
  const stepping = this.fastStepsAfter ? Date.now() - this.fastStepsAfter : -1;
5959
5706
  if (pace === "fast" && stepping >= 0) return Math.min(4 * interval, Math.max(2 * interval, stepping));
5960
5707
  if (pace === "fast" || pace === "connected" || since < 0) return interval;
5708
+ if (pace === "background" && this.presence.lastPacketAt === 0 && this.invite()) return unusedInviteWait(Date.now() - this.awaitingSince, interval);
5961
5709
  return Math.min(interval, Math.max(2 * this.intervals.fast, since));
5962
5710
  }
5963
5711
  /**
@@ -5969,13 +5717,17 @@ var LinkSession = class {
5969
5717
  watching() {
5970
5718
  return Date.now() < this.watchUntil && this.rtcSignal === null && !(this.presence.online && this.presence.lastPacketAt > this.watchFrom);
5971
5719
  }
5720
+ /** A contact never seen, whose invite was sent lately (`AWAITING_PEER_MS`): its join is due. */
5721
+ awaitingPeer() {
5722
+ return this.presence.lastPacketAt === 0 && Date.now() - this.awaitingSince < 6e5;
5723
+ }
5972
5724
  /** How urgently this link looks right now. */
5973
5725
  pace() {
5974
5726
  if (Date.now() < this.stalledUntil) return "fast";
5975
5727
  if (this.connected) return "connected";
5976
5728
  if (Date.now() < this.fastPollUntil) return "fast";
5977
5729
  if (Date.now() < this.watchUntil) return "active";
5978
- if (!this.active && this.presence.lastPacketAt === 0 && Date.now() - this.awaitingSince < 6e5) return "active";
5730
+ if (!this.active && this.awaitingPeer()) return "active";
5979
5731
  if (!this.active) return "background";
5980
5732
  return Date.now() - this.lastActivity > 6e4 ? "idle" : "active";
5981
5733
  }
@@ -6032,7 +5784,9 @@ var LinkSession = class {
6032
5784
  }, this.encKey);
6033
5785
  const started = Date.now();
6034
5786
  try {
6035
- const options = rtcSignal && rtcSignal !== this.rtcSignalOut ? { signal: true } : this.watching() ? { watch: true } : upkeep && this.connected ? { background: true } : void 0;
5787
+ const awaited = this.connected || this.presence.online || this.sentBuffer.length > 0 || this.callSignal !== null || this.awaitingPeer();
5788
+ const takesBack = !rtcSignal && this.rtcSignalOut !== null;
5789
+ const options = rtcSignal && rtcSignal !== this.rtcSignalOut ? { signal: true } : this.watching() ? { watch: true } : upkeep && (this.connected || takesBack) || !rtcSignal && !awaited ? { background: true } : void 0;
6036
5790
  await this.transport.publish(this.identity, built.records, options);
6037
5791
  } catch (error) {
6038
5792
  const ms = Date.now() - started, waiting = isDiscoveryBudgetError(error);
@@ -6096,13 +5850,15 @@ var LinkSession = class {
6096
5850
  nextInMs: 0
6097
5851
  });
6098
5852
  const started = Date.now();
5853
+ let heldBackMs;
6099
5854
  try {
6100
5855
  const pace = this.pace();
6101
5856
  const signal = pace === "fast" && this.fastStepsAfter > 0 && this.rtcSignalOut !== null;
6102
5857
  const watch = !signal && this.watching();
6103
5858
  const departed = !signal && Date.now() < this.departedUntil;
5859
+ const background = pace === "background" || pace === "connected";
6104
5860
  const packet = await this.transport.resolve(this.peerPubKeyZ32, {
6105
- background: pace === "background" || pace === "connected",
5861
+ background,
6106
5862
  urgent: pace === "fast",
6107
5863
  ...signal && { signal },
6108
5864
  ...watch && { watch },
@@ -6117,6 +5873,7 @@ var LinkSession = class {
6117
5873
  const read = answered !== void 0 && answered >= started;
6118
5874
  if (read) this.lastReadAt = started;
6119
5875
  this.readMissed = !!this.transport.readAnsweredAt && !read;
5876
+ if (this.readMissed) heldBackMs = this.transport.readHeldBackFor?.(this.peerPubKeyZ32);
6120
5877
  let receivedNew = false;
6121
5878
  const batch = packet ? parseLinkRecords(packet, this.encKey) : null;
6122
5879
  if (batch && isEmptyLinkPacket(batch)) {
@@ -6205,7 +5962,8 @@ var LinkSession = class {
6205
5962
  ms: Date.now() - started,
6206
5963
  error: String(error)
6207
5964
  });
6208
- if (!isDiscoveryBudgetError(error)) {
5965
+ if (isDiscoveryBudgetError(error)) heldBackMs = error.retryInMs;
5966
+ else {
6209
5967
  this.discoveryResult("read", error);
6210
5968
  this.events.onStatus?.("error");
6211
5969
  }
@@ -6223,7 +5981,7 @@ var LinkSession = class {
6223
5981
  this.poll();
6224
5982
  } else if (this.running && !this.pollTimer) {
6225
5983
  const interval = this.nextInterval();
6226
- const wait = Math.max(MIN_POLL_GAP_MS, interval - (Date.now() - started));
5984
+ const wait = heldBackMs !== void 0 ? Math.min(interval, Math.max(BUDGET_RETRY_MIN_MS, heldBackMs)) : Math.max(MIN_POLL_GAP_MS, interval - (Date.now() - started));
6227
5985
  this.events.onPoll?.({
6228
5986
  polling: false,
6229
5987
  nextInMs: wait
@@ -6333,7 +6091,9 @@ var ICE_GATHERING_TIMEOUT_MS = 5e3;
6333
6091
  * settle); where they do not, every offer of a first pairing waited the full 5 s, twice when the inviter offered too
6334
6092
  * (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
6335
6093
  * contacts (their checks learn this side's address from its own); behind a restricted NAT on the contact's side it may
6336
- * not, and the next offer of the chat then waits as long as it takes (`fullGather`).
6094
+ * not, and the next offer of the chat then waits as long as it takes (`fullGather`). An answer is bounded the same way:
6095
+ * it waited the full 5 s, and two CLI daemons whose STUN server swallowed every request went live 10 s after the offer
6096
+ * rather than 2 s (calls bug hunt, 2026-10-12).
6337
6097
  */
6338
6098
  var OFFER_HOST_GATHER_MS = 2e3;
6339
6099
  /**
@@ -6343,6 +6103,14 @@ var OFFER_HOST_GATHER_MS = 2e3;
6343
6103
  var GATHER_STALL_MS = 3e3;
6344
6104
  /** How long a connection may stay `disconnected` before it is given up. */
6345
6105
  var DISCONNECT_GRACE_MS = 12e3;
6106
+ /**
6107
+ * The same race a moment later: setRemoteDescription resolves, and the connection closes right after it (8 ms on a
6108
+ * loaded core). A connection that ends within this long of its answer going in was refused too: no network fails one
6109
+ * that soon (ICE gives up after tens of seconds), so it says nothing about direct connections (`DirectEvidence`).
6110
+ * Taken for a network that lets none through, an edge between two CLIs left WebRTC for the run, and a member whose
6111
+ * app has WebRTC alone (one from before native group links) was never reached again (groupCompat, 2026-10-12).
6112
+ */
6113
+ var ANSWER_REFUSED_AFTER_MS = 1e3;
6346
6114
  /** An offer this close to the end of the offerer's attempt is not answered again: the new answer would come too late. */
6347
6115
  var REANSWER_MARGIN_MS = 15e3;
6348
6116
  /**
@@ -6374,12 +6142,13 @@ var DataLink = class {
6374
6142
  pc = null;
6375
6143
  myOfferTs = 0;
6376
6144
  /**
6377
- * An offer of this side's that went without a public candidate came to nothing (it was answered, and no path
6378
- * connected): the next one waits for its STUN servers as long as it takes (`ICE_GATHERING_TIMEOUT_MS`).
6145
+ * An offer or answer of this side's that went without a public candidate came to nothing (both descriptions were
6146
+ * exchanged, and no path connected): the next one waits for its STUN servers as long as it takes
6147
+ * (`ICE_GATHERING_TIMEOUT_MS`).
6379
6148
  */
6380
6149
  fullGather = false;
6381
- /** The offer out has no public candidate (as it went: the connection's description gains any that come later). */
6382
- offerLacksPublic = false;
6150
+ /** The offer or answer out has no public candidate (as it went: the connection's description gains any that come later). */
6151
+ sentLacksPublic = false;
6383
6152
  lastSignalTs = 0;
6384
6153
  /** The peer's description as this connection was given it: Chrome shows it as `remoteDescription` only once applied. */
6385
6154
  remoteSdp = null;
@@ -6425,7 +6194,7 @@ var DataLink = class {
6425
6194
  this.options.setFastPoll(true, true);
6426
6195
  }
6427
6196
  this.myOfferTs = Date.now();
6428
- this.offerLacksPublic = !PUBLIC_CANDIDATE.test(pc.localDescription?.sdp ?? "");
6197
+ this.sentLacksPublic = !PUBLIC_CANDIDATE.test(pc.localDescription?.sdp ?? "");
6429
6198
  const signal = {
6430
6199
  t: "o",
6431
6200
  ts: this.myOfferTs,
@@ -6531,9 +6300,12 @@ var DataLink = class {
6531
6300
  this.reset();
6532
6301
  if (evidence) this.options.onDirect?.(evidence);
6533
6302
  }
6534
- /** 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. */
6303
+ /**
6304
+ * This side's offer went with no public candidate and was answered, or its answer went with none, and it came to
6305
+ * nothing: the next offer or answer gathers for as long as it takes.
6306
+ */
6535
6307
  noteNoPublic(ended) {
6536
- if (ended && this.myOfferTs && this.offerLacksPublic && this.state === "connecting") this.fullGather = true;
6308
+ if (ended && (this.myOfferTs || this.answered) && this.sentLacksPublic && this.state === "connecting") this.fullGather = true;
6537
6309
  }
6538
6310
  /**
6539
6311
  * What this attempt says about direct connections if it ends now without opening: nothing unless both descriptions
@@ -6548,17 +6320,22 @@ var DataLink = class {
6548
6320
  /**
6549
6321
  * The attempt on this connection failed (it timed out, ICE failed or closed, its channel closed). An answer the
6550
6322
  * offerer may not have read yet is made again for the same offer while it still stands (`REANSWERS`); anything
6551
- * else goes back to idle.
6323
+ * else goes back to idle. `byConnection`: the connection said so itself, rather than a timer of this link's.
6552
6324
  */
6553
- failed() {
6325
+ failed(byConnection = false) {
6554
6326
  if (this.state === "offering" && this.remoteSdp) {
6555
6327
  this.answerRefused("failed");
6556
6328
  return;
6557
6329
  }
6330
+ if (byConnection && this.state === "connecting" && this.myOfferTs && Date.now() - this.connectingSince < ANSWER_REFUSED_AFTER_MS) {
6331
+ this.answerRefused("failed");
6332
+ return;
6333
+ }
6558
6334
  const answered = this.state === "connecting" ? this.answered : null;
6559
6335
  const left = answered ? CONNECT_TIMEOUT_MS - (Date.now() - answered.at) : 0;
6560
- const evidence = this.directEvidence();
6561
- this.noteNoPublic(true);
6336
+ const givenUp = !!answered && this.options.offerStanding?.(answered.offer.ts) === false;
6337
+ const evidence = givenUp ? null : this.directEvidence();
6338
+ this.noteNoPublic(!givenUp);
6562
6339
  if (answered && answered.again < 2 && left > REANSWER_MARGIN_MS && this.options.offerStanding?.(answered.offer.ts)) {
6563
6340
  answered.again++;
6564
6341
  traceLink(this.options.myPubKeyZ32, "reanswer", {
@@ -6599,6 +6376,7 @@ var DataLink = class {
6599
6376
  await pc.setLocalDescription(await pc.createAnswer());
6600
6377
  });
6601
6378
  if (!pc) return;
6379
+ this.sentLacksPublic = !PUBLIC_CANDIDATE.test(pc.localDescription?.sdp ?? "");
6602
6380
  const signal = {
6603
6381
  t: "a",
6604
6382
  ts: Date.now(),
@@ -6628,7 +6406,7 @@ var DataLink = class {
6628
6406
  await waitForIceGathering(pc, ICE_GATHERING_TIMEOUT_MS, {
6629
6407
  stallMs: GATHER_STALL_MS,
6630
6408
  settleMs: 150,
6631
- ...offer && !this.fullGather ? { hostBoundMs: OFFER_HOST_GATHER_MS } : {}
6409
+ ...!this.fullGather ? { hostBoundMs: OFFER_HOST_GATHER_MS } : {}
6632
6410
  });
6633
6411
  if (this.pc !== pc) return null;
6634
6412
  const ms = Date.now() - gathering;
@@ -6675,7 +6453,7 @@ var DataLink = class {
6675
6453
  tracePair(this.options.myPubKeyZ32, pc);
6676
6454
  });
6677
6455
  dc.addEventListener("close", () => {
6678
- if (this.pc === pc) this.failed();
6456
+ if (this.pc === pc) this.failed(true);
6679
6457
  });
6680
6458
  pc.addEventListener("connectionstatechange", () => {
6681
6459
  if (this.pc !== pc) return;
@@ -6684,7 +6462,7 @@ var DataLink = class {
6684
6462
  if (pc.connectionState === "connected") this.options.onDisconnected?.(false);
6685
6463
  }
6686
6464
  this.disconnectTimer = null;
6687
- if (pc.connectionState === "failed" || pc.connectionState === "closed") this.failed();
6465
+ if (pc.connectionState === "failed" || pc.connectionState === "closed") this.failed(true);
6688
6466
  else if (pc.connectionState === "disconnected") {
6689
6467
  if (this.state === "open") this.options.onDisconnected?.(true);
6690
6468
  this.disconnectTimer = setTimeout(() => {
@@ -7040,1038 +6818,48 @@ function rememberAlone(alone, key, present) {
7040
6818
  }
7041
6819
  var GROUP_ADMIN_OFF_ERROR = "Managing this group is off on this device. Turn on Manage groups from this device first.";
7042
6820
  //#endregion
7043
- //#region ../core/src/canonicalJson.ts
7044
- /** True when `s` holds a lone UTF-16 surrogate, which I-JSON (and so JCS) does not allow. */
7045
- function hasLoneSurrogate(s) {
7046
- for (let i = 0; i < s.length; i++) {
7047
- const c = s.charCodeAt(i);
7048
- if (c >= 55296 && c <= 56319) {
7049
- const next = s.charCodeAt(i + 1);
7050
- if (next >= 56320 && next <= 57343) {
7051
- i++;
7052
- continue;
7053
- }
7054
- return true;
7055
- }
7056
- if (c >= 56320 && c <= 57343) return true;
7057
- }
7058
- return false;
7059
- }
7060
- function write(value, depth) {
7061
- if (depth > 64) throw new Error("JSON nested deeper than 64 levels");
7062
- if (value === null) return "null";
7063
- if (typeof value === "boolean") return value ? "true" : "false";
7064
- if (typeof value === "number") {
7065
- if (!Number.isFinite(value)) throw new Error("JSON numbers must be finite");
7066
- return JSON.stringify(value);
7067
- }
7068
- if (typeof value === "string") {
7069
- if (hasLoneSurrogate(value)) throw new Error("A JSON string holds a lone surrogate");
7070
- return JSON.stringify(value);
7071
- }
7072
- if (Array.isArray(value)) return `[${value.map((v) => write(v, depth + 1)).join(",")}]`;
7073
- if (typeof value === "object") {
7074
- const record = value;
7075
- return `{${Object.keys(value).filter((k) => record[k] !== void 0).sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map((k) => {
7076
- if (hasLoneSurrogate(k)) throw new Error("A JSON key holds a lone surrogate");
7077
- return `${JSON.stringify(k)}:${write(record[k], depth + 1)}`;
7078
- }).join(",")}}`;
7079
- }
7080
- throw new Error(`Not a JSON value: ${typeof value}`);
7081
- }
7082
- /** The canonical text of a JSON value. Throws on a value JSON cannot hold (a non-finite number, a lone surrogate). */
7083
- function canonicalJson(value) {
7084
- return write(value, 0);
7085
- }
7086
- /** The canonical UTF-8 bytes of a JSON value. */
7087
- function canonicalJsonBytes(value) {
7088
- return utf8Encode(canonicalJson(value));
6821
+ //#region ../core/src/replies.ts
6822
+ var REPLY_LIMITS = {
6823
+ /** Code points of the line of the original, an ellipsis included when it was cut. */
6824
+ snippet: 120,
6825
+ /** Characters of the original's id. */
6826
+ id: 128,
6827
+ /** Characters of a line as it arrives, before it is cleaned: anything longer is not a line of a message. */
6828
+ raw: 2048
6829
+ };
6830
+ /** A paired chat's message id, a payment id or a file's wire id; a group message's `sender:epoch:seq`. */
6831
+ var REPLY_ID = /^[A-Za-z0-9_:.-]{1,128}$/;
6832
+ /** The two authors a paired chat has, from the reply author's side. */
6833
+ var PAIRED_REPLY_AUTHORS = ["sender", "recipient"];
6834
+ /** A picture sent inline has no line worth quoting. */
6835
+ var INLINE_PICTURE = /^data:image\//i;
6836
+ /**
6837
+ * A line of a message to quote: plain text on one line (line breaks and runs of spaces become one space), without
6838
+ * control, invisible or direction characters (WISP 400, as for names), at most `REPLY_LIMITS.snippet` code points,
6839
+ * the last of them an ellipsis when it was cut.
6840
+ */
6841
+ function replySnippet(text) {
6842
+ if (INLINE_PICTURE.test(text.trimStart())) return "🖼️ Picture";
6843
+ const flat = text.slice(0, REPLY_LIMITS.raw * 2).replace(/\s+/gu, " ");
6844
+ const clean = sanitizeDisplayText(flat, REPLY_LIMITS.snippet + 1) ?? "";
6845
+ const points = [...clean];
6846
+ return points.length > REPLY_LIMITS.snippet ? `${points.slice(0, REPLY_LIMITS.snippet - 1).join("").trimEnd()}…` : clean;
7089
6847
  }
7090
6848
  /**
7091
- * Reads bytes that must be the canonical form of a JSON value: `not-json` when they are not UTF-8 JSON, `not-canonical`
7092
- * when writing the value again gives other bytes.
6849
+ * A reply as a receiver takes it: an object with an id of this chat's shape, a line (cleaned and cut again here,
6850
+ * whatever the sender did) and an author `author` accepts. Anything else is no reply: the message stays, without it.
7093
6851
  */
7094
- function readCanonicalJson(bytes) {
7095
- let value;
7096
- try {
7097
- value = JSON.parse(utf8Decode(bytes));
7098
- } catch (error) {
7099
- return {
7100
- ok: false,
7101
- reason: "not-json",
7102
- detail: error instanceof Error ? error.message : String(error)
7103
- };
7104
- }
7105
- let again;
7106
- try {
7107
- again = canonicalJsonBytes(value);
7108
- } catch (error) {
7109
- return {
7110
- ok: false,
7111
- reason: "not-canonical",
7112
- detail: error instanceof Error ? error.message : String(error)
7113
- };
7114
- }
7115
- if (!bytesEqual(again, bytes)) return {
7116
- ok: false,
7117
- reason: "not-canonical"
7118
- };
6852
+ function readReply(raw, author) {
6853
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
6854
+ const { i, s, f, b } = raw;
6855
+ if (typeof i !== "string" || !REPLY_ID.test(i)) return void 0;
6856
+ if (typeof s !== "string" || s.length > REPLY_LIMITS.raw) return void 0;
6857
+ if (typeof f !== "string" || !author(f)) return void 0;
7119
6858
  return {
7120
- ok: true,
7121
- value
7122
- };
7123
- }
7124
- //#endregion
7125
- //#region ../core/src/appStatements.ts
7126
- /** The prefixes of phase 1. The others of the WISP (reports, reviews, licences, rotation, publisher proofs) come later. */
7127
- var APP_PREFIXES = {
7128
- app: "ghostly-app/1",
7129
- store: "ghostly-store/1",
7130
- revoke: "ghostly-revoke/1"
7131
- };
7132
- var APP_STATEMENT_LIMITS = {
7133
- /** A signature statement's canonical bytes: 52 + 86 characters of values and the keys around them. */
7134
- signatureBytes: 1024,
7135
- /** Digests named by one revocation. */
7136
- revokeDigests: 64,
7137
- /** A revocation's reason, in characters. */
7138
- reason: 200,
7139
- /** Revocations in one `ghostly-revoke.json`. */
7140
- revocations: 4096
7141
- };
7142
- /** A z-base32 Ed25519 public key: 52 characters that decode to 32 bytes and encode back to the same text. */
7143
- function isAppKey(value) {
7144
- if (typeof value !== "string" || !/^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(value)) return false;
7145
- const bytes = fromZ32(value);
7146
- return bytes.length === 32 && toZ32(bytes) === value;
7147
- }
7148
- /** A SHA-256 in base64url without padding: 43 characters that decode to 32 bytes and encode back to the same text. */
7149
- function isAppHash(value) {
7150
- if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) return false;
7151
- return toBase64Url(fromBase64Url(value)) === value;
7152
- }
7153
- /** An app reference, `<publisher key>/<name>`. */
7154
- var APP_NAME = /^[a-z][a-z0-9-]{0,31}$/;
7155
- function isAppRef(value) {
7156
- if (typeof value !== "string") return false;
7157
- return value.indexOf("/") === 52 && isAppKey(value.slice(0, 52)) && APP_NAME.test(value.slice(53));
7158
- }
7159
- function appRef(publisher, name) {
7160
- return `${publisher}/${name}`;
7161
- }
7162
- /** The publisher key of a reference. */
7163
- function appRefPublisher(ref) {
7164
- return ref.slice(0, 52);
7165
- }
7166
- /**
7167
- * The publisher fingerprint people see: the first 16 z-base32 characters of the key (80 bits), in four groups.
7168
- */
7169
- function appFingerprint(key) {
7170
- return (key.slice(0, 16).match(/.{1,4}/g) ?? []).join(" ");
7171
- }
7172
- /** The bytes a signature covers: the prefix, a zero byte, then the SHA-256 of the object's canonical bytes. */
7173
- function appSignedBytes(prefix, canonical) {
7174
- const p = utf8Encode(prefix);
7175
- const out = new Uint8Array(p.length + 1 + 32);
7176
- out.set(p, 0);
7177
- out.set(sha256(canonical), p.length + 1);
7178
- return out;
7179
- }
7180
- /** Signs an object under a prefix: its canonical bytes and the signature statement. */
7181
- async function signAppObject(prefix, value, signer) {
7182
- const bytes = canonicalJsonBytes(value);
7183
- const sig = await signer.sign(appSignedBytes(prefix, bytes));
7184
- return {
7185
- bytes,
7186
- signature: {
7187
- alg: "ed25519",
7188
- key: toZ32(signer.publicKey),
7189
- sig: toBase64Url(sig)
7190
- }
7191
- };
7192
- }
7193
- /**
7194
- * Ed25519 verification as RFC 8032 writes it (`zip215: false`): canonical encodings of the point and the scalar only,
7195
- * so every parser (this one, a Rust one with `verify_strict`) accepts exactly the same signatures.
7196
- */
7197
- function strictVerify(signature, message, publicKey) {
7198
- try {
7199
- return ed25519.verify(signature, message, publicKey, { zip215: false });
7200
- } catch {
7201
- return false;
7202
- }
7203
- }
7204
- var exactKeys$1 = (value, keys, optional = []) => {
7205
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
7206
- const have = Object.keys(value);
7207
- return keys.every((k) => have.includes(k)) && have.every((k) => keys.includes(k) || optional.includes(k));
7208
- };
7209
- /** Checks the shape of a signature statement: exactly `alg`, `key` and `sig`, a 64-byte signature. */
7210
- function isAppSignature(value) {
7211
- if (!exactKeys$1(value, [
7212
- "alg",
7213
- "key",
7214
- "sig"
7215
- ])) return false;
7216
- if (value.alg !== "ed25519" || !isAppKey(value.key)) return false;
7217
- return typeof value.sig === "string" && /^[A-Za-z0-9_-]{86}$/.test(value.sig) && toBase64Url(fromBase64Url(value.sig)) === value.sig;
7218
- }
7219
- /** Reads a signature statement from its bytes, which must be canonical. */
7220
- function readAppSignature(bytes) {
7221
- if (bytes.length > APP_STATEMENT_LIMITS.signatureBytes) return {
7222
- ok: false,
7223
- reason: "bad-signature-statement",
7224
- detail: "Too large"
7225
- };
7226
- const read = readCanonicalJson(bytes);
7227
- if (!read.ok) return {
7228
- ok: false,
7229
- reason: "bad-signature-statement",
7230
- detail: read.reason
7231
- };
7232
- if (!isAppSignature(read.value)) return {
7233
- ok: false,
7234
- reason: "bad-signature-statement",
7235
- detail: "Not {alg, key, sig}"
7236
- };
7237
- return {
7238
- ok: true,
7239
- signature: read.value
7240
- };
7241
- }
7242
- /**
7243
- * Verifies a signature over an object's canonical bytes under a prefix, made by `key`: `signature-key` when the
7244
- * statement names another key, `bad-signature` when it does not verify.
7245
- */
7246
- function verifyAppSignature(prefix, canonical, signature, key) {
7247
- if (!isAppSignature(signature)) return {
7248
- ok: false,
7249
- reason: "bad-signature-statement"
7250
- };
7251
- if (signature.key !== key) return {
7252
- ok: false,
7253
- reason: "signature-key"
7254
- };
7255
- return strictVerify(fromBase64Url(signature.sig), appSignedBytes(prefix, canonical), fromZ32(key)) ? { ok: true } : {
7256
- ok: false,
7257
- reason: "bad-signature"
7258
- };
7259
- }
7260
- var isSequence = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 1;
7261
- var isPlainLine = (v, max) => typeof v === "string" && [...v].length <= max && !/\p{Cc}/u.test(v);
7262
- /** Checks a revocation statement's shape; the reason it is refused, or null. */
7263
- function checkAppRevokeStatement(value) {
7264
- if (typeof value !== "object" || value === null || Array.isArray(value)) return "Not an object";
7265
- const v = value;
7266
- const byDigests = "digests" in v;
7267
- if (!exactKeys$1(v, [
7268
- "ghostlyRevoke",
7269
- "app",
7270
- byDigests ? "digests" : "upTo"
7271
- ], ["reason"])) return "Keys are not ghostlyRevoke, app, and exactly one of digests and upTo (reason optional)";
7272
- if (v.ghostlyRevoke !== 1) return "ghostlyRevoke is not 1";
7273
- if (!isAppRef(v.app)) return "app is not a reference";
7274
- if (byDigests) {
7275
- const d = v.digests;
7276
- if (!Array.isArray(d) || d.length < 1 || d.length > APP_STATEMENT_LIMITS.revokeDigests) return "digests is not 1 to 64 digests";
7277
- if (!d.every(isAppHash) || new Set(d).size !== d.length) return "digests holds a bad or repeated digest";
7278
- } else if (!isSequence(v.upTo)) return "upTo is not a sequence";
7279
- if (v.reason !== void 0 && !isPlainLine(v.reason, APP_STATEMENT_LIMITS.reason)) return "reason is not one line of 200 characters";
7280
- return null;
7281
- }
7282
- /** Signs a revocation with the publisher's key. */
7283
- async function signAppRevocation(statement, signer) {
7284
- const problem = checkAppRevokeStatement(statement);
7285
- if (problem) throw new Error(`Not a revocation: ${problem}`);
7286
- if (toZ32(signer.publicKey) !== appRefPublisher(statement.app)) throw new Error("A revocation is signed by its app's publisher");
7287
- const { signature } = await signAppObject(APP_PREFIXES.revoke, statement, signer);
7288
- return {
7289
- statement,
7290
- signature
7291
- };
7292
- }
7293
- /** What a checked revocation is kept by: the SHA-256 of its canonical bytes (statement and signature), base64url. */
7294
- function appRevocationId(revocation) {
7295
- return toBase64Url(sha256(canonicalJsonBytes(revocation)));
7296
- }
7297
- /**
7298
- * Reads one signed revocation (a parsed value, from `ghostly-revoke.json` or a store index's `revoked`): its shape, then
7299
- * the signature under `ghostly-revoke/1` by the publisher its `app` names. One that `checked` holds verified before, so
7300
- * its signature is not checked again; one that verifies now is added to it.
7301
- */
7302
- function readAppRevocation(value, checked) {
7303
- const read = readAppRevocationShape(value);
7304
- if (!read.ok) return read;
7305
- const verified = verifyAppRevocation(read.revocation, checked);
7306
- return verified.ok ? read : verified;
7307
- }
7308
- /** The first half of `readAppRevocation`: the shape of a signed revocation, with no signature checked. */
7309
- function readAppRevocationShape(value) {
7310
- if (!exactKeys$1(value, ["statement", "signature"])) return {
7311
- ok: false,
7312
- reason: "bad-revocation",
7313
- detail: "Keys are not statement and signature"
7314
- };
7315
- const problem = checkAppRevokeStatement(value.statement);
7316
- if (problem) return {
7317
- ok: false,
7318
- reason: "bad-revocation",
7319
- detail: problem
7320
- };
7321
- if (!isAppSignature(value.signature)) return {
7322
- ok: false,
7323
- reason: "bad-signature-statement"
7324
- };
7325
- return {
7326
- ok: true,
7327
- revocation: {
7328
- statement: value.statement,
7329
- signature: value.signature
7330
- }
7331
- };
7332
- }
7333
- /** The second half: the signature of a revocation of the right shape, unless `checked` holds it. */
7334
- function verifyAppRevocation(revocation, checked) {
7335
- const id = checked && appRevocationId(revocation);
7336
- if (id !== void 0 && checked.has(id)) return { ok: true };
7337
- const verified = verifyAppSignature(APP_PREFIXES.revoke, canonicalJsonBytes(revocation.statement), revocation.signature, appRefPublisher(revocation.statement.app));
7338
- if (verified.ok && id !== void 0) checked.add(id);
7339
- return verified;
7340
- }
7341
- /**
7342
- * Reads `ghostly-revoke.json`: canonical bytes holding a list of signed revocations. One that does not verify refuses the
7343
- * file, as a store index's `revoked` does.
7344
- */
7345
- function readAppRevocations(bytes) {
7346
- const read = readCanonicalJson(bytes);
7347
- if (!read.ok) return read;
7348
- if (!Array.isArray(read.value) || read.value.length > APP_STATEMENT_LIMITS.revocations) return {
7349
- ok: false,
7350
- reason: "bad-revocation",
7351
- detail: "Not a list of at most 4096 revocations"
7352
- };
7353
- const revocations = [];
7354
- for (const entry of read.value) {
7355
- const one = readAppRevocation(entry);
7356
- if (!one.ok) return one;
7357
- revocations.push(one.revocation);
7358
- }
7359
- return {
7360
- ok: true,
7361
- revocations
7362
- };
7363
- }
7364
- //#endregion
7365
- //#region ../core/src/messageTime.ts
7366
- /**
7367
- * How far ahead of this device's clock a number a peer made from its clock may be (a pin's number, a status card's
7368
- * times): clocks drift, but a time past this is the peer's choice, not its clock.
7369
- */
7370
- var MESSAGE_CLOCK_SKEW_MS = 3e5;
7371
- /**
7372
- * The time a line of a group's history is kept under (a membership change, a rename): what its commit says, but at
7373
- * most `MESSAGE_CLOCK_SKEW_MS` past this clock, and now when it is not a positive number. Messages do not use it:
7374
- * they are placed where they arrive (below).
7375
- */
7376
- function receivedTimestamp(ts, now = Date.now()) {
7377
- if (!Number.isFinite(ts) || ts <= 0) return now;
7378
- return Math.min(ts, now + MESSAGE_CLOCK_SKEW_MS);
7379
- }
7380
- /** The latest moment a date can hold. */
7381
- var MAX_DATE_MS = 864e13;
7382
- /** The time a sender says, as it is kept: a positive time a date can hold, or nothing. */
7383
- function claimedTime(ts) {
7384
- return typeof ts === "number" && Number.isFinite(ts) && ts > 0 && ts <= MAX_DATE_MS ? ts : void 0;
7385
- }
7386
- /**
7387
- * The place of a row stored now: this clock, and past the last place given in its chat, so rows that come in the
7388
- * same millisecond (a hold pickup, a group's catch-up) keep the order they came in.
7389
- */
7390
- function arrivalKey(now, last = 0) {
7391
- return Math.max(now, last + 1);
7392
- }
7393
- /** The time a message shows: what its sender said, never later than when it came. A row of mine shows its own time. */
7394
- function shownTime(message) {
7395
- return message.sentAt === void 0 ? message.timestamp : Math.min(message.sentAt, message.timestamp);
7396
- }
7397
- /**
7398
- * When something a peer dated happened (an edit), for this device: what the peer says, now at the latest, and now
7399
- * when it says nothing a date can hold.
7400
- */
7401
- function heardTime(ts, now = Date.now()) {
7402
- return Math.min(claimedTime(ts) ?? now, now);
7403
- }
7404
- //#endregion
7405
- //#region ../core/src/statusCards.ts
7406
- var STATUS_CARD_LIMITS = {
7407
- /** A card's JSON, as sent (UTF-8 bytes): with the fallback text it stays well inside every message bound. */
7408
- bytes: 8192,
7409
- /** The sender's id for the card: stable across its updates. */
7410
- id: 64,
7411
- title: 120,
7412
- /** The current step, a list item, a run's summary. */
7413
- line: 200,
7414
- branch: 120,
7415
- schedule: 80,
7416
- cron: 64,
7417
- linkLabel: 40,
7418
- url: 512,
7419
- items: 20,
7420
- links: 4,
7421
- runs: 10,
7422
- /** Any count on a card (a PR's number, lines, files; steps). */
7423
- count: 1e9,
7424
- /** How far ahead a routine's next run may be. */
7425
- nextRunMs: 316224e5,
7426
- /** Edits a message with a card takes (a text takes `MAX_EDITS_PER_MESSAGE`): a bot updates a long task often. */
7427
- edits: 5e3,
7428
- /** Labels on a task, and a label's length in characters. */
7429
- tags: 3,
7430
- tag: 24,
7431
- /** Buttons on a message (WISP 406 · Message Buttons). */
7432
- buttons: 6,
7433
- /** A button's id: what a press names. */
7434
- buttonId: 32,
7435
- /** A button's label, in characters. */
7436
- buttonLabel: 40,
7437
- /** An app card's title and version, in characters (WISP 405 § An app): the manifest's bounds. */
7438
- appTitle: 40,
7439
- appVersion: 32,
7440
- /** A usage card's label ("Claude") and account, in characters (WISP 405 § Usage). */
7441
- usageLabel: 24,
7442
- /** A usage window's name ("5 h", "week"), in characters. */
7443
- usageWindow: 16,
7444
- /** Windows beside a usage card's own, shown in its details. */
7445
- usageWindows: 3
7446
- };
7447
- /** How a button looks: `primary` is the one highlighted, `danger` warns; `neutral` when none is said. */
7448
- var BUTTON_STYLES = [
7449
- "primary",
7450
- "neutral",
7451
- "danger"
7452
- ];
7453
- var TASK_STATUSES = [
7454
- "queued",
7455
- "running",
7456
- "blocked",
7457
- "done",
7458
- "failed",
7459
- "cancelled"
7460
- ];
7461
- /** A task that is still going: shown first, and counted on the chat's Tasks button. */
7462
- var ACTIVE_TASK_STATUSES = [
7463
- "queued",
7464
- "running",
7465
- "blocked"
7466
- ];
7467
- var ITEM_STATES = [
7468
- "pending",
7469
- "running",
7470
- "done",
7471
- "failed",
7472
- "skipped"
7473
- ];
7474
- var ROUTINE_STATES = ["active", "paused"];
7475
- var RUN_RESULTS = [
7476
- "ok",
7477
- "failed",
7478
- "skipped"
7479
- ];
7480
- /**
7481
- * Where a task's pull request stands: `draft` (not ready for review), `open` (ready for review), `merged`, `closed`
7482
- * (closed without a merge). A reader that does not know the field shows the pull request as before.
7483
- */
7484
- var PR_STATES = [
7485
- "draft",
7486
- "open",
7487
- "merged",
7488
- "closed"
7489
- ];
7490
- /** How a pull request's checks stand. */
7491
- var PR_CHECKS = [
7492
- "passing",
7493
- "failing",
7494
- "pending"
7495
- ];
7496
- var ID$4 = /^[A-Za-z0-9_.:][A-Za-z0-9_.:-]{0,63}$/;
7497
- /** A button's id: 1 to 32 of `A-Z a-z 0-9 _ . : -`, not starting with `-`. */
7498
- var BUTTON_ID = /^[A-Za-z0-9_.:][A-Za-z0-9_.:-]{0,31}$/;
7499
- var CRON = /^[0-9A-Za-z*,/?#\- ]{1,64}$/;
7500
- /**
7501
- * Whether a typed answer is a button's label or id (WISP 406 · Message Buttons): ignoring case and spaces at the ends.
7502
- * Not the device's locale's case: the presser's and the author's apps must agree ("I" is not "ı" anywhere).
7503
- */
7504
- var sameButtonText = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
7505
- /**
7506
- * Two buttons a typed answer could not tell apart, or none: a label that reads as an earlier label (`label`), or a
7507
- * label that reads as another button's id (`id`), both as `sameButtonText` compares them. The labels as a reader keeps
7508
- * them (one line, cleaned). `at` is the button that clashes, `with` the one it clashes with.
7509
- */
7510
- function buttonLabelClash(buttons) {
7511
- const labels = buttons.map((button) => isObject$4(button) ? cardLine(button.label, Infinity) : void 0);
7512
- const ids = buttons.map((button) => isObject$4(button) && typeof button.id === "string" ? button.id : void 0);
7513
- for (const [at, label] of labels.entries()) {
7514
- if (!label) continue;
7515
- const twin = labels.findIndex((other, i) => i < at && !!other && sameButtonText(other, label));
7516
- if (twin !== -1) return {
7517
- at,
7518
- with: twin,
7519
- kind: "label"
7520
- };
7521
- const named = ids.findIndex((id, i) => i !== at && id !== void 0 && sameButtonText(id, label));
7522
- if (named !== -1) return {
7523
- at,
7524
- with: named,
7525
- kind: "id"
7526
- };
7527
- }
7528
- }
7529
- /** An app card's id, made from its reference: the app's name, a dot, the first 16 characters of the publisher key. */
7530
- function appCardId(ref) {
7531
- return `${ref.slice(53)}.${ref.slice(0, 16)}`;
7532
- }
7533
- /** An app card's `sequence`: a whole number from 0 to 2^53 - 1. */
7534
- var appSequence = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
7535
- var isObject$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
7536
- /** One line of display text: runs of space (line breaks included) become one space, then the display rules for names. */
7537
- function cardLine(value, max) {
7538
- if (typeof value !== "string") return void 0;
7539
- return sanitizeDisplayText(value.replace(/\s+/gu, " "), max);
7540
- }
7541
- /**
7542
- * An https link a card may show: no credentials, a host, no spaces or control characters, at most
7543
- * `STATUS_CARD_LIMITS.url` characters. Returned as the parser writes it (a non-ASCII host in punycode).
7544
- */
7545
- function cardUrl(value) {
7546
- if (typeof value !== "string" || !value || value.length > STATUS_CARD_LIMITS.url || /[\s\p{Cc}\p{Cf}]/u.test(value)) return void 0;
7547
- let url;
7548
- try {
7549
- url = new URL(value);
7550
- } catch {
7551
- return;
7552
- }
7553
- if (url.protocol !== "https:" || !url.hostname || url.username || url.password) return void 0;
7554
- return url.href.length <= STATUS_CARD_LIMITS.url ? url.href : void 0;
7555
- }
7556
- var count = (value, min = 0) => typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= STATUS_CARD_LIMITS.count ? value : void 0;
7557
- /** A time on a card as a reader keeps it: a positive whole number, never later than `latest`. */
7558
- var time = (value, latest) => typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? Math.min(value, latest) : void 0;
7559
- var oneOf = (values, value) => typeof value === "string" && values.includes(value) ? value : void 0;
7560
- function readLinks(raw) {
7561
- if (!Array.isArray(raw)) return [];
7562
- const links = [];
7563
- for (const entry of raw.slice(0, STATUS_CARD_LIMITS.links)) {
7564
- if (!isObject$4(entry)) continue;
7565
- const url = cardUrl(entry.url);
7566
- if (!url) continue;
7567
- const label = cardLine(entry.label, STATUS_CARD_LIMITS.linkLabel);
7568
- links.push({
7569
- url,
7570
- ...label && { label }
7571
- });
7572
- }
7573
- return links;
7574
- }
7575
- /** A task's labels as a reader keeps them: the first `STATUS_CARD_LIMITS.tags` that hold, each one clean line, none twice. */
7576
- function readTags(raw) {
7577
- if (!Array.isArray(raw)) return [];
7578
- const tags = [];
7579
- for (const entry of raw) {
7580
- const tag = cardLine(entry, STATUS_CARD_LIMITS.tag);
7581
- if (tag && !tags.includes(tag)) tags.push(tag);
7582
- if (tags.length === STATUS_CARD_LIMITS.tags) break;
7583
- }
7584
- return tags;
7585
- }
7586
- function readRun(raw, now) {
7587
- if (!isObject$4(raw)) return void 0;
7588
- const at = time(raw.at, now + MESSAGE_CLOCK_SKEW_MS), result = oneOf(RUN_RESULTS, raw.result);
7589
- if (!at || !result) return void 0;
7590
- const summary = cardLine(raw.summary, STATUS_CARD_LIMITS.line);
7591
- return {
7592
- at,
7593
- result,
7594
- ...summary && { summary }
7595
- };
7596
- }
7597
- /** A percent as a reader keeps it: a finite number, rounded and clamped into 0 to 100. */
7598
- var percent = (value) => typeof value === "number" && Number.isFinite(value) ? Math.round(Math.min(100, Math.max(0, value))) : void 0;
7599
- /** A time ahead of the reader (a reset, a next run) as it keeps it: whole, positive, at most a year ahead. */
7600
- var ahead = (value, now) => typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= now + STATUS_CARD_LIMITS.nextRunMs ? value : void 0;
7601
- /** A usage card as a reader keeps it (WISP 405 § Usage): dropped without a percent left or a used of limit. */
7602
- function readUsage(raw, id, now) {
7603
- const left = percent(raw.left);
7604
- let used = count(raw.used), limit = count(raw.limit, 1);
7605
- if (used === void 0 || limit === void 0 || used > limit) used = limit = void 0;
7606
- if (left === void 0 && limit === void 0) return void 0;
7607
- const label = cardLine(raw.label, STATUS_CARD_LIMITS.usageLabel), account = cardLine(raw.account, STATUS_CARD_LIMITS.usageLabel);
7608
- const window = cardLine(raw.window, STATUS_CARD_LIMITS.usageWindow);
7609
- const resetsAt = ahead(raw.resetsAt, now), updatedAt = time(raw.updatedAt, now + MESSAGE_CLOCK_SKEW_MS);
7610
- const windows = [];
7611
- if (Array.isArray(raw.windows)) for (const entry of raw.windows.slice(0, STATUS_CARD_LIMITS.usageWindows)) {
7612
- if (!isObject$4(entry)) continue;
7613
- const name = cardLine(entry.window, STATUS_CARD_LIMITS.usageWindow), share = percent(entry.left);
7614
- if (!name || share === void 0) continue;
7615
- const at = ahead(entry.resetsAt, now);
7616
- windows.push({
7617
- window: name,
7618
- left: share,
7619
- ...at && { resetsAt: at }
7620
- });
7621
- }
7622
- return {
7623
- kind: "usage",
7624
- id,
7625
- ...left !== void 0 && { left },
7626
- ...used !== void 0 && limit !== void 0 && {
7627
- used,
7628
- limit
7629
- },
7630
- ...label && { label },
7631
- ...account && { account },
7632
- ...window && { window },
7633
- ...resetsAt && { resetsAt },
7634
- ...updatedAt && { updatedAt },
7635
- ...windows.length && { windows }
7636
- };
7637
- }
7638
- /** How much of a usage card's quota is left, 0 to 100: its own percent, else worked out from used of limit. */
7639
- function usageLeft(card) {
7640
- if (card.left !== void 0) return card.left;
7641
- return card.used !== void 0 && card.limit ? Math.round((card.limit - card.used) / card.limit * 100) : 0;
7642
- }
7643
- /** The bytes a card takes on the wire: what `STATUS_CARD_LIMITS.bytes` bounds. */
7644
- function statusCardBytes(card) {
7645
- try {
7646
- return utf8Encode(JSON.stringify(card) ?? "").length;
7647
- } catch {
7648
- return Infinity;
7649
- }
7650
- }
7651
- /**
7652
- * A card as a reader takes it from the wire (`sc`), or undefined when it cannot stand: not an object, past
7653
- * `STATUS_CARD_LIMITS.bytes`, a kind this app does not know, no valid id, title or name, status or state. Fields that do
7654
- * not hold are left out and lists are cut to their bound; times are kept no later than the reader's clock allows (a
7655
- * routine's next run up to a year ahead). The message is always kept: without a card it shows its text.
7656
- */
7657
- function readStatusCard(raw, now = Date.now()) {
7658
- if (!isObject$4(raw) || statusCardBytes(raw) > STATUS_CARD_LIMITS.bytes) return void 0;
7659
- const id = typeof raw.id === "string" && ID$4.test(raw.id) ? raw.id : void 0;
7660
- if (!id) return void 0;
7661
- if (raw.kind === "app") {
7662
- if (!isAppRef(raw.ref) || id !== appCardId(raw.ref) || !isAppHash(raw.digest) || !appSequence(raw.sequence)) return void 0;
7663
- const title = cardLine(raw.title, STATUS_CARD_LIMITS.appTitle);
7664
- if (!title) return void 0;
7665
- const version = cardLine(raw.version, STATUS_CARD_LIMITS.appVersion), url = cardUrl(raw.url);
7666
- return {
7667
- kind: "app",
7668
- id,
7669
- ref: raw.ref,
7670
- digest: raw.digest,
7671
- sequence: raw.sequence,
7672
- title,
7673
- ...version && { version },
7674
- ...url && { url },
7675
- ...raw.opened === true && { opened: true }
7676
- };
7677
- }
7678
- const latest = now + MESSAGE_CLOCK_SKEW_MS;
7679
- if (raw.kind === "usage") return readUsage(raw, id, now);
7680
- const links = readLinks(raw.links);
7681
- if (raw.kind === "task") {
7682
- const title = cardLine(raw.title, STATUS_CARD_LIMITS.title), status = oneOf(TASK_STATUSES, raw.status);
7683
- if (!title || !status) return void 0;
7684
- const progress = typeof raw.progress === "number" && Number.isFinite(raw.progress) ? Math.round(Math.min(100, Math.max(0, raw.progress))) : void 0;
7685
- let done = count(raw.done), total = count(raw.total, 1);
7686
- if (done === void 0 || total === void 0 || done > total) done = total = void 0;
7687
- const step = cardLine(raw.step, STATUS_CARD_LIMITS.line), branch = cardLine(raw.branch, STATUS_CARD_LIMITS.branch);
7688
- let pr;
7689
- if (isObject$4(raw.pr)) {
7690
- const url = cardUrl(raw.pr.url);
7691
- if (url) {
7692
- const number = count(raw.pr.number, 1), additions = count(raw.pr.additions), deletions = count(raw.pr.deletions), files = count(raw.pr.files);
7693
- const state = oneOf(PR_STATES, raw.pr.state), checks = oneOf(PR_CHECKS, raw.pr.checks);
7694
- pr = {
7695
- url,
7696
- ...number !== void 0 && { number },
7697
- ...additions !== void 0 && { additions },
7698
- ...deletions !== void 0 && { deletions },
7699
- ...files !== void 0 && { files },
7700
- ...state && { state },
7701
- ...checks && { checks }
7702
- };
7703
- }
7704
- }
7705
- const items = [];
7706
- if (Array.isArray(raw.items)) for (const entry of raw.items.slice(0, STATUS_CARD_LIMITS.items)) {
7707
- if (!isObject$4(entry)) continue;
7708
- const text = cardLine(entry.text, STATUS_CARD_LIMITS.line);
7709
- if (text) items.push({
7710
- text,
7711
- state: oneOf(ITEM_STATES, entry.state) ?? "pending"
7712
- });
7713
- }
7714
- const startedAt = time(raw.startedAt, latest), updatedAt = time(raw.updatedAt, latest);
7715
- const tags = readTags(raw.tags);
7716
- const parent = typeof raw.parent === "string" && ID$4.test(raw.parent) && raw.parent !== id ? raw.parent : void 0;
7717
- return {
7718
- kind: "task",
7719
- id,
7720
- title,
7721
- status,
7722
- ...progress !== void 0 && { progress },
7723
- ...done !== void 0 && total !== void 0 && {
7724
- done,
7725
- total
7726
- },
7727
- ...step && { step },
7728
- ...pr && { pr },
7729
- ...branch && { branch },
7730
- ...startedAt && { startedAt },
7731
- ...updatedAt && { updatedAt },
7732
- ...items.length && { items },
7733
- ...links.length && { links },
7734
- ...tags.length && { tags },
7735
- ...parent && { parent }
7736
- };
7737
- }
7738
- if (raw.kind === "routine") {
7739
- const name = cardLine(raw.name, STATUS_CARD_LIMITS.title), schedule = cardLine(raw.schedule, STATUS_CARD_LIMITS.schedule), state = oneOf(ROUTINE_STATES, raw.state);
7740
- if (!name || !schedule || !state) return void 0;
7741
- const cron = typeof raw.cron === "string" && CRON.test(raw.cron.trim()) ? raw.cron.trim() : void 0;
7742
- const lastRun = readRun(raw.lastRun, now);
7743
- const nextRunAt = typeof raw.nextRunAt === "number" && Number.isSafeInteger(raw.nextRunAt) && raw.nextRunAt > 0 && raw.nextRunAt <= now + STATUS_CARD_LIMITS.nextRunMs ? raw.nextRunAt : void 0;
7744
- const runs = [];
7745
- if (Array.isArray(raw.runs)) for (const entry of raw.runs.slice(0, STATUS_CARD_LIMITS.runs)) {
7746
- const run = readRun(entry, now);
7747
- if (run) runs.push(run);
7748
- }
7749
- return {
7750
- kind: "routine",
7751
- id,
7752
- name,
7753
- schedule,
7754
- state,
7755
- ...cron && { cron },
7756
- ...lastRun && { lastRun },
7757
- ...nextRunAt && { nextRunAt },
7758
- ...runs.length && { runs },
7759
- ...links.length && { links }
7760
- };
7761
- }
7762
- if (raw.kind === "buttons") {
7763
- if (!Array.isArray(raw.buttons)) return void 0;
7764
- const buttons = [], seen = /* @__PURE__ */ new Set();
7765
- for (const entry of raw.buttons.slice(0, STATUS_CARD_LIMITS.buttons)) {
7766
- if (!isObject$4(entry) || typeof entry.id !== "string" || !BUTTON_ID.test(entry.id) || seen.has(entry.id)) continue;
7767
- const label = cardLine(entry.label, STATUS_CARD_LIMITS.buttonLabel);
7768
- if (!label) continue;
7769
- seen.add(entry.id);
7770
- const style = oneOf(BUTTON_STYLES, entry.style);
7771
- buttons.push({
7772
- id: entry.id,
7773
- label,
7774
- ...style && style !== "neutral" && { style },
7775
- ...entry.once === true && { once: true }
7776
- });
7777
- }
7778
- if (!buttons.length) return void 0;
7779
- const chosen = typeof raw.chosen === "string" && seen.has(raw.chosen) ? raw.chosen : void 0;
7780
- return {
7781
- kind: "buttons",
7782
- id,
7783
- buttons,
7784
- ...chosen && { chosen },
7785
- ...raw.closed === true && { closed: true }
7786
- };
7787
- }
7788
- }
7789
- /**
7790
- * A card as a sender may send it, or why not: the reader's rule, but anything it would leave out or cut is refused
7791
- * here instead (an unknown status, a link that is not https, a list past its bound, a line too long), so a bot learns
7792
- * at once and nothing it said is lost on the way. Line breaks and invisible characters are cleaned, as a reader would.
7793
- */
7794
- function checkStatusCard(raw, now = Date.now()) {
7795
- if (!isObject$4(raw)) return { error: "A card is an object" };
7796
- const bytes = statusCardBytes(raw);
7797
- if (bytes > STATUS_CARD_LIMITS.bytes) return { error: `The card takes ${bytes} bytes; at most ${STATUS_CARD_LIMITS.bytes}` };
7798
- if (raw.kind !== "task" && raw.kind !== "routine" && raw.kind !== "buttons" && raw.kind !== "app" && raw.kind !== "usage") return { error: "kind is task, routine, buttons, app or usage" };
7799
- if (typeof raw.id !== "string" || !ID$4.test(raw.id)) return { error: `id is 1 to ${STATUS_CARD_LIMITS.id} of A-Z a-z 0-9 _ . : - and does not start with -` };
7800
- const line = (field, value, max, required = false) => {
7801
- if (value === void 0 && !required) return null;
7802
- if (typeof value !== "string" || !cardLine(value, Infinity)) return `${field} is text`;
7803
- if ([...cardLine(value, Infinity)].length > max) return `${field} is at most ${max} characters`;
7804
- return null;
7805
- };
7806
- const url = (field, value) => cardUrl(value) ? null : `${field} must be an https link of at most ${STATUS_CARD_LIMITS.url} characters`;
7807
- const whole = (field, value, min = 0) => value === void 0 || count(value, min) !== void 0 ? null : `${field} is a whole number from ${min}`;
7808
- const errors = [];
7809
- if (raw.links !== void 0) {
7810
- if (!Array.isArray(raw.links) || raw.links.length > STATUS_CARD_LIMITS.links) errors.push(`links is a list of at most ${STATUS_CARD_LIMITS.links}`);
7811
- else for (const [i, link] of raw.links.entries()) {
7812
- if (!isObject$4(link)) {
7813
- errors.push(`links[${i}] is an object`);
7814
- continue;
7815
- }
7816
- errors.push(url(`links[${i}].url`, link.url), line(`links[${i}].label`, link.label, STATUS_CARD_LIMITS.linkLabel));
7817
- }
7818
- }
7819
- const share = (field, value) => value === void 0 || typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100 ? null : `${field} is a number from 0 to 100`;
7820
- const reset = (field, value) => value === void 0 || ahead(value, now) !== void 0 ? null : `${field} is a time in milliseconds, within a year`;
7821
- if (raw.kind === "usage") {
7822
- if (raw.links !== void 0) errors.push("a usage card takes no links");
7823
- if (raw.left === void 0 && raw.used === void 0) errors.push("left (a percent) or used and limit");
7824
- errors.push(share("left", raw.left));
7825
- if (raw.used === void 0 !== (raw.limit === void 0)) errors.push("used and limit go together");
7826
- else if (raw.used !== void 0) {
7827
- errors.push(whole("used", raw.used), whole("limit", raw.limit, 1));
7828
- if (typeof raw.used === "number" && typeof raw.limit === "number" && raw.used > raw.limit) errors.push("used is at most limit");
7829
- }
7830
- errors.push(line("label", raw.label, STATUS_CARD_LIMITS.usageLabel), line("account", raw.account, STATUS_CARD_LIMITS.usageLabel), line("window", raw.window, STATUS_CARD_LIMITS.usageWindow), reset("resetsAt", raw.resetsAt));
7831
- if (raw.updatedAt !== void 0 && !(typeof raw.updatedAt === "number" && Number.isSafeInteger(raw.updatedAt) && raw.updatedAt > 0 && raw.updatedAt <= now + 3e5)) errors.push("updatedAt is a time in milliseconds, not in the future");
7832
- if (raw.windows !== void 0) {
7833
- if (!Array.isArray(raw.windows) || raw.windows.length > STATUS_CARD_LIMITS.usageWindows) errors.push(`windows is a list of at most ${STATUS_CARD_LIMITS.usageWindows}`);
7834
- else for (const [i, entry] of raw.windows.entries()) {
7835
- if (!isObject$4(entry)) {
7836
- errors.push(`windows[${i}] is an object`);
7837
- continue;
7838
- }
7839
- errors.push(line(`windows[${i}].window`, entry.window, STATUS_CARD_LIMITS.usageWindow, true));
7840
- errors.push(entry.left === void 0 ? `windows[${i}].left is a number from 0 to 100` : share(`windows[${i}].left`, entry.left), reset(`windows[${i}].resetsAt`, entry.resetsAt));
7841
- }
7842
- }
7843
- } else if (raw.kind === "app") {
7844
- if (raw.links !== void 0) errors.push("an app card takes no links");
7845
- if (!isAppRef(raw.ref)) errors.push("ref is <publisher key in z-base32>/<name>");
7846
- else if (raw.id !== appCardId(raw.ref)) errors.push(`id is ${appCardId(raw.ref)}, made from ref`);
7847
- if (!isAppHash(raw.digest)) errors.push("digest is a SHA-256 in base64url without padding");
7848
- if (!appSequence(raw.sequence)) errors.push("sequence is a whole number from 0 to 2^53 - 1");
7849
- errors.push(line("title", raw.title, STATUS_CARD_LIMITS.appTitle, true), line("version", raw.version, STATUS_CARD_LIMITS.appVersion));
7850
- if (raw.url !== void 0) errors.push(url("url", raw.url));
7851
- if (raw.opened !== void 0 && raw.opened !== true) errors.push("opened is true or left out");
7852
- } else if (raw.kind === "buttons") {
7853
- if (raw.links !== void 0) errors.push("buttons take no links");
7854
- if (!Array.isArray(raw.buttons) || !raw.buttons.length || raw.buttons.length > STATUS_CARD_LIMITS.buttons) errors.push(`buttons is a list of 1 to ${STATUS_CARD_LIMITS.buttons}`);
7855
- else {
7856
- const ids = /* @__PURE__ */ new Set();
7857
- for (const [i, button] of raw.buttons.entries()) {
7858
- if (!isObject$4(button)) {
7859
- errors.push(`buttons[${i}] is an object`);
7860
- continue;
7861
- }
7862
- if (typeof button.id !== "string" || !BUTTON_ID.test(button.id)) errors.push(`buttons[${i}].id is 1 to ${STATUS_CARD_LIMITS.buttonId} of A-Z a-z 0-9 _ . : - and does not start with -`);
7863
- else if (ids.has(button.id)) errors.push(`buttons[${i}].id "${button.id}" is used twice`);
7864
- else ids.add(button.id);
7865
- errors.push(line(`buttons[${i}].label`, button.label, STATUS_CARD_LIMITS.buttonLabel, true));
7866
- if (button.style !== void 0 && !oneOf(BUTTON_STYLES, button.style)) errors.push(`buttons[${i}].style is one of ${BUTTON_STYLES.join(", ")}`);
7867
- if (button.once !== void 0 && button.once !== true) errors.push(`buttons[${i}].once is true or left out`);
7868
- }
7869
- const clash = buttonLabelClash(raw.buttons);
7870
- if (clash) errors.push(`buttons[${clash.at}].label ${JSON.stringify(cardLine(raw.buttons[clash.at].label, Infinity))} ${clash.kind === "label" ? `repeats buttons[${clash.with}].label` : `is buttons[${clash.with}].id`} (ignoring case and spaces at the ends)`);
7871
- if (raw.chosen !== void 0 && !(typeof raw.chosen === "string" && ids.has(raw.chosen))) errors.push("chosen names one of the buttons");
7872
- }
7873
- if (raw.closed !== void 0 && raw.closed !== true) errors.push("closed is true or left out");
7874
- } else if (raw.kind === "task") {
7875
- errors.push(line("title", raw.title, STATUS_CARD_LIMITS.title, true));
7876
- if (!oneOf(TASK_STATUSES, raw.status)) errors.push(`status is one of ${TASK_STATUSES.join(", ")}`);
7877
- if (raw.progress !== void 0 && !(typeof raw.progress === "number" && Number.isFinite(raw.progress) && raw.progress >= 0 && raw.progress <= 100)) errors.push("progress is a number from 0 to 100");
7878
- if (raw.done === void 0 !== (raw.total === void 0)) errors.push("done and total go together");
7879
- else if (raw.done !== void 0) {
7880
- errors.push(whole("done", raw.done), whole("total", raw.total, 1));
7881
- if (typeof raw.done === "number" && typeof raw.total === "number" && raw.done > raw.total) errors.push("done is at most total");
7882
- }
7883
- errors.push(line("step", raw.step, STATUS_CARD_LIMITS.line), line("branch", raw.branch, STATUS_CARD_LIMITS.branch));
7884
- if (raw.pr !== void 0) {
7885
- if (!isObject$4(raw.pr)) errors.push("pr is an object");
7886
- else {
7887
- errors.push(url("pr.url", raw.pr.url), whole("pr.number", raw.pr.number, 1), whole("pr.additions", raw.pr.additions), whole("pr.deletions", raw.pr.deletions), whole("pr.files", raw.pr.files));
7888
- if (raw.pr.state !== void 0 && !oneOf(PR_STATES, raw.pr.state)) errors.push(`pr.state is one of ${PR_STATES.join(", ")}`);
7889
- if (raw.pr.checks !== void 0 && !oneOf(PR_CHECKS, raw.pr.checks)) errors.push(`pr.checks is one of ${PR_CHECKS.join(", ")}`);
7890
- }
7891
- }
7892
- if (raw.tags !== void 0) {
7893
- if (!Array.isArray(raw.tags) || raw.tags.length > STATUS_CARD_LIMITS.tags) errors.push(`tags is a list of at most ${STATUS_CARD_LIMITS.tags}`);
7894
- else {
7895
- const seen = /* @__PURE__ */ new Set();
7896
- for (const [i, tag] of raw.tags.entries()) {
7897
- const problem = line(`tags[${i}]`, tag, STATUS_CARD_LIMITS.tag, true);
7898
- errors.push(problem);
7899
- if (problem) continue;
7900
- const clean = cardLine(tag, Infinity);
7901
- if (seen.has(clean)) errors.push(`tags[${i}] ${JSON.stringify(clean)} is there twice`);
7902
- seen.add(clean);
7903
- }
7904
- }
7905
- }
7906
- if (raw.parent !== void 0) {
7907
- if (typeof raw.parent !== "string" || !ID$4.test(raw.parent)) errors.push(`parent is another task's id: 1 to ${STATUS_CARD_LIMITS.id} of A-Z a-z 0-9 _ . : - and does not start with -`);
7908
- else if (raw.parent === raw.id) errors.push("parent is another task's id, not its own");
7909
- }
7910
- for (const field of ["startedAt", "updatedAt"]) {
7911
- const value = raw[field];
7912
- if (value !== void 0 && !(typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= now + 3e5)) errors.push(`${field} is a time in milliseconds, not in the future`);
7913
- }
7914
- if (raw.items !== void 0) {
7915
- if (!Array.isArray(raw.items) || raw.items.length > STATUS_CARD_LIMITS.items) errors.push(`items is a list of at most ${STATUS_CARD_LIMITS.items}`);
7916
- else for (const [i, item] of raw.items.entries()) {
7917
- if (!isObject$4(item)) {
7918
- errors.push(`items[${i}] is an object`);
7919
- continue;
7920
- }
7921
- errors.push(line(`items[${i}].text`, item.text, STATUS_CARD_LIMITS.line, true));
7922
- if (item.state !== void 0 && !oneOf(ITEM_STATES, item.state)) errors.push(`items[${i}].state is one of ${ITEM_STATES.join(", ")}`);
7923
- }
7924
- }
7925
- } else {
7926
- errors.push(line("name", raw.name, STATUS_CARD_LIMITS.title, true), line("schedule", raw.schedule, STATUS_CARD_LIMITS.schedule, true));
7927
- if (!oneOf(ROUTINE_STATES, raw.state)) errors.push(`state is one of ${ROUTINE_STATES.join(", ")}`);
7928
- if (raw.cron !== void 0 && !(typeof raw.cron === "string" && CRON.test(raw.cron.trim()))) errors.push(`cron is a cron line of at most ${STATUS_CARD_LIMITS.cron} characters`);
7929
- if (raw.nextRunAt !== void 0 && !(typeof raw.nextRunAt === "number" && Number.isSafeInteger(raw.nextRunAt) && raw.nextRunAt > 0 && raw.nextRunAt <= now + STATUS_CARD_LIMITS.nextRunMs)) errors.push("nextRunAt is a time in milliseconds, within a year");
7930
- const run = (field, value) => {
7931
- if (!isObject$4(value)) return errors.push(`${field} is an object`);
7932
- if (!(typeof value.at === "number" && Number.isSafeInteger(value.at) && value.at > 0 && value.at <= now + 3e5)) errors.push(`${field}.at is a time in milliseconds, not in the future`);
7933
- if (!oneOf(RUN_RESULTS, value.result)) errors.push(`${field}.result is one of ${RUN_RESULTS.join(", ")}`);
7934
- errors.push(line(`${field}.summary`, value.summary, STATUS_CARD_LIMITS.line));
7935
- };
7936
- if (raw.lastRun !== void 0) run("lastRun", raw.lastRun);
7937
- if (raw.runs !== void 0) {
7938
- if (!Array.isArray(raw.runs) || raw.runs.length > STATUS_CARD_LIMITS.runs) errors.push(`runs is a list of at most ${STATUS_CARD_LIMITS.runs}`);
7939
- else raw.runs.forEach((entry, i) => run(`runs[${i}]`, entry));
7940
- }
7941
- }
7942
- const error = errors.find((e) => typeof e === "string");
7943
- if (error) return { error };
7944
- const card = readStatusCard(raw, now);
7945
- return card ? { card } : { error: "The card does not hold" };
7946
- }
7947
- /** The progress a task shows, 0 to 100: its own, else its steps done of total; undefined when it says neither. */
7948
- function taskProgress(card) {
7949
- if (card.progress !== void 0) return card.progress;
7950
- return card.done !== void 0 && card.total ? Math.round(card.done / card.total * 100) : void 0;
7951
- }
7952
- var TASK_MARK = {
7953
- queued: "⏳",
7954
- running: "🔄",
7955
- blocked: "⛔",
7956
- done: "✅",
7957
- failed: "❌",
7958
- cancelled: "🚫"
7959
- };
7960
- var STATUS_WORD = {
7961
- queued: "Queued",
7962
- running: "Running",
7963
- blocked: "Blocked",
7964
- done: "Done",
7965
- failed: "Failed",
7966
- cancelled: "Cancelled"
7967
- };
7968
- var RESULT_WORD = {
7969
- ok: "ok",
7970
- failed: "failed",
7971
- skipped: "skipped"
7972
- };
7973
- /** A time as the fallback text says it: in UTC, to the minute ("2026-09-30 01:00 UTC"). */
7974
- function cardTimeUtc(ms) {
7975
- return `${new Date(ms).toISOString().slice(0, 16).replace("T", " ")} UTC`;
7976
- }
7977
- /**
7978
- * The text a card's message carries: what an app without cards shows, the DHT floor carries, the chat list previews
7979
- * and search finds. Plain lines, the first naming the card, no bars or layout that would read badly elsewhere.
7980
- */
7981
- function statusCardText(card) {
7982
- if (card.kind === "app") {
7983
- const named = `${card.title}${card.version ? ` ${card.version}` : ""}`;
7984
- return [card.opened ? `🧩 Opened ${named} in this chat (Ghostly app)` : `🧩 ${named} (Ghostly app)`, ...card.url ? [card.url] : []].join("\n");
7985
- }
7986
- if (card.kind === "usage") {
7987
- const left = `${usageLeft(card)}% left${card.window ? ` (${card.window})` : ""}`;
7988
- return [
7989
- `📊 ${card.label ?? "Usage"}`,
7990
- ...card.account ? [card.account] : [],
7991
- left,
7992
- ...card.used !== void 0 && card.limit !== void 0 ? [`${card.used} of ${card.limit} used`] : [],
7993
- ...card.resetsAt ? [`resets ${cardTimeUtc(card.resetsAt)}`] : [],
7994
- ...(card.windows ?? []).map((w) => `${w.window} ${w.left}%`)
7995
- ].join(" · ");
7996
- }
7997
- if (card.kind === "buttons") return `Reply: ${card.buttons.map((b) => b.label).join(" / ")}`;
7998
- if (card.kind === "task") {
7999
- const progress = taskProgress(card);
8000
- const state = [
8001
- STATUS_WORD[card.status],
8002
- ...progress !== void 0 ? [`${progress}%`] : [],
8003
- ...card.done !== void 0 && card.total ? [`${card.done} of ${card.total} steps`] : []
8004
- ].join(" · ");
8005
- const standing = card.pr && [...card.pr.state ? [card.pr.state] : [], ...card.pr.checks ? [`checks ${card.pr.checks}`] : []].join(", ");
8006
- const pr = card.pr && [
8007
- `PR${card.pr.number !== void 0 ? ` #${card.pr.number}` : ""}`,
8008
- ...card.pr.additions !== void 0 || card.pr.deletions !== void 0 ? [`+${card.pr.additions ?? 0} -${card.pr.deletions ?? 0}`] : [],
8009
- ...standing ? [`(${standing})`] : []
8010
- ].join(" ") + `: ${card.pr.url}`;
8011
- const now = card.step && ACTIVE_TASK_STATUSES.includes(card.status) ? [`Now: ${card.step}`] : [];
8012
- return [
8013
- `${TASK_MARK[card.status]} ${card.title}`,
8014
- state,
8015
- ...now,
8016
- ...pr ? [pr] : [],
8017
- ...card.tags?.length ? [`Tags: ${card.tags.join(", ")}`] : [],
8018
- ...card.parent ? [`Part of: ${card.parent}`] : []
8019
- ].join("\n");
8020
- }
8021
- const last = card.lastRun && `Last run: ${RESULT_WORD[card.lastRun.result]}, ${cardTimeUtc(card.lastRun.at)}${card.lastRun.summary ? ` · ${card.lastRun.summary}` : ""}`;
8022
- return [
8023
- `🔁 ${card.name}`,
8024
- `${card.schedule} · ${card.state === "active" ? "active" : "paused"}`,
8025
- ...last ? [last] : [],
8026
- ...card.nextRunAt && card.state === "active" ? [`Next run: ${cardTimeUtc(card.nextRunAt)}`] : []
8027
- ].join("\n");
8028
- }
8029
- /** Whether a message's edit number holds: a text's bound, or a card message's when the edit carries a card. */
8030
- function cardEditNumber(e) {
8031
- return Number.isSafeInteger(e) && e >= 1 && e <= STATUS_CARD_LIMITS.edits;
8032
- }
8033
- //#endregion
8034
- //#region ../core/src/replies.ts
8035
- var REPLY_LIMITS = {
8036
- /** Code points of the line of the original, an ellipsis included when it was cut. */
8037
- snippet: 120,
8038
- /** Characters of the original's id. */
8039
- id: 128,
8040
- /** Characters of a line as it arrives, before it is cleaned: anything longer is not a line of a message. */
8041
- raw: 2048
8042
- };
8043
- /** A paired chat's message id, a payment id or a file's wire id; a group message's `sender:epoch:seq`. */
8044
- var REPLY_ID = /^[A-Za-z0-9_:.-]{1,128}$/;
8045
- /** The two authors a paired chat has, from the reply author's side. */
8046
- var PAIRED_REPLY_AUTHORS = ["sender", "recipient"];
8047
- /** A picture sent inline has no line worth quoting. */
8048
- var INLINE_PICTURE = /^data:image\//i;
8049
- /**
8050
- * A line of a message to quote: plain text on one line (line breaks and runs of spaces become one space), without
8051
- * control, invisible or direction characters (WISP 400, as for names), at most `REPLY_LIMITS.snippet` code points,
8052
- * the last of them an ellipsis when it was cut.
8053
- */
8054
- function replySnippet(text) {
8055
- if (INLINE_PICTURE.test(text.trimStart())) return "🖼️ Picture";
8056
- const clean = sanitizeDisplayText(text.slice(0, REPLY_LIMITS.raw * 2).replace(/\s+/gu, " "), REPLY_LIMITS.snippet + 1) ?? "";
8057
- const points = [...clean];
8058
- return points.length > REPLY_LIMITS.snippet ? `${points.slice(0, REPLY_LIMITS.snippet - 1).join("").trimEnd()}…` : clean;
8059
- }
8060
- /**
8061
- * A reply as a receiver takes it: an object with an id of this chat's shape, a line (cleaned and cut again here,
8062
- * whatever the sender did) and an author `author` accepts. Anything else is no reply: the message stays, without it.
8063
- */
8064
- function readReply(raw, author) {
8065
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
8066
- const { i, s, f, b } = raw;
8067
- if (typeof i !== "string" || !REPLY_ID.test(i)) return void 0;
8068
- if (typeof s !== "string" || s.length > REPLY_LIMITS.raw) return void 0;
8069
- if (typeof f !== "string" || !author(f)) return void 0;
8070
- return {
8071
- i,
8072
- s: replySnippet(s),
8073
- f,
8074
- ...typeof b === "string" && BUTTON_ID.test(b) && { b }
6859
+ i,
6860
+ s: replySnippet(s),
6861
+ f,
6862
+ ...typeof b === "string" && BUTTON_ID.test(b) && { b }
8075
6863
  };
8076
6864
  }
8077
6865
  /** Who may have written the original in a paired chat. */
@@ -8429,7 +7217,7 @@ function previewImageSize(value) {
8429
7217
  if (binary.length > LINK_PREVIEW_LIMITS.imageBytes) return;
8430
7218
  const bytes = new Uint8Array(binary.length);
8431
7219
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
8432
- return jpegSize$1(bytes);
7220
+ return jpegSize(bytes);
8433
7221
  }
8434
7222
  /** The thumbnail if it is one Ghostly shows: a JPEG data URL within the limits whose header declares a sane size. */
8435
7223
  function sanitizePreviewImage(value) {
@@ -8590,500 +7378,6 @@ var RateWindow = class {
8590
7378
  }
8591
7379
  };
8592
7380
  //#endregion
8593
- //#region ../core/src/video.ts
8594
- var VIDEO_LIMITS = {
8595
- /** The poster's bytes: with the rest of an offer, a frame stays far below the 60 KiB a session carries. */
8596
- maxPosterBytes: 12288,
8597
- /** The poster's longest side, in pixels, as the sender draws it. */
8598
- posterEdge: 320,
8599
- /** Ten hours: longer is a mistake, not a video. */
8600
- maxDurationMs: 36e6,
8601
- /** Pixels on either side (8K and a bit). */
8602
- maxEdge: 16384
8603
- };
8604
- /**
8605
- * Video a player may be handed as it is. None of these runs anything in the page's origin, so received bytes
8606
- * of these types are served with their type (see `safeBlobType`): WebKit will not play what is typed as bytes.
8607
- */
8608
- var PLAYABLE_VIDEO = /^video\/(mp4|webm|quicktime|ogg|x-m4v)$/;
8609
- /** Whether a file is a video, by its type: shown in a video bubble whether or not it came with a description. */
8610
- function isVideoMime(mime) {
8611
- return mime.split(";")[0].trim().toLowerCase().startsWith("video/");
8612
- }
8613
- var BASE64URL = /^[A-Za-z0-9_-]+$/;
8614
- var JPEG_START = [
8615
- 255,
8616
- 216,
8617
- 255
8618
- ];
8619
- /**
8620
- * A peer's `video` description, or undefined when it is not one. It never refuses the file. A poster that is
8621
- * too large or not a JPEG is dropped on its own; the length and size are kept.
8622
- */
8623
- function parseVideoMeta(value, mime) {
8624
- if (mime !== void 0 && !isVideoMime(mime)) return void 0;
8625
- if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
8626
- const { duration, width, height, poster } = value;
8627
- if (typeof duration !== "number" || !Number.isSafeInteger(duration) || duration <= 0 || duration > VIDEO_LIMITS.maxDurationMs) return void 0;
8628
- const edge = (n) => typeof n === "number" && Number.isSafeInteger(n) && n > 0 && n <= VIDEO_LIMITS.maxEdge;
8629
- if (!edge(width) || !edge(height)) return void 0;
8630
- const meta = {
8631
- duration,
8632
- width,
8633
- height
8634
- };
8635
- if (typeof poster === "string" && poster.length <= Math.ceil(VIDEO_LIMITS.maxPosterBytes * 4 / 3) && BASE64URL.test(poster)) try {
8636
- const bytes = fromBase64Url(poster);
8637
- if (bytes.length <= VIDEO_LIMITS.maxPosterBytes && JPEG_START.every((b, i) => bytes[i] === b)) meta.poster = poster;
8638
- } catch {}
8639
- return meta;
8640
- }
8641
- /** `0:07`, `12:45`, `1:02:03`: as a player shows a video's length. */
8642
- function formatVideoDuration(ms) {
8643
- const total = Math.max(0, Math.floor((Number.isFinite(ms) ? ms : 0) / 1e3));
8644
- const hours = Math.floor(total / 3600), minutes = Math.floor(total % 3600 / 60), seconds = String(total % 60).padStart(2, "0");
8645
- return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
8646
- }
8647
- //#endregion
8648
- //#region ../core/src/voice.ts
8649
- var VOICE_LIMITS = {
8650
- /** Fifteen minutes: at the bitrate recorded (32 kbit/s) about 3.6 MB, well inside a held item's 8 MiB. */
8651
- maxDurationMs: 9e5,
8652
- /** Bars drawn for a recording. */
8653
- bars: 64,
8654
- /** Bars accepted from a peer. */
8655
- maxBars: 128,
8656
- /** Asked of the recorder; opus speech is clear well below it. */
8657
- audioBitsPerSecond: 32e3
8658
- };
8659
- /**
8660
- * Audio a player may be handed as it is. None of these runs anything in the page's origin,
8661
- * so received bytes of these types are served with their type (see `safeBlobType`).
8662
- */
8663
- var PLAYABLE_AUDIO = /^audio\/(webm|ogg|opus|mp4|mpeg|mp3|aac|x-m4a|wav|x-wav|wave|flac|x-flac)$/;
8664
- /** The part of a type before its parameters, in lower case: `audio/webm;codecs=opus` → `audio/webm`. */
8665
- function baseMime(mime) {
8666
- return mime.split(";")[0].trim().toLowerCase();
8667
- }
8668
- /**
8669
- * A peer's `voice` description, or undefined when it is not one. It never refuses the file:
8670
- * a bad description only means the file is shown as a file.
8671
- */
8672
- function parseVoiceMeta(value, mime) {
8673
- if (mime !== void 0 && !PLAYABLE_AUDIO.test(baseMime(mime))) return void 0;
8674
- if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
8675
- const { duration, peaks } = value;
8676
- if (typeof duration !== "number" || !Number.isSafeInteger(duration) || duration <= 0 || duration > VOICE_LIMITS.maxDurationMs + 5e3) return void 0;
8677
- if (!Array.isArray(peaks) || peaks.length === 0 || peaks.length > VOICE_LIMITS.maxBars) return void 0;
8678
- if (!peaks.every((peak) => typeof peak === "number" && Number.isInteger(peak) && peak >= 0 && peak <= 255)) return void 0;
8679
- return {
8680
- duration,
8681
- peaks: [...peaks]
8682
- };
8683
- }
8684
- /**
8685
- * How the level is metered while recording (apps/ui/src/lib/voiceRecorder.ts): every `intervalMs`, the RMS of the last
8686
- * `window` samples an AnalyserNode holds (at a 48 kHz context, about 21 ms of sound).
8687
- */
8688
- var VOICE_METER = {
8689
- intervalMs: 50,
8690
- window: 1024,
8691
- windowRate: 48e3
8692
- };
8693
- /** One reading of the meter, 0-1: the RMS of the samples, lifted because speech sits low on a linear scale. */
8694
- function voiceLevel(samples) {
8695
- if (samples.length === 0) return 0;
8696
- let sum = 0;
8697
- for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i];
8698
- return Math.min(1, Math.sqrt(sum / samples.length) * 4);
8699
- }
8700
- /**
8701
- * The readings the recorder would have taken of this sound (mono, -1 to 1), for a recording made elsewhere: one
8702
- * every `intervalMs` of it, each over the window that ends there, as long in time as the analyser's.
8703
- */
8704
- function voiceLevelsOf(mono, sampleRate) {
8705
- if (!(sampleRate > 0) || mono.length === 0) return [];
8706
- const window = Math.max(1, Math.round(VOICE_METER.window * sampleRate / VOICE_METER.windowRate));
8707
- const levels = [];
8708
- for (let step = 1;; step++) {
8709
- const end = Math.round(step * VOICE_METER.intervalMs * sampleRate / 1e3);
8710
- if (end > mono.length) break;
8711
- levels.push(voiceLevel(mono.subarray(Math.max(0, end - window), end)));
8712
- }
8713
- return levels;
8714
- }
8715
- /** The waveform sent with a recording made elsewhere, drawn as the recorder draws its own. */
8716
- function voicePeaksOf(mono, sampleRate, bars = VOICE_LIMITS.bars) {
8717
- return downsamplePeaks(voiceLevelsOf(mono, sampleRate), bars);
8718
- }
8719
- /** Below this a recording is silence, and is drawn flat rather than stretched to fill the bars. */
8720
- var QUIET = .02;
8721
- /**
8722
- * Bars from the loudness measured while recording (RMS, 0-1, oldest first): the loudest
8723
- * sample of each stretch, scaled so the loudest bar is full height.
8724
- */
8725
- function downsamplePeaks(levels, bars = VOICE_LIMITS.bars) {
8726
- const count = Math.max(1, Math.min(VOICE_LIMITS.maxBars, Math.floor(bars)));
8727
- const clean = Array.from(levels, (level) => Number.isFinite(level) ? Math.min(1, Math.max(0, level)) : 0);
8728
- if (clean.length === 0) return new Array(count).fill(0);
8729
- const loudest = Math.max(QUIET, ...clean);
8730
- const out = [];
8731
- for (let bar = 0; bar < count; bar++) {
8732
- const from = Math.floor(bar * clean.length / count);
8733
- const to = Math.max(from + 1, Math.floor((bar + 1) * clean.length / count));
8734
- let peak = 0;
8735
- for (let i = from; i < to && i < clean.length; i++) peak = Math.max(peak, clean[i]);
8736
- out.push(Math.round(peak / loudest * 255));
8737
- }
8738
- return out;
8739
- }
8740
- /** `0:07`, `12:45`: minutes and seconds, as a player shows them. */
8741
- function formatVoiceDuration(ms) {
8742
- const total = Math.max(0, Math.floor((Number.isFinite(ms) ? ms : 0) / 1e3));
8743
- return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
8744
- }
8745
- /** The line a file message shows in the chat list and in notifications. */
8746
- function fileMessageText(file) {
8747
- if (file.voice) return `🎤 Voice message (${formatVoiceDuration(file.voice.duration)})`;
8748
- if (file.video) return `🎬 Video (${formatVideoDuration(file.video.duration)})`;
8749
- return `📎 ${file.name}`;
8750
- }
8751
- //#endregion
8752
- //#region ../core/src/files.ts
8753
- var FILE_ID = /^[A-Za-z0-9_-]{8,64}$/;
8754
- var MIME = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,63}$/i;
8755
- var MAX_NAME_LENGTH = 200;
8756
- /**
8757
- * Invisible and direction-changing characters: bidi overrides turn
8758
- * "invoice\u202Efdp.exe" into what reads as "invoiceexe.pdf", zero-width ones hide
8759
- * text. Cf covers both; line and paragraph separators break the layout.
8760
- */
8761
- var INVISIBLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
8762
- /**
8763
- * Names Windows keeps for devices, whatever the case, with any extension, and with spaces before the dot:
8764
- * "CON", "nul.txt", "COM1 .log" open a device there, not a file.
8765
- */
8766
- var WINDOWS_DEVICE = /^(CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[0-9¹²³]|LPT[0-9¹²³])\s*(\.|$)/i;
8767
- /**
8768
- * At most `MAX_NAME_LENGTH` characters. A longer name loses the end of its stem, not its extension (up to 15
8769
- * characters after the last dot): "…report.pdf" stays a PDF to the system it is saved on.
8770
- */
8771
- function shorten(name) {
8772
- const chars = [...name];
8773
- if (chars.length <= MAX_NAME_LENGTH) return name;
8774
- const extension = [.../\.[^.\s]{1,15}$/u.exec(name)?.[0] ?? ""];
8775
- return chars.slice(0, MAX_NAME_LENGTH - extension.length).join("").trimEnd() + extension.join("");
8776
- }
8777
- /** A file name is display text and a download suggestion, never a path. */
8778
- function sanitizeFileName(name) {
8779
- const clean = shorten(name.replace(INVISIBLE, "").replace(/[/\\:]/g, "").replace(/^[\s.]+/, "").trim());
8780
- if (WINDOWS_DEVICE.test(clean)) return shorten("_" + clean);
8781
- return clean || "file";
8782
- }
8783
- function sanitizeMime(mime) {
8784
- return MIME.test(mime) ? mime.toLowerCase() : "application/octet-stream";
8785
- }
8786
- /** A size for people: B, KB, MB, GB, TB (powers of 1024), one decimal from KB up. */
8787
- function formatFileSize(bytes) {
8788
- if (bytes < 1024) return `${bytes} B`;
8789
- const units = [
8790
- "KB",
8791
- "MB",
8792
- "GB",
8793
- "TB"
8794
- ];
8795
- let value = bytes / 1024, unit = 0;
8796
- while (value >= 1024 && unit < units.length - 1) {
8797
- value /= 1024;
8798
- unit++;
8799
- }
8800
- return `${value.toFixed(1)} ${units[unit]}`;
8801
- }
8802
- /** Raster images a UI may show inline. SVG is left out on purpose: it can carry scripts. */
8803
- var PREVIEWABLE_IMAGE = /^image\/(png|jpe?g|gif|webp)$/;
8804
- /**
8805
- * The type received bytes are served with. The peer picks the announced type,
8806
- * and a blob: URL typed text/html or image/svg+xml would run in the app's
8807
- * origin, so anything but a previewable image or playable audio or video is opaque bytes.
8808
- * Audio and video keep their type because WebKit will not play what is typed as bytes.
8809
- */
8810
- function safeBlobType(mime) {
8811
- const clean = sanitizeMime(mime);
8812
- return PREVIEWABLE_IMAGE.test(clean) || PLAYABLE_AUDIO.test(clean) || PLAYABLE_VIDEO.test(clean) ? clean : "application/octet-stream";
8813
- }
8814
- var FileTransfers = class {
8815
- channel;
8816
- events;
8817
- nextStreamId = 1;
8818
- incoming = /* @__PURE__ */ new Map();
8819
- outgoing = /* @__PURE__ */ new Map();
8820
- constructor(channel, events) {
8821
- this.channel = channel;
8822
- this.events = events;
8823
- }
8824
- /** Streams a file to the peer. Resolves once every byte is handed to the channel. */
8825
- async send(file, source) {
8826
- if (file.size > LIMITS.maxFileBytes) throw new Error("File is too large");
8827
- const id = this.nextStreamId++;
8828
- const state = {
8829
- fileId: file.id,
8830
- cancelled: false
8831
- };
8832
- this.outgoing.set(id, state);
8833
- try {
8834
- this.channel.send(encodeControl({
8835
- t: "file",
8836
- id,
8837
- f: file.id,
8838
- ts: file.timestamp,
8839
- n: sanitizeFileName(file.name),
8840
- s: file.size,
8841
- m: sanitizeMime(file.mime)
8842
- }));
8843
- let sent = 0;
8844
- const counted = async function* () {
8845
- for await (const part of source) {
8846
- sent += part.length;
8847
- yield part;
8848
- this.events.onProgress?.(file.id, sent, "out");
8849
- }
8850
- }.call(this);
8851
- await sendBody(this.channel, CHUNK_KIND.fileBody, id, counted, () => state.cancelled);
8852
- if (state.cancelled) throw new Error("The peer refused the file");
8853
- if (sent !== file.size) throw new Error("File changed while it was being sent");
8854
- this.events.onComplete?.(file.id, "out");
8855
- } catch (error) {
8856
- if (!state.cancelled) try {
8857
- this.channel.send(encodeControl({
8858
- t: "rst",
8859
- id,
8860
- d: "f",
8861
- e: "aborted"
8862
- }));
8863
- } catch {}
8864
- this.events.onFailed?.(file.id, error instanceof Error ? error.message : String(error), "out");
8865
- throw error;
8866
- } finally {
8867
- this.outgoing.delete(id);
8868
- }
8869
- }
8870
- handleFile(frame) {
8871
- if (this.incoming.has(frame.id)) return;
8872
- const refuse = (reason) => {
8873
- try {
8874
- this.channel.send(encodeControl({
8875
- t: "rst",
8876
- id: frame.id,
8877
- d: "f",
8878
- e: reason
8879
- }));
8880
- } catch {}
8881
- };
8882
- if (!FILE_ID.test(frame.f)) return refuse("invalid file id");
8883
- if (frame.s > LIMITS.maxFileBytes) return refuse("file too large");
8884
- if (this.incoming.size >= LIMITS.maxIncomingFilesPerPeer) return refuse("too many transfers");
8885
- const file = {
8886
- id: frame.f,
8887
- name: sanitizeFileName(frame.n),
8888
- size: frame.s,
8889
- mime: sanitizeMime(frame.m),
8890
- timestamp: frame.ts
8891
- };
8892
- const sink = this.events.onIncoming(file);
8893
- if (!sink || typeof sink === "string") return refuse(sink || "refused");
8894
- const entry = {
8895
- file,
8896
- sink,
8897
- received: 0,
8898
- queue: Promise.resolve()
8899
- };
8900
- this.incoming.set(frame.id, entry);
8901
- this.armIdleTimer(frame.id, entry);
8902
- }
8903
- handleChunk(chunk) {
8904
- const entry = this.incoming.get(chunk.id);
8905
- if (!entry) return;
8906
- entry.received += chunk.payload.length;
8907
- if (entry.received > entry.file.size) return this.failIncoming(chunk.id, "more data than announced", true);
8908
- if (chunk.payload.length > 0) {
8909
- const payload = chunk.payload.slice();
8910
- entry.queue = entry.queue.then(() => entry.sink.write(payload));
8911
- this.events.onProgress?.(entry.file.id, entry.received, "in");
8912
- }
8913
- if (!chunk.end) return this.armIdleTimer(chunk.id, entry);
8914
- if (entry.received !== entry.file.size) return this.failIncoming(chunk.id, "incomplete file", false);
8915
- clearTimeout(entry.idleTimer);
8916
- this.incoming.delete(chunk.id);
8917
- entry.queue.then(() => entry.sink.close()).then(() => this.events.onComplete?.(entry.file.id, "in"), () => {
8918
- entry.sink.abort();
8919
- this.events.onFailed?.(entry.file.id, "could not store the file", "in");
8920
- });
8921
- }
8922
- handleReset(frame) {
8923
- if (this.incoming.has(frame.id)) this.failIncoming(frame.id, frame.e || "cancelled by the sender", false);
8924
- const sending = this.outgoing.get(frame.id);
8925
- if (sending) sending.cancelled = true;
8926
- }
8927
- /** The link went away: nothing in flight can finish. */
8928
- closeAll() {
8929
- for (const id of [...this.incoming.keys()]) this.failIncoming(id, "connection lost", false);
8930
- for (const sending of this.outgoing.values()) sending.cancelled = true;
8931
- }
8932
- armIdleTimer(id, entry) {
8933
- clearTimeout(entry.idleTimer);
8934
- entry.idleTimer = setTimeout(() => this.failIncoming(id, "transfer stalled", true), LIMITS.bodyIdleTimeoutMs);
8935
- }
8936
- failIncoming(id, reason, notifyPeer) {
8937
- const entry = this.incoming.get(id);
8938
- if (!entry) return;
8939
- clearTimeout(entry.idleTimer);
8940
- this.incoming.delete(id);
8941
- entry.sink.abort();
8942
- if (notifyPeer) try {
8943
- this.channel.send(encodeControl({
8944
- t: "rst",
8945
- id,
8946
- d: "f",
8947
- e: reason
8948
- }));
8949
- } catch {}
8950
- this.events.onFailed?.(entry.file.id, reason, "in");
8951
- }
8952
- };
8953
- //#endregion
8954
- //#region ../core/src/image.ts
8955
- var IMAGE_LIMITS = {
8956
- /** Pixels on either side: the most a JPEG can say, well past what anyone sends as a picture. */
8957
- maxEdge: 65535,
8958
- /** How much of a file is read for its size: past a JPEG's EXIF, thumbnails and colour profile. */
8959
- headBytes: 262144
8960
- };
8961
- /** Whether a file is a picture, by its type. */
8962
- function isImageMime(mime) {
8963
- return mime.split(";")[0].trim().toLowerCase().startsWith("image/");
8964
- }
8965
- var edge = (n) => typeof n === "number" && Number.isSafeInteger(n) && n > 0 && n <= IMAGE_LIMITS.maxEdge;
8966
- /** A peer's `image` description, or undefined when it is not one. It never refuses the file. */
8967
- function parseImageMeta(value, mime) {
8968
- if (mime !== void 0 && !isImageMime(mime)) return void 0;
8969
- if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
8970
- const { width, height } = value;
8971
- return edge(width) && edge(height) ? {
8972
- width,
8973
- height
8974
- } : void 0;
8975
- }
8976
- var u16be = (b, at) => b[at] << 8 | b[at + 1];
8977
- var u16le = (b, at) => b[at] | b[at + 1] << 8;
8978
- var u24le = (b, at) => b[at] | b[at + 1] << 8 | b[at + 2] << 16;
8979
- var u32be = (b, at) => (b[at] << 24 >>> 0) + (b[at + 1] << 16) + (b[at + 2] << 8) + b[at + 3];
8980
- var ascii = (b, at, text) => [...text].every((c, i) => b[at + i] === c.charCodeAt(0));
8981
- var sized = (width, height) => edge(width) && edge(height) ? {
8982
- width,
8983
- height
8984
- } : void 0;
8985
- /** SOFn markers: every frame type but DHT (C4), JPG (C8) and DAC (CC). */
8986
- var JPEG_FRAME = /* @__PURE__ */ new Set([
8987
- 192,
8988
- 193,
8989
- 194,
8990
- 195,
8991
- 197,
8992
- 198,
8993
- 199,
8994
- 201,
8995
- 202,
8996
- 203,
8997
- 205,
8998
- 206,
8999
- 207
9000
- ]);
9001
- /** The EXIF orientation in an APP1 segment's TIFF block (1 when it says none). */
9002
- function exifOrientation(b, tiff, end) {
9003
- if (tiff + 8 > end) return 1;
9004
- const little = ascii(b, tiff, "II");
9005
- if (!little && !ascii(b, tiff, "MM")) return 1;
9006
- const u16 = (at) => little ? u16le(b, at) : u16be(b, at);
9007
- const u32 = (at) => little ? u16le(b, at) + u16le(b, at + 2) * 65536 : u32be(b, at);
9008
- if (u16(tiff + 2) !== 42) return 1;
9009
- const ifd = tiff + u32(tiff + 4);
9010
- if (ifd + 2 > end) return 1;
9011
- const count = u16(ifd);
9012
- for (let i = 0; i < count; i++) {
9013
- const entry = ifd + 2 + i * 12;
9014
- if (entry + 12 > end) return 1;
9015
- if (u16(entry) === 274) {
9016
- const value = u16(entry + 8);
9017
- return value >= 1 && value <= 8 ? value : 1;
9018
- }
9019
- }
9020
- return 1;
9021
- }
9022
- function jpegSize(b) {
9023
- let at = 2, orientation = 1;
9024
- while (at + 4 <= b.length) {
9025
- if (b[at] !== 255) return void 0;
9026
- const marker = b[at + 1];
9027
- if (marker === 255) {
9028
- at++;
9029
- continue;
9030
- }
9031
- if (marker === 1 || marker >= 208 && marker <= 215) {
9032
- at += 2;
9033
- continue;
9034
- }
9035
- if (marker === 217 || marker === 218) return void 0;
9036
- const length = u16be(b, at + 2);
9037
- if (length < 2) return void 0;
9038
- const end = Math.min(b.length, at + 2 + length);
9039
- if (marker === 225 && ascii(b, at + 4, "Exif\0\0")) orientation = exifOrientation(b, at + 10, end);
9040
- if (JPEG_FRAME.has(marker)) {
9041
- if (at + 9 > b.length) return void 0;
9042
- const height = u16be(b, at + 5), width = u16be(b, at + 7);
9043
- return orientation >= 5 ? sized(height, width) : sized(width, height);
9044
- }
9045
- at += 2 + length;
9046
- }
9047
- }
9048
- function webpSize(b) {
9049
- if (b.length < 30) return void 0;
9050
- if (ascii(b, 12, "VP8 ")) {
9051
- if (b[23] !== 157 || b[24] !== 1 || b[25] !== 42) return void 0;
9052
- return sized(u16le(b, 26) & 16383, u16le(b, 28) & 16383);
9053
- }
9054
- if (ascii(b, 12, "VP8L")) {
9055
- if (b[20] !== 47) return void 0;
9056
- const bits = b[21] | b[22] << 8 | b[23] << 16 | b[24] << 24;
9057
- return sized((bits & 16383) + 1, (bits >>> 14 & 16383) + 1);
9058
- }
9059
- if (ascii(b, 12, "VP8X")) return sized(u24le(b, 24) + 1, u24le(b, 27) + 1);
9060
- }
9061
- /**
9062
- * A picture's size as it is shown, read from its first bytes (PNG, JPEG, GIF, WebP), or undefined when they do not
9063
- * say. Only the header is read: a JPEG whose frame header sits past `IMAGE_LIMITS.headBytes` goes without.
9064
- */
9065
- function imageSize(head) {
9066
- const b = head;
9067
- try {
9068
- if (b.length >= 24 && b[0] === 137 && ascii(b, 1, "PNG\r\n\n") && ascii(b, 12, "IHDR")) return sized(u32be(b, 16), u32be(b, 20));
9069
- if (b.length >= 10 && (ascii(b, 0, "GIF87a") || ascii(b, 0, "GIF89a"))) return sized(u16le(b, 6), u16le(b, 8));
9070
- if (b.length >= 4 && b[0] === 255 && b[1] === 216 && b[2] === 255) return jpegSize(b);
9071
- if (b.length >= 16 && ascii(b, 0, "RIFF") && ascii(b, 8, "WEBP")) return webpSize(b);
9072
- } catch {}
9073
- }
9074
- /**
9075
- * The description a picture is sent with, read from the start of the file, or undefined when it is not a picture
9076
- * or its first bytes do not say (it then goes without one).
9077
- */
9078
- async function readImageMeta(source, mime) {
9079
- if (!isImageMime(mime)) return void 0;
9080
- try {
9081
- return imageSize(new Uint8Array(await source.slice(0, IMAGE_LIMITS.headBytes).arrayBuffer()));
9082
- } catch {
9083
- return;
9084
- }
9085
- }
9086
- //#endregion
9087
7381
  //#region ../core/src/dhtFiles.ts
9088
7382
  /**
9089
7383
  * A file's bubble on the DHT floor (WISP 403 § Files): while the contact is away, a file's offer (its name, size, type,
@@ -9177,6 +7471,13 @@ var DHT_EXPIRY_SKEW_MS = PKARR_FUTURE_SKEW_MS;
9177
7471
  */
9178
7472
  var DHT_ISSUED_BACK_MS = 12e4;
9179
7473
  var MAX_ATTEMPTS = 8;
7474
+ /**
7475
+ * Envelope sequences are set aside this many at a time (`DhtDeliveryState.sequence` is the highest set aside): a
7476
+ * keep-alive, every four minutes per chat, and one the relays' budget turns away, every 30 s on a profile idling at
7477
+ * it, then cost no disk write of their own. A reader only needs the sequence to grow, so those a restart skips cost
7478
+ * nothing; one the budget turned away is not used again, as it may have gone out on the DHT (the CLI puts there too).
7479
+ */
7480
+ var SEQUENCE_BLOCK = 64;
9180
7481
  /** The wait before publication attempt `attempts + 1` of a text, or of a receipt the contact still asks for. */
9181
7482
  var backoff = (attempts) => Math.min(6e4, 4e3 * 2 ** attempts);
9182
7483
  /** The contact's mailbox is read this often while either side is DHT-only, and otherwise… */
@@ -9230,6 +7531,10 @@ var emptyDhtDeliveryState = () => ({
9230
7531
  var DhtDelivery = class DhtDelivery {
9231
7532
  options;
9232
7533
  state;
7534
+ /** The state as last saved: a save that would write the same row again is skipped. */
7535
+ saved;
7536
+ /** The sequence of the last envelope signed here; the state's is the highest set aside (`SEQUENCE_BLOCK`). */
7537
+ sequence;
9233
7538
  mode;
9234
7539
  running = false;
9235
7540
  ticking = false;
@@ -9240,6 +7545,7 @@ var DhtDelivery = class DhtDelivery {
9240
7545
  fastUntil = 0;
9241
7546
  live = false;
9242
7547
  active = false;
7548
+ startedAt = Date.now();
9243
7549
  /** The next read was asked for (a refresh, a fresh packet of the contact): it is not a background one. */
9244
7550
  urgent = false;
9245
7551
  /** Reads left that are signaling: the contact is leaving DHT only, and the live link waits on them (`expect`). */
@@ -9284,6 +7590,8 @@ var DhtDelivery = class DhtDelivery {
9284
7590
  const { peerRejected: _peerRejected, ...state } = structuredClone(options.state ?? emptyDhtDeliveryState());
9285
7591
  this.state = state;
9286
7592
  this.mode = options.mode;
7593
+ this.sequence = state.sequence;
7594
+ if (options.state) this.saved = JSON.stringify(state);
9287
7595
  this.unsealedOut = state.sequence > 0 && !options.credentials.peerKey;
9288
7596
  this.from = identityFromSeedB64(options.params.seedB64).pubKeyZ32;
9289
7597
  this.to = options.params.peerPubKeyZ32;
@@ -9372,7 +7680,11 @@ var DhtDelivery = class DhtDelivery {
9372
7680
  return operation;
9373
7681
  }
9374
7682
  async persist(next) {
9375
- await this.options.save(structuredClone(next));
7683
+ const json = JSON.stringify(next);
7684
+ if (json !== this.saved) {
7685
+ await this.options.save(structuredClone(next));
7686
+ this.saved = json;
7687
+ }
9376
7688
  this.state = next;
9377
7689
  }
9378
7690
  sealedKey(peer) {
@@ -9547,7 +7859,7 @@ var DhtDelivery = class DhtDelivery {
9547
7859
  }
9548
7860
  packetError(message, expires, reply, edit) {
9549
7861
  try {
9550
- this.records(this.body(this.state.sequence + 1, Date.now(), expires, message, this.state.receipt?.id ?? "abcdefghijklmnopqrstuv", reply, [], edit), this.options.credentials.peerKey ?? this.participation.pubKeyZ32);
7862
+ this.records(this.body(this.sequence + 1, Date.now(), expires, message, this.state.receipt?.id ?? "abcdefghijklmnopqrstuv", reply, [], edit), this.options.credentials.peerKey ?? this.participation.pubKeyZ32);
9551
7863
  return null;
9552
7864
  } catch (error) {
9553
7865
  return error instanceof Error ? error.message : String(error);
@@ -9555,7 +7867,7 @@ var DhtDelivery = class DhtDelivery {
9555
7867
  }
9556
7868
  /** Whether a text with its hop count fits the packet and what a reader reads (`MAX_ENVELOPE_PLAINTEXT`). */
9557
7869
  roomForHops(message, expires, reply, forwarded) {
9558
- const body = this.body(this.state.sequence + 1, Date.now(), expires, message, this.state.receipt?.id ?? "abcdefghijklmnopqrstuv", reply, [], void 0, forwarded);
7870
+ const body = this.body(this.sequence + 1, Date.now(), expires, message, this.state.receipt?.id ?? "abcdefghijklmnopqrstuv", reply, [], void 0, forwarded);
9559
7871
  try {
9560
7872
  this.records(body, this.options.credentials.peerKey ?? this.participation.pubKeyZ32);
9561
7873
  } catch {
@@ -9634,7 +7946,7 @@ var DhtDelivery = class DhtDelivery {
9634
7946
  ], expires = now + DHT_MESSAGE_TTL;
9635
7947
  const hops = readForwarded(forwarded);
9636
7948
  const fits = (element, withReply, withHops) => {
9637
- const body = this.body(this.state.sequence + 1, now, expires, message, this.state.receipt?.id ?? "abcdefghijklmnopqrstuv", withReply ? reply : void 0, [], void 0, withHops ? hops : void 0, element);
7949
+ const body = this.body(this.sequence + 1, now, expires, message, this.state.receipt?.id ?? "abcdefghijklmnopqrstuv", withReply ? reply : void 0, [], void 0, withHops ? hops : void 0, element);
9638
7950
  try {
9639
7951
  this.records(body, this.options.credentials.peerKey ?? this.participation.pubKeyZ32);
9640
7952
  } catch {
@@ -9823,13 +8135,14 @@ var DhtDelivery = class DhtDelivery {
9823
8135
  if (keepAlive && this.controlDue > now) return;
9824
8136
  const expires = pending?.expires ?? now - 12e4 + CONTROL_TTL;
9825
8137
  const issued = Math.min(now, Math.max(now - DHT_ISSUED_BACK_MS, expires - (pending ? DHT_MESSAGE_TTL : CONTROL_TTL), (pending?.message[1] ?? 0) - 3e4));
9826
- const { body, records, reactions } = this.fitted(this.state.sequence + 1, issued, expires, pending?.message ?? null, receipt?.id ?? null, pending?.reply, pending?.edit, pending?.forwarded, pending?.file);
8138
+ const { body, records, reactions } = this.fitted(this.sequence + 1, issued, expires, pending?.message ?? null, receipt?.id ?? null, pending?.reply, pending?.edit, pending?.forwarded, pending?.file);
9827
8139
  const reactionsWereDue = this.reactionsDue;
9828
8140
  this.reactionsDue = !reactions && !!this.options.reactions?.().length;
9829
8141
  const identity = !reseal && this.state.peerPinned && this.state.peerPinned !== "can" && this.pinned()?.identity || this.identity;
8142
+ const sequence = body[1];
9830
8143
  await this.persist({
9831
8144
  ...this.state,
9832
- sequence: body[1],
8145
+ sequence: sequence > this.state.sequence ? sequence + SEQUENCE_BLOCK - 1 : this.state.sequence,
9833
8146
  pending: pending ? {
9834
8147
  ...pending,
9835
8148
  attempts: pending.attempts + 1,
@@ -9841,6 +8154,7 @@ var DhtDelivery = class DhtDelivery {
9841
8154
  next: now + backoff(receipt.attempts)
9842
8155
  } : this.state.receipt
9843
8156
  });
8157
+ this.sequence = sequence;
9844
8158
  const before = {
9845
8159
  lastPublish: this.lastPublish,
9846
8160
  controlDue: this.controlDue
@@ -10175,11 +8489,20 @@ var DhtDelivery = class DhtDelivery {
10175
8489
  get pollMs() {
10176
8490
  if (this.options.pollMs) return this.options.pollMs;
10177
8491
  const now = Date.now();
8492
+ const unused = this.unusedInviteAge(now);
8493
+ if (unused !== void 0 && !this.active && now >= this.fastUntil) return unusedInviteWait(unused, this.mode === "dht" ? DHT_POLL_MS : STREAM_POLL_MS);
10178
8494
  if (this.mode === "dht" || this.state.peerMode === "dht" && now < this.leavingUntil || now < this.fastUntil) return DHT_POLL_MS;
10179
8495
  if (this.live) return LIVE_POLL_MS;
10180
8496
  if (this.state.pending && this.state.pending.expires > now) return DHT_POLL_MS;
10181
8497
  return this.active ? ACTIVE_DHT_POLL_MS : STREAM_POLL_MS;
10182
8498
  }
8499
+ /** How long ago this side's invite was made, while nobody used it and it is no longer awaited (`AWAITING_PEER_MS`). */
8500
+ unusedInviteAge(now) {
8501
+ const madeAt = this.options.inviteMadeAt;
8502
+ if (madeAt === void 0 || this.options.credentials.peerKey || this.state.peerSequence) return void 0;
8503
+ const age = now - Math.min(madeAt, this.startedAt);
8504
+ return age >= 6e5 ? age : void 0;
8505
+ }
10183
8506
  schedule() {
10184
8507
  if (!this.running || this.ticking) return;
10185
8508
  if (this.timer) clearTimeout(this.timer);
@@ -13713,6 +12036,11 @@ var SERVICES_CAPABILITY = "services/1";
13713
12036
  * confirmed byte, checked by digest (`pf-offer` … frames, `chatFiles.ts`). Without it, files/2 and 100 MiB.
13714
12037
  */
13715
12038
  var FILES_CAPABILITY = "files/3";
12039
+ /**
12040
+ * A file's bytes as binary messages (WISP 501 § files/4): beside `files/3`, on a session whose channel carries binary
12041
+ * messages, a chunk travels as it is (`encodeFileData`, `chatFiles.ts`) and not as base64url text in a `pf-data` frame.
12042
+ */
12043
+ var FILES_BINARY_CAPABILITY = "files/4";
13716
12044
  /** The typing indicator: `paired-typing` frames on this session, 1:1 chats only (WISP 401 § Typing, `pairedTyping.ts`). */
13717
12045
  var TYPING_CAPABILITY = "typing/1";
13718
12046
  /** Reactions to messages: `paired-reaction` frames on this session, 1:1 chats only (WISP 401 § Reactions, `reactions.ts`). */
@@ -13752,6 +12080,7 @@ var KNOWN_SESSION_CAPABILITIES = [
13752
12080
  CALLS_CAPABILITY,
13753
12081
  SERVICES_CAPABILITY,
13754
12082
  FILES_CAPABILITY,
12083
+ FILES_BINARY_CAPABILITY,
13755
12084
  TYPING_CAPABILITY,
13756
12085
  REACTIONS_CAPABILITY,
13757
12086
  EDIT_CAPABILITY,
@@ -13848,6 +12177,8 @@ var SessionCapabilities = class {
13848
12177
  * be matched across a person's chats; it travels only inside the encrypted session.
13849
12178
  */
13850
12179
  var APP_FRAME = "paired-app";
12180
+ /** The largest `d`, as compact UTF-8 JSON. The session's 60 KiB cap on any frame still holds above it. */
12181
+ var APP_DATA_MAX_BYTES = 32768;
13851
12182
  var DOMAIN = "ghostly-apps/1";
13852
12183
  var APP_ID = /^[A-Za-z0-9_-]{22}$/;
13853
12184
  /** Semantic versioning 2.0.0, as a manifest's `version` is. */
@@ -14160,7 +12491,8 @@ function vapidAuthorization(endpoint, keys, subject, now = Date.now()) {
14160
12491
  exp: Math.floor(now / 1e3) + TOKEN_SECONDS,
14161
12492
  sub: subject
14162
12493
  })}`;
14163
- return `vapid t=${unsigned}.${toBase64Url(p256.sign(utf8Encode(unsigned), fromBase64Url(keys.privateKey)))}, k=${keys.publicKey}`;
12494
+ const signature = p256.sign(utf8Encode(unsigned), fromBase64Url(keys.privateKey));
12495
+ return `vapid t=${unsigned}.${toBase64Url(signature)}, k=${keys.publicKey}`;
14164
12496
  }
14165
12497
  /**
14166
12498
  * `plaintext` encrypted for a subscription (RFC 8291 with RFC 8188 `aes128gcm`, one record). `sender` fixes
@@ -14478,7 +12810,8 @@ var LINK_OR_MARKUP = /[a-z][a-z0-9+.-]*:\/\/|\bwww\.|\b(?:mailto|javascript|data
14478
12810
  */
14479
12811
  function sanitizeTypingStatus(value) {
14480
12812
  if (typeof value !== "string") return void 0;
14481
- const clean = sanitizeDisplayText(value.slice(0, 160).replace(/\s+/g, " "), 40);
12813
+ const oneLine = value.slice(0, 160).replace(/\s+/g, " ");
12814
+ const clean = sanitizeDisplayText(oneLine, 40);
14482
12815
  if (!clean || LINK_OR_MARKUP.test(clean)) return void 0;
14483
12816
  return clean;
14484
12817
  }
@@ -14687,6 +13020,11 @@ var FILE_LIMITS = {
14687
13020
  chunkBytes: 16384,
14688
13021
  /** Bytes a sender has sent and not seen confirmed: throughput is this per round trip. */
14689
13022
  windowBytes: 1048576,
13023
+ /**
13024
+ * Bytes all of a chat's files have sent and not seen confirmed, together. Three files' windows at once were 3 MiB, and
13025
+ * as base64 text past what a session's receive queue holds (`SESSION_RECEIVE_PENDING`, 3.75 MiB).
13026
+ */
13027
+ chatWindowBytes: 2097152,
14690
13028
  /** A receiver asks its person before taking a file larger than this. */
14691
13029
  askAboveBytes: 26214400,
14692
13030
  /** Files taken without asking, per contact, while stored. Past it, every file is asked about. */
@@ -14722,7 +13060,7 @@ var FILE_LIMITS = {
14722
13060
  resendBytes: 268435456
14723
13061
  };
14724
13062
  var ID = /^[A-Za-z0-9_-]{8,64}$/;
14725
- var DIGEST$1 = /^[A-Za-z0-9_-]{43}$/;
13063
+ var DIGEST = /^[A-Za-z0-9_-]{43}$/;
14726
13064
  var DATA = /^[A-Za-z0-9_-]+$/;
14727
13065
  var FILE_FRAMES = /* @__PURE__ */ new Set([
14728
13066
  "pf-offer",
@@ -14755,6 +13093,37 @@ function transferStalled(record, movedAt, restored, now) {
14755
13093
  }
14756
13094
  var key = (direction, id) => `${direction}:${id}`;
14757
13095
  var isInt$1 = (value, max = Number.MAX_SAFE_INTEGER) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= max;
13096
+ var FILE_DATA_OFFSET_BYTES = 8;
13097
+ /** The binary message for the bytes of file `id` at `offset`. Throws on what no receiver would take. */
13098
+ function encodeFileData(id, offset, data) {
13099
+ if (!ID.test(id) || !isInt$1(offset) || !data.length || data.length > FILE_LIMITS.chunkBytes) throw new Error("Invalid file data");
13100
+ const head = 2 + id.length;
13101
+ const out = new Uint8Array(head + FILE_DATA_OFFSET_BYTES + data.length);
13102
+ out[0] = 4;
13103
+ out[1] = id.length;
13104
+ for (let i = 0; i < id.length; i++) out[2 + i] = id.charCodeAt(i);
13105
+ const view = new DataView(out.buffer);
13106
+ view.setUint32(head, Math.floor(offset / 2 ** 32));
13107
+ view.setUint32(head + 4, offset % 2 ** 32);
13108
+ out.set(data, head + FILE_DATA_OFFSET_BYTES);
13109
+ return out;
13110
+ }
13111
+ /** 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`. */
13112
+ function decodeFileData(message) {
13113
+ if (message.length < 2 || message[0] !== 4) return null;
13114
+ const head = 2 + message[1], body = head + FILE_DATA_OFFSET_BYTES;
13115
+ if (message.length <= body || message.length - body > FILE_LIMITS.chunkBytes) return null;
13116
+ const id = String.fromCharCode(...message.subarray(2, head));
13117
+ if (!ID.test(id)) return null;
13118
+ const view = new DataView(message.buffer, message.byteOffset, message.byteLength);
13119
+ const high = view.getUint32(head);
13120
+ if (high > 2097151) return null;
13121
+ return {
13122
+ id,
13123
+ offset: high * 2 ** 32 + view.getUint32(head + 4),
13124
+ data: message.subarray(body)
13125
+ };
13126
+ }
14758
13127
  /** What a refusal means to the sender's person. */
14759
13128
  function refusalText(why, room) {
14760
13129
  switch (why) {
@@ -14791,6 +13160,10 @@ function refusalText(why, room) {
14791
13160
  var ChatFiles = class {
14792
13161
  host;
14793
13162
  entries = /* @__PURE__ */ new Map();
13163
+ /** Files sending that may have bytes not confirmed yet: what `inFlight` adds up (the rest are left out as it goes). */
13164
+ moving = /* @__PURE__ */ new Set();
13165
+ /** Files that stopped sending for the chat's window alone: they go on once another's bytes are confirmed. */
13166
+ held = /* @__PURE__ */ new Set();
14794
13167
  attached = false;
14795
13168
  peerRoomValue = null;
14796
13169
  constructor(host) {
@@ -14850,6 +13223,15 @@ var ChatFiles = class {
14850
13223
  send(frame) {
14851
13224
  return this.attached && this.host.send(frame);
14852
13225
  }
13226
+ sendData(id, offset, data) {
13227
+ if (!this.host.sendData) return this.send({
13228
+ t: "pf-data",
13229
+ id,
13230
+ offset,
13231
+ data: toBase64Url(data)
13232
+ });
13233
+ return this.attached && this.host.sendData(id, offset, data);
13234
+ }
14853
13235
  /** A session that agreed files/3 is open: say what fits here, then offer again what is not finished. */
14854
13236
  attach() {
14855
13237
  this.attached = true;
@@ -14868,6 +13250,8 @@ var ChatFiles = class {
14868
13250
  if (!this.attached) return;
14869
13251
  this.attached = false;
14870
13252
  this.peerRoomValue = null;
13253
+ this.moving.clear();
13254
+ this.held.clear();
14871
13255
  for (const entry of this.entries.values()) {
14872
13256
  if (entry.out) {
14873
13257
  entry.out.generation++;
@@ -14991,10 +13375,34 @@ var ChatFiles = class {
14991
13375
  const at = offset - out.block.start;
14992
13376
  return out.block.bytes.subarray(at, at + length);
14993
13377
  }
14994
- shouldPump(entry) {
13378
+ /** Whether this file's own window lets it send more. */
13379
+ fileMayPump(entry) {
14995
13380
  const { record } = entry, out = entry.out;
14996
13381
  return this.attached && record.state === "active" && out.next < record.file.size && out.next - record.confirmed < FILE_LIMITS.windowBytes;
14997
13382
  }
13383
+ shouldPump(entry) {
13384
+ return this.fileMayPump(entry) && this.inFlight() < FILE_LIMITS.chatWindowBytes;
13385
+ }
13386
+ /** Bytes this chat's files sent and have not seen confirmed. */
13387
+ inFlight() {
13388
+ let bytes = 0;
13389
+ for (const entry of this.moving) {
13390
+ const out = entry.out, { record } = entry;
13391
+ if (!out || record.state !== "active" || out.next <= record.confirmed) {
13392
+ this.moving.delete(entry);
13393
+ continue;
13394
+ }
13395
+ bytes += out.next - record.confirmed;
13396
+ }
13397
+ return bytes;
13398
+ }
13399
+ /** Bytes were confirmed, or a file stopped sending: the files the chat's window held go on, before the one that freed it. */
13400
+ wakeHeld() {
13401
+ for (const entry of [...this.held]) {
13402
+ this.held.delete(entry);
13403
+ this.pump(entry);
13404
+ }
13405
+ }
14998
13406
  /**
14999
13407
  * Whether the next `length` bytes go. Within the file's size they always do; past it the receiver is having parts
15000
13408
  * sent again, which nothing it says bounds, so this side does.
@@ -15025,17 +13433,13 @@ var ChatFiles = class {
15025
13433
  this.end(entry, "failed", "Your contact asked for the same part of this file too many times");
15026
13434
  break;
15027
13435
  }
15028
- if (!this.send({
15029
- t: "pf-data",
15030
- id: entry.record.id,
15031
- offset,
15032
- data: toBase64Url(chunk)
15033
- })) {
13436
+ if (!this.sendData(entry.record.id, offset, chunk)) {
15034
13437
  refused = true;
15035
13438
  break;
15036
13439
  }
15037
13440
  out.next = offset + chunk.length;
15038
13441
  out.sent += chunk.length;
13442
+ this.moving.add(entry);
15039
13443
  }
15040
13444
  } catch (error) {
15041
13445
  if (generation === out.generation) {
@@ -15050,7 +13454,7 @@ var ChatFiles = class {
15050
13454
  if (generation === out.generation && this.shouldPump(entry)) {
15051
13455
  if (refused) out.wake = setTimeout(() => void this.pump(entry), FILE_LIMITS.sendRetryMs);
15052
13456
  else this.pump(entry);
15053
- }
13457
+ } else if (generation === out.generation && this.fileMayPump(entry)) this.held.add(entry);
15054
13458
  }
15055
13459
  }
15056
13460
  async sendSum(entry) {
@@ -15082,7 +13486,7 @@ var ChatFiles = class {
15082
13486
  return [...this.entries.values()].filter((e) => e.record.direction === "in" && e.record.state === "asking").length;
15083
13487
  }
15084
13488
  activeIncoming(except) {
15085
- return [...this.entries.values()].filter((e) => e !== except && e.record.direction === "in" && (e.record.state === "active" || e.record.state === "verifying")).length;
13489
+ return [...this.entries.values()].filter((e) => e !== except && e.record.direction === "in" && (e.record.state === "active" || e.record.state === "verifying" || e.in?.starting)).length;
15086
13490
  }
15087
13491
  /**
15088
13492
  * Removes what this side stored of an incoming file that ends here. Through its target, opened now if need be: after
@@ -15109,9 +13513,15 @@ var ChatFiles = class {
15109
13513
  }
15110
13514
  return incoming.target;
15111
13515
  }
15112
- /** An accepted file goes when a session is open and fewer than three others arrive at once. */
13516
+ /**
13517
+ * An accepted file goes when a session is open and fewer than three others arrive at once. One whose target is still
13518
+ * being opened counts among the three: counted only once active, every file offered or queued meanwhile went too, and
13519
+ * the windows of ten files and more at once overflowed the session's receive queue.
13520
+ */
15113
13521
  startIncoming(entry) {
15114
13522
  if (!this.attached || transferEnded(entry.record) || entry.record.state === "asking" || entry.record.state === "paused" && entry.record.pausedBy === "me") return;
13523
+ const incoming = entry.in;
13524
+ if (incoming.starting) return;
15115
13525
  if (this.activeIncoming(entry) >= FILE_LIMITS.maxActiveIncoming) {
15116
13526
  if (entry.record.state !== "queued") {
15117
13527
  entry.record.state = "queued";
@@ -15124,9 +13534,13 @@ var ChatFiles = class {
15124
13534
  });
15125
13535
  return;
15126
13536
  }
13537
+ incoming.starting = true;
15127
13538
  this.target(entry).then(() => {
15128
- if (transferEnded(entry.record) || !this.attached) return;
15129
- if (entry.record.state === "paused" && entry.record.pausedBy === "me") return;
13539
+ incoming.starting = false;
13540
+ if (transferEnded(entry.record) || !this.attached || entry.record.state === "paused" && entry.record.pausedBy === "me") {
13541
+ this.startNext();
13542
+ return;
13543
+ }
15130
13544
  entry.record.state = "active";
15131
13545
  entry.record.pausedBy = void 0;
15132
13546
  entry.in.gapAt = void 0;
@@ -15139,12 +13553,14 @@ var ChatFiles = class {
15139
13553
  entry.in.heardAt = this.now();
15140
13554
  this.armNudge(entry);
15141
13555
  }, (error) => {
13556
+ incoming.starting = false;
15142
13557
  this.end(entry, "failed", `Could not store the file: ${error instanceof Error ? error.message : String(error)}`);
15143
13558
  this.send({
15144
13559
  t: "pf-refuse",
15145
13560
  id: entry.record.id,
15146
13561
  why: "no-room"
15147
13562
  });
13563
+ this.startNext();
15148
13564
  });
15149
13565
  }
15150
13566
  /** The next file waiting its turn, once one ends. */
@@ -15383,7 +13799,9 @@ var ChatFiles = class {
15383
13799
  if (record.state !== "active" || !incoming.target) return;
15384
13800
  incoming.heardAt = this.now();
15385
13801
  const { offset, data } = frame;
15386
- if (!isInt$1(offset) || typeof data !== "string" || !data.length || data.length > Math.ceil(FILE_LIMITS.chunkBytes * 4 / 3) || !DATA.test(data)) return;
13802
+ const binary = data instanceof Uint8Array;
13803
+ if (!isInt$1(offset) || !(binary || typeof data === "string") || !data.length) return;
13804
+ if (!binary && (data.length > Math.ceil(FILE_LIMITS.chunkBytes * 4 / 3) || !DATA.test(data))) return;
15387
13805
  if (offset < incoming.written) {
15388
13806
  if (incoming.stored === incoming.written) this.send({
15389
13807
  t: "pf-got",
@@ -15403,7 +13821,7 @@ var ChatFiles = class {
15403
13821
  }
15404
13822
  return;
15405
13823
  }
15406
- const bytes = fromBase64Url(data);
13824
+ const bytes = binary ? data : fromBase64Url(data);
15407
13825
  if (!bytes.length || bytes.length > FILE_LIMITS.chunkBytes || offset + bytes.length > record.file.size) return;
15408
13826
  incoming.written += bytes.length;
15409
13827
  const target = incoming.target;
@@ -15495,7 +13913,7 @@ var ChatFiles = class {
15495
13913
  return;
15496
13914
  }
15497
13915
  if (record.state !== "active" || incoming.verifying || !incoming.target) return;
15498
- if (frame.size !== record.file.size || typeof frame.digest !== "string" || !DIGEST$1.test(frame.digest)) return;
13916
+ if (frame.size !== record.file.size || typeof frame.digest !== "string" || !DIGEST.test(frame.digest)) return;
15499
13917
  const digest = frame.digest;
15500
13918
  await incoming.writing;
15501
13919
  if (transferEnded(record) || record.state !== "active") return;
@@ -15588,6 +14006,7 @@ var ChatFiles = class {
15588
14006
  record.state = "active";
15589
14007
  record.pausedBy = record.waitingFor = void 0;
15590
14008
  this.changed(outgoing);
14009
+ this.wakeHeld();
15591
14010
  if (record.confirmed === record.file.size) this.sendSum(outgoing);
15592
14011
  else {
15593
14012
  this.armStall(outgoing);
@@ -15607,11 +14026,13 @@ var ChatFiles = class {
15607
14026
  record.waitingFor = frame.why === "busy" ? "busy" : "consent";
15608
14027
  }
15609
14028
  this.changed(outgoing);
14029
+ this.wakeHeld();
15610
14030
  return;
15611
14031
  case "pf-got":
15612
14032
  if (!isInt$1(frame.offset) || frame.offset <= record.confirmed || frame.offset > out.next) return;
15613
14033
  record.confirmed = frame.offset;
15614
14034
  this.changed(outgoing, true);
14035
+ this.wakeHeld();
15615
14036
  if (record.confirmed === record.file.size) this.sendSum(outgoing);
15616
14037
  else {
15617
14038
  this.armStall(outgoing);
@@ -15619,7 +14040,7 @@ var ChatFiles = class {
15619
14040
  }
15620
14041
  return;
15621
14042
  case "pf-done":
15622
- if (record.state !== "verifying") return;
14043
+ if (record.state !== "verifying" && record.confirmed !== record.file.size) return;
15623
14044
  record.confirmed = record.file.size;
15624
14045
  this.end(outgoing, "done");
15625
14046
  return;
@@ -15641,6 +14062,8 @@ var ChatFiles = class {
15641
14062
  clearTimeout(entry.out.wake);
15642
14063
  entry.out.block = void 0;
15643
14064
  entry.out.source = void 0;
14065
+ this.moving.delete(entry);
14066
+ this.held.delete(entry);
15644
14067
  }
15645
14068
  if (entry.in) {
15646
14069
  const incoming = entry.in;
@@ -15650,6 +14073,7 @@ var ChatFiles = class {
15650
14073
  });
15651
14074
  }
15652
14075
  this.changed(entry);
14076
+ if (entry.out) this.wakeHeld();
15653
14077
  }
15654
14078
  entry(direction, id) {
15655
14079
  const entry = this.entries.get(key(direction, id));
@@ -15698,6 +14122,7 @@ var ChatFiles = class {
15698
14122
  this.startNext();
15699
14123
  }
15700
14124
  this.changed(entry);
14125
+ if (entry.out) this.wakeHeld();
15701
14126
  }
15702
14127
  resume(direction, id) {
15703
14128
  const entry = this.entry(direction, id), { record } = entry;
@@ -16316,6 +14741,8 @@ var CarriedTransport = class {
16316
14741
  /** My last packet while the edge is down: what a member that just became reachable is handed. */
16317
14742
  mine = null;
16318
14743
  lastTimestamp = 0n;
14744
+ /** The date each packet `stamp` signed carries: the relays' transport takes it rather than check the signature again. */
14745
+ dated = /* @__PURE__ */ new WeakMap();
16319
14746
  /** My newest packet, wherever it went: what the edge hands the member over itself once it is ready (`linkReady`). */
16320
14747
  newest = null;
16321
14748
  /** The newest packet of the member's that came through a carrier, and the timestamp of the one a read last returned. */
@@ -16395,7 +14822,9 @@ var CarriedTransport = class {
16395
14822
  stamp(identity, records) {
16396
14823
  const now = BigInt(Date.now()) * 1000n;
16397
14824
  this.lastTimestamp = now > this.lastTimestamp ? now : this.lastTimestamp + 1n;
16398
- return createRelayPayload(identity, records, this.lastTimestamp);
14825
+ const payload = createRelayPayload(identity, records, this.lastTimestamp);
14826
+ this.dated.set(payload, this.lastTimestamp);
14827
+ return payload;
16399
14828
  }
16400
14829
  /**
16401
14830
  * The relays get the very packet members carried or the edge itself handed over, byte for byte, where the transport
@@ -16405,7 +14834,11 @@ var CarriedTransport = class {
16405
14834
  * 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).
16406
14835
  */
16407
14836
  toRelays(identity, records, payload, options) {
16408
- return this.inner.publishPayload ? this.inner.publishPayload(identity.pubKeyZ32, payload, options) : this.inner.publish(identity, records, options);
14837
+ const signedAt = this.dated.get(payload);
14838
+ return this.inner.publishPayload ? this.inner.publishPayload(identity.pubKeyZ32, payload, signedAt === void 0 ? options : {
14839
+ ...options,
14840
+ signedAt
14841
+ }) : this.inner.publish(identity, records, options);
16409
14842
  }
16410
14843
  /**
16411
14844
  * The packet the relays just took, handed to the member over the edge itself while it is up. An edge up reads nothing
@@ -16519,142 +14952,6 @@ var CarriedTransport = class {
16519
14952
  }
16520
14953
  };
16521
14954
  //#endregion
16522
- //#region ../core/src/groupFiles.ts
16523
- /**
16524
- * Files and voice messages in groups (WISP 503 · Group Files). A file is announced as a group message carrying its
16525
- * description; its bytes go over `files/3` between two members, from a member that holds it to one that asks for it.
16526
- * The limits below are the profile's first numbers, kept in one place so they are easy to change.
16527
- */
16528
- var GROUP_FILE_LIMITS = {
16529
- /** The largest file a group takes. */
16530
- maxBytes: 104857600,
16531
- /** A file this size or smaller is fetched without asking (voice messages always are, up to `VOICE_LIMITS`). */
16532
- autoBytes: 8388608,
16533
- /** What a device keeps of a group's files it fetched without asking; past it, every file is asked about. */
16534
- autoBytesPerGroup: 268435456,
16535
- /** Bytes of group files a device serves to others in a day, when serving is on. */
16536
- serveBytesPerDay: 1073741824,
16537
- /** Transfers a holder serves at once, all groups together. */
16538
- serveAtOnce: 3,
16539
- /** File announcements a member sends to one group in a minute. */
16540
- announcePerMinute: 8,
16541
- /** File announcements of one member of a group fetched by themselves in `announceWindowMs`; past it, kept for a Download. */
16542
- announceTaken: 30,
16543
- announceWindowMs: 6e5,
16544
- /** Holders remembered per file. */
16545
- holders: 8,
16546
- /**
16547
- * Message ids a `group-have` named before their message came (a member back from away hears who holds what as its
16548
- * catch-up begins), remembered with their holders for when it does.
16549
- */
16550
- heardAhead: 256,
16551
- /** Message ids in one `group-have`. */
16552
- haveIds: 32,
16553
- /** A private group's `group-have` goes on an edge at most this often; a community's, from a member, every `communityHaveEveryMs`. */
16554
- haveEveryMs: 1e4,
16555
- communityHaveEveryMs: 6e4,
16556
- /**
16557
- * How long a want waits for an offer before the next holder is asked; a transfer that does not move for as long is
16558
- * left for the next holder too.
16559
- */
16560
- wantWaitMs: 3e4,
16561
- /** A file session closes this long after its last transfer ends. */
16562
- sessionIdleMs: 6e4,
16563
- /** File sessions opening at once, and open at once, all groups together. */
16564
- sessionsOpening: 2,
16565
- sessionsOpen: 4,
16566
- /** The longest file name and type kept from a description. */
16567
- nameChars: 255,
16568
- mimeChars: 255
16569
- };
16570
- /**
16571
- * The frames of WISP 503 § Getting the bytes, on an edge between two apps that announced `GROUP_VERSION_FILES`:
16572
- * `group-want {g, id}` asks a holder for the file of message `id`; `group-want-no {g, id, why}` is a holder's no;
16573
- * `group-have {g, ids}` says which files a member keeps (in a community, an application frame `{t, ids}` instead).
16574
- */
16575
- var GROUP_WANT_FRAME = "group-want";
16576
- var GROUP_WANT_NO_FRAME = "group-want-no";
16577
- var GROUP_HAVE_FRAME = "group-have";
16578
- var REFUSALS = [
16579
- "gone",
16580
- "busy",
16581
- "refused"
16582
- ];
16583
- /** A group message's id, as a private group (`<s>:<e>:<n>`) or a community (`<s>:<e>:<h>:<n>`) names it. */
16584
- var MESSAGE_ID = /^[a-z0-9]{52}:\d{1,15}(:[A-Za-z0-9_-]{1,64})?:\d{1,15}$/;
16585
- var isGroupMessageId = (value) => typeof value === "string" && MESSAGE_ID.test(value);
16586
- function frameOf(value, t, groupId) {
16587
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
16588
- const f = value;
16589
- return f.t === t && (groupId === void 0 || f.g === groupId) ? f : null;
16590
- }
16591
- /** A `group-want` of group `groupId`: the message whose file is asked for, or null. */
16592
- function readGroupWant(value, groupId) {
16593
- const f = frameOf(value, GROUP_WANT_FRAME, groupId);
16594
- return f && isGroupMessageId(f.id) ? { id: f.id } : null;
16595
- }
16596
- /** A holder's `group-want-no` of group `groupId`, or null. A reason this app does not know reads as `busy`. */
16597
- function readGroupWantNo(value, groupId) {
16598
- const f = frameOf(value, GROUP_WANT_NO_FRAME, groupId);
16599
- if (!f || !isGroupMessageId(f.id)) return null;
16600
- return {
16601
- id: f.id,
16602
- why: typeof f.why === "string" && REFUSALS.includes(f.why) ? f.why : "busy"
16603
- };
16604
- }
16605
- /**
16606
- * The message ids of a `group-have`: of group `groupId` on an edge, or (no `groupId`) a community's application frame.
16607
- * At most `haveIds` message ids; a frame with more, or anything else, is null.
16608
- */
16609
- function readGroupHave(value, groupId) {
16610
- const f = frameOf(value, GROUP_HAVE_FRAME, groupId);
16611
- if (!f || !Array.isArray(f.ids) || !f.ids.length || f.ids.length > GROUP_FILE_LIMITS.haveIds || !f.ids.every(isGroupMessageId)) return null;
16612
- return [...new Set(f.ids)];
16613
- }
16614
- var DIGEST = /^[A-Za-z0-9_-]{43}$/;
16615
- /**
16616
- * A description read from the wire, cleaned: the name made a display name (never a path), the type untrusted, the
16617
- * size within the group limit, the digest the right shape. A malformed voice, video or picture description is dropped
16618
- * on its own and the file is still a file; anything else malformed is no file at all (null), and the text shows.
16619
- */
16620
- function readGroupFileMeta(value) {
16621
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
16622
- const v = value;
16623
- if (typeof v.name !== "string" || !v.name.trim() || [...v.name].length > GROUP_FILE_LIMITS.nameChars) return null;
16624
- if (typeof v.mime !== "string" || v.mime.length > GROUP_FILE_LIMITS.mimeChars) return null;
16625
- if (!Number.isSafeInteger(v.size) || v.size < 1 || v.size > GROUP_FILE_LIMITS.maxBytes) return null;
16626
- if (typeof v.d !== "string" || !DIGEST.test(v.d)) return null;
16627
- const mime = sanitizeMime(v.mime);
16628
- const voice = v.voice === void 0 ? void 0 : parseVoiceMeta(v.voice, mime);
16629
- const video = v.video === void 0 ? void 0 : parseVideoMeta(v.video, mime);
16630
- const image = v.image === void 0 ? void 0 : parseImageMeta(v.image, mime);
16631
- return {
16632
- name: sanitizeFileName(v.name),
16633
- mime,
16634
- size: v.size,
16635
- d: v.d,
16636
- ...voice ? { voice } : {},
16637
- ...video ? { video } : {},
16638
- ...image ? { image } : {}
16639
- };
16640
- }
16641
- /**
16642
- * The text an announcement carries when its author wrote no caption: what an app without group files shows instead
16643
- * of the file. English, as wire text is; an app that knows the file shows the file, not this line.
16644
- */
16645
- function groupFileFallback(file) {
16646
- if (file.voice) return `🎤 Voice message (${formatVoiceDuration(file.voice.duration)}): update Ghostly to hear it`;
16647
- return `${fileMessageText(file)} (${formatFileSize(file.size)}): update Ghostly to get files in groups`;
16648
- }
16649
- /**
16650
- * Whether a device fetches a file by itself (WISP 503 § Automatic and asked downloads): a voice message always, any
16651
- * other file up to `autoBytes` while what it took that way from the group stays within `autoBytesPerGroup`.
16652
- */
16653
- function fetchesByItself(file, keptAutomatically, limits = GROUP_FILE_LIMITS) {
16654
- if (file.voice) return true;
16655
- return file.size <= limits.autoBytes && keptAutomatically + file.size <= limits.autoBytesPerGroup;
16656
- }
16657
- //#endregion
16658
14955
  //#region ../core/src/pairingProgress.ts
16659
14956
  var FIRST_STAGE = {
16660
14957
  inviter: "publishing",
@@ -16846,6 +15143,7 @@ var ENGINE_ERRORS = {
16846
15143
  notEnoughSats: "Not enough sats in your wallet",
16847
15144
  noSharedMint: "You share no mint with this contact",
16848
15145
  ecashAlreadySpent: "This ecash was already spent somewhere else",
15146
+ ecashBeingReceived: "This ecash is already being received",
16849
15147
  ecashOtherUnit: "This ecash is in {unit}: only sat ecash can be redeemed here",
16850
15148
  reviewedEcashSpent: "The ecash for this payment was already spent somewhere else. Nothing was sent, and your balance now shows what the mint still holds.",
16851
15149
  reviewedSatsGone: "The sats this payment was reviewed with went to another payment. Nothing was sent: review it again.",
@@ -16954,8 +15252,14 @@ var PACKET_TRANSPORTS_FAST_ATTEMPTS = 3;
16954
15252
  var DHT_PIN_GRACE_MS = 1e4;
16955
15253
  /** Back after a restart (`resume`), dials wait this long at most for the native endpoint that carried the chat to start. */
16956
15254
  var RESUME_WAIT_MS = 1e4;
16957
- /** Two dials crossing: how long the lower key's side waits for its own to connect before it takes the contact's. */
16958
- var CROSSED_WAIT_MS = 3e3;
15255
+ /**
15256
+ * A dial of the contact's that meets such a session waits this long for that session to answer a ping. It answers when
15257
+ * the dials crossed (the contact holds this side's dial too); it does not when the contact's app ended since and is
15258
+ * back: its dial then takes over. Refused outright, that dial left the contact waiting for the dead session's end, 16.6
15259
+ * 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
15260
+ * 7 ms on loopback and under 0.5 s over a relayed Iroh.
15261
+ */
15262
+ var CROSSED_PROBE_MS = 1500;
16959
15263
  /** A WebRTC attempt under way, not yet open: what a member's packet saying it has no WebRTC ends (`peerPacketTransports`). */
16960
15264
  var WEBRTC_ATTEMPT = /* @__PURE__ */ new Set([
16961
15265
  "offering",
@@ -17077,6 +15381,18 @@ var HANDLED_WAIT_MS = 1e4;
17077
15381
  */
17078
15382
  var WAKE_LOST_MS = 1e4;
17079
15383
  /**
15384
+ * What files/3 may hold on a session's channel (WISP 501, Backpressure): a quarter of its send budget (native and iroh
15385
+ * channels throw past 120 KiB), or the high water mark of a WebRTC channel, which has none.
15386
+ */
15387
+ function filesCap(channel) {
15388
+ return channel.sendBudget ? channel.sendBudget / 4 : LIMITS.sendHighWaterMark;
15389
+ }
15390
+ /**
15391
+ * 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
15392
+ * their line (WISP 1200 § In a chat): a file waiting for `pf-got` holds its share meanwhile.
15393
+ */
15394
+ var FILES_IN_USE_MS = 2e3;
15395
+ /**
17080
15396
  * A `paired-message` frame. The preview (`pv`) is left out when the frame would pass `MAX_PAIRED_MESSAGE_FRAME`
17081
15397
  * with it: the text matters, the card does not. A reply (`r`, WISP 401 § Replies) is a few hundred bytes at most
17082
15398
  * and always goes, and so does a forwarded message's hop count (`fw`, WISP 401 § Forwards) and a status card (`sc`,
@@ -17296,10 +15612,15 @@ var GhostLink = class {
17296
15612
  * was the app's in the background, not a restart (`resumeAfterAbsence`).
17297
15613
  */
17298
15614
  offered;
15615
+ /**
15616
+ * The WebRTC offer this side's dial made last, fallback allowed or not: one made before the contact's record was read
15617
+ * races once it is (`raceStandingOffer`).
15618
+ */
15619
+ offerMade;
17299
15620
  /** The attempt (its epoch) whose direct transports were knocked on as its offer went out (`knockEarly`), and whether that is under way. */
17300
15621
  knockedEarly = -1;
17301
15622
  knockingEarly = false;
17302
- /** When that offer's answer was applied (`RACE_ANSWERED_MS`); 0 while the data link is not connecting on it. */
15623
+ /** When this side's offer's answer was applied (`RACE_ANSWERED_MS`); 0 while the data link is not connecting on it. */
17303
15624
  answeredAt = 0;
17304
15625
  /** This side's offer is out unanswered, or answered and not connected yet: what is ranked after WebRTC may race it. */
17305
15626
  get offerOut() {
@@ -17419,6 +15740,7 @@ var GhostLink = class {
17419
15740
  });
17420
15741
  options.events?.onPairingProgress?.(progress);
17421
15742
  }) : null;
15743
+ const invite = options.pairingProgress?.role === "inviter" && !!options.pairing && !options.pairing.credentials.peerKey;
17422
15744
  this.dht = options.params.profile && options.pairing && options.dht ? new DhtDelivery({
17423
15745
  params: options.params,
17424
15746
  mode: this.deliveryMode,
@@ -17428,6 +15750,7 @@ var GhostLink = class {
17428
15750
  save: options.dht.save,
17429
15751
  pollMs: options.dht.pollMs,
17430
15752
  firstControlAfterMs: options.dht.firstControlAfterMs,
15753
+ ...invite && { inviteMadeAt: options.madeAt ?? Date.now() },
17431
15754
  capsRev: options.dht.capsRev,
17432
15755
  peerCapsRev: options.dht.peerCapsRev,
17433
15756
  peerAcceptsText: options.dht.peerAcceptsText,
@@ -17443,6 +15766,7 @@ var GhostLink = class {
17443
15766
  },
17444
15767
  pin: async (key) => {
17445
15768
  await options.pairing.pinPeer(key, true);
15769
+ if (invite) this.session.lookNow();
17446
15770
  if (this.isDataLinkOpen || !this.tracker || this.tracker.done) return;
17447
15771
  if (this.deliveryMode === "dht") {
17448
15772
  this.tracker.onDht("chosen");
@@ -17513,6 +15837,7 @@ var GhostLink = class {
17513
15837
  lastSeenTimestamp: options.lastSeenTimestamp,
17514
15838
  pollIntervals: options.pollIntervals,
17515
15839
  awaitingSince: options.madeAt,
15840
+ ...invite && { invite: () => !options.pairing.credentials.peerKey },
17516
15841
  getServices: options.getServices,
17517
15842
  firstPublish: options.firstPublish ?? (this.tracker && options.pairingProgress?.role === "joiner" && options.rtcAvailable !== false || this.resuming ? "after-first-poll" : "at-start"),
17518
15843
  events: {
@@ -17632,6 +15957,7 @@ var GhostLink = class {
17632
15957
  channel.close();
17633
15958
  return;
17634
15959
  }
15960
+ if (this.rtcStartedUnpinned) this.openedUnpinned.add(channel);
17635
15961
  const plan = this.switcher.pending;
17636
15962
  if (plan?.choices.includes("webrtc/1") && this.paired?.state.status === "ready") this.attachCandidate(channel, void 0, plan).catch(() => {});
17637
15963
  else if (this.activeBinding) channel.close();
@@ -17647,6 +15973,7 @@ var GhostLink = class {
17647
15973
  const was = this.lastDataLinkState;
17648
15974
  traceLink(this.myPubKeyZ32, "datalink", { state });
17649
15975
  this.trackDataLink(state);
15976
+ if (state === "offering" || state === "answering") this.rtcStartedUnpinned = !options.pairing?.credentials.peerKey && !options.pairing?.credentials.expectedPeerKey;
17650
15977
  if (this.activeBinding) return;
17651
15978
  events.onDataLinkState?.(state);
17652
15979
  if (state === "open") {
@@ -17666,9 +15993,9 @@ var GhostLink = class {
17666
15993
  }
17667
15994
  }
17668
15995
  this.answeredAt = 0;
17669
- if (state === "connecting" && was === "offering" && this.offered?.epoch === this.connectionEpoch) {
15996
+ if (state === "connecting" && was === "offering" && this.offerMade?.epoch === this.connectionEpoch) {
17670
15997
  this.answeredAt = Date.now();
17671
- this.scheduleRace(this.offered.epoch, this.offered.at);
15998
+ if (this.offered?.epoch === this.connectionEpoch) this.scheduleRace(this.offered.epoch, this.offered.at);
17672
15999
  }
17673
16000
  if (state !== "idle") return;
17674
16001
  this.dialUnseen = was === "offering" && this.offerUnsent;
@@ -18023,11 +16350,11 @@ var GhostLink = class {
18023
16350
  }
18024
16351
  start() {
18025
16352
  if (this.saidTransports) this.publishPacketTransports();
18026
- if (this.deliveryMode !== "dht") this.session.start();
18027
16353
  if (this.resuming && this.deliveryMode !== "dht") {
18028
16354
  this.session.expectPeer();
18029
16355
  this.resumeWaitUntil = Date.now() + RESUME_WAIT_MS;
18030
16356
  }
16357
+ if (this.deliveryMode !== "dht") this.session.start();
18031
16358
  if (this.tracker && !this.tracker.done && this.options.pairingProgress?.role === "joiner") {
18032
16359
  this.dht?.expect();
18033
16360
  this.dialEarly();
@@ -18149,6 +16476,7 @@ var GhostLink = class {
18149
16476
  disconnect() {
18150
16477
  this.closeRetired();
18151
16478
  this.dropCrossed();
16479
+ this.dropCrossedProbe();
18152
16480
  this.dropBlockedDial();
18153
16481
  this.switcher.stop();
18154
16482
  this.cancelCandidate();
@@ -18259,6 +16587,7 @@ var GhostLink = class {
18259
16587
  }
18260
16588
  if (held && heldOn && reidentified.has(heldOn) && this.restartedRedial(held, heldOn)) return;
18261
16589
  if (this.redescribedDial()) return;
16590
+ if (!this.dialing) this.raceStandingOffer();
18262
16591
  if (this.channel || this.dialing) return;
18263
16592
  if (news || !again) {
18264
16593
  this.autoConnectFailures = 0;
@@ -18836,6 +17165,10 @@ var GhostLink = class {
18836
17165
  ...resume && { resume },
18837
17166
  ...woke && { woke }
18838
17167
  } : void 0;
17168
+ this.offerMade = {
17169
+ epoch,
17170
+ at: offeredAt
17171
+ };
18839
17172
  const gate = this.dialGate;
18840
17173
  this.dialGate = void 0;
18841
17174
  const replaced = new Promise((resolve) => {
@@ -18847,10 +17180,12 @@ var GhostLink = class {
18847
17180
  traceLink(this.myPubKeyZ32, "dial-held", {});
18848
17181
  this.afterRtc = void 0;
18849
17182
  this.offered = void 0;
17183
+ this.offerMade = void 0;
18850
17184
  return;
18851
17185
  }
18852
17186
  if (this.dataLink.state !== "idle" || epoch !== this.connectionEpoch) {
18853
17187
  this.scheduleRace(epoch, offeredAt);
17188
+ this.raceStandingOffer();
18854
17189
  return;
18855
17190
  }
18856
17191
  this.rtcUnstarted();
@@ -19016,6 +17351,31 @@ var GhostLink = class {
19016
17351
  }, Math.max(0, Math.min(...at) - now));
19017
17352
  }
19018
17353
  /**
17354
+ * This side's offer went out before the contact's record was read (a joiner's early one, `dialEarly`): nothing was
17355
+ * known of what the contact runs, so it was made with no fallback and nothing raced it. Once the record says, and both
17356
+ * sides allow it, the offer races what both run as one made then would: answered and not connected `RACE_ANSWERED_MS`
17357
+ * later, the relayed ones too. Behind a VPN that drops UDP, a first pairing went live over the relayed Iroh only once
17358
+ * the attempt timed out and the next one raced, 32 to 66 s in (zero's VPN lab, 2026-10-13).
17359
+ */
17360
+ raceStandingOffer() {
17361
+ const made = this.offerMade, epoch = this.connectionEpoch;
17362
+ if (!made || made.epoch !== epoch || this.offered?.epoch === epoch || !this.offerOut || this.channel || this.stopped) return;
17363
+ if (!this.fallback || !this.peerFallback || !this.peerTransports) return;
17364
+ const relayed = relayedTransports(this.localDescriptors(), this.peerDescriptors);
17365
+ const rest = rankTransports(this.transportOffer(), this.peerTransports, relayed).filter((t) => t !== "webrtc/1");
17366
+ if (!rest.length) return;
17367
+ this.offered = {
17368
+ epoch,
17369
+ at: made.at
17370
+ };
17371
+ this.afterRtc = {
17372
+ epoch,
17373
+ rest
17374
+ };
17375
+ traceLink(this.myPubKeyZ32, "race-standing", { transports: rest });
17376
+ this.scheduleRace(epoch, made.at);
17377
+ }
17378
+ /**
19019
17379
  * Back after an absence, this side answers an offer the contact made while it was away (it dials whatever its key):
19020
17380
  * 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
19021
17381
  * end before it does. The direct transports both run are knocked on meanwhile, and the relayed ones where both apps
@@ -19123,6 +17483,18 @@ var GhostLink = class {
19123
17483
  if (!this.options.params.profile || this.options.packetTransports) return true;
19124
17484
  return this.transportOffer().some((t) => t === "webrtc/1" ? !this.peerTransports || this.peerTransports.includes(t) : this.canDial(t) && !!this.peerTransports?.includes(t));
19125
17485
  }
17486
+ /**
17487
+ * Neither side can dial the other until something changes, past a first pairing: what the contact runs is known, and
17488
+ * no transport both run has WebRTC on both sides or an endpoint up on both (this side's, and the one its record
17489
+ * describes). A chat that gave its listener up to another (`yieldEndpoint`), or whose contact did, is so. It dials
17490
+ * again when that changes (a listener taken back, `registerEndpoint`; the record describing one, `learnPeerTransports`),
17491
+ * and reads at its own pace meanwhile: looks every 20 s for a dial with nothing to try, and the contact's fast looks
17492
+ * for an offer that could not come, read its key 2.5x to 4x as often as the background pace, for good (bug hunt,
17493
+ * 2026-10-12). A first pairing keeps both: the records are what it is reading for.
17494
+ */
17495
+ nothingToDialEitherWay() {
17496
+ return !(!!this.tracker && !this.tracker.done && this.tracker.progress.stage !== "on-dht") && !!this.peerTransports && !this.anythingToDial();
17497
+ }
19126
17498
  clearRace() {
19127
17499
  if (this.raceTimer) clearTimeout(this.raceTimer);
19128
17500
  this.raceTimer = null;
@@ -19148,6 +17520,7 @@ var GhostLink = class {
19148
17520
  const left = next.rest.filter((t) => t !== "webrtc/1" && !due.includes(t));
19149
17521
  if (!due.length) {
19150
17522
  this.scheduleRace(epoch, offeredAt);
17523
+ this.raceStandingOffer();
19151
17524
  return;
19152
17525
  }
19153
17526
  this.afterRtc = left.length ? {
@@ -19172,6 +17545,7 @@ var GhostLink = class {
19172
17545
  if (live || epoch !== this.connectionEpoch || this.channel || this.stopped) return;
19173
17546
  if (this.dataLink.state !== "idle") {
19174
17547
  this.scheduleRace(epoch, offeredAt);
17548
+ this.raceStandingOffer();
19175
17549
  return;
19176
17550
  }
19177
17551
  const rest = this.afterRtc;
@@ -19420,7 +17794,7 @@ var GhostLink = class {
19420
17794
  return;
19421
17795
  }
19422
17796
  } else if (role !== "joiner" && this.myPubKeyZ32 > this.options.params.peerPubKeyZ32 && !this.resuming && !this.dialsPastRtc()) {
19423
- if (Date.now() - presenceSeenAt(presence) < 3e4 && presence.lastPacketAt !== this.offerAwaitedFor) {
17797
+ if (Date.now() - presenceSeenAt(presence) < 3e4 && presence.lastPacketAt !== this.offerAwaitedFor && !this.nothingToDialEitherWay()) {
19424
17798
  this.offerAwaitedFor = presence.lastPacketAt;
19425
17799
  this.session.expectPeer();
19426
17800
  }
@@ -19498,6 +17872,7 @@ var GhostLink = class {
19498
17872
  this.retryLook = null;
19499
17873
  const from = this.lastAutoConnectAt;
19500
17874
  if (!from || this.stopped || this.leaving || this.streamBlocked || this.keyStopped || !this.options.autoConnect) return;
17875
+ if (this.nothingToDialEitherWay()) return;
19501
17876
  this.retryLook = setTimeout(() => {
19502
17877
  this.retryLook = null;
19503
17878
  if (this.lastAutoConnectAt !== from || this.stopped || this.leaving || this.yielding || this.streamBlocked || this.keyStopped || this.channel || this.dialing || this.dataLink.state !== "idle") return;
@@ -19757,6 +18132,7 @@ var GhostLink = class {
19757
18132
  if (this.options.callsSupport) offered.push(CALLS_CAPABILITY);
19758
18133
  if (this.options.servicesSupport) offered.push(SERVICES_CAPABILITY);
19759
18134
  if (this.options.largeFilesSupport) offered.push(FILES_CAPABILITY);
18135
+ if (this.options.largeFilesSupport && this.options.binaryFilesSupport && this.channel?.binary) offered.push(FILES_BINARY_CAPABILITY);
19760
18136
  if (this.options.typingSupport) offered.push(TYPING_CAPABILITY);
19761
18137
  if (this.options.reactionsSupport) offered.push(REACTIONS_CAPABILITY);
19762
18138
  if (this.options.editSupport) offered.push(EDIT_CAPABILITY);
@@ -19793,6 +18169,10 @@ var GhostLink = class {
19793
18169
  get supportsLargeFiles() {
19794
18170
  return this.isDataLinkOpen && this.sessionCapabilities.agreed("files/3");
19795
18171
  }
18172
+ /** Both sides offer `files/4` beside `files/3` on the open session: a file's bytes go as binary messages. */
18173
+ get supportsBinaryFiles() {
18174
+ return this.supportsLargeFiles && this.sessionCapabilities.agreed("files/4");
18175
+ }
19796
18176
  /** Both sides offer `typing/1` on the open session: typing can be said and shown. */
19797
18177
  get supportsTyping() {
19798
18178
  return this.isDataLinkOpen && this.sessionCapabilities.agreed("typing/1");
@@ -19956,13 +18336,18 @@ var GhostLink = class {
19956
18336
  }
19957
18337
  /**
19958
18338
  * One data frame of an app open on both sides, on the live session only. An error when it did not go: nothing is
19959
- * kept to send later.
18339
+ * kept to send later. While the channel already holds a quarter of what it may queue (its send budget, or the high
18340
+ * water mark of a WebRTC channel), the frame is refused with `too-fast`, so the chat's messages and pings never wait
18341
+ * behind an app's data on a link slower than it sends (WISP 1200 § In a chat). While a file goes, that quarter sits
18342
+ * above what files may hold (`filesWritable`): files keep the channel at their own line for the whole transfer.
19960
18343
  */
19961
18344
  sendAppData(app, data) {
19962
18345
  if (!this.apps.open.has(app)) return "not-open";
19963
18346
  if (appDataBytes(data) > 32768) return "too-large";
19964
18347
  if (!this.channel || !this.isDataLinkOpen) return "offline";
19965
18348
  if (!this.supportsApps) return "peer-closed";
18349
+ const files = this.filesUse?.channel === this.channel && (this.filesUse.waiting > 0 || Date.now() - this.filesUse.at < FILES_IN_USE_MS) ? filesCap(this.channel) : 0;
18350
+ if (this.channel.bufferedAmount >= files + (this.channel.sendBudget ?? LIMITS.sendHighWaterMark) / 4) return "too-fast";
19966
18351
  const refused = this.apps.canSend(app, data);
19967
18352
  if (refused) return refused;
19968
18353
  try {
@@ -20013,19 +18398,57 @@ var GhostLink = class {
20013
18398
  }
20014
18399
  }
20015
18400
  /**
18401
+ * A file's bytes to the contact, on the open session: one binary message while both sides agreed `files/4` on it,
18402
+ * the `pf-data` frame with them as base64url text otherwise (WISP 501 § files/4). The form is chosen for each chunk,
18403
+ * so a transfer follows the session it is on. False when files/3 cannot flow now, or the channel has no room.
18404
+ */
18405
+ sendFileData(id, offset, data) {
18406
+ if (!this.supportsLargeFiles || !this.channel) return false;
18407
+ try {
18408
+ this.channel.send(this.supportsBinaryFiles ? encodeFileData(id, offset, data) : JSON.stringify({
18409
+ t: "pf-data",
18410
+ id,
18411
+ offset,
18412
+ data: toBase64Url(data)
18413
+ }));
18414
+ this.filesUsing(this.channel).at = Date.now();
18415
+ return true;
18416
+ } catch {
18417
+ return false;
18418
+ }
18419
+ }
18420
+ /** Files on a channel: when they last wrote on it, and how many wait for room. An app's data gets room above theirs. */
18421
+ filesUse = null;
18422
+ filesUsing(channel) {
18423
+ if (this.filesUse?.channel !== channel) this.filesUse = {
18424
+ channel,
18425
+ at: 0,
18426
+ waiting: 0
18427
+ };
18428
+ return this.filesUse;
18429
+ }
18430
+ /**
20016
18431
  * 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
20017
18432
  * iroh ones throw past 120 KiB) gives files a quarter of it, so a ping, a message or any other frame still fits
20018
- * while a file goes; others keep under the high water mark, as files/2 does.
18433
+ * while a file goes; others keep under the high water mark, as files/2 does. An app's data gets a quarter above that
18434
+ * line meanwhile (`sendAppData`).
20019
18435
  */
20020
18436
  filesWritable() {
20021
18437
  const channel = this.channel;
20022
18438
  if (!channel) return;
20023
- const cap = channel.sendBudget ? channel.sendBudget / 4 : LIMITS.sendHighWaterMark;
18439
+ const cap = filesCap(channel);
20024
18440
  if (channel.bufferedAmount < cap) return;
18441
+ const use = this.filesUsing(channel);
18442
+ use.waiting++;
20025
18443
  return (async () => {
20026
- while (this.channel === channel && channel.bufferedAmount >= cap) {
20027
- await channel.drained().catch(() => {});
20028
- if (this.channel === channel && channel.bufferedAmount >= cap) await new Promise((resolve) => setTimeout(resolve, 20));
18444
+ try {
18445
+ while (this.channel === channel && channel.bufferedAmount >= cap) {
18446
+ await channel.drained().catch(() => {});
18447
+ if (this.channel === channel && channel.bufferedAmount >= cap) await new Promise((resolve) => setTimeout(resolve, 20));
18448
+ }
18449
+ } finally {
18450
+ use.waiting--;
18451
+ use.at = Date.now();
20029
18452
  }
20030
18453
  })();
20031
18454
  }
@@ -20247,8 +18670,8 @@ var GhostLink = class {
20247
18670
  * Both sides dialled at once (two apps back after a restart, each knocking): the lower key's dial carries the chat, on
20248
18671
  * both sides; the higher key's side takes it over as a replacement (`attachReplacement`). Here, on the lower key's
20249
18672
  * side, the contact's connection waits: dropped once this side's own dial connects, taken if that dial fails, or has
20250
- * not connected within `CROSSED_WAIT_MS` (the contact may be dialling an app that is not up yet, for as long as its
20251
- * dial takes to give up).
18673
+ * not connected within `CROSSED_WAIT_MS` (it stalled, or goes to an endpoint the contact no longer runs, for as long
18674
+ * as it takes to give up).
20252
18675
  */
20253
18676
  crossed = null;
20254
18677
  parkCrossed(channel, binding) {
@@ -20265,7 +18688,7 @@ var GhostLink = class {
20265
18688
  this.dialing = false;
20266
18689
  this.attempt = null;
20267
18690
  this.attach(channel, binding);
20268
- }, CROSSED_WAIT_MS);
18691
+ }, 500);
20269
18692
  channel.onClose = () => {
20270
18693
  if (this.crossed?.channel === channel) {
20271
18694
  clearTimeout(timer);
@@ -20323,13 +18746,20 @@ var GhostLink = class {
20323
18746
  }
20324
18747
  const held = this.channel;
20325
18748
  if (held && this.activeBinding && !this.dialedIn.has(held) && Date.now() - this.channelSince < 1e4 && this.myPubKeyZ32 < this.options.params.peerPubKeyZ32) {
20326
- traceLink(this.myPubKeyZ32, "dial-crossed", {
20327
- transport: binding.transport,
20328
- held: true
20329
- });
20330
- channel.close();
18749
+ if (this.paired?.state.status !== "ready") {
18750
+ traceLink(this.myPubKeyZ32, "dial-crossed", {
18751
+ transport: binding.transport,
18752
+ held: true
18753
+ });
18754
+ channel.close();
18755
+ return;
18756
+ }
18757
+ this.probeCrossed(held, channel, binding);
20331
18758
  return;
20332
18759
  }
18760
+ this.replaceWith(channel, binding);
18761
+ }
18762
+ replaceWith(channel, binding) {
20333
18763
  traceLink(this.myPubKeyZ32, "replacing", {
20334
18764
  transport: binding.transport,
20335
18765
  held: this.paired?.state.transport
@@ -20340,6 +18770,62 @@ var GhostLink = class {
20340
18770
  });
20341
18771
  }
20342
18772
  /**
18773
+ * The contact dialled in while this side's own fresh dial holds the chat (`CROSSED_FRESH_MS`): the dials crossed, or
18774
+ * the contact's app ended since that dial and is back. The held session is pinged; heard from within
18775
+ * `CROSSED_PROBE_MS`, the contact holds this side's dial too and its own closes. Not heard from, the contact's dial
18776
+ * takes over.
18777
+ */
18778
+ crossedProbe = null;
18779
+ /** Frames heard on the open session, counted from its start: whether the contact answered `probeCrossed`. */
18780
+ framesHeard = 0;
18781
+ probeCrossed(held, channel, binding) {
18782
+ this.dropCrossedProbe();
18783
+ traceLink(this.myPubKeyZ32, "dial-crossed", {
18784
+ transport: binding.transport,
18785
+ held: true,
18786
+ probe: true
18787
+ });
18788
+ const heard = this.framesHeard;
18789
+ try {
18790
+ this.sendPing(held, false);
18791
+ } catch {}
18792
+ const timer = setTimeout(() => {
18793
+ if (this.crossedProbe?.channel !== channel) return;
18794
+ this.crossedProbe = null;
18795
+ if (this.stopped || this.leaving || this.candidate) {
18796
+ channel.close();
18797
+ return;
18798
+ }
18799
+ if (!this.channel) {
18800
+ this.attach(channel, binding);
18801
+ return;
18802
+ }
18803
+ if (this.channel === held && this.framesHeard !== heard) {
18804
+ channel.close();
18805
+ return;
18806
+ }
18807
+ traceLink(this.myPubKeyZ32, "crossed-held-silent", { transport: binding.transport });
18808
+ this.replaceWith(channel, binding);
18809
+ }, CROSSED_PROBE_MS);
18810
+ channel.onClose = () => {
18811
+ if (this.crossedProbe?.channel === channel) {
18812
+ clearTimeout(timer);
18813
+ this.crossedProbe = null;
18814
+ }
18815
+ };
18816
+ this.crossedProbe = {
18817
+ channel,
18818
+ timer
18819
+ };
18820
+ }
18821
+ dropCrossedProbe() {
18822
+ const probe = this.crossedProbe;
18823
+ if (!probe) return;
18824
+ this.crossedProbe = null;
18825
+ clearTimeout(probe.timer);
18826
+ probe.channel.close();
18827
+ }
18828
+ /**
20343
18829
  * Connections that came in on this side's native endpoints. Their address was in the capability record, which before
20344
18830
  * the pin anyone holding a copy of the invite could read: a key other than the pinned one on such a connection proves
20345
18831
  * no more than on the DHT, so it closes that connection and nothing else.
@@ -20347,6 +18833,8 @@ var GhostLink = class {
20347
18833
  dialedIn = /* @__PURE__ */ new WeakSet();
20348
18834
  /** Connections whose session started with no key pinned nor named by the invite. */
20349
18835
  openedUnpinned = /* @__PURE__ */ new WeakSet();
18836
+ /** The WebRTC offer under way was made or answered with no key pinned nor named by the invite (its channel opens later). */
18837
+ rtcStartedUnpinned = false;
20350
18838
  /**
20351
18839
  * `migration`: the channel is a candidate that takes over from the session open now once it authenticated. With a
20352
18840
  * `plan`, a live switch both sides agreed; without one, a replacement (`attachReplacement`): the contact dialled
@@ -20532,7 +19020,18 @@ var GhostLink = class {
20532
19020
  if (this.channel !== channel) return;
20533
19021
  this.unansweredPings = 0;
20534
19022
  this.heardFromPeer();
20535
- if (typeof data !== "string" || data.length > 61440) return;
19023
+ this.framesHeard++;
19024
+ if (typeof data !== "string") {
19025
+ const chunk = this.supportsBinaryFiles ? decodeFileData(data) : null;
19026
+ if (!chunk) return;
19027
+ this.activityAt = Date.now();
19028
+ await this.options.events?.onFilesFrame?.({
19029
+ t: "pf-data",
19030
+ ...chunk
19031
+ });
19032
+ return;
19033
+ }
19034
+ if (data.length > 61440) return;
20536
19035
  let frame;
20537
19036
  try {
20538
19037
  frame = JSON.parse(data);
@@ -25251,6 +23750,12 @@ var byeSigned = (f) => utf8Encode(JSON.stringify([
25251
23750
  f.e,
25252
23751
  f.ts
25253
23752
  ]));
23753
+ var leftSigned = (commit) => utf8Encode(JSON.stringify([
23754
+ "ghostly-group/1 left",
23755
+ commit.g,
23756
+ commit.e,
23757
+ commitHash(commit)
23758
+ ]));
25254
23759
  var editSigned = (f) => utf8Encode(JSON.stringify([
25255
23760
  "ghostly-group/1 edit",
25256
23761
  f.g,
@@ -25283,6 +23788,7 @@ function readFirstPlace(o, epoch) {
25283
23788
  /** One of my own frames, among mine: `e:n`. */
25284
23789
  var ownKey = (f) => `${f.e}:${f.n}`;
25285
23790
  /** What a frame weighs in a log, roughly: its boxes plus the fixed fields around them. */
23791
+ var frameKey$1 = (f) => `${f.s}:${f.e}:${f.n}`;
25286
23792
  /** What a kept frame counts for against `GROUP_LIMITS.relayBytes`. */
25287
23793
  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;
25288
23794
  /** Only the fields an edit frame has, as `clean` does for a message. */
@@ -25403,8 +23909,15 @@ var GroupSession = class {
25403
23909
  pendingMeta;
25404
23910
  /** While `handle` runs: the frames it took that a hub passes on (`handle`'s result). */
25405
23911
  passOn = null;
23912
+ /** The frames kept to hand on that the app holds with its messages, oldest first; undefined when the state holds them all. */
23913
+ onRows;
25406
23914
  /** Set while a save of what received messages changed is due (`persistSoon`). */
25407
23915
  saveTimer;
23916
+ /**
23917
+ * The frames of members who left that each member was handed (`frameKey`), so an app that does not take them (from
23918
+ * before revision 2026-10-13, and never marks them seen) is not handed them again at every sync. In memory only.
23919
+ */
23920
+ leaversHanded = /* @__PURE__ */ new Map();
25408
23921
  /** Syncs answered per member, a few a minute (`GROUP_LIMITS.syncAnswers`). In memory only. */
25409
23922
  syncsAnswered = /* @__PURE__ */ new Map();
25410
23923
  /** Catch-up answers still going out, a slice at a time (`handOut`). */
@@ -25417,10 +23930,21 @@ var GroupSession = class {
25417
23930
  provisional;
25418
23931
  /** When my chain last moved (a commit made or applied), by `hooks.clock`: frames are said again once it has settled. */
25419
23932
  chainMovedAt;
25420
- constructor(state, hooks) {
23933
+ /**
23934
+ * `onRows`: the frames the app holds with the messages it stored (`GroupIncomingMessage.frame`), oldest first. Given
23935
+ * (empty at first), the app is where frames are kept: the saved state carries none of them, and a message received
23936
+ * no longer writes them all again. Absent: they are kept in the state, as before.
23937
+ */
23938
+ constructor(state, hooks, onRows) {
25421
23939
  this.hooks = hooks;
25422
23940
  this.state = state;
25423
23941
  this.identity = identityFromSeedB64(state.seedB64);
23942
+ if (onRows) {
23943
+ const held = new Set(onRows.map(frameKey$1));
23944
+ if (state.relay?.length) state.relay = state.relay.filter((f) => !held.has(frameKey$1(f)));
23945
+ this.onRows = state.status === "active" ? onRows.filter((f) => this.handsOn(f.s, f.e)) : [];
23946
+ this.trimRelay();
23947
+ }
25424
23948
  this.answers = new CatchUpAnswers({
25425
23949
  send: (to, frame) => hooks.send(to, frame),
25426
23950
  ...hooks.handled && { handled: (to) => hooks.handled(to) },
@@ -25731,11 +24255,43 @@ var GroupSession = class {
25731
24255
  }];
25732
24256
  });
25733
24257
  }
25734
- remove(memberKey, now = Date.now()) {
24258
+ /**
24259
+ * A welcome for a member already in the roster that never got the first one (its chat closed while it was let in):
24260
+ * nothing is committed. It carries the secrets of the epochs it was in, from its admission on, sealed to its key.
24261
+ */
24262
+ rewelcome(memberKey) {
24263
+ return this.serialize(async () => {
24264
+ this.requireAdmin();
24265
+ if (!rosterHas(this.roster, memberKey) || memberKey === this.myKey) throw new Error("Not a member");
24266
+ const chain = this.state.chain;
24267
+ let admitted = chain.length - 1;
24268
+ while (admitted >= 0 && !(chain[admitted].k === "add" && chain[admitted].s === memberKey)) admitted--;
24269
+ if (admitted < 0) throw new Error("Not a member");
24270
+ const secrets = chain.slice(admitted).filter((c) => this.state.secrets[c.e] && rosterHas(c.m, memberKey)).map((c) => ({
24271
+ e: c.e,
24272
+ s: sealSecret(memberKey, this.secret(c.e), secretAad$1(this.id, c.e, memberKey))
24273
+ }));
24274
+ const pieces = chainPieces(chain);
24275
+ const tail = pieces.pop();
24276
+ return [...pieces.map((commits) => ({
24277
+ t: "group-chain",
24278
+ g: this.id,
24279
+ commits
24280
+ })), {
24281
+ t: "group-welcome",
24282
+ g: this.id,
24283
+ name: this.name,
24284
+ commits: tail,
24285
+ secrets
24286
+ }];
24287
+ });
24288
+ }
24289
+ /** `left`: the member asked to leave (on the admin's chat), and the commit says so (`GroupCommitFrame.left`). */
24290
+ remove(memberKey, now = Date.now(), { left = false } = {}) {
25735
24291
  return this.serialize(async () => {
25736
24292
  this.requireAdmin();
25737
24293
  if (!rosterHas(this.roster, memberKey) || memberKey === this.myKey) throw new Error("Not a member");
25738
- await this.commit("remove", memberKey, now);
24294
+ await this.commit("remove", memberKey, now, left);
25739
24295
  });
25740
24296
  }
25741
24297
  transferAdmin(memberKey, now = Date.now()) {
@@ -25752,7 +24308,7 @@ var GroupSession = class {
25752
24308
  await this.commit("rotate", void 0, now);
25753
24309
  });
25754
24310
  }
25755
- async commit(kind, subject, now) {
24311
+ async commit(kind, subject, now, left = false) {
25756
24312
  if (this.hooks.adminWork?.() === false) throw new Error(GROUP_ADMIN_OFF_ERROR);
25757
24313
  if (this.hooks.adminTurn && !await this.hooks.adminTurn()) throw engineError("groupTurnUnconfirmed");
25758
24314
  if (this.state.chain.length >= 1024) throw new Error("This group has reached its membership history limit. Create a new group.");
@@ -25776,6 +24332,8 @@ var GroupSession = class {
25776
24332
  ...draft,
25777
24333
  c: confirmationTag(epochKeys(secret, this.id, epoch).confirm, commitUntaggedHash(draft))
25778
24334
  }, this.identity.seed);
24335
+ const mark = left && kind === "remove" ? toBase64Url(sign(leftSigned(commit), this.identity.seed)) : void 0;
24336
+ if (mark) (this.state.leftMarks ??= {})[epoch] = mark;
25779
24337
  this.state.chain.push(commit);
25780
24338
  this.chainMovedAt = this.clock();
25781
24339
  this.state.secrets[epoch] = toBase64Url(secret);
@@ -25791,15 +24349,20 @@ var GroupSession = class {
25791
24349
  t: "group-commit",
25792
24350
  g: this.id,
25793
24351
  commit,
25794
- ...member ? { secret: sealSecret(key, secret, secretAad$1(this.id, epoch, key)) } : {}
24352
+ ...member ? { secret: sealSecret(key, secret, secretAad$1(this.id, epoch, key)) } : {},
24353
+ ...mark ? { left: mark } : {}
25795
24354
  });
25796
24355
  }
25797
24356
  this.hooks.changed();
25798
24357
  return { secret };
25799
24358
  }
25800
- /** Someone out of the roster is not handed on any more, whatever they sent while in it (WISP 902 § Catch-up). */
24359
+ /**
24360
+ * Someone removed is not handed on any more, whatever they sent while in the roster; one who left still is, for the
24361
+ * epochs before its leave (WISP 902 § Catch-up).
24362
+ */
25801
24363
  pruneRelay() {
25802
- if (this.state.relay?.length) this.state.relay = this.state.relay.filter((f) => rosterHas(this.roster, f.s));
24364
+ if (this.state.relay?.length) this.state.relay = this.state.relay.filter((f) => this.handsOn(f.s, f.e));
24365
+ if (this.onRows?.length) this.onRows = this.onRows.filter((f) => this.handsOn(f.s, f.e));
25803
24366
  if (this.state.relayEdits?.length) this.state.relayEdits = this.state.relayEdits.filter((f) => rosterHas(this.roster, f.s));
25804
24367
  }
25805
24368
  /** 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. */
@@ -25815,10 +24378,21 @@ var GroupSession = class {
25815
24378
  }
25816
24379
  /** A frame of another member, kept to hand on; the oldest go once the log is full. */
25817
24380
  keep(frame) {
25818
- const log = this.state.relay ??= [];
25819
- log.push(frame);
25820
- let bytes = log.reduce((sum, f) => sum + frameBytes(f), 0);
25821
- while (log.length > GROUP_LIMITS.relay || bytes > GROUP_LIMITS.relayBytes) bytes -= frameBytes(log.shift());
24381
+ if (this.onRows) {
24382
+ const key = frameKey$1(frame);
24383
+ if (this.onRows.some((f) => frameKey$1(f) === key)) return;
24384
+ this.onRows.push(frame);
24385
+ } else (this.state.relay ??= []).push(frame);
24386
+ this.trimRelay();
24387
+ }
24388
+ /** The frames kept to hand on, oldest first: the state's, then the ones the app holds with its messages. */
24389
+ kept() {
24390
+ return this.onRows ? [...this.state.relay ?? [], ...this.onRows] : this.state.relay ?? [];
24391
+ }
24392
+ trimRelay() {
24393
+ const old = this.state.relay ?? [], rows = this.onRows ?? [];
24394
+ let bytes = old.reduce((sum, f) => sum + frameBytes(f), 0) + rows.reduce((sum, f) => sum + frameBytes(f), 0);
24395
+ while (old.length + rows.length > GROUP_LIMITS.relay || bytes > GROUP_LIMITS.relayBytes) bytes -= frameBytes((old.length ? old : rows).shift());
25822
24396
  }
25823
24397
  pruneSecrets() {
25824
24398
  const epochs = Object.keys(this.state.secrets).map(Number).sort((a, b) => a - b);
@@ -25854,6 +24428,7 @@ var GroupSession = class {
25854
24428
  this.state.secrets = {};
25855
24429
  this.state.sent = [];
25856
24430
  this.state.relay = [];
24431
+ if (this.onRows) this.onRows = [];
25857
24432
  this.state.relayEdits = [];
25858
24433
  this.state.alone = [];
25859
24434
  this.waiting = [];
@@ -26191,7 +24766,7 @@ var GroupSession = class {
26191
24766
  case "group-bye":
26192
24767
  await this.receiveBye(from, raw);
26193
24768
  break;
26194
- case "group-leave": if (this.isAdmin && this.hooks.adminWork?.() !== false && rosterHas(this.roster, from) && from !== this.myKey) await this.commitUnlessTurnUnconfirmed("remove", from);
24769
+ case "group-leave": if (this.isAdmin && this.hooks.adminWork?.() !== false && rosterHas(this.roster, from) && from !== this.myKey) await this.commitUnlessTurnUnconfirmed("remove", from, true);
26195
24770
  }
26196
24771
  return this.state.status === "active" ? this.passOn : this.state.status === "removed" ? this.passOn.filter((f) => f.t === "group-commit") : [];
26197
24772
  } finally {
@@ -26306,7 +24881,7 @@ var GroupSession = class {
26306
24881
  return;
26307
24882
  }
26308
24883
  if (bye.e < this.admittedAt(bye.k)) return;
26309
- if (this.isAdmin && this.hooks.adminWork?.() !== false && await this.commitUnlessTurnUnconfirmed("remove", bye.k)) return;
24884
+ if (this.isAdmin && this.hooks.adminWork?.() !== false && await this.commitUnlessTurnUnconfirmed("remove", bye.k, true)) return;
26310
24885
  if (from !== bye.k && this.byesPassed.get(bye.k)?.sig === bye.sig) return;
26311
24886
  this.byesPassed.set(bye.k, bye);
26312
24887
  this.took(bye);
@@ -26331,9 +24906,9 @@ var GroupSession = class {
26331
24906
  * An automatic commit (a member's leave): made, or not made when no fresh turn read says this device is the active
26332
24907
  * one (`adminTurn`). Then false, and the caller goes on as a member would. Any other refusal is thrown as before.
26333
24908
  */
26334
- async commitUnlessTurnUnconfirmed(kind, subject) {
24909
+ async commitUnlessTurnUnconfirmed(kind, subject, left = false) {
26335
24910
  try {
26336
- await this.commit(kind, subject, Date.now());
24911
+ await this.commit(kind, subject, Date.now(), left);
26337
24912
  return true;
26338
24913
  } catch (error) {
26339
24914
  if (error instanceof EngineError && error.engineCode === "groupTurnUnconfirmed") return false;
@@ -26342,7 +24917,8 @@ var GroupSession = class {
26342
24917
  }
26343
24918
  /**
26344
24919
  * A message, from its author's edge or handed on by another member. Handed on, it is taken only while both are in
26345
- * the roster (someone removed is neither heard from nor handed on, #300), and its boxes only with the author's `xs`.
24920
+ * the roster (someone removed is neither heard from nor handed on, #300), or from a member in it when its author left
24921
+ * after it (`handsOn`), and its boxes only with the author's `xs`.
26346
24922
  */
26347
24923
  async receiveMessage(from, raw) {
26348
24924
  if (!isMessageFrame$1(raw)) return;
@@ -26351,7 +24927,7 @@ var GroupSession = class {
26351
24927
  return;
26352
24928
  }
26353
24929
  const relayed = raw.s !== from;
26354
- if (relayed && (!rosterHas(this.roster, from) || !rosterHas(this.roster, raw.s))) return;
24930
+ if (relayed && (!rosterHas(this.roster, from) || !this.handsOn(raw.s, raw.e))) return;
26355
24931
  if (!verify(fromBase64Url(raw.sig), messageSigned$1(raw), publicKeyFromZ32(raw.s))) return;
26356
24932
  if (raw.e > this.epoch) {
26357
24933
  this.park(from, raw);
@@ -26389,10 +24965,11 @@ var GroupSession = class {
26389
24965
  n: first.n
26390
24966
  };
26391
24967
  if (firstFrame && rosterHas(this.state.chain[first.e]?.m ?? [], this.myKey) && this.isDuplicate(firstFrame)) {
24968
+ await this.hooks.kept?.(groupMessageId(raw.s, first.e, first.n), clean(raw));
26392
24969
  this.markSeen(raw);
26393
24970
  this.keep(clean(raw));
26394
24971
  this.persistSoon();
26395
- if (rosterHas(this.roster, raw.s)) this.took(clean(raw));
24972
+ if (this.handsOn(raw.s, raw.e)) this.took(clean(raw));
26396
24973
  return;
26397
24974
  }
26398
24975
  const id = first && !this.provisional.has(own) ? groupMessageId(raw.s, first.e, first.n) : own;
@@ -26429,7 +25006,7 @@ var GroupSession = class {
26429
25006
  if (firstFrame && rosterHas(this.state.chain[firstFrame.e]?.m ?? [], this.myKey)) this.markSeen(firstFrame);
26430
25007
  this.keep(clean(raw));
26431
25008
  this.persistSoon();
26432
- if (rosterHas(this.roster, raw.s)) this.took(clean(raw));
25009
+ if (this.handsOn(raw.s, raw.e)) this.took(clean(raw));
26433
25010
  }
26434
25011
  /**
26435
25012
  * An edit of a member's own message, from its author's edge or handed on by another member: believed only while its
@@ -26463,7 +25040,7 @@ var GroupSession = class {
26463
25040
  const k = validMentions(body.m, body.text, rosterAdmin(commit.m) === raw.s && this.admin === raw.s);
26464
25041
  const sc = body.sc === void 0 ? void 0 : readStatusCard(body.sc);
26465
25042
  if (!validEditNumber(raw.v) && !sc) return;
26466
- const copy = (this.state.relay ?? []).find((f) => f.s === raw.s && f.e === raw.e && f.n === raw.n);
25043
+ const copy = this.kept().find((f) => f.s === raw.s && f.e === raw.e && f.n === raw.n);
26467
25044
  const first = copy && readFirstPlace(copy.o, copy.e);
26468
25045
  await this.hooks.edit?.({
26469
25046
  id: first ? groupMessageId(raw.s, first.e, first.n) : groupMessageId(raw.s, raw.e, raw.n),
@@ -26595,6 +25172,7 @@ var GroupSession = class {
26595
25172
  const result = verifyCommit(raw, this.state.chain[e - 1] ?? null, this.id);
26596
25173
  if ("error" in result || result.commit.by !== known.by) return;
26597
25174
  if (commitHash(result.commit) !== commitHash(known)) await this.fork(`Member ${from.slice(0, 8)} holds a different membership history for epoch ${e}`);
25175
+ else if (this.takeLeftMark(known, frame.left)) await this.persist();
26598
25176
  return;
26599
25177
  }
26600
25178
  if (e > this.epoch + 1) {
@@ -26608,11 +25186,13 @@ var GroupSession = class {
26608
25186
  if (result.error.startsWith("Commit does not follow") && verifyCommitSignature(raw)?.by === this.admin) await this.fork(`Member ${from.slice(0, 8)} holds a different membership history for epoch ${e}`);
26609
25187
  return;
26610
25188
  }
25189
+ const mark = this.takeLeftMark(result.commit, frame.left) ? frame.left : void 0;
26611
25190
  await this.apply(result.commit, frame.secret);
26612
25191
  this.took({
26613
25192
  t: "group-commit",
26614
25193
  g: this.id,
26615
- commit: result.commit
25194
+ commit: result.commit,
25195
+ ...mark ? { left: mark } : {}
26616
25196
  });
26617
25197
  if (this.state.status === "active" && !this.state.secrets[this.epoch]) this.ask(from);
26618
25198
  const next = this.pendingCommits.get(this.epoch + 1);
@@ -26621,6 +25201,27 @@ var GroupSession = class {
26621
25201
  await this.receiveCommit(from, next);
26622
25202
  }
26623
25203
  }
25204
+ /**
25205
+ * Whether a frame of `author` under `epoch` is taken from another member, kept and handed on: while its author is in
25206
+ * the roster, or when it left (its removal marked `left`, and it is not back in) and the frame is from before that.
25207
+ */
25208
+ handsOn(author, epoch) {
25209
+ if (rosterHas(this.roster, author)) return true;
25210
+ const chain = this.state.chain;
25211
+ 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;
25212
+ return false;
25213
+ }
25214
+ /** Keeps the admin's mark on a commit that took out a member who left (`GroupCommitFrame.left`) when it holds; true when new. */
25215
+ takeLeftMark(commit, mark) {
25216
+ if (commit.k !== "remove" || typeof mark !== "string" || mark.length !== 86 || !B64$1.test(mark) || this.state.leftMarks?.[commit.e]) return false;
25217
+ try {
25218
+ if (!verify(fromBase64Url(mark), leftSigned(commit), publicKeyFromZ32(commit.by))) return false;
25219
+ } catch {
25220
+ return false;
25221
+ }
25222
+ (this.state.leftMarks ??= {})[commit.e] = mark;
25223
+ return true;
25224
+ }
26624
25225
  async apply(commit, sealed) {
26625
25226
  this.state.chain.push(commit);
26626
25227
  this.chainMovedAt = this.clock();
@@ -26683,11 +25284,13 @@ var GroupSession = class {
26683
25284
  const answer = [];
26684
25285
  for (let e = frame.e + 1; e <= this.epoch; e++) {
26685
25286
  const commit = this.state.chain[e], secret = this.secret(e);
25287
+ const left = this.state.leftMarks?.[e];
26686
25288
  answer.push({
26687
25289
  t: "group-commit",
26688
25290
  g: this.id,
26689
25291
  commit,
26690
- ...secret && rosterHas(commit.m, from) ? { secret: sealSecret(from, secret, secretAad$1(this.id, e, from)) } : {}
25292
+ ...secret && rosterHas(commit.m, from) ? { secret: sealSecret(from, secret, secretAad$1(this.id, e, from)) } : {},
25293
+ ...left ? { left } : {}
26691
25294
  });
26692
25295
  }
26693
25296
  const theirs = new Set(Array.isArray(frame.secrets) ? frame.secrets.filter((n) => Number.isSafeInteger(n)) : []);
@@ -26702,7 +25305,15 @@ var GroupSession = class {
26702
25305
  });
26703
25306
  const lacks = missingIn(frame, from);
26704
25307
  const asked = new Set(Array.isArray(frame.ask) ? frame.ask.filter((k) => typeof k === "string" && k !== from && k !== this.myKey && rosterHas(this.roster, k)) : []);
26705
- 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)) : [];
25308
+ const handed = this.leaversHanded.get(from) ?? /* @__PURE__ */ new Set();
25309
+ const kept = !asked.size && !this.state.leftMarks ? [] : this.kept().filter((f) => {
25310
+ if (!this.state.chain[f.e] || !rosterHas(this.state.chain[f.e].m, from) || !lacks(f)) return false;
25311
+ if (asked.has(f.s)) return true;
25312
+ if (rosterHas(this.roster, f.s) || handed.has(frameKey$1(f)) || !this.handsOn(f.s, f.e)) return false;
25313
+ handed.add(frameKey$1(f));
25314
+ return true;
25315
+ });
25316
+ if (handed.size) this.leaversHanded.set(from, handed);
26706
25317
  answer.push(...asWritten(this.ownLacked(from, frame), kept));
26707
25318
  for (const author of asked) {
26708
25319
  const edits = (this.state.relayEdits ?? []).filter((f) => f.s === author && this.state.chain[f.e] && rosterHas(this.state.chain[f.e].m, from));
@@ -27009,9 +25620,10 @@ function knockIdentity(link) {
27009
25620
  function knockRecords(link, knocks) {
27010
25621
  const nonce = randomBytes(NONCE_LENGTH);
27011
25622
  const body = utf8Encode(JSON.stringify(knocks.slice(-6).map((k) => [k.key, k.ts])));
25623
+ const box = xchacha20poly1305(knockMaterial(link, "knock key"), nonce, utf8Encode(link.g)).encrypt(body);
27012
25624
  return [{
27013
25625
  label: KNOCK_LABEL,
27014
- value: toBase64Url(concatBytes$1(nonce, xchacha20poly1305(knockMaterial(link, "knock key"), nonce, utf8Encode(link.g)).encrypt(body)))
25626
+ value: toBase64Url(concatBytes$1(nonce, box))
27015
25627
  }];
27016
25628
  }
27017
25629
  /** Knocks that open with the link's key and are well formed; anything else is nobody. */
@@ -27098,6 +25710,8 @@ var COMMUNITY_LIMITS = {
27098
25710
  /** Commits in one welcome or chain piece. */
27099
25711
  chainPiece: 100,
27100
25712
  pendingLeaves: 64,
25713
+ /** Leaves whose mark (`group-left`) is kept, and the epochs one names. */
25714
+ leftMarks: 64,
27101
25715
  /**
27102
25716
  * Syncs of one member answered in `syncWindowMs`: each answer may carry the chain's tail, secrets and the whole
27103
25717
  * store. A member catching up asks every five seconds at most, and every member syncs every thirty; one asking more
@@ -27214,6 +25828,14 @@ var leaveStatement = (g, s) => utf8Encode(JSON.stringify([
27214
25828
  g,
27215
25829
  s
27216
25830
  ]));
25831
+ /** What the member who committed a leave (`h`) had heard of the leaver then: `e:h16` → its highest number there. */
25832
+ var leftStatement = (g, h, hi) => utf8Encode(JSON.stringify([
25833
+ "ghostly-group/2 left",
25834
+ g,
25835
+ h,
25836
+ Object.entries(hi).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
25837
+ ]));
25838
+ var LEFT_EPOCH = /^\d{1,15}:[a-f0-9]{16}$/;
27217
25839
  /** The secret of an epoch that adds, re-roles or re-links: from the previous one and the commit. */
27218
25840
  function derivedSecret(previous, untaggedHash) {
27219
25841
  return hkdf(sha256, previous, utf8Encode(untaggedHash), utf8Encode("ghostly-group/2 derived"), 32);
@@ -27677,6 +26299,60 @@ var CommunitySession = class {
27677
26299
  return -1;
27678
26300
  }
27679
26301
  /**
26302
+ * Whether a frame of someone the chain took out is still heard. A member removed is heard no more, even for an epoch
26303
+ * it was in. What one who left wrote while in the group is taken and handed on, its goodbye included, up to what the
26304
+ * member who committed the leave had heard of it then (its `group-left` mark), never past it: the leaver still holds
26305
+ * those epochs' secrets, and anything it signs later is not heard (WISP 903 § Leaving and removal). No mark here
26306
+ * (one not come yet, or a committer's app before it) and nothing of it is heard.
26307
+ */
26308
+ heardAfterOut(f) {
26309
+ const i = this.outIndex(f.s);
26310
+ if (i < 0) return true;
26311
+ const commit = this.state.chain[i];
26312
+ if (commit.k !== "leave") return false;
26313
+ const high = this.state.left?.[communityCommitHash(commit)]?.hi[seenKey(f.e, f.h)];
26314
+ return high !== void 0 && f.n <= high;
26315
+ }
26316
+ /** The mark of a leave on my branch, as a frame to hand on (see `heardAfterOut`). */
26317
+ leftFrame(key) {
26318
+ const i = this.outIndex(key);
26319
+ if (i < 0 || this.state.chain[i].k !== "leave") return void 0;
26320
+ const h = communityCommitHash(this.state.chain[i]), mark = this.state.left?.[h];
26321
+ return mark && {
26322
+ t: "group-left",
26323
+ v: 2,
26324
+ g: this.id,
26325
+ h,
26326
+ hi: mark.hi,
26327
+ sig: mark.sig
26328
+ };
26329
+ }
26330
+ keepLeft(h, mark) {
26331
+ const left = { ...this.state.left ?? {} };
26332
+ delete left[h];
26333
+ left[h] = mark;
26334
+ const keys = Object.keys(left);
26335
+ for (const old of keys.slice(0, Math.max(0, keys.length - COMMUNITY_LIMITS.leftMarks))) delete left[old];
26336
+ this.state.left = left;
26337
+ }
26338
+ /** A leave's mark, from the member who committed it: kept when it is that member's, for a leave I hold. */
26339
+ receiveLeft(frame) {
26340
+ const { h, hi, sig } = frame;
26341
+ if (typeof h !== "string" || !HASH.test(h) || typeof sig !== "string" || !SIG.test(sig) || !isObject$2(hi)) return false;
26342
+ const entries = Object.entries(hi);
26343
+ if (entries.length > COMMUNITY_LIMITS.leftMarks || entries.some(([k, n]) => !LEFT_EPOCH.test(k) || !Number.isSafeInteger(n) || n < 0)) return false;
26344
+ const commit = this.known.get(h);
26345
+ if (!commit || commit.k !== "leave" || this.state.left?.[h]?.sig === sig) return false;
26346
+ const marks = hi;
26347
+ if (!verify(fromBase64Url(sig), leftStatement(this.id, h, marks), publicKeyFromZ32(commit.by))) return false;
26348
+ this.keepLeft(h, {
26349
+ hi: marks,
26350
+ sig
26351
+ });
26352
+ this.persistSoon();
26353
+ return true;
26354
+ }
26355
+ /**
27680
26356
  * What someone the chain took out is told when it comes back (WISP 903 § Leaving and removal): the commits from where
27681
26357
  * it says it is (`sync`: its tip, or its locator) up to the one that took it out, and nothing after. Later commits
27682
26358
  * are not its business (a `link` commit names the new entry key); no secret, no stored frame, no seed goes with them.
@@ -28276,10 +26952,11 @@ var CommunitySession = class {
28276
26952
  continue;
28277
26953
  }
28278
26954
  try {
28279
- await this.commit("leave", {
26955
+ const { hash } = await this.commit("leave", {
28280
26956
  s: request.s,
28281
26957
  ls: request.ls
28282
26958
  }, now);
26959
+ this.markLeft(hash, request.s);
28283
26960
  done++;
28284
26961
  } catch {}
28285
26962
  this.state.pendingLeaves = this.state.pendingLeaves.filter((r) => r !== request);
@@ -28288,6 +26965,25 @@ var CommunitySession = class {
28288
26965
  return done;
28289
26966
  });
28290
26967
  }
26968
+ /** Signs and says how far I had heard a member whose leave I just committed (`group-left`). */
26969
+ markLeft(h, leaver) {
26970
+ const hi = {};
26971
+ const heard = Object.entries(this.state.seen[leaver] ?? {}).filter(([k]) => LEFT_EPOCH.test(k)).slice(-COMMUNITY_LIMITS.leftMarks);
26972
+ for (const [k, entry] of heard) hi[k] = entry.high;
26973
+ const sig = toBase64Url(sign(leftStatement(this.id, h, hi), this.identity.seed));
26974
+ this.keepLeft(h, {
26975
+ hi,
26976
+ sig
26977
+ });
26978
+ this.hooks.broadcast({
26979
+ t: "group-left",
26980
+ v: 2,
26981
+ g: this.id,
26982
+ h,
26983
+ hi,
26984
+ sig
26985
+ });
26986
+ }
28291
26987
  out(status, reason) {
28292
26988
  this.state.status = status;
28293
26989
  this.state.statusReason = reason;
@@ -28611,6 +27307,7 @@ var CommunitySession = class {
28611
27307
  return false;
28612
27308
  case "group-meta": return this.receiveMeta(from, raw);
28613
27309
  case "group-leave": return this.receiveLeave(frame);
27310
+ case "group-left": return this.receiveLeft(frame);
28614
27311
  case "group-entry": {
28615
27312
  if (frame.to !== this.myKey || !isSealed(frame.s) || frame.x !== this.currentEntryKey()) return false;
28616
27313
  const seed = openSecret(this.identity.seed, this.myKey, frame.s, entryAad(this.id, this.myKey));
@@ -28639,7 +27336,7 @@ var CommunitySession = class {
28639
27336
  return false;
28640
27337
  }
28641
27338
  if (!verify(fromBase64Url(raw.sig), messageSigned(raw), publicKeyFromZ32(raw.s))) return false;
28642
- if (this.wasRemoved(raw.s)) return false;
27339
+ if (!this.heardAfterOut(raw)) return false;
28643
27340
  const found = this.commitByShort(raw.e, raw.h);
28644
27341
  if (!found) {
28645
27342
  this.park(from, raw);
@@ -28879,16 +27576,22 @@ var CommunitySession = class {
28879
27576
  const have = frame.have && typeof frame.have === "object" ? frame.have : {};
28880
27577
  const handed = /* @__PURE__ */ new Set();
28881
27578
  const unheard = new Set(this.state.unheard ?? []);
27579
+ const marked = /* @__PURE__ */ new Set();
28882
27580
  for (const stored of this.state.store) {
28883
27581
  if (stored.s === from) continue;
28884
27582
  const high = have[stored.s]?.[seenKey(stored.e, stored.h)];
28885
27583
  if (Number.isSafeInteger(high) && high >= stored.n) continue;
28886
- if (this.wasRemoved(stored.s)) continue;
27584
+ if (!this.heardAfterOut(stored)) continue;
28887
27585
  const found = this.commitByShort(stored.e, stored.h);
28888
27586
  if (!found) continue;
28889
27587
  const reads = rosterHas(this.rosterAt(found.hash) ?? [], from), mine = stored.s === this.myKey && unheard.has(frameKey(stored));
28890
27588
  if (!reads && !(mine && rosterHas(this.roster, from))) continue;
28891
27589
  if (!this.mainIndex.has(found.hash)) this.handSide(from, found.hash, handed, answer);
27590
+ if (!rosterHas(this.roster, stored.s) && !marked.has(stored.s)) {
27591
+ marked.add(stored.s);
27592
+ const left = this.leftFrame(stored.s);
27593
+ if (left) answer.push(left);
27594
+ }
28892
27595
  answer.push(stored);
28893
27596
  if (mine && reads) unheard.delete(frameKey(stored));
28894
27597
  }
@@ -29550,8 +28253,63 @@ function appDigest(manifestBytes) {
29550
28253
  return toBase64Url(sha256(manifestBytes));
29551
28254
  }
29552
28255
  var u32 = (b, at) => (b[at] << 24 >>> 0) + (b[at + 1] << 16) + (b[at + 2] << 8) + b[at + 3];
29553
- /** Reads and checks a bundle (the order of the checks is part of the format: see above). */
29554
- function readAppBundle(bytes) {
28256
+ /**
28257
+ * Lets the thread go for one turn: what waits (input, drawing, timers, port messages, IndexedDB answers) runs, then the
28258
+ * caller goes on. A message to itself, queued behind the page's other tasks. Never `scheduler.yield()`: in Chromium its
28259
+ * continuation outranks every normal task, so a paced read of 4096 revocations held the page's timers, its apps' broker
28260
+ * ports and IndexedDB for the whole 1.8 s. Never `setTimeout(0)` but as the last resort: from the fifth nested timer on
28261
+ * the browser waits at least 4 ms (HTML's clamp), which made that read take 60% longer than the work, and a hidden tab
28262
+ * runs timers once a second.
28263
+ */
28264
+ function yieldThread() {
28265
+ if (typeof MessageChannel === "function") return new Promise((resolve) => {
28266
+ const channel = new MessageChannel();
28267
+ channel.port1.onmessage = () => {
28268
+ channel.port1.close();
28269
+ resolve();
28270
+ };
28271
+ channel.port2.postMessage(null);
28272
+ });
28273
+ return new Promise((resolve) => setTimeout(resolve, 0));
28274
+ }
28275
+ /**
28276
+ * A pause for a loop of short steps: `await pause()` after each step lets the thread go once `sliceMs` of work has
28277
+ * run since the last time, and answers at once otherwise.
28278
+ */
28279
+ function pacer(sliceMs) {
28280
+ let since = performance.now();
28281
+ return () => {
28282
+ if (performance.now() - since < sliceMs) return void 0;
28283
+ return yieldThread().then(() => {
28284
+ since = performance.now();
28285
+ });
28286
+ };
28287
+ }
28288
+ /** `sha256Paced` hashes up to this much at once: under a millisecond of the thread. */
28289
+ var HASH_AT_ONCE = 65536;
28290
+ /** Without `crypto.subtle`, `sha256Paced` hashes this much (a few ms), then lets the thread go. */
28291
+ var HASH_SLICE = 262144;
28292
+ /** `readAppBundleAsync` hashes files for this long at most before it lets the thread go, however small each file. */
28293
+ var HASH_SLICE_MS = 5;
28294
+ /**
28295
+ * SHA-256 of bytes that may be a whole bundle, for a client whose engine shares its page's thread: `sha256` in
28296
+ * 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
28297
+ * input for 200 ms. With `crypto.subtle` the bytes are hashed off the thread; without it (a page not served over
28298
+ * HTTPS), a slice at a time with a pause between slices. The bytes must not change until it answers.
28299
+ */
28300
+ async function sha256Paced(bytes) {
28301
+ if (bytes.length <= HASH_AT_ONCE) return sha256(bytes);
28302
+ const subtle = globalThis.crypto?.subtle;
28303
+ const away = subtle ? await subtle.digest("SHA-256", bytes).catch(() => null) : null;
28304
+ if (away) return new Uint8Array(away);
28305
+ const hash = sha256.create();
28306
+ for (let at = 0; at < bytes.length; at += HASH_SLICE) {
28307
+ if (at) await yieldThread();
28308
+ hash.update(bytes.subarray(at, at + HASH_SLICE));
28309
+ }
28310
+ return hash.digest();
28311
+ }
28312
+ function readAppBundleHead(bytes) {
29555
28313
  if (bytes.length > APP_BUNDLE_LIMITS.bundleBytes) return {
29556
28314
  ok: false,
29557
28315
  reason: "too-large"
@@ -29610,17 +28368,18 @@ function readAppBundle(bytes) {
29610
28368
  };
29611
28369
  const signed = verifyAppSignature(APP_PREFIXES.app, manifestBytes, statement.signature, manifest.publisher);
29612
28370
  if (!signed.ok) return signed;
29613
- const files = /* @__PURE__ */ new Map();
29614
- for (const f of manifest.files) {
29615
- const content = bytes.subarray(at, at + f.size);
29616
- at += f.size;
29617
- if (toBase64Url(sha256(content)) !== f.sha256) return {
29618
- ok: false,
29619
- reason: "file-hash",
29620
- detail: f.path
29621
- };
29622
- files.set(f.path, content);
29623
- }
28371
+ return {
28372
+ ok: true,
28373
+ head: {
28374
+ manifest,
28375
+ manifestBytes,
28376
+ signature: statement.signature,
28377
+ filesAt: at
28378
+ }
28379
+ };
28380
+ }
28381
+ /** The last check, once every file's hash held: the icon. */
28382
+ function closeAppBundle({ manifest, manifestBytes, signature }, files) {
29624
28383
  const icon = files.get(APP_ICON_PATH);
29625
28384
  if (icon) {
29626
28385
  const size = imageSize(icon);
@@ -29641,11 +28400,54 @@ function readAppBundle(bytes) {
29641
28400
  manifest,
29642
28401
  manifestBytes,
29643
28402
  digest: appDigest(manifestBytes),
29644
- signature: statement.signature,
28403
+ signature,
29645
28404
  files
29646
28405
  }
29647
28406
  };
29648
28407
  }
28408
+ /** Reads and checks a bundle (the order of the checks is part of the format: see above). */
28409
+ function readAppBundle(bytes) {
28410
+ const read = readAppBundleHead(bytes);
28411
+ if (!read.ok) return read;
28412
+ const files = /* @__PURE__ */ new Map();
28413
+ let at = read.head.filesAt;
28414
+ for (const f of read.head.manifest.files) {
28415
+ const content = bytes.subarray(at, at + f.size);
28416
+ at += f.size;
28417
+ if (toBase64Url(sha256(content)) !== f.sha256) return {
28418
+ ok: false,
28419
+ reason: "file-hash",
28420
+ detail: f.path
28421
+ };
28422
+ files.set(f.path, content);
28423
+ }
28424
+ return closeAppBundle(read.head, files);
28425
+ }
28426
+ /**
28427
+ * `readAppBundle` for a client whose engine shares its page's thread: the same checks in the same order, with the same
28428
+ * refusals, and each file hashed by `sha256Paced`, one after the other, with the thread let go between files every
28429
+ * `HASH_SLICE_MS` (64 files of 64 KiB, each hashed at once, held it for 50 ms). The bytes must not change until it
28430
+ * answers.
28431
+ */
28432
+ async function readAppBundleAsync(bytes) {
28433
+ const read = readAppBundleHead(bytes);
28434
+ if (!read.ok) return read;
28435
+ const files = /* @__PURE__ */ new Map();
28436
+ const pause = pacer(HASH_SLICE_MS);
28437
+ let at = read.head.filesAt;
28438
+ for (const f of read.head.manifest.files) {
28439
+ const content = bytes.subarray(at, at + f.size);
28440
+ at += f.size;
28441
+ if (toBase64Url(await sha256Paced(content)) !== f.sha256) return {
28442
+ ok: false,
28443
+ reason: "file-hash",
28444
+ detail: f.path
28445
+ };
28446
+ files.set(f.path, content);
28447
+ await pause();
28448
+ }
28449
+ return closeAppBundle(read.head, files);
28450
+ }
29649
28451
  /** Puts a bundle's parts together, checking nothing: for builders, and for tests that need a broken bundle. */
29650
28452
  function assembleAppBundle(manifestBytes, statementBytes, files) {
29651
28453
  const total = MAGIC.length + 8 + manifestBytes.length + statementBytes.length + files.reduce((s, f) => s + f.length, 0);
@@ -30007,6 +28809,6 @@ function appStoreDecision(held, read) {
30007
28809
  return read.digest === held.digest ? "same" : "equivocation";
30008
28810
  }
30009
28811
  //#endregion
30010
- 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 };
28812
+ export { COMMUNITY_EDIT_FRAME as $, HANDOFF_HELLO as $a, EDIT_RATE_WINDOW_MS as $i, SIGNAL_REPLY_MS as $n, readEnrollGrant as $o, STATUS_CARD_CAPABILITY as $r, parseRelayPayload as $s, didDhtFromPublicKey as $t, CommunitySession as A, RELAY_POLL_INTERVALS as Aa, IdentityCheckUnavailable as Ai, pubkyProofPath as An, readHandoffHello as Ao, typingActivity as Ar, nextTurnPosition as As, verifyAtprotoRecordProof as At, knockRecords as B, callRtcConfig as Ba, PeerProofs as Bi, isFederationId as Bn, signTombstone as Bo, deviceWakeRequest as Br, RelayTransport as Bs, didWebDocumentUrl as Bt, sha256Paced as C, rosterHas as Ca, CapsExchange as Ci, PUBKY_PROOF_MAX_BYTES as Cn, handoffStreamKey as Co, FILE_LIMITS as Cr, TURN_RAISE_READ_TIMEOUT_MS as Cs, atprotoProofRkey as Ct, openGroupTyping as D, AWAITING_PEER_MS as Da, IDENTITY_MAX_EVIDENCE as Di, normalizePubkyKey as Dn, readHandoffCancel as Do, TYPING_REFRESH_MS as Dr, TURN_LAST_SEQUENCE as Ds, parseAtprotoDidDocument as Dt, groupTypingEpoch as E, DirectPathWatch as Ea, dialDescriptors as Ei, newPubkyProofFolder as En, readHandoffAck as Eo, TYPING_KINDS as Er, turnPutSummary as Es, normalizeAtprotoHandle as Et, KNOCK_TTL_MS as F, webLocalFetch as Fa, identityStatement as Fi, decodeBolt11 as Fn, SET_ACK as Fo, pinIsNewer as Fr, turnKeys as Fs, checkDidJws as Ft, groupWakeFrame as G, emptyLinkRecords as Ga, irohDescriptor as Gi, LIVENESS_PING_MS as Gn, ENROLL_PROOF as Go, checkPushEndpoint as Gr, budgetRetryMs as Gs, normalizeDid as Gt, readKnocks as H, extractParamsFromSdp as Ha, validExternalKey as Hi, parseFedimintRequestPayload as Hn, ENROLL_DONE as Ho, parseWakeFrame as Hr, normalizeRelayUrl as Hs, didWebFileNames as Ht, decodeGroupEntryLink as I, CALL_SIGNAL_MAX_AGE_MS as Ia, newIdentityBinding as Ii, ENDPOINT as In, SET_UPDATE as Io, pinNumberHolds as Ir, turnName as Is, checkDidSignature as It, GROUP_LIMITS as J, serviceIdFromName as Ja, DHT_MESSAGE_TTL as Ji, engineText as Jn, enrollDigits as Jo, chatAppId as Jr, PacketTooLargeError as Js, parseDidSignaturePaste as Jt, groupWakes as K, HTTP_SERVICE_PROTO as Ka, irohRelayUrl as Ki, pairedMessageFrame as Kn, enrollCancelFrame as Ko, vapidKeysMatch as Kr, isDiscoveryBudgetError as Ks, parseDid as Kt, encodeGroupEntryLink as L, RTC_CONFIG as La, receivedIdentityStatus as Li, cashuRequestPayload as Ln, readSetUpdate as Lo, readPin as Lr, turnStatedVerifier as Ls, didGhostlyServiceEntry as Lt, communityHasFrame as M, GhostlyHttpError as Ma, boundVerifiedIdentity as Mi, pubkyHomeserverOf as Mn, readHandoffTurnFrame as Mo, PIN_LIMITS as Mr, signTurnPacket as Ms, DID_FILE_MAX_BYTES as Mt, decodeCommunityLink as N, formatLocalTarget as Na, emptyIdentityLedger as Ni, requestLnurlInvoice as Nn, readHandoffVerified as No, mayPin as Nr, signTurnRelease as Ns, DID_JWS_MAX_LENGTH as Nt, sealGroupTyping as O, DHT_POLL_INTERVALS as Oa, IDENTITY_MAX_VALIDITY as Oi, pubkyProofCapability as On, readHandoffDone as Oo, TypingSender as Or, TURN_MAX as Os, shortAtprotoDid as Ot, encodeCommunityLink as P, parseLocalTarget as Pa, identityRevocationValue as Pi, resolveLightningDestination as Pn, sealHandoffPiece as Po, nextPinNumber as Pr, turnInstance as Ps, DidError as Pt, frameBytes as Q, HANDOFF_HAVE as Qa, EDIT_BUFFER_MS as Qi, SIGNAL_HOLD_MS as Qn, enrollProofFrame as Qo, EDIT_CAPABILITY as Qr, openRelayPayload as Qs, didDhtDocument as Qt, entryParams as R, answersOffer as Ra, revokesIdentity as Ri, fedimintRequestPayload as Rn, setAckFrame as Ro, WAKE_CALL_INTERVAL_MS as Rr, turnVerifierMark as Rs, didJwsAlg as Rt, readAppBundleAsync as S, commitHash as Sa, wellKnownUrl as Si, isBitcoinAddress as Sn, handoffRetryAfter as So, ChatFiles as Sr, TURN_PUT_WINDOW_MS as Ss, atprotoProofRecord as St, GroupTypingBoard as T, edgeParams as Ta, capsDescriptors as Ti, isPubkyProofFolder as Tn, newHandoffId as To, transferStalled as Tr, classifyTurnRead as Ts, isAtprotoDid as Tt, GROUP_WAKE_FRAME as U, parseCallSignal as Ua, TRANSPORTS as Ui, GhostLink as Un, ENROLL_GRANT as Uo, relayRequest as Ur, setLinkTraceSink as Us, didWebFileUrl as Ut, mergeKnocks as V, callSignalHeardAt as Va, emptyProofLedger as Vi, parseCashuRequestPayload as Vn, verifySetUpdate as Vo, newWakeToken as Vr, currentRelays as Vs, didWebFile as Vt, GroupWakeLimiter as W, signalHasVideo as Wa, automaticTransport as Wi, INVITE_TAKEN as Wn, ENROLL_HELLO as Wo, wakeRequest as Wr, traceLink as Ws, didWebHost as Wt, GROUP_READ_NOTE as X, HANDOFF_CANCEL as Xa, DHT_FILE_CAPABILITY as Xi, GROUP_SIGNAL_FRAME as Xn, enrollGrantFrame as Xo, APPS_CAPABILITY as Xr, createRelayPayload as Xs, staticDidDocument as Xt, GROUP_REACTED_FRAME as Y, HANDOFF_ACK as Ya, peekDhtMailbox as Yi, CarriedTransport as Yn, enrollDoneFrame as Yo, isAppVersion as Yr, RELAY_PAYLOAD_MAX_BYTES as Ys, signingKeys as Yt, GroupSession as Z, HANDOFF_DONE as Za, dhtFileId as Zi, SIGNAL_FRESH_MS as Zn, enrollHelloFrame as Zo, BUTTONS_CAPABILITY as Zr, newerPacket as Zs, strictBase64Url as Zt, assembleAppBundle as _, readReply as _a, normalizeDomain as _i, walletNetworkOf as _n, handoffHelloFrame as _o, rankHubs as _r, deviceLinkPairing as _s, ATPROTO_DECLARED_SCOPE as _t, planAppUpdate as a, COMMUNITY_REACTION_FRAME as aa, MAX_AVATAR_LENGTH as ac, HoldRefusedError as ai, EVM_TEST_CHAINS as an, HANDOFF_VERIFIED as ao, pickMeshHubs as ar, OwnInviteError as as, groupName as at, pacer as b, MAX_GROUP_CHAIN as ba, recordsName as bi, sparkAddressKind as bn, handoffPakeFrame as bo, readLobby as br, seedSigner as bs, ATPROTO_PROOF_SCOPE as bt, readAppStore as c, ReactionWindow as ca, readManifest as ci, SEPOLIA_TEST_USDT as cn, handoffAttemptFailed as co, beaconRecords as cr, encodeInviteCode as cs, SshSigError as ct, APP_BUNDLE_LIMITS as d, reactionEmoji as da, dnsProofName as di, USDT_PUBLIC_RPC as dn, handoffCancelFrame as do, lobbyKeys as dr, DEVICES_CAPABILITY as ds, sameSshKey as dt, RateWindow as ea, createIdentity as ec, UPGRADE_CAPABILITY as ei, encodeDidDhtDeactivated as en, HANDOFF_OFFER as eo, groupSignalFrame as er, readEnrollHello as es, GROUP_EDIT_FRAME as et, APP_BUNDLE_MAGIC as f, reactionIsNewer as fa, domainTxtRecord as fi, WALLET_NETWORKS as fn, handoffContext as fo, lobbyRecords as fr, ENROLL_CAPABILITY as fs, sshSignCommand as ft, appViewOf as g, pairedReplyAuthor as ga, nip05RootKey as gi, validatePaymentTarget as gn, handoffEphemeral as go, pickHubs as gr, deviceKeyZ32 as gs, decodeAnyBitcoinAddress as gt, appDigest as h, wireReaction as ha, isPublicAddress as hi, formatPaymentAmount as hn, handoffDigest as ho, newerHead as hr, deviceEchoNonce as hs, BitcoinAddressError as ht, checkAppBeforeRun as i, readForwarded as ia, verify as ic, HoldKeys as ii, ETHEREUM_USDT as in, HANDOFF_TIMINGS as io, meshHubs as ir, readDeviceInvite as is, mentionsMember as it, GROUP_READ_NOTE_COMMUNITY as j, presenceSeenAt as ja, IdentityExchange as ji, homeserverWebEndpoint as jn, readHandoffPake as jo, GROUP_PIN_FRAME as jr, readTurnPacket as js, DID_DOCUMENT_MAX_BYTES as jt, COMMUNITY_LIMITS as k, EXPECT_PEER_MS as ka, IDENTITY_REVOCATION_LABEL as ki, pubkyProofFile as kn, readHandoffHave as ko, sanitizeTypingStatus as kr, firstDeviceSetSecret as ks, AtprotoProofError as kt, readAppStoreSteps as l, nextReactionNumber as la, DOMAIN_FILE_MAX_BYTES as li, SEPOLIA_TEST_USDT_FAUCET as ln, handoffAttemptSucceeded as lo, doorHubs as lr, inviteLink as ls, parseSshPublicKey as lt, APP_PERMISSIONS as m, validReactionNumber as ma, encodeDnsQuery as mi, assertWholeSats as mn, handoffDataFrame as mo, mergeLobby as mr, deviceEchoFrame as ms, verifyBitcoinMessage as mt, appStoreDecision as n, parseLinkPreview as na, identityFromSeedB64 as nc, HOLD_DATED_BACK_MS as ni, resolveDidDht as nn, HANDOFF_PIECE_BYTES as no, MESH_HUBS as nr, createDeviceInvite as ns, carryMentions as nt, APP_STORE_LIMITS as o, GROUP_REACTION_FRAME as oa, sanitizeAvatar as oc, isHoldUrl as oi, ONCHAIN_PROVIDER as on, filePartName as oo, COMMUNITY_TOPOLOGY as or, createChatInvite as os, SSHSIG_MAX_ARMOR as ot, APP_ICON_PATH as p, readReaction as pa, domainWellKnownFile as pi, assertTokenUnits as pn, handoffContextProof as po, mergeBeacon as pr, HANDOFF_CAPABILITY as ps, verifySshSignature as pt, parseGroupWakeFrame as q, LEGACY_SERVICES as qa, DHT_FILE_TOO_LARGE as qi, engineError as qn, enrollCancelReason as qo, APP_DATA_MAX_BYTES as qr, withRequestOptions as qs, parseDidDocument as qt, appUpdateDecision as r, forwardedAgain as ra, publicKeyFromZ32 as rc, HOLD_LIMITS as ri, signDidDhtPacket as rn, HANDOFF_REQUEST as ro, mayBeHub as rr, deviceInviteJoinerParams as rs, parseCommunityEdit as rt, checkAppStoreIndex as s, REACTION_LIMITS as sa, newMailbox as si, PaymentPreflightError as sn, handoffAttemptAllowed as so, beaconKeys as sr, decodeInviteCode as ss, SSHSIG_NAMESPACE as st, appRevocationFor as t, dhtEditId as ta, identityFromSeed as tc, HOLD_CAPABILITY as ti, encodeDidDhtPacket as tn, HANDOFF_PAKE as to, readGroupSignal as tr, verifyEnrollProof as ts, GROUP_EDIT_TEXT_BYTES as tt, signAppStore as u, queueReaction as ua, decodeDnsResponse as ui, TEST_USDT_FAUCET_AMOUNT as un, handoffBusyFrame as uo, freshHubs as ur, inviteOwnership as us, parseSshSignature as ut, checkAppManifest as v, replySnippet as va, recordsFromTxt as vi, SPARK_NETWORKS as vn, handoffManifestFrames as vo, readBeacon as vr, devicePingFrame as vs, ATPROTO_FULL_SCOPE as vt, GROUP_TYPING_FRAME as w, verifyCommitSignature as wa, DHT_TEXT_CAPABILITY as wi, isPubkyKey as wn, handoffVersions as wo, transferEnded as wr, TURN_SETTLE_MS as ws, checkAtprotoProofRecord as wt, readAppBundle as x, MEMBER_KEY as xa, txtValue as xi, sparkInvoiceDetails as xn, handoffReleaseFrame as xo, shouldBeHub as xr, webCryptoSigner as xs, atprotoDidMethod as xt, isAppUrl as y, GROUP_MEMBER_CAP as ya, recordsFromWellKnown as yi, SPARK_PROVIDER as yn, handoffOfferFrame as yo, readBeaconHead as yr, newDeviceSetSecret as ys, ATPROTO_PROOF_COLLECTION as yt, knockIdentity as z, buildSdpFromSignal as za, lastSharedWithMe as zi, findEndpoint as zn, setUpdateFrame as zo, WakeLimiter as zr, DEFAULT_RELAYS as zs, didSignedMessages as zt };
30011
28813
 
30012
- //# sourceMappingURL=src-_FzJkeAJ.js.map
28814
+ //# sourceMappingURL=src-DhfGgAUu.js.map