@ghostlytools/cli 1.2.1 → 1.2.3

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.
@@ -142,9 +142,20 @@ var KEEP = /[\u200c\u200d]/;
142
142
  function stripHidden(text) {
143
143
  return text.replace(HIDDEN, (char) => KEEP.test(char) ? char : "");
144
144
  }
145
+ /**
146
+ * What draws nothing when it stands alone and `HIDDEN` leaves in, since text needs it beside something else: the
147
+ * joiners, the fillers, the variation selectors and the other default-ignorable code points, combining marks, white
148
+ * space and the blank Braille pattern.
149
+ */
150
+ var UNSEEN = /^[\p{Default_Ignorable_Code_Point}\p{M}\p{White_Space}\u2800]*$/u;
151
+ /** Whether `text` holds a character that shows by itself: a name of joiners, fillers or marks alone has none. */
152
+ function hasVisible(text) {
153
+ return !UNSEEN.test(text);
154
+ }
145
155
  /** Undefined when nothing visible is left, so callers can fall back. */
146
156
  function sanitizeDisplayText(text, max) {
147
- return [...stripHidden(text)].slice(0, max).join("").trim() || void 0;
157
+ const clean = [...stripHidden(text)].slice(0, max).join("").trim();
158
+ return hasVisible(clean) ? clean : void 0;
148
159
  }
149
160
  /** A nickname as it arrived from a peer, ready to show. */
