@ghostlytools/cli 1.2.4 → 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 +16 -6
  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-CdnKGDbg.js → arkade-D4BVJFEQ.js} +2 -2
  11. package/dist/assets/{arkade-CdnKGDbg.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/buttons-CWl2yNJu.js +2 -0
  17. package/dist/assets/buttons-CX1pKB0j.js +497 -0
  18. package/dist/assets/buttons-CX1pKB0j.js.map +1 -0
  19. package/dist/assets/{endpoint-pgFkR2AS.js → endpoint-Dn7H8qE1.js} +14 -8
  20. package/dist/assets/endpoint-Dn7H8qE1.js.map +1 -0
  21. package/dist/assets/envelope-BvU0fLEt.js +90 -0
  22. package/dist/assets/envelope-BvU0fLEt.js.map +1 -0
  23. package/dist/assets/errors-my6VJR_0.js +77 -0
  24. package/dist/assets/errors-my6VJR_0.js.map +1 -0
  25. package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -1
  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-C1p0nkle.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-BH6s8HVi.js → mainline-D5D5C-jT.js} +11 -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-0zfNpABQ.js → server-CBCEYKMN.js} +1103 -235
  69. package/dist/assets/server-CBCEYKMN.js.map +1 -0
  70. package/dist/assets/{src-CbLkHfz8.js → src-DhfGgAUu.js} +836 -2265
  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-Qf4UuygS.js → usdt-C1Xao2xH.js} +3 -3
  75. package/dist/assets/{usdt-Qf4UuygS.js.map → usdt-C1Xao2xH.js.map} +1 -1
  76. package/dist/ghostly.mjs +4 -22077
  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-C1p0nkle.js.map +0 -1
  82. package/dist/assets/idb-CTF6LF5B.js.map +0 -1
  83. package/dist/assets/mainline-BH6s8HVi.js.map +0 -1
  84. package/dist/assets/rolldown-runtime-8H4AJuhK.js +0 -14
  85. package/dist/assets/server-0zfNpABQ.js.map +0 -1
  86. package/dist/assets/src-CbLkHfz8.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
  /**
@@ -832,6 +693,8 @@ function currentRelays(relays) {
832
693
  * only thing that peer is waiting for) would go out only once the polling slowed down, half a minute later.
833
694
  */
834
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;
835
698
  /**
836
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
837
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),
@@ -1167,14 +1030,22 @@ var RelayTransport = class {
1167
1030
  }
1168
1031
  /**
1169
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.
1170
1036
  * `put` calls `conflict` for a relay that answered 409: it holds a packet under this key dated at or after this one,
1171
1037
  * which this client has not read (an inviter's warm packet under the key it gave its contact, from a clock ahead of
1172
1038
  * this one). That packet is read from that relay, and the records go again dated just past it, once. Without this
1173
1039
  * the packet was refused until this device's clock passed the other one's, however long that took, and on a relay
1174
1040
  * that did take it, readers still preferred the later-dated empty packet of another relay.
1175
1041
  */
1176
- async publishDated(identity, records, put, options = {}, again = false) {
1177
- 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);
1178
1049
  let redated;
1179
1050
  const conflict = (relay) => {
1180
1051
  if (again || redated) return;
@@ -1186,7 +1057,7 @@ var RelayTransport = class {
1186
1057
  relay
1187
1058
  });
1188
1059
  if (this.lastTimestamp.get(key) !== timestamp) return true;
1189
- await this.publishDated(identity, records, put, options, true);
1060
+ await this.publishDated(identity, records, put, options, true, elsewhere);
1190
1061
  return true;
1191
1062
  })();
1192
1063
  redated.catch(() => {});
@@ -1198,6 +1069,46 @@ var RelayTransport = class {
1198
1069
  if (!pending || !await pending) throw error;
1199
1070
  }
1200
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
+ }
1201
1112
  /** One relay's packet for a key, read now: its time, kept for `nextTimestamp`. Null when it has none or does not answer. */
