@ghostlytools/cli 1.1.4 → 1.1.6

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 (35) hide show
  1. package/README.md +8 -2
  2. package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
  3. package/dist/assets/{endpoint-CaSbSFDw.js → endpoint-D_3bYafG.js} +76 -3
  4. package/dist/assets/endpoint-D_3bYafG.js.map +1 -0
  5. package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -1
  6. package/dist/assets/{fileBytes-DxiJHmTk.js → fileBytes-3AQ2tJzW.js} +5 -17
  7. package/dist/assets/{fileBytes-DxiJHmTk.js.map → fileBytes-3AQ2tJzW.js.map} +1 -1
  8. package/dist/assets/{fileBytes-BQ84L9Fe.js → fileBytes-CAZDydIe.js} +2 -2
  9. package/dist/assets/{fileBytes-BQ84L9Fe.js.map → fileBytes-CAZDydIe.js.map} +1 -1
  10. package/dist/assets/{fileBytesIdb-DQkQ10PS.js → fileBytesIdb-CmG00qE-.js} +3 -3
  11. package/dist/assets/{fileBytesIdb-DQkQ10PS.js.map → fileBytesIdb-CmG00qE-.js.map} +1 -1
  12. package/dist/assets/{fileBytesOpfs-BFfR_68b.js → fileBytesOpfs-CTgqt-p3.js} +3 -3
  13. package/dist/assets/{fileBytesOpfs-BFfR_68b.js.map → fileBytesOpfs-CTgqt-p3.js.map} +1 -1
  14. package/dist/assets/{hyperdhtRelay-BoxPHp7v.js → hyperdhtRelay-CnEkFl8R.js} +3 -3
  15. package/dist/assets/{hyperdhtRelay-BoxPHp7v.js.map → hyperdhtRelay-CnEkFl8R.js.map} +1 -1
  16. package/dist/assets/{idb-Co2lObpU.js → idb-BIdus0WX.js} +32 -8
  17. package/dist/assets/idb-BIdus0WX.js.map +1 -0
  18. package/dist/assets/joinTrace-CO8mlhNO.js +29 -0
  19. package/dist/assets/joinTrace-CO8mlhNO.js.map +1 -0
  20. package/dist/assets/{mainline-dkA94eh2.js → mainline-DQxDSWXm.js} +2 -2
  21. package/dist/assets/{mainline-dkA94eh2.js.map → mainline-DQxDSWXm.js.map} +1 -1
  22. package/dist/assets/{restoredRows-C1O9FfA4.js → restoredRows-lhTKGmCO.js} +2 -2
  23. package/dist/assets/{restoredRows-C1O9FfA4.js.map → restoredRows-lhTKGmCO.js.map} +1 -1
  24. package/dist/assets/rolldown-runtime-8H4AJuhK.js +14 -0
  25. package/dist/assets/{server-CXFTDnFI.js → server-HMzdsyn_.js} +1720 -284
  26. package/dist/assets/server-HMzdsyn_.js.map +1 -0
  27. package/dist/assets/{src-K3NijMq-.js → src-DtxVo2Rm.js} +2299 -164
  28. package/dist/assets/src-DtxVo2Rm.js.map +1 -0
  29. package/dist/ghostly.mjs +1944 -64
  30. package/dist/ghostly.mjs.map +1 -1
  31. package/package.json +1 -1
  32. package/dist/assets/endpoint-CaSbSFDw.js.map +0 -1
  33. package/dist/assets/idb-Co2lObpU.js.map +0 -1
  34. package/dist/assets/server-CXFTDnFI.js.map +0 -1
  35. package/dist/assets/src-K3NijMq-.js.map +0 -1
@@ -897,6 +897,8 @@ function normalizeRelayUrl(input) {
897
897
  return null;
898
898
  }
899
899
  }
900
+ /** A relay the breaker leaves alone for failing (`RelayBreaker`): nothing was asked of it now. */
901
+ var RelayLeftAloneError = class extends Error {};
900
902
  var RelayTransport = class {
901
903
  relays;
902
904
  timeoutMs;
@@ -1185,8 +1187,11 @@ var RelayTransport = class {
1185
1187
  return;
1186
1188
  }
1187
1189
  const held = results.map((r) => r.status === "rejected" && isDiscoveryBudgetError(r.reason) ? r.reason : null);
1190
+ const leftAlone = (r) => r.status === "rejected" && r.reason instanceof RelayLeftAloneError;
1191
+ const waits = held.filter((e) => e !== null);
1188
1192
  const reasons = results.map((r) => r.status === "rejected" ? String(r.reason) : "").join("; ");
1189
- if (held.every((e) => e !== null)) reject(new DiscoveryBudgetError(Math.min(...held.map((e) => e.retryInMs)), `Publish held back on every relay: ${reasons}`));
1193
+ if (waits.length === results.length) reject(new DiscoveryBudgetError(Math.min(...waits.map((e) => e.retryInMs)), `Publish held back on every relay: ${reasons}`));
1194
+ else if (waits.length && results.every((r, i) => held[i] !== null || leftAlone(r))) reject(new DiscoveryBudgetError(Math.min(...waits.map((e) => e.retryInMs)), `Publish held back on every relay not left alone: ${reasons}`));
1190
1195
  else reject(/* @__PURE__ */ new Error(`Publish failed on every relay: ${reasons}`));
1191
1196
  });
1192
1197
  });
@@ -1484,7 +1489,7 @@ var RelayTransport = class {
1484
1489
  const blocked = probe ? 0 : this.breaker.blockedFor(relay);
1485
1490
  if (blocked > 0) {
1486
1491
  if (this.breaker.blockedKind(relay) === "throttled") throw new DiscoveryBudgetError(blocked, "Discovery relay is throttling this address; retry shortly");
1487
- throw new Error(`${relay} is left alone after failing; asked again in ${Math.max(1, Math.ceil(this.breaker.askedAgainIn(relay, this.relays) / 1e3))} s`);
1492
+ throw new RelayLeftAloneError(`${relay} is left alone after failing; asked again in ${Math.max(1, Math.ceil(this.breaker.askedAgainIn(relay, this.relays) / 1e3))} s`);
1488
1493
  }
1489
1494
  if (!this.take(relay, who, pubKeyZ32)) throw new DiscoveryBudgetError(this.heldFor(relay, who));
1490
1495
  if (probe) this.breaker.beginAllDown(relay);
@@ -1961,13 +1966,13 @@ function turnName(name) {
1961
1966
  }
1962
1967
  return out;
1963
1968
  }