150
161
  function sanitizeNick(nick) {
@@ -1992,6 +2003,33 @@ function turnSequence(turn, rev, author) {
1992
2003
  if (!Number.isInteger(author) || author < 0 || author >= 4) throw new Error("An author is a slot, 0 to 3");
1993
2004
  return turn * 2 ** 20 + rev * 4 + author;
1994
2005
  }
2006
+ var VERIFIER_DOMAIN = "ghostly-turn-verifier/1";
2007
+ var VERIFIER_TAG = Uint8Array.of(103, 118);
2008
+ var VERIFIER_TAG_AT = 3;
2009
+ var VERIFIER_MARK_AT = 5;
2010
+ /** The mark of a verifier (its `setup` and `record` as stored), or of a profile that has none (null). 3 bytes. */
2011
+ function turnVerifierMark(verifier) {
2012
+ return sha256(utf8Encode(`${VERIFIER_DOMAIN}\n${verifier?.setup ?? ""}\n${verifier?.record ?? ""}`)).slice(0, 3);
2013
+ }
2014
+ /**
2015
+ * A record's `instance` that states `mark` (`turnVerifierMark`), or, with no mark, one that states nothing: 8 random
2016
+ * bytes that never carry the marker (a tombstone, a record written where the profile's verifier is not at hand).
2017
+ */
2018
+ function turnInstance(mark, random = randomBytes) {
2019
+ if (mark && mark.length !== 3) throw new Error("A verifier mark is 3 bytes");
2020
+ const instance = random(8).slice(0, 8);
2021
+ if (instance.length !== 8) throw new Error("An instance is 8 bytes");
2022
+ if (mark) {
2023
+ instance.set(VERIFIER_TAG, VERIFIER_TAG_AT);
2024
+ instance.set(mark, VERIFIER_MARK_AT);
2025
+ } else if (turnStatedVerifier(instance)) instance[VERIFIER_TAG_AT] ^= 255;
2026
+ return instance;
2027
+ }
2028
+ /** The verifier mark an `instance` states, or null when it states none (a record written by an app from before). */
2029
+ function turnStatedVerifier(instance) {
2030
+ if (instance.length !== 8 || instance[VERIFIER_TAG_AT] !== VERIFIER_TAG[0] || instance[4] !== VERIFIER_TAG[1]) return null;
2031
+ return instance.slice(VERIFIER_MARK_AT);
2032
+ }
1995
2033
  var TurnRecordError = class extends Error {
1996
2034
  refusal;
1997
2035
  constructor(refusal, detail) {
@@ -2008,7 +2046,8 @@ var isZero = (bytes) => bytes.every((b) => b === 0);
2008
2046
  * A device's name as its slot holds it: UTF-8, cut at a character boundary to 16 bytes, and with no NUL (the padding).
2009
2047
  * A character is what a person sees as one (a grapheme), so a cut never leaves half an emoji or a lone joiner.
2010
2048
  * A name is display text a device chooses for itself and every device of the set shows: it is cleaned as a nickname is
2011
- * (`sanitizeDisplayText`: no control, invisible or direction-changing character) and trimmed, made and read alike.
2049
+ * (`sanitizeDisplayText`: no control, invisible or direction-changing character, and empty when nothing of it shows)
2050
+ * and trimmed, made and read alike.
2012
2051
  */
2013
2052
  function turnName(name) {
2014
2053
  const clean = stripHidden(name).trim();
@@ -2021,7 +2060,7 @@ function turnName(name) {
2021
2060
  out += character;
2022
2061
  length += size;
2023
2062
  }
2024
- return out.trimEnd();
2063
+ return hasVisible(out) ? out.trimEnd() : "";
2025
2064
  }
2026
2065
  function u32$3(value) {
2027
2066
  return Uint8Array.of(value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255);
@@ -4297,6 +4336,7 @@ function parseCallSignal(json, now = Date.now()) {
4297
4336
  if (raw.t !== "a" || typeof raw.re !== "number" || !Number.isFinite(raw.re)) return null;
4298
4337
  restart.re = raw.re;
4299
4338
  }
4339
+ const closed = raw.vr === 0 ? { vr: 0 } : {};
4300
4340
  if (raw.o !== void 0 && (typeof raw.o !== "number" || !Number.isFinite(raw.o))) return null;
4301
4341
  const answers = raw.t === "a" && raw.o !== void 0 ? { o: raw.o } : {};
4302
4342
  return {
@@ -4312,6 +4352,7 @@ function parseCallSignal(json, now = Date.now()) {
4312
4352
  ...picture,
4313
4353
  ...payloadTypes,
4314
4354
  ...restart,
4355
+ ...closed,
4315
4356
  ...answers,
4316
4357
  ...heard
4317
4358
  };
@@ -4410,7 +4451,7 @@ function buildSdpFromSignal(signal) {
4410
4451
  } else if (mediaType === "v") {
4411
4452
  lines.push(`m=video 9 UDP/TLS/RTP/SAVPF ${vp8}`, "c=IN IP4 0.0.0.0", "a=rtcp:9 IN IP4 0.0.0.0");
4412
4453
  for (const c of signal.c ?? []) lines.push(`a=candidate:${c}`);
4413
- lines.push("a=end-of-candidates", `a=ice-ufrag:${signal.u}`, `a=ice-pwd:${signal.p}`, `a=fingerprint:${fingerprint}`, `a=setup:${signal.s}`, `a=mid:${mid}`, "a=sendrecv", `a=msid:stream video0`, "a=rtcp-mux", "a=rtcp-rsize", `a=rtpmap:${vp8} VP8/90000`, `a=rtcp-fb:${vp8} ccm fir`, `a=rtcp-fb:${vp8} nack`, `a=rtcp-fb:${vp8} nack pli`, `a=rtcp-fb:${vp8} goog-remb`, `a=ssrc:${videoSsrc} cname:pkarr`, `a=ssrc:${videoSsrc} msid:stream video0`);
4454
+ lines.push("a=end-of-candidates", `a=ice-ufrag:${signal.u}`, `a=ice-pwd:${signal.p}`, `a=fingerprint:${fingerprint}`, `a=setup:${signal.s}`, `a=mid:${mid}`, ...signal.vr === 0 ? ["a=inactive"] : ["a=sendrecv", "a=msid:stream video0"], "a=rtcp-mux", "a=rtcp-rsize", `a=rtpmap:${vp8} VP8/90000`, `a=rtcp-fb:${vp8} ccm fir`, `a=rtcp-fb:${vp8} nack`, `a=rtcp-fb:${vp8} nack pli`, `a=rtcp-fb:${vp8} goog-remb`, ...signal.vr === 0 ? [] : [`a=ssrc:${videoSsrc} cname:pkarr`, `a=ssrc:${videoSsrc} msid:stream video0`]);
4414
4455
  }
4415
4456
  });
4416
4457
  return lines.join("\r\n") + "\r\n";
@@ -5607,10 +5648,14 @@ var LinkSession = class {
5607
5648
  pollAgain = false;
5608
5649
  publishing = null;
5609
5650
  publishAgain = false;
5651
+ /** What waits to go out is the presence heartbeat alone (`PRESENCE_HEARTBEAT`): nothing in the packet changed. */
5652
+ upkeepOnly = false;
5610
5653
  pollTimer = null;
5611
5654
  heartbeatTimer = null;
5612
5655
  lastActivity = Date.now();
5613
5656
  startedAt = Date.now();
5657
+ /** From when a peer never seen is awaited (`AWAITING_PEER_MS`): never later than this start, whatever the clock said when the chat was made. */
5658
+ awaitingSince;
5614
5659
  intervals;
5615
5660
  fastPollUntil = 0;
5616
5661
  /** An offer to a saved contact is out: from then on its fast window looks less often (`OFFER_FAST_MS`); 0 for none. */
@@ -5665,6 +5710,7 @@ var LinkSession = class {
5665
5710
  this.intervals = options.pollIntervals ?? DHT_POLL_INTERVALS;
5666
5711
  this.events = options.events ?? {};
5667
5712
  this.firstPublish = options.firstPublish ?? "at-start";
5713
+ this.awaitingSince = Math.min(options.awaitingSince ?? this.startedAt, this.startedAt);
5668
5714
  }
5669
5715
  get peerPresence() {
5670
5716
  return this.presence;
@@ -5868,11 +5914,12 @@ var LinkSession = class {
5868
5914
  t: timestamp,
5869
5915
  m: text
5870
5916
  }];
5917
+ this.upkeepOnly = false;
5871
5918
  try {
5872
5919
  if (await this.publishOnce(true) === 0) return "Message could not be published — DHT payload limit exceeded.";
5873
5920
  } catch (error) {
5874
5921
  if (this.publishRetryTimer) clearTimeout(this.publishRetryTimer);
5875
- this.publishRetryTimer = setTimeout(() => void this.publish().catch(() => {}), this.retryDelay(error));
5922
+ this.publishRetryTimer = setTimeout(() => void this.publish(true).catch(() => {}), this.retryDelay(error));
5876
5923
  if (!isDiscoveryBudgetError(error)) this.events.onStatus?.("error");
5877
5924
  return null;
5878
5925
  }
@@ -5928,7 +5975,7 @@ var LinkSession = class {
5928
5975
  if (this.connected) return "connected";
5929
5976
  if (Date.now() < this.fastPollUntil) return "fast";
5930
5977
  if (Date.now() < this.watchUntil) return "active";
5931
- if (!this.active && this.presence.lastPacketAt === 0 && Date.now() - this.startedAt < 6e5) return "active";
5978
+ if (!this.active && this.presence.lastPacketAt === 0 && Date.now() - this.awaitingSince < 6e5) return "active";
5932
5979
  if (!this.active) return "background";
5933
5980
  return Date.now() - this.lastActivity > 6e4 ? "idle" : "active";
5934
5981
  }
@@ -5936,13 +5983,18 @@ var LinkSession = class {
5936
5983
  if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer);
5937
5984
  this.heartbeatTimer = setTimeout(() => {
5938
5985
  if (!this.running) return;
5939
- if (this.getServices() !== void 0) this.publish().catch(() => {});
5986
+ if (this.getServices() !== void 0) this.publish(true).catch(() => {});
5940
5987
  this.scheduleHeartbeat();
5941
5988
  }, PRESENCE_HEARTBEAT);
5942
5989
  }
5943
- /** Coalesces publishes: at most one in flight and one queued behind it. */
5944
- publish() {
5990
+ /**
5991
+ * Coalesces publishes: at most one in flight and one queued behind it. `upkeep`: nothing new to say (the heartbeat, a
5992
+ * retry of what waits): with nothing else asked for since, the packet goes as upkeep (`publishOnce`).
5993
+ */
5994
+ publish(upkeep = false) {
5945
5995
  if (this.silent) return Promise.resolve();
5996
+ if (!upkeep) this.upkeepOnly = false;
5997
+ else if (!this.publishing && !this.publishRetryTimer) this.upkeepOnly = true;
5946
5998
  if (this.publishing) {
5947
5999
  this.publishAgain = true;
5948
6000
  return this.publishing;
@@ -5953,12 +6005,12 @@ var LinkSession = class {
5953
6005
  try {
5954
6006
  do {
5955
6007
  this.publishAgain = false;
5956
- await this.publishOnce(this.running);
6008
+ await this.publishOnce(this.running, this.upkeepOnly);
5957
6009
  } while (this.publishAgain && this.running);
5958
6010
  } catch (error) {
5959
6011
  if (this.running) {
5960
6012
  if (!isDiscoveryBudgetError(error)) this.discoveryResult("publish", error);
5961
- this.publishRetryTimer = setTimeout(() => void this.publish().catch(() => {}), this.retryDelay(error));
6013
+ this.publishRetryTimer = setTimeout(() => void this.publish(true).catch(() => {}), this.retryDelay(error));
5962
6014
  }
5963
6015
  throw error;
5964
6016
  } finally {
@@ -5967,7 +6019,7 @@ var LinkSession = class {
5967
6019
  })();
5968
6020
  return this.publishing;
5969
6021
  }
5970
- async publishOnce(advertise) {
6022
+ async publishOnce(advertise, upkeep = false) {
5971
6023
  const rtcSignal = advertise ? this.rtcSignal : null;
5972
6024
  const built = buildLinkRecords(this.identity.pubKeyZ32, {
5973
6025
  messages: this.sentBuffer,
@@ -5980,7 +6032,7 @@ var LinkSession = class {
5980
6032
  }, this.encKey);
5981
6033
  const started = Date.now();
5982
6034
  try {
5983
- const options = rtcSignal && rtcSignal !== this.rtcSignalOut ? { signal: true } : this.watching() ? { watch: true } : void 0;
6035
+ const options = rtcSignal && rtcSignal !== this.rtcSignalOut ? { signal: true } : this.watching() ? { watch: true } : upkeep && this.connected ? { background: true } : void 0;
5984
6036
  await this.transport.publish(this.identity, built.records, options);
5985
6037
  } catch (error) {
5986
6038
  const ms = Date.now() - started, waiting = isDiscoveryBudgetError(error);
@@ -6339,6 +6391,8 @@ var DataLink = class {
6339
6391
  answered = null;
6340
6392
  /** Answers refused since a connection last opened, each followed by a new offer at once (`ANSWER_REFUSED_REDIALS`). */
6341
6393
  refusedAnswers = 0;
6394
+ /** The last answer dropped as not for this side's offer (`answer-dropped`): each is said once. */
6395
+ droppedAnswerTs = 0;
6342
6396
  constructor(options) {
6343
6397
  this.options = options;
6344
6398
  }
@@ -6447,6 +6501,12 @@ var DataLink = class {
6447
6501
  } catch (error) {
6448
6502
  if (this.pc === pc) this.answerRefused(error);
6449
6503
  }
6504
+ } else if (signal.t === "a" && signal.ts !== this.droppedAnswerTs) {
6505
+ this.droppedAnswerTs = signal.ts;
6506
+ traceLink(this.options.myPubKeyZ32, "answer-dropped", {
6507
+ state: this.state,
6508
+ ...this.myOfferTs && signal.o !== void 0 ? { offerMs: signal.o - this.myOfferTs } : {}
6509
+ });
6450
6510
  }
6451
6511
  }
6452
6512
  /**
@@ -6520,7 +6580,10 @@ var DataLink = class {
6520
6580
  again
6521
6581
  });
6522
6582
  this.reset();
6523
- if (!again) return;
6583
+ if (!again) {
6584
+ this.options.onAnswersRefused?.();
6585
+ return;
6586
+ }
6524
6587
  this.refusedAnswers++;
6525
6588
  this.options.onAnswerReplaced?.();
6526
6589
  }
@@ -7227,11 +7290,23 @@ async function signAppRevocation(statement, signer) {
7227
7290
  signature
7228
7291
  };
7229
7292
  }
7293
+ /** What a checked revocation is kept by: the SHA-256 of its canonical bytes (statement and signature), base64url. */
7294
+ function appRevocationId(revocation) {
7295
+ return toBase64Url(sha256(canonicalJsonBytes(revocation)));
7296
+ }
7230
7297
  /**
7231
7298
  * Reads one signed revocation (a parsed value, from `ghostly-revoke.json` or a store index's `revoked`): its shape, then
7232
- * the signature under `ghostly-revoke/1` by the publisher its `app` names.
7299
+ * the signature under `ghostly-revoke/1` by the publisher its `app` names. One that `checked` holds verified before, so
7300
+ * its signature is not checked again; one that verifies now is added to it.
7233
7301
  */
7234
- function readAppRevocation(value) {
7302
+ function readAppRevocation(value, checked) {
7303
+ const read = readAppRevocationShape(value);
7304
+ if (!read.ok) return read;
7305
+ const verified = verifyAppRevocation(read.revocation, checked);
7306
+ return verified.ok ? read : verified;
7307
+ }
7308
+ /** The first half of `readAppRevocation`: the shape of a signed revocation, with no signature checked. */
7309
+ function readAppRevocationShape(value) {
7235
7310
  if (!exactKeys$1(value, ["statement", "signature"])) return {
7236
7311
  ok: false,
7237
7312
  reason: "bad-revocation",
@@ -7247,17 +7322,22 @@ function readAppRevocation(value) {
7247
7322
  ok: false,
7248
7323
  reason: "bad-signature-statement"
7249
7324
  };
7250
- const statement = value.statement;
7251
- const checked = verifyAppSignature(APP_PREFIXES.revoke, canonicalJsonBytes(statement), value.signature, appRefPublisher(statement.app));
7252
- if (!checked.ok) return checked;
7253
7325
  return {
7254
7326
  ok: true,
7255
7327
  revocation: {
7256
- statement,
7328
+ statement: value.statement,
7257
7329
  signature: value.signature
7258
7330
  }
7259
7331
  };
7260
7332
  }
7333
+ /** The second half: the signature of a revocation of the right shape, unless `checked` holds it. */
7334
+ function verifyAppRevocation(revocation, checked) {
7335
+ const id = checked && appRevocationId(revocation);
7336
+ if (id !== void 0 && checked.has(id)) return { ok: true };
7337
+ const verified = verifyAppSignature(APP_PREFIXES.revoke, canonicalJsonBytes(revocation.statement), revocation.signature, appRefPublisher(revocation.statement.app));
7338
+ if (verified.ok && id !== void 0) checked.add(id);
7339
+ return verified;
7340
+ }
7261
7341
  /**
7262
7342
  * Reads `ghostly-revoke.json`: canonical bytes holding a list of signed revocations. One that does not verify refuses the
7263
7343
  * file, as a store index's `revoked` does.
@@ -8031,8 +8111,13 @@ var COMMUNITY_REACTION_FRAME = "reaction";
8031
8111
  var REACTION_LIMITS = {
8032
8112
  /** UTF-8 bytes of one emoji: a family or a subdivision flag fits, a run of emoji does not. */
8033
8113
  emojiBytes: 32,
8034
- /** Reaction frames a receiver takes per window on one chat; the rest are dropped unconfirmed and come again. */
8114
+ /**
8115
+ * Reaction frames a receiver takes per window on one chat; the rest are dropped unconfirmed and come again. In a
8116
+ * group nothing comes again (a community's catch-up hands a frame once), so there the rest wait for the next window.
8117
+ */
8035
8118
  receive: 30,
8119
+ /** Group members' reactions past a window, kept per group until it has room: as many as a community's store hands back. */
8120
+ held: 256,
8036
8121
  /** Reactions a sender says per window on one chat; the newest of each message waits for room. */
8037
8122
  send: 20,
8038
8123
  windowMs: 1e4,
@@ -9413,6 +9498,16 @@ var DhtDelivery = class DhtDelivery {
9413
9498
  }
9414
9499
  this.fastUntil = 0;
9415
9500
  this.signalReads = 0;
9501
+ if (this.options.credentials.peerKey && (!this.state.wasLive || this.state.inviteTaken)) this.serialize(async () => {
9502
+ const taken = this.state.inviteTaken;
9503
+ if (taken) traceLink(this.from, "invite-untaken", {});
9504
+ const { inviteTaken: _taken, ...kept } = this.state;
9505
+ await this.persist({
9506
+ ...kept,
9507
+ wasLive: true
9508
+ });
9509
+ if (taken) this.changed();
9510
+ }).catch(() => {});
9416
9511
  if (this.options.credentials.peerKey && this.state.peerMode) this.tick();
9417
9512
  else this.schedule();
9418
9513
  }
@@ -9724,7 +9819,8 @@ var DhtDelivery = class DhtDelivery {
9724
9819
  const receipt = this.state.receipt && this.state.receipt.expires > now && this.state.receipt.attempts < MAX_ATTEMPTS ? this.state.receipt : void 0;
9725
9820
  const receiptDue = !!receipt && !receipt.settled && (receipt.next ?? 0) <= now;
9726
9821
  const reseal = this.resealDue;
9727
- if (!force && (!pending || pending.next > now) && !receiptDue && !this.reactionsDue && !reseal && this.controlDue > now) return;
9822
+ const keepAlive = !force && (!pending || pending.next > now) && !receiptDue && !this.reactionsDue && !reseal;
9823
+ if (keepAlive && this.controlDue > now) return;
9728
9824
  const expires = pending?.expires ?? now - 12e4 + CONTROL_TTL;
9729
9825
  const issued = Math.min(now, Math.max(now - DHT_ISSUED_BACK_MS, expires - (pending ? DHT_MESSAGE_TTL : CONTROL_TTL), (pending?.message[1] ?? 0) - 3e4));
9730
9826
  const { body, records, reactions } = this.fitted(this.state.sequence + 1, issued, expires, pending?.message ?? null, receipt?.id ?? null, pending?.reply, pending?.edit, pending?.forwarded, pending?.file);
@@ -9758,7 +9854,7 @@ var DhtDelivery = class DhtDelivery {
9758
9854
  });
9759
9855
  if (pending) this.lastPublished = DhtDelivery.facts(body, records, identity.pubKeyZ32);
9760
9856
  try {
9761
- await this.options.transport.publish(identity, records);
9857
+ await this.options.transport.publish(identity, records, keepAlive && this.live ? { background: true } : void 0);
9762
9858
  } catch (error) {
9763
9859
  this.reactionsDue ||= reactionsWereDue;
9764
9860
  if (isDiscoveryBudgetError(error)) await this.heldBack(error, now, before, pending, receipt);
@@ -9868,14 +9964,15 @@ var DhtDelivery = class DhtDelivery {
9868
9964
  }
9869
9965
  /**
9870
9966
  * The contact's envelope is sealed to another key (`open`): it pinned someone else who used the same invite first, so
9871
- * nothing of this side's will ever be read. Said once this side is not proven the one it pinned (never seen in the
9872
- * pinned mailbox, no text confirmed, never live). The hint is sealed under the invite's key, which any copy of it
9873
- * holds: a passive warning, taken back by the contact's next envelope that opens here.
9967
+ * nothing of this side's will ever be read. Said only while this side is not proven the one it pinned: no envelope
9968
+ * sealed to this side read (`peerPinned`), no text confirmed, never live (`wasLive`). The hint is sealed under the
9969
+ * invite's key, which any copy of it holds, so past that proof it says nothing of the contact: a passive warning,
9970
+ * taken back by the contact's next envelope that opens here, or by a live session.
9874
9971
  */
9875
9972
  async sealedToAnother(sender) {
9876
9973
  const expected = this.options.credentials.peerKey ?? this.options.credentials.expectedPeerKey;
9877
9974
  if (this.state.inviteTaken || !expected || sender !== expected) return;
9878
- if (this.state.peerPinned === "seen" || this.state.confirmed || this.live) return;
9975
+ if (this.state.peerPinned || this.state.confirmed || this.state.wasLive || this.live) return;
9879
9976
  traceLink(this.from, "invite-taken", {});
9880
9977
  await this.persist({
9881
9978
  ...this.state,
@@ -10419,6 +10516,43 @@ function irohDescriptor(descriptor, spelling = "dotted") {
10419
10516
  };
10420
10517
  }
10421
10518
  /**
10519
+ * A relay server's URL, short: https, or wss for a browser's relay; plain http or ws only on this machine's loopback,
10520
+ * as a test relay is (the same rule as the relay settings).
10521
+ */
10522
+ var RELAY_URL = /^((https|wss):\/\/[^\s]{1,120}|(http|ws):\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d{1,5})?(\/[^\s]{0,100})?)$/;
10523
+ var HEX_KEY = /^[0-9a-f]{64}$/;
10524
+ /** An Iroh direct address as the adapters parse it: an IPv4 or bracketed IPv6 address and its port. */
10525
+ var SOCKET_ADDRESS = /^(\d{1,3}(\.\d{1,3}){3}|\[[0-9A-Fa-f:.]{2,45}(%[A-Za-z0-9._-]{1,16})?\]):\d{1,5}$/;
10526
+ var MAX_ADDRESSES = 8;
10527
+ /**
10528
+ * A contact's native descriptors as it tells them on a session (`paired-policy`, `paired-adapters`) or as this app
10529
+ * kept them: only what the adapters dial, each field in the shape its adapter makes. The keys are 64 hex characters,
10530
+ * a relay passes `RELAY_URL` (the rule its capability record is read by), an Iroh descriptor lists at most eight
10531
+ * socket addresses. A descriptor that is anything else is left out, as one the contact never told; a field this app
10532
+ * does not know is not kept.
10533
+ */
10534
+ function checkedDescriptors(value) {
10535
+ const out = {};
10536
+ if (!value || typeof value !== "object" || Array.isArray(value)) return out;
10537
+ const raw = value;
10538
+ const relay = (v) => typeof v === "string" && RELAY_URL.test(v);
10539
+ const relayed = (v) => v === void 0 || typeof v === "boolean";
10540
+ const iroh = raw["iroh/1"];
10541
+ if (iroh && typeof iroh === "object" && typeof iroh.id === "string" && HEX_KEY.test(iroh.id) && (iroh.relay === void 0 || iroh.relay === null || relay(iroh.relay)) && relayed(iroh.relayed) && Array.isArray(iroh.addresses) && iroh.addresses.length <= MAX_ADDRESSES && iroh.addresses.every((a) => typeof a === "string" && SOCKET_ADDRESS.test(a))) out["iroh/1"] = {
10542
+ id: iroh.id,
10543
+ relay: iroh.relay ?? null,
10544
+ addresses: [...iroh.addresses],
10545
+ ...iroh.relayed !== void 0 ? { relayed: iroh.relayed } : {}
10546
+ };
10547
+ const hyper = raw["hyperdht/1"];
10548
+ if (hyper && typeof hyper === "object" && typeof hyper.publicKey === "string" && HEX_KEY.test(hyper.publicKey) && (hyper.relay === void 0 || relay(hyper.relay)) && relayed(hyper.relayed)) out["hyperdht/1"] = {
10549
+ publicKey: hyper.publicKey,
10550
+ ...hyper.relay !== void 0 ? { relay: hyper.relay } : {},
10551
+ ...hyper.relayed !== void 0 ? { relayed: hyper.relayed } : {}
10552
+ };
10553
+ return out;
10554
+ }
10555
+ /**
10422
10556
  * Relay only (WISP 100, "Relayed"): the transport reaches this contact only through a relay server, never
10423
10557
  * directly. A descriptor says so with `relayed: true`, and an Iroh descriptor with no direct address is relayed
10424
10558
  * too (a browser's, WISP 102). Either side being relay only makes the whole path relayed. Both sides hold both
@@ -10458,15 +10592,13 @@ function parsePolicy(raw) {
10458
10592
  if (!raw || typeof raw !== "object") return null;
10459
10593
  const p = raw;
10460
10594
  if (!Number.isSafeInteger(p.revision) || p.revision < 0 || !Number.isSafeInteger(p.intent) || p.intent < 0 || !TRANSPORTS.includes(p.preferred) || typeof p.fallback !== "boolean" || !Array.isArray(p.available) || p.available.length > 3 || new Set(p.available).size !== p.available.length || p.available.some((t) => !TRANSPORTS.includes(t)) || !p.descriptors || typeof p.descriptors !== "object" || JSON.stringify(p.descriptors).length > 4096) return null;
10461
- const descriptors = {};
10462
- for (const t of ["iroh/1", "hyperdht/1"]) if (p.descriptors[t]) descriptors[t] = p.descriptors[t];
10463
10595
  return {
10464
10596
  revision: p.revision,
10465
10597
  intent: p.intent,
10466
10598
  preferred: p.preferred,
10467
10599
  fallback: p.fallback,
10468
10600
  available: [...p.available],
10469
- descriptors
10601
+ descriptors: checkedDescriptors(p.descriptors)
10470
10602
  };
10471
10603
  }
10472
10604
  /** Runs only inside a participation-authenticated channel. Both roles may propose;
@@ -12042,11 +12174,6 @@ var HEX64$1 = /^[0-9a-f]{64}$/;
12042
12174
  var SIG$4 = /^[A-Za-z0-9_-]{86}$/;
12043
12175
  var Z32$2 = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/;
12044
12176
  var KEY$1 = /^[A-Za-z0-9_-]{43}$/;
12045
- /**
12046
- * A relay server's URL, short: https, or wss for a browser's relay; plain http or ws only on this machine's loopback,
12047
- * as a test relay is (the same rule as the relay settings).
12048
- */
12049
- var RELAY = /^((https|wss):\/\/[^\s]{1,120}|(http|ws):\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d{1,5})?(\/[^\s]{0,100})?)$/;
12050
12177
  var hexToKey = (hex) => toBase64Url(Uint8Array.from(hex.match(/../g), (b) => parseInt(b, 16)));
12051
12178
  var keyToHex = (key) => Array.from(fromBase64Url(key), (b) => b.toString(16).padStart(2, "0")).join("");
12052
12179
  /**
@@ -12058,12 +12185,12 @@ function capsDescriptors(descriptors) {
12058
12185
  const iroh = descriptors?.["iroh/1"];
12059
12186
  if (iroh && typeof iroh.id === "string" && HEX64$1.test(iroh.id)) out["iroh/1"] = {
12060
12187
  id: hexToKey(iroh.id),
12061
- ...typeof iroh.relay === "string" && RELAY.test(iroh.relay) ? { relay: iroh.relay } : {}
12188
+ ...typeof iroh.relay === "string" && RELAY_URL.test(iroh.relay) ? { relay: iroh.relay } : {}
12062
12189
  };
12063
12190
  const hyper = descriptors?.["hyperdht/1"];
12064
12191
  if (hyper && typeof hyper.publicKey === "string" && HEX64$1.test(hyper.publicKey)) out["hyperdht/1"] = {
12065
12192
  publicKey: hexToKey(hyper.publicKey),
12066
- ...typeof hyper.relay === "string" && RELAY.test(hyper.relay) ? { relay: hyper.relay } : {}
12193
+ ...typeof hyper.relay === "string" && RELAY_URL.test(hyper.relay) ? { relay: hyper.relay } : {}
12067
12194
  };
12068
12195
  return out;
12069
12196
  }
@@ -12098,14 +12225,14 @@ function parseDescriptors(value) {
12098
12225
  const out = {};
12099
12226
  const iroh = raw["iroh/1"], hyper = raw["hyperdht/1"];
12100
12227
  if (iroh !== void 0) {
12101
- if (!iroh || typeof iroh.id !== "string" || !KEY$1.test(iroh.id) || iroh.relay !== void 0 && (typeof iroh.relay !== "string" || !RELAY.test(iroh.relay))) return null;
12228
+ if (!iroh || typeof iroh.id !== "string" || !KEY$1.test(iroh.id) || iroh.relay !== void 0 && (typeof iroh.relay !== "string" || !RELAY_URL.test(iroh.relay))) return null;
12102
12229
  out["iroh/1"] = {
12103
12230
  id: iroh.id,
12104
12231
  ...iroh.relay !== void 0 ? { relay: iroh.relay } : {}
12105
12232
  };
12106
12233
  }
12107
12234
  if (hyper !== void 0) {
12108
- if (!hyper || typeof hyper.publicKey !== "string" || !KEY$1.test(hyper.publicKey) || hyper.relay !== void 0 && (typeof hyper.relay !== "string" || !RELAY.test(hyper.relay))) return null;
12235
+ if (!hyper || typeof hyper.publicKey !== "string" || !KEY$1.test(hyper.publicKey) || hyper.relay !== void 0 && (typeof hyper.relay !== "string" || !RELAY_URL.test(hyper.relay))) return null;
12109
12236
  out["hyperdht/1"] = {
12110
12237
  publicKey: hyper.publicKey,
12111
12238
  ...hyper.relay !== void 0 ? { relay: hyper.relay } : {}
@@ -12568,373 +12695,654 @@ async function fitSignedPairedSignalWith(signal, signer, from, to, fits) {
12568
12695
  }
12569
12696
  }
12570
12697
  //#endregion
12571
- //#region ../core/src/storeForward.ts
12572
- /**
12573
- * Store-and-forward for an away contact (WISP 404 draft, `hold/1`).
12574
- *
12575
- * What a person sends while the contact is away is sealed into a *bundle* (signed by the sender's
12576
- * participation key, then encrypted to a key only the two of them can derive), put in the sender's
12577
- * own storage (WISP 1000), and named in a *manifest* the storage also holds. A small signed, encrypted
12578
- * *pointer* record on a per-link Pkarr key tells the contact where the manifest is (a URL that expires)
12579
- * and which of the contact's own bundles have been received. Storage and relays see ciphertext, sizes
12580
- * and timing only. Nothing here promises forward secrecy or retention beyond the bounds below.
12581
- */
12582
- var HOLD_CAPABILITY = "hold/1";
12583
- var HOLD_LIMITS = {
12584
- /** One bundle, ciphertext and header included: a picture, not a video. */
12585
- maxBundleBytes: 8388608,
12586
- /** All bundles held for one contact at once. */
12587
- maxMailboxBytes: 67108864,
12588
- maxMailboxBundles: 64,
12589
- /** A bundle not picked up by then is dropped by its sender. The longest a presigned URL lives, too. */
12590
- ttlMs: 6048e5,
12591
- maxTextBytes: 16384,
12592
- maxPaymentRequestBytes: 65536,
12593
- maxManifestBytes: 65536,
12594
- /**
12595
- * How far past one lifetime from now an expiry may reach by the reader's clock: the sender's clock may be ahead, and
12596
- * a day covers any clock that is merely wrong. Nothing else about a pointer or a bundle is held against this clock.
12597
- */
12598
- clockSkewMs: 864e5
12599
- };
12600
- /**
12601
- * How far before its own clock a sender dates its pointer and its manifest, and ends the lifetimes it gives. A reader
12602
- * refuses nothing for those dates: `rev` orders pointers and `seq` orders items, both kept by the reader. Apps up to
12603
- * 1.0.2 refuse a pointer or a manifest dated more than a minute past their clock, and an expiry more than a minute
12604
- * past one lifetime from now, each without a word: the items of a sender whose clock ran two minutes fast were never
12605
- * fetched. Dated back, a sender up to about eleven minutes ahead is read by them.
12606
- */
12607
- var HOLD_DATED_BACK_MS = 6e5;
12608
- var KINDS$1 = [
12609
- "text",
12610
- "file",
12611
- "pay-req",
12612
- "manifest"
12698
+ //#region ../core/src/domainProofs.ts
12699
+ var DNS_LABEL = "_ghostly";
12700
+ var WELL_KNOWN_PATH = "/.well-known/ghostly.json";
12701
+ var NIP05_PATH = "/.well-known/nostr.json?name=_";
12702
+ /** Everything read from a domain is bounded; these match the fetch limits. */
12703
+ var DOMAIN_FILE_MAX_BYTES = 16384;
12704
+ var z32 = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/;
12705
+ var hex64 = /^[a-f0-9]{64}$/;
12706
+ /** Names that never resolve publicly (RFC 6761, 6762, 7686, 8375, 9476) or that
12707
+ * only ever mean a private network. A proof for them could only point inward. */
12708
+ var SPECIAL_USE = [
12709
+ "localhost",
12710
+ "local",
12711
+ "internal",
12712
+ "lan",
12713
+ "home",
12714
+ "corp",
12715
+ "intranet",
12716
+ "private",
12717
+ "test",
12718
+ "example",
12719
+ "invalid",
12720
+ "onion",
12721
+ "alt",
12722
+ "arpa"
12613
12723
  ];
12614
- var WIRE_ID = /^[A-Za-z0-9_-]{8,64}$/;
12615
- var MAILBOX = /^[A-Za-z0-9_-]{22}$/;
12616
- var SIG$3 = /^[A-Za-z0-9_-]{86}$/;
12617
- var MAGIC$2 = utf8Encode("GHLD");
12618
- var VERSION = 1;
12619
- var NONCE$2 = 24;
12620
- /** Why a bundle, manifest or pointer was refused. Never a reason to trust it a little. */
12621
- var HoldRefusedError = class extends Error {
12622
- reason;
12623
- constructor(reason, message) {
12624
- super(message);
12625
- this.reason = reason;
12626
- }
12627
- };
12628
- var bodyDigest = (body) => toBase64Url(sha256(body));
12629
- var newMailbox = () => toBase64Url(randomBytes(16)).slice(0, 22);
12630
12724
  /**
12631
- * Everything derived once per link for this feature: my pointer key, the contact's pointer address and
12632
- * the key that seals bundles and pointers between the two participation keys. Needs a pinned contact.
12725
+ * Normalizes what a person typed ("Example.COM.", "https://example.com/") into
12726
+ * a lowercase ASCII host name, or throws with a message fit for the UI.
12727
+ * Internationalized names become punycode (xn--), the form DNS carries.
12633
12728
  */
12634
- var HoldKeys = class {
12635
- from;
12636
- to;
12637
- me;
12638
- peer;
12639
- /** Publishes my pointer. */
12640
- identity;
12641
- /** Where the contact publishes its pointer. */
12642
- peerAddress;
12643
- seed;
12644
- sealKey;
12645
- constructor(params, participationSeedB64, peerKey) {
12646
- this.from = identityFromSeedB64(params.seedB64).pubKeyZ32;
12647
- this.to = params.peerPubKeyZ32;
12648
- const mine = identityFromSeedB64(participationSeedB64);
12649
- this.seed = mine.seed;
12650
- this.me = mine.pubKeyZ32;
12651
- this.peer = peerKey;
12652
- const secret = fromBase64Url(params.encKeyB64);
12653
- const context = JSON.stringify(["ghostly-hold/1", [this.from, this.to].sort()]);
12654
- const derive = (label) => hkdf(sha256, secret, utf8Encode(context), utf8Encode(label), 32);
12655
- this.identity = identityFromSeed(derive(`pointer:${this.from}`));
12656
- this.peerAddress = identityFromSeed(derive(`pointer:${this.to}`)).pubKeyZ32;
12657
- const shared = x25519.getSharedSecret(ed25519.utils.toMontgomerySecret(this.seed), ed25519.utils.toMontgomery(publicKeyFromZ32(peerKey)));
12658
- this.sealKey = hkdf(sha256, shared, derive("envelope"), utf8Encode("ghostly-hold-envelope/1"), 32);
12659
- }
12660
- /** Seals one item for the contact: signed with my participation key, encrypted to our shared key. */
12661
- seal(item, body) {
12662
- const fields = [
12663
- VERSION,
12664
- this.from,
12665
- this.to,
12666
- this.me,
12667
- this.peer,
12668
- item.mailbox,
12669
- item.seq,
12670
- item.id,
12671
- item.ts,
12672
- item.kind,
12673
- item.meta ?? null,
12674
- bodyDigest(body),
12675
- item.expires
12676
- ];
12677
- const signature = toBase64Url(sign(utf8Encode(JSON.stringify(["ghostly-hold-bundle", fields])), this.seed));
12678
- const header = utf8Encode(JSON.stringify([fields, signature]));
12679
- const length = /* @__PURE__ */ new Uint8Array(4);
12680
- new DataView(length.buffer).setUint32(0, header.length);
12681
- const nonce = randomBytes(NONCE$2);
12682
- const box = xsalsa20poly1305(this.sealKey, nonce).encrypt(concatBytes$1(length, header, body));
12683
- return concatBytes$1(MAGIC$2, new Uint8Array([VERSION]), nonce, box);
12684
- }
12685
- /**
12686
- * Opens a bundle the contact sealed for me. Everything is checked before anything is returned: the
12687
- * authenticated encryption, the header's shape, that it names this link, this contact and me, the
12688
- * signature, the body digest and the limits of its kind.
12689
- */
12690
- open(bytes, expect = {}) {
12691
- const now = expect.now ?? Date.now();
12692
- if (bytes.length > (expect.maxBytes ?? HOLD_LIMITS.maxBundleBytes)) throw new HoldRefusedError("size", "The held item is larger than allowed");
12693
- 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");
12694
- if (bytes[MAGIC$2.length] !== VERSION) throw new HoldRefusedError("format", "Held item from a newer Ghostly");
12695
- let plain;
12696
- try {
12697
- 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));
12698
- } catch {
12699
- throw new HoldRefusedError("tampered", "The held item was changed or is not for this chat");
12700
- }
12701
- if (plain.length < 4) throw new HoldRefusedError("format", "Not a held item");
12702
- const headerLength = new DataView(plain.buffer, plain.byteOffset, 4).getUint32(0);
12703
- if (headerLength > 131072 || 4 + headerLength > plain.length) throw new HoldRefusedError("format", "Not a held item");
12704
- let parsed;
12729
+ function normalizeDomain(input) {
12730
+ if (typeof input !== "string") throw new Error("Enter a domain name, like example.com");
12731
+ let value = input.trim();
12732
+ if (!value || value.length > 300) throw new Error("Enter a domain name, like example.com");
12733
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
12734
+ let url;
12705
12735
  try {
12706
- parsed = JSON.parse(utf8Decode(plain.subarray(4, 4 + headerLength)));
12736
+ url = new URL(value);
12707
12737
  } catch {
12708
- throw new HoldRefusedError("format", "Not a held item");
12738
+ throw new Error("Enter a domain name, like example.com");
12709
12739
  }
12710
- if (!Array.isArray(parsed) || parsed.length !== 2 || !Array.isArray(parsed[0]) || parsed[0].length !== 13 || typeof parsed[1] !== "string" || !SIG$3.test(parsed[1])) throw new HoldRefusedError("format", "Not a held item");
12711
- const [fields, signature] = parsed;
12712
- const [v, from, to, author, recipient, mailbox, seq, id, ts, kind, meta, digest, expires] = fields;
12713
- if (v !== VERSION || typeof from !== "string" || typeof to !== "string" || typeof author !== "string" || typeof recipient !== "string" || typeof mailbox !== "string" || !MAILBOX.test(mailbox) || !Number.isSafeInteger(seq) || seq < 0 || typeof id !== "string" || !WIRE_ID.test(id) || !Number.isSafeInteger(ts) || ts <= 0 || typeof kind !== "string" || !KINDS$1.includes(kind) || typeof digest !== "string" || !Number.isSafeInteger(expires)) throw new HoldRefusedError("format", "Not a held item");
12714
- if (from !== this.to || to !== this.from || recipient !== this.me) throw new HoldRefusedError("not-for-me", "The held item is for another chat");
12715
- if (author !== this.peer) throw new HoldRefusedError("author", "The held item was not signed by this contact");
12716
- if (expect.mailbox && mailbox !== expect.mailbox) throw new HoldRefusedError("not-for-me", "The held item is from another mailbox");
12717
- let valid;
12740
+ if (url.protocol !== "https:" || url.username || url.password || url.port || url.pathname !== "/" && url.pathname !== "" || url.search || url.hash) throw new Error("Enter only the domain name, without a path or port");
12741
+ value = url.hostname;
12742
+ } else {
12743
+ if (/[/:@?#\s\\]/.test(value)) throw new Error("Enter only the domain name, without a path or port");
12718
12744
  try {
12719
- valid = verify(fromBase64Url(signature), utf8Encode(JSON.stringify(["ghostly-hold-bundle", fields])), publicKeyFromZ32(author));
12745
+ value = new URL(`https://${value}`).hostname;
12720
12746
  } catch {
12721
- valid = false;
12747
+ throw new Error("That is not a valid domain name");
12722
12748
  }
12723
- if (!valid) throw new HoldRefusedError("signature", "The held item's signature does not check out");
12724
- const body = plain.subarray(4 + headerLength);
12725
- if (bodyDigest(body) !== digest) throw new HoldRefusedError("digest", "The held item's content does not match its header");
12726
- if (expires <= ts || expires > now + HOLD_LIMITS.ttlMs + HOLD_LIMITS.clockSkewMs) throw new HoldRefusedError("limits", "The held item's lifetime is out of bounds");
12727
- const header = {
12728
- v: 1,
12729
- from,
12730
- to,
12731
- author,
12732
- recipient,
12733
- mailbox,
12734
- seq,
12735
- id,
12736
- ts,
12737
- kind,
12738
- meta,
12739
- digest,
12740
- expires
12741
- };
12742
- checkKind(header, body);
12743
- return {
12744
- header,
12745
- body
12746
- };
12747
12749
  }
12748
- /** My pointer, as records to publish under `identity`. Throws when it cannot fit a packet. */
12749
- pointerRecords(pointer, now = Date.now()) {
12750
- const body = [
12751
- VERSION,
12752
- pointer.rev,
12753
- pointer.issued,
12754
- pointer.expires,
12755
- pointer.manifestUrl,
12756
- pointer.top,
12757
- pointer.ack,
12758
- pointer.count,
12759
- pointer.bytes,
12760
- pointer.refused.slice(-32)
12761
- ];
12762
- const signature = toBase64Url(sign(utf8Encode(JSON.stringify([
12763
- "ghostly-hold-pointer",
12764
- this.from,
12765
- this.to,
12766
- body
12767
- ])), this.seed));
12768
- const ttl = Math.min(86400, Math.max(60, Math.ceil((pointer.expires - now) / 1e3)));
12769
- const records = [{
12770
- label: "_hold",
12771
- value: encrypt(JSON.stringify([body, signature]), this.sealKey),
12772
- ttl
12773
- }];
12774
- if (measureRecords(this.identity.pubKeyZ32, records) > 1e3) throw new Error("The storage address is too long for a DHT packet. Use a shorter endpoint or prefix.");
12775
- return records;
12776
- }
12777
- /**
12778
- * The contact's pointer out of its packet, or null when the packet is not one of its pointers. Its `issued` is the
12779
- * contact's clock and refuses nothing: an older pointer cannot take a newer one's place because of `rev`.
12780
- */
12781
- readPointer(packet) {
12782
- if (packet.pubKeyZ32 !== this.peerAddress || measureRecords(packet.pubKeyZ32, packet.records) > 1e3) return null;
12783
- const records = packet.records.filter((r) => r.label === "_hold");
12784
- if (records.length !== 1) return null;
12785
- const plain = tryDecrypt(records[0].value, this.sealKey);
12786
- if (!plain) return null;
12787
- let parsed;
12788
- try {
12789
- parsed = JSON.parse(plain);
12790
- } catch {
12791
- return null;
12792
- }
12793
- if (!Array.isArray(parsed) || parsed.length !== 2 || !Array.isArray(parsed[0]) || parsed[0].length !== 9 && parsed[0].length !== 10 || typeof parsed[1] !== "string" || !SIG$3.test(parsed[1])) return null;
12794
- const [body, signature] = parsed;
12795
- const [v, rev, issued, expires, manifestUrl, top, ack, count, bytes, refused = []] = body;
12796
- if (v !== VERSION || ![
12797
- rev,
12798
- issued,
12799
- expires,
12800
- top,
12801
- ack,
12802
- count,
12803
- bytes
12804
- ].every((n) => Number.isSafeInteger(n) && n >= 0) || manifestUrl !== null && (typeof manifestUrl !== "string" || manifestUrl.length > 2048) || !Array.isArray(refused) || refused.length > 32 || !refused.every((n) => Number.isSafeInteger(n) && n > 0)) return null;
12805
- try {
12806
- if (!verify(fromBase64Url(signature), utf8Encode(JSON.stringify([
12807
- "ghostly-hold-pointer",
12808
- this.to,
12809
- this.from,
12810
- body
12811
- ])), publicKeyFromZ32(this.peer))) return null;
12812
- } catch {
12813
- return null;
12814
- }
12815
- return {
12816
- rev,
12817
- issued,
12818
- expires,
12819
- manifestUrl,
12820
- top,
12821
- ack,
12822
- count,
12823
- bytes,
12824
- refused
12825
- };
12826
- }
12827
- };
12828
- /** A URL a bundle or manifest may be fetched from: HTTPS, or HTTP to this machine while testing. */
12829
- function isHoldUrl(value) {
12830
- if (typeof value !== "string" || value.length > 2048) return false;
12750
+ value = value.toLowerCase().replace(/\.$/, "");
12751
+ if (value.length > 244) throw new Error("That domain name is too long");
12752
+ const labels = value.split(".");
12753
+ if (labels.length < 2) throw new Error("Use a full domain name, like example.com");
12754
+ for (const label of labels) if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) throw new Error("That is not a valid domain name");
12755
+ const tld = labels[labels.length - 1];
12756
+ if (/^[0-9]+$/.test(tld) || /^\[/.test(value)) throw new Error("Use a domain name, not an IP address");
12757
+ if (SPECIAL_USE.includes(tld) || labels.length >= 2 && labels.slice(-2).join(".") === "home.arpa") throw new Error("That name only works on private networks");
12758
+ return value;
12759
+ }
12760
+ var dnsProofName = (domain) => `${DNS_LABEL}.${normalizeDomain(domain)}`;
12761
+ var wellKnownUrl = (domain, method = "https") => `https://${normalizeDomain(domain)}${method === "nip05" ? NIP05_PATH : WELL_KNOWN_PATH}`;
12762
+ function validDomainRecord(r) {
12763
+ if (!r || typeof r.key !== "string" || typeof r.proof !== "string" || !z32.test(r.key) || !hex64.test(r.proof)) return false;
12831
12764
  try {
12832
- const url = new URL(value);
12833
- return url.protocol === "https:" || url.protocol === "http:" && [
12834
- "localhost",
12835
- "127.0.0.1",
12836
- "[::1]"
12837
- ].includes(url.hostname);
12765
+ return toZ32(fromZ32(r.key)) === r.key;
12838
12766
  } catch {
12839
12767
  return false;
12840
12768
  }
12841
12769
  }
12842
- /** The limits of each kind, on the way in. */
12843
- function checkKind(header, body) {
12844
- const bad = (message) => new HoldRefusedError("limits", message);
12845
- switch (header.kind) {
12846
- case "text":
12847
- if (body.length > HOLD_LIMITS.maxTextBytes) throw bad("The held text is too long");
12848
- try {
12849
- utf8Decode(body);
12850
- } catch {
12851
- throw bad("The held text is not text");
12852
- }
12853
- return;
12854
- case "pay-req":
12855
- if (body.length > HOLD_LIMITS.maxPaymentRequestBytes) throw bad("The held payment request is too large");
12856
- return;
12857
- case "file": {
12858
- const meta = header.meta;
12859
- if (!meta || typeof meta !== "object" || typeof meta.name !== "string" || meta.name.length > 255 || typeof meta.mime !== "string" || meta.mime.length > 255 || meta.size !== body.length) throw bad("The held file does not match its description");
12860
- return;
12861
- }
12862
- case "manifest":
12863
- if (body.length !== 0) throw bad("A manifest carries no body");
12864
- readManifest(header.meta);
12865
- return;
12770
+ /** The TXT record value, in the `tag=value; …` style people know from DKIM. */
12771
+ function domainTxtRecord(r) {
12772
+ if (!validDomainRecord(r)) throw new Error("Invalid domain record");
12773
+ return `v=ghostly1; key=${r.key}; proof=${r.proof}`;
12774
+ }
12775
+ /** Parses one TXT string. Undefined for anything that is not ours or not exactly
12776
+ * well-formed; unknown extra tags are ignored for forward compatibility. */
12777
+ function parseDomainTxt(value) {
12778
+ if (typeof value !== "string" || utf8Encode(value).length > 1024) return;
12779
+ const tags = /* @__PURE__ */ new Map();
12780
+ const parts = value.split(";").map((p) => p.trim()).filter(Boolean);
12781
+ for (const part of parts) {
12782
+ const eq = part.indexOf("=");
12783
+ if (eq < 1) return;
12784
+ const name = part.slice(0, eq).trim().toLowerCase();
12785
+ if (tags.has(name)) return;
12786
+ tags.set(name, part.slice(eq + 1).trim());
12787
+ }
12788
+ if (!parts[0]?.toLowerCase().startsWith("v=") || tags.get("v") !== "ghostly1") return;
12789
+ const r = {
12790
+ key: tags.get("key"),
12791
+ proof: tags.get("proof")
12792
+ };
12793
+ return validDomainRecord(r) ? r : void 0;
12794
+ }
12795
+ var addRecord = (list, r) => {
12796
+ if (!list.some((x) => x.key === r.key && x.proof === r.proof)) list.push(r);
12797
+ };
12798
+ /** All records among the TXT strings of `_ghostly.<domain>`; other strings are skipped. */
12799
+ function recordsFromTxt(values) {
12800
+ const out = [];
12801
+ for (const value of values.slice(0, 32)) {
12802
+ const r = parseDomainTxt(value);
12803
+ if (r) addRecord(out, r);
12866
12804
  }
12805
+ return out.slice(0, 8);
12867
12806
  }
12868
- /** The entries of a manifest, checked: strictly increasing sequences, bounded sizes, fetchable URLs. */
12869
- function readManifest(meta) {
12870
- const bad = (message) => new HoldRefusedError("limits", message);
12871
- const entries = meta?.entries;
12872
- if (!Array.isArray(entries) || entries.length > HOLD_LIMITS.maxMailboxBundles) throw bad("The manifest lists too many items");
12873
- let last = 0, total = 0;
12807
+ /** The body of `/.well-known/ghostly.json`. */
12808
+ function domainWellKnownFile(records) {
12809
+ if (!records.length || records.length > 8 || !records.every(validDomainRecord)) throw new Error("Invalid domain records");
12810
+ return JSON.stringify({
12811
+ ghostly: 1,
12812
+ proofs: records.map((r) => ({
12813
+ key: r.key,
12814
+ proof: r.proof
12815
+ }))
12816
+ }, null, 2) + "\n";
12817
+ }
12818
+ function parseJson(text) {
12819
+ if (typeof text !== "string" || utf8Encode(text).length > 16384) throw new Error("File too large");
12820
+ return JSON.parse(text);
12821
+ }
12822
+ /** Records from `ghostly.json`. Throws on a malformed file; [] when it names none. */
12823
+ function recordsFromWellKnown(text) {
12824
+ const body = parseJson(text);
12825
+ if (!body || typeof body !== "object" || Array.isArray(body) || body.ghostly !== 1 || !Array.isArray(body.proofs)) throw new Error("Not a Ghostly domain file");
12874
12826
  const out = [];
12875
- for (const entry of entries) {
12876
- if (!Array.isArray(entry) || entry.length !== 6) throw bad("The manifest is malformed");
12877
- const [seq, id, kind, bytes, url, expires] = entry;
12878
- if (!Number.isSafeInteger(seq) || seq <= last || typeof id !== "string" || !WIRE_ID.test(id) || typeof kind !== "string" || !KINDS$1.includes(kind) || kind === "manifest" || !Number.isSafeInteger(bytes) || bytes < 0 || bytes > HOLD_LIMITS.maxBundleBytes || !isHoldUrl(url) || !Number.isSafeInteger(expires)) throw bad("The manifest is malformed");
12879
- last = seq;
12880
- total += bytes;
12881
- if (total > HOLD_LIMITS.maxMailboxBytes) throw bad("The manifest lists more than a mailbox may hold");
12882
- out.push([
12883
- seq,
12884
- id,
12885
- kind,
12886
- bytes,
12887
- url,
12888
- expires
12889
- ]);
12827
+ for (const entry of body.proofs.slice(0, 32)) {
12828
+ if (!entry || typeof entry !== "object") continue;
12829
+ const r = {
12830
+ key: entry.key,
12831
+ proof: entry.proof
12832
+ };
12833
+ if (validDomainRecord(r)) addRecord(out, r);
12890
12834
  }
12891
- return out;
12835
+ return out.slice(0, 8);
12892
12836
  }
12893
- //#endregion
12894
- //#region ../core/src/pairedSession.ts
12895
- /**
12896
- * What this app does that grants nothing, said outside `capabilities`: apps before 0.5 accept at most 16
12897
- * of those, and a full offer is already there. The field is not in the transcript (an app that does not
12898
- * know it could not sign it), so nothing that needs authenticating may go here; the DTLS binding of the
12899
- * transcript still ties it to this connection. `ping/1`: this app answers `paired-ping`, from the open.
12900
- */
12901
- var OFFER_EXTENSIONS = ["ping/1"];
12902
- var MAX_HANDSHAKE_BYTES = 4096;
12903
- /**
12904
- * Frames received and not handled yet, at most: their count and their size together. Frames are handled one at a time,
12905
- * each awaited (a group frame is decrypted, checked and stored before the next), and the peer sends at the network's
12906
- * pace. A member back in a group answers a sync with a burst: a commit per epoch, its kept messages, the ones it hands
12907
- * on, edits, reactions. With a bound of 64 frames a phone handling them a few milliseconds each failed its session
12908
- * 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
12909
- * (64 frames of 60 KiB); the count only bounds a flood of tiny frames.
12910
- */
12911
- var SESSION_RECEIVE_PENDING = {
12912
- frames: 1024,
12913
- bytes: 3932160
12837
+ /** NIP-05: only the root name `_` speaks for the domain itself. Undefined when absent. */
12838
+ function nip05RootKey(text) {
12839
+ const body = parseJson(text);
12840
+ if (!body || typeof body !== "object" || !body.names || typeof body.names !== "object" || Array.isArray(body.names)) throw new Error("Not a NIP-05 file");
12841
+ const root = body.names["_"];
12842
+ return typeof root === "string" && hex64.test(root) ? root : void 0;
12843
+ }
12844
+ var recordsName = (records, r) => records.some((x) => x.key === r.key && x.proof === r.proof);
12845
+ var DNS_TYPE = {
12846
+ A: 1,
12847
+ CNAME: 5,
12848
+ TXT: 16,
12849
+ AAAA: 28
12914
12850
  };
12915
- var KEY = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/;
12916
- var NONCE$1 = /^[A-Za-z0-9_-]{43}$/;
12917
- var SIG$2 = /^[A-Za-z0-9_-]{86}$/;
12918
- var FINGERPRINT = /^[a-f0-9]{64}$/;
12919
- var isList = (v, maximum = 8) => Array.isArray(v) && v.length > 0 && v.length <= maximum && v.every((x) => typeof x === "string" && /^[a-z0-9/-]{1,40}$/.test(x)) && new Set(v).size === v.length;
12920
- function parseOffer(v) {
12921
- if (!Array.isArray(v.versions) || v.versions.length === 0 || v.versions.length > 8 || !v.versions.every((n) => Number.isSafeInteger(n) && n > 0) || new Set(v.versions).size !== v.versions.length || !isList(v.transports) || !isList(v.capabilities, 32) || typeof v.key !== "string" || !KEY.test(v.key) || typeof v.nonce !== "string" || !NONCE$1.test(v.nonce)) return null;
12922
- const extensions = isList(v.extensions, 32) ? v.extensions : void 0;
12923
- return {
12924
- t: "pair-offer",
12925
- versions: v.versions,
12926
- transports: v.transports,
12927
- capabilities: v.capabilities,
12928
- ...extensions && { extensions },
12929
- key: v.key,
12930
- nonce: v.nonce
12931
- };
12851
+ /** A recursive query for one name and type. ID 0, as RFC 8484 recommends for caching;
12852
+ * the AD bit asks the resolver to report DNSSEC validation (RFC 6840 §5.7). */
12853
+ function encodeDnsQuery(name, type) {
12854
+ const labels = name.replace(/\.$/, "").split(".");
12855
+ const parts = [Uint8Array.of(0, 0, 1, 32, 0, 1, 0, 0, 0, 0, 0, 0)];
12856
+ for (const label of labels) {
12857
+ const bytes = utf8Encode(label);
12858
+ if (!bytes.length || bytes.length > 63) throw new Error("Invalid DNS name");
12859
+ parts.push(Uint8Array.of(bytes.length), bytes);
12860
+ }
12861
+ const code = DNS_TYPE[type];
12862
+ parts.push(Uint8Array.of(0, code >> 8, code & 255, 0, 1));
12863
+ return concatBytes$1(...parts);
12932
12864
  }
12933
- var offerTuple = (o) => [
12934
- o.key,
12935
- o.nonce,
12936
- o.versions,
12937
- o.transports,
12865
+ function readName(data, start) {
12866
+ const labels = [];
12867
+ let pos = start, next = -1, jumps = 0, length = 0;
12868
+ for (;;) {
12869
+ if (pos >= data.length) throw new Error("DNS name out of bounds");
12870
+ const len = data[pos];
12871
+ if ((len & 192) === 192) {
12872
+ if (pos + 1 >= data.length) throw new Error("DNS pointer out of bounds");
12873
+ if (next < 0) next = pos + 2;
12874
+ pos = (len & 63) << 8 | data[pos + 1];
12875
+ if (++jumps > 32) throw new Error("DNS name loops");
12876
+ continue;
12877
+ }
12878
+ if (len & 192) throw new Error("Unsupported DNS label");
12879
+ pos += 1;
12880
+ if (len === 0) break;
12881
+ if (pos + len > data.length) throw new Error("DNS label out of bounds");
12882
+ length += len + 1;
12883
+ if (length > 255) throw new Error("DNS name too long");
12884
+ labels.push(utf8Decode(data.subarray(pos, pos + len)).toLowerCase());
12885
+ pos += len;
12886
+ }
12887
+ return {
12888
+ name: labels.join("."),
12889
+ next: next < 0 ? pos : next
12890
+ };
12891
+ }
12892
+ /**
12893
+ * Decodes a resolver's answer to `encodeDnsQuery(name, type)`. Refuses a reply
12894
+ * to another question or a truncated one, and keeps only records of the asked
12895
+ * type owned by the name or by the CNAME chain the answer itself follows.
12896
+ */
12897
+ function decodeDnsResponse(data, name, type) {
12898
+ if (data.length < 12) throw new Error("DNS response too short");
12899
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
12900
+ const flags = view.getUint16(2);
12901
+ if (!(flags & 32768) || flags & 30720 || flags & 512) throw new Error("Unusable DNS response");
12902
+ const questions = view.getUint16(4), count = view.getUint16(6);
12903
+ const want = name.replace(/\.$/, "").toLowerCase(), code = DNS_TYPE[type];
12904
+ if (questions !== 1) throw new Error("DNS response is for another question");
12905
+ const q = readName(data, 12);
12906
+ if (q.name !== want || q.next + 4 > data.length || view.getUint16(q.next) !== code) throw new Error("DNS response is for another question");
12907
+ let pos = q.next + 4;
12908
+ const all = [];
12909
+ for (let i = 0; i < count && i < 64; i++) {
12910
+ const owner = readName(data, pos);
12911
+ pos = owner.next;
12912
+ if (pos + 10 > data.length) throw new Error("DNS record out of bounds");
12913
+ const rtype = view.getUint16(pos), ttl = view.getUint32(pos + 4), len = view.getUint16(pos + 8);
12914
+ pos += 10;
12915
+ if (pos + len > data.length) throw new Error("DNS record out of bounds");
12916
+ const rdata = rtype === DNS_TYPE.CNAME ? utf8Encode(readName(data, pos).name) : data.slice(pos, pos + len);
12917
+ all.push({
12918
+ name: owner.name,
12919
+ type: rtype,
12920
+ ttl,
12921
+ data: rdata
12922
+ });
12923
+ pos += len;
12924
+ }
12925
+ const owners = /* @__PURE__ */ new Set([want]);
12926
+ for (let hop = 0; hop < 8; hop++) {
12927
+ const cname = all.find((a) => a.type === DNS_TYPE.CNAME && owners.has(a.name) && !owners.has(utf8Decode(a.data)));
12928
+ if (!cname) break;
12929
+ owners.add(utf8Decode(cname.data));
12930
+ }
12931
+ return {
12932
+ rcode: flags & 15,
12933
+ authenticated: !!(flags & 32),
12934
+ answers: all.filter((a) => a.type === code && owners.has(a.name))
12935
+ };
12936
+ }
12937
+ /** The TXT character-strings of one record, joined as RFC 7208 §3.3 does. */
12938
+ function txtValue(rdata) {
12939
+ const parts = [];
12940
+ for (let p = 0; p < rdata.length;) {
12941
+ const len = rdata[p];
12942
+ if (p + 1 + len > rdata.length) throw new Error("DNS text out of bounds");
12943
+ parts.push(rdata.subarray(p + 1, p + 1 + len));
12944
+ p += 1 + len;
12945
+ }
12946
+ return new TextDecoder("utf-8", { fatal: true }).decode(concatBytes$1(...parts));
12947
+ }
12948
+ /** False for loopback, private, link-local, shared (CGNAT), multicast, reserved
12949
+ * and unspecified addresses, including IPv4 embedded in IPv6. */
12950
+ function isPublicAddress(address) {
12951
+ if (address.length === 4) {
12952
+ const [a, b, c] = address;
12953
+ return !(a === 0 || a === 10 || a === 127 || a >= 224 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 192 && b === 0 && c === 0 || a === 198 && (b === 18 || b === 19));
12954
+ }
12955
+ if (address.length !== 16) return false;
12956
+ const zeroTo = (n) => address.subarray(0, n).every((x) => x === 0);
12957
+ if (zeroTo(15) && (address[15] === 0 || address[15] === 1)) return false;
12958
+ if (zeroTo(10) && address[10] === 255 && address[11] === 255) return isPublicAddress(address.subarray(12));
12959
+ if (address[0] === 0 && address[1] === 100 && address[2] === 255 && address[3] === 155 && address.subarray(4, 12).every((x) => x === 0)) return isPublicAddress(address.subarray(12));
12960
+ if (zeroTo(12)) return false;
12961
+ if ((address[0] & 254) === 252) return false;
12962
+ if (address[0] === 254 && (address[1] & 192) === 128) return false;
12963
+ if (address[0] === 254 && (address[1] & 192) === 192) return false;
12964
+ if (address[0] === 255) return false;
12965
+ if (address[0] === 1 && address.subarray(1, 8).every((x) => x === 0)) return false;
12966
+ return (address[0] & 224) === 32;
12967
+ }
12968
+ //#endregion
12969
+ //#region ../core/src/storeForward.ts
12970
+ /**
12971
+ * Store-and-forward for an away contact (WISP 404 draft, `hold/1`).
12972
+ *
12973
+ * What a person sends while the contact is away is sealed into a *bundle* (signed by the sender's
12974
+ * participation key, then encrypted to a key only the two of them can derive), put in the sender's
12975
+ * own storage (WISP 1000), and named in a *manifest* the storage also holds. A small signed, encrypted
12976
+ * *pointer* record on a per-link Pkarr key tells the contact where the manifest is (a URL that expires)
12977
+ * and which of the contact's own bundles have been received. Storage and relays see ciphertext, sizes
12978
+ * and timing only. Nothing here promises forward secrecy or retention beyond the bounds below.
12979
+ */
12980
+ var HOLD_CAPABILITY = "hold/1";
12981
+ var HOLD_LIMITS = {
12982
+ /** One bundle, ciphertext and header included: a picture, not a video. */
12983
+ maxBundleBytes: 8388608,
12984
+ /** All bundles held for one contact at once. */
12985
+ maxMailboxBytes: 67108864,
12986
+ maxMailboxBundles: 64,
12987
+ /** A bundle not picked up by then is dropped by its sender. The longest a presigned URL lives, too. */
12988
+ ttlMs: 6048e5,
12989
+ maxTextBytes: 16384,
12990
+ maxPaymentRequestBytes: 65536,
12991
+ maxManifestBytes: 65536,
12992
+ /**
12993
+ * How far past one lifetime from now an expiry may reach by the reader's clock: the sender's clock may be ahead, and
12994
+ * a day covers any clock that is merely wrong. Nothing else about a pointer or a bundle is held against this clock.
12995
+ */
12996
+ clockSkewMs: 864e5
12997
+ };
12998
+ /**
12999
+ * How far before its own clock a sender dates its pointer and its manifest, and ends the lifetimes it gives. A reader
13000
+ * refuses nothing for those dates: `rev` orders pointers and `seq` orders items, both kept by the reader. Apps up to
13001
+ * 1.0.2 refuse a pointer or a manifest dated more than a minute past their clock, and an expiry more than a minute
13002
+ * past one lifetime from now, each without a word: the items of a sender whose clock ran two minutes fast were never
13003
+ * fetched. Dated back, a sender up to about eleven minutes ahead is read by them.
13004
+ */
13005
+ var HOLD_DATED_BACK_MS = 6e5;
13006
+ var KINDS$1 = [
13007
+ "text",
13008
+ "file",
13009
+ "pay-req",
13010
+ "manifest"
13011
+ ];
13012
+ var WIRE_ID = /^[A-Za-z0-9_-]{8,64}$/;
13013
+ var MAILBOX = /^[A-Za-z0-9_-]{22}$/;
13014
+ var SIG$3 = /^[A-Za-z0-9_-]{86}$/;
13015
+ var MAGIC$2 = utf8Encode("GHLD");
13016
+ var VERSION = 1;
13017
+ var NONCE$2 = 24;
13018
+ /** Why a bundle, manifest or pointer was refused. Never a reason to trust it a little. */
13019
+ var HoldRefusedError = class extends Error {
13020
+ reason;
13021
+ constructor(reason, message) {
13022
+ super(message);
13023
+ this.reason = reason;
13024
+ }
13025
+ };
13026
+ var bodyDigest = (body) => toBase64Url(sha256(body));
13027
+ var newMailbox = () => toBase64Url(randomBytes(16)).slice(0, 22);
13028
+ /**
13029
+ * Everything derived once per link for this feature: my pointer key, the contact's pointer address and
13030
+ * the key that seals bundles and pointers between the two participation keys. Needs a pinned contact.
13031
+ */
13032
+ var HoldKeys = class {
13033
+ from;
13034
+ to;
13035
+ me;
13036
+ peer;
13037
+ /** Publishes my pointer. */
13038
+ identity;
13039
+ /** Where the contact publishes its pointer. */
13040
+ peerAddress;
13041
+ seed;
13042
+ sealKey;
13043
+ constructor(params, participationSeedB64, peerKey) {
13044
+ this.from = identityFromSeedB64(params.seedB64).pubKeyZ32;
13045
+ this.to = params.peerPubKeyZ32;
13046
+ const mine = identityFromSeedB64(participationSeedB64);
13047
+ this.seed = mine.seed;
13048
+ this.me = mine.pubKeyZ32;
13049
+ this.peer = peerKey;
13050
+ const secret = fromBase64Url(params.encKeyB64);
13051
+ const context = JSON.stringify(["ghostly-hold/1", [this.from, this.to].sort()]);
13052
+ const derive = (label) => hkdf(sha256, secret, utf8Encode(context), utf8Encode(label), 32);
13053
+ this.identity = identityFromSeed(derive(`pointer:${this.from}`));
13054
+ this.peerAddress = identityFromSeed(derive(`pointer:${this.to}`)).pubKeyZ32;
13055
+ const shared = x25519.getSharedSecret(ed25519.utils.toMontgomerySecret(this.seed), ed25519.utils.toMontgomery(publicKeyFromZ32(peerKey)));
13056
+ this.sealKey = hkdf(sha256, shared, derive("envelope"), utf8Encode("ghostly-hold-envelope/1"), 32);
13057
+ }
13058
+ /** Seals one item for the contact: signed with my participation key, encrypted to our shared key. */
13059
+ seal(item, body) {
13060
+ const fields = [
13061
+ VERSION,
13062
+ this.from,
13063
+ this.to,
13064
+ this.me,
13065
+ this.peer,
13066
+ item.mailbox,
13067
+ item.seq,
13068
+ item.id,
13069
+ item.ts,
13070
+ item.kind,
13071
+ item.meta ?? null,
13072
+ bodyDigest(body),
13073
+ item.expires
13074
+ ];
13075
+ const signature = toBase64Url(sign(utf8Encode(JSON.stringify(["ghostly-hold-bundle", fields])), this.seed));
13076
+ const header = utf8Encode(JSON.stringify([fields, signature]));
13077
+ const length = /* @__PURE__ */ new Uint8Array(4);
13078
+ new DataView(length.buffer).setUint32(0, header.length);
13079
+ const nonce = randomBytes(NONCE$2);
13080
+ const box = xsalsa20poly1305(this.sealKey, nonce).encrypt(concatBytes$1(length, header, body));
13081
+ return concatBytes$1(MAGIC$2, new Uint8Array([VERSION]), nonce, box);
13082
+ }
13083
+ /**
13084
+ * Opens a bundle the contact sealed for me. Everything is checked before anything is returned: the
13085
+ * authenticated encryption, the header's shape, that it names this link, this contact and me, the
13086
+ * signature, the body digest and the limits of its kind. `loopback`: see `isHoldUrl`.
13087
+ */
13088
+ open(bytes, expect = {}) {
13089
+ const now = expect.now ?? Date.now();
13090
+ if (bytes.length > (expect.maxBytes ?? HOLD_LIMITS.maxBundleBytes)) throw new HoldRefusedError("size", "The held item is larger than allowed");
13091
+ 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");
13092
+ if (bytes[MAGIC$2.length] !== VERSION) throw new HoldRefusedError("format", "Held item from a newer Ghostly");
13093
+ let plain;
13094
+ try {
13095
+ 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));
13096
+ } catch {
13097
+ throw new HoldRefusedError("tampered", "The held item was changed or is not for this chat");
13098
+ }
13099
+ if (plain.length < 4) throw new HoldRefusedError("format", "Not a held item");
13100
+ const headerLength = new DataView(plain.buffer, plain.byteOffset, 4).getUint32(0);
13101
+ if (headerLength > 131072 || 4 + headerLength > plain.length) throw new HoldRefusedError("format", "Not a held item");
13102
+ let parsed;
13103
+ try {
13104
+ parsed = JSON.parse(utf8Decode(plain.subarray(4, 4 + headerLength)));
13105
+ } catch {
13106
+ throw new HoldRefusedError("format", "Not a held item");
13107
+ }
13108
+ if (!Array.isArray(parsed) || parsed.length !== 2 || !Array.isArray(parsed[0]) || parsed[0].length !== 13 || typeof parsed[1] !== "string" || !SIG$3.test(parsed[1])) throw new HoldRefusedError("format", "Not a held item");
13109
+ const [fields, signature] = parsed;
13110
+ const [v, from, to, author, recipient, mailbox, seq, id, ts, kind, meta, digest, expires] = fields;
13111
+ if (v !== VERSION || typeof from !== "string" || typeof to !== "string" || typeof author !== "string" || typeof recipient !== "string" || typeof mailbox !== "string" || !MAILBOX.test(mailbox) || !Number.isSafeInteger(seq) || seq < 0 || typeof id !== "string" || !WIRE_ID.test(id) || !Number.isSafeInteger(ts) || ts <= 0 || typeof kind !== "string" || !KINDS$1.includes(kind) || typeof digest !== "string" || !Number.isSafeInteger(expires)) throw new HoldRefusedError("format", "Not a held item");
13112
+ if (from !== this.to || to !== this.from || recipient !== this.me) throw new HoldRefusedError("not-for-me", "The held item is for another chat");
13113
+ if (author !== this.peer) throw new HoldRefusedError("author", "The held item was not signed by this contact");
13114
+ if (expect.mailbox && mailbox !== expect.mailbox) throw new HoldRefusedError("not-for-me", "The held item is from another mailbox");
13115
+ let valid;
13116
+ try {
13117
+ valid = verify(fromBase64Url(signature), utf8Encode(JSON.stringify(["ghostly-hold-bundle", fields])), publicKeyFromZ32(author));
13118
+ } catch {
13119
+ valid = false;
13120
+ }
13121
+ if (!valid) throw new HoldRefusedError("signature", "The held item's signature does not check out");
13122
+ const body = plain.subarray(4 + headerLength);
13123
+ if (bodyDigest(body) !== digest) throw new HoldRefusedError("digest", "The held item's content does not match its header");
13124
+ if (expires <= ts || expires > now + HOLD_LIMITS.ttlMs + HOLD_LIMITS.clockSkewMs) throw new HoldRefusedError("limits", "The held item's lifetime is out of bounds");
13125
+ const header = {
13126
+ v: 1,
13127
+ from,
13128
+ to,
13129
+ author,
13130
+ recipient,
13131
+ mailbox,
13132
+ seq,
13133
+ id,
13134
+ ts,
13135
+ kind,
13136
+ meta,
13137
+ digest,
13138
+ expires
13139
+ };
13140
+ checkKind(header, body, expect.loopback);
13141
+ return {
13142
+ header,
13143
+ body
13144
+ };
13145
+ }
13146
+ /** My pointer, as records to publish under `identity`. Throws when it cannot fit a packet. */
13147
+ pointerRecords(pointer, now = Date.now()) {
13148
+ const body = [
13149
+ VERSION,
13150
+ pointer.rev,
13151
+ pointer.issued,
13152
+ pointer.expires,
13153
+ pointer.manifestUrl,
13154
+ pointer.top,
13155
+ pointer.ack,
13156
+ pointer.count,
13157
+ pointer.bytes,
13158
+ pointer.refused.slice(-32)
13159
+ ];
13160
+ const signature = toBase64Url(sign(utf8Encode(JSON.stringify([
13161
+ "ghostly-hold-pointer",
13162
+ this.from,
13163
+ this.to,
13164
+ body
13165
+ ])), this.seed));
13166
+ const ttl = Math.min(86400, Math.max(60, Math.ceil((pointer.expires - now) / 1e3)));
13167
+ const records = [{
13168
+ label: "_hold",
13169
+ value: encrypt(JSON.stringify([body, signature]), this.sealKey),
13170
+ ttl
13171
+ }];
13172
+ if (measureRecords(this.identity.pubKeyZ32, records) > 1e3) throw new Error("The storage address is too long for a DHT packet. Use a shorter endpoint or prefix.");
13173
+ return records;
13174
+ }
13175
+ /**
13176
+ * The contact's pointer out of its packet, or null when the packet is not one of its pointers. Its `issued` is the
13177
+ * contact's clock and refuses nothing: an older pointer cannot take a newer one's place because of `rev`. The
13178
+ * manifest's address is held to what an item's is (`isHoldUrl`): it is fetched as soon as the pointer is read.
13179
+ */
13180
+ readPointer(packet, loopback = false) {
13181
+ if (packet.pubKeyZ32 !== this.peerAddress || measureRecords(packet.pubKeyZ32, packet.records) > 1e3) return null;
13182
+ const records = packet.records.filter((r) => r.label === "_hold");
13183
+ if (records.length !== 1) return null;
13184
+ const plain = tryDecrypt(records[0].value, this.sealKey);
13185
+ if (!plain) return null;
13186
+ let parsed;
13187
+ try {
13188
+ parsed = JSON.parse(plain);
13189
+ } catch {
13190
+ return null;
13191
+ }
13192
+ if (!Array.isArray(parsed) || parsed.length !== 2 || !Array.isArray(parsed[0]) || parsed[0].length !== 9 && parsed[0].length !== 10 || typeof parsed[1] !== "string" || !SIG$3.test(parsed[1])) return null;
13193
+ const [body, signature] = parsed;
13194
+ const [v, rev, issued, expires, manifestUrl, top, ack, count, bytes, refused = []] = body;
13195
+ if (v !== VERSION || ![
13196
+ rev,
13197
+ issued,
13198
+ expires,
13199
+ top,
13200
+ ack,
13201
+ count,
13202
+ bytes
13203
+ ].every((n) => Number.isSafeInteger(n) && n >= 0) || manifestUrl !== null && !isHoldUrl(manifestUrl, loopback) || !Array.isArray(refused) || refused.length > 32 || !refused.every((n) => Number.isSafeInteger(n) && n > 0)) return null;
13204
+ try {
13205
+ if (!verify(fromBase64Url(signature), utf8Encode(JSON.stringify([
13206
+ "ghostly-hold-pointer",
13207
+ this.to,
13208
+ this.from,
13209
+ body
13210
+ ])), publicKeyFromZ32(this.peer))) return null;
13211
+ } catch {
13212
+ return null;
13213
+ }
13214
+ return {
13215
+ rev,
13216
+ issued,
13217
+ expires,
13218
+ manifestUrl,
13219
+ top,
13220
+ ack,
13221
+ count,
13222
+ bytes,
13223
+ refused
13224
+ };
13225
+ }
13226
+ };
13227
+ /**
13228
+ * A URL a bundle or manifest may be fetched from. The contact chose it and this device fetches it on its own, so it
13229
+ * is HTTPS, without a user or password, at a name or a public IPv4 address: never this machine or its network.
13230
+ * `loopback` adds plain HTTP to this machine, for a test against a local server; no shipped app passes it.
13231
+ */
13232
+ function isHoldUrl(value, loopback = false) {
13233
+ if (typeof value !== "string" || value.length > 2048) return false;
13234
+ let url;
13235
+ try {
13236
+ url = new URL(value);
13237
+ } catch {
13238
+ return false;
13239
+ }
13240
+ if (url.username || url.password) return false;
13241
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
13242
+ if (loopback && url.protocol === "http:" && [
13243
+ "localhost",
13244
+ "127.0.0.1",
13245
+ "[::1]"
13246
+ ].includes(host)) return true;
13247
+ if (url.protocol !== "https:" || !host || host.startsWith("[") || host === "localhost" || host.endsWith(".localhost")) return false;
13248
+ return !/^\d+\.\d+\.\d+\.\d+$/.test(host) || isPublicAddress(Uint8Array.from(host.split("."), Number));
13249
+ }
13250
+ /** The limits of each kind, on the way in. */
13251
+ function checkKind(header, body, loopback = false) {
13252
+ const bad = (message) => new HoldRefusedError("limits", message);
13253
+ switch (header.kind) {
13254
+ case "text":
13255
+ if (body.length > HOLD_LIMITS.maxTextBytes) throw bad("The held text is too long");
13256
+ try {
13257
+ utf8Decode(body);
13258
+ } catch {
13259
+ throw bad("The held text is not text");
13260
+ }
13261
+ return;
13262
+ case "pay-req":
13263
+ if (body.length > HOLD_LIMITS.maxPaymentRequestBytes) throw bad("The held payment request is too large");
13264
+ return;
13265
+ case "file": {
13266
+ const meta = header.meta;
13267
+ if (!meta || typeof meta !== "object" || typeof meta.name !== "string" || meta.name.length > 255 || typeof meta.mime !== "string" || meta.mime.length > 255 || meta.size !== body.length) throw bad("The held file does not match its description");
13268
+ return;
13269
+ }
13270
+ case "manifest":
13271
+ if (body.length !== 0) throw bad("A manifest carries no body");
13272
+ readManifest(header.meta, loopback);
13273
+ return;
13274
+ }
13275
+ }
13276
+ /** The entries of a manifest, checked: strictly increasing sequences, bounded sizes, fetchable URLs. */
13277
+ function readManifest(meta, loopback = false) {
13278
+ const bad = (message) => new HoldRefusedError("limits", message);
13279
+ const entries = meta?.entries;
13280
+ if (!Array.isArray(entries) || entries.length > HOLD_LIMITS.maxMailboxBundles) throw bad("The manifest lists too many items");
13281
+ let last = 0, total = 0;
13282
+ const out = [];
13283
+ for (const entry of entries) {
13284
+ if (!Array.isArray(entry) || entry.length !== 6) throw bad("The manifest is malformed");
13285
+ const [seq, id, kind, bytes, url, expires] = entry;
13286
+ if (!Number.isSafeInteger(seq) || seq <= last || typeof id !== "string" || !WIRE_ID.test(id) || typeof kind !== "string" || !KINDS$1.includes(kind) || kind === "manifest" || !Number.isSafeInteger(bytes) || bytes < 0 || bytes > HOLD_LIMITS.maxBundleBytes || !isHoldUrl(url, loopback) || !Number.isSafeInteger(expires)) throw bad("The manifest is malformed");
13287
+ last = seq;
13288
+ total += bytes;
13289
+ if (total > HOLD_LIMITS.maxMailboxBytes) throw bad("The manifest lists more than a mailbox may hold");
13290
+ out.push([
13291
+ seq,
13292
+ id,
13293
+ kind,
13294
+ bytes,
13295
+ url,
13296
+ expires
13297
+ ]);
13298
+ }
13299
+ return out;
13300
+ }
13301
+ //#endregion
13302
+ //#region ../core/src/pairedSession.ts
13303
+ /**
13304
+ * What this app does that grants nothing, said outside `capabilities`: apps before 0.5 accept at most 16
13305
+ * of those, and a full offer is already there. The field is not in the transcript (an app that does not
13306
+ * know it could not sign it), so nothing that needs authenticating may go here; the DTLS binding of the
13307
+ * transcript still ties it to this connection. `ping/1`: this app answers `paired-ping`, from the open.
13308
+ */
13309
+ var OFFER_EXTENSIONS = ["ping/1"];
13310
+ var MAX_HANDSHAKE_BYTES = 4096;
13311
+ /**
13312
+ * Frames received and not handled yet, at most: their count and their size together. Frames are handled one at a time,
13313
+ * each awaited (a group frame is decrypted, checked and stored before the next), and the peer sends at the network's
13314
+ * pace. A member back in a group answers a sync with a burst: a commit per epoch, its kept messages, the ones it hands
13315
+ * on, edits, reactions. With a bound of 64 frames a phone handling them a few milliseconds each failed its session
13316
+ * 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
13317
+ * (64 frames of 60 KiB); the count only bounds a flood of tiny frames.
13318
+ */
13319
+ var SESSION_RECEIVE_PENDING = {
13320
+ frames: 1024,
13321
+ bytes: 3932160
13322
+ };
13323
+ var KEY = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/;
13324
+ var NONCE$1 = /^[A-Za-z0-9_-]{43}$/;
13325
+ var SIG$2 = /^[A-Za-z0-9_-]{86}$/;
13326
+ var FINGERPRINT = /^[a-f0-9]{64}$/;
13327
+ var isList = (v, maximum = 8) => Array.isArray(v) && v.length > 0 && v.length <= maximum && v.every((x) => typeof x === "string" && /^[a-z0-9/-]{1,40}$/.test(x)) && new Set(v).size === v.length;
13328
+ function parseOffer(v) {
13329
+ if (!Array.isArray(v.versions) || v.versions.length === 0 || v.versions.length > 8 || !v.versions.every((n) => Number.isSafeInteger(n) && n > 0) || new Set(v.versions).size !== v.versions.length || !isList(v.transports) || !isList(v.capabilities, 32) || typeof v.key !== "string" || !KEY.test(v.key) || typeof v.nonce !== "string" || !NONCE$1.test(v.nonce)) return null;
13330
+ const extensions = isList(v.extensions, 32) ? v.extensions : void 0;
13331
+ return {
13332
+ t: "pair-offer",
13333
+ versions: v.versions,
13334
+ transports: v.transports,
13335
+ capabilities: v.capabilities,
13336
+ ...extensions && { extensions },
13337
+ key: v.key,
13338
+ nonce: v.nonce
13339
+ };
13340
+ }
13341
+ var offerTuple = (o) => [
13342
+ o.key,
13343
+ o.nonce,
13344
+ o.versions,
13345
+ o.transports,
12938
13346
  o.capabilities
12939
13347
  ];
12940
13348
  var PairedSession = class {
@@ -14271,6 +14679,8 @@ var TypingReceiver = class {
14271
14679
  * receiver says where it stands (accept from an offset, wait, done, refuse) and the sender goes on from
14272
14680
  * there. Nothing but the offset a receiver confirms moves a sender forward. A receiver may say `pf-accept`
14273
14681
  * again at any time (nothing arrived for a while, or its person asked again): the sender goes on from that offset.
14682
+ * What a sender sends again that way is bounded (`FILE_LIMITS.resendBytes`, and the host's `resend`): past it the
14683
+ * sender ends the transfer.
14274
14684
  */
14275
14685
  var FILE_LIMITS = {
14276
14686
  /** Bytes in one `pf-data` frame: a frame stays far below the 60 KiB a session carries. */
@@ -14304,7 +14714,12 @@ var FILE_LIMITS = {
14304
14714
  /** A sender reads its file this much at a time. */
14305
14715
  readBytes: 1048576,
14306
14716
  /** The session refused a data frame (its send budget was full): the sender looks again after this long. */
14307
- sendRetryMs: 250
14717
+ sendRetryMs: 250,
14718
+ /**
14719
+ * What a sender sends of one file beyond its size (parts sent again after a drop, a restart of the receiver, or a
14720
+ * `pf-accept` from an earlier offset): twice the file's size and this much. Past it the sender ends the transfer (WISP 501 § Resume).
14721
+ */
14722
+ resendBytes: 268435456
14308
14723
  };
14309
14724
  var ID = /^[A-Za-z0-9_-]{8,64}$/;
14310
14725
  var DIGEST$1 = /^[A-Za-z0-9_-]{43}$/;
@@ -14390,6 +14805,7 @@ var ChatFiles = class {
14390
14805
  const entry = { record: { ...record } };
14391
14806
  if (record.direction === "out") entry.out = {
14392
14807
  next: record.confirmed,
14808
+ sent: 0,
14393
14809
  generation: 0
14394
14810
  };
14395
14811
  else entry.in = {
@@ -14421,6 +14837,10 @@ var ChatFiles = class {
14421
14837
  const entry = this.entries.get(key(direction, id));
14422
14838
  return entry ? this.moved(entry) : 0;
14423
14839
  }
14840
+ /** Bytes of data sent for a file so far, parts sent again included: what sending it cost, whatever the receiver confirmed. */
14841
+ sent(id) {
14842
+ return this.entries.get(key("out", id))?.out?.sent ?? 0;
14843
+ }
14424
14844
  moved(entry) {
14425
14845
  return entry.in && !transferEnded(entry.record) ? Math.max(entry.in.stored, entry.record.confirmed) : entry.record.confirmed;
14426
14846
  }
@@ -14501,6 +14921,7 @@ var ChatFiles = class {
14501
14921
  },
14502
14922
  out: {
14503
14923
  next: 0,
14924
+ sent: 0,
14504
14925
  generation: 0
14505
14926
  }
14506
14927
  };
@@ -14574,6 +14995,15 @@ var ChatFiles = class {
14574
14995
  const { record } = entry, out = entry.out;
14575
14996
  return this.attached && record.state === "active" && out.next < record.file.size && out.next - record.confirmed < FILE_LIMITS.windowBytes;
14576
14997
  }
14998
+ /**
14999
+ * Whether the next `length` bytes go. Within the file's size they always do; past it the receiver is having parts
15000
+ * sent again, which nothing it says bounds, so this side does.
15001
+ */
15002
+ mayResend(entry, length) {
15003
+ const { record } = entry, size = record.file.size, sent = entry.out.sent + length;
15004
+ if (sent <= size) return true;
15005
+ return sent - size <= 2 * size + FILE_LIMITS.resendBytes && this.host.resend?.({ ...record }, sent) !== false;
15006
+ }
14577
15007
  async pump(entry) {
14578
15008
  const out = entry.out;
14579
15009
  if (out.pumping === out.generation) return;
@@ -14587,6 +15017,14 @@ var ChatFiles = class {
14587
15017
  const room = this.host.writable?.();
14588
15018
  if (room) await room;
14589
15019
  if (generation !== out.generation || !this.shouldPump(entry) || out.next !== offset) break;
15020
+ if (!this.mayResend(entry, chunk.length)) {
15021
+ this.send({
15022
+ t: "pf-abort",
15023
+ id: entry.record.id
15024
+ });
15025
+ this.end(entry, "failed", "Your contact asked for the same part of this file too many times");
15026
+ break;
15027
+ }
14590
15028
  if (!this.send({
14591
15029
  t: "pf-data",
14592
15030
  id: entry.record.id,
@@ -14597,6 +15035,7 @@ var ChatFiles = class {
14597
15035
  break;
14598
15036
  }
14599
15037
  out.next = offset + chunk.length;
15038
+ out.sent += chunk.length;
14600
15039
  }
14601
15040
  } catch (error) {
14602
15041
  if (generation === out.generation) {
@@ -15305,6 +15744,7 @@ var ChatFiles = class {
15305
15744
  clearTimeout(entry.out.wake);
15306
15745
  entry.out = {
15307
15746
  next: 0,
15747
+ sent: 0,
15308
15748
  generation: entry.out.generation + 1
15309
15749
  };
15310
15750
  this.changed(entry);
@@ -16098,7 +16538,7 @@ var GROUP_FILE_LIMITS = {
16098
16538
  serveAtOnce: 3,
16099
16539
  /** File announcements a member sends to one group in a minute. */
16100
16540
  announcePerMinute: 8,
16101
- /** File announcements taken from one member of a group in `announceWindowMs`; past it, shown as text, unfetched. */
16541
+ /** File announcements of one member of a group fetched by themselves in `announceWindowMs`; past it, kept for a Download. */
16102
16542
  announceTaken: 30,
16103
16543
  announceWindowMs: 6e5,
16104
16544
  /** Holders remembered per file. */
@@ -16569,6 +17009,7 @@ var RTC_UNSTARTED_MS = 6e5;
16569
17009
  var RACE_RETRY_MS = 2e3;
16570
17010
  var DEMOTE_MS = 36e5;
16571
17011
  var AUTO_CONNECT_MAX_RETRY_MS = 18e4;
17012
+ var ANSWERS_REFUSED_PAUSE_MS = 2e3;
16572
17013
  /**
16573
17014
  * A first pairing is different: the contact is right there, having just read the invite, so an attempt
16574
17015
  * that did not make it in `PAIRING_ATTEMPT_MS` is given up and the next one starts after `PAIRING_RETRY_MS`
@@ -16844,6 +17285,9 @@ var GhostLink = class {
16844
17285
  afterRtc;
16845
17286
  /** When the transports after WebRTC are dialled while its offer is still out (`race`). */
16846
17287
  raceTimer = null;
17288
+ /** Pauses taken after a run of refused answers since WebRTC last opened, and the one under way (`ANSWERS_REFUSED_PAUSES`). */
17289
+ refusedPauses = 0;
17290
+ refusedPause = null;
16847
17291
  /** Set while `race` dials: the offer ending meanwhile is followed up by the race. */
16848
17292
  racing = false;
16849
17293
  /**
@@ -17052,7 +17496,7 @@ var GhostLink = class {
17052
17496
  }
17053
17497
  }) : null;
17054
17498
  this.streamWasBlocked = this.streamBlocked;
17055
- this.peerDescriptors = options.native?.peerDescriptors ?? {};
17499
+ this.peerDescriptors = checkedDescriptors(options.native?.peerDescriptors);
17056
17500
  this.peerTransports = options.native?.peerTransports;
17057
17501
  this.saidTransports = !!options.packetTransports && !!options.packetTransportsSaid;
17058
17502
  this.peerPacketSaid = !!options.packetTransports && !!options.native?.peerTransports;
@@ -17068,6 +17512,7 @@ var GhostLink = class {
17068
17512
  nick: options.nick,
17069
17513
  lastSeenTimestamp: options.lastSeenTimestamp,
17070
17514
  pollIntervals: options.pollIntervals,
17515
+ awaitingSince: options.madeAt,
17071
17516
  getServices: options.getServices,
17072
17517
  firstPublish: options.firstPublish ?? (this.tracker && options.pairingProgress?.role === "joiner" && options.rtcAvailable !== false || this.resuming ? "after-first-poll" : "at-start"),
17073
17518
  events: {
@@ -17181,6 +17626,7 @@ var GhostLink = class {
17181
17626
  offerStanding: (ts) => !this.leaving && !this.stopped && !this.streamBlocked && this.peerOfferTs() === ts,
17182
17627
  onAnswerReplaced: () => this.redial(),
17183
17628
  onDirect: (evidence) => events.onDirectEvidence?.(evidence),
17629
+ onAnswersRefused: () => this.answersRefused(),
17184
17630
  onOpen: (channel) => {
17185
17631
  if (this.streamBlocked || this.keyStopped) {
17186
17632
  channel.close();
@@ -17203,7 +17649,10 @@ var GhostLink = class {
17203
17649
  this.trackDataLink(state);
17204
17650
  if (this.activeBinding) return;
17205
17651
  events.onDataLinkState?.(state);
17206
- if (state === "open") this.afterRtc = void 0;
17652
+ if (state === "open") {
17653
+ this.afterRtc = void 0;
17654
+ this.refusedPauses = 0;
17655
+ }
17207
17656
  if (state === "offering") {
17208
17657
  this.offerUnsent = true;
17209
17658
  this.dialUnseen = false;
@@ -17614,6 +18063,7 @@ var GhostLink = class {
17614
18063
  async stop(announce = true) {
17615
18064
  this.stopped = true;
17616
18065
  this.clearRace();
18066
+ if (this.refusedPause) clearTimeout(this.refusedPause);
17617
18067
  if (this.retryLook) clearTimeout(this.retryLook);
17618
18068
  if (this.dhtPinGrace) clearTimeout(this.dhtPinGrace);
17619
18069
  if (this.waiting?.timer) clearTimeout(this.waiting.timer);
@@ -19018,6 +19468,24 @@ var GhostLink = class {
19018
19468
  });
19019
19469
  this.autoDial();
19020
19470
  }
19471
+ /** WebRTC here refused a run of answers to this side's offers (`ANSWERS_REFUSED_PAUSES`): the next offer goes after a short pause. */
19472
+ answersRefused() {
19473
+ if (this.refusedPause || this.stopped) return;
19474
+ if (this.refusedPauses >= 3) {
19475
+ traceLink(this.myPubKeyZ32, "answers-refused-out", {});
19476
+ this.options.events?.onAnswersRefused?.();
19477
+ return;
19478
+ }
19479
+ const ms = ANSWERS_REFUSED_PAUSE_MS * 2 ** this.refusedPauses++;
19480
+ traceLink(this.myPubKeyZ32, "answers-refused-pause", {
19481
+ pause: this.refusedPauses,
19482
+ ms
19483
+ });
19484
+ this.refusedPause = setTimeout(() => {
19485
+ this.refusedPause = null;
19486
+ this.redial();
19487
+ }, ms);
19488
+ }
19021
19489
  /**
19022
19490
  * A failed dial waits `dialWait` before the next, and only a look at the contact's record dials: the next one came at
19023
19491
  * the link's own pace (30 s in the background), which knows nothing of the backoff. A member let in to a group
@@ -20318,11 +20786,7 @@ var GhostLink = class {
20318
20786
  }
20319
20787
  if (frame?.t === "paired-adapters") {
20320
20788
  if (!frame.descriptors || typeof frame.descriptors !== "object" || JSON.stringify(frame.descriptors).length > 4096) return;
20321
- const descriptors = {};
20322
- for (const transport of ["iroh/1", "hyperdht/1"]) {
20323
- const value = frame.descriptors[transport];
20324
- if (value) descriptors[transport] = value;
20325
- }
20789
+ const descriptors = checkedDescriptors(frame.descriptors);
20326
20790
  const transports = [...this.paired?.peerTransports ?? []];
20327
20791
  const fallback = this.paired?.peerAllowsFallback ?? false;
20328
20792
  await this.options.events?.onTransportDiscovery?.(descriptors, transports, fallback);
@@ -20767,772 +21231,501 @@ var GhostLink = class {
20767
21231
  break;
20768
21232
  case "pay-res":
20769
21233
  this.options.events?.onPaymentResult?.({
20770
- id: frame.id,
20771
- ok: frame.ok,
20772
- credited: frame.v,
20773
- error: frame.err,
20774
- ...frame.c ? { closed: true } : {}
20775
- });
20776
- break;
20777
- case "rst":
20778
- if (frame.d === "q") this.httpHost?.handleReset(frame);
20779
- else if (frame.d === "s") this.httpClient?.handleReset(frame);
20780
- else this.files?.handleReset(frame);
20781
- break;
20782
- case "ping": try {
20783
- this.channel?.send(encodeControl({
20784
- t: "pong",
20785
- ts: frame.ts
20786
- }));
20787
- } catch {}
20788
- }
20789
- }
20790
- };
20791
- //#endregion
20792
- //#region ../core/src/payments.ts
20793
- /**
20794
- * Payments between two linked peers. Ghostly does not move money itself: it
20795
- * carries payment requests, in-band payments such as ecash tokens, and
20796
- * receipts over the data link, and a platform wallet executes them.
20797
- *
20798
- * Vocabulary and identifiers follow Pubky's Paykit so that publishing the same
20799
- * endpoints through Paykit later is a change of transport, not of model:
20800
- * an endpoint is an identifier plus a payload, an amount is decimal text plus
20801
- * an asset.
20802
- */
20803
- var ENDPOINT = {
20804
- /** Payload: a BOLT11 invoice. */
20805
- bolt11: "btc-lightning-bolt11",
20806
- /**
20807
- * Payload in a request: JSON `{ "mints": ["https://…"] }`, the mints the
20808
- * payee accepts ecash from. Payload in a payment: a `cashuB…` token.
20809
- */
20810
- cashu: "cashu",
20811
- arkade: "btc-arkade/1",
20812
- /** Second's Ark (Bark). Payload in a request: a payment target; in a payment: a receipt hint. */
20813
- bark: "btc-bark/1",
20814
- usdt: "usdt-erc20/1",
20815
- /** On-chain Bitcoin. Payload in a request: a payment target (a fresh address); in a payment: a receipt hint `{ txid }`. */
20816
- bitcoin: "btc-onchain/1",
20817
- /**
20818
- * Fedimint ecash. Payload in a request: JSON `{ "federations": ["<federation id>", …] }`, the federations the
20819
- * payee has joined and takes ecash from. Payload in a payment: the notes themselves (out-of-band notes, a
20820
- * bearer string only the federation that issued them redeems).
20821
- */
20822
- fedimint: "fedimint-ecash/1",
20823
- /**
20824
- * Spark, wallet to wallet. Payload in a request: a payment target whose address is a Spark invoice made for
20825
- * that request; in a payment: a receipt hint `{ id }` (the payer's transfer id).
20826
- */
20827
- spark: "btc-spark/1"
20828
- };
20829
- function cashuRequestPayload(mints) {
20830
- return JSON.stringify({ mints });
20831
- }
20832
- /** Mints named by a `cashu` endpoint of a request. Anything malformed yields none. */
20833
- function parseCashuRequestPayload(payload) {
20834
- try {
20835
- const { mints } = JSON.parse(payload);
20836
- if (!Array.isArray(mints)) return [];
20837
- return mints.filter((m) => typeof m === "string" && /^https?:\/\//.test(m)).slice(0, 8);
20838
- } catch {
20839
- return [];
20840
- }
20841
- }
20842
- /** A Fedimint federation id: 32 bytes, lowercase hex. */
20843
- var isFederationId = (value) => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
20844
- function fedimintRequestPayload(federations) {
20845
- return JSON.stringify({ federations });
20846
- }
20847
- /** Federations named by a `fedimint-ecash/1` endpoint of a request. Anything malformed yields none. */
20848
- function parseFedimintRequestPayload(payload) {
20849
- try {
20850
- const { federations } = JSON.parse(payload);
20851
- if (!Array.isArray(federations)) return [];
20852
- return [...new Set(federations.filter(isFederationId))].slice(0, 8);
20853
- } catch {
20854
- return [];
20855
- }
20856
- }
20857
- function findEndpoint(endpoints, identifier) {
20858
- return endpoints.find(([id]) => id === identifier)?.[1];
20859
- }
20860
- //#endregion
20861
- //#region ../core/src/bolt11.ts
20862
- var CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
20863
- var GENERATOR = [
20864
- 996825010,
20865
- 642813549,
20866
- 513874426,
20867
- 1027748829,
20868
- 705979059
20869
- ];
20870
- var NETWORKS = {
20871
- bc: "bitcoin",
20872
- tb: "testnet",
20873
- tbs: "signet",
20874
- bcrt: "regtest"
20875
- };
20876
- var MSAT_PER_UNIT = {
20877
- "": 100000000000n,
20878
- m: 100000000n,
20879
- u: 100000n,
20880
- n: 100n
20881
- };
20882
- var TIMESTAMP_WORDS = 7;
20883
- var DEFAULT_EXPIRY = 3600;
20884
- var MAX_LENGTH$1 = 7089;
20885
- function polymod(values) {
20886
- let chk = 1;
20887
- for (const value of values) {
20888
- const top = chk >>> 25;
20889
- chk = (chk & 33554431) << 5 ^ value;
20890
- for (let i = 0; i < 5; i++) if (top >>> i & 1) chk ^= GENERATOR[i];
20891
- }
20892
- return chk >>> 0;
20893
- }
20894
- function expandHrp(hrp) {
20895
- const high = [];
20896
- const low = [];
20897
- for (const char of hrp) {
20898
- high.push(char.charCodeAt(0) >>> 5);
20899
- low.push(char.charCodeAt(0) & 31);
20900
- }
20901
- return [
20902
- ...high,
20903
- 0,
20904
- ...low
20905
- ];
20906
- }
20907
- function wordsToInt(words) {
20908
- return words.reduce((value, word) => value * 32 + word, 0);
20909
- }
20910
- /** Regroups 5-bit words into bytes, dropping the padding bits at the end. */
20911
- function wordsToBytes(words) {
20912
- const bytes = [];
20913
- let buffer = 0;
20914
- let bits = 0;
20915
- for (const word of words) {
20916
- buffer = buffer << 5 | word;
20917
- bits += 5;
20918
- if (bits >= 8) {
20919
- bits -= 8;
20920
- bytes.push(buffer >>> bits & 255);
20921
- }
20922
- }
20923
- return new Uint8Array(bytes);
20924
- }
20925
- function decodeBolt11(text) {
20926
- const invoice = text.trim().toLowerCase().replace(/^lightning:/, "");
20927
- if (invoice.length > MAX_LENGTH$1) return null;
20928
- const separator = invoice.lastIndexOf("1");
20929
- if (separator < 4) return null;
20930
- const hrp = invoice.slice(0, separator);
20931
- const prefix = /^ln(bcrt|tbs|bc|tb)(?:([0-9]+)([munp]?))?$/.exec(hrp);
20932
- if (!prefix) return null;
20933
- const words = [];
20934
- for (const char of invoice.slice(separator + 1)) {
20935
- const word = CHARSET.indexOf(char);
20936
- if (word < 0) return null;
20937
- words.push(word);
20938
- }
20939
- if (words.length < 117) return null;
20940
- if (polymod([...expandHrp(hrp), ...words]) !== 1) return null;
20941
- let amountMsat = null;
20942
- if (prefix[2] !== void 0) {
20943
- const digits = BigInt(prefix[2]);
20944
- if (prefix[3] === "p") {
20945
- if (digits % 10n !== 0n) return null;
20946
- amountMsat = digits / 10n;
20947
- } else amountMsat = digits * MSAT_PER_UNIT[prefix[3]];
20948
- }
20949
- const data = words.slice(0, -110);
20950
- const createdAt = wordsToInt(data.slice(0, TIMESTAMP_WORDS));
20951
- let expiry = DEFAULT_EXPIRY;
20952
- let description;
20953
- let paymentHash;
20954
- let descriptionHash;
20955
- for (let at = TIMESTAMP_WORDS; at + 3 <= data.length;) {
20956
- const tag = CHARSET[data[at]];
20957
- const length = data[at + 1] * 32 + data[at + 2];
20958
- const field = data.slice(at + 3, at + 3 + length);
20959
- if (field.length < length) return null;
20960
- at += 3 + length;
20961
- if (tag === "d") description = new TextDecoder().decode(wordsToBytes(field)).trim() || void 0;
20962
- else if (tag === "x") expiry = wordsToInt(field);
20963
- else if (tag === "p" && length === 52) paymentHash = [...wordsToBytes(field)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
20964
- else if (tag === "h" && length === 52) descriptionHash = [...wordsToBytes(field)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
21234
+ id: frame.id,
21235
+ ok: frame.ok,
21236
+ credited: frame.v,
21237
+ error: frame.err,
21238
+ ...frame.c ? { closed: true } : {}
21239
+ });
21240
+ break;
21241
+ case "rst":
21242
+ if (frame.d === "q") this.httpHost?.handleReset(frame);
21243
+ else if (frame.d === "s") this.httpClient?.handleReset(frame);
21244
+ else this.files?.handleReset(frame);
21245
+ break;
21246
+ case "ping": try {
21247
+ this.channel?.send(encodeControl({
21248
+ t: "pong",
21249
+ ts: frame.ts
21250
+ }));
21251
+ } catch {}
21252
+ }
20965
21253
  }
20966
- return {
20967
- invoice,
20968
- network: NETWORKS[prefix[1]],
20969
- amountMsat,
20970
- amountSat: amountMsat === null ? null : Number((amountMsat + 999n) / 1000n),
20971
- createdAt,
20972
- expiresAt: createdAt + expiry,
20973
- description,
20974
- descriptionHash,
20975
- paymentHash
20976
- };
20977
- }
20978
- //#endregion
20979
- //#region ../core/src/lnurl.ts
20980
- var LNURL_LIMITS = {
20981
- timeoutMs: 15e3,
20982
- maxBytes: 65536,
20983
- maxRedirects: 5,
20984
- maxUrlLength: 2048,
20985
- maxMetadataLength: 8192,
20986
- maxComment: 2e3
20987
21254
  };
20988
- var ADDRESS = /^([a-z0-9._+-]{1,64})@([a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d{1,5})?)$/i;
20989
- var LNURL_BECH32 = /^lnurl1[02-9ac-hj-np-z]{6,}$/i;
20990
- var REDIRECTS = [
20991
- 301,
20992
- 302,
20993
- 303,
20994
- 307,
20995
- 308
20996
- ];
21255
+ //#endregion
21256
+ //#region ../core/src/payments.ts
20997
21257
  /**
20998
- * A host that is not on the public internet, as far as its name tells: this machine, a single label, `.local` and its
20999
- * kin, private and reserved IP addresses. (What a public name resolves to is checked where the client resolves names
21000
- * itself, as Ghostly Desktop does.)
21258
+ * Payments between two linked peers. Ghostly does not move money itself: it
21259
+ * carries payment requests, in-band payments such as ecash tokens, and
21260
+ * receipts over the data link, and a platform wallet executes them.
21261
+ *
21262
+ * Vocabulary and identifiers follow Pubky's Paykit so that publishing the same
21263
+ * endpoints through Paykit later is a change of transport, not of model:
21264
+ * an endpoint is an identifier plus a payload, an amount is decimal text plus
21265
+ * an asset.
21001
21266
  */
21002
- function isLocalHost(hostname) {
21003
- const host = hostname.toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
21004
- if (!host.includes(".") && !host.includes(":")) return true;
21005
- if (/(^|\.)(localhost|local|internal|lan|home\.arpa)$/.test(host)) return true;
21006
- const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
21007
- if (v4) {
21008
- const [a, b] = [Number(v4[1]), Number(v4[2])];
21009
- return a === 0 || a === 10 || a === 127 || a >= 224 || a === 169 && b === 254 || a === 172 && b >= 16 && b < 32 || a === 192 && b === 168 || a === 100 && b >= 64 && b < 128 || a === 198 && (b === 18 || b === 19) || a === 192 && b === 0;
21010
- }
21011
- if (host.includes(":")) return !/^[23][0-9a-f]{0,3}:/.test(host) || host.includes(".") || /^2001:0?db8:/.test(host);
21012
- return false;
21267
+ var ENDPOINT = {
21268
+ /** Payload: a BOLT11 invoice. */
21269
+ bolt11: "btc-lightning-bolt11",
21270
+ /**
21271
+ * Payload in a request: JSON `{ "mints": ["https://…"] }`, the mints the
21272
+ * payee accepts ecash from. Payload in a payment: a `cashuB…` token.
21273
+ */
21274
+ cashu: "cashu",
21275
+ arkade: "btc-arkade/1",
21276
+ /** Second's Ark (Bark). Payload in a request: a payment target; in a payment: a receipt hint. */
21277
+ bark: "btc-bark/1",
21278
+ usdt: "usdt-erc20/1",
21279
+ /** On-chain Bitcoin. Payload in a request: a payment target (a fresh address); in a payment: a receipt hint `{ txid }`. */
21280
+ bitcoin: "btc-onchain/1",
21281
+ /**
21282
+ * Fedimint ecash. Payload in a request: JSON `{ "federations": ["<federation id>", …] }`, the federations the
21283
+ * payee has joined and takes ecash from. Payload in a payment: the notes themselves (out-of-band notes, a
21284
+ * bearer string only the federation that issued them redeems).
21285
+ */
21286
+ fedimint: "fedimint-ecash/1",
21287
+ /**
21288
+ * Spark, wallet to wallet. Payload in a request: a payment target whose address is a Spark invoice made for
21289
+ * that request; in a payment: a receipt hint `{ id }` (the payer's transfer id).
21290
+ */
21291
+ spark: "btc-spark/1"
21292
+ };
21293
+ function cashuRequestPayload(mints) {
21294
+ return JSON.stringify({ mints });
21013
21295
  }
21014
- var isLoopback = (hostname) => [
21015
- "localhost",
21016
- "127.0.0.1",
21017
- "[::1]"
21018
- ].includes(hostname);
21019
- /** HTTPS, or plain HTTP only for a hidden service or a test server on this machine; never credentials. */
21020
- function acceptUrl(text) {
21021
- if (text.length > LNURL_LIMITS.maxUrlLength) throw new Error("That LNURL is too long");
21022
- let url;
21296
+ /** Mints named by a `cashu` endpoint of a request. Anything malformed yields none. */
21297
+ function parseCashuRequestPayload(payload) {
21023
21298
  try {
21024
- url = new URL(text);
21299
+ const { mints } = JSON.parse(payload);
21300
+ if (!Array.isArray(mints)) return [];
21301
+ return mints.filter((m) => typeof m === "string" && /^https?:\/\//.test(m)).slice(0, 8);
21025
21302
  } catch {
21026
- throw new Error("That LNURL does not point to a valid address");
21027
- }
21028
- const plainAllowed = url.hostname.endsWith(".onion") || isLoopback(url.hostname);
21029
- if (url.protocol !== "https:" && !(url.protocol === "http:" && plainAllowed)) throw new Error("Lightning addresses and LNURLs must use HTTPS");
21030
- if (url.username || url.password) throw new Error("That LNURL carries credentials, which is not allowed");
21031
- return url;
21032
- }
21033
- /** Reads what was pasted as a Lightning address or an LNURL; null when it is neither. Throws when it is one, but a bad one. */
21034
- function parseLightningDestination(input) {
21035
- const text = input.trim().replace(/^(lightning:|⚡\s*)/i, "").trim();
21036
- if (!text) return null;
21037
- if (LNURL_BECH32.test(text)) {
21038
- let decoded;
21039
- try {
21040
- decoded = bech32.decode(text.toLowerCase(), LNURL_LIMITS.maxUrlLength);
21041
- } catch {
21042
- throw new Error("That LNURL is not valid");
21043
- }
21044
- const url = acceptUrl(new TextDecoder().decode(bech32.fromWords(decoded.words)));
21045
- return {
21046
- kind: "lnurl",
21047
- text: text.toLowerCase(),
21048
- url: url.href,
21049
- domain: url.host
21050
- };
21051
- }
21052
- if (/^lnurlp:\/\//i.test(text)) {
21053
- const bare = text.slice(9);
21054
- const host = bare.split(/[/?#]/)[0].toLowerCase();
21055
- const url = acceptUrl(`${host.endsWith(".onion") || isLoopback(host.replace(/:\d+$/, "")) ? "http" : "https"}://${bare}`);
21056
- return {
21057
- kind: "lnurl",
21058
- text: `lnurlp://${bare}`,
21059
- url: url.href,
21060
- domain: url.host
21061
- };
21062
- }
21063
- const address = ADDRESS.exec(text);
21064
- if (!address) return null;
21065
- const name = address[1].toLowerCase(), domain = address[2].toLowerCase();
21066
- const hostname = domain.replace(/:\d+$/, "");
21067
- if (!hostname.includes(".") && !isLoopback(hostname)) return null;
21068
- const url = acceptUrl(`${hostname.endsWith(".onion") || isLoopback(hostname) ? "http" : "https"}://${domain}/.well-known/lnurlp/${name}`);
21069
- return {
21070
- kind: "address",
21071
- text: `${name}@${domain}`,
21072
- url: url.href,
21073
- domain: url.host,
21074
- name
21075
- };
21076
- }
21077
- var hex$1 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
21078
- var reason = (value) => typeof value === "string" ? value.replace(/\s+/g, " ").slice(0, 200) : "";
21079
- var isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
21080
- async function readBounded(response, maxBytes) {
21081
- const reader = response.body?.getReader?.();
21082
- if (!reader) {
21083
- const text = await response.text();
21084
- if (text.length > maxBytes) throw new Error("The server's answer is too large");
21085
- return text;
21086
- }
21087
- const chunks = [];
21088
- let size = 0;
21089
- for (;;) {
21090
- const { done, value } = await reader.read();
21091
- if (done) break;
21092
- size += value.byteLength;
21093
- if (size > maxBytes) {
21094
- await reader.cancel().catch(() => {});
21095
- throw new Error("The server's answer is too large");
21096
- }
21097
- chunks.push(value);
21098
- }
21099
- const all = new Uint8Array(size);
21100
- let at = 0;
21101
- for (const chunk of chunks) {
21102
- all.set(chunk, at);
21103
- at += chunk.byteLength;
21303
+ return [];
21104
21304
  }
21105
- return new TextDecoder().decode(all);
21106
21305
  }
21107
- /** One GET to an LNURL service (and to each checked redirect of it): bounded in time and size, no credentials, no referrer, and the answer must be JSON. */
21108
- async function fetchLnurlJson(url, options = {}) {
21109
- const target = acceptUrl(url);
21110
- const doFetch = options.fetch ?? globalThis.fetch;
21111
- if (!doFetch) throw new Error("No way to reach the network here");
21112
- const controller = new AbortController();
21113
- const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("The server took too long to answer")), options.timeoutMs ?? LNURL_LIMITS.timeoutMs);
21114
- const onAbort = () => controller.abort(options.signal?.reason);
21115
- options.signal?.addEventListener("abort", onAbort, { once: true });
21116
- try {
21117
- let response;
21118
- for (let at = target, hops = 0;; hops++) {
21119
- try {
21120
- response = await doFetch(at.href, {
21121
- signal: controller.signal,
21122
- headers: { accept: "application/json" },
21123
- credentials: "omit",
21124
- referrerPolicy: "no-referrer",
21125
- redirect: "manual"
21126
- });
21127
- } catch {
21128
- if (controller.signal.aborted) throw controller.signal.reason instanceof Error ? controller.signal.reason : /* @__PURE__ */ new Error("The request was cancelled");
21129
- throw engineError("lnurlUnreachable", { host: target.host });
21130
- }
21131
- if (response.url) acceptUrl(response.url);
21132
- if (response.type === "opaqueredirect") throw new Error(`${target.host} redirects to another address, which cannot be checked here`);
21133
- const location = REDIRECTS.includes(response.status) ? response.headers.get("location") : null;
21134
- if (!location) break;
21135
- await response.body?.cancel().catch(() => {});
21136
- if (hops >= LNURL_LIMITS.maxRedirects) throw new Error(`${target.host} redirects too many times`);
21137
- let next;
21138
- try {
21139
- next = new URL(location, at);
21140
- } catch {
21141
- throw new Error(`${target.host} redirects to an invalid address`);
21142
- }
21143
- next = acceptUrl(next.href);
21144
- if (next.host !== at.host) {
21145
- if (next.protocol !== "https:") throw new Error("Lightning addresses and LNURLs must use HTTPS");
21146
- if (isLocalHost(next.hostname)) throw new Error(`${target.host} redirects to a local address`);
21147
- }
21148
- at = next;
21149
- }
21150
- const text = await readBounded(response, options.maxBytes ?? LNURL_LIMITS.maxBytes);
21151
- let body;
21152
- try {
21153
- body = JSON.parse(text);
21154
- } catch {
21155
- throw new Error(`${target.host} did not answer with JSON${response.ok ? "" : ` (HTTP ${response.status})`}`);
21156
- }
21157
- if (isRecord(body) && body.status === "ERROR") throw new Error(`${target.host} refused: ${reason(body.reason) || "no reason given"}`);
21158
- if (!response.ok) throw new Error(`${target.host} answered HTTP ${response.status}`);
21159
- return body;
21160
- } finally {
21161
- clearTimeout(timer);
21162
- options.signal?.removeEventListener("abort", onAbort);
21163
- }
21306
+ /** A Fedimint federation id: 32 bytes, lowercase hex. */
21307
+ var isFederationId = (value) => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
21308
+ function fedimintRequestPayload(federations) {
21309
+ return JSON.stringify({ federations });
21164
21310
  }
21165
- /** Checks a service's `payRequest` answer (LUD-06), and reads what a person needs to see of it. */
21166
- function parsePayParams(body, destination) {
21167
- if (!isRecord(body)) throw new Error(`${destination.domain} did not answer with a pay request`);
21168
- if (body.tag !== "payRequest") throw new Error(`${destination.domain} answered with something other than a pay request${typeof body.tag === "string" ? ` (${reason(body.tag)})` : ""}`);
21169
- if (typeof body.callback !== "string") throw new Error("The pay request has no callback");
21170
- const callback = acceptUrl(body.callback);
21171
- const { minSendable, maxSendable, metadata, commentAllowed } = body;
21172
- if (!Number.isSafeInteger(minSendable) || !Number.isSafeInteger(maxSendable) || minSendable <= 0 || maxSendable < minSendable) throw new Error("The pay request has invalid amount limits");
21173
- const minSat = Math.ceil(minSendable / 1e3), maxSat = Math.floor(maxSendable / 1e3);
21174
- if (minSat > maxSat) throw new Error("The pay request only takes fractions of a sat, which cannot be paid here");
21175
- if (typeof metadata !== "string" || metadata.length > LNURL_LIMITS.maxMetadataLength) throw new Error("The pay request has no usable metadata");
21176
- let entries;
21311
+ /** Federations named by a `fedimint-ecash/1` endpoint of a request. Anything malformed yields none. */
21312
+ function parseFedimintRequestPayload(payload) {
21177
21313
  try {
21178
- entries = JSON.parse(metadata);
21314
+ const { federations } = JSON.parse(payload);
21315
+ if (!Array.isArray(federations)) return [];
21316
+ return [...new Set(federations.filter(isFederationId))].slice(0, 8);
21179
21317
  } catch {
21180
- throw new Error("The pay request's metadata is not JSON");
21318
+ return [];
21181
21319
  }
21182
- if (!Array.isArray(entries) || !entries.every((e) => Array.isArray(e) && typeof e[0] === "string")) throw new Error("The pay request's metadata is malformed");
21183
- const entry = (type) => entries.find((e) => e[0] === type)?.[1];
21184
- const description = entry("text/plain");
21185
- if (typeof description !== "string") throw new Error("The pay request says nothing about what it is for (no text/plain metadata)");
21186
- const identifier = [entry("text/identifier"), entry("text/email")].find((v) => typeof v === "string")?.toLowerCase();
21187
- if (destination.kind === "address" && identifier && identifier !== destination.text) throw new Error(`${destination.domain} answered for ${identifier}, not ${destination.text}`);
21188
- const comment = Number.isSafeInteger(commentAllowed) && commentAllowed > 0 ? Math.min(commentAllowed, LNURL_LIMITS.maxComment) : 0;
21189
- return {
21190
- destination,
21191
- callback: callback.href,
21192
- callbackDomain: callback.host,
21193
- minSendable,
21194
- maxSendable,
21195
- minSat,
21196
- maxSat,
21197
- metadata,
21198
- descriptionHash: hex$1(sha256(new TextEncoder().encode(metadata))),
21199
- description: description.slice(0, 280),
21200
- identifier,
21201
- commentAllowed: comment
21202
- };
21203
21320
  }
21204
- /** Where to ask for the invoice of `amountSat` (and a comment, LUD-12). Throws when the amount or comment is not allowed. */
21205
- function invoiceCallbackUrl(params, amountSat, comment) {
21206
- if (!Number.isSafeInteger(amountSat) || amountSat <= 0) throw new Error("Enter a whole number of sats");
21207
- if (amountSat < params.minSat || amountSat > params.maxSat) throw params.minSat === params.maxSat ? engineError("lnurlExactly", {
21208
- domain: params.destination.domain,
21209
- amount: params.minSat.toLocaleString()
21210
- }) : engineError("lnurlRange", {
21211
- domain: params.destination.domain,
21212
- min: params.minSat.toLocaleString(),
21213
- max: params.maxSat.toLocaleString()
21214
- });
21215
- const url = new URL(params.callback);
21216
- url.searchParams.set("amount", String(amountSat * 1e3));
21217
- const text = comment?.trim();
21218
- if (text) {
21219
- if (!params.commentAllowed) throw new Error(`${params.destination.domain} does not take a comment`);
21220
- if (text.length > params.commentAllowed) throw new Error(`The comment can be at most ${params.commentAllowed} characters`);
21221
- url.searchParams.set("comment", text);
21321
+ function findEndpoint(endpoints, identifier) {
21322
+ return endpoints.find(([id]) => id === identifier)?.[1];
21323
+ }
21324
+ //#endregion
21325
+ //#region ../core/src/bolt11.ts
21326
+ var CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
21327
+ var GENERATOR = [
21328
+ 996825010,
21329
+ 642813549,
21330
+ 513874426,
21331
+ 1027748829,
21332
+ 705979059
21333
+ ];
21334
+ var NETWORKS = {
21335
+ bc: "bitcoin",
21336
+ tb: "testnet",
21337
+ tbs: "signet",
21338
+ bcrt: "regtest"
21339
+ };
21340
+ var MSAT_PER_UNIT = {
21341
+ "": 100000000000n,
21342
+ m: 100000000n,
21343
+ u: 100000n,
21344
+ n: 100n
21345
+ };
21346
+ var TIMESTAMP_WORDS = 7;
21347
+ var DEFAULT_EXPIRY = 3600;
21348
+ var MAX_LENGTH$1 = 7089;
21349
+ function polymod(values) {
21350
+ let chk = 1;
21351
+ for (const value of values) {
21352
+ const top = chk >>> 25;
21353
+ chk = (chk & 33554431) << 5 ^ value;
21354
+ for (let i = 0; i < 5; i++) if (top >>> i & 1) chk ^= GENERATOR[i];
21222
21355
  }
21223
- return url.href;
21356
+ return chk >>> 0;
21224
21357
  }
21225
- /**
21226
- * Checks the invoice a service answered with (LUD-06): the amount asked, a payment hash, not expired, and
21227
- * a commitment to the metadata the person was shown: the `h` tag is its sha256, or the invoice carries the
21228
- * metadata itself as its description (the same commitment, unhashed).
21229
- */
21230
- function parseInvoiceCallback(body, params, amountSat) {
21231
- if (!isRecord(body) || typeof body.pr !== "string") throw new Error(`${params.callbackDomain} did not answer with an invoice`);
21232
- const invoice = decodeBolt11(body.pr);
21233
- if (!invoice) throw new Error(`${params.callbackDomain} answered with an invoice that does not decode`);
21234
- if (invoice.amountMsat !== BigInt(amountSat) * 1000n) throw new Error(`The invoice asks for ${invoice.amountSat === null ? "any amount" : `${invoice.amountSat.toLocaleString()} sats`}, not the ${amountSat.toLocaleString()} sats you chose`);
21235
- if (!invoice.paymentHash) throw new Error("The invoice has no payment hash");
21236
- if (invoice.descriptionHash !== params.descriptionHash && invoice.description !== params.metadata) throw new Error("The invoice does not commit to what the service said the payment is for");
21237
- if (invoice.expiresAt * 1e3 < Date.now()) throw new Error("The invoice the service answered with has already expired");
21238
- let successAction;
21239
- const action = body.successAction;
21240
- if (isRecord(action)) {
21241
- if (action.tag === "message" && typeof action.message === "string") successAction = {
21242
- tag: "message",
21243
- message: reason(action.message)
21244
- };
21245
- else if (action.tag === "url" && typeof action.url === "string" && /^https:\/\//i.test(action.url) && action.url.length <= LNURL_LIMITS.maxUrlLength) successAction = {
21246
- tag: "url",
21247
- description: reason(action.description),
21248
- url: action.url
21249
- };
21358
+ function expandHrp(hrp) {
21359
+ const high = [];
21360
+ const low = [];
21361
+ for (const char of hrp) {
21362
+ high.push(char.charCodeAt(0) >>> 5);
21363
+ low.push(char.charCodeAt(0) & 31);
21364
+ }
21365
+ return [
21366
+ ...high,
21367
+ 0,
21368
+ ...low
21369
+ ];
21370
+ }
21371
+ function wordsToInt(words) {
21372
+ return words.reduce((value, word) => value * 32 + word, 0);
21373
+ }
21374
+ /** Regroups 5-bit words into bytes, dropping the padding bits at the end. */
21375
+ function wordsToBytes(words) {
21376
+ const bytes = [];
21377
+ let buffer = 0;
21378
+ let bits = 0;
21379
+ for (const word of words) {
21380
+ buffer = buffer << 5 | word;
21381
+ bits += 5;
21382
+ if (bits >= 8) {
21383
+ bits -= 8;
21384
+ bytes.push(buffer >>> bits & 255);
21385
+ }
21386
+ }
21387
+ return new Uint8Array(bytes);
21388
+ }
21389
+ function decodeBolt11(text) {
21390
+ const invoice = text.trim().toLowerCase().replace(/^lightning:/, "");
21391
+ if (invoice.length > MAX_LENGTH$1) return null;
21392
+ const separator = invoice.lastIndexOf("1");
21393
+ if (separator < 4) return null;
21394
+ const hrp = invoice.slice(0, separator);
21395
+ const prefix = /^ln(bcrt|tbs|bc|tb)(?:([0-9]+)([munp]?))?$/.exec(hrp);
21396
+ if (!prefix) return null;
21397
+ const words = [];
21398
+ for (const char of invoice.slice(separator + 1)) {
21399
+ const word = CHARSET.indexOf(char);
21400
+ if (word < 0) return null;
21401
+ words.push(word);
21402
+ }
21403
+ if (words.length < 117) return null;
21404
+ if (polymod([...expandHrp(hrp), ...words]) !== 1) return null;
21405
+ let amountMsat = null;
21406
+ if (prefix[2] !== void 0) {
21407
+ const digits = BigInt(prefix[2]);
21408
+ if (prefix[3] === "p") {
21409
+ if (digits % 10n !== 0n) return null;
21410
+ amountMsat = digits / 10n;
21411
+ } else amountMsat = digits * MSAT_PER_UNIT[prefix[3]];
21412
+ }
21413
+ const data = words.slice(0, -110);
21414
+ const createdAt = wordsToInt(data.slice(0, TIMESTAMP_WORDS));
21415
+ let expiry = DEFAULT_EXPIRY;
21416
+ let description;
21417
+ let paymentHash;
21418
+ let descriptionHash;
21419
+ for (let at = TIMESTAMP_WORDS; at + 3 <= data.length;) {
21420
+ const tag = CHARSET[data[at]];
21421
+ const length = data[at + 1] * 32 + data[at + 2];
21422
+ const field = data.slice(at + 3, at + 3 + length);
21423
+ if (field.length < length) return null;
21424
+ at += 3 + length;
21425
+ if (tag === "d") description = new TextDecoder().decode(wordsToBytes(field)).trim() || void 0;
21426
+ else if (tag === "x") expiry = wordsToInt(field);
21427
+ else if (tag === "p" && length === 52) paymentHash = [...wordsToBytes(field)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
21428
+ else if (tag === "h" && length === 52) descriptionHash = [...wordsToBytes(field)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
21250
21429
  }
21251
21430
  return {
21252
21431
  invoice,
21253
- successAction
21432
+ network: NETWORKS[prefix[1]],
21433
+ amountMsat,
21434
+ amountSat: amountMsat === null ? null : Number((amountMsat + 999n) / 1000n),
21435
+ createdAt,
21436
+ expiresAt: createdAt + expiry,
21437
+ description,
21438
+ descriptionHash,
21439
+ paymentHash
21254
21440
  };
21255
21441
  }
21256
- /** Fetches and checks what a Lightning address or LNURL asks for. The service's domain learns of it. */
21257
- async function resolveLightningDestination(text, options = {}) {
21258
- const destination = parseLightningDestination(text);
21259
- if (!destination) throw new Error("That is not a Lightning address or an LNURL");
21260
- return parsePayParams(await fetchLnurlJson(destination.url, options), destination);
21261
- }
21262
- /** Asks the service for an invoice of `amountSat`, and checks it before it is handed to any wallet. */
21263
- async function requestLnurlInvoice(params, amountSat, comment, options = {}) {
21264
- return parseInvoiceCallback(await fetchLnurlJson(invoiceCallbackUrl(params, amountSat, comment), options), params, amountSat);
21265
- }
21266
21442
  //#endregion
21267
- //#region ../core/src/domainProofs.ts
21268
- var DNS_LABEL = "_ghostly";
21269
- var WELL_KNOWN_PATH = "/.well-known/ghostly.json";
21270
- var NIP05_PATH = "/.well-known/nostr.json?name=_";
21271
- /** Everything read from a domain is bounded; these match the fetch limits. */
21272
- var DOMAIN_FILE_MAX_BYTES = 16384;
21273
- var z32 = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/;
21274
- var hex64 = /^[a-f0-9]{64}$/;
21275
- /** Names that never resolve publicly (RFC 6761, 6762, 7686, 8375, 9476) or that
21276
- * only ever mean a private network. A proof for them could only point inward. */
21277
- var SPECIAL_USE = [
21278
- "localhost",
21279
- "local",
21280
- "internal",
21281
- "lan",
21282
- "home",
21283
- "corp",
21284
- "intranet",
21285
- "private",
21286
- "test",
21287
- "example",
21288
- "invalid",
21289
- "onion",
21290
- "alt",
21291
- "arpa"
21443
+ //#region ../core/src/lnurl.ts
21444
+ var LNURL_LIMITS = {
21445
+ timeoutMs: 15e3,
21446
+ maxBytes: 65536,
21447
+ maxRedirects: 5,
21448
+ maxUrlLength: 2048,
21449
+ maxMetadataLength: 8192,
21450
+ maxComment: 2e3
21451
+ };
21452
+ var ADDRESS = /^([a-z0-9._+-]{1,64})@([a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d{1,5})?)$/i;
21453
+ var LNURL_BECH32 = /^lnurl1[02-9ac-hj-np-z]{6,}$/i;
21454
+ var REDIRECTS = [
21455
+ 301,
21456
+ 302,
21457
+ 303,
21458
+ 307,
21459
+ 308
21292
21460
  ];
21293
21461
  /**
21294
- * Normalizes what a person typed ("Example.COM.", "https://example.com/") into
21295
- * a lowercase ASCII host name, or throws with a message fit for the UI.
21296
- * Internationalized names become punycode (xn--), the form DNS carries.
21462
+ * A host that is not on the public internet, as far as its name tells: this machine, a single label, `.local` and its
21463
+ * kin, private and reserved IP addresses. (What a public name resolves to is checked where the client resolves names
21464
+ * itself, as Ghostly Desktop does.)
21297
21465
  */
21298
- function normalizeDomain(input) {
21299
- if (typeof input !== "string") throw new Error("Enter a domain name, like example.com");
21300
- let value = input.trim();
21301
- if (!value || value.length > 300) throw new Error("Enter a domain name, like example.com");
21302
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
21303
- let url;
21304
- try {
21305
- url = new URL(value);
21306
- } catch {
21307
- throw new Error("Enter a domain name, like example.com");
21308
- }
21309
- if (url.protocol !== "https:" || url.username || url.password || url.port || url.pathname !== "/" && url.pathname !== "" || url.search || url.hash) throw new Error("Enter only the domain name, without a path or port");
21310
- value = url.hostname;
21311
- } else {
21312
- if (/[/:@?#\s\\]/.test(value)) throw new Error("Enter only the domain name, without a path or port");
21313
- try {
21314
- value = new URL(`https://${value}`).hostname;
21315
- } catch {
21316
- throw new Error("That is not a valid domain name");
21317
- }
21466
+ function isLocalHost(hostname) {
21467
+ const host = hostname.toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
21468
+ if (!host.includes(".") && !host.includes(":")) return true;
21469
+ if (/(^|\.)(localhost|local|internal|lan|home\.arpa)$/.test(host)) return true;
21470
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
21471
+ if (v4) {
21472
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
21473
+ return a === 0 || a === 10 || a === 127 || a >= 224 || a === 169 && b === 254 || a === 172 && b >= 16 && b < 32 || a === 192 && b === 168 || a === 100 && b >= 64 && b < 128 || a === 198 && (b === 18 || b === 19) || a === 192 && b === 0;
21318
21474
  }
21319
- value = value.toLowerCase().replace(/\.$/, "");
21320
- if (value.length > 244) throw new Error("That domain name is too long");
21321
- const labels = value.split(".");
21322
- if (labels.length < 2) throw new Error("Use a full domain name, like example.com");
21323
- for (const label of labels) if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) throw new Error("That is not a valid domain name");
21324
- const tld = labels[labels.length - 1];
21325
- if (/^[0-9]+$/.test(tld) || /^\[/.test(value)) throw new Error("Use a domain name, not an IP address");
21326
- if (SPECIAL_USE.includes(tld) || labels.length >= 2 && labels.slice(-2).join(".") === "home.arpa") throw new Error("That name only works on private networks");
21327
- return value;
21475
+ if (host.includes(":")) return !/^[23][0-9a-f]{0,3}:/.test(host) || host.includes(".") || /^2001:0?db8:/.test(host);
21476
+ return false;
21328
21477
  }
21329
- var dnsProofName = (domain) => `${DNS_LABEL}.${normalizeDomain(domain)}`;
21330
- var wellKnownUrl = (domain, method = "https") => `https://${normalizeDomain(domain)}${method === "nip05" ? NIP05_PATH : WELL_KNOWN_PATH}`;
21331
- function validDomainRecord(r) {
21332
- if (!r || typeof r.key !== "string" || typeof r.proof !== "string" || !z32.test(r.key) || !hex64.test(r.proof)) return false;
21478
+ var isLoopback = (hostname) => [
21479
+ "localhost",
21480
+ "127.0.0.1",
21481
+ "[::1]"
21482
+ ].includes(hostname);
21483
+ /** HTTPS, or plain HTTP only for a hidden service or a test server on this machine; never credentials. */
21484
+ function acceptUrl(text) {
21485
+ if (text.length > LNURL_LIMITS.maxUrlLength) throw new Error("That LNURL is too long");
21486
+ let url;
21333
21487
  try {
21334
- return toZ32(fromZ32(r.key)) === r.key;
21488
+ url = new URL(text);
21335
21489
  } catch {
21336
- return false;
21490
+ throw new Error("That LNURL does not point to a valid address");
21337
21491
  }
21492
+ const plainAllowed = url.hostname.endsWith(".onion") || isLoopback(url.hostname);
21493
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && plainAllowed)) throw new Error("Lightning addresses and LNURLs must use HTTPS");
21494
+ if (url.username || url.password) throw new Error("That LNURL carries credentials, which is not allowed");
21495
+ return url;
21338
21496
  }
21339
- /** The TXT record value, in the `tag=value; …` style people know from DKIM. */
21340
- function domainTxtRecord(r) {
21341
- if (!validDomainRecord(r)) throw new Error("Invalid domain record");
21342
- return `v=ghostly1; key=${r.key}; proof=${r.proof}`;
21343
- }
21344
- /** Parses one TXT string. Undefined for anything that is not ours or not exactly
21345
- * well-formed; unknown extra tags are ignored for forward compatibility. */
21346
- function parseDomainTxt(value) {
21347
- if (typeof value !== "string" || utf8Encode(value).length > 1024) return;
21348
- const tags = /* @__PURE__ */ new Map();
21349
- const parts = value.split(";").map((p) => p.trim()).filter(Boolean);
21350
- for (const part of parts) {
21351
- const eq = part.indexOf("=");
21352
- if (eq < 1) return;
21353
- const name = part.slice(0, eq).trim().toLowerCase();
21354
- if (tags.has(name)) return;
21355
- tags.set(name, part.slice(eq + 1).trim());
21497
+ /** Reads what was pasted as a Lightning address or an LNURL; null when it is neither. Throws when it is one, but a bad one. */
21498
+ function parseLightningDestination(input) {
21499
+ const text = input.trim().replace(/^(lightning:|⚡\s*)/i, "").trim();
21500
+ if (!text) return null;
21501
+ if (LNURL_BECH32.test(text)) {
21502
+ let decoded;
21503
+ try {
21504
+ decoded = bech32.decode(text.toLowerCase(), LNURL_LIMITS.maxUrlLength);
21505
+ } catch {
21506
+ throw new Error("That LNURL is not valid");
21507
+ }
21508
+ const url = acceptUrl(new TextDecoder().decode(bech32.fromWords(decoded.words)));
21509
+ return {
21510
+ kind: "lnurl",
21511
+ text: text.toLowerCase(),
21512
+ url: url.href,
21513
+ domain: url.host
21514
+ };
21356
21515
  }
21357
- if (!parts[0]?.toLowerCase().startsWith("v=") || tags.get("v") !== "ghostly1") return;
21358
- const r = {
21359
- key: tags.get("key"),
21360
- proof: tags.get("proof")
21516
+ if (/^lnurlp:\/\//i.test(text)) {
21517
+ const bare = text.slice(9);
21518
+ const host = bare.split(/[/?#]/)[0].toLowerCase();
21519
+ const url = acceptUrl(`${host.endsWith(".onion") || isLoopback(host.replace(/:\d+$/, "")) ? "http" : "https"}://${bare}`);
21520
+ return {
21521
+ kind: "lnurl",
21522
+ text: `lnurlp://${bare}`,
21523
+ url: url.href,
21524
+ domain: url.host
21525
+ };
21526
+ }
21527
+ const address = ADDRESS.exec(text);
21528
+ if (!address) return null;
21529
+ const name = address[1].toLowerCase(), domain = address[2].toLowerCase();
21530
+ const hostname = domain.replace(/:\d+$/, "");
21531
+ if (!hostname.includes(".") && !isLoopback(hostname)) return null;
21532
+ const url = acceptUrl(`${hostname.endsWith(".onion") || isLoopback(hostname) ? "http" : "https"}://${domain}/.well-known/lnurlp/${name}`);
21533
+ return {
21534
+ kind: "address",
21535
+ text: `${name}@${domain}`,
21536
+ url: url.href,
21537
+ domain: url.host,
21538
+ name
21361
21539
  };
21362
- return validDomainRecord(r) ? r : void 0;
21363
21540
  }
21364
- var addRecord = (list, r) => {
21365
- if (!list.some((x) => x.key === r.key && x.proof === r.proof)) list.push(r);
21366
- };
21367
- /** All records among the TXT strings of `_ghostly.<domain>`; other strings are skipped. */
21368
- function recordsFromTxt(values) {
21369
- const out = [];
21370
- for (const value of values.slice(0, 32)) {
21371
- const r = parseDomainTxt(value);
21372
- if (r) addRecord(out, r);
21541
+ var hex$1 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
21542
+ var reason = (value) => typeof value === "string" ? value.replace(/\s+/g, " ").slice(0, 200) : "";
21543
+ var isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
21544
+ async function readBounded(response, maxBytes) {
21545
+ const reader = response.body?.getReader?.();
21546
+ if (!reader) {
21547
+ const text = await response.text();
21548
+ if (text.length > maxBytes) throw new Error("The server's answer is too large");
21549
+ return text;
21373
21550
  }
21374
- return out.slice(0, 8);
21375
- }
21376
- /** The body of `/.well-known/ghostly.json`. */
21377
- function domainWellKnownFile(records) {
21378
- if (!records.length || records.length > 8 || !records.every(validDomainRecord)) throw new Error("Invalid domain records");
21379
- return JSON.stringify({
21380
- ghostly: 1,
21381
- proofs: records.map((r) => ({
21382
- key: r.key,
21383
- proof: r.proof
21384
- }))
21385
- }, null, 2) + "\n";
21386
- }
21387
- function parseJson(text) {
21388
- if (typeof text !== "string" || utf8Encode(text).length > 16384) throw new Error("File too large");
21389
- return JSON.parse(text);
21390
- }
21391
- /** Records from `ghostly.json`. Throws on a malformed file; [] when it names none. */
21392
- function recordsFromWellKnown(text) {
21393
- const body = parseJson(text);
21394
- if (!body || typeof body !== "object" || Array.isArray(body) || body.ghostly !== 1 || !Array.isArray(body.proofs)) throw new Error("Not a Ghostly domain file");
21395
- const out = [];
21396
- for (const entry of body.proofs.slice(0, 32)) {
21397
- if (!entry || typeof entry !== "object") continue;
21398
- const r = {
21399
- key: entry.key,
21400
- proof: entry.proof
21401
- };
21402
- if (validDomainRecord(r)) addRecord(out, r);
21551
+ const chunks = [];
21552
+ let size = 0;
21553
+ for (;;) {
21554
+ const { done, value } = await reader.read();
21555
+ if (done) break;
21556
+ size += value.byteLength;
21557
+ if (size > maxBytes) {
21558
+ await reader.cancel().catch(() => {});
21559
+ throw new Error("The server's answer is too large");
21560
+ }
21561
+ chunks.push(value);
21403
21562
  }
21404
- return out.slice(0, 8);
21405
- }
21406
- /** NIP-05: only the root name `_` speaks for the domain itself. Undefined when absent. */
21407
- function nip05RootKey(text) {
21408
- const body = parseJson(text);
21409
- if (!body || typeof body !== "object" || !body.names || typeof body.names !== "object" || Array.isArray(body.names)) throw new Error("Not a NIP-05 file");
21410
- const root = body.names["_"];
21411
- return typeof root === "string" && hex64.test(root) ? root : void 0;
21563
+ const all = new Uint8Array(size);
21564
+ let at = 0;
21565
+ for (const chunk of chunks) {
21566
+ all.set(chunk, at);
21567
+ at += chunk.byteLength;
21568
+ }
21569
+ return new TextDecoder().decode(all);
21412
21570
  }
21413
- var recordsName = (records, r) => records.some((x) => x.key === r.key && x.proof === r.proof);
21414
- var DNS_TYPE = {
21415
- A: 1,
21416
- CNAME: 5,
21417
- TXT: 16,
21418
- AAAA: 28
21419
- };
21420
- /** A recursive query for one name and type. ID 0, as RFC 8484 recommends for caching;
21421
- * the AD bit asks the resolver to report DNSSEC validation (RFC 6840 §5.7). */
21422
- function encodeDnsQuery(name, type) {
21423
- const labels = name.replace(/\.$/, "").split(".");
21424
- const parts = [Uint8Array.of(0, 0, 1, 32, 0, 1, 0, 0, 0, 0, 0, 0)];
21425
- for (const label of labels) {
21426
- const bytes = utf8Encode(label);
21427
- if (!bytes.length || bytes.length > 63) throw new Error("Invalid DNS name");
21428
- parts.push(Uint8Array.of(bytes.length), bytes);
21571
+ /** One GET to an LNURL service (and to each checked redirect of it): bounded in time and size, no credentials, no referrer, and the answer must be JSON. */
21572
+ async function fetchLnurlJson(url, options = {}) {
21573
+ const target = acceptUrl(url);
21574
+ const doFetch = options.fetch ?? globalThis.fetch;
21575
+ if (!doFetch) throw new Error("No way to reach the network here");
21576
+ const controller = new AbortController();
21577
+ const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("The server took too long to answer")), options.timeoutMs ?? LNURL_LIMITS.timeoutMs);
21578
+ const onAbort = () => controller.abort(options.signal?.reason);
21579
+ options.signal?.addEventListener("abort", onAbort, { once: true });
21580
+ try {
21581
+ let response;
21582
+ for (let at = target, hops = 0;; hops++) {
21583
+ try {
21584
+ response = await doFetch(at.href, {
21585
+ signal: controller.signal,
21586
+ headers: { accept: "application/json" },
21587
+ credentials: "omit",
21588
+ referrerPolicy: "no-referrer",
21589
+ redirect: "manual"
21590
+ });
21591
+ } catch {
21592
+ if (controller.signal.aborted) throw controller.signal.reason instanceof Error ? controller.signal.reason : /* @__PURE__ */ new Error("The request was cancelled");
21593
+ throw engineError("lnurlUnreachable", { host: target.host });
21594
+ }
21595
+ if (response.url) acceptUrl(response.url);
21596
+ if (response.type === "opaqueredirect") throw new Error(`${target.host} redirects to another address, which cannot be checked here`);
21597
+ const location = REDIRECTS.includes(response.status) ? response.headers.get("location") : null;
21598
+ if (!location) break;
21599
+ await response.body?.cancel().catch(() => {});
21600
+ if (hops >= LNURL_LIMITS.maxRedirects) throw new Error(`${target.host} redirects too many times`);
21601
+ let next;
21602
+ try {
21603
+ next = new URL(location, at);
21604
+ } catch {
21605
+ throw new Error(`${target.host} redirects to an invalid address`);
21606
+ }
21607
+ next = acceptUrl(next.href);
21608
+ if (next.host !== at.host) {
21609
+ if (next.protocol !== "https:") throw new Error("Lightning addresses and LNURLs must use HTTPS");
21610
+ if (isLocalHost(next.hostname)) throw new Error(`${target.host} redirects to a local address`);
21611
+ }
21612
+ at = next;
21613
+ }
21614
+ const text = await readBounded(response, options.maxBytes ?? LNURL_LIMITS.maxBytes);
21615
+ let body;
21616
+ try {
21617
+ body = JSON.parse(text);
21618
+ } catch {
21619
+ throw new Error(`${target.host} did not answer with JSON${response.ok ? "" : ` (HTTP ${response.status})`}`);
21620
+ }
21621
+ if (isRecord(body) && body.status === "ERROR") throw new Error(`${target.host} refused: ${reason(body.reason) || "no reason given"}`);
21622
+ if (!response.ok) throw new Error(`${target.host} answered HTTP ${response.status}`);
21623
+ return body;
21624
+ } finally {
21625
+ clearTimeout(timer);
21626
+ options.signal?.removeEventListener("abort", onAbort);
21429
21627
  }
21430
- const code = DNS_TYPE[type];
21431
- parts.push(Uint8Array.of(0, code >> 8, code & 255, 0, 1));
21432
- return concatBytes$1(...parts);
21433
21628
  }
21434
- function readName(data, start) {
21435
- const labels = [];
21436
- let pos = start, next = -1, jumps = 0, length = 0;
21437
- for (;;) {
21438
- if (pos >= data.length) throw new Error("DNS name out of bounds");
21439
- const len = data[pos];
21440
- if ((len & 192) === 192) {
21441
- if (pos + 1 >= data.length) throw new Error("DNS pointer out of bounds");
21442
- if (next < 0) next = pos + 2;
21443
- pos = (len & 63) << 8 | data[pos + 1];
21444
- if (++jumps > 32) throw new Error("DNS name loops");
21445
- continue;
21446
- }
21447
- if (len & 192) throw new Error("Unsupported DNS label");
21448
- pos += 1;
21449
- if (len === 0) break;
21450
- if (pos + len > data.length) throw new Error("DNS label out of bounds");
21451
- length += len + 1;
21452
- if (length > 255) throw new Error("DNS name too long");
21453
- labels.push(utf8Decode(data.subarray(pos, pos + len)).toLowerCase());
21454
- pos += len;
21629
+ /** Checks a service's `payRequest` answer (LUD-06), and reads what a person needs to see of it. */
21630
+ function parsePayParams(body, destination) {
21631
+ if (!isRecord(body)) throw new Error(`${destination.domain} did not answer with a pay request`);
21632
+ if (body.tag !== "payRequest") throw new Error(`${destination.domain} answered with something other than a pay request${typeof body.tag === "string" ? ` (${reason(body.tag)})` : ""}`);
21633
+ if (typeof body.callback !== "string") throw new Error("The pay request has no callback");
21634
+ const callback = acceptUrl(body.callback);
21635
+ const { minSendable, maxSendable, metadata, commentAllowed } = body;
21636
+ if (!Number.isSafeInteger(minSendable) || !Number.isSafeInteger(maxSendable) || minSendable <= 0 || maxSendable < minSendable) throw new Error("The pay request has invalid amount limits");
21637
+ const minSat = Math.ceil(minSendable / 1e3), maxSat = Math.floor(maxSendable / 1e3);
21638
+ if (minSat > maxSat) throw new Error("The pay request only takes fractions of a sat, which cannot be paid here");
21639
+ if (typeof metadata !== "string" || metadata.length > LNURL_LIMITS.maxMetadataLength) throw new Error("The pay request has no usable metadata");
21640
+ let entries;
21641
+ try {
21642
+ entries = JSON.parse(metadata);
21643
+ } catch {
21644
+ throw new Error("The pay request's metadata is not JSON");
21455
21645
  }
21646
+ if (!Array.isArray(entries) || !entries.every((e) => Array.isArray(e) && typeof e[0] === "string")) throw new Error("The pay request's metadata is malformed");
21647
+ const entry = (type) => entries.find((e) => e[0] === type)?.[1];
21648
+ const description = entry("text/plain");
21649
+ if (typeof description !== "string") throw new Error("The pay request says nothing about what it is for (no text/plain metadata)");
21650
+ const identifier = [entry("text/identifier"), entry("text/email")].find((v) => typeof v === "string")?.toLowerCase();
21651
+ if (destination.kind === "address" && identifier && identifier !== destination.text) throw new Error(`${destination.domain} answered for ${identifier}, not ${destination.text}`);
21652
+ const comment = Number.isSafeInteger(commentAllowed) && commentAllowed > 0 ? Math.min(commentAllowed, LNURL_LIMITS.maxComment) : 0;
21456
21653
  return {
21457
- name: labels.join("."),
21458
- next: next < 0 ? pos : next
21654
+ destination,
21655
+ callback: callback.href,
21656
+ callbackDomain: callback.host,
21657
+ minSendable,
21658
+ maxSendable,
21659
+ minSat,
21660
+ maxSat,
21661
+ metadata,
21662
+ descriptionHash: hex$1(sha256(new TextEncoder().encode(metadata))),
21663
+ description: description.slice(0, 280),
21664
+ identifier,
21665
+ commentAllowed: comment
21459
21666
  };
21460
21667
  }
21668
+ /** Where to ask for the invoice of `amountSat` (and a comment, LUD-12). Throws when the amount or comment is not allowed. */
21669
+ function invoiceCallbackUrl(params, amountSat, comment) {
21670
+ if (!Number.isSafeInteger(amountSat) || amountSat <= 0) throw new Error("Enter a whole number of sats");
21671
+ if (amountSat < params.minSat || amountSat > params.maxSat) throw params.minSat === params.maxSat ? engineError("lnurlExactly", {
21672
+ domain: params.destination.domain,
21673
+ amount: params.minSat.toLocaleString()
21674
+ }) : engineError("lnurlRange", {
21675
+ domain: params.destination.domain,
21676
+ min: params.minSat.toLocaleString(),
21677
+ max: params.maxSat.toLocaleString()
21678
+ });
21679
+ const url = new URL(params.callback);
21680
+ url.searchParams.set("amount", String(amountSat * 1e3));
21681
+ const text = comment?.trim();
21682
+ if (text) {
21683
+ if (!params.commentAllowed) throw new Error(`${params.destination.domain} does not take a comment`);
21684
+ if (text.length > params.commentAllowed) throw new Error(`The comment can be at most ${params.commentAllowed} characters`);
21685
+ url.searchParams.set("comment", text);
21686
+ }
21687
+ return url.href;
21688
+ }
21461
21689
  /**
21462
- * Decodes a resolver's answer to `encodeDnsQuery(name, type)`. Refuses a reply
21463
- * to another question or a truncated one, and keeps only records of the asked
21464
- * type owned by the name or by the CNAME chain the answer itself follows.
21690
+ * Checks the invoice a service answered with (LUD-06): the amount asked, a payment hash, not expired, and
21691
+ * a commitment to the metadata the person was shown: the `h` tag is its sha256, or the invoice carries the
21692
+ * metadata itself as its description (the same commitment, unhashed).
21465
21693
  */
21466
- function decodeDnsResponse(data, name, type) {
21467
- if (data.length < 12) throw new Error("DNS response too short");
21468
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
21469
- const flags = view.getUint16(2);
21470
- if (!(flags & 32768) || flags & 30720 || flags & 512) throw new Error("Unusable DNS response");
21471
- const questions = view.getUint16(4), count = view.getUint16(6);
21472
- const want = name.replace(/\.$/, "").toLowerCase(), code = DNS_TYPE[type];
21473
- if (questions !== 1) throw new Error("DNS response is for another question");
21474
- const q = readName(data, 12);
21475
- if (q.name !== want || q.next + 4 > data.length || view.getUint16(q.next) !== code) throw new Error("DNS response is for another question");
21476
- let pos = q.next + 4;
21477
- const all = [];
21478
- for (let i = 0; i < count && i < 64; i++) {
21479
- const owner = readName(data, pos);
21480
- pos = owner.next;
21481
- if (pos + 10 > data.length) throw new Error("DNS record out of bounds");
21482
- const rtype = view.getUint16(pos), ttl = view.getUint32(pos + 4), len = view.getUint16(pos + 8);
21483
- pos += 10;
21484
- if (pos + len > data.length) throw new Error("DNS record out of bounds");
21485
- const rdata = rtype === DNS_TYPE.CNAME ? utf8Encode(readName(data, pos).name) : data.slice(pos, pos + len);
21486
- all.push({
21487
- name: owner.name,
21488
- type: rtype,
21489
- ttl,
21490
- data: rdata
21491
- });
21492
- pos += len;
21493
- }
21494
- const owners = /* @__PURE__ */ new Set([want]);
21495
- for (let hop = 0; hop < 8; hop++) {
21496
- const cname = all.find((a) => a.type === DNS_TYPE.CNAME && owners.has(a.name) && !owners.has(utf8Decode(a.data)));
21497
- if (!cname) break;
21498
- owners.add(utf8Decode(cname.data));
21694
+ function parseInvoiceCallback(body, params, amountSat) {
21695
+ if (!isRecord(body) || typeof body.pr !== "string") throw new Error(`${params.callbackDomain} did not answer with an invoice`);
21696
+ const invoice = decodeBolt11(body.pr);
21697
+ if (!invoice) throw new Error(`${params.callbackDomain} answered with an invoice that does not decode`);
21698
+ if (invoice.amountMsat !== BigInt(amountSat) * 1000n) throw new Error(`The invoice asks for ${invoice.amountSat === null ? "any amount" : `${invoice.amountSat.toLocaleString()} sats`}, not the ${amountSat.toLocaleString()} sats you chose`);
21699
+ if (!invoice.paymentHash) throw new Error("The invoice has no payment hash");
21700
+ if (invoice.descriptionHash !== params.descriptionHash && invoice.description !== params.metadata) throw new Error("The invoice does not commit to what the service said the payment is for");
21701
+ if (invoice.expiresAt * 1e3 < Date.now()) throw new Error("The invoice the service answered with has already expired");
21702
+ let successAction;
21703
+ const action = body.successAction;
21704
+ if (isRecord(action)) {
21705
+ if (action.tag === "message" && typeof action.message === "string") successAction = {
21706
+ tag: "message",
21707
+ message: reason(action.message)
21708
+ };
21709
+ else if (action.tag === "url" && typeof action.url === "string" && /^https:\/\//i.test(action.url) && action.url.length <= LNURL_LIMITS.maxUrlLength) successAction = {
21710
+ tag: "url",
21711
+ description: reason(action.description),
21712
+ url: action.url
21713
+ };
21499
21714
  }
21500
21715
  return {
21501
- rcode: flags & 15,
21502
- authenticated: !!(flags & 32),
21503
- answers: all.filter((a) => a.type === code && owners.has(a.name))
21716
+ invoice,
21717
+ successAction
21504
21718
  };
21505
21719
  }
21506
- /** The TXT character-strings of one record, joined as RFC 7208 §3.3 does. */
21507
- function txtValue(rdata) {
21508
- const parts = [];
21509
- for (let p = 0; p < rdata.length;) {
21510
- const len = rdata[p];
21511
- if (p + 1 + len > rdata.length) throw new Error("DNS text out of bounds");
21512
- parts.push(rdata.subarray(p + 1, p + 1 + len));
21513
- p += 1 + len;
21514
- }
21515
- return new TextDecoder("utf-8", { fatal: true }).decode(concatBytes$1(...parts));
21720
+ /** Fetches and checks what a Lightning address or LNURL asks for. The service's domain learns of it. */
21721
+ async function resolveLightningDestination(text, options = {}) {
21722
+ const destination = parseLightningDestination(text);
21723
+ if (!destination) throw new Error("That is not a Lightning address or an LNURL");
21724
+ return parsePayParams(await fetchLnurlJson(destination.url, options), destination);
21516
21725
  }
21517
- /** False for loopback, private, link-local, shared (CGNAT), multicast, reserved
21518
- * and unspecified addresses, including IPv4 embedded in IPv6. */
21519
- function isPublicAddress(address) {
21520
- if (address.length === 4) {
21521
- const [a, b, c] = address;
21522
- return !(a === 0 || a === 10 || a === 127 || a >= 224 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 192 && b === 0 && c === 0 || a === 198 && (b === 18 || b === 19));
21523
- }
21524
- if (address.length !== 16) return false;
21525
- const zeroTo = (n) => address.subarray(0, n).every((x) => x === 0);
21526
- if (zeroTo(15) && (address[15] === 0 || address[15] === 1)) return false;
21527
- if (zeroTo(10) && address[10] === 255 && address[11] === 255) return isPublicAddress(address.subarray(12));
21528
- if (address[0] === 0 && address[1] === 100 && address[2] === 255 && address[3] === 155 && address.subarray(4, 12).every((x) => x === 0)) return isPublicAddress(address.subarray(12));
21529
- if (zeroTo(12)) return false;
21530
- if ((address[0] & 254) === 252) return false;
21531
- if (address[0] === 254 && (address[1] & 192) === 128) return false;
21532
- if (address[0] === 254 && (address[1] & 192) === 192) return false;
21533
- if (address[0] === 255) return false;
21534
- if (address[0] === 1 && address.subarray(1, 8).every((x) => x === 0)) return false;
21535
- return (address[0] & 224) === 32;
21726
+ /** Asks the service for an invoice of `amountSat`, and checks it before it is handed to any wallet. */
21727
+ async function requestLnurlInvoice(params, amountSat, comment, options = {}) {
21728
+ return parseInvoiceCallback(await fetchLnurlJson(invoiceCallbackUrl(params, amountSat, comment), options), params, amountSat);
21536
21729
  }
21537
21730
  //#endregion
21538
21731
  //#region ../core/src/pkdns.ts
@@ -24907,7 +25100,9 @@ var GROUP_LIMITS = {
24907
25100
  */
24908
25101
  catchUpSlice: 16,
24909
25102
  /** Between two slices, when the member's app cannot say it handled one (`handled` resolves `false`). */
24910
- catchUpPauseMs: 500
25103
+ catchUpPauseMs: 500,
25104
+ /** A received message is in the saved state within this long (`persistSoon`): a burst of them is one save, not one each. */
25105
+ saveWithinMs: 1e3
24911
25106
  };
24912
25107
  /** What every member should know about who can read what, in the words the apps show. */
24913
25108
  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.`;
@@ -25088,6 +25283,7 @@ function readFirstPlace(o, epoch) {
25088
25283
  /** One of my own frames, among mine: `e:n`. */
25089
25284
  var ownKey = (f) => `${f.e}:${f.n}`;
25090
25285
  /** What a frame weighs in a log, roughly: its boxes plus the fixed fields around them. */
25286
+ /** What a kept frame counts for against `GROUP_LIMITS.relayBytes`. */
25091
25287
  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;
25092
25288
  /** Only the fields an edit frame has, as `clean` does for a message. */
25093
25289
  var cleanEdit = (f) => ({
@@ -25207,6 +25403,8 @@ var GroupSession = class {
25207
25403
  pendingMeta;
25208
25404
  /** While `handle` runs: the frames it took that a hub passes on (`handle`'s result). */
25209
25405
  passOn = null;
25406
+ /** Set while a save of what received messages changed is due (`persistSoon`). */
25407
+ saveTimer;
25210
25408
  /** Syncs answered per member, a few a minute (`GROUP_LIMITS.syncAnswers`). In memory only. */
25211
25409
  syncsAnswered = /* @__PURE__ */ new Map();
25212
25410
  /** Catch-up answers still going out, a slice at a time (`handOut`). */
@@ -25410,11 +25608,14 @@ var GroupSession = class {
25410
25608
  const floor = this.hooks.seqFloor?.() ?? 0;
25411
25609
  return Number.isSafeInteger(floor) && floor > 0 ? floor : 0;
25412
25610
  }
25413
- /** How many messages, per sender, are known to be missing in the current epoch. */
25414
- missing(sender) {
25611
+ /**
25612
+ * How many messages, per sender, are known to be missing in the current epoch. `past`: only the numbers above it,
25613
+ * for a caller that knows nobody holds the ones up to it any more.
25614
+ */
25615
+ missing(sender, past = -1) {
25415
25616
  const entry = this.state.seen[sender]?.[this.epoch];
25416
25617
  if (!entry) return 0;
25417
- const lowest = Math.max(0, entry.high - GROUP_LIMITS.window + 1);
25618
+ const lowest = Math.max(0, entry.high - GROUP_LIMITS.window + 1, past + 1);
25418
25619
  return Math.max(0, entry.high - lowest + 1 - (1 + entry.window.filter((n) => n >= lowest).length));
25419
25620
  }
25420
25621
  /**
@@ -25426,7 +25627,7 @@ var GroupSession = class {
25426
25627
  const out = /* @__PURE__ */ new Map();
25427
25628
  for (const [sender, epochs] of Object.entries(this.state.seen)) {
25428
25629
  if (sender === this.myKey || !rosterHas(this.roster, sender)) continue;
25429
- const marks = Object.entries(epochs).filter(([, entry]) => this.gaps(entry).length).map(([e, entry]) => `${e}:${entry.high}:${entry.window.length}`);
25630
+ const marks = Object.entries(epochs).map(([e, entry]) => [e, this.gaps(entry)]).filter(([, gaps]) => gaps.length).map(([e, gaps]) => `${e}:${gaps.join(".")}`);
25430
25631
  if (marks.length) out.set(sender, marks.join(","));
25431
25632
  }
25432
25633
  return out;
@@ -25455,8 +25656,31 @@ var GroupSession = class {
25455
25656
  return operation;
25456
25657
  }
25457
25658
  persist() {
25659
+ if (this.saveTimer) {
25660
+ clearTimeout(this.saveTimer);
25661
+ this.saveTimer = void 0;
25662
+ }
25458
25663
  return this.hooks.save(structuredClone(this.state));
25459
25664
  }
25665
+ /**
25666
+ * A save within `GROUP_LIMITS.saveWithinMs`, for what a received message changes (what was seen, what is kept to hand
25667
+ * on): the state holds the whole hand-on log, and a member back in a busy group wrote all of it once per message.
25668
+ * The message itself is stored before (`hooks.message`). An app killed before the save takes those frames again when
25669
+ * it syncs: its store has the messages already, and the state then catches up.
25670
+ */
25671
+ persistSoon() {
25672
+ if (this.saveTimer) return;
25673
+ this.saveTimer = setTimeout(() => {
25674
+ this.saveTimer = void 0;
25675
+ this.serialize(() => this.hooks.save(structuredClone(this.state))).catch(() => {});
25676
+ }, GROUP_LIMITS.saveWithinMs);
25677
+ }
25678
+ /** Writes what is waiting to be saved now (before a restart, in tests). */
25679
+ flush() {
25680
+ return this.serialize(async () => {
25681
+ if (this.saveTimer) await this.persist();
25682
+ });
25683
+ }
25460
25684
  secret(epoch) {
25461
25685
  const s = this.state.secrets[epoch];
25462
25686
  return s ? fromBase64Url(s) : void 0;
@@ -25848,7 +26072,7 @@ var GroupSession = class {
25848
26072
  const text = edit.text.trim();
25849
26073
  if (!validEditText(text)) return { error: text ? "Message exceeds 16 KiB" : "An edit cannot be empty" };
25850
26074
  if (!this.secret(ref.e)) return { error: "This message is too old to edit: its epoch's key is gone" };
25851
- const named = validMentions(wireMentions(edit.mentions ?? []), text, rosterAdmin(commit.m) === this.myKey);
26075
+ const named = validMentions(wireMentions(edit.mentions ?? []), text, rosterAdmin(commit.m) === this.myKey && this.isAdmin);
25852
26076
  const plain = JSON.stringify({
25853
26077
  text,
25854
26078
  ...named.length ? { m: named } : {},
@@ -26167,12 +26391,12 @@ var GroupSession = class {
26167
26391
  if (firstFrame && rosterHas(this.state.chain[first.e]?.m ?? [], this.myKey) && this.isDuplicate(firstFrame)) {
26168
26392
  this.markSeen(raw);
26169
26393
  this.keep(clean(raw));
26170
- await this.persist();
26394
+ this.persistSoon();
26171
26395
  if (rosterHas(this.roster, raw.s)) this.took(clean(raw));
26172
26396
  return;
26173
26397
  }
26174
26398
  const id = first && !this.provisional.has(own) ? groupMessageId(raw.s, first.e, first.n) : own;
26175
- const mentions = this.openMentions(key, raw, text, rosterAdmin(commit.m) === raw.s);
26399
+ const mentions = this.openMentions(key, raw, text, rosterAdmin(commit.m) === raw.s && this.admin === raw.s);
26176
26400
  const reply = this.openReply(key, raw);
26177
26401
  const forwarded = readForwarded(raw.f);
26178
26402
  const card = this.openCard(key, raw);
@@ -26190,7 +26414,8 @@ var GroupSession = class {
26190
26414
  ...forwarded ? { forwarded } : {},
26191
26415
  ...card ? { card } : {},
26192
26416
  ...file ? { file } : {},
26193
- ...completes ? { completes: true } : {}
26417
+ ...completes ? { completes: true } : {},
26418
+ ...whole ? { frame: clean(raw) } : {}
26194
26419
  });
26195
26420
  if (!whole) {
26196
26421
  this.provisional.add(id);
@@ -26203,7 +26428,7 @@ var GroupSession = class {
26203
26428
  this.markSeen(raw);
26204
26429
  if (firstFrame && rosterHas(this.state.chain[firstFrame.e]?.m ?? [], this.myKey)) this.markSeen(firstFrame);
26205
26430
  this.keep(clean(raw));
26206
- await this.persist();
26431
+ this.persistSoon();
26207
26432
  if (rosterHas(this.roster, raw.s)) this.took(clean(raw));
26208
26433
  }
26209
26434
  /**
@@ -26235,7 +26460,7 @@ var GroupSession = class {
26235
26460
  return;
26236
26461
  }
26237
26462
  if (!body || typeof body !== "object" || !validEditText(body.text)) return;
26238
- const k = validMentions(body.m, body.text, rosterAdmin(commit.m) === raw.s);
26463
+ const k = validMentions(body.m, body.text, rosterAdmin(commit.m) === raw.s && this.admin === raw.s);
26239
26464
  const sc = body.sc === void 0 ? void 0 : readStatusCard(body.sc);
26240
26465
  if (!validEditNumber(raw.v) && !sc) return;
26241
26466
  const copy = (this.state.relay ?? []).find((f) => f.s === raw.s && f.e === raw.e && f.n === raw.n);
@@ -26863,6 +27088,10 @@ var COMMUNITY_LIMITS = {
26863
27088
  side: 512,
26864
27089
  /** Commits waiting for their parent. */
26865
27090
  pendingCommits: 64,
27091
+ /** Epoch secrets waiting for their commit. */
27092
+ pendingSecrets: 32,
27093
+ /** How long a commit waits for its parent, or a secret for its commit: many catch-ups; after it, a catch-up brings both again. */
27094
+ pendingMs: 12e4,
26866
27095
  waiting: 64,
26867
27096
  waitingBytes: 1048576,
26868
27097
  replay: 256,
@@ -26922,6 +27151,17 @@ var FRESH = /* @__PURE__ */ new Set([
26922
27151
  "rotate"
26923
27152
  ]);
26924
27153
  /**
27154
+ * Commits that make the membership history longer and the group no smaller. A committer signs one only while the
27155
+ * history keeps room for everyone else to get out after it: two commits a member (a leave or a remove is one, and a
27156
+ * leaving admin hands the role over first). The history is never compacted, and one that filled with people in it
27157
+ * took no leave, remove or handover any more. Its own rule: a history up to `COMMUNITY_LIMITS.chain` verifies as before.
27158
+ */
27159
+ var GROWS = /* @__PURE__ */ new Set([
27160
+ "add",
27161
+ "rotate",
27162
+ "link"
27163
+ ]);
27164
+ /**
26925
27165
  * Kinds only the admin commits. They are final: a branch that drops one never replaces the branch
26926
27166
  * that has it, however long, so no member can undo a removal, a hand-over, a rotation or a link change
26927
27167
  * by outgrowing it with admissions.
@@ -27600,13 +27840,20 @@ var CommunitySession = class {
27600
27840
  if (typeof p !== "string" || !Number.isSafeInteger(e)) return false;
27601
27841
  const parent = this.known.get(p), parentRoster = parent ? this.rosterAt(p) : void 0;
27602
27842
  if (!parent || !parentRoster) {
27603
- if (this.pendingCommits.size < COMMUNITY_LIMITS.pendingCommits) {
27604
- this.pendingCommits.set(p + ":" + String(e), {
27843
+ const commit = parseCommit(raw);
27844
+ if (!commit || commit.g !== this.id || !verify(fromBase64Url(commit.sig), utf8Encode(tuple(commit)), publicKeyFromZ32(commit.by))) return false;
27845
+ if (this.wasAdmin(commit.by)) {
27846
+ const now = this.hooks.clock?.() ?? Date.now(), key = p + ":" + String(e);
27847
+ this.dropStale(this.pendingCommits, now);
27848
+ this.pendingCommits.delete(key);
27849
+ if (this.pendingCommits.size >= COMMUNITY_LIMITS.pendingCommits) this.pendingCommits.delete(this.pendingCommits.keys().next().value);
27850
+ this.pendingCommits.set(key, {
27605
27851
  from,
27606
- commit: raw
27852
+ commit,
27853
+ at: now
27607
27854
  });
27608
- this.ask(from);
27609
27855
  }
27856
+ this.ask(from);
27610
27857
  return false;
27611
27858
  }
27612
27859
  const result = verifyCommunityCommit(raw, parent, parentRoster, this.id);
@@ -27642,7 +27889,7 @@ var CommunitySession = class {
27642
27889
  const sealed = this.pendingSecrets.get(h);
27643
27890
  if (sealed) {
27644
27891
  this.pendingSecrets.delete(h);
27645
- this.takeSecret(h, sealed);
27892
+ this.takeSecret(h, sealed.sealed);
27646
27893
  }
27647
27894
  await this.chooseBranch();
27648
27895
  for (const [key, pending] of [...this.pendingCommits]) if (key.startsWith(h + ":")) {
@@ -27656,6 +27903,18 @@ var CommunitySession = class {
27656
27903
  }
27657
27904
  return true;
27658
27905
  }
27906
+ /** Is, or was on the branch I follow, the admin (and was not taken out since). */
27907
+ wasAdmin(key) {
27908
+ if (this.wasRemoved(key)) return false;
27909
+ return this.admin === key || this.state.chain.some((c, i) => i === 0 ? c.by === key : c.k === "role" && c.s === key);
27910
+ }
27911
+ /** What waited longer than `pendingMs` goes (the oldest first: a Map keeps the order they came in). */
27912
+ dropStale(pending, now) {
27913
+ for (const [key, { at }] of pending) {
27914
+ if (now - at < COMMUNITY_LIMITS.pendingMs) break;
27915
+ pending.delete(key);
27916
+ }
27917
+ }
27659
27918
  /**
27660
27919
  * Follows the best branch among the known tips (see `better`); reorganizes the main branch when it
27661
27920
  * changed. A branch that keeps the admin's changes, or a longer one, wins however far back it parts
@@ -27758,7 +28017,15 @@ var CommunitySession = class {
27758
28017
  takeSecret(h, sealed) {
27759
28018
  const c = this.known.get(h);
27760
28019
  if (!c) {
27761
- if (isSealed(sealed) && this.pendingSecrets.size < 32) this.pendingSecrets.set(h, sealed);
28020
+ if (!HASH.test(h) || !isSealed(sealed)) return false;
28021
+ const now = this.hooks.clock?.() ?? Date.now();
28022
+ this.dropStale(this.pendingSecrets, now);
28023
+ this.pendingSecrets.delete(h);
28024
+ if (this.pendingSecrets.size >= COMMUNITY_LIMITS.pendingSecrets) this.pendingSecrets.delete(this.pendingSecrets.keys().next().value);
28025
+ this.pendingSecrets.set(h, {
28026
+ sealed,
28027
+ at: now
28028
+ });
27762
28029
  return false;
27763
28030
  }
27764
28031
  if (this.state.secrets[h] || !isSealed(sealed) || !rosterHas(this.rosterAt(h) ?? [], this.myKey)) return false;
@@ -27823,6 +28090,7 @@ var CommunitySession = class {
27823
28090
  };
27824
28091
  const roster = communityRoster(this.roster, draftBase);
27825
28092
  if (!roster) throw new Error("This change is not allowed");
28093
+ if (GROWS.has(kind) && this.state.chain.length + 1 + 2 * (roster.length - 1) > COMMUNITY_LIMITS.chain) throw new Error("This group has reached its membership history limit. Create a new group.");
27826
28094
  const draft = {
27827
28095
  ...draftBase,
27828
28096
  m: rosterHash(roster)
@@ -28528,7 +28796,9 @@ var CommunitySession = class {
28528
28796
  }
28529
28797
  /** Something is missing here: the current epoch's secret, or the epoch of frames waiting. */
28530
28798
  get needsCatchUp() {
28531
- return this.isMember && (!this.state.secrets[this.topHash] || this.waiting.length > 0 || this.pendingCommits.size > 0);
28799
+ if (!this.isMember) return false;
28800
+ this.dropStale(this.pendingCommits, this.hooks.clock?.() ?? Date.now());
28801
+ return !this.state.secrets[this.topHash] || this.waiting.length > 0 || this.pendingCommits.size > 0;
28532
28802
  }
28533
28803
  /** Asks a member I have an edge to for what I lack (the engine calls it now and then while `needsCatchUp`). */
28534
28804
  catchUp(to) {
@@ -28962,6 +29232,8 @@ var APP_BUNDLE_LIMITS = {
28962
29232
  /** A path, in bytes (paths are ASCII). */
28963
29233
  pathBytes: 128,
28964
29234
  iconBytes: 262144,
29235
+ /** The icon's width and height, in pixels: a client decodes it whole to draw it, whatever its bytes. */
29236
+ iconEdge: 1024,
28965
29237
  screenshots: 8,
28966
29238
  /** Lengths in Unicode code points. */
28967
29239
  title: 40,
@@ -29357,6 +29629,11 @@ function readAppBundle(bytes) {
29357
29629
  reason: "bad-icon",
29358
29630
  detail: "icon.png is not a square PNG"
29359
29631
  };
29632
+ if (size.width > APP_BUNDLE_LIMITS.iconEdge) return {
29633
+ ok: false,
29634
+ reason: "bad-icon",
29635
+ detail: "icon.png is larger than 1024 × 1024 pixels"
29636
+ };
29360
29637
  }
29361
29638
  return {
29362
29639
  ok: true,
@@ -29510,6 +29787,13 @@ var STORE_REQUIRED = [
29510
29787
  "removed",
29511
29788
  "revoked"
29512
29789
  ];
29790
+ /** Runs a check written in steps to its end, in one go. */
29791
+ function inOneRun(steps) {
29792
+ for (;;) {
29793
+ const step = steps.next();
29794
+ if (step.done) return step.value;
29795
+ }
29796
+ }
29513
29797
  /**
29514
29798
  * Checks an index's fields and bounds, and every revocation it carries (one that does not verify refuses the index:
29515
29799
  * the store copied it, so a broken one is the store's fault). The signature and `expires` are `readAppStore`'s. An
@@ -29517,6 +29801,14 @@ var STORE_REQUIRED = [
29517
29801
  * statement and keeps its exact keys.
29518
29802
  */
29519
29803
  function checkAppStoreIndex(value, options = {}) {
29804
+ const fields = inOneRun(checkAppStoreFieldsSteps(value, options));
29805
+ return fields.ok ? inOneRun(checkAppStoreRevocationsSteps(value)) : fields;
29806
+ }
29807
+ /**
29808
+ * The first half of `checkAppStoreIndex`: the fields and bounds, and the shape of every revocation, with no signature
29809
+ * checked. A step after each listing, removal and revocation (see `readAppStoreSteps`).
29810
+ */
29811
+ function* checkAppStoreFieldsSteps(value, options = {}) {
29520
29812
  if (!isObject(value)) return {
29521
29813
  ok: false,
29522
29814
  reason: "bad-field",
@@ -29553,38 +29845,65 @@ function checkAppStoreIndex(value, options = {}) {
29553
29845
  detail: ref
29554
29846
  };
29555
29847
  refs.add(ref);
29848
+ yield;
29556
29849
  }
29557
29850
  if (!Array.isArray(removed) || removed.length > APP_STORE_LIMITS.removed) return bad("removed");
29558
29851
  for (const entry of removed) {
29559
29852
  const checked = checkRemoval(entry, options);
29560
29853
  if (!checked.ok) return checked;
29854
+ yield;
29561
29855
  }
29562
29856
  if (!Array.isArray(revoked) || revoked.length > APP_STORE_LIMITS.revoked) return bad("revoked");
29563
29857
  for (const entry of revoked) {
29564
- const read = readAppRevocation(entry);
29858
+ const read = readAppRevocationShape(entry);
29565
29859
  if (!read.ok) return {
29566
29860
  ok: false,
29567
29861
  reason: "bad-revocation",
29568
29862
  detail: read.detail ?? read.reason
29569
29863
  };
29864
+ yield;
29865
+ }
29866
+ return fine;
29867
+ }
29868
+ /** The second half: the signature of each revocation of an index whose fields hold, one step each. */
29869
+ function* checkAppStoreRevocationsSteps(index, checked) {
29870
+ for (const revocation of index.revoked) {
29871
+ const verified = verifyAppRevocation(revocation, checked);
29872
+ if (!verified.ok) return {
29873
+ ok: false,
29874
+ reason: "bad-revocation",
29875
+ detail: verified.reason
29876
+ };
29877
+ yield;
29570
29878
  }
29571
29879
  return fine;
29572
29880
  }
29573
29881
  /**
29574
- * Reads `ghostly-store.json` with its `ghostly-store.sig`, in this order: size, JSON, canonical bytes, fields and
29575
- * revocations, the signature statement, the key the reader holds for this store (when it holds one), the signature,
29576
- * then `expires` against `now` (seconds): more than 90 days ahead is refused, past is `expired`. A key it does not know
29577
- * is ignored (a later store's optional field) and stays in the bytes the signature covers.
29882
+ * Reads `ghostly-store.json` with its `ghostly-store.sig`, in this order: size, JSON, canonical bytes, fields and the
29883
+ * shape of each revocation, the signature statement, the key the reader holds for this store (when it holds one), the
29884
+ * signature, the signature of each revocation, then `expires` against `now` (seconds): more than 90 days ahead is
29885
+ * refused, past is `expired`. The index's own signature comes before its revocations': an index nobody signed costs
29886
+ * its reader one signature check, whatever it carries. A key it does not know is ignored (a later store's optional
29887
+ * field) and stays in the bytes the signature covers.
29578
29888
  */
29579
29889
  function readAppStore(indexBytes, sigBytes, now, heldKey) {
29890
+ return inOneRun(readAppStoreSteps(indexBytes, sigBytes, now, heldKey));
29891
+ }
29892
+ /**
29893
+ * `readAppStore` in steps, for a reader that shares its thread (a page): it stops after each listing, removal and
29894
+ * revocation, and after each revocation's signature check, so the caller can let the thread go between two `next()`
29895
+ * and an index of 4096 revocations never holds it for seconds. With `checked`, a revocation it holds is not checked again.
29896
+ */
29897
+ function* readAppStoreSteps(indexBytes, sigBytes, now, heldKey, checked) {
29580
29898
  if (indexBytes.length > APP_STORE_LIMITS.indexBytes) return {
29581
29899
  ok: false,
29582
29900
  reason: "too-large"
29583
29901
  };
29584
29902
  const read = readCanonicalJson(indexBytes);
29585
29903
  if (!read.ok) return read;
29586
- const checked = checkAppStoreIndex(read.value);
29587
- if (!checked.ok) return checked;
29904
+ yield;
29905
+ const fields = yield* checkAppStoreFieldsSteps(read.value);
29906
+ if (!fields.ok) return fields;
29588
29907
  const index = read.value;
29589
29908
  const statement = readAppSignature(sigBytes);
29590
29909
  if (!statement.ok) return statement;
@@ -29595,6 +29914,9 @@ function readAppStore(indexBytes, sigBytes, now, heldKey) {
29595
29914
  };
29596
29915
  const signed = verifyAppSignature(APP_PREFIXES.store, indexBytes, statement.signature, index.key);
29597
29916
  if (!signed.ok) return signed;
29917
+ yield;
29918
+ const revocations = yield* checkAppStoreRevocationsSteps(index, checked);
29919
+ if (!revocations.ok) return revocations;
29598
29920
  if (index.expires > now + APP_STORE_LIMITS.expiresAheadS) return {
29599
29921
  ok: false,
29600
29922
  reason: "expires-too-far"
@@ -29685,6 +30007,6 @@ function appStoreDecision(held, read) {
29685
30007
  return read.digest === held.digest ? "same" : "equivocation";
29686
30008
  }
29687
30009
  //#endregion
29688
- export { SSHSIG_NAMESPACE as $, appRef as $a, randomBytes as $c, parseImageMeta as $i, engineError as $n, filePartName as $o, pinNumberHolds as $r, createChatInvite as $s, PaymentPreflightError as $t, decodeGroupEntryLink as A, pairedReplyAuthor as Aa, RelayTransport as Ac, IDENTITY_REVOCATION_LABEL as Ai, encodeDnsQuery as An, RTC_CONFIG as Ao, mergeBeacon as Ar, readSetUpdate as As, didJwsAlg as At, groupWakes as B, cardLine as Ba, createRelayPayload as Bc, lastSharedWithMe as Bi, resolveLightningDestination as Bn, LEGACY_SERVICES as Bo, FILE_LIMITS as Br, enrollCancelReason as Bs, parseDidSignaturePaste as Bt, COMMUNITY_LIMITS as C, nextReactionNumber as Ca, nextTurnPosition as Cc, readManifest as Ci, homeserverWebEndpoint as Cn, formatLocalTarget as Co, COMMUNITY_TOPOLOGY as Cr, readHandoffHello as Cs, DID_DOCUMENT_MAX_BYTES as Ct, decodeCommunityLink as D, readReaction as Da, turnKeys as Dc, dialDescriptors as Di, dnsProofName as Dn, decodeControl as Do, freshHubs as Dr, sealHandoffPiece as Ds, checkDidJws as Dt, communityHasFrame as E, reactionIsNewer as Ea, signTurnRelease as Ec, capsDescriptors as Ei, decodeDnsResponse as En, LIMITS as Eo, doorHubs as Er, readHandoffVerified as Es, DidError as Et, mergeKnocks as F, PR_CHECKS as Fa, budgetRetryMs as Fc, identityRevocationValue as Fi, recordsFromWellKnown as Fn, extractParamsFromSdp as Fo, readBeacon as Fr, ENROLL_DONE as Fs, didWebFileUrl as Ft, COMMUNITY_EDIT_FRAME as G, arrivalKey as Ga, identityFromSeed as Gc, automaticTransport as Gi, findEndpoint as Gn, HANDOFF_HAVE as Go, TypingSender as Gr, enrollProofFrame as Gs, didDhtFromPublicKey as Gt, GROUP_REACTED_FRAME as H, checkStatusCard as Ha, openRelayPayload as Hc, emptyProofLedger as Hi, ENDPOINT as Hn, HANDOFF_ACK as Ho, transferStalled as Hr, enrollDoneFrame as Hs, staticDidDocument as Ht, readKnocks as I, PR_STATES as Ia, isDiscoveryBudgetError as Ic, identityStatement as Ii, recordsName as In, parseCallSignal as Io, readBeaconHead as Ir, ENROLL_GRANT as Is, didWebHost as It, carryMentions as J, receivedTimestamp as Ja, verify as Jc, DHT_FILE_TOO_LARGE as Ji, parseFedimintRequestPayload as Jn, HANDOFF_PAKE as Jo, GROUP_PIN_FRAME as Jr, verifyEnrollProof as Js, resolveDidDht as Jt, GROUP_EDIT_FRAME as K, claimedTime as Ka, identityFromSeedB64 as Kc, irohDescriptor as Ki, isFederationId as Kn, HANDOFF_HELLO as Ko, sanitizeTypingStatus as Kr, readEnrollGrant as Ks, encodeDidDhtDeactivated as Kt, GROUP_WAKE_FRAME as L, RUN_RESULTS as La, withRequestOptions as Lc, newIdentityBinding as Li, txtValue as Ln, signalHasVideo as Lo, readLobby as Lr, ENROLL_HELLO as Ls, normalizeDid as Lt, entryParams as M, replySnippet as Ma, normalizeRelayUrl as Mc, IdentityExchange as Mi, nip05RootKey as Mn, buildSdpFromSignal as Mo, newerHead as Mr, setUpdateFrame as Ms, didWebDocumentUrl as Mt, knockIdentity as N, BUTTON_ID as Na, setLinkTraceSink as Nc, boundVerifiedIdentity as Ni, normalizeDomain as Nn, callRtcConfig as No, pickHubs as Nr, signTombstone as Ns, didWebFile as Nt, encodeCommunityLink as O, validReactionNumber as Oa, turnName as Oc, IDENTITY_MAX_EVIDENCE as Oi, domainTxtRecord as On, encodeControl as Oo, lobbyKeys as Or, SET_ACK as Os, checkDidSignature as Ot, knockRecords as P, ITEM_STATES as Pa, traceLink as Pc, emptyIdentityLedger as Pi, recordsFromTxt as Pn, callSignalHeardAt as Po, rankHubs as Pr, verifySetUpdate as Ps, didWebFileNames as Pt, SSHSIG_MAX_ARMOR as Q, appFingerprint as Qa, fromBase64Url as Qc, dhtFileId as Qi, pairedMessageFrame as Qn, HANDOFF_VERIFIED as Qo, pinIsNewer as Qr, OwnInviteError as Qs, ONCHAIN_PROVIDER as Qt, GroupWakeLimiter as R, STATUS_CARD_LIMITS as Ra, PacketTooLargeError as Rc, receivedIdentityStatus as Ri, wellKnownUrl as Rn, emptyLinkRecords as Ro, shouldBeHub as Rr, ENROLL_PROOF as Rs, parseDid as Rt, sealGroupTyping as S, ReactionWindow as Sa, firstDeviceSetSecret as Sc, newMailbox as Si, pubkyProofPath as Sn, GhostlyHttpError as So, pickMeshHubs as Sr, readHandoffHave as Ss, verifyAtprotoRecordProof as St, GROUP_READ_NOTE_COMMUNITY as T, reactionEmoji as Ta, signTurnPacket as Tc, DHT_TEXT_CAPABILITY as Ti, DOMAIN_FILE_MAX_BYTES as Tn, webLocalFetch as To, beaconRecords as Tr, readHandoffTurnFrame as Ts, DID_JWS_MAX_LENGTH as Tt, GROUP_READ_NOTE as U, sameButtonText as Ua, parseRelayPayload as Uc, validExternalKey as Ui, cashuRequestPayload as Un, HANDOFF_CANCEL as Uo, TYPING_KINDS as Ur, enrollGrantFrame as Us, strictBase64Url as Ut, parseGroupWakeFrame as V, cardUrl as Va, newerPacket as Vc, PeerProofs as Vi, decodeBolt11 as Vn, serviceIdFromName as Vo, transferEnded as Vr, enrollDigits as Vs, signingKeys as Vt, GroupSession as W, statusCardText as Wa, createIdentity as Wc, TRANSPORTS as Wi, fedimintRequestPayload as Wn, HANDOFF_DONE as Wo, TYPING_REFRESH_MS as Wr, enrollHelloFrame as Ws, didDhtDocument as Wt, mentionsMember as X, APP_PREFIXES as Xa, sanitizeAvatar as Xc, peekDhtMailbox as Xi, INVITE_TAKEN as Xn, HANDOFF_REQUEST as Xo, mayPin as Xr, deviceInviteJoinerParams as Xs, ETHEREUM_USDT as Xt, parseCommunityEdit as Y, shownTime as Ya, MAX_AVATAR_LENGTH as Yc, DHT_MESSAGE_TTL as Yi, GhostLink as Yn, HANDOFF_PIECE_BYTES as Yo, PIN_LIMITS as Yr, createDeviceInvite as Ys, signDidDhtPacket as Yt, groupName as Z, APP_STATEMENT_LIMITS as Za, bytesEqual as Zc, DHT_FILE_CAPABILITY as Zi, LIVENESS_PING_MS as Zn, HANDOFF_TIMINGS as Zo, nextPinNumber as Zr, readDeviceInvite as Zs, EVM_TEST_CHAINS as Zt, readAppBundle as _, forwardedAgain as _a, TURN_SETTLE_MS as _c, HOLD_CAPABILITY as _i, isPubkyProofFolder as _n, DirectPathWatch as _o, groupSignalFrame as _r, handoffVersions as _s, isAtprotoDid as _t, planAppUpdate as a, PLAYABLE_AUDIO as aa, ENROLL_CAPABILITY as ac, parseWakeFrame as ai, assertTokenUnits as an, signAppObject as ao, fetchesByItself as ar, handoffContext as as, verifySshSignature as at, groupTypingEpoch as b, GROUP_REACTION_FRAME as ba, TURN_LAST_SEQUENCE as bc, HoldKeys as bi, pubkyProofCapability as bn, RELAY_POLL_INTERVALS as bo, mayBeHub as br, readHandoffCancel as bs, shortAtprotoDid as bt, signAppStore as c, fileMessageText as ca, deviceEchoNonce as cc, checkPushEndpoint as ci, validatePaymentTarget as cn, canonicalJsonBytes as co, readGroupFileMeta as cr, handoffDigest as cs, decodeAnyBitcoinAddress as ct, APP_ICON_PATH as d, parseVideoMeta as da, devicePingFrame as dc, isAppVersion as di, SPARK_PROVIDER as dn, MAX_GROUP_CHAIN as do, readGroupWantNo as dr, handoffManifestFrames as ds, ATPROTO_PROOF_COLLECTION as dt, readImageMeta as ea, decodeInviteCode as ec, readPin as ei, toBase64Url as el, SEPOLIA_TEST_USDT as en, checkAppRevokeStatement as eo, engineText as er, handoffAttemptAllowed as es, SshSigError as et, appDigest as f, EDIT_BUFFER_MS as fa, newDeviceSetSecret as fc, APPS_CAPABILITY as fi, sparkAddressKind as fn, MEMBER_KEY as fo, CarriedTransport as fr, handoffOfferFrame as fs, ATPROTO_PROOF_SCOPE as ft, isAppUrl as g, parseLinkPreview as ga, TURN_RAISE_READ_TIMEOUT_MS as gc, UPGRADE_CAPABILITY as gi, isPubkyKey as gn, edgeParams as go, SIGNAL_REPLY_MS as gr, handoffStreamKey as gs, checkAtprotoProofRecord as gt, checkAppManifest as h, dhtEditId as ha, TURN_PUT_WINDOW_MS as hc, STATUS_CARD_CAPABILITY as hi, PUBKY_PROOF_MAX_BYTES as hn, verifyCommitSignature as ho, SIGNAL_HOLD_MS as hr, handoffRetryAfter as hs, atprotoProofRkey as ht, checkAppBeforeRun as i, sanitizeMime as ia, DEVICES_CAPABILITY as ic, newWakeToken as ii, WALLET_NETWORKS as in, readAppRevocations as io, GROUP_WANT_NO_FRAME as ir, handoffCancelFrame as is, sshSignCommand as it, encodeGroupEntryLink as j, readReply as ja, currentRelays as jc, IdentityCheckUnavailable as ji, isPublicAddress as jn, answersOffer as jo, mergeLobby as jr, setAckFrame as js, didSignedMessages as jt, KNOCK_TTL_MS as k, wireReaction as ka, DEFAULT_RELAYS as kc, IDENTITY_MAX_VALIDITY as ki, domainWellKnownFile as kn, CALL_SIGNAL_MAX_AGE_MS as ko, lobbyRecords as kr, SET_UPDATE as ks, didGhostlyServiceEntry as kt, APP_BUNDLE_LIMITS as l, parseVoiceMeta as la, deviceKeyZ32 as lc, vapidKeysMatch as li, walletNetworkOf as ln, readCanonicalJson as lo, readGroupHave as lr, handoffEphemeral as ls, ATPROTO_DECLARED_SCOPE as lt, assembleAppBundle as m, RateWindow as ma, webCryptoSigner as mc, EDIT_CAPABILITY as mi, isBitcoinAddress as mn, rosterHas as mo, SIGNAL_FRESH_MS as mr, handoffReleaseFrame as ms, atprotoProofRecord as mt, appStoreDecision as n, safeBlobType as na, inviteLink as nc, WakeLimiter as ni, utf8Decode as nl, TEST_USDT_FAUCET_AMOUNT as nn, isAppRef as no, GROUP_HAVE_FRAME as nr, handoffAttemptSucceeded as ns, parseSshSignature as nt, checkAppStoreIndex as o, VOICE_LIMITS as oa, HANDOFF_CAPABILITY as oc, relayRequest as oi, assertWholeSats as on, signAppRevocation as oo, groupFileFallback as or, handoffContextProof as os, verifyBitcoinMessage as ot, appViewOf as p, EDIT_RATE_WINDOW_MS as pa, seedSigner as pc, BUTTONS_CAPABILITY as pi, sparkInvoiceDetails as pn, commitHash as po, GROUP_SIGNAL_FRAME as pr, handoffPakeFrame as ps, atprotoDidMethod as pt, GROUP_EDIT_TEXT_BYTES as q, heardTime as qa, publicKeyFromZ32 as qc, irohRelayUrl as qi, parseCashuRequestPayload as qn, HANDOFF_OFFER as qo, typingActivity as qr, readEnrollHello as qs, encodeDidDhtPacket as qt, appUpdateDecision as r, sanitizeFileName as ra, inviteOwnership as rc, deviceWakeRequest as ri, utf8Encode as rl, USDT_PUBLIC_RPC as rn, readAppRevocation as ro, GROUP_WANT_FRAME as rr, handoffBusyFrame as rs, sameSshKey as rt, readAppStore as s, baseMime as sa, deviceEchoFrame as sc, wakeRequest as si, formatPaymentAmount as sn, canonicalJson as so, isGroupMessageId as sr, handoffDataFrame as ss, BitcoinAddressError as st, appRevocationFor as t, formatFileSize as ta, encodeInviteCode as tc, WAKE_CALL_INTERVAL_MS as ti, toZ32 as tl, SEPOLIA_TEST_USDT_FAUCET as tn, isAppKey as to, GROUP_FILE_LIMITS as tr, handoffAttemptFailed as ts, parseSshPublicKey as tt, APP_BUNDLE_MAGIC as u, voicePeaksOf as ua, deviceLinkPairing as uc, chatAppId as ui, SPARK_NETWORKS as un, GROUP_MEMBER_CAP as uo, readGroupWant as ur, handoffHelloFrame as us, ATPROTO_FULL_SCOPE as ut, GROUP_TYPING_FRAME as v, readForwarded as va, classifyTurnRead as vc, HOLD_DATED_BACK_MS as vi, newPubkyProofFolder as vn, DHT_POLL_INTERVALS as vo, readGroupSignal as vr, newHandoffId as vs, normalizeAtprotoHandle as vt, CommunitySession as w, queueReaction as wa, readTurnPacket as wc, CapsExchange as wi, pubkyHomeserverOf as wn, parseLocalTarget as wo, beaconKeys as wr, readHandoffPake as ws, DID_FILE_MAX_BYTES as wt, openGroupTyping as x, REACTION_LIMITS as xa, TURN_MAX as xc, HoldRefusedError as xi, pubkyProofFile as xn, presenceSeenAt as xo, meshHubs as xr, readHandoffDone as xs, AtprotoProofError as xt, GroupTypingBoard as y, COMMUNITY_REACTION_FRAME as ya, turnPutSummary as yc, HOLD_LIMITS as yi, normalizePubkyKey as yn, EXPECT_PEER_MS as yo, MESH_HUBS as yr, readHandoffAck as ys, parseAtprotoDidDocument as yt, groupWakeFrame as z, buttonLabelClash as za, RELAY_PAYLOAD_MAX_BYTES as zc, revokesIdentity as zi, requestLnurlInvoice as zn, HTTP_SERVICE_PROTO as zo, ChatFiles as zr, enrollCancelFrame as zs, parseDidDocument as zt };
30010
+ export { parseCommunityEdit as $, receivedTimestamp as $a, identityFromSeed as $c, DHT_FILE_TOO_LARGE as $i, readGroupHave as $n, HANDOFF_PAKE as $o, vapidKeysMatch as $r, verifyEnrollProof as $s, signDidDhtPacket as $t, encodeCommunityLink as A, reactionEmoji as Aa, signTurnPacket as Ac, DHT_TEXT_CAPABILITY as Ai, resolveLightningDestination as An, webLocalFetch as Ao, FILE_LIMITS as Ar, readHandoffTurnFrame as As, DidError as At, GroupWakeLimiter as B, PR_CHECKS as Ba, normalizeRelayUrl as Bc, identityRevocationValue as Bi, INVITE_TAKEN as Bn, extractParamsFromSdp as Bo, mayPin as Br, ENROLL_DONE as Bs, didWebHost as Bt, openGroupTyping as C, readForwarded as Ca, classifyTurnRead as Cc, normalizeDomain as Ci, normalizePubkyKey as Cn, DHT_POLL_INTERVALS as Co, pickHubs as Cr, newHandoffId as Cs, parseAtprotoDidDocument as Ct, GROUP_READ_NOTE_COMMUNITY as D, ReactionWindow as Da, firstDeviceSetSecret as Dc, txtValue as Di, homeserverWebEndpoint as Dn, GhostlyHttpError as Do, readLobby as Dr, readHandoffHave as Ds, DID_DOCUMENT_MAX_BYTES as Dt, CommunitySession as E, REACTION_LIMITS as Ea, TURN_MAX as Ec, recordsName as Ei, pubkyProofPath as En, presenceSeenAt as Eo, readBeaconHead as Er, readHandoffDone as Es, verifyAtprotoRecordProof as Et, knockIdentity as F, pairedReplyAuthor as Fa, turnStatedVerifier as Fc, IDENTITY_REVOCATION_LABEL as Fi, findEndpoint as Fn, RTC_CONFIG as Fo, TypingSender as Fr, readSetUpdate as Fs, didSignedMessages as Ft, GROUP_REACTED_FRAME as G, cardLine as Ga, withRequestOptions as Gc, lastSharedWithMe as Gi, GROUP_FILE_LIMITS as Gn, LEGACY_SERVICES as Go, WAKE_CALL_INTERVAL_MS as Gr, enrollCancelReason as Gs, signingKeys as Gt, groupWakes as H, RUN_RESULTS as Ha, traceLink as Hc, newIdentityBinding as Hi, pairedMessageFrame as Hn, signalHasVideo as Ho, pinIsNewer as Hr, ENROLL_HELLO as Hs, parseDid as Ht, knockRecords as I, readReply as Ia, turnVerifierMark as Ic, IdentityCheckUnavailable as Ii, isFederationId as In, answersOffer as Io, sanitizeTypingStatus as Ir, setAckFrame as Is, didWebDocumentUrl as It, frameBytes as J, sameButtonText as Ja, createRelayPayload as Jc, validExternalKey as Ji, GROUP_WANT_NO_FRAME as Jn, HANDOFF_CANCEL as Jo, newWakeToken as Jr, enrollGrantFrame as Js, didDhtDocument as Jt, GROUP_READ_NOTE as K, cardUrl as Ka, PacketTooLargeError as Kc, PeerProofs as Ki, GROUP_HAVE_FRAME as Kn, serviceIdFromName as Ko, WakeLimiter as Kr, enrollDigits as Ks, staticDidDocument as Kt, mergeKnocks as L, replySnippet as La, DEFAULT_RELAYS as Lc, IdentityExchange as Li, parseCashuRequestPayload as Ln, buildSdpFromSignal as Lo, typingActivity as Lr, setUpdateFrame as Ls, didWebFile as Lt, decodeGroupEntryLink as M, readReaction as Ma, turnInstance as Mc, dialDescriptors as Mi, ENDPOINT as Mn, decodeControl as Mo, transferStalled as Mr, sealHandoffPiece as Ms, checkDidSignature as Mt, encodeGroupEntryLink as N, validReactionNumber as Na, turnKeys as Nc, IDENTITY_MAX_EVIDENCE as Ni, cashuRequestPayload as Nn, encodeControl as No, TYPING_KINDS as Nr, SET_ACK as Ns, didGhostlyServiceEntry as Nt, communityHasFrame as O, nextReactionNumber as Oa, nextTurnPosition as Oc, wellKnownUrl as Oi, pubkyHomeserverOf as On, formatLocalTarget as Oo, shouldBeHub as Or, readHandoffHello as Os, DID_FILE_MAX_BYTES as Ot, entryParams as P, wireReaction as Pa, turnName as Pc, IDENTITY_MAX_VALIDITY as Pi, fedimintRequestPayload as Pn, CALL_SIGNAL_MAX_AGE_MS as Po, TYPING_REFRESH_MS as Pr, SET_UPDATE as Ps, didJwsAlg as Pt, carryMentions as Q, heardTime as Qa, createIdentity as Qc, irohRelayUrl as Qi, readGroupFileMeta as Qn, HANDOFF_OFFER as Qo, checkPushEndpoint as Qr, readEnrollHello as Qs, resolveDidDht as Qt, readKnocks as R, BUTTON_ID as Ra, RelayTransport as Rc, boundVerifiedIdentity as Ri, parseFedimintRequestPayload as Rn, callRtcConfig as Ro, GROUP_PIN_FRAME as Rr, signTombstone as Rs, didWebFileNames as Rt, groupTypingEpoch as S, forwardedAgain as Sa, TURN_SETTLE_MS as Sc, nip05RootKey as Si, newPubkyProofFolder as Sn, DirectPathWatch as So, newerHead as Sr, handoffVersions as Ss, normalizeAtprotoHandle as St, COMMUNITY_LIMITS as T, GROUP_REACTION_FRAME as Ta, TURN_LAST_SEQUENCE as Tc, recordsFromWellKnown as Ti, pubkyProofFile as Tn, RELAY_POLL_INTERVALS as To, readBeacon as Tr, readHandoffCancel as Ts, AtprotoProofError as Tt, parseGroupWakeFrame as U, STATUS_CARD_LIMITS as Ua, budgetRetryMs as Uc, receivedIdentityStatus as Ui, engineError as Un, emptyLinkRecords as Uo, pinNumberHolds as Ur, ENROLL_PROOF as Us, parseDidDocument as Ut, groupWakeFrame as V, PR_STATES as Va, setLinkTraceSink as Vc, identityStatement as Vi, LIVENESS_PING_MS as Vn, parseCallSignal as Vo, nextPinNumber as Vr, ENROLL_GRANT as Vs, normalizeDid as Vt, GROUP_LIMITS as W, buttonLabelClash as Wa, isDiscoveryBudgetError as Wc, revokesIdentity as Wi, engineText as Wn, HTTP_SERVICE_PROTO as Wo, readPin as Wr, enrollCancelFrame as Ws, parseDidSignaturePaste as Wt, GROUP_EDIT_FRAME as X, arrivalKey as Xa, openRelayPayload as Xc, automaticTransport as Xi, groupFileFallback as Xn, HANDOFF_HAVE as Xo, relayRequest as Xr, enrollProofFrame as Xs, encodeDidDhtDeactivated as Xt, COMMUNITY_EDIT_FRAME as Y, statusCardText as Ya, newerPacket as Yc, TRANSPORTS as Yi, fetchesByItself as Yn, HANDOFF_DONE as Yo, parseWakeFrame as Yr, enrollHelloFrame as Ys, didDhtFromPublicKey as Yt, GROUP_EDIT_TEXT_BYTES as Z, claimedTime as Za, parseRelayPayload as Zc, irohDescriptor as Zi, isGroupMessageId as Zn, HANDOFF_HELLO as Zo, wakeRequest as Zr, readEnrollGrant as Zs, encodeDidDhtPacket as Zt, checkAppManifest as _, EDIT_BUFFER_MS as _a, newDeviceSetSecret as _c, dnsProofName as _i, sparkInvoiceDetails as _n, MEMBER_KEY as _o, freshHubs as _r, handoffOfferFrame as _s, atprotoDidMethod as _t, planAppUpdate as a, readImageMeta as aa, decodeInviteCode as ac, STATUS_CARD_CAPABILITY as ai, bytesEqual as al, SEPOLIA_TEST_USDT_FAUCET as an, checkAppRevokeStatement as ao, SIGNAL_HOLD_MS as ar, handoffAttemptAllowed as as, parseSshPublicKey as at, GROUP_TYPING_FRAME as b, dhtEditId as ba, TURN_PUT_WINDOW_MS as bc, encodeDnsQuery as bi, isPubkyKey as bn, verifyCommitSignature as bo, mergeBeacon as br, handoffRetryAfter as bs, checkAtprotoProofRecord as bt, readAppStore as c, sanitizeFileName as ca, inviteOwnership as cc, HOLD_DATED_BACK_MS as ci, toBase64Url as cl, WALLET_NETWORKS as cn, readAppRevocation as co, readGroupSignal as cr, handoffBusyFrame as cs, sshSignCommand as ct, APP_BUNDLE_LIMITS as d, VOICE_LIMITS as da, HANDOFF_CAPABILITY as dc, HoldRefusedError as di, utf8Encode as dl, formatPaymentAmount as dn, signAppRevocation as do, meshHubs as dr, handoffContextProof as ds, BitcoinAddressError as dt, DHT_MESSAGE_TTL as ea, createDeviceInvite as ec, chatAppId as ei, identityFromSeedB64 as el, ETHEREUM_USDT as en, shownTime as eo, readGroupWant as er, HANDOFF_PIECE_BYTES as es, mentionsMember as et, APP_BUNDLE_MAGIC as f, baseMime as fa, deviceEchoFrame as fc, isHoldUrl as fi, validatePaymentTarget as fn, canonicalJson as fo, pickMeshHubs as fr, handoffDataFrame as fs, decodeAnyBitcoinAddress as ft, assembleAppBundle as g, parseVideoMeta as ga, devicePingFrame as gc, decodeDnsResponse as gi, sparkAddressKind as gn, MAX_GROUP_CHAIN as go, doorHubs as gr, handoffManifestFrames as gs, ATPROTO_PROOF_SCOPE as gt, appViewOf as h, voicePeaksOf as ha, deviceLinkPairing as hc, DOMAIN_FILE_MAX_BYTES as hi, SPARK_PROVIDER as hn, GROUP_MEMBER_CAP as ho, beaconRecords as hr, handoffHelloFrame as hs, ATPROTO_PROOF_COLLECTION as ht, checkAppBeforeRun as i, parseImageMeta as ia, createChatInvite as ic, EDIT_CAPABILITY as ii, sanitizeAvatar as il, SEPOLIA_TEST_USDT as in, appRef as io, SIGNAL_FRESH_MS as ir, filePartName as is, SshSigError as it, KNOCK_TTL_MS as j, reactionIsNewer as ja, signTurnRelease as jc, capsDescriptors as ji, decodeBolt11 as jn, LIMITS as jo, transferEnded as jr, readHandoffVerified as js, checkDidJws as jt, decodeCommunityLink as k, queueReaction as ka, readTurnPacket as kc, CapsExchange as ki, requestLnurlInvoice as kn, parseLocalTarget as ko, ChatFiles as kr, readHandoffPake as ks, DID_JWS_MAX_LENGTH as kt, readAppStoreSteps as l, sanitizeMime as la, DEVICES_CAPABILITY as lc, HOLD_LIMITS as li, toZ32 as ll, assertTokenUnits as ln, readAppRevocations as lo, MESH_HUBS as lr, handoffCancelFrame as ls, verifySshSignature as lt, appDigest as m, parseVoiceMeta as ma, deviceKeyZ32 as mc, readManifest as mi, SPARK_NETWORKS as mn, readCanonicalJson as mo, beaconKeys as mr, handoffEphemeral as ms, ATPROTO_FULL_SCOPE as mt, appStoreDecision as n, DHT_FILE_CAPABILITY as na, readDeviceInvite as nc, APPS_CAPABILITY as ni, verify as nl, ONCHAIN_PROVIDER as nn, APP_STATEMENT_LIMITS as no, CarriedTransport as nr, HANDOFF_TIMINGS as ns, SSHSIG_MAX_ARMOR as nt, APP_STORE_LIMITS as o, formatFileSize as oa, encodeInviteCode as oc, UPGRADE_CAPABILITY as oi, fromBase64Url as ol, TEST_USDT_FAUCET_AMOUNT as on, isAppKey as oo, SIGNAL_REPLY_MS as or, handoffAttemptFailed as os, parseSshSignature as ot, APP_ICON_PATH as p, fileMessageText as pa, deviceEchoNonce as pc, newMailbox as pi, walletNetworkOf as pn, canonicalJsonBytes as po, COMMUNITY_TOPOLOGY as pr, handoffDigest as ps, ATPROTO_DECLARED_SCOPE as pt, GroupSession as q, checkStatusCard as qa, RELAY_PAYLOAD_MAX_BYTES as qc, emptyProofLedger as qi, GROUP_WANT_FRAME as qn, HANDOFF_ACK as qo, deviceWakeRequest as qr, enrollDoneFrame as qs, strictBase64Url as qt, appUpdateDecision as r, dhtFileId as ra, OwnInviteError as rc, BUTTONS_CAPABILITY as ri, MAX_AVATAR_LENGTH as rl, PaymentPreflightError as rn, appFingerprint as ro, GROUP_SIGNAL_FRAME as rr, HANDOFF_VERIFIED as rs, SSHSIG_NAMESPACE as rt, checkAppStoreIndex as s, safeBlobType as sa, inviteLink as sc, HOLD_CAPABILITY as si, randomBytes as sl, USDT_PUBLIC_RPC as sn, isAppRef as so, groupSignalFrame as sr, handoffAttemptSucceeded as ss, sameSshKey as st, appRevocationFor as t, peekDhtMailbox as ta, deviceInviteJoinerParams as tc, isAppVersion as ti, publicKeyFromZ32 as tl, EVM_TEST_CHAINS as tn, APP_PREFIXES as to, readGroupWantNo as tr, HANDOFF_REQUEST as ts, groupName as tt, signAppStore as u, PLAYABLE_AUDIO as ua, ENROLL_CAPABILITY as uc, HoldKeys as ui, utf8Decode as ul, assertWholeSats as un, signAppObject as uo, mayBeHub as ur, handoffContext as us, verifyBitcoinMessage as ut, isAppUrl as v, EDIT_RATE_WINDOW_MS as va, seedSigner as vc, domainTxtRecord as vi, isBitcoinAddress as vn, commitHash as vo, lobbyKeys as vr, handoffPakeFrame as vs, atprotoProofRecord as vt, sealGroupTyping as w, COMMUNITY_REACTION_FRAME as wa, turnPutSummary as wc, recordsFromTxt as wi, pubkyProofCapability as wn, EXPECT_PEER_MS as wo, rankHubs as wr, readHandoffAck as ws, shortAtprotoDid as wt, GroupTypingBoard as x, parseLinkPreview as xa, TURN_RAISE_READ_TIMEOUT_MS as xc, isPublicAddress as xi, isPubkyProofFolder as xn, edgeParams as xo, mergeLobby as xr, handoffStreamKey as xs, isAtprotoDid as xt, readAppBundle as y, RateWindow as ya, webCryptoSigner as yc, domainWellKnownFile as yi, PUBKY_PROOF_MAX_BYTES as yn, rosterHas as yo, lobbyRecords as yr, handoffReleaseFrame as ys, atprotoProofRkey as yt, GROUP_WAKE_FRAME as z, ITEM_STATES as za, currentRelays as zc, emptyIdentityLedger as zi, GhostLink as zn, callSignalHeardAt as zo, PIN_LIMITS as zr, verifySetUpdate as zs, didWebFileUrl as zt };
29689
30011
 
29690
- //# sourceMappingURL=src-aKGjb_yz.js.map
30012
+ //# sourceMappingURL=src-_FzJkeAJ.js.map