1202
1113
  async heldAt(relay, pubKeyZ32, who) {
1203
1114
  if (!this.take(relay, who, pubKeyZ32)) return null;
@@ -1217,10 +1128,22 @@ var RelayTransport = class {
1217
1128
  sawTimestamp(pubKeyZ32, timestamp) {
1218
1129
  if (timestamp > (this.seenTimestamp.get(pubKeyZ32) ?? 0n)) this.seenTimestamp.set(pubKeyZ32, timestamp);
1219
1130
  }
1220
- /** 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
+ */
1221
1144
  async publishPayload(pubKeyZ32, payload, options = {}, conflict) {
1222
1145
  if (this.relays.length === 0) throw new Error("No Pkarr relays configured");
1223
- const { seq } = openRelayPayload(pubKeyZ32, payload);
1146
+ const seq = options.signedAt ?? openRelayPayload(pubKeyZ32, payload).seq;
1224
1147
  await this.putEverywhere(pubKeyZ32, payload, seq, options, conflict);
1225
1148
  }
1226
1149
  /**
@@ -1261,7 +1184,7 @@ var RelayTransport = class {
1261
1184
  accepted = true;
1262
1185
  noWriteWaits();
1263
1186
  try {
1264
- this.written.set(pubKeyZ32, parseRelayPayload(pubKeyZ32, payload));
1187
+ this.written.set(pubKeyZ32, readOwnRelayPayload(pubKeyZ32, payload));
1265
1188
  } catch {}
1266
1189
  resolve();
1267
1190
  }, () => {});
@@ -1723,9 +1646,9 @@ var RelayTransport = class {
1723
1646
  if (rationed.length >= most) wait = Math.max(wait, rationed[rationed.length - most] + GROUP_RATION_WINDOW_MS - now);
1724
1647
  if (this.readsAhead(key, now) > 0) wait = Math.max(wait, TURN_WAIT_MS);
1725
1648
  }
1726
- if (who.group) {
1649
+ if (who.group || who.background) {
1727
1650
  const reserved = this.chatReservedFor(relay, now);
1728
- 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))));
1729
1652
  }
1730
1653
  const writer = firstWriter(who);
1731
1654
  const waiting = (lane) => {
@@ -1835,6 +1758,12 @@ var RelayTransport = class {
1835
1758
  if (share === void 0) return this.perMinute;
1836
1759
  return this.perMinuteGiven ? Math.min(this.perMinute, share) : share;
1837
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
+ }
1838
1767
  /** What groups leave a chat on this relay: `CHAT_RESERVE`, or a third of a smaller budget. */
1839
1768
  reserveOf(relay) {
1840
1769
  return Math.min(10, Math.floor(this.limitOf(relay) / 3));
@@ -3567,9 +3496,9 @@ var HANDOFF_CANCEL_REASONS = [
3567
3496
  "stalled"
3568
3497
  ];
3569
3498
  var KEY$3 = /^[A-Za-z0-9_-]{43}$/;
3570
- var DIGEST$2 = KEY$3;
3499
+ var DIGEST$1 = KEY$3;
3571
3500
  var SIGNATURE = /^[A-Za-z0-9_-]{86}$/;
3572
- var ID$5 = /^[A-Za-z0-9_-]{22}$/;
3501
+ var ID$4 = /^[A-Za-z0-9_-]{22}$/;
3573
3502
  var B64$4 = /^[A-Za-z0-9_-]*$/;
3574
3503
  var HEX = /^[0-9a-f]{64}$/;
3575
3504
  var VERSION_TEXT = /^[0-9A-Za-z.+_-]{1,40}$/;
@@ -3583,9 +3512,9 @@ var isHandoffPartName = (name) => typeof name === "string" && PART.test(name);
3583
3512
  /** The part a file is: `file/<its SHA-256, base64url>`. */
3584
3513
  var filePartName = (sha256B64) => `file/${sha256B64}`;
3585
3514
  /** The digest a file part names, or null for any other part. */
3586
- 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;
3587
3516
  function isPart(value) {
3588
- 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]);
3589
3518
  }
3590
3519
  /** Names compared as UTF-8 byte strings, as `H` sorts them. */
3591
3520
  function compareNames(a, b) {
@@ -3707,7 +3636,7 @@ function readHandoffHello(frame) {
3707
3636
  if (typeof frame.room !== "number" || !Number.isSafeInteger(frame.room) || frame.room < -1) return null;
3708
3637
  if (typeof frame.metered !== "boolean") return null;
3709
3638
  if (frame.later !== void 0 && !isCount$1(frame.later)) return null;
3710
- 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;
3711
3640
  const kind = DEVICE_KINDS.includes(frame.kind) ? frame.kind : "web";
3712
3641
  return {
3713
3642
  v: 1,
@@ -3733,7 +3662,7 @@ var handoffOfferFrame = (turn, id, bytes) => ({
3733
3662
  });
3734
3663
  function readHandoffTurnFrame(frame) {
3735
3664
  if (frame.t !== "handoff-request" && frame.t !== "handoff-offer") return null;
3736
- 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;
3737
3666
  if (frame.t === "handoff-offer") return isCount$1(frame.bytes) ? {
3738
3667
  turn: frame.turn,
3739
3668
  id: frame.id,
@@ -3776,7 +3705,7 @@ function readHandoffPake(frame) {
3776
3705
  };
3777
3706
  }
3778
3707
  function readHandoffHave(frame) {
3779
- 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;
3780
3709
  const p = {};
3781
3710
  if (frame.p !== void 0) {
3782
3711
  if (!frame.p || typeof frame.p !== "object" || Array.isArray(frame.p) || Object.keys(frame.p).length > 300) return null;
@@ -3832,7 +3761,7 @@ function readHandoffAck(frame) {
3832
3761
  }
3833
3762
  /** `H` of a `handoff-verified` that B's key signed for this turn address and turn, or null. */
3834
3763
  function readHandoffVerified(frame, turnAddress, turn, takerKey) {
3835
- 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;
3836
3765
  const h = fromBase64Url(frame.h);
3837
3766
  try {
3838
3767
  return verify(fromBase64Url(frame.s), handoffVerifiedMessage(turnAddress, turn, h), takerKey) ? h : null;
@@ -3900,7 +3829,7 @@ var handoffAttemptSucceeded = () => ({
3900
3829
  /** Seconds until a locked-out device may try again (for `handoff-busy`). */
3901
3830
  var handoffRetryAfter = (attempts, now) => attempts?.until !== void 0 && attempts.until > now ? Math.ceil((attempts.until - now) / 1e3) : 0;
3902
3831
  var HTTP_SERVICE_PROTO = "ghostly-http/1";
3903
- var MAX_NAME_LENGTH$1 = 48;
3832
+ var MAX_NAME_LENGTH = 48;
3904
3833
  var MAX_META_ENTRIES = 8;
3905
3834
  var MAX_META_LENGTH = 64;
3906
3835
  var SERVICE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
@@ -3981,7 +3910,7 @@ function servicesFromWire(wire) {
3981
3910
  id: e.i,
3982
3911
  type: e.t
3983
3912
  };
3984
- const name = cleanString(e.n, MAX_NAME_LENGTH$1);
3913
+ const name = cleanString(e.n, MAX_NAME_LENGTH);
3985
3914
  const proto = cleanString(e.p, MAX_META_LENGTH);
3986
3915
  if (name) ad.name = name;
3987
3916
  if (proto) ad.proto = proto;
@@ -4827,304 +4756,6 @@ function buildDataSdp(signal) {
4827
4756
  return lines.join("\r\n") + "\r\n";
4828
4757
  }
4829
4758
  //#endregion
4830
- //#region ../core/src/frames.ts
4831
- /**
4832
- * Framing on the `ghostly/1` DataChannel (ordered, reliable).
4833
- *
4834
- * - Text messages are control frames: compact JSON with a `t` discriminator,
4835
- * the same shape Ghostly already uses for `_msgs` and `_call`.
4836
- * - Binary messages are body chunks that belong to a stream opened by a
4837
- * control frame: `<kind u8><stream id u32 BE><flags u8><payload>`.
4838
- *
4839
- * Everything here handles data from the remote peer and must never trust it.
4840
- */
4841
- var LIMITS = {
4842
- /** Largest control frame accepted, headers included. */
4843
- maxControlFrameBytes: 61440,
4844
- /** Longest payment endpoint value (a Cashu token, an invoice) a payment frame may carry; a longer one is dropped. */
4845
- maxPaymentEndpointChars: 32768,
4846
- /** Largest DataChannel message sent. 16 KiB is safe across WebRTC stacks. */
4847
- maxChunkMessageBytes: 16384,
4848
- maxRequestBodyBytes: 8388608,
4849
- maxResponseBodyBytes: 67108864,
4850
- maxHeaderCount: 128,
4851
- maxHeaderValueBytes: 16384,
4852
- maxPathBytes: 8192,
4853
- /** Requests a host serves at once for one peer. */
4854
- maxConcurrentRequestsPerPeer: 32,
4855
- /** Requests a client keeps in flight; the rest wait in a queue. */
4856
- maxClientInFlight: 16,
4857
- maxChatMessageBytes: 16384,
4858
- maxFileBytes: 104857600,
4859
- /** Files a peer may be sending us at once. */
4860
- maxIncomingFilesPerPeer: 3,
4861
- /** Received bytes kept for one peer, in flight included. Past it new files are refused. */
4862
- maxStoredIncomingBytesPerPeer: 524288e3,
4863
- requestTimeoutMs: 6e4,
4864
- bodyIdleTimeoutMs: 3e4,
4865
- /** Stop writing to the channel above this many buffered bytes. */
4866
- sendHighWaterMark: 1048576,
4867
- sendLowWaterMark: 262144
4868
- };
4869
- var CHUNK_KIND = {
4870
- requestBody: 1,
4871
- responseBody: 2,
4872
- fileBody: 3
4873
- };
4874
- var CHUNK_HEADER_BYTES = 6;
4875
- var MAX_CHUNK_PAYLOAD_BYTES = LIMITS.maxChunkMessageBytes - CHUNK_HEADER_BYTES;
4876
- function encodeControl(frame) {
4877
- const json = JSON.stringify(frame);
4878
- if (utf8Encode(json).length > LIMITS.maxControlFrameBytes) throw new Error("Control frame too large");
4879
- return json;
4880
- }
4881
- function isHeaderList(value) {
4882
- if (!Array.isArray(value) || value.length > LIMITS.maxHeaderCount) return false;
4883
- 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);
4884
- }
4885
- function isStreamId(value) {
4886
- return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 4294967295;
4887
- }
4888
- function isPayId(value) {
4889
- return typeof value === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(value);
4890
- }
4891
- /** Decimal text, as in Paykit's Payment Amount: no floats on the wire. */
4892
- function isAmount(value) {
4893
- return typeof value === "string" && /^(0|[1-9]\d{0,15})(\.\d{1,8})?$/.test(value);
4894
- }
4895
- function isUnit(value) {
4896
- return typeof value === "string" && /^[a-z0-9]{1,12}$/.test(value);
4897
- }
4898
- function isEndpoint(value) {
4899
- 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;
4900
- }
4901
- /** Returns null for anything malformed; callers drop the frame. */
4902
- function decodeControl(text) {
4903
- if (text.length > LIMITS.maxControlFrameBytes || utf8Encode(text).length > LIMITS.maxControlFrameBytes) return null;
4904
- let raw;
4905
- try {
4906
- raw = JSON.parse(text);
4907
- } catch {
4908
- return null;
4909
- }
4910
- if (!raw || typeof raw !== "object") return null;
4911
- const f = raw;
4912
- switch (f.t) {
4913
- case "hello":
4914
- if (typeof f.v !== "number") return null;
4915
- return {
4916
- t: "hello",
4917
- v: f.v,
4918
- svc: f.svc,
4919
- nick: sanitizeNick(f.nick)
4920
- };
4921
- case "m":
4922
- if (typeof f.ts !== "number" || typeof f.m !== "string") return null;
4923
- if (utf8Encode(f.m).length > LIMITS.maxChatMessageBytes) return null;
4924
- return {
4925
- t: "m",
4926
- ts: f.ts,
4927
- m: f.m
4928
- };
4929
- case "call":
4930
- if (typeof f.s !== "string" || utf8Encode(f.s).length > LIMITS.maxChatMessageBytes) return null;
4931
- return {
4932
- t: "call",
4933
- s: f.s
4934
- };
4935
- case "svc": return {
4936
- t: "svc",
4937
- svc: f.svc
4938
- };
4939
- case "req":
4940
- if (!isStreamId(f.id) || typeof f.s !== "string" || typeof f.m !== "string") return null;
4941
- if (typeof f.p !== "string" || f.p.length > LIMITS.maxPathBytes || !isHeaderList(f.h)) return null;
4942
- return {
4943
- t: "req",
4944
- id: f.id,
4945
- s: f.s,
4946
- m: f.m,
4947
- p: f.p,
4948
- h: f.h,
4949
- b: f.b === true
4950
- };
4951
- case "res":
4952
- if (!isStreamId(f.id) || typeof f.st !== "number" || !Number.isInteger(f.st)) return null;
4953
- if (f.st < 100 || f.st > 599 || !isHeaderList(f.h)) return null;
4954
- return {
4955
- t: "res",
4956
- id: f.id,
4957
- st: f.st,
4958
- h: f.h,
4959
- b: f.b === true
4960
- };
4961
- case "file":
4962
- if (!isStreamId(f.id) || typeof f.f !== "string" || typeof f.ts !== "number") return null;
4963
- if (typeof f.n !== "string" || typeof f.m !== "string") return null;
4964
- if (typeof f.s !== "number" || !Number.isSafeInteger(f.s) || f.s < 0) return null;
4965
- return {
4966
- t: "file",
4967
- id: f.id,
4968
- f: f.f,
4969
- ts: f.ts,
4970
- n: f.n,
4971
- s: f.s,
4972
- m: f.m
4973
- };
4974
- case "pay-req":
4975
- case "pay": {
4976
- if (!isPayId(f.id) || typeof f.ts !== "number" || !isAmount(f.v) || !isUnit(f.u)) return null;
4977
- const memo = typeof f.memo === "string" ? f.memo.slice(0, 140) : void 0;
4978
- if (f.t === "pay-req") {
4979
- if (!Array.isArray(f.e) || f.e.length === 0 || f.e.length > 8 || !f.e.every(isEndpoint)) return null;
4980
- return {
4981
- t: "pay-req",
4982
- id: f.id,
4983
- ts: f.ts,
4984
- v: f.v,
4985
- u: f.u,
4986
- memo,
4987
- e: f.e,
4988
- a: isPayId(f.a) ? f.a : void 0,
4989
- n: f.n === "mainnet" || f.n === "testnet" ? f.n : void 0
4990
- };
4991
- }
4992
- if (!isEndpoint(f.e) || f.rid !== void 0 && !isPayId(f.rid)) return null;
4993
- return {
4994
- t: "pay",
4995
- id: f.id,
4996
- ts: f.ts,
4997
- rid: f.rid,
4998
- v: f.v,
4999
- u: f.u,
5000
- memo,
5001
- e: f.e
5002
- };
5003
- }
5004
- case "pay-ask":
5005
- if (!isPayId(f.id) || typeof f.ts !== "number" || !isAmount(f.v) || !isUnit(f.u)) return null;
5006
- if (f.m !== "arkade" && f.m !== "usdt" && f.m !== "bark" && f.m !== "bitcoin" && f.m !== "fedimint" && f.m !== "spark") return null;
5007
- return {
5008
- t: "pay-ask",
5009
- id: f.id,
5010
- ts: f.ts,
5011
- v: f.v,
5012
- u: f.u,
5013
- m: f.m,
5014
- memo: typeof f.memo === "string" ? f.memo.slice(0, 140) : void 0,
5015
- n: f.n === "mainnet" || f.n === "testnet" ? f.n : void 0
5016
- };
5017
- case "pay-res":
5018
- if (!isPayId(f.id) || typeof f.ok !== "boolean") return null;
5019
- if (f.v !== void 0 && !isAmount(f.v)) return null;
5020
- return {
5021
- t: "pay-res",
5022
- id: f.id,
5023
- ok: f.ok,
5024
- v: f.v,
5025
- err: typeof f.err === "string" ? f.err.slice(0, 200) : void 0,
5026
- ...f.c === true && !f.ok ? { c: true } : {}
5027
- };
5028
- case "rst":
5029
- if (!isStreamId(f.id) || f.d !== "q" && f.d !== "s" && f.d !== "f") return null;
5030
- return {
5031
- t: "rst",
5032
- id: f.id,
5033
- d: f.d,
5034
- e: typeof f.e === "string" ? f.e.slice(0, 256) : ""
5035
- };
5036
- case "ping":
5037
- case "pong":
5038
- if (typeof f.ts !== "number") return null;
5039
- return {
5040
- t: f.t,
5041
- ts: f.ts
5042
- };
5043
- default: return null;
5044
- }
5045
- }
5046
- function encodeChunk(chunk) {
5047
- if (chunk.payload.length > MAX_CHUNK_PAYLOAD_BYTES) throw new Error("Chunk payload too large");
5048
- const out = new Uint8Array(CHUNK_HEADER_BYTES + chunk.payload.length);
5049
- const view = new DataView(out.buffer);
5050
- view.setUint8(0, chunk.kind);
5051
- view.setUint32(1, chunk.id);
5052
- view.setUint8(5, chunk.end ? 1 : 0);
5053
- out.set(chunk.payload, CHUNK_HEADER_BYTES);
5054
- return out;
5055
- }
5056
- function decodeChunk(data) {
5057
- if (data.length < CHUNK_HEADER_BYTES || data.length > LIMITS.maxChunkMessageBytes) return null;
5058
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
5059
- const kind = view.getUint8(0);
5060
- if (kind !== CHUNK_KIND.requestBody && kind !== CHUNK_KIND.responseBody && kind !== CHUNK_KIND.fileBody) return null;
5061
- return {
5062
- kind,
5063
- id: view.getUint32(1),
5064
- end: (view.getUint8(5) & 1) !== 0,
5065
- payload: data.subarray(CHUNK_HEADER_BYTES)
5066
- };
5067
- }
5068
- function wrapDataChannel(dc) {
5069
- dc.binaryType = "arraybuffer";
5070
- dc.bufferedAmountLowThreshold = LIMITS.sendLowWaterMark;
5071
- const channel = {
5072
- send(data) {
5073
- if (dc.readyState !== "open") throw new Error("Data link is not open");
5074
- if (typeof data === "string") dc.send(data);
5075
- else dc.send(data);
5076
- },
5077
- get bufferedAmount() {
5078
- return dc.bufferedAmount;
5079
- },
5080
- drained() {
5081
- if (dc.bufferedAmount <= LIMITS.sendLowWaterMark || dc.readyState !== "open") return Promise.resolve();
5082
- return new Promise((resolve) => {
5083
- const done = () => {
5084
- dc.removeEventListener("bufferedamountlow", done);
5085
- dc.removeEventListener("close", done);
5086
- resolve();
5087
- };
5088
- dc.addEventListener("bufferedamountlow", done);
5089
- dc.addEventListener("close", done);
5090
- });
5091
- },
5092
- close() {
5093
- dc.close();
5094
- },
5095
- onMessage: null,
5096
- onClose: null
5097
- };
5098
- dc.addEventListener("message", (event) => {
5099
- const data = event.data;
5100
- if (typeof data === "string") channel.onMessage?.(data);
5101
- else if (data instanceof ArrayBuffer) channel.onMessage?.(new Uint8Array(data));
5102
- });
5103
- dc.addEventListener("close", () => channel.onClose?.());
5104
- return channel;
5105
- }
5106
- /** Writes a body as chunks, honouring backpressure. Always ends with an END chunk. */
5107
- async function sendBody(channel, kind, id, body, isCancelled = () => false) {
5108
- for await (const part of body) for (let offset = 0; offset < part.length; offset += MAX_CHUNK_PAYLOAD_BYTES) {
5109
- if (isCancelled()) return;
5110
- if (channel.bufferedAmount > LIMITS.sendHighWaterMark) await channel.drained();
5111
- const payload = part.subarray(offset, offset + MAX_CHUNK_PAYLOAD_BYTES);
5112
- channel.send(encodeChunk({
5113
- kind,
5114
- id,
5115
- end: false,
5116
- payload
5117
- }));
5118
- }
5119
- if (isCancelled()) return;
5120
- channel.send(encodeChunk({
5121
- kind,
5122
- id,
5123
- end: true,
5124
- payload: /* @__PURE__ */ new Uint8Array(0)
5125
- }));
5126
- }
5127
- //#endregion
5128
4759
  //#region ../core/src/http.ts
5129
4760
  var LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
5130
4761
  "localhost",
@@ -5701,6 +5332,19 @@ var STALLED_LOOK_MS = 15e3;
5701
5332
  * #1441, on a loaded host), and past it the race's DHT dial has brought the chat back anyway.
5702
5333
  */
5703
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
+ }
5704
5348
  var PUBLISH_RETRY_MS = 4e3;
5705
5349
  /**
5706
5350
  * A packet the relays' request budget held back goes again when the budget frees a request, and at least this often
@@ -5756,6 +5400,8 @@ var LinkSession = class {
5756
5400
  startedAt = Date.now();
5757
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. */
5758
5402
  awaitingSince;
5403
+ /** This side's invite, nobody used yet (`LinkSessionOptions.invite`). */
5404
+ invite;
5759
5405
  intervals;
5760
5406
  fastPollUntil = 0;
5761
5407
  /** An offer to a saved contact is out: from then on its fast window looks less often (`OFFER_FAST_MS`); 0 for none. */
@@ -5811,6 +5457,7 @@ var LinkSession = class {
5811
5457
  this.events = options.events ?? {};
5812
5458
  this.firstPublish = options.firstPublish ?? "at-start";
5813
5459
  this.awaitingSince = Math.min(options.awaitingSince ?? this.startedAt, this.startedAt);
5460
+ this.invite = options.invite ?? (() => false);
5814
5461
  }
5815
5462
  get peerPresence() {
5816
5463
  return this.presence;
@@ -5984,8 +5631,8 @@ var LinkSession = class {
5984
5631
  async setRtcSignal(signal, reportFailure = false) {
5985
5632
  if (this.rtcSignal === signal) return;
5986
5633
  this.rtcSignal = signal;
5987
- if (reportFailure) await this.publish();
5988
- else await this.publish().catch(() => {});
5634
+ if (reportFailure) await this.publish(signal === null);
5635
+ else await this.publish(signal === null).catch(() => {});
5989
5636
  if (signal) this.pollNow();
5990
5637
  }
5991
5638
  /**
@@ -6058,6 +5705,7 @@ var LinkSession = class {
6058
5705
  const stepping = this.fastStepsAfter ? Date.now() - this.fastStepsAfter : -1;
6059
5706
  if (pace === "fast" && stepping >= 0) return Math.min(4 * interval, Math.max(2 * interval, stepping));
6060
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);
6061
5709
  return Math.min(interval, Math.max(2 * this.intervals.fast, since));
6062
5710
  }
6063
5711
  /**
@@ -6069,13 +5717,17 @@ var LinkSession = class {
6069
5717
  watching() {
6070
5718
  return Date.now() < this.watchUntil && this.rtcSignal === null && !(this.presence.online && this.presence.lastPacketAt > this.watchFrom);
6071
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
+ }
6072
5724
  /** How urgently this link looks right now. */
6073
5725
  pace() {
6074
5726
  if (Date.now() < this.stalledUntil) return "fast";
6075
5727
  if (this.connected) return "connected";
6076
5728
  if (Date.now() < this.fastPollUntil) return "fast";
6077
5729
  if (Date.now() < this.watchUntil) return "active";
6078
- if (!this.active && this.presence.lastPacketAt === 0 && Date.now() - this.awaitingSince < 6e5) return "active";
5730
+ if (!this.active && this.awaitingPeer()) return "active";
6079
5731
  if (!this.active) return "background";
6080
5732
  return Date.now() - this.lastActivity > 6e4 ? "idle" : "active";
6081
5733
  }
@@ -6132,7 +5784,9 @@ var LinkSession = class {
6132
5784
  }, this.encKey);
6133
5785
  const started = Date.now();
6134
5786
  try {
6135
- 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;
6136
5790
  await this.transport.publish(this.identity, built.records, options);
6137
5791
  } catch (error) {
6138
5792
  const ms = Date.now() - started, waiting = isDiscoveryBudgetError(error);
@@ -6437,7 +6091,9 @@ var ICE_GATHERING_TIMEOUT_MS = 5e3;
6437
6091
  * settle); where they do not, every offer of a first pairing waited the full 5 s, twice when the inviter offered too
6438
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
6439
6093
  * contacts (their checks learn this side's address from its own); behind a restricted NAT on the contact's side it may
6440
- * not, and the next offer of the chat then waits as long as it takes (`fullGather`).
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).
6441
6097
  */
6442
6098
  var OFFER_HOST_GATHER_MS = 2e3;
6443
6099
  /**
@@ -6447,6 +6103,14 @@ var OFFER_HOST_GATHER_MS = 2e3;
6447
6103
  var GATHER_STALL_MS = 3e3;
6448
6104
  /** How long a connection may stay `disconnected` before it is given up. */
6449
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;
6450
6114
  /** An offer this close to the end of the offerer's attempt is not answered again: the new answer would come too late. */
6451
6115
  var REANSWER_MARGIN_MS = 15e3;
6452
6116
  /**
@@ -6478,12 +6142,13 @@ var DataLink = class {
6478
6142
  pc = null;
6479
6143
  myOfferTs = 0;
6480
6144
  /**
6481
- * An offer of this side's that went without a public candidate came to nothing (it was answered, and no path
6482
- * connected): the next one waits for its STUN servers as long as it takes (`ICE_GATHERING_TIMEOUT_MS`).
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`).
6483
6148
  */
6484
6149
  fullGather = false;
6485
- /** The offer out has no public candidate (as it went: the connection's description gains any that come later). */
6486
- offerLacksPublic = false;
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;
6487
6152
  lastSignalTs = 0;
6488
6153
  /** The peer's description as this connection was given it: Chrome shows it as `remoteDescription` only once applied. */
6489
6154
  remoteSdp = null;
@@ -6529,7 +6194,7 @@ var DataLink = class {
6529
6194
  this.options.setFastPoll(true, true);
6530
6195
  }
6531
6196
  this.myOfferTs = Date.now();
6532
- this.offerLacksPublic = !PUBLIC_CANDIDATE.test(pc.localDescription?.sdp ?? "");
6197
+ this.sentLacksPublic = !PUBLIC_CANDIDATE.test(pc.localDescription?.sdp ?? "");
6533
6198
  const signal = {
6534
6199
  t: "o",
6535
6200
  ts: this.myOfferTs,
@@ -6635,9 +6300,12 @@ var DataLink = class {
6635
6300
  this.reset();
6636
6301
  if (evidence) this.options.onDirect?.(evidence);
6637
6302
  }
6638
- /** This side's offer went with no public candidate, was answered, and came to nothing: the next offer gathers for as long as it takes. */
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
+ */
6639
6307
  noteNoPublic(ended) {
6640
- 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;
6641
6309
  }
6642
6310
  /**
6643
6311
  * What this attempt says about direct connections if it ends now without opening: nothing unless both descriptions
@@ -6652,17 +6320,22 @@ var DataLink = class {
6652
6320
  /**
6653
6321
  * The attempt on this connection failed (it timed out, ICE failed or closed, its channel closed). An answer the
6654
6322
  * offerer may not have read yet is made again for the same offer while it still stands (`REANSWERS`); anything
6655
- * else goes back to idle.
6323
+ * else goes back to idle. `byConnection`: the connection said so itself, rather than a timer of this link's.
6656
6324
  */
6657
- failed() {
6325
+ failed(byConnection = false) {
6658
6326
  if (this.state === "offering" && this.remoteSdp) {
6659
6327
  this.answerRefused("failed");
6660
6328
  return;
6661
6329
  }
6330
+ if (byConnection && this.state === "connecting" && this.myOfferTs && Date.now() - this.connectingSince < ANSWER_REFUSED_AFTER_MS) {
6331
+ this.answerRefused("failed");
6332
+ return;
6333
+ }
6662
6334
  const answered = this.state === "connecting" ? this.answered : null;
6663
6335
  const left = answered ? CONNECT_TIMEOUT_MS - (Date.now() - answered.at) : 0;
6664
- const evidence = this.directEvidence();
6665
- 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);
6666
6339
  if (answered && answered.again < 2 && left > REANSWER_MARGIN_MS && this.options.offerStanding?.(answered.offer.ts)) {
6667
6340
  answered.again++;
6668
6341
  traceLink(this.options.myPubKeyZ32, "reanswer", {
@@ -6703,6 +6376,7 @@ var DataLink = class {
6703
6376
  await pc.setLocalDescription(await pc.createAnswer());
6704
6377
  });
6705
6378
  if (!pc) return;
6379
+ this.sentLacksPublic = !PUBLIC_CANDIDATE.test(pc.localDescription?.sdp ?? "");
6706
6380
  const signal = {
6707
6381
  t: "a",
6708
6382
  ts: Date.now(),
@@ -6732,7 +6406,7 @@ var DataLink = class {
6732
6406
  await waitForIceGathering(pc, ICE_GATHERING_TIMEOUT_MS, {
6733
6407
  stallMs: GATHER_STALL_MS,
6734
6408
  settleMs: 150,
6735
- ...offer && !this.fullGather ? { hostBoundMs: OFFER_HOST_GATHER_MS } : {}
6409
+ ...!this.fullGather ? { hostBoundMs: OFFER_HOST_GATHER_MS } : {}
6736
6410
  });
6737
6411
  if (this.pc !== pc) return null;
6738
6412
  const ms = Date.now() - gathering;
@@ -6779,7 +6453,7 @@ var DataLink = class {
6779
6453
  tracePair(this.options.myPubKeyZ32, pc);
6780
6454
  });
6781
6455
  dc.addEventListener("close", () => {
6782
- if (this.pc === pc) this.failed();
6456
+ if (this.pc === pc) this.failed(true);
6783
6457
  });
6784
6458
  pc.addEventListener("connectionstatechange", () => {
6785
6459
  if (this.pc !== pc) return;
@@ -6788,7 +6462,7 @@ var DataLink = class {
6788
6462
  if (pc.connectionState === "connected") this.options.onDisconnected?.(false);
6789
6463
  }
6790
6464
  this.disconnectTimer = null;
6791
- if (pc.connectionState === "failed" || pc.connectionState === "closed") this.failed();
6465
+ if (pc.connectionState === "failed" || pc.connectionState === "closed") this.failed(true);
6792
6466
  else if (pc.connectionState === "disconnected") {
6793
6467
  if (this.state === "open") this.options.onDisconnected?.(true);
6794
6468
  this.disconnectTimer = setTimeout(() => {
@@ -7144,1053 +6818,63 @@ function rememberAlone(alone, key, present) {
7144
6818
  }
7145
6819
  var GROUP_ADMIN_OFF_ERROR = "Managing this group is off on this device. Turn on Manage groups from this device first.";
7146
6820
  //#endregion
7147
- //#region ../core/src/canonicalJson.ts
7148
- /** True when `s` holds a lone UTF-16 surrogate, which I-JSON (and so JCS) does not allow. */
7149
- function hasLoneSurrogate(s) {
7150
- for (let i = 0; i < s.length; i++) {
7151
- const c = s.charCodeAt(i);
7152
- if (c >= 55296 && c <= 56319) {
7153
- const next = s.charCodeAt(i + 1);
7154
- if (next >= 56320 && next <= 57343) {
7155
- i++;
7156
- continue;
7157
- }
7158
- return true;
7159
- }
7160
- if (c >= 56320 && c <= 57343) return true;
7161
- }
7162
- return false;
7163
- }
7164
- function write(value, depth) {
7165
- if (depth > 64) throw new Error("JSON nested deeper than 64 levels");
7166
- if (value === null) return "null";
7167
- if (typeof value === "boolean") return value ? "true" : "false";
7168
- if (typeof value === "number") {
7169
- if (!Number.isFinite(value)) throw new Error("JSON numbers must be finite");
7170
- return JSON.stringify(value);
7171
- }
7172
- if (typeof value === "string") {
7173
- if (hasLoneSurrogate(value)) throw new Error("A JSON string holds a lone surrogate");
7174
- return JSON.stringify(value);
7175
- }
7176
- if (Array.isArray(value)) return `[${value.map((v) => write(v, depth + 1)).join(",")}]`;
7177
- if (typeof value === "object") {
7178
- const record = value;
7179
- return `{${Object.keys(value).filter((k) => record[k] !== void 0).sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map((k) => {
7180
- if (hasLoneSurrogate(k)) throw new Error("A JSON key holds a lone surrogate");
7181
- return `${JSON.stringify(k)}:${write(record[k], depth + 1)}`;
7182
- }).join(",")}}`;
7183
- }
7184
- throw new Error(`Not a JSON value: ${typeof value}`);
7185
- }
7186
- /** The canonical text of a JSON value. Throws on a value JSON cannot hold (a non-finite number, a lone surrogate). */
7187
- function canonicalJson(value) {
7188
- return write(value, 0);
7189
- }
7190
- /** The canonical UTF-8 bytes of a JSON value. */
7191
- function canonicalJsonBytes(value) {
7192
- return utf8Encode(canonicalJson(value));
7193
- }
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;
7194
6836
  /**
7195
- * Reads bytes that must be the canonical form of a JSON value: `not-json` when they are not UTF-8 JSON, `not-canonical`
7196
- * when writing the value again gives other bytes.
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.
7197
6840
  */
7198
- function readCanonicalJson(bytes) {
7199
- let value;
7200
- try {
7201
- value = JSON.parse(utf8Decode(bytes));
7202
- } catch (error) {
7203
- return {
7204
- ok: false,
7205
- reason: "not-json",
7206
- detail: error instanceof Error ? error.message : String(error)
7207
- };
7208
- }
7209
- let again;
7210
- try {
7211
- again = canonicalJsonBytes(value);
7212
- } catch (error) {
7213
- return {
7214
- ok: false,
7215
- reason: "not-canonical",
7216
- detail: error instanceof Error ? error.message : String(error)
7217
- };
7218
- }
7219
- if (!bytesEqual(again, bytes)) return {
7220
- ok: false,
7221
- reason: "not-canonical"
7222
- };
7223
- return {
7224
- ok: true,
7225
- value
7226
- };
7227
- }
7228
- //#endregion
7229
- //#region ../core/src/appStatements.ts
7230
- /** The prefixes of phase 1. The others of the WISP (reports, reviews, licences, rotation, publisher proofs) come later. */
7231
- var APP_PREFIXES = {
7232
- app: "ghostly-app/1",
7233
- store: "ghostly-store/1",
7234
- revoke: "ghostly-revoke/1"
7235
- };
7236
- var APP_STATEMENT_LIMITS = {
7237
- /** A signature statement's canonical bytes: 52 + 86 characters of values and the keys around them. */
7238
- signatureBytes: 1024,
7239
- /** Digests named by one revocation. */
7240
- revokeDigests: 64,
7241
- /** A revocation's reason, in characters. */
7242
- reason: 200,
7243
- /** Revocations in one `ghostly-revoke.json`. */
7244
- revocations: 4096
7245
- };
7246
- /** A z-base32 Ed25519 public key: 52 characters that decode to 32 bytes and encode back to the same text. */
7247
- function isAppKey(value) {
7248
- if (typeof value !== "string" || !/^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(value)) return false;
7249
- const bytes = fromZ32(value);
7250
- return bytes.length === 32 && toZ32(bytes) === value;
7251
- }
7252
- /** A SHA-256 in base64url without padding: 43 characters that decode to 32 bytes and encode back to the same text. */
7253
- function isAppHash(value) {
7254
- if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) return false;
7255
- return toBase64Url(fromBase64Url(value)) === value;
7256
- }
7257
- /** An app reference, `<publisher key>/<name>`. */
7258
- var APP_NAME = /^[a-z][a-z0-9-]{0,31}$/;
7259
- function isAppRef(value) {
7260
- if (typeof value !== "string") return false;
7261
- return value.indexOf("/") === 52 && isAppKey(value.slice(0, 52)) && APP_NAME.test(value.slice(53));
7262
- }
7263
- function appRef(publisher, name) {
7264
- return `${publisher}/${name}`;
7265
- }
7266
- /** The publisher key of a reference. */
7267
- function appRefPublisher(ref) {
7268
- return ref.slice(0, 52);
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;
7269
6847
  }
7270
6848
  /**
7271
- * The publisher fingerprint people see: the first 16 z-base32 characters of the key (80 bits), in four groups.
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.
7272
6851
  */
7273
- function appFingerprint(key) {
7274
- return (key.slice(0, 16).match(/.{1,4}/g) ?? []).join(" ");
7275
- }
7276
- /** The bytes a signature covers: the prefix, a zero byte, then the SHA-256 of the object's canonical bytes. */
7277
- function appSignedBytes(prefix, canonical) {
7278
- const p = utf8Encode(prefix);
7279
- const out = new Uint8Array(p.length + 1 + 32);
7280
- out.set(p, 0);
7281
- out.set(sha256(canonical), p.length + 1);
7282
- return out;
7283
- }
7284
- /** Signs an object under a prefix: its canonical bytes and the signature statement. */
7285
- async function signAppObject(prefix, value, signer) {
7286
- const bytes = canonicalJsonBytes(value);
7287
- const sig = await signer.sign(appSignedBytes(prefix, bytes));
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;
7288
6858
  return {
7289
- bytes,
7290
- signature: {
7291
- alg: "ed25519",
7292
- key: toZ32(signer.publicKey),
7293
- sig: toBase64Url(sig)
7294
- }
6859
+ i,
6860
+ s: replySnippet(s),
6861
+ f,
6862
+ ...typeof b === "string" && BUTTON_ID.test(b) && { b }
7295
6863
  };
7296
6864
  }
7297
- /**
7298
- * Ed25519 verification as RFC 8032 writes it (`zip215: false`): canonical encodings of the point and the scalar only,
7299
- * so every parser (this one, a Rust one with `verify_strict`) accepts exactly the same signatures.
7300
- */
7301
- function strictVerify(signature, message, publicKey) {
7302
- try {
7303
- return ed25519.verify(signature, message, publicKey, { zip215: false });
7304
- } catch {
7305
- return false;
7306
- }
7307
- }
7308
- var exactKeys$1 = (value, keys, optional = []) => {
7309
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
7310
- const have = Object.keys(value);
7311
- return keys.every((k) => have.includes(k)) && have.every((k) => keys.includes(k) || optional.includes(k));
7312
- };
7313
- /** Checks the shape of a signature statement: exactly `alg`, `key` and `sig`, a 64-byte signature. */
7314
- function isAppSignature(value) {
7315
- if (!exactKeys$1(value, [
7316
- "alg",
7317
- "key",
7318
- "sig"
7319
- ])) return false;
7320
- if (value.alg !== "ed25519" || !isAppKey(value.key)) return false;
7321
- return typeof value.sig === "string" && /^[A-Za-z0-9_-]{86}$/.test(value.sig) && toBase64Url(fromBase64Url(value.sig)) === value.sig;
7322
- }
7323
- /** Reads a signature statement from its bytes, which must be canonical. */
7324
- function readAppSignature(bytes) {
7325
- if (bytes.length > APP_STATEMENT_LIMITS.signatureBytes) return {
7326
- ok: false,
7327
- reason: "bad-signature-statement",
7328
- detail: "Too large"
7329
- };
7330
- const read = readCanonicalJson(bytes);
7331
- if (!read.ok) return {
7332
- ok: false,
7333
- reason: "bad-signature-statement",
7334
- detail: read.reason
7335
- };
7336
- if (!isAppSignature(read.value)) return {
7337
- ok: false,
7338
- reason: "bad-signature-statement",
7339
- detail: "Not {alg, key, sig}"
7340
- };
7341
- return {
7342
- ok: true,
7343
- signature: read.value
7344
- };
7345
- }
7346
- /**
7347
- * Verifies a signature over an object's canonical bytes under a prefix, made by `key`: `signature-key` when the
7348
- * statement names another key, `bad-signature` when it does not verify.
7349
- */
7350
- function verifyAppSignature(prefix, canonical, signature, key) {
7351
- if (!isAppSignature(signature)) return {
7352
- ok: false,
7353
- reason: "bad-signature-statement"
7354
- };
7355
- if (signature.key !== key) return {
7356
- ok: false,
7357
- reason: "signature-key"
7358
- };
7359
- return strictVerify(fromBase64Url(signature.sig), appSignedBytes(prefix, canonical), fromZ32(key)) ? { ok: true } : {
7360
- ok: false,
7361
- reason: "bad-signature"
7362
- };
7363
- }
7364
- var isSequence = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 1;
7365
- var isPlainLine = (v, max) => typeof v === "string" && [...v].length <= max && !/\p{Cc}/u.test(v);
7366
- /** Checks a revocation statement's shape; the reason it is refused, or null. */
7367
- function checkAppRevokeStatement(value) {
7368
- if (typeof value !== "object" || value === null || Array.isArray(value)) return "Not an object";
7369
- const v = value;
7370
- const byDigests = "digests" in v;
7371
- if (!exactKeys$1(v, [
7372
- "ghostlyRevoke",
7373
- "app",
7374
- byDigests ? "digests" : "upTo"
7375
- ], ["reason"])) return "Keys are not ghostlyRevoke, app, and exactly one of digests and upTo (reason optional)";
7376
- if (v.ghostlyRevoke !== 1) return "ghostlyRevoke is not 1";
7377
- if (!isAppRef(v.app)) return "app is not a reference";
7378
- if (byDigests) {
7379
- const d = v.digests;
7380
- if (!Array.isArray(d) || d.length < 1 || d.length > APP_STATEMENT_LIMITS.revokeDigests) return "digests is not 1 to 64 digests";
7381
- if (!d.every(isAppHash) || new Set(d).size !== d.length) return "digests holds a bad or repeated digest";
7382
- } else if (!isSequence(v.upTo)) return "upTo is not a sequence";
7383
- if (v.reason !== void 0 && !isPlainLine(v.reason, APP_STATEMENT_LIMITS.reason)) return "reason is not one line of 200 characters";
7384
- return null;
7385
- }
7386
- /** Signs a revocation with the publisher's key. */
7387
- async function signAppRevocation(statement, signer) {
7388
- const problem = checkAppRevokeStatement(statement);
7389
- if (problem) throw new Error(`Not a revocation: ${problem}`);
7390
- if (toZ32(signer.publicKey) !== appRefPublisher(statement.app)) throw new Error("A revocation is signed by its app's publisher");
7391
- const { signature } = await signAppObject(APP_PREFIXES.revoke, statement, signer);
7392
- return {
7393
- statement,
7394
- signature
7395
- };
7396
- }
7397
- /** What a checked revocation is kept by: the SHA-256 of its canonical bytes (statement and signature), base64url. */
7398
- function appRevocationId(revocation) {
7399
- return toBase64Url(sha256(canonicalJsonBytes(revocation)));
7400
- }
7401
- /**
7402
- * Reads one signed revocation (a parsed value, from `ghostly-revoke.json` or a store index's `revoked`): its shape, then
7403
- * the signature under `ghostly-revoke/1` by the publisher its `app` names. One that `checked` holds verified before, so
7404
- * its signature is not checked again; one that verifies now is added to it.
7405
- */
7406
- function readAppRevocation(value, checked) {
7407
- const read = readAppRevocationShape(value);
7408
- if (!read.ok) return read;
7409
- const verified = verifyAppRevocation(read.revocation, checked);
7410
- return verified.ok ? read : verified;
7411
- }
7412
- /** The first half of `readAppRevocation`: the shape of a signed revocation, with no signature checked. */
7413
- function readAppRevocationShape(value) {
7414
- if (!exactKeys$1(value, ["statement", "signature"])) return {
7415
- ok: false,
7416
- reason: "bad-revocation",
7417
- detail: "Keys are not statement and signature"
7418
- };
7419
- const problem = checkAppRevokeStatement(value.statement);
7420
- if (problem) return {
7421
- ok: false,
7422
- reason: "bad-revocation",
7423
- detail: problem
7424
- };
7425
- if (!isAppSignature(value.signature)) return {
7426
- ok: false,
7427
- reason: "bad-signature-statement"
7428
- };
7429
- return {
7430
- ok: true,
7431
- revocation: {
7432
- statement: value.statement,
7433
- signature: value.signature
7434
- }
7435
- };
7436
- }
7437
- /** The second half: the signature of a revocation of the right shape, unless `checked` holds it. */
7438
- function verifyAppRevocation(revocation, checked) {
7439
- const id = checked && appRevocationId(revocation);
7440
- if (id !== void 0 && checked.has(id)) return { ok: true };
7441
- const verified = verifyAppSignature(APP_PREFIXES.revoke, canonicalJsonBytes(revocation.statement), revocation.signature, appRefPublisher(revocation.statement.app));
7442
- if (verified.ok && id !== void 0) checked.add(id);
7443
- return verified;
7444
- }
7445
- /**
7446
- * Reads `ghostly-revoke.json`: canonical bytes holding a list of signed revocations. One that does not verify refuses the
7447
- * file, as a store index's `revoked` does.
7448
- */
7449
- function readAppRevocations(bytes) {
7450
- const read = readCanonicalJson(bytes);
7451
- if (!read.ok) return read;
7452
- if (!Array.isArray(read.value) || read.value.length > APP_STATEMENT_LIMITS.revocations) return {
7453
- ok: false,
7454
- reason: "bad-revocation",
7455
- detail: "Not a list of at most 4096 revocations"
7456
- };
7457
- const revocations = [];
7458
- for (const entry of read.value) {
7459
- const one = readAppRevocation(entry);
7460
- if (!one.ok) return one;
7461
- revocations.push(one.revocation);
7462
- }
7463
- return {
7464
- ok: true,
7465
- revocations
7466
- };
7467
- }
7468
- //#endregion
7469
- //#region ../core/src/messageTime.ts
7470
- /**
7471
- * 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
7472
- * times): clocks drift, but a time past this is the peer's choice, not its clock.
7473
- */
7474
- var MESSAGE_CLOCK_SKEW_MS = 3e5;
7475
- /**
7476
- * The time a line of a group's history is kept under (a membership change, a rename): what its commit says, but at
7477
- * most `MESSAGE_CLOCK_SKEW_MS` past this clock, and now when it is not a positive number. Messages do not use it:
7478
- * they are placed where they arrive (below).
7479
- */
7480
- function receivedTimestamp(ts, now = Date.now()) {
7481
- if (!Number.isFinite(ts) || ts <= 0) return now;
7482
- return Math.min(ts, now + MESSAGE_CLOCK_SKEW_MS);
7483
- }
7484
- /** The latest moment a date can hold. */
7485
- var MAX_DATE_MS = 864e13;
7486
- /** The time a sender says, as it is kept: a positive time a date can hold, or nothing. */
7487
- function claimedTime(ts) {
7488
- return typeof ts === "number" && Number.isFinite(ts) && ts > 0 && ts <= MAX_DATE_MS ? ts : void 0;
7489
- }
7490
- /**
7491
- * The place of a row stored now: this clock, and past the last place given in its chat, so rows that come in the
7492
- * same millisecond (a hold pickup, a group's catch-up) keep the order they came in.
7493
- */
7494
- function arrivalKey(now, last = 0) {
7495
- return Math.max(now, last + 1);
7496
- }
7497
- /** The time a message shows: what its sender said, never later than when it came. A row of mine shows its own time. */
7498
- function shownTime(message) {
7499
- return message.sentAt === void 0 ? message.timestamp : Math.min(message.sentAt, message.timestamp);
7500
- }
7501
- /**
7502
- * When something a peer dated happened (an edit), for this device: what the peer says, now at the latest, and now
7503
- * when it says nothing a date can hold.
7504
- */
7505
- function heardTime(ts, now = Date.now()) {
7506
- return Math.min(claimedTime(ts) ?? now, now);
7507
- }
7508
- //#endregion
7509
- //#region ../core/src/statusCards.ts
7510
- var STATUS_CARD_LIMITS = {
7511
- /** A card's JSON, as sent (UTF-8 bytes): with the fallback text it stays well inside every message bound. */
7512
- bytes: 8192,
7513
- /** The sender's id for the card: stable across its updates. */
7514
- id: 64,
7515
- title: 120,
7516
- /** The current step, a list item, a run's summary. */
7517
- line: 200,
7518
- branch: 120,
7519
- schedule: 80,
7520
- cron: 64,
7521
- linkLabel: 40,
7522
- url: 512,
7523
- items: 20,
7524
- links: 4,
7525
- runs: 10,
7526
- /** Any count on a card (a PR's number, lines, files; steps). */
7527
- count: 1e9,
7528
- /** How far ahead a routine's next run may be. */
7529
- nextRunMs: 316224e5,
7530
- /** Edits a message with a card takes (a text takes `MAX_EDITS_PER_MESSAGE`): a bot updates a long task often. */
7531
- edits: 5e3,
7532
- /** Labels on a task, and a label's length in characters. */
7533
- tags: 3,
7534
- tag: 24,
7535
- /** Buttons on a message (WISP 406 · Message Buttons). */
7536
- buttons: 6,
7537
- /** A button's id: what a press names. */
7538
- buttonId: 32,
7539
- /** A button's label, in characters. */
7540
- buttonLabel: 40,
7541
- /** An app card's title and version, in characters (WISP 405 § An app): the manifest's bounds. */
7542
- appTitle: 40,
7543
- appVersion: 32,
7544
- /** A usage card's label ("Claude") and account, in characters (WISP 405 § Usage). */
7545
- usageLabel: 24,
7546
- /** A usage window's name ("5 h", "week"), in characters. */
7547
- usageWindow: 16,
7548
- /** Windows beside a usage card's own, shown in its details. */
7549
- usageWindows: 3
7550
- };
7551
- /** How a button looks: `primary` is the one highlighted, `danger` warns; `neutral` when none is said. */
7552
- var BUTTON_STYLES = [
7553
- "primary",
7554
- "neutral",
7555
- "danger"
7556
- ];
7557
- var TASK_STATUSES = [
7558
- "queued",
7559
- "running",
7560
- "blocked",
7561
- "done",
7562
- "failed",
7563
- "cancelled"
7564
- ];
7565
- /** A task that is still going: shown first, and counted on the chat's Tasks button. */
7566
- var ACTIVE_TASK_STATUSES = [
7567
- "queued",
7568
- "running",
7569
- "blocked"
7570
- ];
7571
- var ITEM_STATES = [
7572
- "pending",
7573
- "running",
7574
- "done",
7575
- "failed",
7576
- "skipped"
7577
- ];
7578
- var ROUTINE_STATES = ["active", "paused"];
7579
- var RUN_RESULTS = [
7580
- "ok",
7581
- "failed",
7582
- "skipped"
7583
- ];
7584
- /**
7585
- * Where a task's pull request stands: `draft` (not ready for review), `open` (ready for review), `merged`, `closed`
7586
- * (closed without a merge). A reader that does not know the field shows the pull request as before.
7587
- */
7588
- var PR_STATES = [
7589
- "draft",
7590
- "open",
7591
- "merged",
7592
- "closed"
7593
- ];
7594
- /** How a pull request's checks stand. */
7595
- var PR_CHECKS = [
7596
- "passing",
7597
- "failing",
7598
- "pending"
7599
- ];
7600
- var ID$4 = /^[A-Za-z0-9_.:][A-Za-z0-9_.:-]{0,63}$/;
7601
- /** A button's id: 1 to 32 of `A-Z a-z 0-9 _ . : -`, not starting with `-`. */
7602
- var BUTTON_ID = /^[A-Za-z0-9_.:][A-Za-z0-9_.:-]{0,31}$/;
7603
- var CRON = /^[0-9A-Za-z*,/?#\- ]{1,64}$/;
7604
- /**
7605
- * Whether a typed answer is a button's label or id (WISP 406 · Message Buttons): ignoring case and spaces at the ends.
7606
- * Not the device's locale's case: the presser's and the author's apps must agree ("I" is not "ı" anywhere).
7607
- */
7608
- var sameButtonText = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
7609
- /**
7610
- * Two buttons a typed answer could not tell apart, or none: a label that reads as an earlier label (`label`), or a
7611
- * label that reads as another button's id (`id`), both as `sameButtonText` compares them. The labels as a reader keeps
7612
- * them (one line, cleaned). `at` is the button that clashes, `with` the one it clashes with.
7613
- */
7614
- function buttonLabelClash(buttons) {
7615
- const labels = buttons.map((button) => isObject$4(button) ? cardLine(button.label, Infinity) : void 0);
7616
- const ids = buttons.map((button) => isObject$4(button) && typeof button.id === "string" ? button.id : void 0);
7617
- for (const [at, label] of labels.entries()) {
7618
- if (!label) continue;
7619
- const twin = labels.findIndex((other, i) => i < at && !!other && sameButtonText(other, label));
7620
- if (twin !== -1) return {
7621
- at,
7622
- with: twin,
7623
- kind: "label"
7624
- };
7625
- const named = ids.findIndex((id, i) => i !== at && id !== void 0 && sameButtonText(id, label));
7626
- if (named !== -1) return {
7627
- at,
7628
- with: named,
7629
- kind: "id"
7630
- };
7631
- }
7632
- }
7633
- /** An app card's id, made from its reference: the app's name, a dot, the first 16 characters of the publisher key. */
7634
- function appCardId(ref) {
7635
- return `${ref.slice(53)}.${ref.slice(0, 16)}`;
7636
- }
7637
- /** An app card's `sequence`: a whole number from 0 to 2^53 - 1. */
7638
- var appSequence = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
7639
- var isObject$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
7640
- /** One line of display text: runs of space (line breaks included) become one space, then the display rules for names. */
7641
- function cardLine(value, max) {
7642
- if (typeof value !== "string") return void 0;
7643
- return sanitizeDisplayText(value.replace(/\s+/gu, " "), max);
7644
- }
7645
- /**
7646
- * An https link a card may show: no credentials, a host, no spaces or control characters, at most
7647
- * `STATUS_CARD_LIMITS.url` characters. Returned as the parser writes it (a non-ASCII host in punycode).
7648
- */
7649
- function cardUrl(value) {
7650
- if (typeof value !== "string" || !value || value.length > STATUS_CARD_LIMITS.url || /[\s\p{Cc}\p{Cf}]/u.test(value)) return void 0;
7651
- let url;
7652
- try {
7653
- url = new URL(value);
7654
- } catch {
7655
- return;
7656
- }
7657
- if (url.protocol !== "https:" || !url.hostname || url.username || url.password) return void 0;
7658
- return url.href.length <= STATUS_CARD_LIMITS.url ? url.href : void 0;
7659
- }
7660
- var count = (value, min = 0) => typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= STATUS_CARD_LIMITS.count ? value : void 0;
7661
- /** A time on a card as a reader keeps it: a positive whole number, never later than `latest`. */
7662
- var time = (value, latest) => typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? Math.min(value, latest) : void 0;
7663
- var oneOf = (values, value) => typeof value === "string" && values.includes(value) ? value : void 0;
7664
- function readLinks(raw) {
7665
- if (!Array.isArray(raw)) return [];
7666
- const links = [];
7667
- for (const entry of raw.slice(0, STATUS_CARD_LIMITS.links)) {
7668
- if (!isObject$4(entry)) continue;
7669
- const url = cardUrl(entry.url);
7670
- if (!url) continue;
7671
- const label = cardLine(entry.label, STATUS_CARD_LIMITS.linkLabel);
7672
- links.push({
7673
- url,
7674
- ...label && { label }
7675
- });
7676
- }
7677
- return links;
7678
- }
7679
- /** A task's labels as a reader keeps them: the first `STATUS_CARD_LIMITS.tags` that hold, each one clean line, none twice. */
7680
- function readTags(raw) {
7681
- if (!Array.isArray(raw)) return [];
7682
- const tags = [];
7683
- for (const entry of raw) {
7684
- const tag = cardLine(entry, STATUS_CARD_LIMITS.tag);
7685
- if (tag && !tags.includes(tag)) tags.push(tag);
7686
- if (tags.length === STATUS_CARD_LIMITS.tags) break;
7687
- }
7688
- return tags;
7689
- }
7690
- function readRun(raw, now) {
7691
- if (!isObject$4(raw)) return void 0;
7692
- const at = time(raw.at, now + MESSAGE_CLOCK_SKEW_MS), result = oneOf(RUN_RESULTS, raw.result);
7693
- if (!at || !result) return void 0;
7694
- const summary = cardLine(raw.summary, STATUS_CARD_LIMITS.line);
7695
- return {
7696
- at,
7697
- result,
7698
- ...summary && { summary }
7699
- };
7700
- }
7701
- /** A percent as a reader keeps it: a finite number, rounded and clamped into 0 to 100. */
7702
- var percent = (value) => typeof value === "number" && Number.isFinite(value) ? Math.round(Math.min(100, Math.max(0, value))) : void 0;
7703
- /** A time ahead of the reader (a reset, a next run) as it keeps it: whole, positive, at most a year ahead. */
7704
- var ahead = (value, now) => typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= now + STATUS_CARD_LIMITS.nextRunMs ? value : void 0;
7705
- /** A usage card as a reader keeps it (WISP 405 § Usage): dropped without a percent left or a used of limit. */
7706
- function readUsage(raw, id, now) {
7707
- const left = percent(raw.left);
7708
- let used = count(raw.used), limit = count(raw.limit, 1);
7709
- if (used === void 0 || limit === void 0 || used > limit) used = limit = void 0;
7710
- if (left === void 0 && limit === void 0) return void 0;
7711
- const label = cardLine(raw.label, STATUS_CARD_LIMITS.usageLabel), account = cardLine(raw.account, STATUS_CARD_LIMITS.usageLabel);
7712
- const window = cardLine(raw.window, STATUS_CARD_LIMITS.usageWindow);
7713
- const resetsAt = ahead(raw.resetsAt, now), updatedAt = time(raw.updatedAt, now + MESSAGE_CLOCK_SKEW_MS);
7714
- const windows = [];
7715
- if (Array.isArray(raw.windows)) for (const entry of raw.windows.slice(0, STATUS_CARD_LIMITS.usageWindows)) {
7716
- if (!isObject$4(entry)) continue;
7717
- const name = cardLine(entry.window, STATUS_CARD_LIMITS.usageWindow), share = percent(entry.left);
7718
- if (!name || share === void 0) continue;
7719
- const at = ahead(entry.resetsAt, now);
7720
- windows.push({
7721
- window: name,
7722
- left: share,
7723
- ...at && { resetsAt: at }
7724
- });
7725
- }
7726
- return {
7727
- kind: "usage",
7728
- id,
7729
- ...left !== void 0 && { left },
7730
- ...used !== void 0 && limit !== void 0 && {
7731
- used,
7732
- limit
7733
- },
7734
- ...label && { label },
7735
- ...account && { account },
7736
- ...window && { window },
7737
- ...resetsAt && { resetsAt },
7738
- ...updatedAt && { updatedAt },
7739
- ...windows.length && { windows }
7740
- };
7741
- }
7742
- /** How much of a usage card's quota is left, 0 to 100: its own percent, else worked out from used of limit. */
7743
- function usageLeft(card) {
7744
- if (card.left !== void 0) return card.left;
7745
- return card.used !== void 0 && card.limit ? Math.round((card.limit - card.used) / card.limit * 100) : 0;
7746
- }
7747
- /** The bytes a card takes on the wire: what `STATUS_CARD_LIMITS.bytes` bounds. */
7748
- function statusCardBytes(card) {
7749
- try {
7750
- return utf8Encode(JSON.stringify(card) ?? "").length;
7751
- } catch {
7752
- return Infinity;
7753
- }
7754
- }
7755
- /**
7756
- * A card as a reader takes it from the wire (`sc`), or undefined when it cannot stand: not an object, past
7757
- * `STATUS_CARD_LIMITS.bytes`, a kind this app does not know, no valid id, title or name, status or state. Fields that do
7758
- * not hold are left out and lists are cut to their bound; times are kept no later than the reader's clock allows (a
7759
- * routine's next run up to a year ahead). The message is always kept: without a card it shows its text.
7760
- */
7761
- function readStatusCard(raw, now = Date.now()) {
7762
- if (!isObject$4(raw) || statusCardBytes(raw) > STATUS_CARD_LIMITS.bytes) return void 0;
7763
- const id = typeof raw.id === "string" && ID$4.test(raw.id) ? raw.id : void 0;
7764
- if (!id) return void 0;
7765
- if (raw.kind === "app") {
7766
- if (!isAppRef(raw.ref) || id !== appCardId(raw.ref) || !isAppHash(raw.digest) || !appSequence(raw.sequence)) return void 0;
7767
- const title = cardLine(raw.title, STATUS_CARD_LIMITS.appTitle);
7768
- if (!title) return void 0;
7769
- const version = cardLine(raw.version, STATUS_CARD_LIMITS.appVersion), url = cardUrl(raw.url);
7770
- return {
7771
- kind: "app",
7772
- id,
7773
- ref: raw.ref,
7774
- digest: raw.digest,
7775
- sequence: raw.sequence,
7776
- title,
7777
- ...version && { version },
7778
- ...url && { url },
7779
- ...raw.opened === true && { opened: true }
7780
- };
7781
- }
7782
- const latest = now + MESSAGE_CLOCK_SKEW_MS;
7783
- if (raw.kind === "usage") return readUsage(raw, id, now);
7784
- const links = readLinks(raw.links);
7785
- if (raw.kind === "task") {
7786
- const title = cardLine(raw.title, STATUS_CARD_LIMITS.title), status = oneOf(TASK_STATUSES, raw.status);
7787
- if (!title || !status) return void 0;
7788
- const progress = typeof raw.progress === "number" && Number.isFinite(raw.progress) ? Math.round(Math.min(100, Math.max(0, raw.progress))) : void 0;
7789
- let done = count(raw.done), total = count(raw.total, 1);
7790
- if (done === void 0 || total === void 0 || done > total) done = total = void 0;
7791
- const step = cardLine(raw.step, STATUS_CARD_LIMITS.line), branch = cardLine(raw.branch, STATUS_CARD_LIMITS.branch);
7792
- let pr;
7793
- if (isObject$4(raw.pr)) {
7794
- const url = cardUrl(raw.pr.url);
7795
- if (url) {
7796
- const number = count(raw.pr.number, 1), additions = count(raw.pr.additions), deletions = count(raw.pr.deletions), files = count(raw.pr.files);
7797
- const state = oneOf(PR_STATES, raw.pr.state), checks = oneOf(PR_CHECKS, raw.pr.checks);
7798
- pr = {
7799
- url,
7800
- ...number !== void 0 && { number },
7801
- ...additions !== void 0 && { additions },
7802
- ...deletions !== void 0 && { deletions },
7803
- ...files !== void 0 && { files },
7804
- ...state && { state },
7805
- ...checks && { checks }
7806
- };
7807
- }
7808
- }
7809
- const items = [];
7810
- if (Array.isArray(raw.items)) for (const entry of raw.items.slice(0, STATUS_CARD_LIMITS.items)) {
7811
- if (!isObject$4(entry)) continue;
7812
- const text = cardLine(entry.text, STATUS_CARD_LIMITS.line);
7813
- if (text) items.push({
7814
- text,
7815
- state: oneOf(ITEM_STATES, entry.state) ?? "pending"
7816
- });
7817
- }
7818
- const startedAt = time(raw.startedAt, latest), updatedAt = time(raw.updatedAt, latest);
7819
- const tags = readTags(raw.tags);
7820
- const parent = typeof raw.parent === "string" && ID$4.test(raw.parent) && raw.parent !== id ? raw.parent : void 0;
7821
- return {
7822
- kind: "task",
7823
- id,
7824
- title,
7825
- status,
7826
- ...progress !== void 0 && { progress },
7827
- ...done !== void 0 && total !== void 0 && {
7828
- done,
7829
- total
7830
- },
7831
- ...step && { step },
7832
- ...pr && { pr },
7833
- ...branch && { branch },
7834
- ...startedAt && { startedAt },
7835
- ...updatedAt && { updatedAt },
7836
- ...items.length && { items },
7837
- ...links.length && { links },
7838
- ...tags.length && { tags },
7839
- ...parent && { parent }
7840
- };
7841
- }
7842
- if (raw.kind === "routine") {
7843
- const name = cardLine(raw.name, STATUS_CARD_LIMITS.title), schedule = cardLine(raw.schedule, STATUS_CARD_LIMITS.schedule), state = oneOf(ROUTINE_STATES, raw.state);
7844
- if (!name || !schedule || !state) return void 0;
7845
- const cron = typeof raw.cron === "string" && CRON.test(raw.cron.trim()) ? raw.cron.trim() : void 0;
7846
- const lastRun = readRun(raw.lastRun, now);
7847
- const nextRunAt = typeof raw.nextRunAt === "number" && Number.isSafeInteger(raw.nextRunAt) && raw.nextRunAt > 0 && raw.nextRunAt <= now + STATUS_CARD_LIMITS.nextRunMs ? raw.nextRunAt : void 0;
7848
- const runs = [];
7849
- if (Array.isArray(raw.runs)) for (const entry of raw.runs.slice(0, STATUS_CARD_LIMITS.runs)) {
7850
- const run = readRun(entry, now);
7851
- if (run) runs.push(run);
7852
- }
7853
- return {
7854
- kind: "routine",
7855
- id,
7856
- name,
7857
- schedule,
7858
- state,
7859
- ...cron && { cron },
7860
- ...lastRun && { lastRun },
7861
- ...nextRunAt && { nextRunAt },
7862
- ...runs.length && { runs },
7863
- ...links.length && { links }
7864
- };
7865
- }
7866
- if (raw.kind === "buttons") {
7867
- if (!Array.isArray(raw.buttons)) return void 0;
7868
- const buttons = [], seen = /* @__PURE__ */ new Set();
7869
- for (const entry of raw.buttons.slice(0, STATUS_CARD_LIMITS.buttons)) {
7870
- if (!isObject$4(entry) || typeof entry.id !== "string" || !BUTTON_ID.test(entry.id) || seen.has(entry.id)) continue;
7871
- const label = cardLine(entry.label, STATUS_CARD_LIMITS.buttonLabel);
7872
- if (!label) continue;
7873
- seen.add(entry.id);
7874
- const style = oneOf(BUTTON_STYLES, entry.style);
7875
- buttons.push({
7876
- id: entry.id,
7877
- label,
7878
- ...style && style !== "neutral" && { style },
7879
- ...entry.once === true && { once: true }
7880
- });
7881
- }
7882
- if (!buttons.length) return void 0;
7883
- const chosen = typeof raw.chosen === "string" && seen.has(raw.chosen) ? raw.chosen : void 0;
7884
- return {
7885
- kind: "buttons",
7886
- id,
7887
- buttons,
7888
- ...chosen && { chosen },
7889
- ...raw.closed === true && { closed: true }
7890
- };
7891
- }
7892
- }
7893
- /**
7894
- * 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
7895
- * here instead (an unknown status, a link that is not https, a list past its bound, a line too long), so a bot learns
7896
- * at once and nothing it said is lost on the way. Line breaks and invisible characters are cleaned, as a reader would.
7897
- */
7898
- function checkStatusCard(raw, now = Date.now()) {
7899
- if (!isObject$4(raw)) return { error: "A card is an object" };
7900
- const bytes = statusCardBytes(raw);
7901
- if (bytes > STATUS_CARD_LIMITS.bytes) return { error: `The card takes ${bytes} bytes; at most ${STATUS_CARD_LIMITS.bytes}` };
7902
- 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" };
7903
- 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 -` };
7904
- const line = (field, value, max, required = false) => {
7905
- if (value === void 0 && !required) return null;
7906
- if (typeof value !== "string" || !cardLine(value, Infinity)) return `${field} is text`;
7907
- if ([...cardLine(value, Infinity)].length > max) return `${field} is at most ${max} characters`;
7908
- return null;
7909
- };
7910
- const url = (field, value) => cardUrl(value) ? null : `${field} must be an https link of at most ${STATUS_CARD_LIMITS.url} characters`;
7911
- const whole = (field, value, min = 0) => value === void 0 || count(value, min) !== void 0 ? null : `${field} is a whole number from ${min}`;
7912
- const errors = [];
7913
- if (raw.links !== void 0) {
7914
- 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}`);
7915
- else for (const [i, link] of raw.links.entries()) {
7916
- if (!isObject$4(link)) {
7917
- errors.push(`links[${i}] is an object`);
7918
- continue;
7919
- }
7920
- errors.push(url(`links[${i}].url`, link.url), line(`links[${i}].label`, link.label, STATUS_CARD_LIMITS.linkLabel));
7921
- }
7922
- }
7923
- 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`;
7924
- const reset = (field, value) => value === void 0 || ahead(value, now) !== void 0 ? null : `${field} is a time in milliseconds, within a year`;
7925
- if (raw.kind === "usage") {
7926
- if (raw.links !== void 0) errors.push("a usage card takes no links");
7927
- if (raw.left === void 0 && raw.used === void 0) errors.push("left (a percent) or used and limit");
7928
- errors.push(share("left", raw.left));
7929
- if (raw.used === void 0 !== (raw.limit === void 0)) errors.push("used and limit go together");
7930
- else if (raw.used !== void 0) {
7931
- errors.push(whole("used", raw.used), whole("limit", raw.limit, 1));
7932
- if (typeof raw.used === "number" && typeof raw.limit === "number" && raw.used > raw.limit) errors.push("used is at most limit");
7933
- }
7934
- 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));
7935
- 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");
7936
- if (raw.windows !== void 0) {
7937
- 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}`);
7938
- else for (const [i, entry] of raw.windows.entries()) {
7939
- if (!isObject$4(entry)) {
7940
- errors.push(`windows[${i}] is an object`);
7941
- continue;
7942
- }
7943
- errors.push(line(`windows[${i}].window`, entry.window, STATUS_CARD_LIMITS.usageWindow, true));
7944
- 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));
7945
- }
7946
- }
7947
- } else if (raw.kind === "app") {
7948
- if (raw.links !== void 0) errors.push("an app card takes no links");
7949
- if (!isAppRef(raw.ref)) errors.push("ref is <publisher key in z-base32>/<name>");
7950
- else if (raw.id !== appCardId(raw.ref)) errors.push(`id is ${appCardId(raw.ref)}, made from ref`);
7951
- if (!isAppHash(raw.digest)) errors.push("digest is a SHA-256 in base64url without padding");
7952
- if (!appSequence(raw.sequence)) errors.push("sequence is a whole number from 0 to 2^53 - 1");
7953
- errors.push(line("title", raw.title, STATUS_CARD_LIMITS.appTitle, true), line("version", raw.version, STATUS_CARD_LIMITS.appVersion));
7954
- if (raw.url !== void 0) errors.push(url("url", raw.url));
7955
- if (raw.opened !== void 0 && raw.opened !== true) errors.push("opened is true or left out");
7956
- } else if (raw.kind === "buttons") {
7957
- if (raw.links !== void 0) errors.push("buttons take no links");
7958
- 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}`);
7959
- else {
7960
- const ids = /* @__PURE__ */ new Set();
7961
- for (const [i, button] of raw.buttons.entries()) {
7962
- if (!isObject$4(button)) {
7963
- errors.push(`buttons[${i}] is an object`);
7964
- continue;
7965
- }
7966
- 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 -`);
7967
- else if (ids.has(button.id)) errors.push(`buttons[${i}].id "${button.id}" is used twice`);
7968
- else ids.add(button.id);
7969
- errors.push(line(`buttons[${i}].label`, button.label, STATUS_CARD_LIMITS.buttonLabel, true));
7970
- if (button.style !== void 0 && !oneOf(BUTTON_STYLES, button.style)) errors.push(`buttons[${i}].style is one of ${BUTTON_STYLES.join(", ")}`);
7971
- if (button.once !== void 0 && button.once !== true) errors.push(`buttons[${i}].once is true or left out`);
7972
- }
7973
- const clash = buttonLabelClash(raw.buttons);
7974
- 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)`);
7975
- if (raw.chosen !== void 0 && !(typeof raw.chosen === "string" && ids.has(raw.chosen))) errors.push("chosen names one of the buttons");
7976
- }
7977
- if (raw.closed !== void 0 && raw.closed !== true) errors.push("closed is true or left out");
7978
- } else if (raw.kind === "task") {
7979
- errors.push(line("title", raw.title, STATUS_CARD_LIMITS.title, true));
7980
- if (!oneOf(TASK_STATUSES, raw.status)) errors.push(`status is one of ${TASK_STATUSES.join(", ")}`);
7981
- 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");
7982
- if (raw.done === void 0 !== (raw.total === void 0)) errors.push("done and total go together");
7983
- else if (raw.done !== void 0) {
7984
- errors.push(whole("done", raw.done), whole("total", raw.total, 1));
7985
- if (typeof raw.done === "number" && typeof raw.total === "number" && raw.done > raw.total) errors.push("done is at most total");
7986
- }
7987
- errors.push(line("step", raw.step, STATUS_CARD_LIMITS.line), line("branch", raw.branch, STATUS_CARD_LIMITS.branch));
7988
- if (raw.pr !== void 0) {
7989
- if (!isObject$4(raw.pr)) errors.push("pr is an object");
7990
- else {
7991
- 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));
7992
- if (raw.pr.state !== void 0 && !oneOf(PR_STATES, raw.pr.state)) errors.push(`pr.state is one of ${PR_STATES.join(", ")}`);
7993
- if (raw.pr.checks !== void 0 && !oneOf(PR_CHECKS, raw.pr.checks)) errors.push(`pr.checks is one of ${PR_CHECKS.join(", ")}`);
7994
- }
7995
- }
7996
- if (raw.tags !== void 0) {
7997
- 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}`);
7998
- else {
7999
- const seen = /* @__PURE__ */ new Set();
8000
- for (const [i, tag] of raw.tags.entries()) {
8001
- const problem = line(`tags[${i}]`, tag, STATUS_CARD_LIMITS.tag, true);
8002
- errors.push(problem);
8003
- if (problem) continue;
8004
- const clean = cardLine(tag, Infinity);
8005
- if (seen.has(clean)) errors.push(`tags[${i}] ${JSON.stringify(clean)} is there twice`);
8006
- seen.add(clean);
8007
- }
8008
- }
8009
- }
8010
- if (raw.parent !== void 0) {
8011
- 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 -`);
8012
- else if (raw.parent === raw.id) errors.push("parent is another task's id, not its own");
8013
- }
8014
- for (const field of ["startedAt", "updatedAt"]) {
8015
- const value = raw[field];
8016
- 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`);
8017
- }
8018
- if (raw.items !== void 0) {
8019
- 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}`);
8020
- else for (const [i, item] of raw.items.entries()) {
8021
- if (!isObject$4(item)) {
8022
- errors.push(`items[${i}] is an object`);
8023
- continue;
8024
- }
8025
- errors.push(line(`items[${i}].text`, item.text, STATUS_CARD_LIMITS.line, true));
8026
- if (item.state !== void 0 && !oneOf(ITEM_STATES, item.state)) errors.push(`items[${i}].state is one of ${ITEM_STATES.join(", ")}`);
8027
- }
8028
- }
8029
- } else {
8030
- errors.push(line("name", raw.name, STATUS_CARD_LIMITS.title, true), line("schedule", raw.schedule, STATUS_CARD_LIMITS.schedule, true));
8031
- if (!oneOf(ROUTINE_STATES, raw.state)) errors.push(`state is one of ${ROUTINE_STATES.join(", ")}`);
8032
- 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`);
8033
- 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");
8034
- const run = (field, value) => {
8035
- if (!isObject$4(value)) return errors.push(`${field} is an object`);
8036
- 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`);
8037
- if (!oneOf(RUN_RESULTS, value.result)) errors.push(`${field}.result is one of ${RUN_RESULTS.join(", ")}`);
8038
- errors.push(line(`${field}.summary`, value.summary, STATUS_CARD_LIMITS.line));
8039
- };
8040
- if (raw.lastRun !== void 0) run("lastRun", raw.lastRun);
8041
- if (raw.runs !== void 0) {
8042
- 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}`);
8043
- else raw.runs.forEach((entry, i) => run(`runs[${i}]`, entry));
8044
- }
8045
- }
8046
- const error = errors.find((e) => typeof e === "string");
8047
- if (error) return { error };
8048
- const card = readStatusCard(raw, now);
8049
- return card ? { card } : { error: "The card does not hold" };
8050
- }
8051
- /** The progress a task shows, 0 to 100: its own, else its steps done of total; undefined when it says neither. */
8052
- function taskProgress(card) {
8053
- if (card.progress !== void 0) return card.progress;
8054
- return card.done !== void 0 && card.total ? Math.round(card.done / card.total * 100) : void 0;
8055
- }
8056
- var TASK_MARK = {
8057
- queued: "⏳",
8058
- running: "🔄",
8059
- blocked: "⛔",
8060
- done: "✅",
8061
- failed: "❌",
8062
- cancelled: "🚫"
8063
- };
8064
- var STATUS_WORD = {
8065
- queued: "Queued",
8066
- running: "Running",
8067
- blocked: "Blocked",
8068
- done: "Done",
8069
- failed: "Failed",
8070
- cancelled: "Cancelled"
8071
- };
8072
- var RESULT_WORD = {
8073
- ok: "ok",
8074
- failed: "failed",
8075
- skipped: "skipped"
8076
- };
8077
- /** A time as the fallback text says it: in UTC, to the minute ("2026-09-30 01:00 UTC"). */
8078
- function cardTimeUtc(ms) {
8079
- return `${new Date(ms).toISOString().slice(0, 16).replace("T", " ")} UTC`;
8080
- }
8081
- /**
8082
- * The text a card's message carries: what an app without cards shows, the DHT floor carries, the chat list previews
8083
- * and search finds. Plain lines, the first naming the card, no bars or layout that would read badly elsewhere.
8084
- */
8085
- function statusCardText(card) {
8086
- if (card.kind === "app") {
8087
- const named = `${card.title}${card.version ? ` ${card.version}` : ""}`;
8088
- return [card.opened ? `🧩 Opened ${named} in this chat (Ghostly app)` : `🧩 ${named} (Ghostly app)`, ...card.url ? [card.url] : []].join("\n");
8089
- }
8090
- if (card.kind === "usage") {
8091
- const left = `${usageLeft(card)}% left${card.window ? ` (${card.window})` : ""}`;
8092
- return [
8093
- `📊 ${card.label ?? "Usage"}`,
8094
- ...card.account ? [card.account] : [],
8095
- left,
8096
- ...card.used !== void 0 && card.limit !== void 0 ? [`${card.used} of ${card.limit} used`] : [],
8097
- ...card.resetsAt ? [`resets ${cardTimeUtc(card.resetsAt)}`] : [],
8098
- ...(card.windows ?? []).map((w) => `${w.window} ${w.left}%`)
8099
- ].join(" · ");
8100
- }
8101
- if (card.kind === "buttons") return `Reply: ${card.buttons.map((b) => b.label).join(" / ")}`;
8102
- if (card.kind === "task") {
8103
- const progress = taskProgress(card);
8104
- const state = [
8105
- STATUS_WORD[card.status],
8106
- ...progress !== void 0 ? [`${progress}%`] : [],
8107
- ...card.done !== void 0 && card.total ? [`${card.done} of ${card.total} steps`] : []
8108
- ].join(" · ");
8109
- const standing = card.pr && [...card.pr.state ? [card.pr.state] : [], ...card.pr.checks ? [`checks ${card.pr.checks}`] : []].join(", ");
8110
- const pr = card.pr && [
8111
- `PR${card.pr.number !== void 0 ? ` #${card.pr.number}` : ""}`,
8112
- ...card.pr.additions !== void 0 || card.pr.deletions !== void 0 ? [`+${card.pr.additions ?? 0} -${card.pr.deletions ?? 0}`] : [],
8113
- ...standing ? [`(${standing})`] : []
8114
- ].join(" ") + `: ${card.pr.url}`;
8115
- const now = card.step && ACTIVE_TASK_STATUSES.includes(card.status) ? [`Now: ${card.step}`] : [];
8116
- return [
8117
- `${TASK_MARK[card.status]} ${card.title}`,
8118
- state,
8119
- ...now,
8120
- ...pr ? [pr] : [],
8121
- ...card.tags?.length ? [`Tags: ${card.tags.join(", ")}`] : [],
8122
- ...card.parent ? [`Part of: ${card.parent}`] : []
8123
- ].join("\n");
8124
- }
8125
- const last = card.lastRun && `Last run: ${RESULT_WORD[card.lastRun.result]}, ${cardTimeUtc(card.lastRun.at)}${card.lastRun.summary ? ` · ${card.lastRun.summary}` : ""}`;
8126
- return [
8127
- `🔁 ${card.name}`,
8128
- `${card.schedule} · ${card.state === "active" ? "active" : "paused"}`,
8129
- ...last ? [last] : [],
8130
- ...card.nextRunAt && card.state === "active" ? [`Next run: ${cardTimeUtc(card.nextRunAt)}`] : []
8131
- ].join("\n");
8132
- }
8133
- /** Whether a message's edit number holds: a text's bound, or a card message's when the edit carries a card. */
8134
- function cardEditNumber(e) {
8135
- return Number.isSafeInteger(e) && e >= 1 && e <= STATUS_CARD_LIMITS.edits;
8136
- }
8137
- //#endregion
8138
- //#region ../core/src/replies.ts
8139
- var REPLY_LIMITS = {
8140
- /** Code points of the line of the original, an ellipsis included when it was cut. */
8141
- snippet: 120,
8142
- /** Characters of the original's id. */
8143
- id: 128,
8144
- /** Characters of a line as it arrives, before it is cleaned: anything longer is not a line of a message. */
8145
- raw: 2048
8146
- };
8147
- /** A paired chat's message id, a payment id or a file's wire id; a group message's `sender:epoch:seq`. */
8148
- var REPLY_ID = /^[A-Za-z0-9_:.-]{1,128}$/;
8149
- /** The two authors a paired chat has, from the reply author's side. */
8150
- var PAIRED_REPLY_AUTHORS = ["sender", "recipient"];
8151
- /** A picture sent inline has no line worth quoting. */
8152
- var INLINE_PICTURE = /^data:image\//i;
8153
- /**
8154
- * A line of a message to quote: plain text on one line (line breaks and runs of spaces become one space), without
8155
- * control, invisible or direction characters (WISP 400, as for names), at most `REPLY_LIMITS.snippet` code points,
8156
- * the last of them an ellipsis when it was cut.
8157
- */
8158
- function replySnippet(text) {
8159
- if (INLINE_PICTURE.test(text.trimStart())) return "🖼️ Picture";
8160
- const clean = sanitizeDisplayText(text.slice(0, REPLY_LIMITS.raw * 2).replace(/\s+/gu, " "), REPLY_LIMITS.snippet + 1) ?? "";
8161
- const points = [...clean];
8162
- return points.length > REPLY_LIMITS.snippet ? `${points.slice(0, REPLY_LIMITS.snippet - 1).join("").trimEnd()}…` : clean;
8163
- }
8164
- /**
8165
- * A reply as a receiver takes it: an object with an id of this chat's shape, a line (cleaned and cut again here,
8166
- * whatever the sender did) and an author `author` accepts. Anything else is no reply: the message stays, without it.
8167
- */
8168
- function readReply(raw, author) {
8169
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
8170
- const { i, s, f, b } = raw;
8171
- if (typeof i !== "string" || !REPLY_ID.test(i)) return void 0;
8172
- if (typeof s !== "string" || s.length > REPLY_LIMITS.raw) return void 0;
8173
- if (typeof f !== "string" || !author(f)) return void 0;
8174
- return {
8175
- i,
8176
- s: replySnippet(s),
8177
- f,
8178
- ...typeof b === "string" && BUTTON_ID.test(b) && { b }
8179
- };
8180
- }
8181
- /** Who may have written the original in a paired chat. */
8182
- var pairedReplyAuthor = (f) => PAIRED_REPLY_AUTHORS.includes(f);
8183
- /** Who may have written the original in a group: a member key (whether they are still a member is the reader's to see). */
8184
- var groupReplyAuthor = (f) => MEMBER_KEY.test(f);
8185
- /** Only the wire fields, in a fixed order, the line cleaned: what the sender puts on a frame. */
8186
- var wireReply = (reply) => ({
8187
- i: reply.i,
8188
- s: replySnippet(reply.s),
8189
- f: reply.f,
8190
- ...reply.b && { b: reply.b }
8191
- });
8192
- //#endregion
8193
- //#region ../core/src/reactions.ts
6865
+ /** Who may have written the original in a paired chat. */
6866
+ var pairedReplyAuthor = (f) => PAIRED_REPLY_AUTHORS.includes(f);
6867
+ /** Who may have written the original in a group: a member key (whether they are still a member is the reader's to see). */
6868
+ var groupReplyAuthor = (f) => MEMBER_KEY.test(f);
6869
+ /** Only the wire fields, in a fixed order, the line cleaned: what the sender puts on a frame. */
6870
+ var wireReply = (reply) => ({
6871
+ i: reply.i,
6872
+ s: replySnippet(reply.s),
6873
+ f: reply.f,
6874
+ ...reply.b && { b: reply.b }
6875
+ });
6876
+ //#endregion
6877
+ //#region ../core/src/reactions.ts
8194
6878
  /**
8195
6879
  * Reactions to messages (WISP 400 § Reactions): one emoji per person per message, like WhatsApp. Whoever reacts says
8196
6880
  * which message (the id both sides know it by, as a reply names it), the emoji, and a number of its own that only
@@ -8533,7 +7217,7 @@ function previewImageSize(value) {
8533
7217
  if (binary.length > LINK_PREVIEW_LIMITS.imageBytes) return;
8534
7218
  const bytes = new Uint8Array(binary.length);
8535
7219
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
8536
- return jpegSize$1(bytes);
7220
+ return jpegSize(bytes);
8537
7221
  }
8538
7222
  /** The thumbnail if it is one Ghostly shows: a JPEG data URL within the limits whose header declares a sane size. */
8539
7223
  function sanitizePreviewImage(value) {
@@ -8672,521 +7356,27 @@ var RateWindow = class {
8672
7356
  recent = [];
8673
7357
  constructor(limit, windowMs, now = Date.now) {
8674
7358
  this.limit = limit;
8675
- this.windowMs = windowMs;
8676
- this.now = now;
8677
- }
8678
- /** Counts one now; false (and not counted) when the window is full. */
8679
- take() {
8680
- const now = this.now();
8681
- this.recent = this.recent.filter((at) => now - at < this.windowMs);
8682
- if (this.recent.length >= this.limit) return false;
8683
- this.recent.push(now);
8684
- return true;
8685
- }
8686
- /** Milliseconds until the window has room again; 0 when it has room now. */
8687
- wait() {
8688
- const now = this.now();
8689
- this.recent = this.recent.filter((at) => now - at < this.windowMs);
8690
- return this.recent.length < this.limit ? 0 : this.recent[0] + this.windowMs - now;
8691
- }
8692
- reset() {
8693
- this.recent = [];
8694
- }
8695
- };
8696
- //#endregion
8697
- //#region ../core/src/video.ts
8698
- var VIDEO_LIMITS = {
8699
- /** The poster's bytes: with the rest of an offer, a frame stays far below the 60 KiB a session carries. */
8700
- maxPosterBytes: 12288,
8701
- /** The poster's longest side, in pixels, as the sender draws it. */
8702
- posterEdge: 320,
8703
- /** Ten hours: longer is a mistake, not a video. */
8704
- maxDurationMs: 36e6,
8705
- /** Pixels on either side (8K and a bit). */
8706
- maxEdge: 16384
8707
- };
8708
- /**
8709
- * Video a player may be handed as it is. None of these runs anything in the page's origin, so received bytes
8710
- * of these types are served with their type (see `safeBlobType`): WebKit will not play what is typed as bytes.
8711
- */
8712
- var PLAYABLE_VIDEO = /^video\/(mp4|webm|quicktime|ogg|x-m4v)$/;
8713
- /** Whether a file is a video, by its type: shown in a video bubble whether or not it came with a description. */
8714
- function isVideoMime(mime) {
8715
- return mime.split(";")[0].trim().toLowerCase().startsWith("video/");
8716
- }
8717
- var BASE64URL = /^[A-Za-z0-9_-]+$/;
8718
- var JPEG_START = [
8719
- 255,
8720
- 216,
8721
- 255
8722
- ];
8723
- /**
8724
- * A peer's `video` description, or undefined when it is not one. It never refuses the file. A poster that is
8725
- * too large or not a JPEG is dropped on its own; the length and size are kept.
8726
- */
8727
- function parseVideoMeta(value, mime) {
8728
- if (mime !== void 0 && !isVideoMime(mime)) return void 0;
8729
- if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
8730
- const { duration, width, height, poster } = value;
8731
- if (typeof duration !== "number" || !Number.isSafeInteger(duration) || duration <= 0 || duration > VIDEO_LIMITS.maxDurationMs) return void 0;
8732
- const edge = (n) => typeof n === "number" && Number.isSafeInteger(n) && n > 0 && n <= VIDEO_LIMITS.maxEdge;
8733
- if (!edge(width) || !edge(height)) return void 0;
8734
- const meta = {
8735
- duration,
8736
- width,
8737
- height
8738
- };
8739
- if (typeof poster === "string" && poster.length <= Math.ceil(VIDEO_LIMITS.maxPosterBytes * 4 / 3) && BASE64URL.test(poster)) try {
8740
- const bytes = fromBase64Url(poster);
8741
- if (bytes.length <= VIDEO_LIMITS.maxPosterBytes && JPEG_START.every((b, i) => bytes[i] === b)) meta.poster = poster;
8742
- } catch {}
8743
- return meta;
8744
- }
8745
- /** `0:07`, `12:45`, `1:02:03`: as a player shows a video's length. */
8746
- function formatVideoDuration(ms) {
8747
- const total = Math.max(0, Math.floor((Number.isFinite(ms) ? ms : 0) / 1e3));
8748
- const hours = Math.floor(total / 3600), minutes = Math.floor(total % 3600 / 60), seconds = String(total % 60).padStart(2, "0");
8749
- return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
8750
- }
8751
- //#endregion
8752
- //#region ../core/src/voice.ts
8753
- var VOICE_LIMITS = {
8754
- /** Fifteen minutes: at the bitrate recorded (32 kbit/s) about 3.6 MB, well inside a held item's 8 MiB. */
8755
- maxDurationMs: 9e5,
8756
- /** Bars drawn for a recording. */
8757
- bars: 64,
8758
- /** Bars accepted from a peer. */
8759
- maxBars: 128,
8760
- /** Asked of the recorder; opus speech is clear well below it. */
8761
- audioBitsPerSecond: 32e3
8762
- };
8763
- /**
8764
- * Audio a player may be handed as it is. None of these runs anything in the page's origin,
8765
- * so received bytes of these types are served with their type (see `safeBlobType`).
8766
- */
8767
- var PLAYABLE_AUDIO = /^audio\/(webm|ogg|opus|mp4|mpeg|mp3|aac|x-m4a|wav|x-wav|wave|flac|x-flac)$/;
8768
- /** The part of a type before its parameters, in lower case: `audio/webm;codecs=opus` → `audio/webm`. */
8769
- function baseMime(mime) {
8770
- return mime.split(";")[0].trim().toLowerCase();
8771
- }
8772
- /**
8773
- * A peer's `voice` description, or undefined when it is not one. It never refuses the file:
8774
- * a bad description only means the file is shown as a file.
8775
- */
8776
- function parseVoiceMeta(value, mime) {
8777
- if (mime !== void 0 && !PLAYABLE_AUDIO.test(baseMime(mime))) return void 0;
8778
- if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
8779
- const { duration, peaks } = value;
8780
- if (typeof duration !== "number" || !Number.isSafeInteger(duration) || duration <= 0 || duration > VOICE_LIMITS.maxDurationMs + 5e3) return void 0;
8781
- if (!Array.isArray(peaks) || peaks.length === 0 || peaks.length > VOICE_LIMITS.maxBars) return void 0;
8782
- if (!peaks.every((peak) => typeof peak === "number" && Number.isInteger(peak) && peak >= 0 && peak <= 255)) return void 0;
8783
- return {
8784
- duration,
8785
- peaks: [...peaks]
8786
- };
8787
- }
8788
- /**
8789
- * How the level is metered while recording (apps/ui/src/lib/voiceRecorder.ts): every `intervalMs`, the RMS of the last
8790
- * `window` samples an AnalyserNode holds (at a 48 kHz context, about 21 ms of sound).
8791
- */
8792
- var VOICE_METER = {
8793
- intervalMs: 50,
8794
- window: 1024,
8795
- windowRate: 48e3
8796
- };
8797
- /** One reading of the meter, 0-1: the RMS of the samples, lifted because speech sits low on a linear scale. */
8798
- function voiceLevel(samples) {
8799
- if (samples.length === 0) return 0;
8800
- let sum = 0;
8801
- for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i];
8802
- return Math.min(1, Math.sqrt(sum / samples.length) * 4);
8803
- }
8804
- /**
8805
- * The readings the recorder would have taken of this sound (mono, -1 to 1), for a recording made elsewhere: one
8806
- * every `intervalMs` of it, each over the window that ends there, as long in time as the analyser's.
8807
- */
8808
- function voiceLevelsOf(mono, sampleRate) {
8809
- if (!(sampleRate > 0) || mono.length === 0) return [];
8810
- const window = Math.max(1, Math.round(VOICE_METER.window * sampleRate / VOICE_METER.windowRate));
8811
- const levels = [];
8812
- for (let step = 1;; step++) {
8813
- const end = Math.round(step * VOICE_METER.intervalMs * sampleRate / 1e3);
8814
- if (end > mono.length) break;
8815
- levels.push(voiceLevel(mono.subarray(Math.max(0, end - window), end)));
8816
- }
8817
- return levels;
8818
- }
8819
- /** The waveform sent with a recording made elsewhere, drawn as the recorder draws its own. */
8820
- function voicePeaksOf(mono, sampleRate, bars = VOICE_LIMITS.bars) {
8821
- return downsamplePeaks(voiceLevelsOf(mono, sampleRate), bars);
8822
- }
8823
- /** Below this a recording is silence, and is drawn flat rather than stretched to fill the bars. */
8824
- var QUIET = .02;
8825
- /**
8826
- * Bars from the loudness measured while recording (RMS, 0-1, oldest first): the loudest
8827
- * sample of each stretch, scaled so the loudest bar is full height.
8828
- */
8829
- function downsamplePeaks(levels, bars = VOICE_LIMITS.bars) {
8830
- const count = Math.max(1, Math.min(VOICE_LIMITS.maxBars, Math.floor(bars)));
8831
- const clean = Array.from(levels, (level) => Number.isFinite(level) ? Math.min(1, Math.max(0, level)) : 0);
8832
- if (clean.length === 0) return new Array(count).fill(0);
8833
- const loudest = Math.max(QUIET, ...clean);
8834
- const out = [];
8835
- for (let bar = 0; bar < count; bar++) {
8836
- const from = Math.floor(bar * clean.length / count);
8837
- const to = Math.max(from + 1, Math.floor((bar + 1) * clean.length / count));
8838
- let peak = 0;
8839
- for (let i = from; i < to && i < clean.length; i++) peak = Math.max(peak, clean[i]);
8840
- out.push(Math.round(peak / loudest * 255));
8841
- }
8842
- return out;
8843
- }
8844
- /** `0:07`, `12:45`: minutes and seconds, as a player shows them. */
8845
- function formatVoiceDuration(ms) {
8846
- const total = Math.max(0, Math.floor((Number.isFinite(ms) ? ms : 0) / 1e3));
8847
- return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
8848
- }
8849
- /** The line a file message shows in the chat list and in notifications. */
8850
- function fileMessageText(file) {
8851
- if (file.voice) return `🎤 Voice message (${formatVoiceDuration(file.voice.duration)})`;
8852
- if (file.video) return `🎬 Video (${formatVideoDuration(file.video.duration)})`;
8853
- return `📎 ${file.name}`;
8854
- }
8855
- //#endregion
8856
- //#region ../core/src/files.ts
8857
- var FILE_ID = /^[A-Za-z0-9_-]{8,64}$/;
8858
- var MIME = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,63}$/i;
8859
- var MAX_NAME_LENGTH = 200;
8860
- /**
8861
- * Invisible and direction-changing characters: bidi overrides turn
8862
- * "invoice\u202Efdp.exe" into what reads as "invoiceexe.pdf", zero-width ones hide
8863
- * text. Cf covers both; line and paragraph separators break the layout.
8864
- */
8865
- var INVISIBLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
8866
- /**
8867
- * Names Windows keeps for devices, whatever the case, with any extension, and with spaces before the dot:
8868
- * "CON", "nul.txt", "COM1 .log" open a device there, not a file.
8869
- */
8870
- var WINDOWS_DEVICE = /^(CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[0-9¹²³]|LPT[0-9¹²³])\s*(\.|$)/i;
8871
- /**
8872
- * At most `MAX_NAME_LENGTH` characters. A longer name loses the end of its stem, not its extension (up to 15
8873
- * characters after the last dot): "…report.pdf" stays a PDF to the system it is saved on.
8874
- */
8875
- function shorten(name) {
8876
- const chars = [...name];
8877
- if (chars.length <= MAX_NAME_LENGTH) return name;
8878
- const extension = [.../\.[^.\s]{1,15}$/u.exec(name)?.[0] ?? ""];
8879
- return chars.slice(0, MAX_NAME_LENGTH - extension.length).join("").trimEnd() + extension.join("");
8880
- }
8881
- /** A file name is display text and a download suggestion, never a path. */
8882
- function sanitizeFileName(name) {
8883
- const clean = shorten(name.replace(INVISIBLE, "").replace(/[/\\:]/g, "").replace(/^[\s.]+/, "").trim());
8884
- if (WINDOWS_DEVICE.test(clean)) return shorten("_" + clean);
8885
- return clean || "file";
8886
- }
8887
- function sanitizeMime(mime) {
8888
- return MIME.test(mime) ? mime.toLowerCase() : "application/octet-stream";
8889
- }
8890
- /** A size for people: B, KB, MB, GB, TB (powers of 1024), one decimal from KB up. */
8891
- function formatFileSize(bytes) {
8892
- if (bytes < 1024) return `${bytes} B`;
8893
- const units = [
8894
- "KB",
8895
- "MB",
8896
- "GB",
8897
- "TB"
8898
- ];
8899
- let value = bytes / 1024, unit = 0;
8900
- while (value >= 1024 && unit < units.length - 1) {
8901
- value /= 1024;
8902
- unit++;
8903
- }
8904
- return `${value.toFixed(1)} ${units[unit]}`;
8905
- }
8906
- /** Raster images a UI may show inline. SVG is left out on purpose: it can carry scripts. */
8907
- var PREVIEWABLE_IMAGE = /^image\/(png|jpe?g|gif|webp)$/;
8908
- /**
8909
- * The type received bytes are served with. The peer picks the announced type,
8910
- * and a blob: URL typed text/html or image/svg+xml would run in the app's
8911
- * origin, so anything but a previewable image or playable audio or video is opaque bytes.
8912
- * Audio and video keep their type because WebKit will not play what is typed as bytes.
8913
- */
8914
- function safeBlobType(mime) {
8915
- const clean = sanitizeMime(mime);
8916
- return PREVIEWABLE_IMAGE.test(clean) || PLAYABLE_AUDIO.test(clean) || PLAYABLE_VIDEO.test(clean) ? clean : "application/octet-stream";
8917
- }
8918
- var FileTransfers = class {
8919
- channel;
8920
- events;
8921
- nextStreamId = 1;
8922
- incoming = /* @__PURE__ */ new Map();
8923
- outgoing = /* @__PURE__ */ new Map();
8924
- constructor(channel, events) {
8925
- this.channel = channel;
8926
- this.events = events;
8927
- }
8928
- /** Streams a file to the peer. Resolves once every byte is handed to the channel. */
8929
- async send(file, source) {
8930
- if (file.size > LIMITS.maxFileBytes) throw new Error("File is too large");
8931
- const id = this.nextStreamId++;
8932
- const state = {
8933
- fileId: file.id,
8934
- cancelled: false
8935
- };
8936
- this.outgoing.set(id, state);
8937
- try {
8938
- this.channel.send(encodeControl({
8939
- t: "file",
8940
- id,
8941
- f: file.id,
8942
- ts: file.timestamp,
8943
- n: sanitizeFileName(file.name),
8944
- s: file.size,
8945
- m: sanitizeMime(file.mime)
8946
- }));
8947
- let sent = 0;
8948
- const counted = async function* () {
8949
- for await (const part of source) {
8950
- sent += part.length;
8951
- yield part;
8952
- this.events.onProgress?.(file.id, sent, "out");
8953
- }
8954
- }.call(this);
8955
- await sendBody(this.channel, CHUNK_KIND.fileBody, id, counted, () => state.cancelled);
8956
- if (state.cancelled) throw new Error("The peer refused the file");
8957
- if (sent !== file.size) throw new Error("File changed while it was being sent");
8958
- this.events.onComplete?.(file.id, "out");
8959
- } catch (error) {
8960
- if (!state.cancelled) try {
8961
- this.channel.send(encodeControl({
8962
- t: "rst",
8963
- id,
8964
- d: "f",
8965
- e: "aborted"
8966
- }));
8967
- } catch {}
8968
- this.events.onFailed?.(file.id, error instanceof Error ? error.message : String(error), "out");
8969
- throw error;
8970
- } finally {
8971
- this.outgoing.delete(id);
8972
- }
8973
- }
8974
- handleFile(frame) {
8975
- if (this.incoming.has(frame.id)) return;
8976
- const refuse = (reason) => {
8977
- try {
8978
- this.channel.send(encodeControl({
8979
- t: "rst",
8980
- id: frame.id,
8981
- d: "f",
8982
- e: reason
8983
- }));
8984
- } catch {}
8985
- };
8986
- if (!FILE_ID.test(frame.f)) return refuse("invalid file id");
8987
- if (frame.s > LIMITS.maxFileBytes) return refuse("file too large");
8988
- if (this.incoming.size >= LIMITS.maxIncomingFilesPerPeer) return refuse("too many transfers");
8989
- const file = {
8990
- id: frame.f,
8991
- name: sanitizeFileName(frame.n),
8992
- size: frame.s,
8993
- mime: sanitizeMime(frame.m),
8994
- timestamp: frame.ts
8995
- };
8996
- const sink = this.events.onIncoming(file);
8997
- if (!sink || typeof sink === "string") return refuse(sink || "refused");
8998
- const entry = {
8999
- file,
9000
- sink,
9001
- received: 0,
9002
- queue: Promise.resolve()
9003
- };
9004
- this.incoming.set(frame.id, entry);
9005
- this.armIdleTimer(frame.id, entry);
9006
- }
9007
- handleChunk(chunk) {
9008
- const entry = this.incoming.get(chunk.id);
9009
- if (!entry) return;
9010
- entry.received += chunk.payload.length;
9011
- if (entry.received > entry.file.size) return this.failIncoming(chunk.id, "more data than announced", true);
9012
- if (chunk.payload.length > 0) {
9013
- const payload = chunk.payload.slice();
9014
- entry.queue = entry.queue.then(() => entry.sink.write(payload));
9015
- this.events.onProgress?.(entry.file.id, entry.received, "in");
9016
- }
9017
- if (!chunk.end) return this.armIdleTimer(chunk.id, entry);
9018
- if (entry.received !== entry.file.size) return this.failIncoming(chunk.id, "incomplete file", false);
9019
- clearTimeout(entry.idleTimer);
9020
- this.incoming.delete(chunk.id);
9021
- entry.queue.then(() => entry.sink.close()).then(() => this.events.onComplete?.(entry.file.id, "in"), () => {
9022
- entry.sink.abort();
9023
- this.events.onFailed?.(entry.file.id, "could not store the file", "in");
9024
- });
9025
- }
9026
- handleReset(frame) {
9027
- if (this.incoming.has(frame.id)) this.failIncoming(frame.id, frame.e || "cancelled by the sender", false);
9028
- const sending = this.outgoing.get(frame.id);
9029
- if (sending) sending.cancelled = true;
9030
- }
9031
- /** The link went away: nothing in flight can finish. */
9032
- closeAll() {
9033
- for (const id of [...this.incoming.keys()]) this.failIncoming(id, "connection lost", false);
9034
- for (const sending of this.outgoing.values()) sending.cancelled = true;
9035
- }
9036
- armIdleTimer(id, entry) {
9037
- clearTimeout(entry.idleTimer);
9038
- entry.idleTimer = setTimeout(() => this.failIncoming(id, "transfer stalled", true), LIMITS.bodyIdleTimeoutMs);
9039
- }
9040
- failIncoming(id, reason, notifyPeer) {
9041
- const entry = this.incoming.get(id);
9042
- if (!entry) return;
9043
- clearTimeout(entry.idleTimer);
9044
- this.incoming.delete(id);
9045
- entry.sink.abort();
9046
- if (notifyPeer) try {
9047
- this.channel.send(encodeControl({
9048
- t: "rst",
9049
- id,
9050
- d: "f",
9051
- e: reason
9052
- }));
9053
- } catch {}
9054
- this.events.onFailed?.(entry.file.id, reason, "in");
9055
- }
9056
- };
9057
- //#endregion
9058
- //#region ../core/src/image.ts
9059
- var IMAGE_LIMITS = {
9060
- /** Pixels on either side: the most a JPEG can say, well past what anyone sends as a picture. */
9061
- maxEdge: 65535,
9062
- /** How much of a file is read for its size: past a JPEG's EXIF, thumbnails and colour profile. */
9063
- headBytes: 262144
9064
- };
9065
- /** Whether a file is a picture, by its type. */
9066
- function isImageMime(mime) {
9067
- return mime.split(";")[0].trim().toLowerCase().startsWith("image/");
9068
- }
9069
- var edge = (n) => typeof n === "number" && Number.isSafeInteger(n) && n > 0 && n <= IMAGE_LIMITS.maxEdge;
9070
- /** A peer's `image` description, or undefined when it is not one. It never refuses the file. */
9071
- function parseImageMeta(value, mime) {
9072
- if (mime !== void 0 && !isImageMime(mime)) return void 0;
9073
- if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
9074
- const { width, height } = value;
9075
- return edge(width) && edge(height) ? {
9076
- width,
9077
- height
9078
- } : void 0;
9079
- }
9080
- var u16be = (b, at) => b[at] << 8 | b[at + 1];
9081
- var u16le = (b, at) => b[at] | b[at + 1] << 8;
9082
- var u24le = (b, at) => b[at] | b[at + 1] << 8 | b[at + 2] << 16;
9083
- var u32be = (b, at) => (b[at] << 24 >>> 0) + (b[at + 1] << 16) + (b[at + 2] << 8) + b[at + 3];
9084
- var ascii = (b, at, text) => [...text].every((c, i) => b[at + i] === c.charCodeAt(0));
9085
- var sized = (width, height) => edge(width) && edge(height) ? {
9086
- width,
9087
- height
9088
- } : void 0;
9089
- /** SOFn markers: every frame type but DHT (C4), JPG (C8) and DAC (CC). */
9090
- var JPEG_FRAME = /* @__PURE__ */ new Set([
9091
- 192,
9092
- 193,
9093
- 194,
9094
- 195,
9095
- 197,
9096
- 198,
9097
- 199,
9098
- 201,
9099
- 202,
9100
- 203,
9101
- 205,
9102
- 206,
9103
- 207
9104
- ]);
9105
- /** The EXIF orientation in an APP1 segment's TIFF block (1 when it says none). */
9106
- function exifOrientation(b, tiff, end) {
9107
- if (tiff + 8 > end) return 1;
9108
- const little = ascii(b, tiff, "II");
9109
- if (!little && !ascii(b, tiff, "MM")) return 1;
9110
- const u16 = (at) => little ? u16le(b, at) : u16be(b, at);
9111
- const u32 = (at) => little ? u16le(b, at) + u16le(b, at + 2) * 65536 : u32be(b, at);
9112
- if (u16(tiff + 2) !== 42) return 1;
9113
- const ifd = tiff + u32(tiff + 4);
9114
- if (ifd + 2 > end) return 1;
9115
- const count = u16(ifd);
9116
- for (let i = 0; i < count; i++) {
9117
- const entry = ifd + 2 + i * 12;
9118
- if (entry + 12 > end) return 1;
9119
- if (u16(entry) === 274) {
9120
- const value = u16(entry + 8);
9121
- return value >= 1 && value <= 8 ? value : 1;
9122
- }
9123
- }
9124
- return 1;
9125
- }
9126
- function jpegSize(b) {
9127
- let at = 2, orientation = 1;
9128
- while (at + 4 <= b.length) {
9129
- if (b[at] !== 255) return void 0;
9130
- const marker = b[at + 1];
9131
- if (marker === 255) {
9132
- at++;
9133
- continue;
9134
- }
9135
- if (marker === 1 || marker >= 208 && marker <= 215) {
9136
- at += 2;
9137
- continue;
9138
- }
9139
- if (marker === 217 || marker === 218) return void 0;
9140
- const length = u16be(b, at + 2);
9141
- if (length < 2) return void 0;
9142
- const end = Math.min(b.length, at + 2 + length);
9143
- if (marker === 225 && ascii(b, at + 4, "Exif\0\0")) orientation = exifOrientation(b, at + 10, end);
9144
- if (JPEG_FRAME.has(marker)) {
9145
- if (at + 9 > b.length) return void 0;
9146
- const height = u16be(b, at + 5), width = u16be(b, at + 7);
9147
- return orientation >= 5 ? sized(height, width) : sized(width, height);
9148
- }
9149
- at += 2 + length;
7359
+ this.windowMs = windowMs;
7360
+ this.now = now;
9150
7361
  }
9151
- }
9152
- function webpSize(b) {
9153
- if (b.length < 30) return void 0;
9154
- if (ascii(b, 12, "VP8 ")) {
9155
- if (b[23] !== 157 || b[24] !== 1 || b[25] !== 42) return void 0;
9156
- return sized(u16le(b, 26) & 16383, u16le(b, 28) & 16383);
7362
+ /** Counts one now; false (and not counted) when the window is full. */
7363
+ take() {
7364
+ const now = this.now();
7365
+ this.recent = this.recent.filter((at) => now - at < this.windowMs);
7366
+ if (this.recent.length >= this.limit) return false;
7367
+ this.recent.push(now);
7368
+ return true;
9157
7369
  }
9158
- if (ascii(b, 12, "VP8L")) {
9159
- if (b[20] !== 47) return void 0;
9160
- const bits = b[21] | b[22] << 8 | b[23] << 16 | b[24] << 24;
9161
- return sized((bits & 16383) + 1, (bits >>> 14 & 16383) + 1);
7370
+ /** Milliseconds until the window has room again; 0 when it has room now. */
7371
+ wait() {
7372
+ const now = this.now();
7373
+ this.recent = this.recent.filter((at) => now - at < this.windowMs);
7374
+ return this.recent.length < this.limit ? 0 : this.recent[0] + this.windowMs - now;
9162
7375
  }
9163
- if (ascii(b, 12, "VP8X")) return sized(u24le(b, 24) + 1, u24le(b, 27) + 1);
9164
- }
9165
- /**
9166
- * A picture's size as it is shown, read from its first bytes (PNG, JPEG, GIF, WebP), or undefined when they do not
9167
- * say. Only the header is read: a JPEG whose frame header sits past `IMAGE_LIMITS.headBytes` goes without.
9168
- */
9169
- function imageSize(head) {
9170
- const b = head;
9171
- try {
9172
- 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));
9173
- if (b.length >= 10 && (ascii(b, 0, "GIF87a") || ascii(b, 0, "GIF89a"))) return sized(u16le(b, 6), u16le(b, 8));
9174
- if (b.length >= 4 && b[0] === 255 && b[1] === 216 && b[2] === 255) return jpegSize(b);
9175
- if (b.length >= 16 && ascii(b, 0, "RIFF") && ascii(b, 8, "WEBP")) return webpSize(b);
9176
- } catch {}
9177
- }
9178
- /**
9179
- * The description a picture is sent with, read from the start of the file, or undefined when it is not a picture
9180
- * or its first bytes do not say (it then goes without one).
9181
- */
9182
- async function readImageMeta(source, mime) {
9183
- if (!isImageMime(mime)) return void 0;
9184
- try {
9185
- return imageSize(new Uint8Array(await source.slice(0, IMAGE_LIMITS.headBytes).arrayBuffer()));
9186
- } catch {
9187
- return;
7376
+ reset() {
7377
+ this.recent = [];
9188
7378
  }
9189
- }
7379
+ };
9190
7380
  //#endregion
9191
7381
  //#region ../core/src/dhtFiles.ts
9192
7382
  /**
@@ -9355,6 +7545,7 @@ var DhtDelivery = class DhtDelivery {
9355
7545
  fastUntil = 0;
9356
7546
  live = false;
9357
7547
  active = false;
7548
+ startedAt = Date.now();
9358
7549
  /** The next read was asked for (a refresh, a fresh packet of the contact): it is not a background one. */
9359
7550
  urgent = false;
9360
7551
  /** Reads left that are signaling: the contact is leaving DHT only, and the live link waits on them (`expect`). */
@@ -10298,11 +8489,20 @@ var DhtDelivery = class DhtDelivery {
10298
8489
  get pollMs() {
10299
8490
  if (this.options.pollMs) return this.options.pollMs;
10300
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);
10301
8494
  if (this.mode === "dht" || this.state.peerMode === "dht" && now < this.leavingUntil || now < this.fastUntil) return DHT_POLL_MS;
10302
8495
  if (this.live) return LIVE_POLL_MS;
10303
8496
  if (this.state.pending && this.state.pending.expires > now) return DHT_POLL_MS;
10304
8497
  return this.active ? ACTIVE_DHT_POLL_MS : STREAM_POLL_MS;
10305
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
+ }
10306
8506
  schedule() {
10307
8507
  if (!this.running || this.ticking) return;
10308
8508
  if (this.timer) clearTimeout(this.timer);
@@ -13836,6 +12036,11 @@ var SERVICES_CAPABILITY = "services/1";
13836
12036
  * confirmed byte, checked by digest (`pf-offer` … frames, `chatFiles.ts`). Without it, files/2 and 100 MiB.
13837
12037
  */
13838
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";
13839
12044
  /** The typing indicator: `paired-typing` frames on this session, 1:1 chats only (WISP 401 § Typing, `pairedTyping.ts`). */
13840
12045
  var TYPING_CAPABILITY = "typing/1";
13841
12046
  /** Reactions to messages: `paired-reaction` frames on this session, 1:1 chats only (WISP 401 § Reactions, `reactions.ts`). */
@@ -13875,6 +12080,7 @@ var KNOWN_SESSION_CAPABILITIES = [
13875
12080
  CALLS_CAPABILITY,
13876
12081
  SERVICES_CAPABILITY,
13877
12082
  FILES_CAPABILITY,
12083
+ FILES_BINARY_CAPABILITY,
13878
12084
  TYPING_CAPABILITY,
13879
12085
  REACTIONS_CAPABILITY,
13880
12086
  EDIT_CAPABILITY,
@@ -13971,6 +12177,8 @@ var SessionCapabilities = class {
13971
12177
  * be matched across a person's chats; it travels only inside the encrypted session.
13972
12178
  */
13973
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;
13974
12182
  var DOMAIN = "ghostly-apps/1";
13975
12183
  var APP_ID = /^[A-Za-z0-9_-]{22}$/;
13976
12184
  /** Semantic versioning 2.0.0, as a manifest's `version` is. */
@@ -14283,7 +12491,8 @@ function vapidAuthorization(endpoint, keys, subject, now = Date.now()) {
14283
12491
  exp: Math.floor(now / 1e3) + TOKEN_SECONDS,
14284
12492
  sub: subject
14285
12493
  })}`;
14286
- 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}`;
14287
12496
  }
14288
12497
  /**
14289
12498
  * `plaintext` encrypted for a subscription (RFC 8291 with RFC 8188 `aes128gcm`, one record). `sender` fixes
@@ -14601,7 +12810,8 @@ var LINK_OR_MARKUP = /[a-z][a-z0-9+.-]*:\/\/|\bwww\.|\b(?:mailto|javascript|data
14601
12810
  */
14602
12811
  function sanitizeTypingStatus(value) {
14603
12812
  if (typeof value !== "string") return void 0;
14604
- 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);
14605
12815
  if (!clean || LINK_OR_MARKUP.test(clean)) return void 0;
14606
12816
  return clean;
14607
12817
  }
@@ -14810,6 +13020,11 @@ var FILE_LIMITS = {
14810
13020
  chunkBytes: 16384,
14811
13021
  /** Bytes a sender has sent and not seen confirmed: throughput is this per round trip. */
14812
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,
14813
13028
  /** A receiver asks its person before taking a file larger than this. */
14814
13029
  askAboveBytes: 26214400,
14815
13030
  /** Files taken without asking, per contact, while stored. Past it, every file is asked about. */
@@ -14845,7 +13060,7 @@ var FILE_LIMITS = {
14845
13060
  resendBytes: 268435456
14846
13061
  };
14847
13062
  var ID = /^[A-Za-z0-9_-]{8,64}$/;
14848
- var DIGEST$1 = /^[A-Za-z0-9_-]{43}$/;
13063
+ var DIGEST = /^[A-Za-z0-9_-]{43}$/;
14849
13064
  var DATA = /^[A-Za-z0-9_-]+$/;
14850
13065
  var FILE_FRAMES = /* @__PURE__ */ new Set([
14851
13066
  "pf-offer",
@@ -14878,6 +13093,37 @@ function transferStalled(record, movedAt, restored, now) {
14878
13093
  }
14879
13094
  var key = (direction, id) => `${direction}:${id}`;
14880
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
+ }
14881
13127
  /** What a refusal means to the sender's person. */
14882
13128
  function refusalText(why, room) {
14883
13129
  switch (why) {
@@ -14914,6 +13160,10 @@ function refusalText(why, room) {
14914
13160
  var ChatFiles = class {
14915
13161
  host;
14916
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();
14917
13167
  attached = false;
14918
13168
  peerRoomValue = null;
14919
13169
  constructor(host) {
@@ -14973,6 +13223,15 @@ var ChatFiles = class {
14973
13223
  send(frame) {
14974
13224
  return this.attached && this.host.send(frame);
14975
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
+ }
14976
13235
  /** A session that agreed files/3 is open: say what fits here, then offer again what is not finished. */
14977
13236
  attach() {
14978
13237
  this.attached = true;
@@ -14991,6 +13250,8 @@ var ChatFiles = class {
14991
13250
  if (!this.attached) return;
14992
13251
  this.attached = false;
14993
13252
  this.peerRoomValue = null;
13253
+ this.moving.clear();
13254
+ this.held.clear();
14994
13255
  for (const entry of this.entries.values()) {
14995
13256
  if (entry.out) {
14996
13257
  entry.out.generation++;
@@ -15114,10 +13375,34 @@ var ChatFiles = class {
15114
13375
  const at = offset - out.block.start;
15115
13376
  return out.block.bytes.subarray(at, at + length);
15116
13377
  }
15117
- shouldPump(entry) {
13378
+ /** Whether this file's own window lets it send more. */
13379
+ fileMayPump(entry) {
15118
13380
  const { record } = entry, out = entry.out;
15119
13381
  return this.attached && record.state === "active" && out.next < record.file.size && out.next - record.confirmed < FILE_LIMITS.windowBytes;
15120
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
+ }
15121
13406
  /**
15122
13407
  * Whether the next `length` bytes go. Within the file's size they always do; past it the receiver is having parts
15123
13408
  * sent again, which nothing it says bounds, so this side does.
@@ -15148,17 +13433,13 @@ var ChatFiles = class {
15148
13433
  this.end(entry, "failed", "Your contact asked for the same part of this file too many times");
15149
13434
  break;
15150
13435
  }
15151
- if (!this.send({
15152
- t: "pf-data",
15153
- id: entry.record.id,
15154
- offset,
15155
- data: toBase64Url(chunk)
15156
- })) {
13436
+ if (!this.sendData(entry.record.id, offset, chunk)) {
15157
13437
  refused = true;
15158
13438
  break;
15159
13439
  }
15160
13440
  out.next = offset + chunk.length;
15161
13441
  out.sent += chunk.length;
13442
+ this.moving.add(entry);
15162
13443
  }
15163
13444
  } catch (error) {
15164
13445
  if (generation === out.generation) {
@@ -15173,7 +13454,7 @@ var ChatFiles = class {
15173
13454
  if (generation === out.generation && this.shouldPump(entry)) {
15174
13455
  if (refused) out.wake = setTimeout(() => void this.pump(entry), FILE_LIMITS.sendRetryMs);
15175
13456
  else this.pump(entry);
15176
- }
13457
+ } else if (generation === out.generation && this.fileMayPump(entry)) this.held.add(entry);
15177
13458
  }
15178
13459
  }
15179
13460
  async sendSum(entry) {
@@ -15205,7 +13486,7 @@ var ChatFiles = class {
15205
13486
  return [...this.entries.values()].filter((e) => e.record.direction === "in" && e.record.state === "asking").length;
15206
13487
  }
15207
13488
  activeIncoming(except) {
15208
- 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;
15209
13490
  }
15210
13491
  /**
15211
13492
  * Removes what this side stored of an incoming file that ends here. Through its target, opened now if need be: after
@@ -15232,9 +13513,15 @@ var ChatFiles = class {
15232
13513
  }
15233
13514
  return incoming.target;
15234
13515
  }
15235
- /** 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
+ */
15236
13521
  startIncoming(entry) {
15237
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;
15238
13525
  if (this.activeIncoming(entry) >= FILE_LIMITS.maxActiveIncoming) {
15239
13526
  if (entry.record.state !== "queued") {
15240
13527
  entry.record.state = "queued";
@@ -15247,9 +13534,13 @@ var ChatFiles = class {
15247
13534
  });
15248
13535
  return;
15249
13536
  }
13537
+ incoming.starting = true;
15250
13538
  this.target(entry).then(() => {
15251
- if (transferEnded(entry.record) || !this.attached) return;
15252
- 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
+ }
15253
13544
  entry.record.state = "active";
15254
13545
  entry.record.pausedBy = void 0;
15255
13546
  entry.in.gapAt = void 0;
@@ -15262,12 +13553,14 @@ var ChatFiles = class {
15262
13553
  entry.in.heardAt = this.now();
15263
13554
  this.armNudge(entry);
15264
13555
  }, (error) => {
13556
+ incoming.starting = false;
15265
13557
  this.end(entry, "failed", `Could not store the file: ${error instanceof Error ? error.message : String(error)}`);
15266
13558
  this.send({
15267
13559
  t: "pf-refuse",
15268
13560
  id: entry.record.id,
15269
13561
  why: "no-room"
15270
13562
  });
13563
+ this.startNext();
15271
13564
  });
15272
13565
  }
15273
13566
  /** The next file waiting its turn, once one ends. */
@@ -15506,7 +13799,9 @@ var ChatFiles = class {
15506
13799
  if (record.state !== "active" || !incoming.target) return;
15507
13800
  incoming.heardAt = this.now();
15508
13801
  const { offset, data } = frame;
15509
- if (!isInt$1(offset) || typeof data !== "string" || !data.length || data.length > Math.ceil(FILE_LIMITS.chunkBytes * 4 / 3) || !DATA.test(data)) return;
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;
15510
13805
  if (offset < incoming.written) {
15511
13806
  if (incoming.stored === incoming.written) this.send({
15512
13807
  t: "pf-got",
@@ -15526,7 +13821,7 @@ var ChatFiles = class {
15526
13821
  }
15527
13822
  return;
15528
13823
  }
15529
- const bytes = fromBase64Url(data);
13824
+ const bytes = binary ? data : fromBase64Url(data);
15530
13825
  if (!bytes.length || bytes.length > FILE_LIMITS.chunkBytes || offset + bytes.length > record.file.size) return;
15531
13826
  incoming.written += bytes.length;
15532
13827
  const target = incoming.target;
@@ -15618,7 +13913,7 @@ var ChatFiles = class {
15618
13913
  return;
15619
13914
  }
15620
13915
  if (record.state !== "active" || incoming.verifying || !incoming.target) return;
15621
- 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;
15622
13917
  const digest = frame.digest;
15623
13918
  await incoming.writing;
15624
13919
  if (transferEnded(record) || record.state !== "active") return;
@@ -15711,6 +14006,7 @@ var ChatFiles = class {
15711
14006
  record.state = "active";
15712
14007
  record.pausedBy = record.waitingFor = void 0;
15713
14008
  this.changed(outgoing);
14009
+ this.wakeHeld();
15714
14010
  if (record.confirmed === record.file.size) this.sendSum(outgoing);
15715
14011
  else {
15716
14012
  this.armStall(outgoing);
@@ -15730,11 +14026,13 @@ var ChatFiles = class {
15730
14026
  record.waitingFor = frame.why === "busy" ? "busy" : "consent";
15731
14027
  }
15732
14028
  this.changed(outgoing);
14029
+ this.wakeHeld();
15733
14030
  return;
15734
14031
  case "pf-got":
15735
14032
  if (!isInt$1(frame.offset) || frame.offset <= record.confirmed || frame.offset > out.next) return;
15736
14033
  record.confirmed = frame.offset;
15737
14034
  this.changed(outgoing, true);
14035
+ this.wakeHeld();
15738
14036
  if (record.confirmed === record.file.size) this.sendSum(outgoing);
15739
14037
  else {
15740
14038
  this.armStall(outgoing);
@@ -15764,6 +14062,8 @@ var ChatFiles = class {
15764
14062
  clearTimeout(entry.out.wake);
15765
14063
  entry.out.block = void 0;
15766
14064
  entry.out.source = void 0;
14065
+ this.moving.delete(entry);
14066
+ this.held.delete(entry);
15767
14067
  }
15768
14068
  if (entry.in) {
15769
14069
  const incoming = entry.in;
@@ -15773,6 +14073,7 @@ var ChatFiles = class {
15773
14073
  });
15774
14074
  }
15775
14075
  this.changed(entry);
14076
+ if (entry.out) this.wakeHeld();
15776
14077
  }
15777
14078
  entry(direction, id) {
15778
14079
  const entry = this.entries.get(key(direction, id));
@@ -15821,6 +14122,7 @@ var ChatFiles = class {
15821
14122
  this.startNext();
15822
14123
  }
15823
14124
  this.changed(entry);
14125
+ if (entry.out) this.wakeHeld();
15824
14126
  }
15825
14127
  resume(direction, id) {
15826
14128
  const entry = this.entry(direction, id), { record } = entry;
@@ -16439,6 +14741,8 @@ var CarriedTransport = class {
16439
14741
  /** My last packet while the edge is down: what a member that just became reachable is handed. */
16440
14742
  mine = null;
16441
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();
16442
14746
  /** My newest packet, wherever it went: what the edge hands the member over itself once it is ready (`linkReady`). */
16443
14747
  newest = null;
16444
14748
  /** The newest packet of the member's that came through a carrier, and the timestamp of the one a read last returned. */
@@ -16518,7 +14822,9 @@ var CarriedTransport = class {
16518
14822
  stamp(identity, records) {
16519
14823
  const now = BigInt(Date.now()) * 1000n;
16520
14824
  this.lastTimestamp = now > this.lastTimestamp ? now : this.lastTimestamp + 1n;
16521
- return createRelayPayload(identity, records, this.lastTimestamp);
14825
+ const payload = createRelayPayload(identity, records, this.lastTimestamp);
14826
+ this.dated.set(payload, this.lastTimestamp);
14827
+ return payload;
16522
14828
  }
16523
14829
  /**
16524
14830
  * The relays get the very packet members carried or the edge itself handed over, byte for byte, where the transport
@@ -16528,7 +14834,11 @@ var CarriedTransport = class {
16528
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).
16529
14835
  */
16530
14836
  toRelays(identity, records, payload, options) {
16531
- 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);
16532
14842
  }
16533
14843
  /**
16534
14844
  * The packet the relays just took, handed to the member over the edge itself while it is up. An edge up reads nothing
@@ -16642,142 +14952,6 @@ var CarriedTransport = class {
16642
14952
  }
16643
14953
  };
16644
14954
  //#endregion
16645
- //#region ../core/src/groupFiles.ts
16646
- /**
16647
- * Files and voice messages in groups (WISP 503 · Group Files). A file is announced as a group message carrying its
16648
- * description; its bytes go over `files/3` between two members, from a member that holds it to one that asks for it.
16649
- * The limits below are the profile's first numbers, kept in one place so they are easy to change.
16650
- */
16651
- var GROUP_FILE_LIMITS = {
16652
- /** The largest file a group takes. */
16653
- maxBytes: 104857600,
16654
- /** A file this size or smaller is fetched without asking (voice messages always are, up to `VOICE_LIMITS`). */
16655
- autoBytes: 8388608,
16656
- /** What a device keeps of a group's files it fetched without asking; past it, every file is asked about. */
16657
- autoBytesPerGroup: 268435456,
16658
- /** Bytes of group files a device serves to others in a day, when serving is on. */
16659
- serveBytesPerDay: 1073741824,
16660
- /** Transfers a holder serves at once, all groups together. */
16661
- serveAtOnce: 3,
16662
- /** File announcements a member sends to one group in a minute. */
16663
- announcePerMinute: 8,
16664
- /** File announcements of one member of a group fetched by themselves in `announceWindowMs`; past it, kept for a Download. */
16665
- announceTaken: 30,
16666
- announceWindowMs: 6e5,
16667
- /** Holders remembered per file. */
16668
- holders: 8,
16669
- /**
16670
- * Message ids a `group-have` named before their message came (a member back from away hears who holds what as its
16671
- * catch-up begins), remembered with their holders for when it does.
16672
- */
16673
- heardAhead: 256,
16674
- /** Message ids in one `group-have`. */
16675
- haveIds: 32,
16676
- /** A private group's `group-have` goes on an edge at most this often; a community's, from a member, every `communityHaveEveryMs`. */
16677
- haveEveryMs: 1e4,
16678
- communityHaveEveryMs: 6e4,
16679
- /**
16680
- * How long a want waits for an offer before the next holder is asked; a transfer that does not move for as long is
16681
- * left for the next holder too.
16682
- */
16683
- wantWaitMs: 3e4,
16684
- /** A file session closes this long after its last transfer ends. */
16685
- sessionIdleMs: 6e4,
16686
- /** File sessions opening at once, and open at once, all groups together. */
16687
- sessionsOpening: 2,
16688
- sessionsOpen: 4,
16689
- /** The longest file name and type kept from a description. */
16690
- nameChars: 255,
16691
- mimeChars: 255
16692
- };
16693
- /**
16694
- * The frames of WISP 503 § Getting the bytes, on an edge between two apps that announced `GROUP_VERSION_FILES`:
16695
- * `group-want {g, id}` asks a holder for the file of message `id`; `group-want-no {g, id, why}` is a holder's no;
16696
- * `group-have {g, ids}` says which files a member keeps (in a community, an application frame `{t, ids}` instead).
16697
- */
16698
- var GROUP_WANT_FRAME = "group-want";
16699
- var GROUP_WANT_NO_FRAME = "group-want-no";
16700
- var GROUP_HAVE_FRAME = "group-have";
16701
- var REFUSALS = [
16702
- "gone",
16703
- "busy",
16704
- "refused"
16705
- ];
16706
- /** A group message's id, as a private group (`<s>:<e>:<n>`) or a community (`<s>:<e>:<h>:<n>`) names it. */
16707
- var MESSAGE_ID = /^[a-z0-9]{52}:\d{1,15}(:[A-Za-z0-9_-]{1,64})?:\d{1,15}$/;
16708
- var isGroupMessageId = (value) => typeof value === "string" && MESSAGE_ID.test(value);
16709
- function frameOf(value, t, groupId) {
16710
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
16711
- const f = value;
16712
- return f.t === t && (groupId === void 0 || f.g === groupId) ? f : null;
16713
- }
16714
- /** A `group-want` of group `groupId`: the message whose file is asked for, or null. */
16715
- function readGroupWant(value, groupId) {
16716
- const f = frameOf(value, GROUP_WANT_FRAME, groupId);
16717
- return f && isGroupMessageId(f.id) ? { id: f.id } : null;
16718
- }
16719
- /** A holder's `group-want-no` of group `groupId`, or null. A reason this app does not know reads as `busy`. */
16720
- function readGroupWantNo(value, groupId) {
16721
- const f = frameOf(value, GROUP_WANT_NO_FRAME, groupId);
16722
- if (!f || !isGroupMessageId(f.id)) return null;
16723
- return {
16724
- id: f.id,
16725
- why: typeof f.why === "string" && REFUSALS.includes(f.why) ? f.why : "busy"
16726
- };
16727
- }
16728
- /**
16729
- * The message ids of a `group-have`: of group `groupId` on an edge, or (no `groupId`) a community's application frame.
16730
- * At most `haveIds` message ids; a frame with more, or anything else, is null.
16731
- */
16732
- function readGroupHave(value, groupId) {
16733
- const f = frameOf(value, GROUP_HAVE_FRAME, groupId);
16734
- if (!f || !Array.isArray(f.ids) || !f.ids.length || f.ids.length > GROUP_FILE_LIMITS.haveIds || !f.ids.every(isGroupMessageId)) return null;
16735
- return [...new Set(f.ids)];
16736
- }
16737
- var DIGEST = /^[A-Za-z0-9_-]{43}$/;
16738
- /**
16739
- * A description read from the wire, cleaned: the name made a display name (never a path), the type untrusted, the
16740
- * size within the group limit, the digest the right shape. A malformed voice, video or picture description is dropped
16741
- * on its own and the file is still a file; anything else malformed is no file at all (null), and the text shows.
16742
- */
16743
- function readGroupFileMeta(value) {
16744
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
16745
- const v = value;
16746
- if (typeof v.name !== "string" || !v.name.trim() || [...v.name].length > GROUP_FILE_LIMITS.nameChars) return null;
16747
- if (typeof v.mime !== "string" || v.mime.length > GROUP_FILE_LIMITS.mimeChars) return null;
16748
- if (!Number.isSafeInteger(v.size) || v.size < 1 || v.size > GROUP_FILE_LIMITS.maxBytes) return null;
16749
- if (typeof v.d !== "string" || !DIGEST.test(v.d)) return null;
16750
- const mime = sanitizeMime(v.mime);
16751
- const voice = v.voice === void 0 ? void 0 : parseVoiceMeta(v.voice, mime);
16752
- const video = v.video === void 0 ? void 0 : parseVideoMeta(v.video, mime);
16753
- const image = v.image === void 0 ? void 0 : parseImageMeta(v.image, mime);
16754
- return {
16755
- name: sanitizeFileName(v.name),
16756
- mime,
16757
- size: v.size,
16758
- d: v.d,
16759
- ...voice ? { voice } : {},
16760
- ...video ? { video } : {},
16761
- ...image ? { image } : {}
16762
- };
16763
- }
16764
- /**
16765
- * The text an announcement carries when its author wrote no caption: what an app without group files shows instead
16766
- * of the file. English, as wire text is; an app that knows the file shows the file, not this line.
16767
- */
16768
- function groupFileFallback(file) {
16769
- if (file.voice) return `🎤 Voice message (${formatVoiceDuration(file.voice.duration)}): update Ghostly to hear it`;
16770
- return `${fileMessageText(file)} (${formatFileSize(file.size)}): update Ghostly to get files in groups`;
16771
- }
16772
- /**
16773
- * Whether a device fetches a file by itself (WISP 503 § Automatic and asked downloads): a voice message always, any
16774
- * other file up to `autoBytes` while what it took that way from the group stays within `autoBytesPerGroup`.
16775
- */
16776
- function fetchesByItself(file, keptAutomatically, limits = GROUP_FILE_LIMITS) {
16777
- if (file.voice) return true;
16778
- return file.size <= limits.autoBytes && keptAutomatically + file.size <= limits.autoBytesPerGroup;
16779
- }
16780
- //#endregion
16781
14955
  //#region ../core/src/pairingProgress.ts
16782
14956
  var FIRST_STAGE = {
16783
14957
  inviter: "publishing",
@@ -17078,6 +15252,14 @@ var PACKET_TRANSPORTS_FAST_ATTEMPTS = 3;
17078
15252
  var DHT_PIN_GRACE_MS = 1e4;
17079
15253
  /** Back after a restart (`resume`), dials wait this long at most for the native endpoint that carried the chat to start. */
17080
15254
  var RESUME_WAIT_MS = 1e4;
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;
17081
15263
  /** A WebRTC attempt under way, not yet open: what a member's packet saying it has no WebRTC ends (`peerPacketTransports`). */
17082
15264
  var WEBRTC_ATTEMPT = /* @__PURE__ */ new Set([
17083
15265
  "offering",
@@ -17199,6 +15381,18 @@ var HANDLED_WAIT_MS = 1e4;
17199
15381
  */
17200
15382
  var WAKE_LOST_MS = 1e4;
17201
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
+ /**
17202
15396
  * A `paired-message` frame. The preview (`pv`) is left out when the frame would pass `MAX_PAIRED_MESSAGE_FRAME`
17203
15397
  * with it: the text matters, the card does not. A reply (`r`, WISP 401 § Replies) is a few hundred bytes at most
17204
15398
  * and always goes, and so does a forwarded message's hop count (`fw`, WISP 401 § Forwards) and a status card (`sc`,
@@ -17546,6 +15740,7 @@ var GhostLink = class {
17546
15740
  });
17547
15741
  options.events?.onPairingProgress?.(progress);
17548
15742
  }) : null;
15743
+ const invite = options.pairingProgress?.role === "inviter" && !!options.pairing && !options.pairing.credentials.peerKey;
17549
15744
  this.dht = options.params.profile && options.pairing && options.dht ? new DhtDelivery({
17550
15745
  params: options.params,
17551
15746
  mode: this.deliveryMode,
@@ -17555,6 +15750,7 @@ var GhostLink = class {
17555
15750
  save: options.dht.save,
17556
15751
  pollMs: options.dht.pollMs,
17557
15752
  firstControlAfterMs: options.dht.firstControlAfterMs,
15753
+ ...invite && { inviteMadeAt: options.madeAt ?? Date.now() },
17558
15754
  capsRev: options.dht.capsRev,
17559
15755
  peerCapsRev: options.dht.peerCapsRev,
17560
15756
  peerAcceptsText: options.dht.peerAcceptsText,
@@ -17570,6 +15766,7 @@ var GhostLink = class {
17570
15766
  },
17571
15767
  pin: async (key) => {
17572
15768
  await options.pairing.pinPeer(key, true);
15769
+ if (invite) this.session.lookNow();
17573
15770
  if (this.isDataLinkOpen || !this.tracker || this.tracker.done) return;
17574
15771
  if (this.deliveryMode === "dht") {
17575
15772
  this.tracker.onDht("chosen");
@@ -17640,6 +15837,7 @@ var GhostLink = class {
17640
15837
  lastSeenTimestamp: options.lastSeenTimestamp,
17641
15838
  pollIntervals: options.pollIntervals,
17642
15839
  awaitingSince: options.madeAt,
15840
+ ...invite && { invite: () => !options.pairing.credentials.peerKey },
17643
15841
  getServices: options.getServices,
17644
15842
  firstPublish: options.firstPublish ?? (this.tracker && options.pairingProgress?.role === "joiner" && options.rtcAvailable !== false || this.resuming ? "after-first-poll" : "at-start"),
17645
15843
  events: {
@@ -18152,11 +16350,11 @@ var GhostLink = class {
18152
16350
  }
18153
16351
  start() {
18154
16352
  if (this.saidTransports) this.publishPacketTransports();
18155
- if (this.deliveryMode !== "dht") this.session.start();
18156
16353
  if (this.resuming && this.deliveryMode !== "dht") {
18157
16354
  this.session.expectPeer();
18158
16355
  this.resumeWaitUntil = Date.now() + RESUME_WAIT_MS;
18159
16356
  }
16357
+ if (this.deliveryMode !== "dht") this.session.start();
18160
16358
  if (this.tracker && !this.tracker.done && this.options.pairingProgress?.role === "joiner") {
18161
16359
  this.dht?.expect();
18162
16360
  this.dialEarly();
@@ -18278,6 +16476,7 @@ var GhostLink = class {
18278
16476
  disconnect() {
18279
16477
  this.closeRetired();
18280
16478
  this.dropCrossed();
16479
+ this.dropCrossedProbe();
18281
16480
  this.dropBlockedDial();
18282
16481
  this.switcher.stop();
18283
16482
  this.cancelCandidate();
@@ -19284,6 +17483,18 @@ var GhostLink = class {
19284
17483
  if (!this.options.params.profile || this.options.packetTransports) return true;
19285
17484
  return this.transportOffer().some((t) => t === "webrtc/1" ? !this.peerTransports || this.peerTransports.includes(t) : this.canDial(t) && !!this.peerTransports?.includes(t));
19286
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
+ }
19287
17498
  clearRace() {
19288
17499
  if (this.raceTimer) clearTimeout(this.raceTimer);
19289
17500
  this.raceTimer = null;
@@ -19583,7 +17794,7 @@ var GhostLink = class {
19583
17794
  return;
19584
17795
  }
19585
17796
  } else if (role !== "joiner" && this.myPubKeyZ32 > this.options.params.peerPubKeyZ32 && !this.resuming && !this.dialsPastRtc()) {
19586
- if (Date.now() - presenceSeenAt(presence) < 3e4 && presence.lastPacketAt !== this.offerAwaitedFor) {
17797
+ if (Date.now() - presenceSeenAt(presence) < 3e4 && presence.lastPacketAt !== this.offerAwaitedFor && !this.nothingToDialEitherWay()) {
19587
17798
  this.offerAwaitedFor = presence.lastPacketAt;
19588
17799
  this.session.expectPeer();
19589
17800
  }
@@ -19661,6 +17872,7 @@ var GhostLink = class {
19661
17872
  this.retryLook = null;
19662
17873
  const from = this.lastAutoConnectAt;
19663
17874
  if (!from || this.stopped || this.leaving || this.streamBlocked || this.keyStopped || !this.options.autoConnect) return;
17875
+ if (this.nothingToDialEitherWay()) return;
19664
17876
  this.retryLook = setTimeout(() => {
19665
17877
  this.retryLook = null;
19666
17878
  if (this.lastAutoConnectAt !== from || this.stopped || this.leaving || this.yielding || this.streamBlocked || this.keyStopped || this.channel || this.dialing || this.dataLink.state !== "idle") return;
@@ -19920,6 +18132,7 @@ var GhostLink = class {
19920
18132
  if (this.options.callsSupport) offered.push(CALLS_CAPABILITY);
19921
18133
  if (this.options.servicesSupport) offered.push(SERVICES_CAPABILITY);
19922
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);
19923
18136
  if (this.options.typingSupport) offered.push(TYPING_CAPABILITY);
19924
18137
  if (this.options.reactionsSupport) offered.push(REACTIONS_CAPABILITY);
19925
18138
  if (this.options.editSupport) offered.push(EDIT_CAPABILITY);
@@ -19956,6 +18169,10 @@ var GhostLink = class {
19956
18169
  get supportsLargeFiles() {
19957
18170
  return this.isDataLinkOpen && this.sessionCapabilities.agreed("files/3");
19958
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
+ }
19959
18176
  /** Both sides offer `typing/1` on the open session: typing can be said and shown. */
19960
18177
  get supportsTyping() {
19961
18178
  return this.isDataLinkOpen && this.sessionCapabilities.agreed("typing/1");
@@ -20119,13 +18336,18 @@ var GhostLink = class {
20119
18336
  }
20120
18337
  /**
20121
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
20122
- * 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.
20123
18343
  */
20124
18344
  sendAppData(app, data) {
20125
18345
  if (!this.apps.open.has(app)) return "not-open";
20126
18346
  if (appDataBytes(data) > 32768) return "too-large";
20127
18347
  if (!this.channel || !this.isDataLinkOpen) return "offline";
20128
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";
20129
18351
  const refused = this.apps.canSend(app, data);
20130
18352
  if (refused) return refused;
20131
18353
  try {
@@ -20176,19 +18398,57 @@ var GhostLink = class {
20176
18398
  }
20177
18399
  }
20178
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
+ /**
20179
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
20180
18432
  * iroh ones throw past 120 KiB) gives files a quarter of it, so a ping, a message or any other frame still fits
20181
- * while a file goes; others keep under the high water mark, as files/2 does.
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`).
20182
18435
  */
20183
18436
  filesWritable() {
20184
18437
  const channel = this.channel;
20185
18438
  if (!channel) return;
20186
- const cap = channel.sendBudget ? channel.sendBudget / 4 : LIMITS.sendHighWaterMark;
18439
+ const cap = filesCap(channel);
20187
18440
  if (channel.bufferedAmount < cap) return;
18441
+ const use = this.filesUsing(channel);
18442
+ use.waiting++;
20188
18443
  return (async () => {
20189
- while (this.channel === channel && channel.bufferedAmount >= cap) {
20190
- await channel.drained().catch(() => {});
20191
- if (this.channel === channel && channel.bufferedAmount >= cap) await new Promise((resolve) => setTimeout(resolve, 20));
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();
20192
18452
  }
20193
18453
  })();
20194
18454
  }
@@ -20486,13 +18746,20 @@ var GhostLink = class {
20486
18746
  }
20487
18747
  const held = this.channel;
20488
18748
  if (held && this.activeBinding && !this.dialedIn.has(held) && Date.now() - this.channelSince < 1e4 && this.myPubKeyZ32 < this.options.params.peerPubKeyZ32) {
20489
- traceLink(this.myPubKeyZ32, "dial-crossed", {
20490
- transport: binding.transport,
20491
- held: true
20492
- });
20493
- channel.close();
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);
20494
18758
  return;
20495
18759
  }
18760
+ this.replaceWith(channel, binding);
18761
+ }
18762
+ replaceWith(channel, binding) {
20496
18763
  traceLink(this.myPubKeyZ32, "replacing", {
20497
18764
  transport: binding.transport,
20498
18765
  held: this.paired?.state.transport
@@ -20503,6 +18770,62 @@ var GhostLink = class {
20503
18770
  });
20504
18771
  }
20505
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
+ /**
20506
18829
  * Connections that came in on this side's native endpoints. Their address was in the capability record, which before
20507
18830
  * the pin anyone holding a copy of the invite could read: a key other than the pinned one on such a connection proves
20508
18831
  * no more than on the DHT, so it closes that connection and nothing else.
@@ -20697,7 +19020,18 @@ var GhostLink = class {
20697
19020
  if (this.channel !== channel) return;
20698
19021
  this.unansweredPings = 0;
20699
19022
  this.heardFromPeer();
20700
- 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;
20701
19035
  let frame;
20702
19036
  try {
20703
19037
  frame = JSON.parse(data);
@@ -25454,6 +23788,7 @@ function readFirstPlace(o, epoch) {
25454
23788
  /** One of my own frames, among mine: `e:n`. */
25455
23789
  var ownKey = (f) => `${f.e}:${f.n}`;
25456
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}`;
25457
23792
  /** What a kept frame counts for against `GROUP_LIMITS.relayBytes`. */
25458
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;
25459
23794
  /** Only the fields an edit frame has, as `clean` does for a message. */
@@ -25574,8 +23909,15 @@ var GroupSession = class {
25574
23909
  pendingMeta;
25575
23910
  /** While `handle` runs: the frames it took that a hub passes on (`handle`'s result). */
25576
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;
25577
23914
  /** Set while a save of what received messages changed is due (`persistSoon`). */
25578
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();
25579
23921
  /** Syncs answered per member, a few a minute (`GROUP_LIMITS.syncAnswers`). In memory only. */
25580
23922
  syncsAnswered = /* @__PURE__ */ new Map();
25581
23923
  /** Catch-up answers still going out, a slice at a time (`handOut`). */
@@ -25588,10 +23930,21 @@ var GroupSession = class {
25588
23930
  provisional;
25589
23931
  /** When my chain last moved (a commit made or applied), by `hooks.clock`: frames are said again once it has settled. */
25590
23932
  chainMovedAt;
25591
- 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) {
25592
23939
  this.hooks = hooks;
25593
23940
  this.state = state;
25594
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
+ }
25595
23948
  this.answers = new CatchUpAnswers({
25596
23949
  send: (to, frame) => hooks.send(to, frame),
25597
23950
  ...hooks.handled && { handled: (to) => hooks.handled(to) },
@@ -26003,9 +24356,13 @@ var GroupSession = class {
26003
24356
  this.hooks.changed();
26004
24357
  return { secret };
26005
24358
  }
26006
- /** 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
+ */
26007
24363
  pruneRelay() {
26008
- 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));
26009
24366
  if (this.state.relayEdits?.length) this.state.relayEdits = this.state.relayEdits.filter((f) => rosterHas(this.roster, f.s));
26010
24367
  }
26011
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. */
@@ -26021,10 +24378,21 @@ var GroupSession = class {
26021
24378
  }
26022
24379
  /** A frame of another member, kept to hand on; the oldest go once the log is full. */
26023
24380
  keep(frame) {
26024
- const log = this.state.relay ??= [];
26025
- log.push(frame);
26026
- let bytes = log.reduce((sum, f) => sum + frameBytes(f), 0);
26027
- while (log.length > GROUP_LIMITS.relay || bytes > GROUP_LIMITS.relayBytes) bytes -= frameBytes(log.shift());
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());
26028
24396
  }
26029
24397
  pruneSecrets() {
26030
24398
  const epochs = Object.keys(this.state.secrets).map(Number).sort((a, b) => a - b);
@@ -26060,6 +24428,7 @@ var GroupSession = class {
26060
24428
  this.state.secrets = {};
26061
24429
  this.state.sent = [];
26062
24430
  this.state.relay = [];
24431
+ if (this.onRows) this.onRows = [];
26063
24432
  this.state.relayEdits = [];
26064
24433
  this.state.alone = [];
26065
24434
  this.waiting = [];
@@ -26548,7 +24917,8 @@ var GroupSession = class {
26548
24917
  }
26549
24918
  /**
26550
24919
  * A message, from its author's edge or handed on by another member. Handed on, it is taken only while both are in
26551
- * the roster (someone removed is neither heard from nor handed on, #300), and its boxes only with the author's `xs`.
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`.
26552
24922
  */
26553
24923
  async receiveMessage(from, raw) {
26554
24924
  if (!isMessageFrame$1(raw)) return;
@@ -26557,7 +24927,7 @@ var GroupSession = class {
26557
24927
  return;
26558
24928
  }
26559
24929
  const relayed = raw.s !== from;
26560
- 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;
26561
24931
  if (!verify(fromBase64Url(raw.sig), messageSigned$1(raw), publicKeyFromZ32(raw.s))) return;
26562
24932
  if (raw.e > this.epoch) {
26563
24933
  this.park(from, raw);
@@ -26595,10 +24965,11 @@ var GroupSession = class {
26595
24965
  n: first.n
26596
24966
  };
26597
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));
26598
24969
  this.markSeen(raw);
26599
24970
  this.keep(clean(raw));
26600
24971
  this.persistSoon();
26601
- if (rosterHas(this.roster, raw.s)) this.took(clean(raw));
24972
+ if (this.handsOn(raw.s, raw.e)) this.took(clean(raw));
26602
24973
  return;
26603
24974
  }
26604
24975
  const id = first && !this.provisional.has(own) ? groupMessageId(raw.s, first.e, first.n) : own;
@@ -26635,7 +25006,7 @@ var GroupSession = class {
26635
25006
  if (firstFrame && rosterHas(this.state.chain[firstFrame.e]?.m ?? [], this.myKey)) this.markSeen(firstFrame);
26636
25007
  this.keep(clean(raw));
26637
25008
  this.persistSoon();
26638
- if (rosterHas(this.roster, raw.s)) this.took(clean(raw));
25009
+ if (this.handsOn(raw.s, raw.e)) this.took(clean(raw));
26639
25010
  }
26640
25011
  /**
26641
25012
  * An edit of a member's own message, from its author's edge or handed on by another member: believed only while its
@@ -26669,7 +25040,7 @@ var GroupSession = class {
26669
25040
  const k = validMentions(body.m, body.text, rosterAdmin(commit.m) === raw.s && this.admin === raw.s);
26670
25041
  const sc = body.sc === void 0 ? void 0 : readStatusCard(body.sc);
26671
25042
  if (!validEditNumber(raw.v) && !sc) return;
26672
- const copy = (this.state.relay ?? []).find((f) => f.s === raw.s && f.e === raw.e && f.n === raw.n);
25043
+ const copy = this.kept().find((f) => f.s === raw.s && f.e === raw.e && f.n === raw.n);
26673
25044
  const first = copy && readFirstPlace(copy.o, copy.e);
26674
25045
  await this.hooks.edit?.({
26675
25046
  id: first ? groupMessageId(raw.s, first.e, first.n) : groupMessageId(raw.s, raw.e, raw.n),
@@ -26830,6 +25201,16 @@ var GroupSession = class {
26830
25201
  await this.receiveCommit(from, next);
26831
25202
  }
26832
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
+ }
26833
25214
  /** Keeps the admin's mark on a commit that took out a member who left (`GroupCommitFrame.left`) when it holds; true when new. */
26834
25215
  takeLeftMark(commit, mark) {
26835
25216
  if (commit.k !== "remove" || typeof mark !== "string" || mark.length !== 86 || !B64$1.test(mark) || this.state.leftMarks?.[commit.e]) return false;
@@ -26924,7 +25305,15 @@ var GroupSession = class {
26924
25305
  });
26925
25306
  const lacks = missingIn(frame, from);
26926
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)) : []);
26927
- const kept = asked.size ? (this.state.relay ?? []).filter((f) => asked.has(f.s) && this.state.chain[f.e] && rosterHas(this.state.chain[f.e].m, from) && lacks(f)) : [];
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);
26928
25317
  answer.push(...asWritten(this.ownLacked(from, frame), kept));
26929
25318
  for (const author of asked) {
26930
25319
  const edits = (this.state.relayEdits ?? []).filter((f) => f.s === author && this.state.chain[f.e] && rosterHas(this.state.chain[f.e].m, from));
@@ -27231,9 +25620,10 @@ function knockIdentity(link) {
27231
25620
  function knockRecords(link, knocks) {
27232
25621
  const nonce = randomBytes(NONCE_LENGTH);
27233
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);
27234
25624
  return [{
27235
25625
  label: KNOCK_LABEL,
27236
- value: toBase64Url(concatBytes$1(nonce, xchacha20poly1305(knockMaterial(link, "knock key"), nonce, utf8Encode(link.g)).encrypt(body)))
25626
+ value: toBase64Url(concatBytes$1(nonce, box))
27237
25627
  }];
27238
25628
  }
27239
25629
  /** Knocks that open with the link's key and are well formed; anything else is nobody. */
@@ -27320,6 +25710,8 @@ var COMMUNITY_LIMITS = {
27320
25710
  /** Commits in one welcome or chain piece. */
27321
25711
  chainPiece: 100,
27322
25712
  pendingLeaves: 64,
25713
+ /** Leaves whose mark (`group-left`) is kept, and the epochs one names. */
25714
+ leftMarks: 64,
27323
25715
  /**
27324
25716
  * Syncs of one member answered in `syncWindowMs`: each answer may carry the chain's tail, secrets and the whole
27325
25717
  * store. A member catching up asks every five seconds at most, and every member syncs every thirty; one asking more
@@ -27436,6 +25828,14 @@ var leaveStatement = (g, s) => utf8Encode(JSON.stringify([
27436
25828
  g,
27437
25829
  s
27438
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}$/;
27439
25839
  /** The secret of an epoch that adds, re-roles or re-links: from the previous one and the commit. */
27440
25840
  function derivedSecret(previous, untaggedHash) {
27441
25841
  return hkdf(sha256, previous, utf8Encode(untaggedHash), utf8Encode("ghostly-group/2 derived"), 32);
@@ -27899,13 +26299,58 @@ var CommunitySession = class {
27899
26299
  return -1;
27900
26300
  }
27901
26301
  /**
27902
- * Someone the chain removed (not one who left) and who is not back in: nothing it signs is heard any more, even for
27903
- * an epoch it was in. What one who left wrote while in the group is still taken and handed on, for the epochs it was
27904
- * in (WISP 903 § Leaving and removal): its goodbye reaches the members who were away.
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.
27905
26307
  */
27906
- wasExpelled(key) {
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) {
27907
26318
  const i = this.outIndex(key);
27908
- return i >= 0 && this.state.chain[i].k === "remove";
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;
27909
26354
  }
27910
26355
  /**
27911
26356
  * What someone the chain took out is told when it comes back (WISP 903 § Leaving and removal): the commits from where
@@ -28507,10 +26952,11 @@ var CommunitySession = class {
28507
26952
  continue;
28508
26953
  }
28509
26954
  try {
28510
- await this.commit("leave", {
26955
+ const { hash } = await this.commit("leave", {
28511
26956
  s: request.s,
28512
26957
  ls: request.ls
28513
26958
  }, now);
26959
+ this.markLeft(hash, request.s);
28514
26960
  done++;
28515
26961
  } catch {}
28516
26962
  this.state.pendingLeaves = this.state.pendingLeaves.filter((r) => r !== request);
@@ -28519,6 +26965,25 @@ var CommunitySession = class {
28519
26965
  return done;
28520
26966
  });
28521
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
+ }
28522
26987
  out(status, reason) {
28523
26988
  this.state.status = status;
28524
26989
  this.state.statusReason = reason;
@@ -28842,6 +27307,7 @@ var CommunitySession = class {
28842
27307
  return false;
28843
27308
  case "group-meta": return this.receiveMeta(from, raw);
28844
27309
  case "group-leave": return this.receiveLeave(frame);
27310
+ case "group-left": return this.receiveLeft(frame);
28845
27311
  case "group-entry": {
28846
27312
  if (frame.to !== this.myKey || !isSealed(frame.s) || frame.x !== this.currentEntryKey()) return false;
28847
27313
  const seed = openSecret(this.identity.seed, this.myKey, frame.s, entryAad(this.id, this.myKey));
@@ -28870,7 +27336,7 @@ var CommunitySession = class {
28870
27336
  return false;
28871
27337
  }
28872
27338
  if (!verify(fromBase64Url(raw.sig), messageSigned(raw), publicKeyFromZ32(raw.s))) return false;
28873
- if (this.wasExpelled(raw.s)) return false;
27339
+ if (!this.heardAfterOut(raw)) return false;
28874
27340
  const found = this.commitByShort(raw.e, raw.h);
28875
27341
  if (!found) {
28876
27342
  this.park(from, raw);
@@ -29110,16 +27576,22 @@ var CommunitySession = class {
29110
27576
  const have = frame.have && typeof frame.have === "object" ? frame.have : {};
29111
27577
  const handed = /* @__PURE__ */ new Set();
29112
27578
  const unheard = new Set(this.state.unheard ?? []);
27579
+ const marked = /* @__PURE__ */ new Set();
29113
27580
  for (const stored of this.state.store) {
29114
27581
  if (stored.s === from) continue;
29115
27582
  const high = have[stored.s]?.[seenKey(stored.e, stored.h)];
29116
27583
  if (Number.isSafeInteger(high) && high >= stored.n) continue;
29117
- if (this.wasExpelled(stored.s)) continue;
27584
+ if (!this.heardAfterOut(stored)) continue;
29118
27585
  const found = this.commitByShort(stored.e, stored.h);
29119
27586
  if (!found) continue;
29120
27587
  const reads = rosterHas(this.rosterAt(found.hash) ?? [], from), mine = stored.s === this.myKey && unheard.has(frameKey(stored));
29121
27588
  if (!reads && !(mine && rosterHas(this.roster, from))) continue;
29122
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
+ }
29123
27595
  answer.push(stored);
29124
27596
  if (mine && reads) unheard.delete(frameKey(stored));
29125
27597
  }
@@ -29781,8 +28253,63 @@ function appDigest(manifestBytes) {
29781
28253
  return toBase64Url(sha256(manifestBytes));
29782
28254
  }
29783
28255
  var u32 = (b, at) => (b[at] << 24 >>> 0) + (b[at + 1] << 16) + (b[at + 2] << 8) + b[at + 3];
29784
- /** Reads and checks a bundle (the order of the checks is part of the format: see above). */
29785
- function readAppBundle(bytes) {
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) {
29786
28313
  if (bytes.length > APP_BUNDLE_LIMITS.bundleBytes) return {
29787
28314
  ok: false,
29788
28315
  reason: "too-large"
@@ -29841,17 +28368,18 @@ function readAppBundle(bytes) {
29841
28368
  };
29842
28369
  const signed = verifyAppSignature(APP_PREFIXES.app, manifestBytes, statement.signature, manifest.publisher);
29843
28370
  if (!signed.ok) return signed;
29844
- const files = /* @__PURE__ */ new Map();
29845
- for (const f of manifest.files) {
29846
- const content = bytes.subarray(at, at + f.size);
29847
- at += f.size;
29848
- if (toBase64Url(sha256(content)) !== f.sha256) return {
29849
- ok: false,
29850
- reason: "file-hash",
29851
- detail: f.path
29852
- };
29853
- files.set(f.path, content);
29854
- }
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) {
29855
28383
  const icon = files.get(APP_ICON_PATH);
29856
28384
  if (icon) {
29857
28385
  const size = imageSize(icon);
@@ -29872,11 +28400,54 @@ function readAppBundle(bytes) {
29872
28400
  manifest,
29873
28401
  manifestBytes,
29874
28402
  digest: appDigest(manifestBytes),
29875
- signature: statement.signature,
28403
+ signature,
29876
28404
  files
29877
28405
  }
29878
28406
  };
29879
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
+ }
29880
28451
  /** Puts a bundle's parts together, checking nothing: for builders, and for tests that need a broken bundle. */
29881
28452
  function assembleAppBundle(manifestBytes, statementBytes, files) {
29882
28453
  const total = MAGIC.length + 8 + manifestBytes.length + statementBytes.length + files.reduce((s, f) => s + f.length, 0);
@@ -30238,6 +28809,6 @@ function appStoreDecision(held, read) {
30238
28809
  return read.digest === held.digest ? "same" : "equivocation";
30239
28810
  }
30240
28811
  //#endregion
30241
- export { parseCommunityEdit as $, receivedTimestamp as $a, identityFromSeed as $c, DHT_FILE_TOO_LARGE as $i, readGroupHave as $n, HANDOFF_PAKE as $o, vapidKeysMatch as $r, verifyEnrollProof as $s, signDidDhtPacket as $t, encodeCommunityLink as A, reactionEmoji as Aa, signTurnPacket as Ac, DHT_TEXT_CAPABILITY as Ai, resolveLightningDestination as An, webLocalFetch as Ao, FILE_LIMITS as Ar, readHandoffTurnFrame as As, DidError as At, GroupWakeLimiter as B, PR_CHECKS as Ba, normalizeRelayUrl as Bc, identityRevocationValue as Bi, INVITE_TAKEN as Bn, extractParamsFromSdp as Bo, mayPin as Br, ENROLL_DONE as Bs, didWebHost as Bt, openGroupTyping as C, readForwarded as Ca, classifyTurnRead as Cc, normalizeDomain as Ci, normalizePubkyKey as Cn, DHT_POLL_INTERVALS as Co, pickHubs as Cr, newHandoffId as Cs, parseAtprotoDidDocument as Ct, GROUP_READ_NOTE_COMMUNITY as D, ReactionWindow as Da, firstDeviceSetSecret as Dc, txtValue as Di, homeserverWebEndpoint as Dn, GhostlyHttpError as Do, readLobby as Dr, readHandoffHave as Ds, DID_DOCUMENT_MAX_BYTES as Dt, CommunitySession as E, REACTION_LIMITS as Ea, TURN_MAX as Ec, recordsName as Ei, pubkyProofPath as En, presenceSeenAt as Eo, readBeaconHead as Er, readHandoffDone as Es, verifyAtprotoRecordProof as Et, knockIdentity as F, pairedReplyAuthor as Fa, turnStatedVerifier as Fc, IDENTITY_REVOCATION_LABEL as Fi, findEndpoint as Fn, RTC_CONFIG as Fo, TypingSender as Fr, readSetUpdate as Fs, didSignedMessages as Ft, GROUP_REACTED_FRAME as G, cardLine as Ga, withRequestOptions as Gc, lastSharedWithMe as Gi, GROUP_FILE_LIMITS as Gn, LEGACY_SERVICES as Go, WAKE_CALL_INTERVAL_MS as Gr, enrollCancelReason as Gs, signingKeys as Gt, groupWakes as H, RUN_RESULTS as Ha, traceLink as Hc, newIdentityBinding as Hi, pairedMessageFrame as Hn, signalHasVideo as Ho, pinIsNewer as Hr, ENROLL_HELLO as Hs, parseDid as Ht, knockRecords as I, readReply as Ia, turnVerifierMark as Ic, IdentityCheckUnavailable as Ii, isFederationId as In, answersOffer as Io, sanitizeTypingStatus as Ir, setAckFrame as Is, didWebDocumentUrl as It, frameBytes as J, sameButtonText as Ja, createRelayPayload as Jc, validExternalKey as Ji, GROUP_WANT_NO_FRAME as Jn, HANDOFF_CANCEL as Jo, newWakeToken as Jr, enrollGrantFrame as Js, didDhtDocument as Jt, GROUP_READ_NOTE as K, cardUrl as Ka, PacketTooLargeError as Kc, PeerProofs as Ki, GROUP_HAVE_FRAME as Kn, serviceIdFromName as Ko, WakeLimiter as Kr, enrollDigits as Ks, staticDidDocument as Kt, mergeKnocks as L, replySnippet as La, DEFAULT_RELAYS as Lc, IdentityExchange as Li, parseCashuRequestPayload as Ln, buildSdpFromSignal as Lo, typingActivity as Lr, setUpdateFrame as Ls, didWebFile as Lt, decodeGroupEntryLink as M, readReaction as Ma, turnInstance as Mc, dialDescriptors as Mi, ENDPOINT as Mn, decodeControl as Mo, transferStalled as Mr, sealHandoffPiece as Ms, checkDidSignature as Mt, encodeGroupEntryLink as N, validReactionNumber as Na, turnKeys as Nc, IDENTITY_MAX_EVIDENCE as Ni, cashuRequestPayload as Nn, encodeControl as No, TYPING_KINDS as Nr, SET_ACK as Ns, didGhostlyServiceEntry as Nt, communityHasFrame as O, nextReactionNumber as Oa, nextTurnPosition as Oc, wellKnownUrl as Oi, pubkyHomeserverOf as On, formatLocalTarget as Oo, shouldBeHub as Or, readHandoffHello as Os, DID_FILE_MAX_BYTES as Ot, entryParams as P, wireReaction as Pa, turnName as Pc, IDENTITY_MAX_VALIDITY as Pi, fedimintRequestPayload as Pn, CALL_SIGNAL_MAX_AGE_MS as Po, TYPING_REFRESH_MS as Pr, SET_UPDATE as Ps, didJwsAlg as Pt, carryMentions as Q, heardTime as Qa, createIdentity as Qc, irohRelayUrl as Qi, readGroupFileMeta as Qn, HANDOFF_OFFER as Qo, checkPushEndpoint as Qr, readEnrollHello as Qs, resolveDidDht as Qt, readKnocks as R, BUTTON_ID as Ra, RelayTransport as Rc, boundVerifiedIdentity as Ri, parseFedimintRequestPayload as Rn, callRtcConfig as Ro, GROUP_PIN_FRAME as Rr, signTombstone as Rs, didWebFileNames as Rt, groupTypingEpoch as S, forwardedAgain as Sa, TURN_SETTLE_MS as Sc, nip05RootKey as Si, newPubkyProofFolder as Sn, DirectPathWatch as So, newerHead as Sr, handoffVersions as Ss, normalizeAtprotoHandle as St, COMMUNITY_LIMITS as T, GROUP_REACTION_FRAME as Ta, TURN_LAST_SEQUENCE as Tc, recordsFromWellKnown as Ti, pubkyProofFile as Tn, RELAY_POLL_INTERVALS as To, readBeacon as Tr, readHandoffCancel as Ts, AtprotoProofError as Tt, parseGroupWakeFrame as U, STATUS_CARD_LIMITS as Ua, budgetRetryMs as Uc, receivedIdentityStatus as Ui, engineError as Un, emptyLinkRecords as Uo, pinNumberHolds as Ur, ENROLL_PROOF as Us, parseDidDocument as Ut, groupWakeFrame as V, PR_STATES as Va, setLinkTraceSink as Vc, identityStatement as Vi, LIVENESS_PING_MS as Vn, parseCallSignal as Vo, nextPinNumber as Vr, ENROLL_GRANT as Vs, normalizeDid as Vt, GROUP_LIMITS as W, buttonLabelClash as Wa, isDiscoveryBudgetError as Wc, revokesIdentity as Wi, engineText as Wn, HTTP_SERVICE_PROTO as Wo, readPin as Wr, enrollCancelFrame as Ws, parseDidSignaturePaste as Wt, GROUP_EDIT_FRAME as X, arrivalKey as Xa, openRelayPayload as Xc, automaticTransport as Xi, groupFileFallback as Xn, HANDOFF_HAVE as Xo, relayRequest as Xr, enrollProofFrame as Xs, encodeDidDhtDeactivated as Xt, COMMUNITY_EDIT_FRAME as Y, statusCardText as Ya, newerPacket as Yc, TRANSPORTS as Yi, fetchesByItself as Yn, HANDOFF_DONE as Yo, parseWakeFrame as Yr, enrollHelloFrame as Ys, didDhtFromPublicKey as Yt, GROUP_EDIT_TEXT_BYTES as Z, claimedTime as Za, parseRelayPayload as Zc, irohDescriptor as Zi, isGroupMessageId as Zn, HANDOFF_HELLO as Zo, wakeRequest as Zr, readEnrollGrant as Zs, encodeDidDhtPacket as Zt, checkAppManifest as _, EDIT_BUFFER_MS as _a, newDeviceSetSecret as _c, dnsProofName as _i, sparkInvoiceDetails as _n, MEMBER_KEY as _o, freshHubs as _r, handoffOfferFrame as _s, atprotoDidMethod as _t, planAppUpdate as a, readImageMeta as aa, decodeInviteCode as ac, STATUS_CARD_CAPABILITY as ai, bytesEqual as al, SEPOLIA_TEST_USDT_FAUCET as an, checkAppRevokeStatement as ao, SIGNAL_HOLD_MS as ar, handoffAttemptAllowed as as, parseSshPublicKey as at, GROUP_TYPING_FRAME as b, dhtEditId as ba, TURN_PUT_WINDOW_MS as bc, encodeDnsQuery as bi, isPubkyKey as bn, verifyCommitSignature as bo, mergeBeacon as br, handoffRetryAfter as bs, checkAtprotoProofRecord as bt, readAppStore as c, sanitizeFileName as ca, inviteOwnership as cc, HOLD_DATED_BACK_MS as ci, toBase64Url as cl, WALLET_NETWORKS as cn, readAppRevocation as co, readGroupSignal as cr, handoffBusyFrame as cs, sshSignCommand as ct, APP_BUNDLE_LIMITS as d, VOICE_LIMITS as da, HANDOFF_CAPABILITY as dc, HoldRefusedError as di, utf8Encode as dl, formatPaymentAmount as dn, signAppRevocation as do, meshHubs as dr, handoffContextProof as ds, BitcoinAddressError as dt, DHT_MESSAGE_TTL as ea, createDeviceInvite as ec, chatAppId as ei, identityFromSeedB64 as el, ETHEREUM_USDT as en, shownTime as eo, readGroupWant as er, HANDOFF_PIECE_BYTES as es, mentionsMember as et, APP_BUNDLE_MAGIC as f, baseMime as fa, deviceEchoFrame as fc, isHoldUrl as fi, validatePaymentTarget as fn, canonicalJson as fo, pickMeshHubs as fr, handoffDataFrame as fs, decodeAnyBitcoinAddress as ft, assembleAppBundle as g, parseVideoMeta as ga, devicePingFrame as gc, decodeDnsResponse as gi, sparkAddressKind as gn, MAX_GROUP_CHAIN as go, doorHubs as gr, handoffManifestFrames as gs, ATPROTO_PROOF_SCOPE as gt, appViewOf as h, voicePeaksOf as ha, deviceLinkPairing as hc, DOMAIN_FILE_MAX_BYTES as hi, SPARK_PROVIDER as hn, GROUP_MEMBER_CAP as ho, beaconRecords as hr, handoffHelloFrame as hs, ATPROTO_PROOF_COLLECTION as ht, checkAppBeforeRun as i, parseImageMeta as ia, createChatInvite as ic, EDIT_CAPABILITY as ii, sanitizeAvatar as il, SEPOLIA_TEST_USDT as in, appRef as io, SIGNAL_FRESH_MS as ir, filePartName as is, SshSigError as it, KNOCK_TTL_MS as j, reactionIsNewer as ja, signTurnRelease as jc, capsDescriptors as ji, decodeBolt11 as jn, LIMITS as jo, transferEnded as jr, readHandoffVerified as js, checkDidJws as jt, decodeCommunityLink as k, queueReaction as ka, readTurnPacket as kc, CapsExchange as ki, requestLnurlInvoice as kn, parseLocalTarget as ko, ChatFiles as kr, readHandoffPake as ks, DID_JWS_MAX_LENGTH as kt, readAppStoreSteps as l, sanitizeMime as la, DEVICES_CAPABILITY as lc, HOLD_LIMITS as li, toZ32 as ll, assertTokenUnits as ln, readAppRevocations as lo, MESH_HUBS as lr, handoffCancelFrame as ls, verifySshSignature as lt, appDigest as m, parseVoiceMeta as ma, deviceKeyZ32 as mc, readManifest as mi, SPARK_NETWORKS as mn, readCanonicalJson as mo, beaconKeys as mr, handoffEphemeral as ms, ATPROTO_FULL_SCOPE as mt, appStoreDecision as n, DHT_FILE_CAPABILITY as na, readDeviceInvite as nc, APPS_CAPABILITY as ni, verify as nl, ONCHAIN_PROVIDER as nn, APP_STATEMENT_LIMITS as no, CarriedTransport as nr, HANDOFF_TIMINGS as ns, SSHSIG_MAX_ARMOR as nt, APP_STORE_LIMITS as o, formatFileSize as oa, encodeInviteCode as oc, UPGRADE_CAPABILITY as oi, fromBase64Url as ol, TEST_USDT_FAUCET_AMOUNT as on, isAppKey as oo, SIGNAL_REPLY_MS as or, handoffAttemptFailed as os, parseSshSignature as ot, APP_ICON_PATH as p, fileMessageText as pa, deviceEchoNonce as pc, newMailbox as pi, walletNetworkOf as pn, canonicalJsonBytes as po, COMMUNITY_TOPOLOGY as pr, handoffDigest as ps, ATPROTO_DECLARED_SCOPE as pt, GroupSession as q, checkStatusCard as qa, RELAY_PAYLOAD_MAX_BYTES as qc, emptyProofLedger as qi, GROUP_WANT_FRAME as qn, HANDOFF_ACK as qo, deviceWakeRequest as qr, enrollDoneFrame as qs, strictBase64Url as qt, appUpdateDecision as r, dhtFileId as ra, OwnInviteError as rc, BUTTONS_CAPABILITY as ri, MAX_AVATAR_LENGTH as rl, PaymentPreflightError as rn, appFingerprint as ro, GROUP_SIGNAL_FRAME as rr, HANDOFF_VERIFIED as rs, SSHSIG_NAMESPACE as rt, checkAppStoreIndex as s, safeBlobType as sa, inviteLink as sc, HOLD_CAPABILITY as si, randomBytes as sl, USDT_PUBLIC_RPC as sn, isAppRef as so, groupSignalFrame as sr, handoffAttemptSucceeded as ss, sameSshKey as st, appRevocationFor as t, peekDhtMailbox as ta, deviceInviteJoinerParams as tc, isAppVersion as ti, publicKeyFromZ32 as tl, EVM_TEST_CHAINS as tn, APP_PREFIXES as to, readGroupWantNo as tr, HANDOFF_REQUEST as ts, groupName as tt, signAppStore as u, PLAYABLE_AUDIO as ua, ENROLL_CAPABILITY as uc, HoldKeys as ui, utf8Decode as ul, assertWholeSats as un, signAppObject as uo, mayBeHub as ur, handoffContext as us, verifyBitcoinMessage as ut, isAppUrl as v, EDIT_RATE_WINDOW_MS as va, seedSigner as vc, domainTxtRecord as vi, isBitcoinAddress as vn, commitHash as vo, lobbyKeys as vr, handoffPakeFrame as vs, atprotoProofRecord as vt, sealGroupTyping as w, COMMUNITY_REACTION_FRAME as wa, turnPutSummary as wc, recordsFromTxt as wi, pubkyProofCapability as wn, EXPECT_PEER_MS as wo, rankHubs as wr, readHandoffAck as ws, shortAtprotoDid as wt, GroupTypingBoard as x, parseLinkPreview as xa, TURN_RAISE_READ_TIMEOUT_MS as xc, isPublicAddress as xi, isPubkyProofFolder as xn, edgeParams as xo, mergeLobby as xr, handoffStreamKey as xs, isAtprotoDid as xt, readAppBundle as y, RateWindow as ya, webCryptoSigner as yc, domainWellKnownFile as yi, PUBKY_PROOF_MAX_BYTES as yn, rosterHas as yo, lobbyRecords as yr, handoffReleaseFrame as ys, atprotoProofRkey as yt, GROUP_WAKE_FRAME as z, ITEM_STATES as za, currentRelays as zc, emptyIdentityLedger as zi, GhostLink as zn, callSignalHeardAt as zo, PIN_LIMITS as zr, verifySetUpdate as zs, didWebFileUrl as zt };
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 };
30242
28813
 
30243
- //# sourceMappingURL=src-CbLkHfz8.js.map
28814
+ //# sourceMappingURL=src-DhfGgAUu.js.map