1964
- function u32$2(value) {
1969
+ function u32$3(value) {
1965
1970
  return Uint8Array.of(value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255);
1966
1971
  }
1967
1972
  /** What a release signs: `"ghostly-turn-release" || turnAddress(32) || turn(4) || toKey(32) || H(32)`. */
1968
1973
  function turnReleaseMessage(address, turn, toKey, h) {
1969
1974
  if (address.length !== 32 || toKey.length !== 32 || h.length !== 32) throw new Error("A release names 32-byte values");
1970
- return concatBytes$1(RELEASE_DOMAIN, address, u32$2(turn), toKey, h);
1975
+ return concatBytes$1(RELEASE_DOMAIN, address, u32$3(turn), toKey, h);
1971
1976
  }
1972
1977
  /** What the author signs: `"ghostly-turn" || turnAddress(32) || body[0..310]`. */
1973
1978
  function turnSignedMessage(address, unsigned) {
@@ -1985,8 +1990,8 @@ function encodeTurnFields(fields) {
1985
1990
  if (tombstone ? fields.active !== 255 : !held(fields.active)) throw new Error(tombstone ? "A tombstone names no active device" : "The active device has no slot");
1986
1991
  const body = /* @__PURE__ */ new Uint8Array(310);
1987
1992
  body[OFFSET.version] = 1;
1988
- body.set(u32$2(fields.turn), OFFSET.turn);
1989
- body.set(u32$2(fields.rev).subarray(1), OFFSET.rev);
1993
+ body.set(u32$3(fields.turn), OFFSET.turn);
1994
+ body.set(u32$3(fields.rev).subarray(1), OFFSET.rev);
1990
1995
  body[OFFSET.author] = fields.author;
1991
1996
  body[OFFSET.active] = fields.active;
1992
1997
  body[OFFSET.count] = fields.slots.filter(Boolean).length;
@@ -2238,7 +2243,7 @@ var TURN_PUT_WINDOW_MS = 1e4;
2238
2243
  var TURN_RAISE_READ_TIMEOUT_MS = 5e3;
2239
2244
  /** `T`: how long a device that raised the turn waits, from the end of its put, before the read that may make it active. `T >= P + 2V`. */
2240
2245
  var TURN_SETTLE_MS = 3e4;
2241
- function compareBytes$2(a, b) {
2246
+ function compareBytes$3(a, b) {
2242
2247
  for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) return a[i] - b[i];
2243
2248
  return a.length - b.length;
2244
2249
  }
@@ -2249,7 +2254,7 @@ function compareBytes$2(a, b) {
2249
2254
  * reader still agrees.
2250
2255
  */
2251
2256
  function keptAtEqualSequence(a, b) {
2252
- return compareBytes$2(a.record.instance, b.record.instance) || compareBytes$2(a.record.body, b.record.body) || compareBytes$2(a.payload, b.payload);
2257
+ return compareBytes$3(a.record.instance, b.record.instance) || compareBytes$3(a.record.body, b.record.body) || compareBytes$3(a.payload, b.payload);
2253
2258
  }
2254
2259
  var kept = (f) => ({
2255
2260
  record: f.read.record,
@@ -3358,7 +3363,7 @@ var HANDOFF_CANCEL_REASONS = [
3358
3363
  "stalled"
3359
3364
  ];
3360
3365
  var KEY$3 = /^[A-Za-z0-9_-]{43}$/;
3361
- var DIGEST$1 = KEY$3;
3366
+ var DIGEST$2 = KEY$3;
3362
3367
  var SIGNATURE = /^[A-Za-z0-9_-]{86}$/;
3363
3368
  var ID$5 = /^[A-Za-z0-9_-]{22}$/;
3364
3369
  var B64$4 = /^[A-Za-z0-9_-]*$/;
@@ -3368,15 +3373,15 @@ var VERSION_TEXT = /^[0-9A-Za-z.+_-]{1,40}$/;
3368
3373
  var PART = /^(db\/[A-Za-z0-9_-]{1,64}|local|devices|file\/[A-Za-z0-9_-]{43}|wallet\/[a-z0-9-]{1,32}\/[A-Za-z0-9_.-]{1,100})$/;
3369
3374
  /** The largest part the reader accepts: a file of WISP 501 has no limit beneath this. */
3370
3375
  var MAX_PART = 2 ** 50;
3371
- var isCount = (value, max = Number.MAX_SAFE_INTEGER) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= max;
3372
- var isText = (value, pattern) => typeof value === "string" && pattern.test(value);
3376
+ var isCount$1 = (value, max = Number.MAX_SAFE_INTEGER) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= max;
3377
+ var isText$2 = (value, pattern) => typeof value === "string" && pattern.test(value);
3373
3378
  var isHandoffPartName = (name) => typeof name === "string" && PART.test(name);
3374
3379
  /** The part a file is: `file/<its SHA-256, base64url>`. */
3375
3380
  var filePartName = (sha256B64) => `file/${sha256B64}`;
3376
3381
  /** The digest a file part names, or null for any other part. */
3377
- var filePartDigest = (name) => name.startsWith("file/") && DIGEST$1.test(name.slice(5)) ? name.slice(5) : null;
3382
+ var filePartDigest = (name) => name.startsWith("file/") && DIGEST$2.test(name.slice(5)) ? name.slice(5) : null;
3378
3383
  function isPart(value) {
3379
- return Array.isArray(value) && value.length === 3 && isHandoffPartName(value[0]) && isCount(value[1], MAX_PART) && isText(value[2], DIGEST$1) && (filePartDigest(value[0]) === null || filePartDigest(value[0]) === value[2]);
3384
+ 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]);
3380
3385
  }
3381
3386
  /** Names compared as UTF-8 byte strings, as `H` sorts them. */
3382
3387
  function compareNames(a, b) {
@@ -3404,7 +3409,7 @@ function sortParts(parts) {
3404
3409
  * and every part the taker already held and keeps, as `[name, size, sha256]` sorted by name. Keys are base64url.
3405
3410
  */
3406
3411
  function handoffDigest(turn, fromKey, toKey, parts) {
3407
- if (!isCount(turn, TURN_MAX)) throw new Error("A turn is at most 2^32 - 2");
3412
+ if (!isCount$1(turn, TURN_MAX)) throw new Error("A turn is at most 2^32 - 2");
3408
3413
  if (fromKey.length !== 32 || toKey.length !== 32) throw new Error("A device signing key is 32 bytes");
3409
3414
  for (const part of parts) if (!isPart(part)) throw new Error("A part is [name, size, sha256]");
3410
3415
  return sha256(utf8Encode(JSON.stringify([
@@ -3418,7 +3423,7 @@ function handoffDigest(turn, fromKey, toKey, parts) {
3418
3423
  /** What `handoff-verified` signs: `["ghostly-handoff-verified", turnAddress, N + 1, H]`, the bytes in base64url. */
3419
3424
  function handoffVerifiedMessage(turnAddress, turn, h) {
3420
3425
  if (turnAddress.length !== 32 || h.length !== 32) throw new Error("A turn address and H are 32 bytes");
3421
- if (!isCount(turn, TURN_MAX)) throw new Error("A turn is at most 2^32 - 2");
3426
+ if (!isCount$1(turn, TURN_MAX)) throw new Error("A turn is at most 2^32 - 2");
3422
3427
  return utf8Encode(JSON.stringify([
3423
3428
  "ghostly-handoff-verified",
3424
3429
  toBase64Url(turnAddress),
@@ -3470,7 +3475,7 @@ function handoffStreamKey(ownSecret, peerPublic, k, transcriptHash) {
3470
3475
  * does not open there. (XSalsa20-Poly1305 takes no associated data; the header inside the box does that job.)
3471
3476
  */
3472
3477
  function pieceHeader(part, offset) {
3473
- if (!isCount(offset, MAX_PART)) throw new Error("A piece's offset is a whole number of bytes");
3478
+ if (!isCount$1(offset, MAX_PART)) throw new Error("A piece's offset is a whole number of bytes");
3474
3479
  const header = /* @__PURE__ */ new Uint8Array(16);
3475
3480
  header.set(sha256(utf8Encode(`ghostly-handoff-piece/1${part}`)).subarray(0, 8), 0);
3476
3481
  new DataView(header.buffer).setBigUint64(8, BigInt(offset));
@@ -3484,7 +3489,7 @@ function sealHandoffPiece(key, part, offset, piece, nonce = randomBytes(24)) {
3484
3489
  if (key.length !== 32 || nonce.length !== 24) throw new Error("A stream key is 32 bytes and a nonce 24");
3485
3490
  return concatBytes$1(nonce, xsalsa20poly1305(key, nonce).encrypt(concatBytes$1(pieceHeader(part, offset), piece)));
3486
3491
  }
3487
- var isPins = (value) => !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length <= 16 && Object.entries(value).every(([type, version]) => /^[a-z0-9-]{1,32}$/.test(type) && isText(version, VERSION_TEXT));
3492
+ var isPins = (value) => !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length <= 16 && Object.entries(value).every(([type, version]) => /^[a-z0-9-]{1,32}$/.test(type) && isText$2(version, VERSION_TEXT));
3488
3493
  function handoffHelloFrame(hello) {
3489
3494
  return {
3490
3495
  t: HANDOFF_HELLO,
@@ -3493,12 +3498,12 @@ function handoffHelloFrame(hello) {
3493
3498
  }
3494
3499
  function readHandoffHello(frame) {
3495
3500
  if (frame.t !== "handoff-hello" || frame.v !== 1) return null;
3496
- if (!isText(frame.e, KEY$3) || fromBase64Url(frame.e).length !== 32) return null;
3497
- if (!isText(frame.app, VERSION_TEXT) || !isCount(frame.db, 1e6) || !isPins(frame.pins)) return null;
3501
+ if (!isText$2(frame.e, KEY$3) || fromBase64Url(frame.e).length !== 32) return null;
3502
+ if (!isText$2(frame.app, VERSION_TEXT) || !isCount$1(frame.db, 1e6) || !isPins(frame.pins)) return null;
3498
3503
  if (typeof frame.room !== "number" || !Number.isSafeInteger(frame.room) || frame.room < -1) return null;
3499
3504
  if (typeof frame.metered !== "boolean") return null;
3500
- if (frame.later !== void 0 && !isCount(frame.later)) return null;
3501
- if (frame.id !== void 0 && !isText(frame.id, ID$5)) return null;
3505
+ if (frame.later !== void 0 && !isCount$1(frame.later)) return null;
3506
+ if (frame.id !== void 0 && !isText$2(frame.id, ID$5)) return null;
3502
3507
  const kind = DEVICE_KINDS.includes(frame.kind) ? frame.kind : "web";
3503
3508
  return {
3504
3509
  v: 1,
@@ -3524,8 +3529,8 @@ var handoffOfferFrame = (turn, id, bytes) => ({
3524
3529
  });
3525
3530
  function readHandoffTurnFrame(frame) {
3526
3531
  if (frame.t !== "handoff-request" && frame.t !== "handoff-offer") return null;
3527
- if (!isCount(frame.turn, TURN_MAX) || !isText(frame.id, ID$5)) return null;
3528
- if (frame.t === "handoff-offer") return isCount(frame.bytes) ? {
3532
+ if (!isCount$1(frame.turn, TURN_MAX) || !isText$2(frame.id, ID$5)) return null;
3533
+ if (frame.t === "handoff-offer") return isCount$1(frame.bytes) ? {
3529
3534
  turn: frame.turn,
3530
3535
  id: frame.id,
3531
3536
  bytes: frame.bytes
@@ -3558,8 +3563,8 @@ function readHandoffPake(frame) {
3558
3563
  n: 3,
3559
3564
  wrong: true
3560
3565
  } : null;
3561
- if (!isText(frame.m, B64$4) || !frame.m.length || frame.m.length > 4096) return null;
3562
- if (frame.n === 3 && !isText(frame.c, KEY$3)) return null;
3566
+ if (!isText$2(frame.m, B64$4) || !frame.m.length || frame.m.length > 4096) return null;
3567
+ if (frame.n === 3 && !isText$2(frame.c, KEY$3)) return null;
3563
3568
  return {
3564
3569
  n: frame.n,
3565
3570
  m: frame.m,
@@ -3567,12 +3572,12 @@ function readHandoffPake(frame) {
3567
3572
  };
3568
3573
  }
3569
3574
  function readHandoffHave(frame) {
3570
- if (frame.t !== "handoff-have" || !Array.isArray(frame.d) || frame.d.length > 300 || !frame.d.every((d) => isText(d, DIGEST$1))) return null;
3575
+ if (frame.t !== "handoff-have" || !Array.isArray(frame.d) || frame.d.length > 300 || !frame.d.every((d) => isText$2(d, DIGEST$2))) return null;
3571
3576
  const p = {};
3572
3577
  if (frame.p !== void 0) {
3573
3578
  if (!frame.p || typeof frame.p !== "object" || Array.isArray(frame.p) || Object.keys(frame.p).length > 300) return null;
3574
3579
  for (const [name, offset] of Object.entries(frame.p)) {
3575
- if (!isHandoffPartName(name) || !isCount(offset, MAX_PART)) return null;
3580
+ if (!isHandoffPartName(name) || !isCount$1(offset, MAX_PART)) return null;
3576
3581
  p[name] = offset;
3577
3582
  }
3578
3583
  }
@@ -3615,7 +3620,7 @@ var handoffDataFrame = (part, offset, sealed) => ({
3615
3620
  d: toBase64Url(sealed)
3616
3621
  });
3617
3622
  function readHandoffAck(frame) {
3618
- if (frame.t !== "handoff-ack" || !isHandoffPartName(frame.p) || !isCount(frame.o, MAX_PART)) return null;
3623
+ if (frame.t !== "handoff-ack" || !isHandoffPartName(frame.p) || !isCount$1(frame.o, MAX_PART)) return null;
3619
3624
  return {
3620
3625
  part: frame.p,
3621
3626
  offset: frame.o
@@ -3623,7 +3628,7 @@ function readHandoffAck(frame) {
3623
3628
  }
3624
3629
  /** `H` of a `handoff-verified` that B's key signed for this turn address and turn, or null. */
3625
3630
  function readHandoffVerified(frame, turnAddress, turn, takerKey) {
3626
- if (frame.t !== "handoff-verified" || !isText(frame.h, DIGEST$1) || !isText(frame.s, SIGNATURE)) return null;
3631
+ if (frame.t !== "handoff-verified" || !isText$2(frame.h, DIGEST$2) || !isText$2(frame.s, SIGNATURE)) return null;
3627
3632
  const h = fromBase64Url(frame.h);
3628
3633
  try {
3629
3634
  return verify(fromBase64Url(frame.s), handoffVerifiedMessage(turnAddress, turn, h), takerKey) ? h : null;
@@ -3636,7 +3641,7 @@ var handoffReleaseFrame = (release) => ({
3636
3641
  ...release
3637
3642
  });
3638
3643
  function readHandoffDone(frame) {
3639
- return frame.t === "handoff-done" && isCount(frame.seq) ? frame.seq : null;
3644
+ return frame.t === "handoff-done" && isCount$1(frame.seq) ? frame.seq : null;
3640
3645
  }
3641
3646
  /**
3642
3647
  * Whether two devices can hand over (WISP 06 § Versions), seen from the taker: a taker whose database is older than
@@ -6141,6 +6146,8 @@ var DataLink = class {
6141
6146
  disconnectTimer = null;
6142
6147
  /** The offer this side answered in the current attempt, when it took it (this clock), and how many times it answered it again. */
6143
6148
  answered = null;
6149
+ /** Answers refused since a connection last opened, each followed by a new offer at once (`ANSWER_REFUSED_REDIALS`). */
6150
+ refusedAnswers = 0;
6144
6151
  constructor(options) {
6145
6152
  this.options = options;
6146
6153
  }
@@ -6246,8 +6253,8 @@ var DataLink = class {
6246
6253
  sdp: this.remoteSdp
6247
6254
  });
6248
6255
  if (this.pc === pc && this.state === "offering") this.setState("connecting");
6249
- } catch {
6250
- if (this.pc === pc) this.reset();
6256
+ } catch (error) {
6257
+ if (this.pc === pc) this.answerRefused(error);
6251
6258
  }
6252
6259
  }
6253
6260
  }
@@ -6293,6 +6300,10 @@ var DataLink = class {
6293
6300
  * else goes back to idle.
6294
6301
  */
6295
6302
  failed() {
6303
+ if (this.state === "offering" && this.remoteSdp) {
6304
+ this.answerRefused("failed");
6305
+ return;
6306
+ }
6296
6307
  const answered = this.state === "connecting" ? this.answered : null;
6297
6308
  const left = answered ? CONNECT_TIMEOUT_MS - (Date.now() - answered.at) : 0;
6298
6309
  const evidence = this.directEvidence();
@@ -6310,6 +6321,18 @@ var DataLink = class {
6310
6321
  this.reset();
6311
6322
  if (evidence) this.options.onDirect?.(evidence);
6312
6323
  }
6324
+ /** The answer to this side's offer did not go in (`ANSWER_REFUSED_REDIALS`): the attempt ends, and the caller may offer again now. */
6325
+ answerRefused(error) {
6326
+ const again = this.refusedAnswers < 4;
6327
+ traceLink(this.options.myPubKeyZ32, "answer-refused", {
6328
+ error: error instanceof Error ? error.message : String(error),
6329
+ again
6330
+ });
6331
+ this.reset();
6332
+ if (!again) return;
6333
+ this.refusedAnswers++;
6334
+ this.options.onAnswerReplaced?.();
6335
+ }
6313
6336
  async answer(offer) {
6314
6337
  this.setState("answering");
6315
6338
  this.options.setFastPoll(true);
@@ -6388,6 +6411,7 @@ var DataLink = class {
6388
6411
  if (this.pc !== pc) return;
6389
6412
  this.clearTimers();
6390
6413
  this.answered = null;
6414
+ this.refusedAnswers = 0;
6391
6415
  this.fullGather = false;
6392
6416
  this.setState("open");
6393
6417
  this.options.setFastPoll(false);
@@ -6761,6 +6785,311 @@ function rememberAlone(alone, key, present) {
6761
6785
  }
6762
6786
  var GROUP_ADMIN_OFF_ERROR = "Managing this group is off on this device. Turn on Manage groups from this device first.";
6763
6787
  //#endregion
6788
+ //#region ../core/src/canonicalJson.ts
6789
+ /** True when `s` holds a lone UTF-16 surrogate, which I-JSON (and so JCS) does not allow. */
6790
+ function hasLoneSurrogate(s) {
6791
+ for (let i = 0; i < s.length; i++) {
6792
+ const c = s.charCodeAt(i);
6793
+ if (c >= 55296 && c <= 56319) {
6794
+ const next = s.charCodeAt(i + 1);
6795
+ if (next >= 56320 && next <= 57343) {
6796
+ i++;
6797
+ continue;
6798
+ }
6799
+ return true;
6800
+ }
6801
+ if (c >= 56320 && c <= 57343) return true;
6802
+ }
6803
+ return false;
6804
+ }
6805
+ function write(value, depth) {
6806
+ if (depth > 64) throw new Error("JSON nested deeper than 64 levels");
6807
+ if (value === null) return "null";
6808
+ if (typeof value === "boolean") return value ? "true" : "false";
6809
+ if (typeof value === "number") {
6810
+ if (!Number.isFinite(value)) throw new Error("JSON numbers must be finite");
6811
+ return JSON.stringify(value);
6812
+ }
6813
+ if (typeof value === "string") {
6814
+ if (hasLoneSurrogate(value)) throw new Error("A JSON string holds a lone surrogate");
6815
+ return JSON.stringify(value);
6816
+ }
6817
+ if (Array.isArray(value)) return `[${value.map((v) => write(v, depth + 1)).join(",")}]`;
6818
+ if (typeof value === "object") {
6819
+ const record = value;
6820
+ return `{${Object.keys(value).filter((k) => record[k] !== void 0).sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map((k) => {
6821
+ if (hasLoneSurrogate(k)) throw new Error("A JSON key holds a lone surrogate");
6822
+ return `${JSON.stringify(k)}:${write(record[k], depth + 1)}`;
6823
+ }).join(",")}}`;
6824
+ }
6825
+ throw new Error(`Not a JSON value: ${typeof value}`);
6826
+ }
6827
+ /** The canonical text of a JSON value. Throws on a value JSON cannot hold (a non-finite number, a lone surrogate). */
6828
+ function canonicalJson(value) {
6829
+ return write(value, 0);
6830
+ }
6831
+ /** The canonical UTF-8 bytes of a JSON value. */
6832
+ function canonicalJsonBytes(value) {
6833
+ return utf8Encode(canonicalJson(value));
6834
+ }
6835
+ /**
6836
+ * Reads bytes that must be the canonical form of a JSON value: `not-json` when they are not UTF-8 JSON, `not-canonical`
6837
+ * when writing the value again gives other bytes.
6838
+ */
6839
+ function readCanonicalJson(bytes) {
6840
+ let value;
6841
+ try {
6842
+ value = JSON.parse(utf8Decode(bytes));
6843
+ } catch (error) {
6844
+ return {
6845
+ ok: false,
6846
+ reason: "not-json",
6847
+ detail: error instanceof Error ? error.message : String(error)
6848
+ };
6849
+ }
6850
+ let again;
6851
+ try {
6852
+ again = canonicalJsonBytes(value);
6853
+ } catch (error) {
6854
+ return {
6855
+ ok: false,
6856
+ reason: "not-canonical",
6857
+ detail: error instanceof Error ? error.message : String(error)
6858
+ };
6859
+ }
6860
+ if (!bytesEqual(again, bytes)) return {
6861
+ ok: false,
6862
+ reason: "not-canonical"
6863
+ };
6864
+ return {
6865
+ ok: true,
6866
+ value
6867
+ };
6868
+ }
6869
+ //#endregion
6870
+ //#region ../core/src/appStatements.ts
6871
+ /** The prefixes of phase 1. The others of the WISP (reports, reviews, licences, rotation, publisher proofs) come later. */
6872
+ var APP_PREFIXES = {
6873
+ app: "ghostly-app/1",
6874
+ store: "ghostly-store/1",
6875
+ revoke: "ghostly-revoke/1"
6876
+ };
6877
+ var APP_STATEMENT_LIMITS = {
6878
+ /** A signature statement's canonical bytes: 52 + 86 characters of values and the keys around them. */
6879
+ signatureBytes: 1024,
6880
+ /** Digests named by one revocation. */
6881
+ revokeDigests: 64,
6882
+ /** A revocation's reason, in characters. */
6883
+ reason: 200,
6884
+ /** Revocations in one `ghostly-revoke.json`. */
6885
+ revocations: 4096
6886
+ };
6887
+ /** A z-base32 Ed25519 public key: 52 characters that decode to 32 bytes and encode back to the same text. */
6888
+ function isAppKey(value) {
6889
+ if (typeof value !== "string" || !/^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(value)) return false;
6890
+ const bytes = fromZ32(value);
6891
+ return bytes.length === 32 && toZ32(bytes) === value;
6892
+ }
6893
+ /** A SHA-256 in base64url without padding: 43 characters that decode to 32 bytes and encode back to the same text. */
6894
+ function isAppHash(value) {
6895
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) return false;
6896
+ return toBase64Url(fromBase64Url(value)) === value;
6897
+ }
6898
+ /** An app reference, `<publisher key>/<name>`. */
6899
+ var APP_NAME = /^[a-z][a-z0-9-]{0,31}$/;
6900
+ function isAppRef(value) {
6901
+ if (typeof value !== "string") return false;
6902
+ return value.indexOf("/") === 52 && isAppKey(value.slice(0, 52)) && APP_NAME.test(value.slice(53));
6903
+ }
6904
+ function appRef(publisher, name) {
6905
+ return `${publisher}/${name}`;
6906
+ }
6907
+ /** The publisher key of a reference. */
6908
+ function appRefPublisher(ref) {
6909
+ return ref.slice(0, 52);
6910
+ }
6911
+ /**
6912
+ * The publisher fingerprint people see: the first 16 z-base32 characters of the key (80 bits), in four groups.
6913
+ */
6914
+ function appFingerprint(key) {
6915
+ return (key.slice(0, 16).match(/.{1,4}/g) ?? []).join(" ");
6916
+ }
6917
+ /** The bytes a signature covers: the prefix, a zero byte, then the SHA-256 of the object's canonical bytes. */
6918
+ function appSignedBytes(prefix, canonical) {
6919
+ const p = utf8Encode(prefix);
6920
+ const out = new Uint8Array(p.length + 1 + 32);
6921
+ out.set(p, 0);
6922
+ out.set(sha256(canonical), p.length + 1);
6923
+ return out;
6924
+ }
6925
+ /** Signs an object under a prefix: its canonical bytes and the signature statement. */
6926
+ async function signAppObject(prefix, value, signer) {
6927
+ const bytes = canonicalJsonBytes(value);
6928
+ const sig = await signer.sign(appSignedBytes(prefix, bytes));
6929
+ return {
6930
+ bytes,
6931
+ signature: {
6932
+ alg: "ed25519",
6933
+ key: toZ32(signer.publicKey),
6934
+ sig: toBase64Url(sig)
6935
+ }
6936
+ };
6937
+ }
6938
+ /**
6939
+ * Ed25519 verification as RFC 8032 writes it (`zip215: false`): canonical encodings of the point and the scalar only,
6940
+ * so every parser (this one, a Rust one with `verify_strict`) accepts exactly the same signatures.
6941
+ */
6942
+ function strictVerify(signature, message, publicKey) {
6943
+ try {
6944
+ return ed25519.verify(signature, message, publicKey, { zip215: false });
6945
+ } catch {
6946
+ return false;
6947
+ }
6948
+ }
6949
+ var exactKeys$1 = (value, keys, optional = []) => {
6950
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
6951
+ const have = Object.keys(value);
6952
+ return keys.every((k) => have.includes(k)) && have.every((k) => keys.includes(k) || optional.includes(k));
6953
+ };
6954
+ /** Checks the shape of a signature statement: exactly `alg`, `key` and `sig`, a 64-byte signature. */
6955
+ function isAppSignature(value) {
6956
+ if (!exactKeys$1(value, [
6957
+ "alg",
6958
+ "key",
6959
+ "sig"
6960
+ ])) return false;
6961
+ if (value.alg !== "ed25519" || !isAppKey(value.key)) return false;
6962
+ return typeof value.sig === "string" && /^[A-Za-z0-9_-]{86}$/.test(value.sig) && toBase64Url(fromBase64Url(value.sig)) === value.sig;
6963
+ }
6964
+ /** Reads a signature statement from its bytes, which must be canonical. */
6965
+ function readAppSignature(bytes) {
6966
+ if (bytes.length > APP_STATEMENT_LIMITS.signatureBytes) return {
6967
+ ok: false,
6968
+ reason: "bad-signature-statement",
6969
+ detail: "Too large"
6970
+ };
6971
+ const read = readCanonicalJson(bytes);
6972
+ if (!read.ok) return {
6973
+ ok: false,
6974
+ reason: "bad-signature-statement",
6975
+ detail: read.reason
6976
+ };
6977
+ if (!isAppSignature(read.value)) return {
6978
+ ok: false,
6979
+ reason: "bad-signature-statement",
6980
+ detail: "Not {alg, key, sig}"
6981
+ };
6982
+ return {
6983
+ ok: true,
6984
+ signature: read.value
6985
+ };
6986
+ }
6987
+ /**
6988
+ * Verifies a signature over an object's canonical bytes under a prefix, made by `key`: `signature-key` when the
6989
+ * statement names another key, `bad-signature` when it does not verify.
6990
+ */
6991
+ function verifyAppSignature(prefix, canonical, signature, key) {
6992
+ if (!isAppSignature(signature)) return {
6993
+ ok: false,
6994
+ reason: "bad-signature-statement"
6995
+ };
6996
+ if (signature.key !== key) return {
6997
+ ok: false,
6998
+ reason: "signature-key"
6999
+ };
7000
+ return strictVerify(fromBase64Url(signature.sig), appSignedBytes(prefix, canonical), fromZ32(key)) ? { ok: true } : {
7001
+ ok: false,
7002
+ reason: "bad-signature"
7003
+ };
7004
+ }
7005
+ var isSequence = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 1;
7006
+ var isPlainLine = (v, max) => typeof v === "string" && [...v].length <= max && !/\p{Cc}/u.test(v);
7007
+ /** Checks a revocation statement's shape; the reason it is refused, or null. */
7008
+ function checkAppRevokeStatement(value) {
7009
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return "Not an object";
7010
+ const v = value;
7011
+ const byDigests = "digests" in v;
7012
+ if (!exactKeys$1(v, [
7013
+ "ghostlyRevoke",
7014
+ "app",
7015
+ byDigests ? "digests" : "upTo"
7016
+ ], ["reason"])) return "Keys are not ghostlyRevoke, app, and exactly one of digests and upTo (reason optional)";
7017
+ if (v.ghostlyRevoke !== 1) return "ghostlyRevoke is not 1";
7018
+ if (!isAppRef(v.app)) return "app is not a reference";
7019
+ if (byDigests) {
7020
+ const d = v.digests;
7021
+ if (!Array.isArray(d) || d.length < 1 || d.length > APP_STATEMENT_LIMITS.revokeDigests) return "digests is not 1 to 64 digests";
7022
+ if (!d.every(isAppHash) || new Set(d).size !== d.length) return "digests holds a bad or repeated digest";
7023
+ } else if (!isSequence(v.upTo)) return "upTo is not a sequence";
7024
+ if (v.reason !== void 0 && !isPlainLine(v.reason, APP_STATEMENT_LIMITS.reason)) return "reason is not one line of 200 characters";
7025
+ return null;
7026
+ }
7027
+ /** Signs a revocation with the publisher's key. */
7028
+ async function signAppRevocation(statement, signer) {
7029
+ const problem = checkAppRevokeStatement(statement);
7030
+ if (problem) throw new Error(`Not a revocation: ${problem}`);
7031
+ if (toZ32(signer.publicKey) !== appRefPublisher(statement.app)) throw new Error("A revocation is signed by its app's publisher");
7032
+ const { signature } = await signAppObject(APP_PREFIXES.revoke, statement, signer);
7033
+ return {
7034
+ statement,
7035
+ signature
7036
+ };
7037
+ }
7038
+ /**
7039
+ * Reads one signed revocation (a parsed value, from `ghostly-revoke.json` or a store index's `revoked`): its shape, then
7040
+ * the signature under `ghostly-revoke/1` by the publisher its `app` names.
7041
+ */
7042
+ function readAppRevocation(value) {
7043
+ if (!exactKeys$1(value, ["statement", "signature"])) return {
7044
+ ok: false,
7045
+ reason: "bad-revocation",
7046
+ detail: "Keys are not statement and signature"
7047
+ };
7048
+ const problem = checkAppRevokeStatement(value.statement);
7049
+ if (problem) return {
7050
+ ok: false,
7051
+ reason: "bad-revocation",
7052
+ detail: problem
7053
+ };
7054
+ if (!isAppSignature(value.signature)) return {
7055
+ ok: false,
7056
+ reason: "bad-signature-statement"
7057
+ };
7058
+ const statement = value.statement;
7059
+ const checked = verifyAppSignature(APP_PREFIXES.revoke, canonicalJsonBytes(statement), value.signature, appRefPublisher(statement.app));
7060
+ if (!checked.ok) return checked;
7061
+ return {
7062
+ ok: true,
7063
+ revocation: {
7064
+ statement,
7065
+ signature: value.signature
7066
+ }
7067
+ };
7068
+ }
7069
+ /**
7070
+ * Reads `ghostly-revoke.json`: canonical bytes holding a list of signed revocations. One that does not verify refuses the
7071
+ * file, as a store index's `revoked` does.
7072
+ */
7073
+ function readAppRevocations(bytes) {
7074
+ const read = readCanonicalJson(bytes);
7075
+ if (!read.ok) return read;
7076
+ if (!Array.isArray(read.value) || read.value.length > APP_STATEMENT_LIMITS.revocations) return {
7077
+ ok: false,
7078
+ reason: "bad-revocation",
7079
+ detail: "Not a list of at most 4096 revocations"
7080
+ };
7081
+ const revocations = [];
7082
+ for (const entry of read.value) {
7083
+ const one = readAppRevocation(entry);
7084
+ if (!one.ok) return one;
7085
+ revocations.push(one.revocation);
7086
+ }
7087
+ return {
7088
+ ok: true,
7089
+ revocations
7090
+ };
7091
+ }
7092
+ //#endregion
6764
7093
  //#region ../core/src/messageTime.ts
6765
7094
  /**
6766
7095
  * 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
@@ -6832,7 +7161,16 @@ var STATUS_CARD_LIMITS = {
6832
7161
  /** A button's id: what a press names. */
6833
7162
  buttonId: 32,
6834
7163
  /** A button's label, in characters. */
6835
- buttonLabel: 40
7164
+ buttonLabel: 40,
7165
+ /** An app card's title and version, in characters (WISP 405 § An app): the manifest's bounds. */
7166
+ appTitle: 40,
7167
+ appVersion: 32,
7168
+ /** A usage card's label ("Claude") and account, in characters (WISP 405 § Usage). */
7169
+ usageLabel: 24,
7170
+ /** A usage window's name ("5 h", "week"), in characters. */
7171
+ usageWindow: 16,
7172
+ /** Windows beside a usage card's own, shown in its details. */
7173
+ usageWindows: 3
6836
7174
  };
6837
7175
  /** How a button looks: `primary` is the one highlighted, `danger` warns; `neutral` when none is said. */
6838
7176
  var BUTTON_STYLES = [
@@ -6898,8 +7236,8 @@ var sameButtonText = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase()
6898
7236
  * them (one line, cleaned). `at` is the button that clashes, `with` the one it clashes with.
6899
7237
  */
6900
7238
  function buttonLabelClash(buttons) {
6901
- const labels = buttons.map((button) => isObject$2(button) ? cardLine(button.label, Infinity) : void 0);
6902
- const ids = buttons.map((button) => isObject$2(button) && typeof button.id === "string" ? button.id : void 0);
7239
+ const labels = buttons.map((button) => isObject$4(button) ? cardLine(button.label, Infinity) : void 0);
7240
+ const ids = buttons.map((button) => isObject$4(button) && typeof button.id === "string" ? button.id : void 0);
6903
7241
  for (const [at, label] of labels.entries()) {
6904
7242
  if (!label) continue;
6905
7243
  const twin = labels.findIndex((other, i) => i < at && !!other && sameButtonText(other, label));
@@ -6916,7 +7254,13 @@ function buttonLabelClash(buttons) {
6916
7254
  };
6917
7255
  }
6918
7256
  }
6919
- var isObject$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
7257
+ /** An app card's id, made from its reference: the app's name, a dot, the first 16 characters of the publisher key. */
7258
+ function appCardId(ref) {
7259
+ return `${ref.slice(53)}.${ref.slice(0, 16)}`;
7260
+ }
7261
+ /** An app card's `sequence`: a whole number from 0 to 2^53 - 1. */
7262
+ var appSequence = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
7263
+ var isObject$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
6920
7264
  /** One line of display text: runs of space (line breaks included) become one space, then the display rules for names. */
6921
7265
  function cardLine(value, max) {
6922
7266
  if (typeof value !== "string") return void 0;
@@ -6945,7 +7289,7 @@ function readLinks(raw) {
6945
7289
  if (!Array.isArray(raw)) return [];
6946
7290
  const links = [];
6947
7291
  for (const entry of raw.slice(0, STATUS_CARD_LIMITS.links)) {
6948
- if (!isObject$2(entry)) continue;
7292
+ if (!isObject$4(entry)) continue;
6949
7293
  const url = cardUrl(entry.url);
6950
7294
  if (!url) continue;
6951
7295
  const label = cardLine(entry.label, STATUS_CARD_LIMITS.linkLabel);
@@ -6968,7 +7312,7 @@ function readTags(raw) {
6968
7312
  return tags;
6969
7313
  }
6970
7314
  function readRun(raw, now) {
6971
- if (!isObject$2(raw)) return void 0;
7315
+ if (!isObject$4(raw)) return void 0;
6972
7316
  const at = time(raw.at, now + MESSAGE_CLOCK_SKEW_MS), result = oneOf(RUN_RESULTS, raw.result);
6973
7317
  if (!at || !result) return void 0;
6974
7318
  const summary = cardLine(raw.summary, STATUS_CARD_LIMITS.line);
@@ -6978,6 +7322,52 @@ function readRun(raw, now) {
6978
7322
  ...summary && { summary }
6979
7323
  };
6980
7324
  }
7325
+ /** A percent as a reader keeps it: a finite number, rounded and clamped into 0 to 100. */
7326
+ var percent = (value) => typeof value === "number" && Number.isFinite(value) ? Math.round(Math.min(100, Math.max(0, value))) : void 0;
7327
+ /** A time ahead of the reader (a reset, a next run) as it keeps it: whole, positive, at most a year ahead. */
7328
+ var ahead = (value, now) => typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= now + STATUS_CARD_LIMITS.nextRunMs ? value : void 0;
7329
+ /** A usage card as a reader keeps it (WISP 405 § Usage): dropped without a percent left or a used of limit. */
7330
+ function readUsage(raw, id, now) {
7331
+ const left = percent(raw.left);
7332
+ let used = count(raw.used), limit = count(raw.limit, 1);
7333
+ if (used === void 0 || limit === void 0 || used > limit) used = limit = void 0;
7334
+ if (left === void 0 && limit === void 0) return void 0;
7335
+ const label = cardLine(raw.label, STATUS_CARD_LIMITS.usageLabel), account = cardLine(raw.account, STATUS_CARD_LIMITS.usageLabel);
7336
+ const window = cardLine(raw.window, STATUS_CARD_LIMITS.usageWindow);
7337
+ const resetsAt = ahead(raw.resetsAt, now), updatedAt = time(raw.updatedAt, now + MESSAGE_CLOCK_SKEW_MS);
7338
+ const windows = [];
7339
+ if (Array.isArray(raw.windows)) for (const entry of raw.windows.slice(0, STATUS_CARD_LIMITS.usageWindows)) {
7340
+ if (!isObject$4(entry)) continue;
7341
+ const name = cardLine(entry.window, STATUS_CARD_LIMITS.usageWindow), share = percent(entry.left);
7342
+ if (!name || share === void 0) continue;
7343
+ const at = ahead(entry.resetsAt, now);
7344
+ windows.push({
7345
+ window: name,
7346
+ left: share,
7347
+ ...at && { resetsAt: at }
7348
+ });
7349
+ }
7350
+ return {
7351
+ kind: "usage",
7352
+ id,
7353
+ ...left !== void 0 && { left },
7354
+ ...used !== void 0 && limit !== void 0 && {
7355
+ used,
7356
+ limit
7357
+ },
7358
+ ...label && { label },
7359
+ ...account && { account },
7360
+ ...window && { window },
7361
+ ...resetsAt && { resetsAt },
7362
+ ...updatedAt && { updatedAt },
7363
+ ...windows.length && { windows }
7364
+ };
7365
+ }
7366
+ /** How much of a usage card's quota is left, 0 to 100: its own percent, else worked out from used of limit. */
7367
+ function usageLeft(card) {
7368
+ if (card.left !== void 0) return card.left;
7369
+ return card.used !== void 0 && card.limit ? Math.round((card.limit - card.used) / card.limit * 100) : 0;
7370
+ }
6981
7371
  /** The bytes a card takes on the wire: what `STATUS_CARD_LIMITS.bytes` bounds. */
6982
7372
  function statusCardBytes(card) {
6983
7373
  try {
@@ -6993,10 +7383,28 @@ function statusCardBytes(card) {
6993
7383
  * routine's next run up to a year ahead). The message is always kept: without a card it shows its text.
6994
7384
  */
6995
7385
  function readStatusCard(raw, now = Date.now()) {
6996
- if (!isObject$2(raw) || statusCardBytes(raw) > STATUS_CARD_LIMITS.bytes) return void 0;
7386
+ if (!isObject$4(raw) || statusCardBytes(raw) > STATUS_CARD_LIMITS.bytes) return void 0;
6997
7387
  const id = typeof raw.id === "string" && ID$4.test(raw.id) ? raw.id : void 0;
6998
7388
  if (!id) return void 0;
7389
+ if (raw.kind === "app") {
7390
+ if (!isAppRef(raw.ref) || id !== appCardId(raw.ref) || !isAppHash(raw.digest) || !appSequence(raw.sequence)) return void 0;
7391
+ const title = cardLine(raw.title, STATUS_CARD_LIMITS.appTitle);
7392
+ if (!title) return void 0;
7393
+ const version = cardLine(raw.version, STATUS_CARD_LIMITS.appVersion), url = cardUrl(raw.url);
7394
+ return {
7395
+ kind: "app",
7396
+ id,
7397
+ ref: raw.ref,
7398
+ digest: raw.digest,
7399
+ sequence: raw.sequence,
7400
+ title,
7401
+ ...version && { version },
7402
+ ...url && { url },
7403
+ ...raw.opened === true && { opened: true }
7404
+ };
7405
+ }
6999
7406
  const latest = now + MESSAGE_CLOCK_SKEW_MS;
7407
+ if (raw.kind === "usage") return readUsage(raw, id, now);
7000
7408
  const links = readLinks(raw.links);
7001
7409
  if (raw.kind === "task") {
7002
7410
  const title = cardLine(raw.title, STATUS_CARD_LIMITS.title), status = oneOf(TASK_STATUSES, raw.status);
@@ -7006,7 +7414,7 @@ function readStatusCard(raw, now = Date.now()) {
7006
7414
  if (done === void 0 || total === void 0 || done > total) done = total = void 0;
7007
7415
  const step = cardLine(raw.step, STATUS_CARD_LIMITS.line), branch = cardLine(raw.branch, STATUS_CARD_LIMITS.branch);
7008
7416
  let pr;
7009
- if (isObject$2(raw.pr)) {
7417
+ if (isObject$4(raw.pr)) {
7010
7418
  const url = cardUrl(raw.pr.url);
7011
7419
  if (url) {
7012
7420
  const number = count(raw.pr.number, 1), additions = count(raw.pr.additions), deletions = count(raw.pr.deletions), files = count(raw.pr.files);
@@ -7024,7 +7432,7 @@ function readStatusCard(raw, now = Date.now()) {
7024
7432
  }
7025
7433
  const items = [];
7026
7434
  if (Array.isArray(raw.items)) for (const entry of raw.items.slice(0, STATUS_CARD_LIMITS.items)) {
7027
- if (!isObject$2(entry)) continue;
7435
+ if (!isObject$4(entry)) continue;
7028
7436
  const text = cardLine(entry.text, STATUS_CARD_LIMITS.line);
7029
7437
  if (text) items.push({
7030
7438
  text,
@@ -7083,7 +7491,7 @@ function readStatusCard(raw, now = Date.now()) {
7083
7491
  if (!Array.isArray(raw.buttons)) return void 0;
7084
7492
  const buttons = [], seen = /* @__PURE__ */ new Set();
7085
7493
  for (const entry of raw.buttons.slice(0, STATUS_CARD_LIMITS.buttons)) {
7086
- if (!isObject$2(entry) || typeof entry.id !== "string" || !BUTTON_ID.test(entry.id) || seen.has(entry.id)) continue;
7494
+ if (!isObject$4(entry) || typeof entry.id !== "string" || !BUTTON_ID.test(entry.id) || seen.has(entry.id)) continue;
7087
7495
  const label = cardLine(entry.label, STATUS_CARD_LIMITS.buttonLabel);
7088
7496
  if (!label) continue;
7089
7497
  seen.add(entry.id);
@@ -7112,10 +7520,10 @@ function readStatusCard(raw, now = Date.now()) {
7112
7520
  * at once and nothing it said is lost on the way. Line breaks and invisible characters are cleaned, as a reader would.
7113
7521
  */
7114
7522
  function checkStatusCard(raw, now = Date.now()) {
7115
- if (!isObject$2(raw)) return { error: "A card is an object" };
7523
+ if (!isObject$4(raw)) return { error: "A card is an object" };
7116
7524
  const bytes = statusCardBytes(raw);
7117
7525
  if (bytes > STATUS_CARD_LIMITS.bytes) return { error: `The card takes ${bytes} bytes; at most ${STATUS_CARD_LIMITS.bytes}` };
7118
- if (raw.kind !== "task" && raw.kind !== "routine" && raw.kind !== "buttons") return { error: "kind is task, routine or buttons" };
7526
+ 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" };
7119
7527
  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 -` };
7120
7528
  const line = (field, value, max, required = false) => {
7121
7529
  if (value === void 0 && !required) return null;
@@ -7129,20 +7537,53 @@ function checkStatusCard(raw, now = Date.now()) {
7129
7537
  if (raw.links !== void 0) {
7130
7538
  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}`);
7131
7539
  else for (const [i, link] of raw.links.entries()) {
7132
- if (!isObject$2(link)) {
7540
+ if (!isObject$4(link)) {
7133
7541
  errors.push(`links[${i}] is an object`);
7134
7542
  continue;
7135
7543
  }
7136
7544
  errors.push(url(`links[${i}].url`, link.url), line(`links[${i}].label`, link.label, STATUS_CARD_LIMITS.linkLabel));
7137
7545
  }
7138
7546
  }
7139
- if (raw.kind === "buttons") {
7547
+ 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`;
7548
+ const reset = (field, value) => value === void 0 || ahead(value, now) !== void 0 ? null : `${field} is a time in milliseconds, within a year`;
7549
+ if (raw.kind === "usage") {
7550
+ if (raw.links !== void 0) errors.push("a usage card takes no links");
7551
+ if (raw.left === void 0 && raw.used === void 0) errors.push("left (a percent) or used and limit");
7552
+ errors.push(share("left", raw.left));
7553
+ if (raw.used === void 0 !== (raw.limit === void 0)) errors.push("used and limit go together");
7554
+ else if (raw.used !== void 0) {
7555
+ errors.push(whole("used", raw.used), whole("limit", raw.limit, 1));
7556
+ if (typeof raw.used === "number" && typeof raw.limit === "number" && raw.used > raw.limit) errors.push("used is at most limit");
7557
+ }
7558
+ 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));
7559
+ 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");
7560
+ if (raw.windows !== void 0) {
7561
+ 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}`);
7562
+ else for (const [i, entry] of raw.windows.entries()) {
7563
+ if (!isObject$4(entry)) {
7564
+ errors.push(`windows[${i}] is an object`);
7565
+ continue;
7566
+ }
7567
+ errors.push(line(`windows[${i}].window`, entry.window, STATUS_CARD_LIMITS.usageWindow, true));
7568
+ 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));
7569
+ }
7570
+ }
7571
+ } else if (raw.kind === "app") {
7572
+ if (raw.links !== void 0) errors.push("an app card takes no links");
7573
+ if (!isAppRef(raw.ref)) errors.push("ref is <publisher key in z-base32>/<name>");
7574
+ else if (raw.id !== appCardId(raw.ref)) errors.push(`id is ${appCardId(raw.ref)}, made from ref`);
7575
+ if (!isAppHash(raw.digest)) errors.push("digest is a SHA-256 in base64url without padding");
7576
+ if (!appSequence(raw.sequence)) errors.push("sequence is a whole number from 0 to 2^53 - 1");
7577
+ errors.push(line("title", raw.title, STATUS_CARD_LIMITS.appTitle, true), line("version", raw.version, STATUS_CARD_LIMITS.appVersion));
7578
+ if (raw.url !== void 0) errors.push(url("url", raw.url));
7579
+ if (raw.opened !== void 0 && raw.opened !== true) errors.push("opened is true or left out");
7580
+ } else if (raw.kind === "buttons") {
7140
7581
  if (raw.links !== void 0) errors.push("buttons take no links");
7141
7582
  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}`);
7142
7583
  else {
7143
7584
  const ids = /* @__PURE__ */ new Set();
7144
7585
  for (const [i, button] of raw.buttons.entries()) {
7145
- if (!isObject$2(button)) {
7586
+ if (!isObject$4(button)) {
7146
7587
  errors.push(`buttons[${i}] is an object`);
7147
7588
  continue;
7148
7589
  }
@@ -7169,7 +7610,7 @@ function checkStatusCard(raw, now = Date.now()) {
7169
7610
  }
7170
7611
  errors.push(line("step", raw.step, STATUS_CARD_LIMITS.line), line("branch", raw.branch, STATUS_CARD_LIMITS.branch));
7171
7612
  if (raw.pr !== void 0) {
7172
- if (!isObject$2(raw.pr)) errors.push("pr is an object");
7613
+ if (!isObject$4(raw.pr)) errors.push("pr is an object");
7173
7614
  else {
7174
7615
  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));
7175
7616
  if (raw.pr.state !== void 0 && !oneOf(PR_STATES, raw.pr.state)) errors.push(`pr.state is one of ${PR_STATES.join(", ")}`);
@@ -7201,7 +7642,7 @@ function checkStatusCard(raw, now = Date.now()) {
7201
7642
  if (raw.items !== void 0) {
7202
7643
  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}`);
7203
7644
  else for (const [i, item] of raw.items.entries()) {
7204
- if (!isObject$2(item)) {
7645
+ if (!isObject$4(item)) {
7205
7646
  errors.push(`items[${i}] is an object`);
7206
7647
  continue;
7207
7648
  }
@@ -7215,7 +7656,7 @@ function checkStatusCard(raw, now = Date.now()) {
7215
7656
  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`);
7216
7657
  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");
7217
7658
  const run = (field, value) => {
7218
- if (!isObject$2(value)) return errors.push(`${field} is an object`);
7659
+ if (!isObject$4(value)) return errors.push(`${field} is an object`);
7219
7660
  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`);
7220
7661
  if (!oneOf(RUN_RESULTS, value.result)) errors.push(`${field}.result is one of ${RUN_RESULTS.join(", ")}`);
7221
7662
  errors.push(line(`${field}.summary`, value.summary, STATUS_CARD_LIMITS.line));
@@ -7266,6 +7707,21 @@ function cardTimeUtc(ms) {
7266
7707
  * and search finds. Plain lines, the first naming the card, no bars or layout that would read badly elsewhere.
7267
7708
  */
7268
7709
  function statusCardText(card) {
7710
+ if (card.kind === "app") {
7711
+ const named = `${card.title}${card.version ? ` ${card.version}` : ""}`;
7712
+ return [card.opened ? `🧩 Opened ${named} in this chat (Ghostly app)` : `🧩 ${named} (Ghostly app)`, ...card.url ? [card.url] : []].join("\n");
7713
+ }
7714
+ if (card.kind === "usage") {
7715
+ const left = `${usageLeft(card)}% left${card.window ? ` (${card.window})` : ""}`;
7716
+ return [
7717
+ `📊 ${card.label ?? "Usage"}`,
7718
+ ...card.account ? [card.account] : [],
7719
+ left,
7720
+ ...card.used !== void 0 && card.limit !== void 0 ? [`${card.used} of ${card.limit} used`] : [],
7721
+ ...card.resetsAt ? [`resets ${cardTimeUtc(card.resetsAt)}`] : [],
7722
+ ...(card.windows ?? []).map((w) => `${w.window} ${w.left}%`)
7723
+ ].join(" · ");
7724
+ }
7269
7725
  if (card.kind === "buttons") return `Reply: ${card.buttons.map((b) => b.label).join(" / ")}`;
7270
7726
  if (card.kind === "task") {
7271
7727
  const progress = taskProgress(card);
@@ -9806,6 +10262,14 @@ var TransportSwitch = class {
9806
10262
  settled = "";
9807
10263
  failed = "";
9808
10264
  preparing = false;
10265
+ /** The coordinator's dial for the pending plan has started (`dialing`): from then on a newer choice waits for it. */
10266
+ dialStarted = false;
10267
+ /**
10268
+ * The contact's `paired-policy` says `replaces`: its app reads a `paired-switch-keep` for a plan a newer choice
10269
+ * replaced as that (`supersede`). An app before 1.1.5 reads it as its target missed and says the chat was kept on its
10270
+ * transport; 1.1.5 reads it right but does not say so.
10271
+ */
10272
+ remoteReplaces = false;
9809
10273
  /** This side chose while no session was open: the next one begins with it as a choice. */
9810
10274
  apart = false;
9811
10275
  constructor(options) {
@@ -9861,10 +10325,12 @@ var TransportSwitch = class {
9861
10325
  context: this.context
9862
10326
  });
9863
10327
  }
10328
+ /** Older apps read the policy's own fields only, and drop `replaces`. */
9864
10329
  announce() {
9865
10330
  this.send({
9866
10331
  t: "paired-policy",
9867
- policy: this.local()
10332
+ policy: this.local(),
10333
+ replaces: true
9868
10334
  });
9869
10335
  }
9870
10336
  /**
@@ -9881,6 +10347,7 @@ var TransportSwitch = class {
9881
10347
  if (!migrated) {
9882
10348
  this.failed = "";
9883
10349
  this.remote = null;
10350
+ this.remoteReplaces = false;
9884
10351
  }
9885
10352
  if (this.apart) {
9886
10353
  this.intent = Math.max(this.intent, this.lastRemote?.intent ?? 0) + 1;
@@ -9903,6 +10370,7 @@ var TransportSwitch = class {
9903
10370
  } else if (userIntent) this.intent = Math.max(this.intent, this.remote?.intent ?? 0) + 1;
9904
10371
  this.failed = "";
9905
10372
  this.announce();
10373
+ this.supersede();
9906
10374
  this.reconcile();
9907
10375
  }
9908
10376
  /**
@@ -9964,12 +10432,38 @@ var TransportSwitch = class {
9964
10432
  this.clearPlan();
9965
10433
  this.context = "";
9966
10434
  this.remote = null;
10435
+ this.remoteReplaces = false;
9967
10436
  }
9968
10437
  clearPlan() {
9969
10438
  if (this.timer) clearTimeout(this.timer);
9970
10439
  this.timer = null;
9971
10440
  this.plan = null;
9972
10441
  this.preparing = false;
10442
+ this.dialStarted = false;
10443
+ }
10444
+ /** The coordinator starts dialling the pending plan (a move back to WebRTC first waits for the old session to retire). */
10445
+ dialing(plan) {
10446
+ if (this.plan === plan) this.dialStarted = true;
10447
+ }
10448
+ /**
10449
+ * A newer choice (this side's, or the contact's policy) while a plan both sides agreed has not started its dial: a
10450
+ * move back to WebRTC waits for the WebRTC session it left to retire (`SWITCH_RETIRE_MS`), and nothing has been tried
10451
+ * yet. The coordinator drops that plan, so the chat does not move there only to move again; the other side is told
10452
+ * with `paired-switch-keep`, and whatever the newer policies call for is planned next. A plan already dialling goes
10453
+ * on: a choice made then is planned once it ended (`replanIfMoved`). So does a plan the contact's app would take
10454
+ * that keep for a target missed (its policy did not say `replaces`): the newer choice is planned once it landed.
10455
+ */
10456
+ supersede() {
10457
+ const plan = this.plan;
10458
+ if (!plan || !this.preparing || this.dialStarted || !this.context || !this.remote || this.options.key > this.options.peerKey || !this.remoteReplaces) return;
10459
+ if (this.choices(this.local(), this.remote)[0] === plan.choices[0]) return;
10460
+ this.send({
10461
+ t: "paired-switch-keep",
10462
+ id: plan.id
10463
+ });
10464
+ this.clearPlan();
10465
+ this.options.cancel();
10466
+ this.options.state();
9973
10467
  }
9974
10468
  fail(error) {
9975
10469
  const target = this.plan?.choices[0], upgrade = !!this.plan?.upgrade;
@@ -10091,8 +10585,10 @@ var TransportSwitch = class {
10091
10585
  if (!policy || this.remote && (policy.revision < this.remote.revision || policy.revision === this.remote.revision && JSON.stringify(policy) !== JSON.stringify(this.remote))) return true;
10092
10586
  const changed = !this.remote || policy.revision !== this.remote.revision;
10093
10587
  this.remote = this.lastRemote = policy;
10588
+ this.remoteReplaces = frame.replaces === true;
10094
10589
  this.options.peer(policy);
10095
10590
  if (changed && this.plan && !this.preparing) this.clearPlan();
10591
+ else if (changed) this.supersede();
10096
10592
  this.reconcile();
10097
10593
  return true;
10098
10594
  }
@@ -10129,6 +10625,12 @@ var TransportSwitch = class {
10129
10625
  return true;
10130
10626
  }
10131
10627
  if (frame.t === "paired-switch-keep") {
10628
+ if (this.preparing && this.remote && this.choices(this.local(), this.remote)[0] !== this.plan.choices[0]) {
10629
+ this.clearPlan();
10630
+ this.options.state();
10631
+ this.reconcile();
10632
+ return true;
10633
+ }
10132
10634
  if (this.actual && this.plan.choices.includes(this.actual)) {
10133
10635
  if (this.plan.choices[0] !== this.actual && !this.plan.upgrade) this.options.kept?.(this.plan.choices[0]);
10134
10636
  this.settled = this.signature(this.plan.local, this.plan.remote);
@@ -11282,11 +11784,17 @@ var CAPS_READ_SPACING_MS = 15e3;
11282
11784
  * already knows and looks the key up behind it, and a lookup that started before the publication landed finds the old
11283
11785
  * one; a relay may serve an old copy for a while too. The envelope names each revision once, so one read was the last
11284
11786
  * until the next envelope, minutes away, and the chat stayed on the DHT with no descriptors to dial.
11787
+ *
11788
+ * The first ones come soon: a contact's record that lists a native transport with no descriptor is the "Peer native
11789
+ * address unavailable" a dial ends with, and its revision with the descriptors is usually out a second or two after the
11790
+ * first (its endpoints were starting). Read 5 s, then 20 s after, two Desktops DHT direct with Iroh's relay out of reach
11791
+ * went live 11 to 28 s after their chats opened (2026-10-07). One read more than before, three in the first 7 s.
11285
11792
  */
11286
11793
  var CAPS_READ_RETRY_MS = [
11287
- 5e3,
11794
+ 1e3,
11795
+ 2e3,
11796
+ 4e3,
11288
11797
  15e3,
11289
- 3e4,
11290
11798
  6e4
11291
11799
  ];
11292
11800
  var NATIVE = ["iroh/1", "hyperdht/1"];
@@ -11878,7 +12386,7 @@ var KINDS$1 = [
11878
12386
  var WIRE_ID = /^[A-Za-z0-9_-]{8,64}$/;
11879
12387
  var MAILBOX = /^[A-Za-z0-9_-]{22}$/;
11880
12388
  var SIG$3 = /^[A-Za-z0-9_-]{86}$/;
11881
- var MAGIC$1 = utf8Encode("GHLD");
12389
+ var MAGIC$2 = utf8Encode("GHLD");
11882
12390
  var VERSION = 1;
11883
12391
  var NONCE$2 = 24;
11884
12392
  /** Why a bundle, manifest or pointer was refused. Never a reason to trust it a little. */
@@ -11944,7 +12452,7 @@ var HoldKeys = class {
11944
12452
  new DataView(length.buffer).setUint32(0, header.length);
11945
12453
  const nonce = randomBytes(NONCE$2);
11946
12454
  const box = xsalsa20poly1305(this.sealKey, nonce).encrypt(concatBytes$1(length, header, body));
11947
- return concatBytes$1(MAGIC$1, new Uint8Array([VERSION]), nonce, box);
12455
+ return concatBytes$1(MAGIC$2, new Uint8Array([VERSION]), nonce, box);
11948
12456
  }
11949
12457
  /**
11950
12458
  * Opens a bundle the contact sealed for me. Everything is checked before anything is returned: the
@@ -11954,11 +12462,11 @@ var HoldKeys = class {
11954
12462
  open(bytes, expect = {}) {
11955
12463
  const now = expect.now ?? Date.now();
11956
12464
  if (bytes.length > (expect.maxBytes ?? HOLD_LIMITS.maxBundleBytes)) throw new HoldRefusedError("size", "The held item is larger than allowed");
11957
- if (bytes.length < MAGIC$1.length + 1 + NONCE$2 + 16 + 4 || !MAGIC$1.every((b, i) => bytes[i] === b)) throw new HoldRefusedError("format", "Not a held item");
11958
- if (bytes[MAGIC$1.length] !== VERSION) throw new HoldRefusedError("format", "Held item from a newer Ghostly");
12465
+ if (bytes.length < MAGIC$2.length + 1 + NONCE$2 + 16 + 4 || !MAGIC$2.every((b, i) => bytes[i] === b)) throw new HoldRefusedError("format", "Not a held item");
12466
+ if (bytes[MAGIC$2.length] !== VERSION) throw new HoldRefusedError("format", "Held item from a newer Ghostly");
11959
12467
  let plain;
11960
12468
  try {
11961
- plain = xsalsa20poly1305(this.sealKey, bytes.subarray(MAGIC$1.length + 1, MAGIC$1.length + 1 + NONCE$2)).decrypt(bytes.subarray(MAGIC$1.length + 1 + NONCE$2));
12469
+ plain = xsalsa20poly1305(this.sealKey, bytes.subarray(MAGIC$2.length + 1, MAGIC$2.length + 1 + NONCE$2)).decrypt(bytes.subarray(MAGIC$2.length + 1 + NONCE$2));
11962
12470
  } catch {
11963
12471
  throw new HoldRefusedError("tampered", "The held item was changed or is not for this chat");
11964
12472
  }
@@ -12164,6 +12672,18 @@ function readManifest(meta) {
12164
12672
  */
12165
12673
  var OFFER_EXTENSIONS = ["ping/1"];
12166
12674
  var MAX_HANDSHAKE_BYTES = 4096;
12675
+ /**
12676
+ * Frames received and not handled yet, at most: their count and their size together. Frames are handled one at a time,
12677
+ * each awaited (a group frame is decrypted, checked and stored before the next), and the peer sends at the network's
12678
+ * pace. A member back in a group answers a sync with a burst: a commit per epoch, its kept messages, the ones it hands
12679
+ * on, edits, reactions. With a bound of 64 frames a phone handling them a few milliseconds each failed its session
12680
+ * mid catch-up ("Session receive limit exceeded" then, "Session receive queue full" now, 2026-10-07). The size bound keeps the old worst case in memory
12681
+ * (64 frames of 60 KiB); the count only bounds a flood of tiny frames.
12682
+ */
12683
+ var SESSION_RECEIVE_PENDING = {
12684
+ frames: 1024,
12685
+ bytes: 3932160
12686
+ };
12167
12687
  var KEY = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/;
12168
12688
  var NONCE$1 = /^[A-Za-z0-9_-]{43}$/;
12169
12689
  var SIG$2 = /^[A-Za-z0-9_-]{86}$/;
@@ -12195,6 +12715,11 @@ var PairedSession = class {
12195
12715
  state = { status: "negotiating" };
12196
12716
  /** The peer said nothing more within `authTimeoutMs`: the connection carried nothing, which proves nothing about the peer. */
12197
12717
  authTimedOut = false;
12718
+ /**
12719
+ * The peer sent faster than this side handles, past `SESSION_RECEIVE_PENDING`: the session ends, which is no proof
12720
+ * of anything wrong with the peer (a burst of catch-up on a slow device), so the chat dials again.
12721
+ */
12722
+ overloaded = false;
12198
12723
  /** The seed's identity; null when a signer signs. */
12199
12724
  identity;
12200
12725
  signer;
@@ -12211,6 +12736,7 @@ var PairedSession = class {
12211
12736
  confirming = false;
12212
12737
  admission = null;
12213
12738
  pending = 0;
12739
+ pendingBytes = 0;
12214
12740
  queue = Promise.resolve();
12215
12741
  timer = null;
12216
12742
  constructor(channel, options) {
@@ -12298,7 +12824,12 @@ var PairedSession = class {
12298
12824
  if (!(binding ? (binding.transport === "hyperdht/1" ? /^[a-f0-9]{128}$/.test(binding.context) : FINGERPRINT.test(binding.context)) && binding.identities.length === 2 && binding.identities.every((k) => FINGERPRINT.test(k)) && binding.identities[0] !== binding.identities[1] : this.options.fingerprints?.length === 2 && this.options.fingerprints.every((f) => FINGERPRINT.test(f))) || !this.offer.transports.includes(this.transport) || this.options.rendezvousKeys.some((k) => !KEY.test(k))) return this.fail("Invalid connection binding or unavailable transport");
12299
12825
  this.channel.onMessage = (data) => {
12300
12826
  if (this.stopped) return;
12301
- if ((typeof data === "string" ? data.length : data.length) > 61440 || ++this.pending > 64) return this.fail("Session receive limit exceeded");
12827
+ const size = data.length;
12828
+ if (size > 61440) return this.fail("Session frame too large");
12829
+ if (++this.pending > SESSION_RECEIVE_PENDING.frames || (this.pendingBytes += size) > SESSION_RECEIVE_PENDING.bytes) {
12830
+ this.overloaded = true;
12831
+ return this.fail("Session receive queue full");
12832
+ }
12302
12833
  this.queue = this.queue.then(async () => {
12303
12834
  if (this.stopped) return;
12304
12835
  if (typeof data === "string" && data.startsWith("{\"t\":\"pair-")) {
@@ -12307,6 +12838,7 @@ var PairedSession = class {
12307
12838
  } else if (this.state.status === "ready") await this.options.onApplication(data);
12308
12839
  }).catch(() => this.fail("Invalid session negotiation")).finally(() => {
12309
12840
  this.pending--;
12841
+ this.pendingBytes -= size;
12310
12842
  });
12311
12843
  };
12312
12844
  this.timer = setTimeout(() => {
@@ -12574,6 +13106,11 @@ var BUTTONS_CAPABILITY = "buttons/1";
12574
13106
  * (WISP 03), so an app back after an absence knows before any session whether a relay can carry it there at once.
12575
13107
  */
12576
13108
  var UPGRADE_CAPABILITY = "upgrade/1";
13109
+ /**
13110
+ * Mini-apps in a 1:1 chat: `paired-app` frames on this session, live only (WISP 1200 § In a chat: `apps/1`,
13111
+ * `pairedApps.ts`). Offered only while the apps feature is on.
13112
+ */
13113
+ var APPS_CAPABILITY = "apps/1";
12577
13114
  /** Every capability this app knows on a session: what `receive` reports changes of. */
12578
13115
  var KNOWN_SESSION_CAPABILITIES = [
12579
13116
  CALLS_CAPABILITY,
@@ -12586,7 +13123,8 @@ var KNOWN_SESSION_CAPABILITIES = [
12586
13123
  PIN_CAPABILITY,
12587
13124
  STATUS_CARD_CAPABILITY,
12588
13125
  BUTTONS_CAPABILITY,
12589
- UPGRADE_CAPABILITY
13126
+ UPGRADE_CAPABILITY,
13127
+ APPS_CAPABILITY
12590
13128
  ];
12591
13129
  var SESSION_CAPABILITIES_FRAME = "paired-capabilities";
12592
13130
  var MAX_ENTRIES$1 = 32;
@@ -12653,6 +13191,269 @@ var SessionCapabilities = class {
12653
13191
  }
12654
13192
  };
12655
13193
  //#endregion
13194
+ //#region ../core/src/pairedApps.ts
13195
+ /**
13196
+ * Mini-apps talking in a 1:1 chat (WISP 1200 § In a chat: `apps/1`): the same app on both sides sends JSON to the
13197
+ * other over the live session, in one frame with three forms.
13198
+ *
13199
+ * {"t":"paired-app","a":"<chat app id>","o":"open","v":"1.2.0"}
13200
+ * {"t":"paired-app","a":"<chat app id>","o":"close"}
13201
+ * {"t":"paired-app","a":"<chat app id>","d":<JSON value>}
13202
+ *
13203
+ * It needs `apps/1` in `paired-capabilities` on both sides. Live only: never on the DHT, never held, never queued for
13204
+ * later; while the session is not live a send fails with `offline`. An `open` goes when the person opens the app in
13205
+ * the chat, again on an update, and again on every session that comes back while the app is open. The receiver
13206
+ * enforces the limits (the sender's client may be the attacker): at most `APP_DATA_MAX_BYTES` of data, at most
13207
+ * `APP_RATE_LIMIT` frames a second per app, data only for an app open on both sides, at most `APP_PEER_OPEN_MAX`
13208
+ * apps the peer has open, and nothing unless both sides offer `apps/1`.
13209
+ *
13210
+ * The chat app id names one app in one chat: the first 16 bytes of HMAC-SHA-256 keyed by the two pinned participation
13211
+ * keys over the app reference `<publisher key in z-base32>/<name>`. One app's id differs in each chat, so ids cannot
13212
+ * be matched across a person's chats; it travels only inside the encrypted session.
13213
+ */
13214
+ var APP_FRAME = "paired-app";
13215
+ var DOMAIN = "ghostly-apps/1";
13216
+ var APP_ID = /^[A-Za-z0-9_-]{22}$/;
13217
+ /** Semantic versioning 2.0.0, as a manifest's `version` is. */
13218
+ var SEMVER$1 = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
13219
+ /** A chat app id as it is on the wire: 22 base64url characters (16 bytes, no padding). */
13220
+ function isChatAppId(value) {
13221
+ return typeof value === "string" && APP_ID.test(value);
13222
+ }
13223
+ /** A version an `open` may carry: semantic versioning, at most `APP_VERSION_MAX` characters. */
13224
+ function isAppVersion(value) {
13225
+ return typeof value === "string" && value.length <= 64 && SEMVER$1.test(value);
13226
+ }
13227
+ function compareBytes$2(a, b) {
13228
+ for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) return a[i] - b[i];
13229
+ return a.length - b.length;
13230
+ }
13231
+ /**
13232
+ * The chat app id of `ref` in the chat between these two participation keys (raw, 32 bytes each, in either order):
13233
+ * HMAC-SHA-256 keyed by SHA-256(`ghostly-apps/1`, 0x00, the lower key, the higher key), over the UTF-8 reference;
13234
+ * its first 16 bytes, base64url without padding.
13235
+ */
13236
+ function chatAppId(keyA, keyB, ref) {
13237
+ if (keyA.length !== 32 || keyB.length !== 32) throw new Error("A participation key is 32 bytes");
13238
+ if (!isAppRef(ref)) throw new Error("Not an app reference");
13239
+ const [low, high] = compareBytes$2(keyA, keyB) <= 0 ? [keyA, keyB] : [keyB, keyA];
13240
+ const key = sha256(concatBytes$1(utf8Encode(DOMAIN), new Uint8Array([0]), low, high));
13241
+ return toBase64Url(hmac(sha256, key, utf8Encode(ref)).slice(0, 16));
13242
+ }
13243
+ /** The size of a value as compact UTF-8 JSON; Infinity when it is not JSON (undefined, a function, a cycle, a BigInt). */
13244
+ function appDataBytes(value) {
13245
+ let json;
13246
+ try {
13247
+ json = JSON.stringify(value);
13248
+ } catch {
13249
+ return Infinity;
13250
+ }
13251
+ return json === void 0 ? Infinity : utf8Encode(json).length;
13252
+ }
13253
+ function appOpenFrame(app, version) {
13254
+ if (!isChatAppId(app) || !isAppVersion(version)) throw new Error("Not an app open");
13255
+ return {
13256
+ t: APP_FRAME,
13257
+ a: app,
13258
+ o: "open",
13259
+ v: version
13260
+ };
13261
+ }
13262
+ function appCloseFrame(app) {
13263
+ if (!isChatAppId(app)) throw new Error("Not an app close");
13264
+ return {
13265
+ t: APP_FRAME,
13266
+ a: app,
13267
+ o: "close"
13268
+ };
13269
+ }
13270
+ function appDataFrame(app, data) {
13271
+ if (!isChatAppId(app) || appDataBytes(data) > 32768) throw new Error("Not app data");
13272
+ return {
13273
+ t: APP_FRAME,
13274
+ a: app,
13275
+ d: data
13276
+ };
13277
+ }
13278
+ /**
13279
+ * A `paired-app` frame as the peer sent it, or why a reader must refuse it: no valid `a` (22 base64url characters),
13280
+ * both or neither of `o` and `d`, an unknown `o`, an `open` without a valid `v`, or a `d` over `APP_DATA_MAX_BYTES`.
13281
+ * Other keys are ignored, so a later version can add some.
13282
+ */
13283
+ function readAppFrame(frame) {
13284
+ const refuse = (refusal) => ({
13285
+ ok: false,
13286
+ refusal
13287
+ });
13288
+ if (frame?.t !== "paired-app") return refuse("not-app");
13289
+ if (!isChatAppId(frame.a)) return refuse("bad-a");
13290
+ const own = (key) => Object.prototype.hasOwnProperty.call(frame, key);
13291
+ const hasO = own("o"), hasD = own("d");
13292
+ if (hasO && hasD) return refuse("o-and-d");
13293
+ if (!hasO && !hasD) return refuse("no-o-or-d");
13294
+ if (hasD) return appDataBytes(frame.d) <= 32768 ? {
13295
+ ok: true,
13296
+ frame: {
13297
+ t: APP_FRAME,
13298
+ a: frame.a,
13299
+ d: frame.d
13300
+ }
13301
+ } : refuse("too-large");
13302
+ if (frame.o === "close") return {
13303
+ ok: true,
13304
+ frame: {
13305
+ t: APP_FRAME,
13306
+ a: frame.a,
13307
+ o: "close"
13308
+ }
13309
+ };
13310
+ if (frame.o !== "open") return refuse("unknown-o");
13311
+ return isAppVersion(frame.v) ? {
13312
+ ok: true,
13313
+ frame: {
13314
+ t: APP_FRAME,
13315
+ a: frame.a,
13316
+ o: "open",
13317
+ v: frame.v
13318
+ }
13319
+ } : refuse("bad-v");
13320
+ }
13321
+ /** A `paired-app` frame as the peer sent it, or null when a reader must refuse it (`readAppFrame` says why). */
13322
+ function parseAppFrame(frame) {
13323
+ const read = readAppFrame(frame);
13324
+ return read.ok ? read.frame : null;
13325
+ }
13326
+ /** Frames per app in a sliding window, for at most `max` apps at once. */
13327
+ var RateWindows = class {
13328
+ limit;
13329
+ max;
13330
+ windows = /* @__PURE__ */ new Map();
13331
+ constructor(limit, max) {
13332
+ this.limit = limit;
13333
+ this.max = max;
13334
+ }
13335
+ /** Counts one frame for `app` at `now`; false when it is over the limit (not counted then). */
13336
+ take(app, now) {
13337
+ let window = this.windows.get(app);
13338
+ if (!window) {
13339
+ if (this.windows.size >= this.max) this.prune(now);
13340
+ if (this.windows.size >= this.max) return false;
13341
+ window = [];
13342
+ this.windows.set(app, window);
13343
+ }
13344
+ while (window.length && now - window[0] >= 1e3) window.shift();
13345
+ if (window.length >= this.limit) return false;
13346
+ window.push(now);
13347
+ return true;
13348
+ }
13349
+ prune(now) {
13350
+ for (const [app, window] of this.windows) if (!window.length || now - window[window.length - 1] >= 1e3) this.windows.delete(app);
13351
+ }
13352
+ clear() {
13353
+ this.windows.clear();
13354
+ }
13355
+ };
13356
+ /**
13357
+ * The apps of one chat on its live sessions. `open` (this side's apps, by chat app id, with their versions) lasts
13358
+ * across sessions, so each new one says `open` again; what the peer has open lasts one session.
13359
+ */
13360
+ var AppSessions = class {
13361
+ open;
13362
+ now;
13363
+ peer = /* @__PURE__ */ new Map();
13364
+ /** What the peer had open on a session that ended, held for `APP_RESUME_GRACE_MS` until it says so again. */
13365
+ held = /* @__PURE__ */ new Map();
13366
+ received = new RateWindows(50, 64);
13367
+ sent = new RateWindows(48, 64);
13368
+ constructor(open = /* @__PURE__ */ new Map(), now = Date.now) {
13369
+ this.open = open;
13370
+ this.now = now;
13371
+ }
13372
+ /** The peer has this app open on this session, at this version. */
13373
+ peerVersion(app) {
13374
+ return this.peer.get(app);
13375
+ }
13376
+ /** The apps the peer has open on this session. */
13377
+ get peerOpen() {
13378
+ return this.peer;
13379
+ }
13380
+ /**
13381
+ * A `paired-app` frame from the peer, on a session where both sides offer `apps/1`. Counted before it is read; the
13382
+ * event to pass on, or null when it was dropped (malformed, over the rate, a data frame for an app not open on both
13383
+ * sides, an `open` past `APP_PEER_OPEN_MAX`, a `close` of an app the peer did not have open).
13384
+ */
13385
+ receive(frame) {
13386
+ if (!isChatAppId(frame?.a) || !this.received.take(frame.a, this.now())) return null;
13387
+ const parsed = parseAppFrame(frame);
13388
+ if (!parsed) return null;
13389
+ const app = parsed.a;
13390
+ if ("d" in parsed) return this.open.has(app) && this.peer.has(app) ? {
13391
+ app,
13392
+ d: parsed.d
13393
+ } : null;
13394
+ if (parsed.o === "close") return this.peer.delete(app) || this.held.delete(app) ? {
13395
+ app,
13396
+ o: "close"
13397
+ } : null;
13398
+ if (!this.peer.has(app) && this.peer.size >= 16) return null;
13399
+ this.held.delete(app);
13400
+ this.peer.set(app, parsed.v);
13401
+ return {
13402
+ app,
13403
+ o: "open",
13404
+ v: parsed.v
13405
+ };
13406
+ }
13407
+ /** Whether a data frame for `app` may go now, the session being live with `apps/1` agreed; counted when it may. */
13408
+ canSend(app, data) {
13409
+ if (!this.open.has(app)) return "not-open";
13410
+ if (appDataBytes(data) > 32768) return "too-large";
13411
+ if (!this.peer.has(app)) return "peer-closed";
13412
+ return this.sent.take(app, this.now()) ? null : "too-fast";
13413
+ }
13414
+ /**
13415
+ * The session ended, or `apps/1` is no longer agreed: what the peer had open is gone, and each such app hears a
13416
+ * `close` with `offline`. This side's open apps stay, and say `open` again on the next session.
13417
+ */
13418
+ sessionEnded() {
13419
+ const closed = [.../* @__PURE__ */ new Set([...this.held.keys(), ...this.peer.keys()])].map((app) => ({
13420
+ app,
13421
+ o: "close",
13422
+ offline: true
13423
+ }));
13424
+ this.peer.clear();
13425
+ this.held.clear();
13426
+ this.received.clear();
13427
+ this.sent.clear();
13428
+ return closed;
13429
+ }
13430
+ /**
13431
+ * The session ended or was replaced, and another may follow at once: what the peer had open is held, not closed (no
13432
+ * event now). Nothing is sent to a held app; an `open` on the next session takes it back, a `close` closes it, and
13433
+ * `heldExpired` closes the rest once `APP_RESUME_GRACE_MS` has passed.
13434
+ */
13435
+ sessionSuspended() {
13436
+ for (const [app, version] of this.peer) this.held.set(app, version);
13437
+ this.peer.clear();
13438
+ this.received.clear();
13439
+ this.sent.clear();
13440
+ }
13441
+ /** Whether any of the peer's apps is held, waiting for its next session. */
13442
+ get holding() {
13443
+ return this.held.size > 0;
13444
+ }
13445
+ /** The grace is over: each app still held is closed, with `offline`. */
13446
+ heldExpired() {
13447
+ const closed = [...this.held.keys()].map((app) => ({
13448
+ app,
13449
+ o: "close",
13450
+ offline: true
13451
+ }));
13452
+ this.held.clear();
13453
+ return closed;
13454
+ }
13455
+ };
13456
+ //#endregion
12656
13457
  //#region ../core/src/webPush.ts
12657
13458
  var WebPushError = class extends Error {};
12658
13459
  var RECORD_SIZE = 4096;
@@ -13013,6 +13814,8 @@ function parsePinnedFrame(frame) {
13013
13814
  * DHT, never stored, never counted as unread, no message id (older apps drop it). While the person keeps typing,
13014
13815
  * `start` is said again at most every `TYPING_REFRESH_MS`; the receiver shows it until `stop`, a message from that
13015
13816
  * contact, the end of the session, or `TYPING_TIMEOUT_MS` without a new `start` (so a dropped link never leaves it on).
13817
+ * A start standing when a session ends (a transport switch, a reconnect) is said again once the next one agreed
13818
+ * `typing/1`, if the person's last word is still that recent.
13016
13819
  *
13017
13820
  * A `start` may say what the contact is doing (`kind`: typing, recording a voice note, thinking) and, for bots, a
13018
13821
  * short `status` line. Both are optional: an app that knows neither shows "typing…", and so does a kind it does not
@@ -13076,12 +13879,21 @@ function parseTypingActivity(frame) {
13076
13879
  return typingActivity(frame?.kind, frame?.status);
13077
13880
  }
13078
13881
  var sameActivity = (a, b) => a?.kind === b?.kind && a?.status === b?.status;
13079
- /** This side's word: when to say `start` again, and whether a `stop` is owed. One per session. */
13882
+ /**
13883
+ * This side's word: when to say `start` again, and whether a `stop` is owed. One per chat; what was said is per session
13884
+ * (`reset`), what the person is doing is not: a new session (a transport switch, a reconnect) says a standing start
13885
+ * again (`resume`), since the contact's reader cleared it when the old session ended.
13886
+ *
13887
+ * The rate window is not per session: the reader's is per chat too (it is not cleared when a session ends), so a
13888
+ * window that started afresh on each session could put a start past the reader's limit just after a switch.
13889
+ */
13080
13890
  var TypingSender = class {
13081
13891
  now;
13082
13892
  startedAt = 0;
13083
13893
  standing = null;
13084
13894
  recent = [];
13895
+ /** What the person is doing, and when it last said so: kept across sessions, until it stops. */
13896
+ wanted = null;
13085
13897
  constructor(now = Date.now) {
13086
13898
  this.now = now;
13087
13899
  }
@@ -13092,6 +13904,33 @@ var TypingSender = class {
13092
13904
  typing(activity = { kind: "typing" }) {
13093
13905
  const now = this.now();
13094
13906
  const word = typingActivity(activity.kind, activity.status);
13907
+ this.wanted = {
13908
+ activity: word,
13909
+ at: now
13910
+ };
13911
+ return this.say(word, now);
13912
+ }
13913
+ /**
13914
+ * The person typed while nothing can be said (no session, or `typing/1` not agreed on it yet): kept, so the start
13915
+ * goes once a session can carry it (`resume`).
13916
+ */
13917
+ want(activity = { kind: "typing" }) {
13918
+ this.wanted = {
13919
+ activity: typingActivity(activity.kind, activity.status),
13920
+ at: this.now()
13921
+ };
13922
+ }
13923
+ /**
13924
+ * A new session agreed `typing/1`: the `start` to say on it when the person was still at it, its last word less than
13925
+ * `TYPING_TIMEOUT_MS` ago (as long as the contact would still have shown it). Null otherwise, when a start already
13926
+ * went on this session, or when the window is full (the person's next keystroke says it).
13927
+ */
13928
+ resume() {
13929
+ const wanted = this.wanted, now = this.now();
13930
+ if (!wanted || this.startedAt || now - wanted.at >= 6e3) return null;
13931
+ return this.say(wanted.activity, now);
13932
+ }
13933
+ say(word, now) {
13095
13934
  if (this.startedAt && now - this.startedAt < 3e3 && sameActivity(this.standing, word)) return null;
13096
13935
  this.recent = this.recent.filter((at) => now - at < TYPING_RATE_WINDOW_MS);
13097
13936
  if (this.recent.length >= 6) return null;
@@ -13100,8 +13939,12 @@ var TypingSender = class {
13100
13939
  this.standing = word;
13101
13940
  return typingFrame("start", word);
13102
13941
  }
13103
- /** The person stopped (cleared the text, sent it, left the chat): a `stop`, only when a `start` is standing. */
13104
- stopped() {
13942
+ /**
13943
+ * The person stopped (cleared the text, sent it, left the chat): a `stop`, only when a `start` is standing on this
13944
+ * session. Nothing is said again on the next one either way.
13945
+ */
13946
+ stopped() {
13947
+ this.wanted = null;
13105
13948
  if (!this.startedAt) return null;
13106
13949
  this.recent.push(this.now());
13107
13950
  this.startedAt = 0;
@@ -13112,7 +13955,7 @@ var TypingSender = class {
13112
13955
  get active() {
13113
13956
  return this.startedAt !== 0;
13114
13957
  }
13115
- /** The session ended: nothing is standing on the next one. */
13958
+ /** The session ended: nothing is standing on the next one until `resume` (or the next keystroke) says it. */
13116
13959
  reset() {
13117
13960
  this.startedAt = 0;
13118
13961
  this.standing = null;
@@ -13236,7 +14079,7 @@ var FILE_LIMITS = {
13236
14079
  sendRetryMs: 250
13237
14080
  };
13238
14081
  var ID = /^[A-Za-z0-9_-]{8,64}$/;
13239
- var DIGEST = /^[A-Za-z0-9_-]{43}$/;
14082
+ var DIGEST$1 = /^[A-Za-z0-9_-]{43}$/;
13240
14083
  var DATA = /^[A-Za-z0-9_-]+$/;
13241
14084
  var FILE_FRAMES = /* @__PURE__ */ new Set([
13242
14085
  "pf-offer",
@@ -13980,7 +14823,7 @@ var ChatFiles = class {
13980
14823
  return;
13981
14824
  }
13982
14825
  if (record.state !== "active" || incoming.verifying || !incoming.target) return;
13983
- if (frame.size !== record.file.size || typeof frame.digest !== "string" || !DIGEST.test(frame.digest)) return;
14826
+ if (frame.size !== record.file.size || typeof frame.digest !== "string" || !DIGEST$1.test(frame.digest)) return;
13984
14827
  const digest = frame.digest;
13985
14828
  await incoming.writing;
13986
14829
  if (transferEnded(record) || record.state !== "active") return;
@@ -14993,6 +15836,142 @@ var CarriedTransport = class {
14993
15836
  }
14994
15837
  };
14995
15838
  //#endregion
15839
+ //#region ../core/src/groupFiles.ts
15840
+ /**
15841
+ * Files and voice messages in groups (WISP 503 · Group Files). A file is announced as a group message carrying its
15842
+ * description; its bytes go over `files/3` between two members, from a member that holds it to one that asks for it.
15843
+ * The limits below are the profile's first numbers, kept in one place so they are easy to change.
15844
+ */
15845
+ var GROUP_FILE_LIMITS = {
15846
+ /** The largest file a group takes. */
15847
+ maxBytes: 104857600,
15848
+ /** A file this size or smaller is fetched without asking (voice messages always are, up to `VOICE_LIMITS`). */
15849
+ autoBytes: 8388608,
15850
+ /** What a device keeps of a group's files it fetched without asking; past it, every file is asked about. */
15851
+ autoBytesPerGroup: 268435456,
15852
+ /** Bytes of group files a device serves to others in a day, when serving is on. */
15853
+ serveBytesPerDay: 1073741824,
15854
+ /** Transfers a holder serves at once, all groups together. */
15855
+ serveAtOnce: 3,
15856
+ /** File announcements a member sends to one group in a minute. */
15857
+ announcePerMinute: 8,
15858
+ /** File announcements taken from one member of a group in `announceWindowMs`; past it, shown as text, unfetched. */
15859
+ announceTaken: 30,
15860
+ announceWindowMs: 6e5,
15861
+ /** Holders remembered per file. */
15862
+ holders: 8,
15863
+ /**
15864
+ * Message ids a `group-have` named before their message came (a member back from away hears who holds what as its
15865
+ * catch-up begins), remembered with their holders for when it does.
15866
+ */
15867
+ heardAhead: 256,
15868
+ /** Message ids in one `group-have`. */
15869
+ haveIds: 32,
15870
+ /** A private group's `group-have` goes on an edge at most this often; a community's, from a member, every `communityHaveEveryMs`. */
15871
+ haveEveryMs: 1e4,
15872
+ communityHaveEveryMs: 6e4,
15873
+ /**
15874
+ * How long a want waits for an offer before the next holder is asked; a transfer that does not move for as long is
15875
+ * left for the next holder too.
15876
+ */
15877
+ wantWaitMs: 3e4,
15878
+ /** A file session closes this long after its last transfer ends. */
15879
+ sessionIdleMs: 6e4,
15880
+ /** File sessions opening at once, and open at once, all groups together. */
15881
+ sessionsOpening: 2,
15882
+ sessionsOpen: 4,
15883
+ /** The longest file name and type kept from a description. */
15884
+ nameChars: 255,
15885
+ mimeChars: 255
15886
+ };
15887
+ /**
15888
+ * The frames of WISP 503 § Getting the bytes, on an edge between two apps that announced `GROUP_VERSION_FILES`:
15889
+ * `group-want {g, id}` asks a holder for the file of message `id`; `group-want-no {g, id, why}` is a holder's no;
15890
+ * `group-have {g, ids}` says which files a member keeps (in a community, an application frame `{t, ids}` instead).
15891
+ */
15892
+ var GROUP_WANT_FRAME = "group-want";
15893
+ var GROUP_WANT_NO_FRAME = "group-want-no";
15894
+ var GROUP_HAVE_FRAME = "group-have";
15895
+ var REFUSALS = [
15896
+ "gone",
15897
+ "busy",
15898
+ "refused"
15899
+ ];
15900
+ /** A group message's id, as a private group (`<s>:<e>:<n>`) or a community (`<s>:<e>:<h>:<n>`) names it. */
15901
+ var MESSAGE_ID = /^[a-z0-9]{52}:\d{1,15}(:[A-Za-z0-9_-]{1,64})?:\d{1,15}$/;
15902
+ var isGroupMessageId = (value) => typeof value === "string" && MESSAGE_ID.test(value);
15903
+ function frameOf(value, t, groupId) {
15904
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
15905
+ const f = value;
15906
+ return f.t === t && (groupId === void 0 || f.g === groupId) ? f : null;
15907
+ }
15908
+ /** A `group-want` of group `groupId`: the message whose file is asked for, or null. */
15909
+ function readGroupWant(value, groupId) {
15910
+ const f = frameOf(value, GROUP_WANT_FRAME, groupId);
15911
+ return f && isGroupMessageId(f.id) ? { id: f.id } : null;
15912
+ }
15913
+ /** A holder's `group-want-no` of group `groupId`, or null. A reason this app does not know reads as `busy`. */
15914
+ function readGroupWantNo(value, groupId) {
15915
+ const f = frameOf(value, GROUP_WANT_NO_FRAME, groupId);
15916
+ if (!f || !isGroupMessageId(f.id)) return null;
15917
+ return {
15918
+ id: f.id,
15919
+ why: typeof f.why === "string" && REFUSALS.includes(f.why) ? f.why : "busy"
15920
+ };
15921
+ }
15922
+ /**
15923
+ * The message ids of a `group-have`: of group `groupId` on an edge, or (no `groupId`) a community's application frame.
15924
+ * At most `haveIds` message ids; a frame with more, or anything else, is null.
15925
+ */
15926
+ function readGroupHave(value, groupId) {
15927
+ const f = frameOf(value, GROUP_HAVE_FRAME, groupId);
15928
+ if (!f || !Array.isArray(f.ids) || !f.ids.length || f.ids.length > GROUP_FILE_LIMITS.haveIds || !f.ids.every(isGroupMessageId)) return null;
15929
+ return [...new Set(f.ids)];
15930
+ }
15931
+ var DIGEST = /^[A-Za-z0-9_-]{43}$/;
15932
+ /**
15933
+ * A description read from the wire, cleaned: the name made a display name (never a path), the type untrusted, the
15934
+ * size within the group limit, the digest the right shape. A malformed voice, video or picture description is dropped
15935
+ * on its own and the file is still a file; anything else malformed is no file at all (null), and the text shows.
15936
+ */
15937
+ function readGroupFileMeta(value) {
15938
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
15939
+ const v = value;
15940
+ if (typeof v.name !== "string" || !v.name.trim() || [...v.name].length > GROUP_FILE_LIMITS.nameChars) return null;
15941
+ if (typeof v.mime !== "string" || v.mime.length > GROUP_FILE_LIMITS.mimeChars) return null;
15942
+ if (!Number.isSafeInteger(v.size) || v.size < 1 || v.size > GROUP_FILE_LIMITS.maxBytes) return null;
15943
+ if (typeof v.d !== "string" || !DIGEST.test(v.d)) return null;
15944
+ const mime = sanitizeMime(v.mime);
15945
+ const voice = v.voice === void 0 ? void 0 : parseVoiceMeta(v.voice, mime);
15946
+ const video = v.video === void 0 ? void 0 : parseVideoMeta(v.video, mime);
15947
+ const image = v.image === void 0 ? void 0 : parseImageMeta(v.image, mime);
15948
+ return {
15949
+ name: sanitizeFileName(v.name),
15950
+ mime,
15951
+ size: v.size,
15952
+ d: v.d,
15953
+ ...voice ? { voice } : {},
15954
+ ...video ? { video } : {},
15955
+ ...image ? { image } : {}
15956
+ };
15957
+ }
15958
+ /**
15959
+ * The text an announcement carries when its author wrote no caption: what an app without group files shows instead
15960
+ * of the file. English, as wire text is; an app that knows the file shows the file, not this line.
15961
+ */
15962
+ function groupFileFallback(file) {
15963
+ if (file.voice) return `🎤 Voice message (${formatVoiceDuration(file.voice.duration)}): update Ghostly to hear it`;
15964
+ return `${fileMessageText(file)} (${formatFileSize(file.size)}): update Ghostly to get files in groups`;
15965
+ }
15966
+ /**
15967
+ * Whether a device fetches a file by itself (WISP 503 § Automatic and asked downloads): a voice message always, any
15968
+ * other file up to `autoBytes` while what it took that way from the group stays within `autoBytesPerGroup`.
15969
+ */
15970
+ function fetchesByItself(file, keptAutomatically, limits = GROUP_FILE_LIMITS) {
15971
+ if (file.voice) return true;
15972
+ return file.size <= limits.autoBytes && keptAutomatically + file.size <= limits.autoBytesPerGroup;
15973
+ }
15974
+ //#endregion
14996
15975
  //#region ../core/src/pairingProgress.ts
14997
15976
  var FIRST_STAGE = {
14998
15977
  inviter: "publishing",
@@ -15279,6 +16258,13 @@ var QUIET_FRAMES = /* @__PURE__ */ new Set([
15279
16258
  * `disconnected`) otherwise went on until its consent checks gave up, 27 s after the crash (CI run 36741701666).
15280
16259
  */
15281
16260
  var PONG_WAIT_MS = 4e3;
16261
+ /** How long `handled` waits for the contact to answer its ping before it says it cannot tell. */
16262
+ var HANDLED_WAIT_MS = 1e4;
16263
+ /**
16264
+ * Back in front after an absence (`wake`), a live session that ends this soon after it was lost while the app was
16265
+ * away (a phone that put it to sleep): the chat resumes as after a restart, not as a session the contact closed.
16266
+ */
16267
+ var WAKE_LOST_MS = 1e4;
15282
16268
  /**
15283
16269
  * A `paired-message` frame. The preview (`pv`) is left out when the frame would pass `MAX_PAIRED_MESSAGE_FRAME`
15284
16270
  * with it: the text matters, the card does not. A reply (`r`, WISP 401 § Replies) is a few hundred bytes at most
@@ -15446,6 +16432,8 @@ var GhostLink = class {
15446
16432
  /** Typing on this session (`typing/1`): when to say `start` again, and the contact's word with its timeout. */
15447
16433
  typingSender = new TypingSender();
15448
16434
  typingReceiver = new TypingReceiver((typing, activity) => this.options.events?.onPeerTyping?.(typing, activity));
16435
+ /** Mini-apps on this session (`apps/1`): this side's open apps (kept across sessions), the peer's, the limits. */
16436
+ apps;
15449
16437
  /** Reaction frames the contact may send per window; the rest go unconfirmed and come again. */
15450
16438
  reactionsReceived = new ReactionWindow(REACTION_LIMITS.receive);
15451
16439
  /** Pin frames the contact may send per window; the rest go unconfirmed and come again. */
@@ -15461,6 +16449,16 @@ var GhostLink = class {
15461
16449
  /** The contact's transport policy as last seen on this session, to tell its explicit choices apart. */
15462
16450
  peerPolicySeen = null;
15463
16451
  unansweredPings = 0;
16452
+ /**
16453
+ * Pings sent on this session and pongs heard on it, counted from its start: the contact answers each ping in the order
16454
+ * frames come, so the pong to ping n says it handled everything sent before it (`handled`).
16455
+ */
16456
+ pingsSent = 0;
16457
+ pongsHeard = 0;
16458
+ /** The liveness ping whose pong measures the round trip: its number in `pingsSent`. */
16459
+ rttPing = 0;
16460
+ /** `handled` callers, each waiting for the pong to its ping. */
16461
+ handledWaiters = [];
15464
16462
  /** The last ping's `PONG_WAIT_MS`: running until something comes back from the contact. */
15465
16463
  pongWait = null;
15466
16464
  /** The last ping's wait ran out with nothing back, and nothing came since (`restartedRedial`). */
@@ -15474,7 +16472,8 @@ var GhostLink = class {
15474
16472
  racing = false;
15475
16473
  /**
15476
16474
  * The WebRTC offer this side's dial made last, with fallback allowed: what a transport that comes up meanwhile joins.
15477
- * `resume`: the dial that made it is this side back after an absence, over WebRTC (`resuming`).
16475
+ * `resume`: the dial that made it is this side back after an absence, over WebRTC (`resuming`); `woke`: the absence
16476
+ * was the app's in the background, not a restart (`resumeAfterAbsence`).
15478
16477
  */
15479
16478
  offered;
15480
16479
  /** The attempt (its epoch) whose direct transports were knocked on as its offer went out (`knockEarly`), and whether that is under way. */
@@ -15490,6 +16489,12 @@ var GhostLink = class {
15490
16489
  answeringEpoch = -1;
15491
16490
  lastAutoConnectAt = 0;
15492
16491
  autoConnectFailures = 0;
16492
+ /** The look due when a failed dial's backoff ends (`lookAtRetry`). */
16493
+ retryLook = null;
16494
+ /** This side's offer is out to the data link and has not reached the relays yet. */
16495
+ offerUnsent = false;
16496
+ /** The last dial ended with its offer never out (`offerUnsent`): the contact saw nothing of it. */
16497
+ dialUnseen = false;
15493
16498
  /** Native transports a record just read listed with no way to dial them (`record-undescribed`), until one describes them again. */
15494
16499
  undescribed = /* @__PURE__ */ new Set();
15495
16500
  /**
@@ -15546,8 +16551,15 @@ var GhostLink = class {
15546
16551
  resuming;
15547
16552
  /** Until then, a dial waits for the native endpoint `resuming` names to start (it is knocked on as it does). */
15548
16553
  resumeWaitUntil = 0;
16554
+ /** What the last live session ran on, kept after it ended: a wake resumes on it (`resumeAfterAbsence`). */
16555
+ lastLiveOn;
16556
+ /** `resuming` is a wake's, not a restart's. */
16557
+ resumingWoke = false;
16558
+ /** Until then, a session that ends was lost while the app was away (`WAKE_LOST_MS`). */
16559
+ wokeUntil = 0;
15549
16560
  constructor(options) {
15550
16561
  this.options = options;
16562
+ this.apps = new AppSessions(options.appsOpen);
15551
16563
  if (options.pairing?.credentials.signer && (options.dht || options.params.deliveryMode === "dht")) throw new Error("A link that signs through a signer has no DHT delivery");
15552
16564
  this.deliveryMode = options.params.deliveryMode ?? "stream";
15553
16565
  this.tracker = options.pairingProgress && options.pairing && !options.pairing.credentials.peerKey && this.deliveryMode !== "dht" ? new PairingTracker(options.pairingProgress.role, options.pairingProgress.startedAt, (progress) => {
@@ -15683,9 +16695,13 @@ var GhostLink = class {
15683
16695
  this.tracker?.failed("publish", true, result.error);
15684
16696
  return;
15685
16697
  }
15686
- if (result.signalOut) this.dataLink.signalWentOut();
16698
+ if (result.signalOut) {
16699
+ this.offerUnsent = false;
16700
+ this.dataLink.signalWentOut();
16701
+ }
15687
16702
  this.tracker?.published();
15688
16703
  this.publishRecovered();
16704
+ this.dialSeenNow();
15689
16705
  },
15690
16706
  onPeerAck: (ack) => events.onPeerAck?.(ack),
15691
16707
  onCallSignal: (signal) => {
@@ -15780,6 +16796,10 @@ var GhostLink = class {
15780
16796
  if (this.activeBinding) return;
15781
16797
  events.onDataLinkState?.(state);
15782
16798
  if (state === "open") this.afterRtc = void 0;
16799
+ if (state === "offering") {
16800
+ this.offerUnsent = true;
16801
+ this.dialUnseen = false;
16802
+ }
15783
16803
  if (state === "answering") {
15784
16804
  this.answeringEpoch = this.connectionEpoch;
15785
16805
  if (this.resumingOverRtc) {
@@ -15793,6 +16813,8 @@ var GhostLink = class {
15793
16813
  this.scheduleRace(this.offered.epoch, this.offered.at);
15794
16814
  }
15795
16815
  if (state !== "idle") return;
16816
+ this.dialUnseen = was === "offering" && this.offerUnsent;
16817
+ this.offerUnsent = false;
15796
16818
  const unanswerable = was === "answering" && this.answeringEpoch === this.connectionEpoch && !this.channel && !this.stopped && !this.leaving;
15797
16819
  if (unanswerable) this.rtcUnstarted();
15798
16820
  this.clearRace();
@@ -15805,7 +16827,10 @@ var GhostLink = class {
15805
16827
  return;
15806
16828
  }
15807
16829
  this.afterRtc = void 0;
15808
- if (!this.dialing) this.attemptEnded();
16830
+ if (!this.dialing) {
16831
+ this.attemptEnded();
16832
+ this.lookAtRetry();
16833
+ }
15809
16834
  this.rejectWaiters(new GhostlyHttpError("unreachable", "Could not connect to the peer"));
15810
16835
  if (unanswerable && this.dialsPastRtc()) this.redial();
15811
16836
  },
@@ -16160,15 +17185,18 @@ var GhostLink = class {
16160
17185
  this.dial().catch((error) => {
16161
17186
  this.tracker?.failed("transport", true);
16162
17187
  this.dialFailed(error instanceof Error ? error.message : String(error));
17188
+ this.lookAtRetry();
16163
17189
  });
16164
17190
  }
16165
17191
  async stop(announce = true) {
16166
17192
  this.stopped = true;
16167
17193
  this.clearRace();
17194
+ if (this.retryLook) clearTimeout(this.retryLook);
16168
17195
  if (this.dhtPinGrace) clearTimeout(this.dhtPinGrace);
16169
17196
  if (this.waiting?.timer) clearTimeout(this.waiting.timer);
16170
17197
  await this.dht?.stop();
16171
17198
  this.disconnect();
17199
+ this.appsEnded();
16172
17200
  await Promise.allSettled([...this.endpoints.values()].map((endpoint) => endpoint.close()));
16173
17201
  this.endpoints.clear();
16174
17202
  await this.session.stop(announce);
@@ -16560,6 +17588,7 @@ var GhostLink = class {
16560
17588
  return;
16561
17589
  }
16562
17590
  this.endpoints.set(endpoint.transport, endpoint);
17591
+ traceLink(this.myPubKeyZ32, "endpoint-up", { transport: endpoint.transport });
16563
17592
  endpoint.onConnection = ({ channel, binding }) => {
16564
17593
  traceLink(this.myPubKeyZ32, "dialed-in", {
16565
17594
  transport: binding.transport,
@@ -16570,6 +17599,7 @@ var GhostLink = class {
16570
17599
  };
16571
17600
  endpoint.onUnavailable = () => {
16572
17601
  if (this.endpoints.get(endpoint.transport) !== endpoint) return;
17602
+ traceLink(this.myPubKeyZ32, "endpoint-gone", { transport: endpoint.transport });
16573
17603
  this.endpoints.delete(endpoint.transport);
16574
17604
  this.advertiseTransports();
16575
17605
  this.options.events?.onTransportsChanged?.();
@@ -16705,7 +17735,10 @@ var GhostLink = class {
16705
17735
  * simply be away. Two dials crossing are settled by key order (see `registerEndpoint`).
16706
17736
  */
16707
17737
  async knock(transport) {
16708
- if (this.resuming === transport) this.resuming = void 0;
17738
+ if (this.resuming === transport) {
17739
+ this.resuming = void 0;
17740
+ this.resumingWoke = false;
17741
+ }
16709
17742
  const endpoint = this.endpoints.get(transport), descriptor = this.peerDescriptors[transport];
16710
17743
  if (!endpoint || !descriptor || this.channel || this.dialing || this.stopped || this.streamBlocked || this.keyStopped) return;
16711
17744
  if (!this.transportOffer().includes(transport)) return;
@@ -16858,8 +17891,11 @@ var GhostLink = class {
16858
17891
  const remembered = this.peerTransports && rankTransports(local, this.peerFallback ? this.peerTransports : this.peerTransports.slice(0, 1), relayed);
16859
17892
  const ranked = remembered?.length ? remembered : this.peerTransports ? rankTransports(local, this.peerTransports, relayed) : local.filter((t) => t === "webrtc/1");
16860
17893
  const chosen = this.switcher.chosenTarget ?? this.choiceApart?.transport ?? this.resuming;
16861
- const resume = this.resuming === "webrtc/1";
16862
- if (chosen && chosen === this.resuming && ranked.includes(chosen)) this.resuming = void 0;
17894
+ const resume = this.resuming === "webrtc/1", woke = resume && this.resumingWoke;
17895
+ if (chosen && chosen === this.resuming && ranked.includes(chosen)) {
17896
+ this.resuming = void 0;
17897
+ this.resumingWoke = false;
17898
+ }
16863
17899
  const choices = chosen && ranked.includes(chosen) ? [chosen, ...ranked.filter((t) => t !== chosen)] : ranked;
16864
17900
  if (!choices.length) {
16865
17901
  if (this.options.packetTransports && this.autoConnectFailures <= PACKET_TRANSPORTS_FAST_ATTEMPTS) this.session.expectPeer();
@@ -16883,7 +17919,8 @@ var GhostLink = class {
16883
17919
  this.offered = fallback ? standing ?? {
16884
17920
  epoch,
16885
17921
  at: offeredAt,
16886
- ...resume && { resume }
17922
+ ...resume && { resume },
17923
+ ...woke && { woke }
16887
17924
  } : void 0;
16888
17925
  const gate = this.dialGate;
16889
17926
  this.dialGate = void 0;
@@ -17070,7 +18107,10 @@ var GhostLink = class {
17070
18107
  if (this.channel || this.dialing || this.stopped || this.streamBlocked || this.keyStopped || !this.contactQuiet(1e4)) return;
17071
18108
  if (!this.fallback || !this.peerFallback) return;
17072
18109
  this.knockedEarly = epoch;
17073
- if (this.resuming === "webrtc/1") this.resuming = void 0;
18110
+ if (this.resuming === "webrtc/1") {
18111
+ this.resuming = void 0;
18112
+ this.resumingWoke = false;
18113
+ }
17074
18114
  const relayed = this.upgradesFromRelay() ? [] : this.relayedTransports;
17075
18115
  const offer = this.transportOffer();
17076
18116
  const direct = TRANSPORTS.filter((t) => t !== "webrtc/1" && offer.includes(t) && !!this.peerTransports?.includes(t) && !relayed.includes(t) && this.canDial(t));
@@ -17093,7 +18133,7 @@ var GhostLink = class {
17093
18133
  maybeKnockEarly(epoch) {
17094
18134
  const next = this.afterRtc;
17095
18135
  if (!this.offered?.resume || this.offered.epoch !== epoch || this.knockedEarly === epoch || next?.epoch !== epoch || epoch !== this.connectionEpoch) return;
17096
- if (this.dialing || this.channel || this.stopped || !this.offerOut || !this.contactQuiet()) return;
18136
+ if (this.dialing || this.channel || this.stopped || !this.offerOut || !(this.offered.woke || this.contactQuiet())) return;
17097
18137
  const relayed = this.upgradesFromRelay() ? [] : this.relayedTransports;
17098
18138
  const direct = next.rest.filter((t) => !relayed.includes(t) && this.canDial(t));
17099
18139
  if (!direct.length) return;
@@ -17141,6 +18181,16 @@ var GhostLink = class {
17141
18181
  dialsPastRtc() {
17142
18182
  return (this.demotedUntil.get("webrtc/1") ?? 0) > Date.now() && this.fallback && this.peerFallback && TRANSPORTS.some((t) => this.canDial(t) && !!this.peerTransports?.includes(t) && this.transportOffer().includes(t));
17143
18183
  }
18184
+ /**
18185
+ * A first pairing's inviter has no offer of the joiner's to wait for: WebRTC is missing here, or the joiner's record
18186
+ * says its app has none (a Linux Desktop's WebKitGTK), so its first packet carries none. A native transport the record
18187
+ * describes is dialled now. Waiting out `INVITER_DIAL_GRACE_MS` left two Linux Desktops 2.5 s more apart, and the
18188
+ * joiner, still reading, often went on without dialling for seconds (dht-direct on CI, 2026-10-07). Should the joiner
18189
+ * dial too, the two dials cross and the lower key's carries the chat (`parkCrossed`, `attachReplacement`).
18190
+ */
18191
+ nothingToAwait() {
18192
+ return !(this.availableTransports.includes("webrtc/1") && (!this.peerTransports || this.peerTransports.includes("webrtc/1"))) && TRANSPORTS.some((t) => this.canDial(t) && !!this.peerTransports?.includes(t) && this.transportOffer().includes(t));
18193
+ }
17144
18194
  /** Whether a native dial of `transport` can go now: this side's endpoint is up and the contact's descriptor known. */
17145
18195
  canDial(transport) {
17146
18196
  return transport !== "webrtc/1" && this.endpoints.has(transport) && !!this.peerDescriptors[transport];
@@ -17424,8 +18474,8 @@ var GhostLink = class {
17424
18474
  * Only the lower key offers, so two peers coming online together do not collide. A first pairing is
17425
18475
  * the exception: the joiner offers, whatever its key, in the very packet that says it is here (one hop
17426
18476
  * less than presence, then the inviter's offer), and the inviter waits for that offer. An inviter that
17427
- * sees the joiner but no offer for `INVITER_DIAL_GRACE_MS` (an app from before this rule) offers itself;
17428
- * the lower-key rule settles any collision, on both sides.
18477
+ * sees the joiner but no offer for `INVITER_DIAL_GRACE_MS` (an app from before this rule) offers itself,
18478
+ * and dials at once when no offer can come (`nothingToAwait`); the lower-key rule settles any collision, on both sides.
17429
18479
  */
17430
18480
  maybeAutoConnect(presence) {
17431
18481
  if (this.stopped || this.leaving || this.yielding || this.streamBlocked || this.keyStopped || !this.options.autoConnect || !presence.online || this.channel || this.dialing || this.dataLink.state !== "idle") return;
@@ -17437,7 +18487,7 @@ var GhostLink = class {
17437
18487
  const role = !!this.tracker && !this.tracker.done && this.tracker.progress.stage !== "on-dht" ? this.options.pairingProgress?.role : void 0;
17438
18488
  if (role === "inviter") {
17439
18489
  this.peerSeenAt ||= Date.now();
17440
- if (Date.now() - this.peerSeenAt < 2500) {
18490
+ if (Date.now() - this.peerSeenAt < 2500 && !this.nothingToAwait()) {
17441
18491
  this.session.expectPeer();
17442
18492
  return;
17443
18493
  }
@@ -17454,6 +18504,7 @@ var GhostLink = class {
17454
18504
  failures: this.autoConnectFailures,
17455
18505
  left: wait - (Date.now() - this.lastAutoConnectAt)
17456
18506
  });
18507
+ this.lookAtRetry();
17457
18508
  return;
17458
18509
  }
17459
18510
  traceLink(this.myPubKeyZ32, "dial", { failures: this.autoConnectFailures });
@@ -17462,6 +18513,7 @@ var GhostLink = class {
17462
18513
  this.dial().catch((error) => {
17463
18514
  this.tracker?.failed("transport", true);
17464
18515
  this.dialFailed(error instanceof Error ? error.message : String(error));
18516
+ this.lookAtRetry();
17465
18517
  });
17466
18518
  }
17467
18519
  /**
@@ -17481,8 +18533,42 @@ var GhostLink = class {
17481
18533
  this.dial().catch((error) => {
17482
18534
  this.tracker?.failed("transport", true);
17483
18535
  this.dialFailed(error instanceof Error ? error.message : String(error));
18536
+ this.lookAtRetry();
17484
18537
  });
17485
18538
  }
18539
+ /**
18540
+ * A failed dial waits `dialWait` before the next, and only a look at the contact's record dials: the next one came at
18541
+ * the link's own pace (30 s in the background), which knows nothing of the backoff. A member let in to a group
18542
+ * reached one of its members 72 s after its welcome, 40 s of backoff and 32 s more for its next look (meshSignals on
18543
+ * CI, 2026-10-06). One look goes when the backoff ends (`liveAttempt.retryAt`), and its read decides, as any look's
18544
+ * does. A dial or a reset of the backoff since makes it moot: what comes next arms its own.
18545
+ */
18546
+ lookAtRetry() {
18547
+ if (this.retryLook) clearTimeout(this.retryLook);
18548
+ this.retryLook = null;
18549
+ const from = this.lastAutoConnectAt;
18550
+ if (!from || this.stopped || this.leaving || this.streamBlocked || this.keyStopped || !this.options.autoConnect) return;
18551
+ this.retryLook = setTimeout(() => {
18552
+ this.retryLook = null;
18553
+ if (this.lastAutoConnectAt !== from || this.stopped || this.leaving || this.yielding || this.streamBlocked || this.keyStopped || this.channel || this.dialing || this.dataLink.state !== "idle") return;
18554
+ traceLink(this.myPubKeyZ32, "retry-look", { failures: this.autoConnectFailures });
18555
+ this.session.lookNow();
18556
+ }, Math.max(0, from + this.dialWait() - Date.now()));
18557
+ }
18558
+ /**
18559
+ * A packet of this link reached the relays after a dial whose offer never did (the relays failed, or their budget held
18560
+ * it, for the whole attempt): the contact saw nothing of that dial, so it is no reason to wait, and the next goes now.
18561
+ * Without this a dial made during an outage set the wait, three minutes once it had grown, from when the relays
18562
+ * answered again.
18563
+ */
18564
+ dialSeenNow() {
18565
+ if (!this.dialUnseen) return;
18566
+ this.dialUnseen = false;
18567
+ if (this.channel || this.dialing || this.dataLink.state !== "idle") return;
18568
+ traceLink(this.myPubKeyZ32, "dial-unseen", { failures: this.autoConnectFailures });
18569
+ this.lastAutoConnectAt = 0;
18570
+ this.maybeAutoConnect(this.presence);
18571
+ }
17486
18572
  /** How long after the last automatic dial the next one may go. A first pairing tries again sooner: the contact just read the invite and is waiting. */
17487
18573
  dialWait() {
17488
18574
  return !!this.tracker && !this.tracker.done && this.tracker.progress.stage !== "on-dht" ? Math.min(PAIRING_RETRY_MS * 2 ** this.autoConnectFailures, PAIRING_MAX_RETRY_MS) : Math.min(AUTO_CONNECT_RETRY_MS * 2 ** this.autoConnectFailures, AUTO_CONNECT_MAX_RETRY_MS);
@@ -17729,6 +18815,7 @@ var GhostLink = class {
17729
18815
  if (this.options.statusCardSupport) offered.push(STATUS_CARD_CAPABILITY);
17730
18816
  if (this.options.buttonsSupport) offered.push(BUTTONS_CAPABILITY);
17731
18817
  if (this.options.upgradeSupport) offered.push(UPGRADE_CAPABILITY);
18818
+ if (this.options.appsSupport) offered.push(APPS_CAPABILITY);
17732
18819
  if (this.options.deviceCapabilities) offered.push(...this.options.deviceCapabilities);
17733
18820
  return offered;
17734
18821
  }
@@ -17873,12 +18960,90 @@ var GhostLink = class {
17873
18960
  */
17874
18961
  setTyping(typing, activity) {
17875
18962
  if (!this.options.params.profile) return;
17876
- const frame = typing ? this.supportsTyping ? this.typingSender.typing(activity) : null : this.typingSender.stopped();
18963
+ if (typing && !this.supportsTyping) {
18964
+ this.typingSender.want(activity);
18965
+ return;
18966
+ }
18967
+ const frame = typing ? this.typingSender.typing(activity) : this.typingSender.stopped();
17877
18968
  if (!frame || !this.channel || !this.supportsTyping) return;
17878
18969
  try {
17879
18970
  this.channel.send(JSON.stringify(frame));
17880
18971
  } catch {}
17881
18972
  }
18973
+ resumeTyping() {
18974
+ const frame = this.typingSender.resume();
18975
+ if (!frame || !this.channel) return;
18976
+ try {
18977
+ this.channel.send(JSON.stringify(frame));
18978
+ } catch {}
18979
+ }
18980
+ /** Both sides offer `apps/1` on the open session: mini-apps can talk (WISP 1200 § In a chat). */
18981
+ get supportsApps() {
18982
+ return !!this.options.params.profile && this.isDataLinkOpen && this.sessionCapabilities.agreed("apps/1");
18983
+ }
18984
+ /** The apps the contact has open on this session (chat app id to version). */
18985
+ get peerApps() {
18986
+ return this.apps.peerOpen;
18987
+ }
18988
+ /**
18989
+ * This side opened an app in this chat (or updated it): `open` goes now when both sides offer `apps/1` on the live
18990
+ * session, and again on every session that comes back while the app stays open. Never on the DHT.
18991
+ */
18992
+ openApp(app, version) {
18993
+ const frame = JSON.stringify(appOpenFrame(app, version));
18994
+ this.apps.open.set(app, version);
18995
+ if (this.supportsApps && this.channel) try {
18996
+ this.channel.send(frame);
18997
+ } catch {}
18998
+ }
18999
+ /** This side closed an app in this chat: `close` goes now when it can; nothing is said later. */
19000
+ closeApp(app) {
19001
+ const frame = JSON.stringify(appCloseFrame(app));
19002
+ if (!this.apps.open.delete(app)) return;
19003
+ if (this.supportsApps && this.channel) try {
19004
+ this.channel.send(frame);
19005
+ } catch {}
19006
+ }
19007
+ /**
19008
+ * One data frame of an app open on both sides, on the live session only. An error when it did not go: nothing is
19009
+ * kept to send later.
19010
+ */
19011
+ sendAppData(app, data) {
19012
+ if (!this.apps.open.has(app)) return "not-open";
19013
+ if (appDataBytes(data) > 32768) return "too-large";
19014
+ if (!this.channel || !this.isDataLinkOpen) return "offline";
19015
+ if (!this.supportsApps) return "peer-closed";
19016
+ const refused = this.apps.canSend(app, data);
19017
+ if (refused) return refused;
19018
+ try {
19019
+ this.channel.send(JSON.stringify(appDataFrame(app, data)));
19020
+ return null;
19021
+ } catch {
19022
+ return "offline";
19023
+ }
19024
+ }
19025
+ /** What the contact had open is gone (`apps/1` is no longer agreed, or this link stopped): each app hears it now. */
19026
+ appsEnded() {
19027
+ if (this.appsGrace) {
19028
+ clearTimeout(this.appsGrace);
19029
+ this.appsGrace = null;
19030
+ }
19031
+ for (const event of this.apps.sessionEnded()) this.options.events?.onAppFrame?.(event);
19032
+ }
19033
+ appsGrace = null;
19034
+ /**
19035
+ * The session ended or a new one took its place (a transport switch, a resume, a re-dial): what the contact had open
19036
+ * is held for `APP_RESUME_GRACE_MS` (WISP 1200 § In a chat). The next session's `open` takes each back, and the app
19037
+ * hears only that open, not a close first; one not said again by then is closed with `offline`.
19038
+ */
19039
+ appsSuspended() {
19040
+ this.apps.sessionSuspended();
19041
+ if (!this.apps.holding || this.appsGrace) return;
19042
+ this.appsGrace = setTimeout(() => {
19043
+ this.appsGrace = null;
19044
+ for (const event of this.apps.heldExpired()) this.options.events?.onAppFrame?.(event);
19045
+ }, this.options.appResumeGraceMs ?? 1e4);
19046
+ }
17882
19047
  /** Whether files/3 was agreed on the session open now; kept to say when that changes. */
17883
19048
  filesOpen = false;
17884
19049
  emitFilesSession(again = false) {
@@ -17951,15 +19116,24 @@ var GhostLink = class {
17951
19116
  this.channel.send(JSON.stringify(pending));
17952
19117
  } catch {}
17953
19118
  }
17954
- if (changed.includes("typing/1") && !this.supportsTyping) {
17955
- this.typingSender.reset();
17956
- this.typingReceiver.clear();
19119
+ if (changed.includes("typing/1")) {
19120
+ if (!this.supportsTyping) {
19121
+ this.typingSender.reset();
19122
+ this.typingReceiver.clear();
19123
+ } else this.resumeTyping();
17957
19124
  }
17958
19125
  if (changed.includes("react/1")) this.options.events?.onReactionsSupport?.(this.supportsReactions);
17959
19126
  if (changed.includes("edit/1")) this.options.events?.onEditSupport?.(this.supportsEdits);
17960
19127
  if (changed.includes("pin/1")) this.options.events?.onPinSupport?.(this.supportsPins);
17961
19128
  if (changed.includes("wake/1")) this.options.events?.onWakeSupport?.(this.supportsWake);
17962
19129
  if (changed.includes("upgrade/1")) this.switcher.reconsider();
19130
+ if (changed.includes("apps/1")) {
19131
+ if (this.supportsApps && this.channel) for (const [app, version] of this.apps.open) try {
19132
+ this.channel.send(JSON.stringify(appOpenFrame(app, version)));
19133
+ } catch {}
19134
+ if (!this.supportsApps) this.appsEnded();
19135
+ this.options.events?.onAppsSupport?.(this.supportsApps);
19136
+ }
17963
19137
  const devices = this.options.deviceCapabilities;
17964
19138
  if (devices && changed.some((capability) => devices.includes(capability))) this.options.events?.onDeviceCapabilities?.(devices.filter((capability) => this.supportsDevice(capability)));
17965
19139
  this.emitPairingState();
@@ -17990,7 +19164,8 @@ var GhostLink = class {
17990
19164
  2,
17991
19165
  3,
17992
19166
  4,
17993
- 5
19167
+ 5,
19168
+ 6
17994
19169
  ]
17995
19170
  }));
17996
19171
  } catch {}
@@ -18059,7 +19234,9 @@ var GhostLink = class {
18059
19234
  if (transport === "webrtc/1") {
18060
19235
  this.rtcCandidateWaiter = done;
18061
19236
  this.rtcRetired().then(() => {
18062
- if (active) return this.dataLink.connect();
19237
+ if (!active || epoch !== this.candidateEpoch || this.switcher.pending !== plan) return;
19238
+ this.switcher.dialing(plan);
19239
+ return this.dataLink.connect();
18063
19240
  }).catch(done.reject);
18064
19241
  } else {
18065
19242
  const endpoint = this.endpoints.get(transport), descriptor = plan.remote.descriptors[transport];
@@ -18067,6 +19244,7 @@ var GhostLink = class {
18067
19244
  done.reject(/* @__PURE__ */ new Error("Peer native address unavailable"));
18068
19245
  return;
18069
19246
  }
19247
+ this.switcher.dialing(plan);
18070
19248
  endpoint.connect(descriptor).then(({ channel, binding }) => {
18071
19249
  if (!active || epoch !== this.candidateEpoch || this.switcher.pending !== plan) {
18072
19250
  channel.close();
@@ -18267,9 +19445,10 @@ var GhostLink = class {
18267
19445
  },
18268
19446
  onFailure: () => {
18269
19447
  if (paired.state.keyMismatch) this.options.events?.onPeerKeyRefused?.();
18270
- if (this.unproven(channel) || paired.authTimedOut) {
19448
+ if (this.unproven(channel) || paired.authTimedOut || paired.overloaded) {
18271
19449
  if (paired.state.keyMismatch) this.dht?.foreignKeySeen("stream");
18272
19450
  if (this.unproven(channel)) traceLink(this.myPubKeyZ32, "dialed-in-refused", { keyMismatch: !!paired.state.keyMismatch });
19451
+ else if (paired.overloaded) traceLink(this.myPubKeyZ32, "receive-overload", { transport: binding?.transport ?? "webrtc/1" });
18273
19452
  else traceLink(this.myPubKeyZ32, "auth-timeout", { transport: binding?.transport ?? "webrtc/1" });
18274
19453
  migration?.reject(new Error(paired.state.error ?? "Candidate authentication failed"));
18275
19454
  channel.close();
@@ -18349,6 +19528,7 @@ var GhostLink = class {
18349
19528
  this.sessionCapabilities.reset();
18350
19529
  this.typingSender.reset();
18351
19530
  this.typingReceiver.clear();
19531
+ this.appsSuspended();
18352
19532
  this.pairedHttp?.close();
18353
19533
  this.pairedHttp = new PairedHttp(channel, this.options.getHostedHttpService, this.options.localFetch);
18354
19534
  this.emitPairingState();
@@ -18405,11 +19585,13 @@ var GhostLink = class {
18405
19585
  }
18406
19586
  if (frame?.t === "paired-pong") {
18407
19587
  this.peerAnswersPings = true;
18408
- if (this.pingSentAt) {
19588
+ const answered = ++this.pongsHeard;
19589
+ if (this.pingSentAt && answered >= this.rttPing) {
18409
19590
  this.rtt = Date.now() - this.pingSentAt;
18410
19591
  this.pingSentAt = 0;
18411
19592
  this.options.events?.onRtt?.(this.rtt);
18412
19593
  }
19594
+ for (const waiter of this.handledWaiters.filter((w) => w.ping <= answered)) waiter.done(true);
18413
19595
  return;
18414
19596
  }
18415
19597
  if (frame?.t === "paired-bye") {
@@ -18529,6 +19711,12 @@ var GhostLink = class {
18529
19711
  if (this.supportsTyping) this.typingReceiver.receive(frame);
18530
19712
  return;
18531
19713
  }
19714
+ if (frame?.t === "paired-app") {
19715
+ if (!this.supportsApps) return;
19716
+ const event = this.apps.receive(frame);
19717
+ if (event) this.options.events?.onAppFrame?.(event);
19718
+ return;
19719
+ }
18532
19720
  if (frame?.t === "paired-reaction") {
18533
19721
  if (!this.supportsReactions || !this.reactionsReceived.take()) return;
18534
19722
  const reaction = parseReactionFrame(frame);
@@ -18726,8 +19914,7 @@ var GhostLink = class {
18726
19914
  this.stopLiveness();
18727
19915
  this.peerAnswersPings = peerAnswersPings;
18728
19916
  const ping = () => {
18729
- this.pingSentAt = Date.now();
18730
- channel.send(JSON.stringify({ t: "paired-ping" }));
19917
+ this.sendPing(channel, true);
18731
19918
  if (!this.peerAnswersPings || this.pongWait) return;
18732
19919
  this.pongWait = setTimeout(() => {
18733
19920
  this.pongWait = null;
@@ -18765,6 +19952,43 @@ var GhostLink = class {
18765
19952
  this.peerAnswersPings = false;
18766
19953
  this.pingSentAt = 0;
18767
19954
  this.rtt = void 0;
19955
+ this.pingsSent = this.pongsHeard = this.rttPing = 0;
19956
+ for (const waiter of [...this.handledWaiters]) waiter.done(false);
19957
+ }
19958
+ /** `live`: a liveness ping, whose pong measures the round trip. */
19959
+ sendPing(channel, live) {
19960
+ if (live) this.pingSentAt = Date.now();
19961
+ channel.send(JSON.stringify({ t: "paired-ping" }));
19962
+ this.pingsSent++;
19963
+ if (live) this.rttPing = this.pingsSent;
19964
+ }
19965
+ /**
19966
+ * Resolves once the contact's app handled every frame sent on this session so far: it answers a ping in the order
19967
+ * frames come (every app since 1.0), so the pong to one sent now says so, however slowly it handles them. A group
19968
+ * member's catch-up answer goes a slice at a time on it (`GroupSessionHooks.handled`). `false` when it cannot tell:
19969
+ * no session, a contact that answers no ping, or no answer within `HANDLED_WAIT_MS`.
19970
+ */
19971
+ handled() {
19972
+ const channel = this.channel;
19973
+ if (!channel || !this.peerAnswersPings || this.paired?.state.status !== "ready") return Promise.resolve(false);
19974
+ try {
19975
+ this.sendPing(channel, false);
19976
+ } catch {
19977
+ return Promise.resolve(false);
19978
+ }
19979
+ return new Promise((resolve) => {
19980
+ const waiter = {
19981
+ ping: this.pingsSent,
19982
+ done: (exact) => {
19983
+ clearTimeout(timer);
19984
+ const at = this.handledWaiters.indexOf(waiter);
19985
+ if (at >= 0) this.handledWaiters.splice(at, 1);
19986
+ resolve(exact);
19987
+ }
19988
+ };
19989
+ const timer = setTimeout(() => waiter.done(false), HANDLED_WAIT_MS);
19990
+ this.handledWaiters.push(waiter);
19991
+ });
18768
19992
  }
18769
19993
  /** Something came back on the session: the ping's wait is over. */
18770
19994
  heardFromPeer() {
@@ -18802,6 +20026,10 @@ var GhostLink = class {
18802
20026
  peerLost(why) {
18803
20027
  if (this.stopped || this.leaving || this.yielding) return;
18804
20028
  traceLink(this.myPubKeyZ32, "peer-lost", { why });
20029
+ if (Date.now() < this.wokeUntil) {
20030
+ this.wokeUntil = 0;
20031
+ this.resumeAfterAbsence();
20032
+ }
18805
20033
  this.lostUntil = Date.now() + WATCH_PEER_MS;
18806
20034
  this.autoConnectFailures = 0;
18807
20035
  this.lastAutoConnectAt = 0;
@@ -18877,12 +20105,67 @@ var GhostLink = class {
18877
20105
  this.autoConnectFailures = 0;
18878
20106
  this.lastAutoConnectAt = 0;
18879
20107
  this.session.pollNow();
18880
- if (!this.channel) {
20108
+ if (this.channel) {
20109
+ this.wokeUntil = Date.now() + WAKE_LOST_MS;
20110
+ this.probeAfterWake(this.channel);
20111
+ } else {
20112
+ this.resumeAfterAbsence();
18881
20113
  if (!this.session.publishedRecently(RECENTLY_PUBLISHED_MS)) this.session.refreshAdvertisement();
18882
20114
  this.maybeAutoConnect(this.presence);
18883
20115
  }
18884
20116
  }
20117
+ /**
20118
+ * Back in front with no session, after one was live: the contact likely did not notice this app go (a phone puts a
20119
+ * backgrounded app to sleep without a word), and reads this chat at its slow pace (30 s on the relays in the
20120
+ * background), so an offer or a fresh packet of this side's waited about that long for it. The chat resumes as after a
20121
+ * restart (WISP 100, "Back after a restart"): this side dials whatever its key, on the transport it was live on, and
20122
+ * knocks on the contact's other transports as its offer goes out, which reaches the contact at once.
20123
+ */
20124
+ resumeAfterAbsence() {
20125
+ const transport = this.lastLiveOn;
20126
+ if (!transport || this.resuming || this.departed || !this.options.pairing?.credentials.peerKey || !this.options.params.profile || this.deliveryMode === "dht") return;
20127
+ this.lastLiveOn = void 0;
20128
+ this.resuming = transport;
20129
+ this.resumingWoke = true;
20130
+ this.knockedEarly = -1;
20131
+ traceLink(this.myPubKeyZ32, "wake-resume", { transport });
20132
+ if (transport !== "webrtc/1") {
20133
+ this.resumeWaitUntil = Date.now() + RESUME_WAIT_MS;
20134
+ return;
20135
+ }
20136
+ if (this.dataLink.state === "answering" && this.answeringEpoch === this.connectionEpoch) {
20137
+ const epoch = this.connectionEpoch;
20138
+ setTimeout(() => this.maybeKnockAnswering(epoch), 0);
20139
+ return;
20140
+ }
20141
+ const offered = this.offered;
20142
+ if (!offered || offered.resume || offered.epoch !== this.connectionEpoch || !this.offerOut && !this.dialing) return;
20143
+ this.resuming = void 0;
20144
+ this.resumingWoke = false;
20145
+ offered.resume = true;
20146
+ offered.woke = true;
20147
+ if (!this.dialing) setTimeout(() => this.maybeKnockEarly(offered.epoch), 0);
20148
+ }
20149
+ /** A ping on the session right after a wake, with `PONG_WAIT_MS` for its answer: none, and the session is dead. */
20150
+ probeAfterWake(channel) {
20151
+ if (!this.peerAnswersPings || this.paired?.state.status !== "ready") return;
20152
+ this.unansweredPings++;
20153
+ try {
20154
+ this.sendPing(channel, true);
20155
+ } catch {
20156
+ this.dropDeadSession(channel);
20157
+ return;
20158
+ }
20159
+ if (this.pongWait) clearTimeout(this.pongWait);
20160
+ this.pongWait = setTimeout(() => {
20161
+ this.pongWait = null;
20162
+ if (this.channel !== channel || this.stopped) return;
20163
+ traceLink(this.myPubKeyZ32, "wake-unanswered", {});
20164
+ this.dropDeadSession(channel);
20165
+ }, PONG_WAIT_MS);
20166
+ }
18885
20167
  detach() {
20168
+ if (this.channel && this.paired?.state.status === "ready") this.lastLiveOn = this.activeBinding?.transport ?? "webrtc/1";
18886
20169
  this.stopLiveness();
18887
20170
  const wasNative = !!this.activeBinding;
18888
20171
  this.applicationOpen = false;
@@ -18908,6 +20191,7 @@ var GhostLink = class {
18908
20191
  this.sessionCapabilities.reset();
18909
20192
  this.typingSender.reset();
18910
20193
  this.typingReceiver.clear();
20194
+ this.appsSuspended();
18911
20195
  this.emitFilesSession();
18912
20196
  if (this.peerGroupVersions) {
18913
20197
  this.peerGroupVersions = null;
@@ -20771,7 +22055,7 @@ var DID_GHOSTLY_SERVICE = "GhostlyIdentityProof";
20771
22055
  var METHOD_NAME = /^[a-z0-9]+$/;
20772
22056
  var Z32_KEY = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/;
20773
22057
  var B64URL = /^[A-Za-z0-9_-]*$/;
20774
- var SEGMENT = /^[A-Za-z0-9._~-]{1,64}$/;
22058
+ var SEGMENT$1 = /^[A-Za-z0-9._~-]{1,64}$/;
20775
22059
  var HEX64 = /^[a-f0-9]{64}$/;
20776
22060
  var METHOD_LIST = "did:key, did:jwk, did:dht and did:web";
20777
22061
  /** base64url without padding, strictly: decoding and encoding again gives the same text. */
@@ -20782,7 +22066,7 @@ function strictBase64Url(value) {
20782
22066
  if (toBase64Url(bytes) !== value) throw new DidError("Not canonical base64url");
20783
22067
  return bytes;
20784
22068
  }
20785
- var isObject$1 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
22069
+ var isObject$3 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
20786
22070
  /**
20787
22071
  * The DID a person typed, checked and in canonical form, or a message they can act on. Methods other than
20788
22072
  * the four Ghostly checks are refused with "not supported yet"; a DID URL (a `#fragment`, `?query` or
@@ -20856,7 +22140,7 @@ function parseWeb(id) {
20856
22140
  if (!(port >= 1 && port <= 65535) || String(port) !== portMatch[2]) throw new DidError("did:web: that port is not valid");
20857
22141
  }
20858
22142
  if (path.length > 8) throw new DidError("did:web: at most 8 path segments");
20859
- for (const segment of path) if (!SEGMENT.test(segment) || segment === "." || segment === "..") throw new DidError("did:web: each path segment is letters, digits, dots, dashes, underscores or tildes");
22143
+ for (const segment of path) if (!SEGMENT$1.test(segment) || segment === "." || segment === "..") throw new DidError("did:web: each path segment is letters, digits, dots, dashes, underscores or tildes");
20860
22144
  return {
20861
22145
  id: [`${host}${port ? `%3A${port}` : ""}`, ...path].join(":"),
20862
22146
  host,
@@ -20900,7 +22184,7 @@ function didWebFileNames(text, did, statement) {
20900
22184
  } catch {
20901
22185
  return false;
20902
22186
  }
20903
- return isObject$1(body) && body.ghostly === 1 && body.did === did && body.statement === statement;
22187
+ return isObject$3(body) && body.ghostly === 1 && body.did === did && body.statement === statement;
20904
22188
  }
20905
22189
  /** The service entry a did.json can carry instead of the file. */
20906
22190
  function didGhostlyServiceEntry(did, key, proofId) {
@@ -20982,7 +22266,7 @@ var JWK_CURVES = {
20982
22266
  };
20983
22267
  /** A public JWK. Other curves are `undefined`; a malformed one throws. */
20984
22268
  function jwkKey(jwk) {
20985
- if (!isObject$1(jwk)) throw new DidError("That JWK is not an object");
22269
+ if (!isObject$3(jwk)) throw new DidError("That JWK is not an object");
20986
22270
  const spec = typeof jwk.crv === "string" ? JWK_CURVES[jwk.crv] : void 0;
20987
22271
  if (!spec || jwk.kty !== spec.kty) return void 0;
20988
22272
  const coordinate = (v) => {
@@ -21027,7 +22311,7 @@ function staticKey(did) {
21027
22311
  } catch {
21028
22312
  throw new DidError("That did:jwk does not hold a JSON Web Key");
21029
22313
  }
21030
- if (!isObject$1(jwk)) throw new DidError("That did:jwk does not hold a JSON Web Key");
22314
+ if (!isObject$3(jwk)) throw new DidError("That did:jwk does not hold a JSON Web Key");
21031
22315
  if ("d" in jwk || "p" in jwk || "k" in jwk) throw new DidError("That did:jwk contains a private key. Never share it: make a new key");
21032
22316
  const key = jwkKey(jwk);
21033
22317
  if (!key) throw new DidError("That did:jwk is not an Ed25519, secp256k1 or P-256 key");
@@ -21090,13 +22374,13 @@ var list = (value, name) => {
21090
22374
  * `GhostlyIdentityProof` services are kept. Keys Ghostly cannot use are counted in `skipped`.
21091
22375
  */
21092
22376
  function parseDidDocument(did, raw) {
21093
- if (!isObject$1(raw)) throw new DidError("The DID document is not a JSON object");
22377
+ if (!isObject$3(raw)) throw new DidError("The DID document is not a JSON object");
21094
22378
  if (raw.id !== did) throw new DidError(typeof raw.id === "string" ? `The DID document is for another DID (${raw.id.slice(0, 80)})` : "The DID document has no id");
21095
22379
  const keys = /* @__PURE__ */ new Map();
21096
22380
  const unusable = /* @__PURE__ */ new Set();
21097
22381
  let skipped = 0;
21098
22382
  const add = (vm) => {
21099
- if (!isObject$1(vm)) {
22383
+ if (!isObject$3(vm)) {
21100
22384
  skipped++;
21101
22385
  return;
21102
22386
  }
@@ -21127,10 +22411,10 @@ function parseDidDocument(did, raw) {
21127
22411
  }
21128
22412
  const services = [];
21129
22413
  for (const s of list(raw.service, "service")) {
21130
- if (!isObject$1(s)) continue;
22414
+ if (!isObject$3(s)) continue;
21131
22415
  const types = Array.isArray(s.type) ? s.type : [s.type];
21132
22416
  const endpoint = s.serviceEndpoint;
21133
- if (!types.includes("GhostlyIdentityProof") || !isObject$1(endpoint)) continue;
22417
+ if (!types.includes("GhostlyIdentityProof") || !isObject$3(endpoint)) continue;
21134
22418
  if (typeof endpoint.key === "string" && Z32_KEY.test(endpoint.key) && typeof endpoint.proof === "string" && HEX64.test(endpoint.proof)) services.push({
21135
22419
  id: typeof s.id === "string" ? s.id.slice(0, 200) : "",
21136
22420
  key: endpoint.key,
@@ -21173,7 +22457,7 @@ function parseDidJws(input) {
21173
22457
  } catch {
21174
22458
  throw new DidError("The JWS header is not base64url JSON");
21175
22459
  }
21176
- if (!isObject$1(header)) throw new DidError("The JWS header is not a JSON object");
22460
+ if (!isObject$3(header)) throw new DidError("The JWS header is not a JSON object");
21177
22461
  const alg = header.alg;
21178
22462
  if (typeof alg !== "string" || !DID_JWS_ALGS[alg]) throw new DidError(`The JWS uses ${typeof alg === "string" ? alg.slice(0, 16) : "no algorithm"}: Ghostly checks EdDSA, ES256K and ES256`);
21179
22463
  if (header.kid !== void 0 && (typeof header.kid !== "string" || header.kid.length > 640)) throw new DidError("The JWS kid is not a key id");
@@ -21985,7 +23269,7 @@ function compactSize(n) {
21985
23269
  throw new Error("Too large");
21986
23270
  }
21987
23271
  var varBytes = (b) => concatBytes$1(compactSize(b.length), b);
21988
- var u32$1 = (n) => {
23272
+ var u32$2 = (n) => {
21989
23273
  const b = /* @__PURE__ */ new Uint8Array(4);
21990
23274
  new DataView(b.buffer).setUint32(0, n >>> 0, true);
21991
23275
  return b;
@@ -21996,14 +23280,14 @@ var u64 = (n) => {
21996
23280
  return b;
21997
23281
  };
21998
23282
  function encodeTx(tx, withWitness = tx.inputs.some((i) => i.witness.length > 0)) {
21999
- const parts = [u32$1(tx.version)];
23283
+ const parts = [u32$2(tx.version)];
22000
23284
  if (withWitness) parts.push(Uint8Array.of(0, 1));
22001
23285
  parts.push(compactSize(tx.inputs.length));
22002
- for (const i of tx.inputs) parts.push(i.txid, u32$1(i.vout), varBytes(i.scriptSig), u32$1(i.sequence));
23286
+ for (const i of tx.inputs) parts.push(i.txid, u32$2(i.vout), varBytes(i.scriptSig), u32$2(i.sequence));
22003
23287
  parts.push(compactSize(tx.outputs.length));
22004
23288
  for (const o of tx.outputs) parts.push(u64(o.value), varBytes(o.script));
22005
23289
  if (withWitness) for (const i of tx.inputs) parts.push(encodeWitness(i.witness));
22006
- parts.push(u32$1(tx.lockTime));
23290
+ parts.push(u32$2(tx.lockTime));
22007
23291
  return concatBytes$1(...parts);
22008
23292
  }
22009
23293
  var encodeWitness = (stack) => concatBytes$1(compactSize(stack.length), ...stack.map(varBytes));
@@ -22169,12 +23453,12 @@ function decodeBase64(text) {
22169
23453
  /** BIP 143, for witness v0. `scriptCode` is the P2PKH script of the key. */
22170
23454
  function sighashV0(tx, scriptCode, hashType) {
22171
23455
  const input = tx.inputs[0];
22172
- return sha256d(u32$1(tx.version), sha256d(...tx.inputs.map((i) => concatBytes$1(i.txid, u32$1(i.vout)))), sha256d(...tx.inputs.map((i) => u32$1(i.sequence))), input.txid, u32$1(input.vout), varBytes(scriptCode), u64(0n), u32$1(input.sequence), sha256d(...tx.outputs.map((o) => concatBytes$1(u64(o.value), varBytes(o.script)))), u32$1(tx.lockTime), u32$1(hashType));
23456
+ return sha256d(u32$2(tx.version), sha256d(...tx.inputs.map((i) => concatBytes$1(i.txid, u32$2(i.vout)))), sha256d(...tx.inputs.map((i) => u32$2(i.sequence))), input.txid, u32$2(input.vout), varBytes(scriptCode), u64(0n), u32$2(input.sequence), sha256d(...tx.outputs.map((o) => concatBytes$1(u64(o.value), varBytes(o.script)))), u32$2(tx.lockTime), u32$2(hashType));
22173
23457
  }
22174
23458
  /** BIP 341 key path, SIGHASH_DEFAULT or SIGHASH_ALL, no annex. */
22175
23459
  function sighashV1(tx, scriptPubKey, hashType) {
22176
23460
  const input = tx.inputs[0];
22177
- return schnorr.utils.taggedHash("TapSighash", concatBytes$1(Uint8Array.of(0, hashType), u32$1(tx.version), u32$1(tx.lockTime), sha256(concatBytes$1(input.txid, u32$1(input.vout))), sha256(u64(0n)), sha256(varBytes(scriptPubKey)), sha256(u32$1(input.sequence)), sha256(concatBytes$1(...tx.outputs.map((o) => concatBytes$1(u64(o.value), varBytes(o.script))))), Uint8Array.of(0), u32$1(0)));
23461
+ return schnorr.utils.taggedHash("TapSighash", concatBytes$1(Uint8Array.of(0, hashType), u32$2(tx.version), u32$2(tx.lockTime), sha256(concatBytes$1(input.txid, u32$2(input.vout))), sha256(u64(0n)), sha256(varBytes(scriptPubKey)), sha256(u32$2(input.sequence)), sha256(concatBytes$1(...tx.outputs.map((o) => concatBytes$1(u64(o.value), varBytes(o.script))))), Uint8Array.of(0), u32$2(0)));
22178
23462
  }
22179
23463
  /** The original sighash, for P2PKH: the input's script replaced by the spent output's, the rest as is. */
22180
23464
  function sighashLegacy(tx, scriptPubKey, hashType) {
@@ -22185,7 +23469,7 @@ function sighashLegacy(tx, scriptPubKey, hashType) {
22185
23469
  witness: [],
22186
23470
  scriptSig: n === 0 ? scriptPubKey : /* @__PURE__ */ new Uint8Array(0)
22187
23471
  }))
22188
- }, false), u32$1(hashType));
23472
+ }, false), u32$2(hashType));
22189
23473
  }
22190
23474
  /** A check that throws on malformed input (a point off the curve…) simply does not hold. */
22191
23475
  function holds(check) {
@@ -22370,11 +23654,11 @@ function verifyBip322(address, message, signature) {
22370
23654
  */
22371
23655
  var MAX_MESSAGE = 16384;
22372
23656
  var MAX_SIGNATURE = 16384;
22373
- var MAGIC = utf8Encode("Bitcoin Signed Message:\n");
23657
+ var MAGIC$1 = utf8Encode("Bitcoin Signed Message:\n");
22374
23658
  /** The digest `signmessage` signs: double SHA-256 of the length-prefixed magic and message. */
22375
23659
  function legacyMessageHash(message) {
22376
23660
  const bytes = utf8Encode(message);
22377
- return sha256d(compactSize(MAGIC.length), MAGIC, compactSize(bytes.length), bytes);
23661
+ return sha256d(compactSize(MAGIC$1.length), MAGIC$1, compactSize(bytes.length), bytes);
22378
23662
  }
22379
23663
  /** Header bytes 27-34 are P2PKH (uncompressed 27-30, compressed 31-34); 35-42 are BIP 137 SegWit headers. */
22380
23664
  var isLegacySignature = (bytes) => bytes.length === 65 && bytes[0] >= 27 && bytes[0] <= 42;
@@ -22501,8 +23785,8 @@ var Reader = class {
22501
23785
  if (!this.done) fail("Trailing bytes in SSH data");
22502
23786
  }
22503
23787
  };
22504
- var u32 = (n) => Uint8Array.of(n >>> 24, n >>> 16 & 255, n >>> 8 & 255, n & 255);
22505
- var sshString = (b) => concatBytes$1(u32(b.length), b);
23788
+ var u32$1 = (n) => Uint8Array.of(n >>> 24, n >>> 16 & 255, n >>> 8 & 255, n & 255);
23789
+ var sshString = (b) => concatBytes$1(u32$1(b.length), b);
22506
23790
  function strictBase64(text, max) {
22507
23791
  if (text.length > max || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(text)) fail("Invalid base64");
22508
23792
  const bytes = fromBase64(text);
@@ -22668,7 +23952,7 @@ async function verifySshSignature(armored, message, options = {}) {
22668
23952
  userVerified: !!(flags & SK_USER_VERIFIED),
22669
23953
  counter
22670
23954
  };
22671
- data = concatBytes$1(sha256(utf8Encode(key.application)), Uint8Array.of(flags), u32(counter), sha256(signed));
23955
+ data = concatBytes$1(sha256(utf8Encode(key.application)), Uint8Array.of(flags), u32$1(counter), sha256(signed));
22672
23956
  }
22673
23957
  r.end();
22674
23958
  const verifyWith = async () => {
@@ -23078,6 +24362,64 @@ function indexOf(points, words, from) {
23078
24362
  }
23079
24363
  return -1;
23080
24364
  }
24365
+ /**
24366
+ * Catch-up answers, a slice at a time (`CATCH_UP_SLICE`), with two slices at most not handled yet by the member's app
24367
+ * (`hooks.handled`): the next once it handled the one before the last, so the slices follow each other without a round
24368
+ * trip between them. All at once, a member back in a busy group was handed over a hundred frames on one session, and an
24369
+ * app that holds 64 waiting ended that session mid catch-up ("Session receive limit exceeded", 2026-10-07). The frames
24370
+ * and their order are what they were: only when they go changes.
24371
+ */
24372
+ var CatchUpAnswers = class {
24373
+ hooks;
24374
+ /** Answers still going out, per member: what is left of each. */
24375
+ going = /* @__PURE__ */ new Map();
24376
+ constructor(hooks) {
24377
+ this.hooks = hooks;
24378
+ }
24379
+ /**
24380
+ * The first two slices go now, the rest without holding the group's other frames back. A newer answer to the same
24381
+ * member takes the place of what is left of the last; a frame the edge did not take ends it (the member asks again
24382
+ * when its edge opens).
24383
+ */
24384
+ handOut(to, frames) {
24385
+ const handled = this.hooks.handled;
24386
+ if (!handled) {
24387
+ for (const frame of frames) this.hooks.send(to, frame);
24388
+ return;
24389
+ }
24390
+ const going = this.going.get(to);
24391
+ if (going) {
24392
+ going.splice(0, going.length, ...frames);
24393
+ return;
24394
+ }
24395
+ const rest = [...frames], told = [];
24396
+ const next = () => {
24397
+ if (!this.sendSlice(to, rest)) return false;
24398
+ if (rest.length) told.push(handled(to).catch(() => false));
24399
+ return true;
24400
+ };
24401
+ if (!next() || !next() || !rest.length) return;
24402
+ this.going.set(to, rest);
24403
+ (async () => {
24404
+ try {
24405
+ while (rest.length) {
24406
+ if (!await told.shift()) await new Promise((resolve) => setTimeout(resolve, 500));
24407
+ if (!this.hooks.active() || !next()) break;
24408
+ }
24409
+ } finally {
24410
+ this.going.delete(to);
24411
+ }
24412
+ })();
24413
+ }
24414
+ /** The next slice of `rest` to `to`: false, and nothing left, when the edge did not take one. */
24415
+ sendSlice(to, rest) {
24416
+ for (const frame of rest.splice(0, 16)) if (this.hooks.send(to, frame) === false) {
24417
+ rest.length = 0;
24418
+ return false;
24419
+ }
24420
+ return true;
24421
+ }
24422
+ };
23081
24423
  //#endregion
23082
24424
  //#region ../core/src/groupSession.ts
23083
24425
  /**
@@ -23129,7 +24471,15 @@ var GROUP_LIMITS = {
23129
24471
  syncAnswers: 8,
23130
24472
  syncWindowMs: 6e4,
23131
24473
  /** Messages delivered from a copy that was not whole, remembered until a whole copy completes them. */
23132
- provisional: 256
24474
+ provisional: 256,
24475
+ /**
24476
+ * Frames of one catch-up answer in a slice (`GroupSessionHooks.handled`), two slices at most not handled yet by the
24477
+ * member's app: the next goes once it handled the one before the last. An app holds 64 frames waiting at most before
24478
+ * 2026-10-07 (more ends the session), and a member back in a busy group was handed over a hundred at once.
24479
+ */
24480
+ catchUpSlice: 16,
24481
+ /** Between two slices, when the member's app cannot say it handled one (`handled` resolves `false`). */
24482
+ catchUpPauseMs: 500
23133
24483
  };
23134
24484
  /** What every member should know about who can read what, in the words the apps show. */
23135
24485
  var GROUP_READ_NOTE = `Everyone in the group can read everything sent while they are a member. Someone removed keeps what they already received and cannot read what comes after; someone who joins later cannot read what came before. Messages go directly to each member; whoever was away gets what they missed from any member who has it (the group's last ${GROUP_LIMITS.relay} messages) when they meet again.`;
@@ -23168,6 +24518,17 @@ var cardAad = (f) => JSON.stringify([
23168
24518
  f.ts
23169
24519
  ]);
23170
24520
  var isCardBox = isSealedBox(MAX_CARD_BOX);
24521
+ /** A file's description as JSON: a name and a type of 255 characters each, 128 voice bars or a 12 KiB poster, sealed. */
24522
+ var MAX_FILE_BOX = Math.ceil((GROUP_FILE_LIMITS.nameChars * 4 + GROUP_FILE_LIMITS.mimeChars + 17e3) * 4 / 3) + 4;
24523
+ var fileAad = (f) => JSON.stringify([
24524
+ "ghostly-group/1 file",
24525
+ f.g,
24526
+ f.e,
24527
+ f.s,
24528
+ f.n,
24529
+ f.ts
24530
+ ]);
24531
+ var isFileBox = isSealedBox(MAX_FILE_BOX);
23171
24532
  var B64$1 = /^[A-Za-z0-9_-]*$/;
23172
24533
  var secretAad$1 = (g, e, member) => JSON.stringify([
23173
24534
  "ghostly-group/1 secret",
@@ -23216,6 +24577,11 @@ var messageSignedWhole = (f) => utf8Encode(JSON.stringify([
23216
24577
  f.sc.n,
23217
24578
  f.sc.c
23218
24579
  ] : [],
24580
+ ...f.fl ? [
24581
+ "fl",
24582
+ f.fl.n,
24583
+ f.fl.c
24584
+ ] : [],
23219
24585
  ...f.o ? [
23220
24586
  "o",
23221
24587
  f.o.e,
@@ -23294,7 +24660,7 @@ function readFirstPlace(o, epoch) {
23294
24660
  /** One of my own frames, among mine: `e:n`. */
23295
24661
  var ownKey = (f) => `${f.e}:${f.n}`;
23296
24662
  /** What a frame weighs in a log, roughly: its boxes plus the fixed fields around them. */
23297
- var frameBytes = (f) => f.c.length + (f.m?.c.length ?? 0) + (f.r?.c.length ?? 0) + (f.sc?.c.length ?? 0) + 400;
24663
+ 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;
23298
24664
  /** Only the fields an edit frame has, as `clean` does for a message. */
23299
24665
  var cleanEdit = (f) => ({
23300
24666
  t: GROUP_EDIT_FRAME,
@@ -23333,6 +24699,10 @@ function clean(f) {
23333
24699
  n: f.sc.n,
23334
24700
  c: f.sc.c
23335
24701
  } } : {},
24702
+ ...isFileBox(f.fl) ? { fl: {
24703
+ n: f.fl.n,
24704
+ c: f.fl.c
24705
+ } } : {},
23336
24706
  ...readFirstPlace(f.o, f.e) ? { o: readFirstPlace(f.o, f.e) } : {},
23337
24707
  ...typeof f.xs === "string" && f.xs.length === 86 && B64$1.test(f.xs) ? { xs: f.xs } : {}
23338
24708
  };
@@ -23409,6 +24779,8 @@ var GroupSession = class {
23409
24779
  passOn = null;
23410
24780
  /** Syncs answered per member, a few a minute (`GROUP_LIMITS.syncAnswers`). In memory only. */
23411
24781
  syncsAnswered = /* @__PURE__ */ new Map();
24782
+ /** Catch-up answers still going out, a slice at a time (`handOut`). */
24783
+ answers;
23412
24784
  /**
23413
24785
  * Messages delivered from a copy that was not whole (handed on without its author's whole signature): not seen, so
23414
24786
  * a sync still asks for them and a whole copy completes them. Oldest first, saved as `GroupState.provisional`, so a
@@ -23421,6 +24793,11 @@ var GroupSession = class {
23421
24793
  this.hooks = hooks;
23422
24794
  this.state = state;
23423
24795
  this.identity = identityFromSeedB64(state.seedB64);
24796
+ this.answers = new CatchUpAnswers({
24797
+ send: (to, frame) => hooks.send(to, frame),
24798
+ ...hooks.handled && { handled: (to) => hooks.handled(to) },
24799
+ active: () => this.state.status === "active"
24800
+ });
23424
24801
  this.provisional = new Set((state.provisional ?? []).slice(-GROUP_LIMITS.provisional));
23425
24802
  this.chainMovedAt = this.clock();
23426
24803
  }
@@ -23545,6 +24922,15 @@ var GroupSession = class {
23545
24922
  get others() {
23546
24923
  return this.roster.map(([k]) => k).filter((k) => k !== this.myKey);
23547
24924
  }
24925
+ /**
24926
+ * Whether `key` could read message `messageId` (`<s>:<e>:<n>`) and still can: in the roster of its epoch, and in the
24927
+ * roster now. A holder serves a group file only to such a member (WISP 503 § Asking).
24928
+ */
24929
+ couldRead(key, messageId) {
24930
+ const e = Number(messageId.split(":")[1]);
24931
+ const then = this.state.chain.find((c) => c.e === e);
24932
+ return !!then && rosterHas(then.m, key) && rosterHas(this.roster, key);
24933
+ }
23548
24934
  role(key) {
23549
24935
  return this.roster.find(([k]) => k === key)?.[1];
23550
24936
  }
@@ -23793,14 +25179,18 @@ var GroupSession = class {
23793
25179
  * text that name members (everyone: the admin only); what does not hold is left out. `reply`: the message it
23794
25180
  * answers, sealed apart from the text (`r`) like the mentions, so an older app still reads the text. `forwarded`: the
23795
25181
  * hop count of a forwarded text (`f`, WISP 902 § Forwards). `card`: a status card, checked by the caller, sealed in a
23796
- * box of its own (`sc`, WISP 405 · Status Cards); the text is its fallback.
25182
+ * box of its own (`sc`, WISP 405 · Status Cards); the text is its fallback. `file`: a file or voice message the text
25183
+ * announces (`fl`, WISP 503 · Group Files), its description checked by the caller; the text is its caption, or the
25184
+ * line an older app shows.
23797
25185
  */
23798
- sendText(text, now = Date.now(), mentions = [], reply, forwarded, card) {
25186
+ sendText(text, now = Date.now(), mentions = [], reply, forwarded, card, file) {
23799
25187
  return this.serialize(async () => {
23800
25188
  if (this.state.status !== "active") return { error: this.state.statusReason ?? "You are no longer in this group" };
23801
25189
  const trimmed = text.trim();
23802
25190
  if (!trimmed) return { error: "Nothing to send" };
23803
25191
  if (utf8Encode(trimmed).length > GROUP_LIMITS.textBytes) return { error: "Message exceeds 16 KiB" };
25192
+ const described = file === void 0 ? void 0 : readGroupFileMeta(file);
25193
+ if (file !== void 0 && !described) return { error: "That file cannot go to a group" };
23804
25194
  const epoch = this.epoch;
23805
25195
  if (!this.secret(epoch)) return { error: "This epoch's key has not arrived yet. Wait for a member to catch you up." };
23806
25196
  const n = this.nextSeq(epoch);
@@ -23811,7 +25201,8 @@ var GroupSession = class {
23811
25201
  mentions: named,
23812
25202
  reply: answers || void 0,
23813
25203
  forwarded: fw,
23814
- card
25204
+ card,
25205
+ file: described ?? void 0
23815
25206
  });
23816
25207
  this.logOwn(frame);
23817
25208
  const id = groupMessageId(this.myKey, epoch, n);
@@ -23825,7 +25216,8 @@ var GroupSession = class {
23825
25216
  ...named.length ? { mentions: named } : {},
23826
25217
  ...answers ? { reply: answers } : {},
23827
25218
  ...fw ? { forwarded: fw } : {},
23828
- ...card ? { card } : {}
25219
+ ...card ? { card } : {},
25220
+ ...described ? { file: described } : {}
23829
25221
  });
23830
25222
  await this.say(frame);
23831
25223
  return { id };
@@ -23866,6 +25258,7 @@ var GroupSession = class {
23866
25258
  ...extra.reply ? { r: encryptText(key, replyAad(header), JSON.stringify(extra.reply)) } : {},
23867
25259
  ...extra.forwarded ? { f: extra.forwarded } : {},
23868
25260
  ...extra.card ? { sc: encryptText(key, cardAad(header), JSON.stringify(extra.card)) } : {},
25261
+ ...extra.file ? { fl: encryptText(key, fileAad(header), JSON.stringify(extra.file)) } : {},
23869
25262
  ...o ? { o } : {}
23870
25263
  };
23871
25264
  return {
@@ -23935,7 +25328,8 @@ var GroupSession = class {
23935
25328
  mentions,
23936
25329
  reply: this.openReply(key, f),
23937
25330
  forwarded: readForwarded(f.f) ? f.f : void 0,
23938
- card: this.openCard(key, f)
25331
+ card: this.openCard(key, f),
25332
+ file: this.openFile(key, f)
23939
25333
  }, readFirstPlace(f.o, f.e) ?? {
23940
25334
  e: f.e,
23941
25335
  n: f.n
@@ -24270,6 +25664,7 @@ var GroupSession = class {
24270
25664
  delete raw.r;
24271
25665
  delete raw.f;
24272
25666
  delete raw.sc;
25667
+ delete raw.fl;
24273
25668
  delete raw.o;
24274
25669
  delete raw.xs;
24275
25670
  }
@@ -24300,6 +25695,7 @@ var GroupSession = class {
24300
25695
  const reply = this.openReply(key, raw);
24301
25696
  const forwarded = readForwarded(raw.f);
24302
25697
  const card = this.openCard(key, raw);
25698
+ const file = this.openFile(key, raw);
24303
25699
  const completes = whole && this.provisional.delete(id);
24304
25700
  await this.hooks.message({
24305
25701
  id,
@@ -24312,6 +25708,7 @@ var GroupSession = class {
24312
25708
  ...reply ? { reply } : {},
24313
25709
  ...forwarded ? { forwarded } : {},
24314
25710
  ...card ? { card } : {},
25711
+ ...file ? { file } : {},
24315
25712
  ...completes ? { completes: true } : {}
24316
25713
  });
24317
25714
  if (!whole) {
@@ -24413,6 +25810,17 @@ var GroupSession = class {
24413
25810
  return;
24414
25811
  }
24415
25812
  }
25813
+ /** The file a message announces (WISP 503); a box that does not open, or a description that does not hold, is no file. */
25814
+ openFile(key, raw) {
25815
+ if (!isFileBox(raw.fl)) return void 0;
25816
+ const plain = decryptText(key, fileAad(raw), raw.fl.n, raw.fl.c);
25817
+ if (plain === null) return void 0;
25818
+ try {
25819
+ return readGroupFileMeta(JSON.parse(plain)) ?? void 0;
25820
+ } catch {
25821
+ return;
25822
+ }
25823
+ }
24416
25824
  /** The message a text answers; a box that does not open, or does not hold one, is no reply, not no message. */
24417
25825
  openReply(key, raw) {
24418
25826
  if (!isReplyBox(raw.r)) return void 0;
@@ -24566,9 +25974,10 @@ var GroupSession = class {
24566
25974
  });
24567
25975
  return;
24568
25976
  }
25977
+ const answer = [];
24569
25978
  for (let e = frame.e + 1; e <= this.epoch; e++) {
24570
25979
  const commit = this.state.chain[e], secret = this.secret(e);
24571
- this.hooks.send(from, {
25980
+ answer.push({
24572
25981
  t: "group-commit",
24573
25982
  g: this.id,
24574
25983
  commit,
@@ -24580,7 +25989,7 @@ var GroupSession = class {
24580
25989
  e,
24581
25990
  s: sealSecret(from, this.secret(e), secretAad$1(this.id, e, from))
24582
25991
  }));
24583
- for (let i = 0; i < secrets.length; i += GROUP_LIMITS.secretsPerFrame) this.hooks.send(from, {
25992
+ for (let i = 0; i < secrets.length; i += GROUP_LIMITS.secretsPerFrame) answer.push({
24584
25993
  t: "group-secrets",
24585
25994
  g: this.id,
24586
25995
  secrets: secrets.slice(i, i + GROUP_LIMITS.secretsPerFrame)
@@ -24588,12 +25997,24 @@ var GroupSession = class {
24588
25997
  const lacks = missingIn(frame, from);
24589
25998
  const asked = new Set(Array.isArray(frame.ask) ? frame.ask.filter((k) => typeof k === "string" && k !== from && k !== this.myKey && rosterHas(this.roster, k)) : []);
24590
25999
  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)) : [];
24591
- for (const next of asWritten(this.ownLacked(from, frame), kept)) this.hooks.send(from, next);
26000
+ answer.push(...asWritten(this.ownLacked(from, frame), kept));
24592
26001
  for (const author of asked) {
24593
26002
  const edits = (this.state.relayEdits ?? []).filter((f) => f.s === author && this.state.chain[f.e] && rosterHas(this.state.chain[f.e].m, from));
24594
- for (const edit of edits.slice(-GROUP_LIMITS.handOnEdits)) this.hooks.send(from, edit);
26003
+ answer.push(...edits.slice(-GROUP_LIMITS.handOnEdits));
24595
26004
  }
24596
- this.offerMeta(from, frame.mt);
26005
+ const meta = this.metaFor(from, frame.mt);
26006
+ if (meta) answer.push(meta);
26007
+ this.handOut(from, answer);
26008
+ }
26009
+ /**
26010
+ * A catch-up answer, a slice at a time (`GROUP_LIMITS.catchUpSlice`), with two slices at most not handled yet by the
26011
+ * member's app (`hooks.handled`), as a community's goes (`CatchUpAnswers`). All at once, a member back in a busy group
26012
+ * was handed over a hundred frames on one session, and an app that holds 64 waiting ended that session mid catch-up
26013
+ * ("Session receive limit exceeded", 2026-10-07). A newer answer to the same member takes the place of what is left of
26014
+ * the last; a frame the edge did not take ends it (the member asks again when its edge opens).
26015
+ */
26016
+ handOut(to, frames) {
26017
+ this.answers.handOut(to, frames);
24597
26018
  }
24598
26019
  /** My own messages a member's sync says it lacks, from my bounded log, for the epochs it was a member of. */
24599
26020
  handOwn(to, frame) {
@@ -24672,10 +26093,14 @@ var GroupSession = class {
24672
26093
  }
24673
26094
  /** My statement, to a member whose sync says it holds an older one (or none); nothing to an app that says nothing. */
24674
26095
  offerMeta(to, theirTag) {
26096
+ const frame = this.metaFor(to, theirTag);
26097
+ if (frame) this.hooks.send(to, frame);
26098
+ }
26099
+ metaFor(to, theirTag) {
24675
26100
  const meta = this.state.meta, theirs = parseGroupMetaTag(theirTag);
24676
- if (!meta || theirs === null || !groupMetaNewer(meta, theirs) || !rosterHas(this.roster, to)) return;
26101
+ if (!meta || theirs === null || !groupMetaNewer(meta, theirs) || !rosterHas(this.roster, to)) return null;
24677
26102
  const secret = this.secret(this.epoch);
24678
- if (secret) this.hooks.send(to, wrapGroupMeta(meta, this.epoch, epochKeys(secret, this.id, this.epoch).message));
26103
+ return secret ? wrapGroupMeta(meta, this.epoch, epochKeys(secret, this.id, this.epoch).message) : null;
24679
26104
  }
24680
26105
  /**
24681
26106
  * A metadata statement: kept when it is newer than mine, its commit is on my chain, its signer was
@@ -24959,7 +26384,15 @@ var COMMUNITY_LIMITS = {
24959
26384
  * waits for the window.
24960
26385
  */
24961
26386
  syncAnswers: 16,
24962
- syncWindowMs: 6e4
26387
+ syncWindowMs: 6e4,
26388
+ /**
26389
+ * Frames of one catch-up answer in a slice (`CommunitySessionHooks.handled`), two slices at most not handled yet by
26390
+ * the member's app: a member back after the store filled was handed 258 at once, and an app holds 64 waiting at most
26391
+ * before 2026-10-07 (more ends the session). As a private group's (`GROUP_LIMITS.catchUpSlice`).
26392
+ */
26393
+ catchUpSlice: 16,
26394
+ /** Between two slices, when the member's app cannot say it handled one (`handled` resolves `false`). */
26395
+ catchUpPauseMs: 500
24963
26396
  };
24964
26397
  /** What every member should know about who can read what, and what the link does, in the words the apps show. */
24965
26398
  var GROUP_READ_NOTE_COMMUNITY = `Anyone with the group's link can join, and any member can let people in. Everyone in the group can read everything sent while they are a member; someone removed cannot read what comes after, someone who joins later cannot read what came before. Members who were away get the last ${COMMUNITY_LIMITS.store} messages from whoever is there when they return.`;
@@ -25184,7 +26617,7 @@ var pairAad = (f, to) => JSON.stringify([
25184
26617
  f.ts,
25185
26618
  to
25186
26619
  ]);
25187
- var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
26620
+ var isObject$2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
25188
26621
  var messageSigned = (f) => utf8Encode(JSON.stringify([
25189
26622
  "ghostly-group/2 msg",
25190
26623
  f.g,
@@ -25269,6 +26702,8 @@ var CommunitySession = class {
25269
26702
  asked = /* @__PURE__ */ new Map();
25270
26703
  /** Syncs answered per member, a few a minute (`COMMUNITY_LIMITS.syncAnswers`). In memory only. */
25271
26704
  syncsAnswered = /* @__PURE__ */ new Map();
26705
+ /** Catch-up answers still going out, a slice at a time (`receiveSync`). */
26706
+ answers;
25272
26707
  queue = Promise.resolve();
25273
26708
  /** When the commit I follow last changed (`adopt`), by `hooks.clock`. */
25274
26709
  chainMovedAt = 0;
@@ -25281,6 +26716,11 @@ var CommunitySession = class {
25281
26716
  this.hooks = hooks;
25282
26717
  this.state = state;
25283
26718
  this.identity = identityFromSeedB64(state.seedB64);
26719
+ this.answers = new CatchUpAnswers({
26720
+ send: (to, frame) => hooks.direct(to, frame),
26721
+ ...hooks.handled && { handled: (to) => hooks.handled(to) },
26722
+ active: () => this.state.status === "active"
26723
+ });
25284
26724
  this.rebuild();
25285
26725
  if ((state.status === "active" || state.status === "lost") && this.bestBranch().tip !== this.topHash) this.serialize(() => this.chooseBranch());
25286
26726
  }
@@ -25454,6 +26894,15 @@ var CommunitySession = class {
25454
26894
  for (const roster of this.rosters.values()) if (rosterHas(roster, key)) return true;
25455
26895
  return false;
25456
26896
  }
26897
+ /**
26898
+ * Whether `key` could read message `messageId` (`<s>:<e>:<h>:<n>`) and still can: a member of the roster it was sealed
26899
+ * for, and of the roster now. A holder serves a group file only to such a member (WISP 503 § Asking).
26900
+ */
26901
+ couldRead(key, messageId) {
26902
+ const [, e, h] = messageId.split(":");
26903
+ const found = h === void 0 ? void 0 : this.commitByShort(Number(e), h);
26904
+ return !!found && rosterHas(this.rosterAt(found.hash) ?? [], key) && rosterHas(this.roster, key);
26905
+ }
25457
26906
  /** Someone the chain took out (removed or left) and who is not back in. */
25458
26907
  wasRemoved(key) {
25459
26908
  return this.outIndex(key) >= 0;
@@ -26071,23 +27520,29 @@ var CommunitySession = class {
26071
27520
  * count against the text's 16 KiB, so the box stays within what older apps accept. `reply`: the message it
26072
27521
  * answers (`r`), counted the same way. `forwarded`: a forwarded text's hop count (`fw`, WISP 903 § Forwards). `card`:
26073
27522
  * a status card, checked by the caller (`sc`, WISP 405 · Status Cards), counted the same way; the text is its fallback.
27523
+ * `file`: a file or voice message the text announces (`fl`, WISP 503 · Group Files), counted the same way; the text is
27524
+ * its caption, or the line an older app shows.
26074
27525
  */
26075
- sendText(text, nick, now = Date.now(), mentions = [], reply, forwarded, card) {
27526
+ sendText(text, nick, now = Date.now(), mentions = [], reply, forwarded, card, file) {
26076
27527
  return this.serialize(async () => {
26077
27528
  if (!this.isMember) return { error: this.state.statusReason ?? "You are not in this group" };
26078
27529
  const trimmed = text.trim();
26079
27530
  if (!trimmed) return { error: "Nothing to send" };
27531
+ const described = file === void 0 ? void 0 : readGroupFileMeta(file);
27532
+ if (file !== void 0 && !described) return { error: "That file cannot go to a group" };
26080
27533
  const named = validMentions(wireMentions(mentions), trimmed, false);
26081
27534
  const answers = reply && readReply(wireReply(reply), groupReplyAuthor);
26082
27535
  const replyBytes = answers ? utf8Encode(JSON.stringify(answers)).length : 0;
27536
+ const fileBytes = described ? utf8Encode(JSON.stringify(described)).length + 8 : 0;
26083
27537
  const hops = readForwarded(forwarded);
26084
- if (utf8Encode(trimmed).length + mentionsBytes(named) + replyBytes + (hops ? 16 : 0) + (card ? statusCardBytes(card) + 8 : 0) > COMMUNITY_LIMITS.textBytes) return { error: "Message exceeds 16 KiB" };
27538
+ if (utf8Encode(trimmed).length + mentionsBytes(named) + replyBytes + fileBytes + (hops ? 16 : 0) + (card ? statusCardBytes(card) + 8 : 0) > COMMUNITY_LIMITS.textBytes) return { error: "Message exceeds 16 KiB" };
26085
27539
  const sent = await this.sendPayload(() => ({
26086
27540
  text: trimmed,
26087
27541
  ...named.length ? { m: named } : {},
26088
27542
  ...answers ? { r: answers } : {},
26089
27543
  ...hops ? { fw: hops } : {},
26090
- ...card ? { sc: card } : {}
27544
+ ...card ? { sc: card } : {},
27545
+ ...described ? { fl: described } : {}
26091
27546
  }), nick, now);
26092
27547
  if ("error" in sent) return sent;
26093
27548
  await this.hooks.message({
@@ -26100,7 +27555,8 @@ var CommunitySession = class {
26100
27555
  ...named.length ? { mentions: named } : {},
26101
27556
  ...answers ? { reply: answers } : {},
26102
27557
  ...hops ? { forwarded: hops } : {},
26103
- ...card ? { card } : {}
27558
+ ...card ? { card } : {},
27559
+ ...described ? { file: described } : {}
26104
27560
  });
26105
27561
  await this.say(sent.frame);
26106
27562
  return { id: sent.id };
@@ -26114,7 +27570,7 @@ var CommunitySession = class {
26114
27570
  sendApp(frame, nick, now = Date.now()) {
26115
27571
  return this.serialize(async () => {
26116
27572
  if (!this.isMember) return { error: this.state.statusReason ?? "You are not in this group" };
26117
- if (!isObject(frame) || utf8Encode(JSON.stringify(frame)).length > COMMUNITY_LIMITS.appBytes) return { error: "Too large for the group" };
27573
+ if (!isObject$2(frame) || utf8Encode(JSON.stringify(frame)).length > COMMUNITY_LIMITS.appBytes) return { error: "Too large for the group" };
26118
27574
  const sent = await this.sendPayload(() => ({ x: frame }), nick, now);
26119
27575
  if ("error" in sent) return sent;
26120
27576
  await this.say(sent.frame);
@@ -26163,7 +27619,7 @@ var CommunitySession = class {
26163
27619
  return this.serialize(async () => {
26164
27620
  if (!this.isMember) return { error: this.state.statusReason ?? "You are not in this group" };
26165
27621
  if (to === this.myKey || !rosterHas(this.roster, to)) return { error: "Not a member of this group" };
26166
- if (!isObject(payload)) return { error: "Nothing to send" };
27622
+ if (!isObject$2(payload)) return { error: "Nothing to send" };
26167
27623
  const plain = utf8Encode(JSON.stringify(payload));
26168
27624
  if (plain.length > COMMUNITY_LIMITS.appBytes) return { error: "Too large for the group" };
26169
27625
  const sent = await this.sendPayload((header) => ({ p: {
@@ -26276,7 +27732,7 @@ var CommunitySession = class {
26276
27732
  try {
26277
27733
  parsed = plain === null ? null : JSON.parse(plain);
26278
27734
  } catch {}
26279
- if (!isObject(parsed) || isObject(parsed.p)) continue;
27735
+ if (!isObject$2(parsed) || isObject$2(parsed.p)) continue;
26280
27736
  const o = readOriginal(parsed.o, f) ?? {
26281
27737
  e: f.e,
26282
27738
  h: f.h,
@@ -26424,7 +27880,7 @@ var CommunitySession = class {
26424
27880
  } catch {
26425
27881
  return false;
26426
27882
  }
26427
- if (!isObject(parsed)) return false;
27883
+ if (!isObject$2(parsed)) return false;
26428
27884
  const original = parsed.o === void 0 ? null : readOriginal(parsed.o, raw);
26429
27885
  if (parsed.o !== void 0 && !original) return false;
26430
27886
  const first = original ? {
@@ -26442,8 +27898,8 @@ var CommunitySession = class {
26442
27898
  const text = typeof parsed.text === "string" ? parsed.text.slice(0, COMMUNITY_LIMITS.textBytes) : void 0;
26443
27899
  if ([
26444
27900
  text !== void 0,
26445
- isObject(parsed.x),
26446
- isObject(parsed.p)
27901
+ isObject$2(parsed.x),
27902
+ isObject$2(parsed.p)
26447
27903
  ].filter(Boolean).length !== 1) return false;
26448
27904
  const nick = typeof parsed.nick === "string" ? sanitizeNick(parsed.nick) : void 0;
26449
27905
  if (nick && this.state.nicks[raw.s] !== nick) {
@@ -26456,6 +27912,7 @@ var CommunitySession = class {
26456
27912
  const reply = parsed.r === void 0 ? void 0 : readReply(parsed.r, groupReplyAuthor);
26457
27913
  const forwarded = readForwarded(parsed.fw);
26458
27914
  const card = parsed.sc === void 0 ? void 0 : readStatusCard(parsed.sc);
27915
+ const file = parsed.fl === void 0 ? void 0 : readGroupFileMeta(parsed.fl) ?? void 0;
26459
27916
  await this.hooks.message({
26460
27917
  id,
26461
27918
  sender: raw.s,
@@ -26466,9 +27923,10 @@ var CommunitySession = class {
26466
27923
  ...mentions.length ? { mentions } : {},
26467
27924
  ...reply ? { reply } : {},
26468
27925
  ...forwarded ? { forwarded } : {},
26469
- ...card ? { card } : {}
27926
+ ...card ? { card } : {},
27927
+ ...file ? { file } : {}
26470
27928
  });
26471
- } else if (isObject(parsed.x)) await this.hooks.app?.({
27929
+ } else if (isObject$2(parsed.x)) await this.hooks.app?.({
26472
27930
  id,
26473
27931
  sender: raw.s,
26474
27932
  epoch: raw.e,
@@ -26484,7 +27942,7 @@ var CommunitySession = class {
26484
27942
  try {
26485
27943
  opened = plain && plain.length <= COMMUNITY_LIMITS.appBytes ? JSON.parse(utf8Decode(plain)) : null;
26486
27944
  } catch {}
26487
- if (isObject(opened)) await this.hooks.pair?.({
27945
+ if (isObject$2(opened)) await this.hooks.pair?.({
26488
27946
  id,
26489
27947
  sender: raw.s,
26490
27948
  epoch: raw.e,
@@ -26558,10 +28016,15 @@ var CommunitySession = class {
26558
28016
  this.ask(to);
26559
28017
  }
26560
28018
  ask(from) {
28019
+ const sync = this.question(from);
28020
+ if (sync) this.hooks.direct(from, sync);
28021
+ }
28022
+ /** My sync for a member, unless I asked it a moment ago. */
28023
+ question(from) {
26561
28024
  const now = this.hooks.clock?.() ?? Date.now();
26562
- if (now - (this.asked.get(from) ?? 0) < 5e3) return;
28025
+ if (now - (this.asked.get(from) ?? 0) < 5e3) return null;
26563
28026
  this.asked.set(from, now);
26564
- this.hooks.direct(from, this.syncFrame());
28027
+ return this.syncFrame();
26565
28028
  }
26566
28029
  async replayWaiting() {
26567
28030
  await this.metaFollowsChain();
@@ -26579,6 +28042,11 @@ var CommunitySession = class {
26579
28042
  let answered = this.syncsAnswered.get(from);
26580
28043
  if (!answered) this.syncsAnswered.set(from, answered = new RateWindow(COMMUNITY_LIMITS.syncAnswers, COMMUNITY_LIMITS.syncWindowMs, () => this.hooks.clock?.() ?? Date.now()));
26581
28044
  if (!answered.take()) return;
28045
+ const answer = [];
28046
+ const ask = () => {
28047
+ const sync = this.question(from);
28048
+ if (sync) answer.push(sync);
28049
+ };
26582
28050
  const theirs = this.known.get(frame.h);
26583
28051
  let start;
26584
28052
  if (theirs && this.mainIndex.has(frame.h)) start = this.mainIndex.get(frame.h) + 1;
@@ -26588,18 +28056,18 @@ var CommunitySession = class {
26588
28056
  } else {
26589
28057
  const common = (Array.isArray(frame.loc) ? frame.loc : []).find((x) => typeof x === "string" && this.mainIndex.has(x));
26590
28058
  start = common !== void 0 ? this.mainIndex.get(common) + 1 : Math.max(0, this.state.chain.length - COMMUNITY_LIMITS.side);
26591
- this.ask(from);
28059
+ ask();
26592
28060
  }
26593
28061
  for (let i = start; i < this.state.chain.length; i++) {
26594
28062
  const commit = this.state.chain[i], h = communityCommitHash(commit);
26595
- this.hooks.direct(from, {
28063
+ answer.push({
26596
28064
  t: "group-commit",
26597
28065
  v: 2,
26598
28066
  g: this.id,
26599
28067
  commit
26600
28068
  });
26601
28069
  const secret = this.state.secrets[h];
26602
- if (secret && rosterHas(this.rosterAt(h) ?? [], from) && FRESH.has(commit.k)) this.hooks.direct(from, {
28070
+ if (secret && rosterHas(this.rosterAt(h) ?? [], from) && FRESH.has(commit.k)) answer.push({
26603
28071
  t: "group-secret",
26604
28072
  v: 2,
26605
28073
  g: this.id,
@@ -26613,7 +28081,7 @@ var CommunitySession = class {
26613
28081
  h,
26614
28082
  s: sealSecret(from, fromBase64Url(this.state.secrets[h]), secretAad(this.id, h, from))
26615
28083
  }));
26616
- if (secrets.length) this.hooks.direct(from, {
28084
+ if (secrets.length) answer.push({
26617
28085
  t: "group-secrets",
26618
28086
  v: 2,
26619
28087
  g: this.id,
@@ -26630,8 +28098,8 @@ var CommunitySession = class {
26630
28098
  if (!reads && !(mine && rosterHas(this.roster, from))) continue;
26631
28099
  const high = have[stored.s]?.[seenKey(stored.e, stored.h)];
26632
28100
  if (Number.isSafeInteger(high) && high >= stored.n) continue;
26633
- if (!this.mainIndex.has(found.hash)) this.handSide(from, found.hash, handed);
26634
- this.hooks.direct(from, stored);
28101
+ if (!this.mainIndex.has(found.hash)) this.handSide(from, found.hash, handed, answer);
28102
+ answer.push(stored);
26635
28103
  if (mine && reads) unheard.delete(frameKey(stored));
26636
28104
  }
26637
28105
  if (unheard.size !== (this.state.unheard?.length ?? 0)) {
@@ -26640,30 +28108,32 @@ var CommunitySession = class {
26640
28108
  }
26641
28109
  if (rosterHas(this.roster, from)) {
26642
28110
  const entry = this.entryFrame(from);
26643
- if (entry) this.hooks.direct(from, entry);
28111
+ if (entry) answer.push(entry);
26644
28112
  }
26645
- for (const request of this.state.pendingLeaves) this.hooks.direct(from, {
28113
+ for (const request of this.state.pendingLeaves) answer.push({
26646
28114
  t: "group-leave",
26647
28115
  v: 2,
26648
28116
  g: this.id,
26649
28117
  ...request
26650
28118
  });
26651
- this.offerMeta(from, frame.mt);
26652
- this.ask(from);
28119
+ const meta = this.metaFor(from, frame.mt);
28120
+ if (meta) answer.push(meta);
28121
+ ask();
28122
+ this.answers.handOut(from, answer);
26653
28123
  }
26654
- /** The commits of a branch I do not follow, from where it leaves mine, and the secrets I hold of those `to` was in. */
26655
- handSide(to, tip, handed) {
28124
+ /** The commits of a branch I do not follow, from where it leaves mine, and the secrets I hold of those `to` was in, onto `answer`. */
28125
+ handSide(to, tip, handed, answer) {
26656
28126
  for (const h of this.pathToMain(tip) ?? []) {
26657
28127
  if (handed.has(h)) continue;
26658
28128
  handed.add(h);
26659
- this.hooks.direct(to, {
28129
+ answer.push({
26660
28130
  t: "group-commit",
26661
28131
  v: 2,
26662
28132
  g: this.id,
26663
28133
  commit: this.known.get(h)
26664
28134
  });
26665
28135
  const secret = this.state.secrets[h];
26666
- if (secret && rosterHas(this.rosterAt(h) ?? [], to)) this.hooks.direct(to, {
28136
+ if (secret && rosterHas(this.rosterAt(h) ?? [], to)) answer.push({
26667
28137
  t: "group-secret",
26668
28138
  v: 2,
26669
28139
  g: this.id,
@@ -26762,12 +28232,11 @@ var CommunitySession = class {
26762
28232
  const meta = this.state.meta, secret = this.state.secrets[this.topHash];
26763
28233
  return meta && secret ? wrapGroupMeta(meta, this.topHash, epochKeys(fromBase64Url(secret), this.id, this.epoch).message, true) : null;
26764
28234
  }
26765
- /** My statement, to a member whose sync says it holds an older one (or none); nothing to an app that says nothing. */
26766
- offerMeta(to, theirTag) {
28235
+ /** My statement, for a member whose sync says it holds an older one (or none); none for an app that says nothing. */
28236
+ metaFor(to, theirTag) {
26767
28237
  const meta = this.state.meta, theirs = parseGroupMetaTag(theirTag);
26768
- if (!meta || theirs === null || !groupMetaNewer(meta, theirs) || !this.isMember || !rosterHas(this.roster, to)) return;
26769
- const frame = this.metaFrame();
26770
- if (frame) this.hooks.direct(to, frame);
28238
+ if (!meta || theirs === null || !groupMetaNewer(meta, theirs) || !this.isMember || !rosterHas(this.roster, to)) return null;
28239
+ return this.metaFrame();
26771
28240
  }
26772
28241
  /**
26773
28242
  * A metadata statement (from its author's edge or a hub's): kept when it is newer than mine, its
@@ -26939,6 +28408,672 @@ var GroupTypingBoard = class {
26939
28408
  }
26940
28409
  };
26941
28410
  //#endregion
26942
- export { checkAtprotoProofRecord as $, HANDOFF_VERIFIED as $a, replySnippet as $i, freshHubs as $n, OwnInviteError as $o, IdentityCheckUnavailable as $r, fromBase64Url as $s, isPubkyKey as $t, GROUP_EDIT_TEXT_BYTES as A, CALL_SIGNAL_MAX_AGE_MS as Aa, parseVoiceMeta as Ai, findEndpoint as An, SET_UPDATE as Ao, newWakeToken as Ar, DEFAULT_RELAYS as As, encodeDidDhtPacket as At, sameSshKey as B, HTTP_SERVICE_PROTO as Ba, COMMUNITY_REACTION_FRAME as Bi, SIGNAL_FRESH_MS as Bn, enrollCancelFrame as Bo, HOLD_DATED_BACK_MS as Br, RELAY_PAYLOAD_MAX_BYTES as Bs, USDT_PUBLIC_RPC as Bt, groupWakes as C, GhostlyHttpError as Ca, safeBlobType as Ci, resolveLightningDestination as Cn, readHandoffHave as Co, nextPinNumber as Cr, firstDeviceSetSecret as Cs, parseDidSignaturePaste as Ct, GroupSession as D, LIMITS as Da, VOICE_LIMITS as Di, ENDPOINT as Dn, readHandoffVerified as Do, WAKE_CALL_INTERVAL_MS as Dr, signTurnRelease as Ds, didDhtDocument as Dt, GROUP_READ_NOTE as E, webLocalFetch as Ea, PLAYABLE_AUDIO as Ei, decodeBolt11 as En, readHandoffTurnFrame as Eo, readPin as Er, signTurnPacket as Es, strictBase64Url as Et, SSHSIG_MAX_ARMOR as F, callSignalHeardAt as Fa, RateWindow as Fi, INVITE_TAKEN as Fn, verifySetUpdate as Fo, vapidKeysMatch as Fr, traceLink as Fs, ONCHAIN_PROVIDER as Ft, decodeAnyBitcoinAddress as G, HANDOFF_DONE as Ga, queueReaction as Gi, MESH_HUBS as Gn, enrollHelloFrame as Go, readManifest as Gr, createIdentity as Gs, validatePaymentTarget as Gt, verifySshSignature as H, serviceIdFromName as Ha, REACTION_LIMITS as Hi, SIGNAL_REPLY_MS as Hn, enrollDigits as Ho, HoldKeys as Hr, newerPacket as Hs, assertTokenUnits as Ht, SSHSIG_NAMESPACE as I, extractParamsFromSdp as Ia, dhtEditId as Ii, LIVENESS_PING_MS as In, ENROLL_DONE as Io, BUTTONS_CAPABILITY as Ir, budgetRetryMs as Is, PaymentPreflightError as It, ATPROTO_PROOF_COLLECTION as J, HANDOFF_OFFER as Ja, readReaction as Ji, pickMeshHubs as Jn, readEnrollHello as Jo, capsDescriptors as Jr, publicKeyFromZ32 as Js, SPARK_PROVIDER as Jt, ATPROTO_DECLARED_SCOPE as K, HANDOFF_HAVE as Ka, reactionEmoji as Ki, mayBeHub as Kn, enrollProofFrame as Ko, CapsExchange as Kr, identityFromSeed as Ks, walletNetworkOf as Kt, SshSigError as L, parseCallSignal as La, parseLinkPreview as Li, pairedMessageFrame as Ln, ENROLL_GRANT as Lo, EDIT_CAPABILITY as Lr, isDiscoveryBudgetError as Ls, SEPOLIA_TEST_USDT as Lt, parseCommunityEdit as M, answersOffer as Ma, parseVideoMeta as Mi, parseCashuRequestPayload as Mn, setAckFrame as Mo, relayRequest as Mr, currentRelays as Ms, signDidDhtPacket as Mt, mentionsMember as N, buildSdpFromSignal as Na, EDIT_BUFFER_MS as Ni, parseFedimintRequestPayload as Nn, setUpdateFrame as No, wakeRequest as Nr, normalizeRelayUrl as Ns, ETHEREUM_USDT as Nt, COMMUNITY_EDIT_FRAME as O, decodeControl as Oa, baseMime as Oi, cashuRequestPayload as On, sealHandoffPiece as Oo, WakeLimiter as Or, turnKeys as Os, didDhtFromPublicKey as Ot, groupName as P, callRtcConfig as Pa, EDIT_RATE_WINDOW_MS as Pi, GhostLink as Pn, signTombstone as Po, checkPushEndpoint as Pr, setLinkTraceSink as Ps, EVM_TEST_CHAINS as Pt, atprotoProofRkey as Q, HANDOFF_TIMINGS as Qa, readReply as Qi, doorHubs as Qn, readDeviceInvite as Qo, IDENTITY_REVOCATION_LABEL as Qr, bytesEqual as Qs, PUBKY_PROOF_MAX_BYTES as Qt, parseSshPublicKey as R, signalHasVideo as Ra, forwardedAgain as Ri, CarriedTransport as Rn, ENROLL_HELLO as Ro, UPGRADE_CAPABILITY as Rr, withRequestOptions as Rs, SEPOLIA_TEST_USDT_FAUCET as Rt, groupWakeFrame as S, presenceSeenAt as Sa, readImageMeta as Si, requestLnurlInvoice as Sn, readHandoffDone as So, mayPin as Sr, TURN_MAX as Ss, parseDidDocument as St, GROUP_REACTED_FRAME as T, parseLocalTarget as Ta, sanitizeMime as Ti, engineText as Tn, readHandoffPake as To, pinNumberHolds as Tr, readTurnPacket as Ts, staticDidDocument as Tt, verifyBitcoinMessage as U, HANDOFF_ACK as Ua, ReactionWindow as Ui, groupSignalFrame as Un, enrollDoneFrame as Uo, HoldRefusedError as Ur, openRelayPayload as Us, assertWholeSats as Ut, sshSignCommand as V, LEGACY_SERVICES as Va, GROUP_REACTION_FRAME as Vi, SIGNAL_HOLD_MS as Vn, enrollCancelReason as Vo, HOLD_LIMITS as Vr, createRelayPayload as Vs, WALLET_NETWORKS as Vt, BitcoinAddressError as W, HANDOFF_CANCEL as Wa, nextReactionNumber as Wi, readGroupSignal as Wn, enrollGrantFrame as Wo, newMailbox as Wr, parseRelayPayload as Ws, formatPaymentAmount as Wt, atprotoDidMethod as X, HANDOFF_PIECE_BYTES as Xa, wireReaction as Xi, beaconKeys as Xn, createDeviceInvite as Xo, IDENTITY_MAX_EVIDENCE as Xr, MAX_AVATAR_LENGTH as Xs, sparkInvoiceDetails as Xt, ATPROTO_PROOF_SCOPE as Y, HANDOFF_PAKE as Ya, validReactionNumber as Yi, COMMUNITY_TOPOLOGY as Yn, verifyEnrollProof as Yo, dialDescriptors as Yr, verify as Ys, sparkAddressKind as Yt, atprotoProofRecord as Z, HANDOFF_REQUEST as Za, pairedReplyAuthor as Zi, beaconRecords as Zn, deviceInviteJoinerParams as Zo, IDENTITY_MAX_VALIDITY as Zr, sanitizeAvatar as Zs, isBitcoinAddress as Zt, knockRecords as _, verifyCommitSignature as _a, DHT_MESSAGE_TTL as _i, recordsFromTxt as _n, handoffStreamKey as _o, TypingSender as _r, TURN_RAISE_READ_TIMEOUT_MS as _s, didWebFileNames as _t, sealGroupTyping as a, checkStatusCard as aa, newIdentityBinding as ai, pubkyProofPath as an, handoffCancelFrame as ao, pickHubs as ar, DEVICES_CAPABILITY as as, verifyAtprotoRecordProof as at, GROUP_WAKE_FRAME as b, EXPECT_PEER_MS as ba, dhtFileId as bi, txtValue as bn, readHandoffAck as bo, GROUP_PIN_FRAME as br, turnPutSummary as bs, normalizeDid as bt, GROUP_READ_NOTE_COMMUNITY as c, arrivalKey as ca, lastSharedWithMe as ci, DOMAIN_FILE_MAX_BYTES as cn, handoffDataFrame as co, readBeaconHead as cr, deviceEchoFrame as cs, DID_JWS_MAX_LENGTH as ct, encodeCommunityLink as d, receivedTimestamp as da, validExternalKey as di, domainTxtRecord as dn, handoffHelloFrame as do, ChatFiles as dr, deviceLinkPairing as ds, checkDidSignature as dt, BUTTON_ID as ea, randomBytes as ec, IdentityExchange as ei, isPubkyProofFolder as en, filePartName as eo, lobbyKeys as er, createChatInvite as es, isAtprotoDid as et, KNOCK_TTL_MS as f, shownTime as fa, TRANSPORTS as fi, domainWellKnownFile as fn, handoffManifestFrames as fo, FILE_LIMITS as fr, devicePingFrame as fs, didGhostlyServiceEntry as ft, knockIdentity as g, rosterHas as ga, DHT_FILE_TOO_LARGE as gi, normalizeDomain as gn, handoffRetryAfter as go, TYPING_REFRESH_MS as gr, TURN_PUT_WINDOW_MS as gs, didWebFile as gt, entryParams as h, MEMBER_KEY as ha, irohRelayUrl as hi, nip05RootKey as hn, handoffReleaseFrame as ho, TYPING_KINDS as hr, webCryptoSigner as hs, didWebDocumentUrl as ht, openGroupTyping as i, buttonLabelClash as ia, utf8Encode as ic, identityStatement as ii, pubkyProofFile as in, handoffBusyFrame as io, newerHead as ir, inviteOwnership as is, AtprotoProofError as it, carryMentions as j, RTC_CONFIG as ja, voicePeaksOf as ji, isFederationId as jn, readSetUpdate as jo, parseWakeFrame as jr, RelayTransport as js, resolveDidDht as jt, GROUP_EDIT_FRAME as k, encodeControl as ka, fileMessageText as ki, fedimintRequestPayload as kn, SET_ACK as ko, deviceWakeRequest as kr, turnName as ks, encodeDidDhtDeactivated as kt, communityHasFrame as l, claimedTime as la, PeerProofs as li, decodeDnsResponse as ln, handoffDigest as lo, readLobby as lr, deviceEchoNonce as ls, DidError as lt, encodeGroupEntryLink as m, MAX_GROUP_CHAIN as ma, irohDescriptor as mi, isPublicAddress as mn, handoffPakeFrame as mo, transferStalled as mr, seedSigner as ms, didSignedMessages as mt, GroupTypingBoard as n, RUN_RESULTS as na, toZ32 as nc, emptyIdentityLedger as ni, normalizePubkyKey as nn, handoffAttemptFailed as no, mergeBeacon as nr, encodeInviteCode as ns, parseAtprotoDidDocument as nt, COMMUNITY_LIMITS as o, sameButtonText as oa, receivedIdentityStatus as oi, homeserverWebEndpoint as on, handoffContext as oo, rankHubs as or, ENROLL_CAPABILITY as os, DID_DOCUMENT_MAX_BYTES as ot, decodeGroupEntryLink as p, GROUP_MEMBER_CAP as pa, automaticTransport as pi, encodeDnsQuery as pn, handoffOfferFrame as po, transferEnded as pr, newDeviceSetSecret as ps, didJwsAlg as pt, ATPROTO_FULL_SCOPE as q, HANDOFF_HELLO as qa, reactionIsNewer as qi, meshHubs as qn, readEnrollGrant as qo, DHT_TEXT_CAPABILITY as qr, identityFromSeedB64 as qs, SPARK_NETWORKS as qt, groupTypingEpoch as r, STATUS_CARD_LIMITS as ra, utf8Decode as rc, identityRevocationValue as ri, pubkyProofCapability as rn, handoffAttemptSucceeded as ro, mergeLobby as rr, inviteLink as rs, shortAtprotoDid as rt, CommunitySession as s, statusCardText as sa, revokesIdentity as si, pubkyHomeserverOf as sn, handoffContextProof as so, readBeacon as sr, HANDOFF_CAPABILITY as ss, DID_FILE_MAX_BYTES as st, GROUP_TYPING_FRAME as t, ITEM_STATES as ta, toBase64Url as tc, boundVerifiedIdentity as ti, newPubkyProofFolder as tn, handoffAttemptAllowed as to, lobbyRecords as tr, decodeInviteCode as ts, normalizeAtprotoHandle as tt, decodeCommunityLink as u, heardTime as ua, emptyProofLedger as ui, dnsProofName as un, handoffEphemeral as uo, shouldBeHub as ur, deviceKeyZ32 as us, checkDidJws as ut, mergeKnocks as v, edgeParams as va, peekDhtMailbox as vi, recordsFromWellKnown as vn, handoffVersions as vo, sanitizeTypingStatus as vr, TURN_SETTLE_MS as vs, didWebFileUrl as vt, parseGroupWakeFrame as w, formatLocalTarget as wa, sanitizeFileName as wi, engineError as wn, readHandoffHello as wo, pinIsNewer as wr, nextTurnPosition as ws, signingKeys as wt, GroupWakeLimiter as x, RELAY_POLL_INTERVALS as xa, parseImageMeta as xi, wellKnownUrl as xn, readHandoffCancel as xo, PIN_LIMITS as xr, TURN_LAST_SEQUENCE as xs, parseDid as xt, readKnocks as y, DirectPathWatch as ya, DHT_FILE_CAPABILITY as yi, recordsName as yn, newHandoffId as yo, typingActivity as yr, classifyTurnRead as ys, didWebHost as yt, parseSshSignature as z, emptyLinkRecords as za, readForwarded as zi, GROUP_SIGNAL_FRAME as zn, ENROLL_PROOF as zo, HOLD_CAPABILITY as zr, PacketTooLargeError as zs, TEST_USDT_FAUCET_AMOUNT as zt };
28411
+ //#region ../core/src/appBundle.ts
28412
+ var APP_BUNDLE_MAGIC = "GHOSTLYAPP1";
28413
+ var MAGIC = utf8Encode(APP_BUNDLE_MAGIC);
28414
+ var APP_BUNDLE_LIMITS = {
28415
+ /** The whole bundle, phase 1. */
28416
+ bundleBytes: 16777216,
28417
+ files: 64,
28418
+ manifestBytes: 65536,
28419
+ /** A path, in bytes (paths are ASCII). */
28420
+ pathBytes: 128,
28421
+ iconBytes: 262144,
28422
+ screenshots: 8,
28423
+ /** Lengths in Unicode code points. */
28424
+ title: 40,
28425
+ tagline: 80,
28426
+ description: 2e3,
28427
+ releaseNotes: 500,
28428
+ version: 64,
28429
+ license: 128,
28430
+ /** An https URL, in characters. */
28431
+ url: 512,
28432
+ sources: 8
28433
+ };
28434
+ var APP_ICON_PATH = "icon.png";
28435
+ var SCREENSHOT_TYPES = /\.(png|jpg|webp)$/;
28436
+ /**
28437
+ * The permissions of phase 1 (WISP 1200 · Permissions). `internet`: the app reaches HTTPS and WSS servers of its
28438
+ * choosing (the client runs it in its network runner); without it, an app has no network.
28439
+ */
28440
+ var APP_PERMISSIONS = [
28441
+ "chat",
28442
+ "internet",
28443
+ "name"
28444
+ ];
28445
+ var APP_CLIENTS = [
28446
+ "web",
28447
+ "desktop",
28448
+ "extension"
28449
+ ];
28450
+ /**
28451
+ * Where an app shows (WISP 1200 · Manifest, `view`). `chat`, the default when the manifest has none: inside a 1:1 chat
28452
+ * only, beside it on a wide screen. `full`: full screen, alone or from a chat, never beside one.
28453
+ */
28454
+ var APP_VIEWS = ["chat", "full"];
28455
+ /** The view of a manifest: `chat` when it names none. */
28456
+ var appViewOf = (manifest) => manifest.view ?? "chat";
28457
+ var REQUIRED = [
28458
+ "ghostlyApp",
28459
+ "publisher",
28460
+ "name",
28461
+ "version",
28462
+ "sequence",
28463
+ "kind",
28464
+ "title",
28465
+ "tagline",
28466
+ "entry",
28467
+ "permissions",
28468
+ "runtime",
28469
+ "license",
28470
+ "files"
28471
+ ];
28472
+ var OPTIONAL = [
28473
+ "description",
28474
+ "view",
28475
+ "sources",
28476
+ "proofs",
28477
+ "homepage",
28478
+ "support",
28479
+ "releaseNotes"
28480
+ ];
28481
+ /** Reserved for phase 2 and refused in phase 1. */
28482
+ var RESERVED = ["price", "recovery"];
28483
+ var SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
28484
+ var HOST = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)(\.(0|[1-9]\d*))?$/;
28485
+ var SEGMENT = /^[A-Za-z0-9._-]+$/;
28486
+ var codePoints = (s) => [...s].length;
28487
+ /** One line: no control character. */
28488
+ var isLine$1 = (v, max, min = 0) => typeof v === "string" && codePoints(v) >= min && codePoints(v) <= max && !/\p{Cc}/u.test(v);
28489
+ /** Text of several lines: no control character but the line feed. */
28490
+ var isText$1 = (v, max) => typeof v === "string" && codePoints(v) <= max && !/[^\P{Cc}\n]/u.test(v);
28491
+ var isSafeCount = (v, min) => typeof v === "number" && Number.isSafeInteger(v) && v >= min;
28492
+ var isObject$1 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
28493
+ /**
28494
+ * An https URL as WISP 1200 takes one anywhere: at most 512 characters, no user or password, and on jsDelivr only
28495
+ * `https://cdn.jsdelivr.net/gh/<owner>/<repo>@<40-hex commit>/...`, since a branch, a tag, a range or `latest` is
28496
+ * served from a copy that moves.
28497
+ */
28498
+ function isAppUrl(value) {
28499
+ if (typeof value !== "string" || value.length > APP_BUNDLE_LIMITS.url || !value.startsWith("https://")) return false;
28500
+ let url;
28501
+ try {
28502
+ url = new URL(value);
28503
+ } catch {
28504
+ return false;
28505
+ }
28506
+ if (url.protocol !== "https:" || url.username || url.password || !url.hostname) return false;
28507
+ const host = url.hostname.toLowerCase();
28508
+ if (host === "jsdelivr.net" || host.endsWith(".jsdelivr.net")) return host === "cdn.jsdelivr.net" && /^\/gh\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@[0-9a-f]{40}\/./.test(url.pathname);
28509
+ return true;
28510
+ }
28511
+ /**
28512
+ * An SPDX license expression, read lightly: identifiers (`MIT`, `GPL-3.0-or-later`, `LicenseRef-x`, `Apache-2.0+`)
28513
+ * joined by `AND` and `OR`, `WITH` an exception, and parentheses; or `proprietary`.
28514
+ */
28515
+ function isAppLicense(value) {
28516
+ if (typeof value !== "string" || value.length < 1 || value.length > APP_BUNDLE_LIMITS.license) return false;
28517
+ const tokens = value.match(/\(|\)|[^\s()]+/g);
28518
+ if (!tokens || tokens.join("").length !== value.replace(/\s/g, "").length) return false;
28519
+ let i = 0;
28520
+ const id = () => {
28521
+ const t = tokens[i];
28522
+ if (t && /^[A-Za-z0-9][A-Za-z0-9.-]*\+?$/.test(t) && ![
28523
+ "AND",
28524
+ "OR",
28525
+ "WITH"
28526
+ ].includes(t)) {
28527
+ i++;
28528
+ return true;
28529
+ }
28530
+ return false;
28531
+ };
28532
+ const term = (depth) => {
28533
+ if (tokens[i] === "(") {
28534
+ i++;
28535
+ if (depth > 8 || !expr(depth + 1) || tokens[i] !== ")") return false;
28536
+ i++;
28537
+ return true;
28538
+ }
28539
+ if (!id()) return false;
28540
+ if (tokens[i] === "WITH") {
28541
+ i++;
28542
+ return id();
28543
+ }
28544
+ return true;
28545
+ };
28546
+ const expr = (depth) => {
28547
+ if (!term(depth)) return false;
28548
+ while (tokens[i] === "AND" || tokens[i] === "OR") {
28549
+ i++;
28550
+ if (!term(depth)) return false;
28551
+ }
28552
+ return true;
28553
+ };
28554
+ return expr(0) && i === tokens.length;
28555
+ }
28556
+ /** A path of a bundle: relative, `/`-separated, `[A-Za-z0-9._-]` per segment, no `.` or `..`, at most 128 bytes. */
28557
+ function isAppPath(value) {
28558
+ if (typeof value !== "string" || value.length < 1 || value.length > APP_BUNDLE_LIMITS.pathBytes) return false;
28559
+ return value.split("/").every((s) => SEGMENT.test(s) && s !== "." && s !== "..");
28560
+ }
28561
+ var bad = (field, detail = `${field} is not valid`) => ({
28562
+ ok: false,
28563
+ reason: "bad-field",
28564
+ detail
28565
+ });
28566
+ /** Checks a parsed manifest against every rule and bound of WISP 1200 that the manifest alone decides. */
28567
+ function checkAppManifest(value) {
28568
+ if (!isObject$1(value)) return bad("manifest", "The manifest is not an object");
28569
+ if (value.ghostlyApp !== void 0 && value.ghostlyApp !== 1) return {
28570
+ ok: false,
28571
+ reason: "unsupported-format",
28572
+ detail: "ghostlyApp is not 1"
28573
+ };
28574
+ for (const key of Object.keys(value)) {
28575
+ if (RESERVED.includes(key)) return {
28576
+ ok: false,
28577
+ reason: "reserved-key",
28578
+ detail: key
28579
+ };
28580
+ if (!REQUIRED.includes(key) && !OPTIONAL.includes(key)) return {
28581
+ ok: false,
28582
+ reason: "unknown-key",
28583
+ detail: key
28584
+ };
28585
+ }
28586
+ for (const key of REQUIRED) if (!(key in value)) return {
28587
+ ok: false,
28588
+ reason: "missing-key",
28589
+ detail: key
28590
+ };
28591
+ const m = value;
28592
+ if (!isAppKey(m.publisher)) return bad("publisher");
28593
+ if (typeof m.name !== "string" || !APP_NAME.test(m.name)) return bad("name");
28594
+ if (typeof m.version !== "string" || m.version.length > APP_BUNDLE_LIMITS.version || !SEMVER.test(m.version)) return bad("version");
28595
+ if (!isSafeCount(m.sequence, 1)) return bad("sequence");
28596
+ if (m.kind !== "mini-app") return bad("kind");
28597
+ if (!isLine$1(m.title, APP_BUNDLE_LIMITS.title, 1)) return bad("title");
28598
+ if (!isLine$1(m.tagline, APP_BUNDLE_LIMITS.tagline, 1)) return bad("tagline");
28599
+ if (m.description !== void 0 && !isText$1(m.description, APP_BUNDLE_LIMITS.description)) return bad("description");
28600
+ if (m.releaseNotes !== void 0 && !isLine$1(m.releaseNotes, APP_BUNDLE_LIMITS.releaseNotes)) return bad("releaseNotes");
28601
+ const permissions = m.permissions;
28602
+ if (!Array.isArray(permissions) || new Set(permissions).size !== permissions.length || !permissions.every((p) => APP_PERMISSIONS.includes(p))) return bad("permissions");
28603
+ if (m.view !== void 0 && !APP_VIEWS.includes(m.view)) return bad("view", `view is "chat" or "full"`);
28604
+ const runtime = m.runtime;
28605
+ if (!isObject$1(runtime) || Object.keys(runtime).length !== 2 || typeof runtime.host !== "string" || !HOST.test(runtime.host)) return bad("runtime");
28606
+ const clients = runtime.clients;
28607
+ if (!Array.isArray(clients) || clients.length < 1 || new Set(clients).size !== clients.length || !clients.every((c) => APP_CLIENTS.includes(c))) return bad("runtime");
28608
+ if (!isAppLicense(m.license)) return bad("license");
28609
+ if (m.sources !== void 0 && (!Array.isArray(m.sources) || m.sources.length > APP_BUNDLE_LIMITS.sources || !m.sources.every(isAppUrl))) return bad("sources");
28610
+ if (m.homepage !== void 0 && !isAppUrl(m.homepage)) return bad("homepage");
28611
+ if (m.support !== void 0 && !isAppUrl(m.support)) return bad("support");
28612
+ if (m.proofs !== void 0) {
28613
+ if (!Array.isArray(m.proofs)) return bad("proofs");
28614
+ if (m.proofs.length > 0) return {
28615
+ ok: false,
28616
+ reason: "proofs-unsupported",
28617
+ detail: "Publisher proofs come after phase 1"
28618
+ };
28619
+ }
28620
+ const files = m.files;
28621
+ if (!Array.isArray(files)) return bad("files");
28622
+ if (files.length > APP_BUNDLE_LIMITS.files) return {
28623
+ ok: false,
28624
+ reason: "too-many-files",
28625
+ detail: `${files.length} files`
28626
+ };
28627
+ const lower = /* @__PURE__ */ new Set();
28628
+ let previous = "";
28629
+ let screenshots = 0;
28630
+ for (const f of files) {
28631
+ if (!isObject$1(f) || Object.keys(f).length !== 3 || !isAppPath(f.path) || !isSafeCount(f.size, 0) || !isAppHash(f.sha256)) return isObject$1(f) && typeof f.path === "string" && !isAppPath(f.path) ? {
28632
+ ok: false,
28633
+ reason: "bad-path",
28634
+ detail: f.path
28635
+ } : bad("files", "A file is not {path, size, sha256}");
28636
+ if (lower.has(f.path.toLowerCase())) return {
28637
+ ok: false,
28638
+ reason: "path-collision",
28639
+ detail: f.path
28640
+ };
28641
+ lower.add(f.path.toLowerCase());
28642
+ if (f.path <= previous) return {
28643
+ ok: false,
28644
+ reason: "files-unsorted",
28645
+ detail: f.path
28646
+ };
28647
+ previous = f.path;
28648
+ if (f.size > APP_BUNDLE_LIMITS.bundleBytes) return bad("files", `${f.path} is larger than a bundle`);
28649
+ if (f.path.startsWith("screenshots/")) {
28650
+ if (!SCREENSHOT_TYPES.test(f.path)) return {
28651
+ ok: false,
28652
+ reason: "bad-path",
28653
+ detail: `${f.path}: a screenshot is .png, .jpg or .webp`
28654
+ };
28655
+ if (++screenshots > APP_BUNDLE_LIMITS.screenshots) return {
28656
+ ok: false,
28657
+ reason: "too-many-screenshots"
28658
+ };
28659
+ }
28660
+ if (f.path === "icon.png" && f.size > APP_BUNDLE_LIMITS.iconBytes) return {
28661
+ ok: false,
28662
+ reason: "bad-icon",
28663
+ detail: "icon.png is larger than 256 KiB"
28664
+ };
28665
+ }
28666
+ const entry = m.entry;
28667
+ if (typeof entry !== "string" || !entry.endsWith(".html") || !files.some((f) => isObject$1(f) && f.path === entry)) return {
28668
+ ok: false,
28669
+ reason: "bad-entry",
28670
+ detail: "The entry is not an .html file of the bundle"
28671
+ };
28672
+ return {
28673
+ ok: true,
28674
+ manifest: m
28675
+ };
28676
+ }
28677
+ /** The digest of a version: SHA-256 of its canonical manifest bytes, base64url. */
28678
+ function appDigest(manifestBytes) {
28679
+ return toBase64Url(sha256(manifestBytes));
28680
+ }
28681
+ var u32 = (b, at) => (b[at] << 24 >>> 0) + (b[at + 1] << 16) + (b[at + 2] << 8) + b[at + 3];
28682
+ /** Reads and checks a bundle (the order of the checks is part of the format: see above). */
28683
+ function readAppBundle(bytes) {
28684
+ if (bytes.length > APP_BUNDLE_LIMITS.bundleBytes) return {
28685
+ ok: false,
28686
+ reason: "too-large"
28687
+ };
28688
+ const head = bytes.subarray(0, MAGIC.length);
28689
+ if (!head.every((b, i) => b === MAGIC[i])) return {
28690
+ ok: false,
28691
+ reason: "magic"
28692
+ };
28693
+ if (head.length < MAGIC.length) return {
28694
+ ok: false,
28695
+ reason: "truncated"
28696
+ };
28697
+ let at = MAGIC.length;
28698
+ if (bytes.length < at + 4) return {
28699
+ ok: false,
28700
+ reason: "truncated"
28701
+ };
28702
+ const manifestLength = u32(bytes, at);
28703
+ at += 4;
28704
+ if (manifestLength > APP_BUNDLE_LIMITS.manifestBytes) return {
28705
+ ok: false,
28706
+ reason: "manifest-too-large"
28707
+ };
28708
+ if (bytes.length < at + manifestLength + 4) return {
28709
+ ok: false,
28710
+ reason: "truncated"
28711
+ };
28712
+ const manifestBytes = bytes.subarray(at, at + manifestLength);
28713
+ at += manifestLength;
28714
+ const statementLength = u32(bytes, at);
28715
+ at += 4;
28716
+ if (bytes.length < at + statementLength) return {
28717
+ ok: false,
28718
+ reason: "truncated"
28719
+ };
28720
+ const statementBytes = bytes.subarray(at, at + statementLength);
28721
+ at += statementLength;
28722
+ const read = readCanonicalJson(manifestBytes);
28723
+ if (!read.ok) return read;
28724
+ const checked = checkAppManifest(read.value);
28725
+ if (!checked.ok) return checked;
28726
+ const manifest = checked.manifest;
28727
+ const statement = readAppSignature(statementBytes);
28728
+ if (!statement.ok) return statement;
28729
+ const total = manifest.files.reduce((sum, f) => sum + f.size, 0);
28730
+ const rest = bytes.length - at;
28731
+ if (total > rest) return {
28732
+ ok: false,
28733
+ reason: "truncated"
28734
+ };
28735
+ if (total < rest) return {
28736
+ ok: false,
28737
+ reason: "trailing-bytes",
28738
+ detail: `${rest - total} bytes past the last file`
28739
+ };
28740
+ const signed = verifyAppSignature(APP_PREFIXES.app, manifestBytes, statement.signature, manifest.publisher);
28741
+ if (!signed.ok) return signed;
28742
+ const files = /* @__PURE__ */ new Map();
28743
+ for (const f of manifest.files) {
28744
+ const content = bytes.subarray(at, at + f.size);
28745
+ at += f.size;
28746
+ if (toBase64Url(sha256(content)) !== f.sha256) return {
28747
+ ok: false,
28748
+ reason: "file-hash",
28749
+ detail: f.path
28750
+ };
28751
+ files.set(f.path, content);
28752
+ }
28753
+ const icon = files.get(APP_ICON_PATH);
28754
+ if (icon) {
28755
+ const size = imageSize(icon);
28756
+ if (!size || icon[0] !== 137 || size.width !== size.height) return {
28757
+ ok: false,
28758
+ reason: "bad-icon",
28759
+ detail: "icon.png is not a square PNG"
28760
+ };
28761
+ }
28762
+ return {
28763
+ ok: true,
28764
+ bundle: {
28765
+ manifest,
28766
+ manifestBytes,
28767
+ digest: appDigest(manifestBytes),
28768
+ signature: statement.signature,
28769
+ files
28770
+ }
28771
+ };
28772
+ }
28773
+ /** Puts a bundle's parts together, checking nothing: for builders, and for tests that need a broken bundle. */
28774
+ function assembleAppBundle(manifestBytes, statementBytes, files) {
28775
+ const total = MAGIC.length + 8 + manifestBytes.length + statementBytes.length + files.reduce((s, f) => s + f.length, 0);
28776
+ const out = new Uint8Array(total);
28777
+ const view = new DataView(out.buffer);
28778
+ let at = 0;
28779
+ out.set(MAGIC, at);
28780
+ at += MAGIC.length;
28781
+ view.setUint32(at, manifestBytes.length);
28782
+ at += 4;
28783
+ out.set(manifestBytes, at);
28784
+ at += manifestBytes.length;
28785
+ view.setUint32(at, statementBytes.length);
28786
+ at += 4;
28787
+ out.set(statementBytes, at);
28788
+ at += statementBytes.length;
28789
+ for (const f of files) {
28790
+ out.set(f, at);
28791
+ at += f.length;
28792
+ }
28793
+ return out;
28794
+ }
28795
+ //#endregion
28796
+ //#region ../core/src/appStore.ts
28797
+ var APP_STORE_LIMITS = {
28798
+ /** The index's bytes, uncompressed. */
28799
+ indexBytes: 16777216,
28800
+ /** How far ahead `expires` may be. */
28801
+ expiresAheadS: 7776e3,
28802
+ name: 40,
28803
+ description: 2e3,
28804
+ title: 40,
28805
+ tagline: 80,
28806
+ /** `category`, `developer`, `submitter`. */
28807
+ label: 40,
28808
+ reason: 200,
28809
+ urls: 4,
28810
+ apps: 4096,
28811
+ removed: 4096,
28812
+ revoked: 4096
28813
+ };
28814
+ var APP_STORE_KINDS = ["curated", "indexed"];
28815
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
28816
+ var isLine = (v, max, min = 0) => typeof v === "string" && [...v].length >= min && [...v].length <= max && !/\p{Cc}/u.test(v);
28817
+ var isText = (v, max) => typeof v === "string" && [...v].length <= max && !/[^\P{Cc}\n]/u.test(v);
28818
+ var isCount = (v, min) => typeof v === "number" && Number.isSafeInteger(v) && v >= min;
28819
+ var fine = { ok: true };
28820
+ function keysOf(value, required, optional) {
28821
+ for (const k of Object.keys(value)) if (!required.includes(k) && !optional.includes(k)) return {
28822
+ ok: false,
28823
+ reason: "unknown-key",
28824
+ detail: k
28825
+ };
28826
+ for (const k of required) if (!(k in value)) return {
28827
+ ok: false,
28828
+ reason: "missing-key",
28829
+ detail: k
28830
+ };
28831
+ return fine;
28832
+ }
28833
+ var LISTING_REQUIRED = [
28834
+ "ref",
28835
+ "sequence",
28836
+ "digest",
28837
+ "urls",
28838
+ "title",
28839
+ "tagline"
28840
+ ];
28841
+ var LISTING_OPTIONAL = [
28842
+ "category",
28843
+ "developer",
28844
+ "submitter",
28845
+ "repo",
28846
+ "support"
28847
+ ];
28848
+ /** Checks one entry of `apps`, which is also the whole of a `listing.json`. */
28849
+ function checkAppListing(value) {
28850
+ if (!isObject(value)) return {
28851
+ ok: false,
28852
+ reason: "bad-field",
28853
+ detail: "A listing is not an object"
28854
+ };
28855
+ const keys = keysOf(value, LISTING_REQUIRED, LISTING_OPTIONAL);
28856
+ if (!keys.ok) return keys;
28857
+ const bad = (field) => ({
28858
+ ok: false,
28859
+ reason: "bad-field",
28860
+ detail: `apps: ${field}`
28861
+ });
28862
+ if (!isAppRef(value.ref)) return bad("ref");
28863
+ if (!isCount(value.sequence, 1)) return bad("sequence");
28864
+ if (!isAppHash(value.digest)) return bad("digest");
28865
+ const urls = value.urls;
28866
+ if (!Array.isArray(urls) || urls.length < 1 || urls.length > APP_STORE_LIMITS.urls || !urls.every(isAppUrl)) return bad("urls");
28867
+ if (!isLine(value.title, APP_STORE_LIMITS.title, 1)) return bad("title");
28868
+ if (!isLine(value.tagline, APP_STORE_LIMITS.tagline, 1)) return bad("tagline");
28869
+ for (const k of [
28870
+ "category",
28871
+ "developer",
28872
+ "submitter"
28873
+ ]) if (value[k] !== void 0 && !isLine(value[k], APP_STORE_LIMITS.label, 1)) return bad(k);
28874
+ for (const k of ["repo", "support"]) if (value[k] !== void 0 && !isAppUrl(value[k])) return bad(k);
28875
+ return fine;
28876
+ }
28877
+ function checkRemoval(value) {
28878
+ if (!isObject(value)) return {
28879
+ ok: false,
28880
+ reason: "bad-field",
28881
+ detail: "A removal is not an object"
28882
+ };
28883
+ const keys = keysOf(value, [
28884
+ "ref",
28885
+ "digest",
28886
+ "reason",
28887
+ "at"
28888
+ ], []);
28889
+ if (!keys.ok) return keys;
28890
+ if (!isAppRef(value.ref) || !isAppHash(value.digest) || !isLine(value.reason, APP_STORE_LIMITS.reason, 1) || !isCount(value.at, 0)) return {
28891
+ ok: false,
28892
+ reason: "bad-field",
28893
+ detail: "removed: not {ref, digest, reason, at}"
28894
+ };
28895
+ return fine;
28896
+ }
28897
+ var STORE_REQUIRED = [
28898
+ "ghostlyStore",
28899
+ "key",
28900
+ "name",
28901
+ "kind",
28902
+ "sequence",
28903
+ "expires",
28904
+ "apps",
28905
+ "removed",
28906
+ "revoked"
28907
+ ];
28908
+ /**
28909
+ * Checks an index's fields and bounds, and every revocation it carries (one that does not verify refuses the index:
28910
+ * the store copied it, so a broken one is the store's fault). The signature and `expires` are `readAppStore`'s.
28911
+ */
28912
+ function checkAppStoreIndex(value) {
28913
+ if (!isObject(value)) return {
28914
+ ok: false,
28915
+ reason: "bad-field",
28916
+ detail: "The index is not an object"
28917
+ };
28918
+ if (value.ghostlyStore !== void 0 && value.ghostlyStore !== 1) return {
28919
+ ok: false,
28920
+ reason: "unsupported-format",
28921
+ detail: "ghostlyStore is not 1"
28922
+ };
28923
+ const keys = keysOf(value, STORE_REQUIRED, ["description"]);
28924
+ if (!keys.ok) return keys;
28925
+ const bad = (field) => ({
28926
+ ok: false,
28927
+ reason: "bad-field",
28928
+ detail: field
28929
+ });
28930
+ if (!isAppKey(value.key)) return bad("key");
28931
+ if (!isLine(value.name, APP_STORE_LIMITS.name, 1)) return bad("name");
28932
+ if (value.description !== void 0 && !isText(value.description, APP_STORE_LIMITS.description)) return bad("description");
28933
+ if (!APP_STORE_KINDS.includes(value.kind)) return bad("kind");
28934
+ if (!isCount(value.sequence, 1)) return bad("sequence");
28935
+ if (!isCount(value.expires, 0)) return bad("expires");
28936
+ const { apps, removed, revoked } = value;
28937
+ if (!Array.isArray(apps) || apps.length > APP_STORE_LIMITS.apps) return bad("apps");
28938
+ const refs = /* @__PURE__ */ new Set();
28939
+ for (const listing of apps) {
28940
+ const checked = checkAppListing(listing);
28941
+ if (!checked.ok) return checked;
28942
+ const ref = listing.ref;
28943
+ if (refs.has(ref)) return {
28944
+ ok: false,
28945
+ reason: "duplicate-app",
28946
+ detail: ref
28947
+ };
28948
+ refs.add(ref);
28949
+ }
28950
+ if (!Array.isArray(removed) || removed.length > APP_STORE_LIMITS.removed) return bad("removed");
28951
+ for (const entry of removed) {
28952
+ const checked = checkRemoval(entry);
28953
+ if (!checked.ok) return checked;
28954
+ }
28955
+ if (!Array.isArray(revoked) || revoked.length > APP_STORE_LIMITS.revoked) return bad("revoked");
28956
+ for (const entry of revoked) {
28957
+ const read = readAppRevocation(entry);
28958
+ if (!read.ok) return {
28959
+ ok: false,
28960
+ reason: "bad-revocation",
28961
+ detail: read.detail ?? read.reason
28962
+ };
28963
+ }
28964
+ return fine;
28965
+ }
28966
+ /**
28967
+ * Reads `ghostly-store.json` with its `ghostly-store.sig`, in this order: size, JSON, canonical bytes, fields and
28968
+ * revocations, the signature statement, the key the reader holds for this store (when it holds one), the signature,
28969
+ * then `expires` against `now` (seconds): more than 90 days ahead is refused, past is `expired`.
28970
+ */
28971
+ function readAppStore(indexBytes, sigBytes, now, heldKey) {
28972
+ if (indexBytes.length > APP_STORE_LIMITS.indexBytes) return {
28973
+ ok: false,
28974
+ reason: "too-large"
28975
+ };
28976
+ const read = readCanonicalJson(indexBytes);
28977
+ if (!read.ok) return read;
28978
+ const checked = checkAppStoreIndex(read.value);
28979
+ if (!checked.ok) return checked;
28980
+ const index = read.value;
28981
+ const statement = readAppSignature(sigBytes);
28982
+ if (!statement.ok) return statement;
28983
+ if (heldKey !== void 0 && index.key !== heldKey) return {
28984
+ ok: false,
28985
+ reason: "store-key",
28986
+ detail: "Another key than the store's"
28987
+ };
28988
+ const signed = verifyAppSignature(APP_PREFIXES.store, indexBytes, statement.signature, index.key);
28989
+ if (!signed.ok) return signed;
28990
+ if (index.expires > now + APP_STORE_LIMITS.expiresAheadS) return {
28991
+ ok: false,
28992
+ reason: "expires-too-far"
28993
+ };
28994
+ return {
28995
+ ok: true,
28996
+ store: {
28997
+ index,
28998
+ digest: toBase64Url(sha256(indexBytes)),
28999
+ expired: index.expires < now
29000
+ }
29001
+ };
29002
+ }
29003
+ /** Signs an index with the store key: its canonical bytes and the `ghostly-store.sig` bytes. Throws on a bad index. */
29004
+ async function signAppStore(index, signer) {
29005
+ if (index.key !== toZ32(signer.publicKey)) throw new Error("A store index is signed by its own key");
29006
+ const checked = checkAppStoreIndex(JSON.parse(JSON.stringify(index)));
29007
+ if (!checked.ok) throw new Error(`Not a valid store index (${checked.reason}${checked.detail ? `: ${checked.detail}` : ""})`);
29008
+ const { bytes, signature } = await signAppObject(APP_PREFIXES.store, index, signer);
29009
+ return {
29010
+ indexBytes: bytes,
29011
+ sigBytes: canonicalJsonBytes(signature),
29012
+ signature
29013
+ };
29014
+ }
29015
+ //#endregion
29016
+ //#region ../core/src/appUpdates.ts
29017
+ function appUpdateDecision(installed, candidate) {
29018
+ if (installed.ref !== candidate.ref) return "other-app";
29019
+ if (candidate.sequence > installed.sequence) return "update";
29020
+ if (candidate.sequence < installed.sequence) return "rollback";
29021
+ return candidate.digest === installed.digest ? "same" : "equivocation";
29022
+ }
29023
+ /** The permissions an update adds: when there is any, the update waits for the person, who sees only these. */
29024
+ function addedAppPermissions(granted, requested) {
29025
+ return requested.filter((p) => !granted.includes(p));
29026
+ }
29027
+ function planAppUpdate(installed, candidate) {
29028
+ const decision = appUpdateDecision(installed, candidate);
29029
+ if (decision !== "update") return {
29030
+ action: "refuse",
29031
+ decision
29032
+ };
29033
+ const added = addedAppPermissions(installed.permissions, candidate.permissions);
29034
+ return added.length ? {
29035
+ action: "ask",
29036
+ added
29037
+ } : { action: "install" };
29038
+ }
29039
+ /**
29040
+ * The revocation that stops a version, or null: one signed by the app's own publisher (a reader verified it) that names
29041
+ * its digest, or reaches its `sequence` with `upTo`.
29042
+ */
29043
+ function appRevocationFor(version, revocations) {
29044
+ const publisher = appRefPublisher(version.ref);
29045
+ for (const r of revocations) {
29046
+ const s = r.statement;
29047
+ if (s.app !== version.ref || r.signature.key !== publisher) continue;
29048
+ if ("digests" in s ? s.digests.includes(version.digest) : version.sequence <= s.upTo) return r;
29049
+ }
29050
+ return null;
29051
+ }
29052
+ function checkAppBeforeRun(version, stores, revocations = []) {
29053
+ const revocation = appRevocationFor(version, [...revocations, ...stores.flatMap((s) => s.revoked)]);
29054
+ if (revocation) return {
29055
+ status: "revoked",
29056
+ revocation
29057
+ };
29058
+ const by = stores.flatMap((s) => s.removed.filter((r) => r.ref === version.ref && r.digest === version.digest).map((r) => ({
29059
+ store: s.key,
29060
+ name: s.name,
29061
+ reason: r.reason,
29062
+ at: r.at
29063
+ })));
29064
+ return by.length ? {
29065
+ status: "removed",
29066
+ by
29067
+ } : { status: "ok" };
29068
+ }
29069
+ function appStoreDecision(held, read) {
29070
+ if (!held) return "new";
29071
+ if (held.key !== read.key) return "other-key";
29072
+ if (read.sequence > held.sequence) return "update";
29073
+ if (read.sequence < held.sequence) return "rollback";
29074
+ return read.digest === held.digest ? "same" : "equivocation";
29075
+ }
29076
+ //#endregion
29077
+ export { parseSshPublicKey as $, signAppRevocation as $a, formatFileSize as $i, GROUP_FILE_LIMITS as $n, handoffEphemeral as $o, WAKE_CALL_INTERVAL_MS as $r, deviceKeyZ32 as $s, SEPOLIA_TEST_USDT_FAUCET as $t, entryParams as A, replySnippet as Aa, createRelayPayload as Ac, IdentityExchange as Ai, nip05RootKey as An, LEGACY_SERVICES as Ao, newerHead as Ar, enrollCancelReason as As, didWebDocumentUrl as At, GROUP_REACTED_FRAME as B, claimedTime as Ba, sanitizeAvatar as Bc, emptyProofLedger as Bi, engineText as Bn, HANDOFF_REQUEST as Bo, transferStalled as Br, deviceInviteJoinerParams as Bs, staticDidDocument as Bt, GROUP_READ_NOTE_COMMUNITY as C, reactionEmoji as Ca, setLinkTraceSink as Cc, DHT_TEXT_CAPABILITY as Ci, DOMAIN_FILE_MAX_BYTES as Cn, callRtcConfig as Co, beaconRecords as Cr, signTombstone as Cs, DID_JWS_MAX_LENGTH as Ct, KNOCK_TTL_MS as D, wireReaction as Da, withRequestOptions as Dc, IDENTITY_MAX_VALIDITY as Di, domainWellKnownFile as Dn, signalHasVideo as Do, lobbyRecords as Dr, ENROLL_HELLO as Ds, didGhostlyServiceEntry as Dt, encodeCommunityLink as E, validReactionNumber as Ea, isDiscoveryBudgetError as Ec, IDENTITY_MAX_EVIDENCE as Ei, domainTxtRecord as En, parseCallSignal as Eo, lobbyKeys as Er, ENROLL_GRANT as Es, checkDidSignature as Et, GROUP_WAKE_FRAME as F, buttonLabelClash as Fa, identityFromSeed as Fc, newIdentityBinding as Fi, txtValue as Fn, HANDOFF_HAVE as Fo, readLobby as Fr, enrollProofFrame as Fs, normalizeDid as Ft, GROUP_EDIT_TEXT_BYTES as G, APP_STATEMENT_LIMITS as Ga, toZ32 as Gc, irohRelayUrl as Gi, findEndpoint as Gn, handoffAttemptFailed as Go, typingActivity as Gr, encodeInviteCode as Gs, encodeDidDhtPacket as Gt, GroupSession as H, receivedTimestamp as Ha, fromBase64Url as Hc, TRANSPORTS as Hi, ENDPOINT as Hn, HANDOFF_VERIFIED as Ho, TYPING_REFRESH_MS as Hr, OwnInviteError as Hs, didDhtDocument as Ht, GroupWakeLimiter as I, checkStatusCard as Ia, identityFromSeedB64 as Ic, receivedIdentityStatus as Ii, wellKnownUrl as In, HANDOFF_HELLO as Io, shouldBeHub as Ir, readEnrollGrant as Is, parseDid as It, mentionsMember as J, checkAppRevokeStatement as Ja, peekDhtMailbox as Ji, parseFedimintRequestPayload as Jn, handoffCancelFrame as Jo, mayPin as Jr, DEVICES_CAPABILITY as Js, ETHEREUM_USDT as Jt, carryMentions as K, appFingerprint as Ka, utf8Decode as Kc, DHT_FILE_TOO_LARGE as Ki, isFederationId as Kn, handoffAttemptSucceeded as Ko, GROUP_PIN_FRAME as Kr, inviteLink as Ks, resolveDidDht as Kt, groupWakeFrame as L, sameButtonText as La, publicKeyFromZ32 as Lc, revokesIdentity as Li, requestLnurlInvoice as Ln, HANDOFF_OFFER as Lo, ChatFiles as Lr, readEnrollHello as Ls, parseDidDocument as Lt, knockRecords as M, ITEM_STATES as Ma, openRelayPayload as Mc, emptyIdentityLedger as Mi, recordsFromTxt as Mn, HANDOFF_ACK as Mo, rankHubs as Mr, enrollDoneFrame as Ms, didWebFileNames as Mt, mergeKnocks as N, RUN_RESULTS as Na, parseRelayPayload as Nc, identityRevocationValue as Ni, recordsFromWellKnown as Nn, HANDOFF_CANCEL as No, readBeacon as Nr, enrollGrantFrame as Ns, didWebFileUrl as Nt, decodeGroupEntryLink as O, pairedReplyAuthor as Oa, PacketTooLargeError as Oc, IDENTITY_REVOCATION_LABEL as Oi, encodeDnsQuery as On, emptyLinkRecords as Oo, mergeBeacon as Or, ENROLL_PROOF as Os, didJwsAlg as Ot, readKnocks as P, STATUS_CARD_LIMITS as Pa, createIdentity as Pc, identityStatement as Pi, recordsName as Pn, HANDOFF_DONE as Po, readBeaconHead as Pr, enrollHelloFrame as Ps, didWebHost as Pt, SshSigError as Q, signAppObject as Qa, readImageMeta as Qi, pairedMessageFrame as Qn, handoffDigest as Qo, readPin as Qr, deviceEchoNonce as Qs, SEPOLIA_TEST_USDT as Qt, groupWakes as R, statusCardText as Ra, verify as Rc, lastSharedWithMe as Ri, resolveLightningDestination as Rn, HANDOFF_PAKE as Ro, FILE_LIMITS as Rr, verifyEnrollProof as Rs, parseDidSignaturePaste as Rt, CommunitySession as S, queueReaction as Sa, normalizeRelayUrl as Sc, CapsExchange as Si, pubkyHomeserverOf as Sn, buildSdpFromSignal as So, beaconKeys as Sr, setUpdateFrame as Ss, DID_FILE_MAX_BYTES as St, decodeCommunityLink as T, readReaction as Ta, budgetRetryMs as Tc, dialDescriptors as Ti, dnsProofName as Tn, extractParamsFromSdp as To, freshHubs as Tr, ENROLL_DONE as Ts, checkDidJws as Tt, COMMUNITY_EDIT_FRAME as U, shownTime as Ua, randomBytes as Uc, automaticTransport as Ui, cashuRequestPayload as Un, filePartName as Uo, TypingSender as Ur, createChatInvite as Us, didDhtFromPublicKey as Ut, GROUP_READ_NOTE as V, heardTime as Va, bytesEqual as Vc, validExternalKey as Vi, decodeBolt11 as Vn, HANDOFF_TIMINGS as Vo, TYPING_KINDS as Vr, readDeviceInvite as Vs, strictBase64Url as Vt, GROUP_EDIT_FRAME as W, APP_PREFIXES as Wa, toBase64Url as Wc, irohDescriptor as Wi, fedimintRequestPayload as Wn, handoffAttemptAllowed as Wo, sanitizeTypingStatus as Wr, decodeInviteCode as Ws, encodeDidDhtDeactivated as Wt, SSHSIG_MAX_ARMOR as X, isAppRef as Xa, dhtFileId as Xi, INVITE_TAKEN as Xn, handoffContextProof as Xo, pinIsNewer as Xr, HANDOFF_CAPABILITY as Xs, ONCHAIN_PROVIDER as Xt, groupName as Y, isAppKey as Ya, DHT_FILE_CAPABILITY as Yi, GhostLink as Yn, handoffContext as Yo, nextPinNumber as Yr, ENROLL_CAPABILITY as Ys, EVM_TEST_CHAINS as Yt, SSHSIG_NAMESPACE as Z, readAppRevocations as Za, parseImageMeta as Zi, LIVENESS_PING_MS as Zn, handoffDataFrame as Zo, pinNumberHolds as Zr, deviceEchoFrame as Zs, PaymentPreflightError as Zt, GroupTypingBoard as _, COMMUNITY_REACTION_FRAME as _a, turnKeys as _c, HOLD_LIMITS as _i, normalizePubkyKey as _n, decodeControl as _o, MESH_HUBS as _r, sealHandoffPiece as _s, parseAtprotoDidDocument as _t, checkAppStoreIndex as a, baseMime as aa, TURN_PUT_WINDOW_MS as ac, wakeRequest as ai, formatPaymentAmount as an, rosterHas as ao, isGroupMessageId as ar, handoffRetryAfter as as, BitcoinAddressError as at, sealGroupTyping as b, ReactionWindow as ba, RelayTransport as bc, newMailbox as bi, pubkyProofPath as bn, RTC_CONFIG as bo, pickMeshHubs as br, readSetUpdate as bs, verifyAtprotoRecordProof as bt, APP_BUNDLE_LIMITS as c, voicePeaksOf as ca, classifyTurnRead as cc, chatAppId as ci, SPARK_NETWORKS as cn, DirectPathWatch as co, readGroupWant as cr, newHandoffId as cs, ATPROTO_FULL_SCOPE as ct, appViewOf as d, EDIT_RATE_WINDOW_MS as da, TURN_MAX as dc, BUTTONS_CAPABILITY as di, sparkInvoiceDetails as dn, presenceSeenAt as do, GROUP_SIGNAL_FRAME as dr, readHandoffDone as ds, atprotoDidMethod as dt, safeBlobType as ea, deviceLinkPairing as ec, WakeLimiter as ei, TEST_USDT_FAUCET_AMOUNT as en, canonicalJson as eo, GROUP_HAVE_FRAME as er, handoffHelloFrame as es, parseSshSignature as et, assembleAppBundle as f, RateWindow as fa, firstDeviceSetSecret as fc, EDIT_CAPABILITY as fi, isBitcoinAddress as fn, GhostlyHttpError as fo, SIGNAL_FRESH_MS as fr, readHandoffHave as fs, atprotoProofRecord as ft, GROUP_TYPING_FRAME as g, readForwarded as ga, signTurnRelease as gc, HOLD_DATED_BACK_MS as gi, newPubkyProofFolder as gn, LIMITS as go, readGroupSignal as gr, readHandoffVerified as gs, normalizeAtprotoHandle as gt, readAppBundle as h, forwardedAgain as ha, signTurnPacket as hc, HOLD_CAPABILITY as hi, isPubkyProofFolder as hn, webLocalFetch as ho, groupSignalFrame as hr, readHandoffTurnFrame as hs, isAtprotoDid as ht, planAppUpdate as i, VOICE_LIMITS as ia, webCryptoSigner as ic, relayRequest as ii, assertWholeSats as in, MEMBER_KEY as io, groupFileFallback as ir, handoffReleaseFrame as is, verifyBitcoinMessage as it, knockIdentity as j, BUTTON_ID as ja, newerPacket as jc, boundVerifiedIdentity as ji, normalizeDomain as jn, serviceIdFromName as jo, pickHubs as jr, enrollDigits as js, didWebFile as jt, encodeGroupEntryLink as k, readReply as ka, RELAY_PAYLOAD_MAX_BYTES as kc, IdentityCheckUnavailable as ki, isPublicAddress as kn, HTTP_SERVICE_PROTO as ko, mergeLobby as kr, enrollCancelFrame as ks, didSignedMessages as kt, APP_BUNDLE_MAGIC as l, parseVideoMeta as la, turnPutSummary as lc, isAppVersion as li, SPARK_PROVIDER as ln, EXPECT_PEER_MS as lo, readGroupWantNo as lr, readHandoffAck as ls, ATPROTO_PROOF_COLLECTION as lt, isAppUrl as m, parseLinkPreview as ma, readTurnPacket as mc, UPGRADE_CAPABILITY as mi, isPubkyKey as mn, parseLocalTarget as mo, SIGNAL_REPLY_MS as mr, readHandoffPake as ms, checkAtprotoProofRecord as mt, appUpdateDecision as n, sanitizeMime as na, newDeviceSetSecret as nc, newWakeToken as ni, WALLET_NETWORKS as nn, GROUP_MEMBER_CAP as no, GROUP_WANT_NO_FRAME as nr, handoffOfferFrame as ns, sshSignCommand as nt, readAppStore as o, fileMessageText as oa, TURN_RAISE_READ_TIMEOUT_MS as oc, checkPushEndpoint as oi, validatePaymentTarget as on, verifyCommitSignature as oo, readGroupFileMeta as or, handoffStreamKey as os, decodeAnyBitcoinAddress as ot, checkAppManifest as p, dhtEditId as pa, nextTurnPosition as pc, STATUS_CARD_CAPABILITY as pi, PUBKY_PROOF_MAX_BYTES as pn, formatLocalTarget as po, SIGNAL_HOLD_MS as pr, readHandoffHello as ps, atprotoProofRkey as pt, parseCommunityEdit as q, appRef as qa, utf8Encode as qc, DHT_MESSAGE_TTL as qi, parseCashuRequestPayload as qn, handoffBusyFrame as qo, PIN_LIMITS as qr, inviteOwnership as qs, signDidDhtPacket as qt, checkAppBeforeRun as r, PLAYABLE_AUDIO as ra, seedSigner as rc, parseWakeFrame as ri, assertTokenUnits as rn, MAX_GROUP_CHAIN as ro, fetchesByItself as rr, handoffPakeFrame as rs, verifySshSignature as rt, signAppStore as s, parseVoiceMeta as sa, TURN_SETTLE_MS as sc, vapidKeysMatch as si, walletNetworkOf as sn, edgeParams as so, readGroupHave as sr, handoffVersions as ss, ATPROTO_DECLARED_SCOPE as st, appStoreDecision as t, sanitizeFileName as ta, devicePingFrame as tc, deviceWakeRequest as ti, USDT_PUBLIC_RPC as tn, canonicalJsonBytes as to, GROUP_WANT_FRAME as tr, handoffManifestFrames as ts, sameSshKey as tt, appDigest as u, EDIT_BUFFER_MS as ua, TURN_LAST_SEQUENCE as uc, APPS_CAPABILITY as ui, sparkAddressKind as un, RELAY_POLL_INTERVALS as uo, CarriedTransport as ur, readHandoffCancel as us, ATPROTO_PROOF_SCOPE as ut, groupTypingEpoch as v, GROUP_REACTION_FRAME as va, turnName as vc, HoldKeys as vi, pubkyProofCapability as vn, encodeControl as vo, mayBeHub as vr, SET_ACK as vs, shortAtprotoDid as vt, communityHasFrame as w, reactionIsNewer as wa, traceLink as wc, capsDescriptors as wi, decodeDnsResponse as wn, callSignalHeardAt as wo, doorHubs as wr, verifySetUpdate as ws, DidError as wt, COMMUNITY_LIMITS as x, nextReactionNumber as xa, currentRelays as xc, readManifest as xi, homeserverWebEndpoint as xn, answersOffer as xo, COMMUNITY_TOPOLOGY as xr, setAckFrame as xs, DID_DOCUMENT_MAX_BYTES as xt, openGroupTyping as y, REACTION_LIMITS as ya, DEFAULT_RELAYS as yc, HoldRefusedError as yi, pubkyProofFile as yn, CALL_SIGNAL_MAX_AGE_MS as yo, meshHubs as yr, SET_UPDATE as ys, AtprotoProofError as yt, parseGroupWakeFrame as z, arrivalKey as za, MAX_AVATAR_LENGTH as zc, PeerProofs as zi, engineError as zn, HANDOFF_PIECE_BYTES as zo, transferEnded as zr, createDeviceInvite as zs, signingKeys as zt };
26943
29078
 
26944
- //# sourceMappingURL=src-K3NijMq-.js.map
29079
+ //# sourceMappingURL=src-DtxVo2Rm.js.map