@ghostlytools/cli 1.2.0 → 1.2.2

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) {
@@ -764,12 +775,15 @@ var asker = (options, write) => ({
764
775
  write,
765
776
  signal: !!options.signal,
766
777
  watch: !!options.watch && !write,
767
- watchWrite: !!options.watch && write
778
+ watchWrite: !!options.watch && write,
779
+ departed: !!options.departed && !write
768
780
  });
769
781
  /** A 1:1 chat's request: neither a group's nor a background one. */
770
782
  var isChat = (who) => !who.background && !who.group;
771
783
  /** A 1:1 chat's offer or answer, or its read for the answer to its offer: it may use the allowance (`SIGNALING_ALLOWANCE_SHARE`). */
772
784
  var isChatSignal = (who) => who.signal && isChat(who);
785
+ /** A 1:1 chat's read of a contact that just went away: a few may use the chat's allowance (`DEPARTED_READS`). */
786
+ var isDepartedRead = (who) => who.departed && isChat(who);
773
787
  /** A group's edge looking fast for a signal (`GROUP_BURST_MS`). */
774
788
  var isGroupUrgentRead = (who) => who.group && who.urgent && !who.write && !who.background;
775
789
  /**
@@ -887,7 +901,8 @@ var CATCH_UP_ASKER = {
887
901
  write: true,
888
902
  signal: false,
889
903
  watch: false,
890
- watchWrite: false
904
+ watchWrite: false,
905
+ departed: false
891
906
  };
892
907
  /** Keys whose last relay is remembered for their next read (`resolve`): a profile's links and lookups, with room. */
893
908
  var READ_TURNS_KEPT = 512;
@@ -968,6 +983,8 @@ var RelayTransport = class {
968
983
  spentWatch = /* @__PURE__ */ new Map();
969
984
  /** Groups' reads past the limit on each relay in the last `GROUP_OVER_PACE_MS` (`GROUP_SIGNALING_ALLOWANCE_SHARE`). */
970
985
  spentGroupOver = /* @__PURE__ */ new Map();
986
+ /** Per contact, its departed reads past the limit in the last minute, every relay together (`DEPARTED_READS`). */
987
+ departedOver = /* @__PURE__ */ new Map();
971
988
  /**
972
989
  * Per key a group reads (not in the background), when a read of it last went out and when it last asked: whose turn
973
990
  * it is under the watch share (`WATCH_SHARE`) and while the groups' reads are rationed (`GROUP_RATION_WINDOW_MS`).
@@ -1575,6 +1592,7 @@ var RelayTransport = class {
1575
1592
  }
1576
1593
  if (writer) this.writeWaiting.delete(`${writer} ${relay}`);
1577
1594
  if (isGroupSignal(who) && !who.write && this.spent.get(relay).length >= this.limitOf(relay)) this.spentGroupOver.get(relay).push(now);
1595
+ if (isDepartedRead(who) && this.spent.get(relay).length >= this.limitOf(relay)) this.departedOverAdd(key, now);
1578
1596
  this.spent.get(relay).push(now);
1579
1597
  if (who.background) this.spentBackground.get(relay).push(now);
1580
1598
  if (isGroupUrgentRead(who)) this.spentGroupUrgent.get(relay).push(now);
@@ -1597,7 +1615,8 @@ var RelayTransport = class {
1597
1615
  *
1598
1616
  * Who goes first: a chat's refused write holds back everything but chat writes; a group's refused write holds back
1599
1617
  * group reads and background requests, never a chat's. Background requests yield to a link that signals. A chat's
1600
- * signaling goes over the limit by its allowance (`SIGNALING_ALLOWANCE_SHARE`).
1618
+ * signaling goes over the limit by its allowance (`SIGNALING_ALLOWANCE_SHARE`), and so do a few of its reads of a
1619
+ * contact that just went away (`DEPARTED_READS`).
1601
1620
  */
1602
1621
  heldFor(relay, who, now = Date.now(), key) {
1603
1622
  const recent = (this.spent.get(relay) ?? []).filter((at) => now - at < 6e4);
@@ -1606,7 +1625,8 @@ var RelayTransport = class {
1606
1625
  this.spentBackground.set(relay, recentBackground);
1607
1626
  const over = (list, limit) => list.length >= limit ? list[list.length - limit] + 6e4 - now : 0;
1608
1627
  const limit = this.limitOf(relay);
1609
- const share = isChatSignal(who) ? SIGNALING_ALLOWANCE_SHARE : isGroupSignal(who) ? GROUP_SIGNALING_ALLOWANCE_SHARE : 0;
1628
+ const departed = isDepartedRead(who) && key !== void 0 && recent.length >= limit && this.departedReads(key, now) < 6;
1629
+ const share = isChatSignal(who) || departed ? SIGNALING_ALLOWANCE_SHARE : isGroupSignal(who) ? GROUP_SIGNALING_ALLOWANCE_SHARE : 0;
1610
1630
  let wait = over(recent, limit + (share ? Math.max(0, Math.floor(limit * share) - (who.write ? 0 : 1)) : 0));
1611
1631
  const groupOver = (this.spentGroupOver.get(relay) ?? []).filter((at) => now - at < GROUP_OVER_PACE_MS);
1612
1632
  this.spentGroupOver.set(relay, groupOver);
@@ -1646,7 +1666,7 @@ var RelayTransport = class {
1646
1666
  const at = this.writeWaiting.get(`${lane} ${relay}`);
1647
1667
  return at !== void 0 && now - at < 5e3 ? at + WRITE_FIRST_MS - now : 0;
1648
1668
  };
1649
- if (writer !== "chat") wait = Math.max(wait, waiting("chat"));
1669
+ if (writer !== "chat" && !departed) wait = Math.max(wait, waiting("chat"));
1650
1670
  if (writer === null && who.group && !pastLimit || who.background) wait = Math.max(wait, waiting("group"));
1651
1671
  return Math.max(wait, 0);
1652
1672
  }
@@ -1677,6 +1697,18 @@ var RelayTransport = class {
1677
1697
  }
1678
1698
  return ahead;
1679
1699
  }
1700
+ /** This contact's departed reads past the limit in the last minute (`DEPARTED_READS`). */
1701
+ departedReads(key, now) {
1702
+ const reads = (this.departedOver.get(key) ?? []).filter((at) => now - at < 6e4);
1703
+ if (reads.length) this.departedOver.set(key, reads);
1704
+ else this.departedOver.delete(key);
1705
+ return reads.length;
1706
+ }
1707
+ /** One more, the contacts with none in the last minute dropped. */
1708
+ departedOverAdd(key, now) {
1709
+ for (const [other, reads] of this.departedOver) if (now - reads[reads.length - 1] >= 6e4) this.departedOver.delete(other);
1710
+ this.departedOver.set(key, [...this.departedOver.get(key) ?? [], now]);
1711
+ }
1680
1712
  chatNeed(key) {
1681
1713
  let need = this.chatNeeds.get(key);
1682
1714
  if (!need) this.chatNeeds.set(key, need = {
@@ -1987,7 +2019,8 @@ var isZero = (bytes) => bytes.every((b) => b === 0);
1987
2019
  * 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).
1988
2020
  * A character is what a person sees as one (a grapheme), so a cut never leaves half an emoji or a lone joiner.
1989
2021
  * A name is display text a device chooses for itself and every device of the set shows: it is cleaned as a nickname is
1990
- * (`sanitizeDisplayText`: no control, invisible or direction-changing character) and trimmed, made and read alike.
2022
+ * (`sanitizeDisplayText`: no control, invisible or direction-changing character, and empty when nothing of it shows)
2023
+ * and trimmed, made and read alike.
1991
2024
  */
1992
2025
  function turnName(name) {
1993
2026
  const clean = stripHidden(name).trim();
@@ -2000,7 +2033,7 @@ function turnName(name) {
2000
2033
  out += character;
2001
2034
  length += size;
2002
2035
  }
2003
- return out.trimEnd();
2036
+ return hasVisible(out) ? out.trimEnd() : "";
2004
2037
  }
2005
2038
  function u32$3(value) {
2006
2039
  return Uint8Array.of(value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255);
@@ -5532,6 +5565,13 @@ var WATCH_PEER_MS = 12e4;
5532
5565
  * past the 12 s such a connection is given (`DISCONNECT_GRACE_MS`), by when it is back or the session is gone.
5533
5566
  */
5534
5567
  var STALLED_LOOK_MS = 15e3;
5568
+ /**
5569
+ * For this long after its contact went away from a live session, a chat's reads of it say so (`PkarrRequestOptions.departed`):
5570
+ * a few may go over the relays' budget (`DEPARTED_READS` in relay.ts). The fast window that follows a goodbye
5571
+ * (`EXPECT_PEER_MS`): an app that restarts has its offer out well within it (2.5 to 10 s after its goodbye in the lab of
5572
+ * #1441, on a loaded host), and past it the race's DHT dial has brought the chat back anyway.
5573
+ */
5574
+ var DEPARTED_READ_MS = EXPECT_PEER_MS;
5535
5575
  var PUBLISH_RETRY_MS = 4e3;
5536
5576
  /**
5537
5577
  * A packet the relays' request budget held back goes again when the budget frees a request, and at least this often
@@ -5592,6 +5632,8 @@ var LinkSession = class {
5592
5632
  watchUntil = 0;
5593
5633
  /** The time of the peer's packet read last when it went away (`watchPeer`): a newer one that advertises says it is back. */
5594
5634
  watchFrom = 0;
5635
+ /** Until when the reads of a contact that went away are its first ones (`DEPARTED_READ_MS`). */
5636
+ departedUntil = 0;
5595
5637
  publishRetryTimer = null;
5596
5638
  active = false;
5597
5639
  connected = false;
@@ -5749,6 +5791,7 @@ var LinkSession = class {
5749
5791
  watchPeer() {
5750
5792
  this.watchUntil = Math.max(this.watchUntil, Date.now() + WATCH_PEER_MS);
5751
5793
  this.watchFrom = this.presence.lastPacketAt;
5794
+ this.departedUntil = Date.now() + DEPARTED_READ_MS;
5752
5795
  this.expectPeer();
5753
5796
  }
5754
5797
  /** The data link carries chat and services while it is up. */
@@ -6017,11 +6060,13 @@ var LinkSession = class {
6017
6060
  const pace = this.pace();
6018
6061
  const signal = pace === "fast" && this.fastStepsAfter > 0 && this.rtcSignalOut !== null;
6019
6062
  const watch = !signal && this.watching();
6063
+ const departed = !signal && Date.now() < this.departedUntil;
6020
6064
  const packet = await this.transport.resolve(this.peerPubKeyZ32, {
6021
6065
  background: pace === "background" || pace === "connected",
6022
6066
  urgent: pace === "fast",
6023
6067
  ...signal && { signal },
6024
- ...watch && { watch }
6068
+ ...watch && { watch },
6069
+ ...departed && { departed }
6025
6070
  });
6026
6071
  if (!this.running) return;
6027
6072
  this.discoveryResult("read");
@@ -7194,11 +7239,23 @@ async function signAppRevocation(statement, signer) {
7194
7239
  signature
7195
7240
  };
7196
7241
  }
7242
+ /** What a checked revocation is kept by: the SHA-256 of its canonical bytes (statement and signature), base64url. */
7243
+ function appRevocationId(revocation) {
7244
+ return toBase64Url(sha256(canonicalJsonBytes(revocation)));
7245
+ }
7197
7246
  /**
7198
7247
  * Reads one signed revocation (a parsed value, from `ghostly-revoke.json` or a store index's `revoked`): its shape, then
7199
- * the signature under `ghostly-revoke/1` by the publisher its `app` names.
7248
+ * the signature under `ghostly-revoke/1` by the publisher its `app` names. One that `checked` holds verified before, so
7249
+ * its signature is not checked again; one that verifies now is added to it.
7200
7250
  */
7201
- function readAppRevocation(value) {
7251
+ function readAppRevocation(value, checked) {
7252
+ const read = readAppRevocationShape(value);
7253
+ if (!read.ok) return read;
7254
+ const verified = verifyAppRevocation(read.revocation, checked);
7255
+ return verified.ok ? read : verified;
7256
+ }
7257
+ /** The first half of `readAppRevocation`: the shape of a signed revocation, with no signature checked. */
7258
+ function readAppRevocationShape(value) {
7202
7259
  if (!exactKeys$1(value, ["statement", "signature"])) return {
7203
7260
  ok: false,
7204
7261
  reason: "bad-revocation",
@@ -7214,17 +7271,22 @@ function readAppRevocation(value) {
7214
7271
  ok: false,
7215
7272
  reason: "bad-signature-statement"
7216
7273
  };
7217
- const statement = value.statement;
7218
- const checked = verifyAppSignature(APP_PREFIXES.revoke, canonicalJsonBytes(statement), value.signature, appRefPublisher(statement.app));
7219
- if (!checked.ok) return checked;
7220
7274
  return {
7221
7275
  ok: true,
7222
7276
  revocation: {
7223
- statement,
7277
+ statement: value.statement,
7224
7278
  signature: value.signature
7225
7279
  }
7226
7280
  };
7227
7281
  }
7282
+ /** The second half: the signature of a revocation of the right shape, unless `checked` holds it. */
7283
+ function verifyAppRevocation(revocation, checked) {
7284
+ const id = checked && appRevocationId(revocation);
7285
+ if (id !== void 0 && checked.has(id)) return { ok: true };
7286
+ const verified = verifyAppSignature(APP_PREFIXES.revoke, canonicalJsonBytes(revocation.statement), revocation.signature, appRefPublisher(revocation.statement.app));
7287
+ if (verified.ok && id !== void 0) checked.add(id);
7288
+ return verified;
7289
+ }
7228
7290
  /**
7229
7291
  * Reads `ghostly-revoke.json`: canonical bytes holding a list of signed revocations. One that does not verify refuses the
7230
7292
  * file, as a store index's `revoked` does.
@@ -9380,6 +9442,16 @@ var DhtDelivery = class DhtDelivery {
9380
9442
  }
9381
9443
  this.fastUntil = 0;
9382
9444
  this.signalReads = 0;
9445
+ if (this.options.credentials.peerKey && (!this.state.wasLive || this.state.inviteTaken)) this.serialize(async () => {
9446
+ const taken = this.state.inviteTaken;
9447
+ if (taken) traceLink(this.from, "invite-untaken", {});
9448
+ const { inviteTaken: _taken, ...kept } = this.state;
9449
+ await this.persist({
9450
+ ...kept,
9451
+ wasLive: true
9452
+ });
9453
+ if (taken) this.changed();
9454
+ }).catch(() => {});
9383
9455
  if (this.options.credentials.peerKey && this.state.peerMode) this.tick();
9384
9456
  else this.schedule();
9385
9457
  }
@@ -9835,14 +9907,15 @@ var DhtDelivery = class DhtDelivery {
9835
9907
  }
9836
9908
  /**
9837
9909
  * The contact's envelope is sealed to another key (`open`): it pinned someone else who used the same invite first, so
9838
- * nothing of this side's will ever be read. Said once this side is not proven the one it pinned (never seen in the
9839
- * pinned mailbox, no text confirmed, never live). The hint is sealed under the invite's key, which any copy of it
9840
- * holds: a passive warning, taken back by the contact's next envelope that opens here.
9910
+ * nothing of this side's will ever be read. Said only while this side is not proven the one it pinned: no envelope
9911
+ * sealed to this side read (`peerPinned`), no text confirmed, never live (`wasLive`). The hint is sealed under the
9912
+ * invite's key, which any copy of it holds, so past that proof it says nothing of the contact: a passive warning,
9913
+ * taken back by the contact's next envelope that opens here, or by a live session.
9841
9914
  */
9842
9915
  async sealedToAnother(sender) {
9843
9916
  const expected = this.options.credentials.peerKey ?? this.options.credentials.expectedPeerKey;
9844
9917
  if (this.state.inviteTaken || !expected || sender !== expected) return;
9845
- if (this.state.peerPinned === "seen" || this.state.confirmed || this.live) return;
9918
+ if (this.state.peerPinned || this.state.confirmed || this.state.wasLive || this.live) return;
9846
9919
  traceLink(this.from, "invite-taken", {});
9847
9920
  await this.persist({
9848
9921
  ...this.state,
@@ -12535,219 +12608,491 @@ async function fitSignedPairedSignalWith(signal, signer, from, to, fits) {
12535
12608
  }
12536
12609
  }
12537
12610
  //#endregion
12538
- //#region ../core/src/storeForward.ts
12539
- /**
12540
- * Store-and-forward for an away contact (WISP 404 draft, `hold/1`).
12541
- *
12542
- * What a person sends while the contact is away is sealed into a *bundle* (signed by the sender's
12543
- * participation key, then encrypted to a key only the two of them can derive), put in the sender's
12544
- * own storage (WISP 1000), and named in a *manifest* the storage also holds. A small signed, encrypted
12545
- * *pointer* record on a per-link Pkarr key tells the contact where the manifest is (a URL that expires)
12546
- * and which of the contact's own bundles have been received. Storage and relays see ciphertext, sizes
12547
- * and timing only. Nothing here promises forward secrecy or retention beyond the bounds below.
12548
- */
12549
- var HOLD_CAPABILITY = "hold/1";
12550
- var HOLD_LIMITS = {
12551
- /** One bundle, ciphertext and header included: a picture, not a video. */
12552
- maxBundleBytes: 8388608,
12553
- /** All bundles held for one contact at once. */
12554
- maxMailboxBytes: 67108864,
12555
- maxMailboxBundles: 64,
12556
- /** A bundle not picked up by then is dropped by its sender. The longest a presigned URL lives, too. */
12557
- ttlMs: 6048e5,
12558
- maxTextBytes: 16384,
12559
- maxPaymentRequestBytes: 65536,
12560
- maxManifestBytes: 65536,
12561
- /**
12562
- * How far past one lifetime from now an expiry may reach by the reader's clock: the sender's clock may be ahead, and
12563
- * a day covers any clock that is merely wrong. Nothing else about a pointer or a bundle is held against this clock.
12564
- */
12565
- clockSkewMs: 864e5
12566
- };
12567
- /**
12568
- * How far before its own clock a sender dates its pointer and its manifest, and ends the lifetimes it gives. A reader
12569
- * refuses nothing for those dates: `rev` orders pointers and `seq` orders items, both kept by the reader. Apps up to
12570
- * 1.0.2 refuse a pointer or a manifest dated more than a minute past their clock, and an expiry more than a minute
12571
- * past one lifetime from now, each without a word: the items of a sender whose clock ran two minutes fast were never
12572
- * fetched. Dated back, a sender up to about eleven minutes ahead is read by them.
12573
- */
12574
- var HOLD_DATED_BACK_MS = 6e5;
12575
- var KINDS$1 = [
12576
- "text",
12577
- "file",
12578
- "pay-req",
12579
- "manifest"
12611
+ //#region ../core/src/domainProofs.ts
12612
+ var DNS_LABEL = "_ghostly";
12613
+ var WELL_KNOWN_PATH = "/.well-known/ghostly.json";
12614
+ var NIP05_PATH = "/.well-known/nostr.json?name=_";
12615
+ /** Everything read from a domain is bounded; these match the fetch limits. */
12616
+ var DOMAIN_FILE_MAX_BYTES = 16384;
12617
+ var z32 = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/;
12618
+ var hex64 = /^[a-f0-9]{64}$/;
12619
+ /** Names that never resolve publicly (RFC 6761, 6762, 7686, 8375, 9476) or that
12620
+ * only ever mean a private network. A proof for them could only point inward. */
12621
+ var SPECIAL_USE = [
12622
+ "localhost",
12623
+ "local",
12624
+ "internal",
12625
+ "lan",
12626
+ "home",
12627
+ "corp",
12628
+ "intranet",
12629
+ "private",
12630
+ "test",
12631
+ "example",
12632
+ "invalid",
12633
+ "onion",
12634
+ "alt",
12635
+ "arpa"
12580
12636
  ];
12581
- var WIRE_ID = /^[A-Za-z0-9_-]{8,64}$/;
12582
- var MAILBOX = /^[A-Za-z0-9_-]{22}$/;
12583
- var SIG$3 = /^[A-Za-z0-9_-]{86}$/;
12584
- var MAGIC$2 = utf8Encode("GHLD");
12585
- var VERSION = 1;
12586
- var NONCE$2 = 24;
12587
- /** Why a bundle, manifest or pointer was refused. Never a reason to trust it a little. */
12588
- var HoldRefusedError = class extends Error {
12589
- reason;
12590
- constructor(reason, message) {
12591
- super(message);
12592
- this.reason = reason;
12593
- }
12594
- };
12595
- var bodyDigest = (body) => toBase64Url(sha256(body));
12596
- var newMailbox = () => toBase64Url(randomBytes(16)).slice(0, 22);
12597
12637
  /**
12598
- * Everything derived once per link for this feature: my pointer key, the contact's pointer address and
12599
- * the key that seals bundles and pointers between the two participation keys. Needs a pinned contact.
12638
+ * Normalizes what a person typed ("Example.COM.", "https://example.com/") into
12639
+ * a lowercase ASCII host name, or throws with a message fit for the UI.
12640
+ * Internationalized names become punycode (xn--), the form DNS carries.
12600
12641
  */
12601
- var HoldKeys = class {
12602
- from;
12603
- to;
12604
- me;
12605
- peer;
12606
- /** Publishes my pointer. */
12607
- identity;
12608
- /** Where the contact publishes its pointer. */
12609
- peerAddress;
12610
- seed;
12611
- sealKey;
12612
- constructor(params, participationSeedB64, peerKey) {
12613
- this.from = identityFromSeedB64(params.seedB64).pubKeyZ32;
12614
- this.to = params.peerPubKeyZ32;
12615
- const mine = identityFromSeedB64(participationSeedB64);
12616
- this.seed = mine.seed;
12617
- this.me = mine.pubKeyZ32;
12618
- this.peer = peerKey;
12619
- const secret = fromBase64Url(params.encKeyB64);
12620
- const context = JSON.stringify(["ghostly-hold/1", [this.from, this.to].sort()]);
12621
- const derive = (label) => hkdf(sha256, secret, utf8Encode(context), utf8Encode(label), 32);
12622
- this.identity = identityFromSeed(derive(`pointer:${this.from}`));
12623
- this.peerAddress = identityFromSeed(derive(`pointer:${this.to}`)).pubKeyZ32;
12624
- const shared = x25519.getSharedSecret(ed25519.utils.toMontgomerySecret(this.seed), ed25519.utils.toMontgomery(publicKeyFromZ32(peerKey)));
12625
- this.sealKey = hkdf(sha256, shared, derive("envelope"), utf8Encode("ghostly-hold-envelope/1"), 32);
12626
- }
12627
- /** Seals one item for the contact: signed with my participation key, encrypted to our shared key. */
12628
- seal(item, body) {
12629
- const fields = [
12630
- VERSION,
12631
- this.from,
12632
- this.to,
12633
- this.me,
12634
- this.peer,
12635
- item.mailbox,
12636
- item.seq,
12637
- item.id,
12638
- item.ts,
12639
- item.kind,
12640
- item.meta ?? null,
12641
- bodyDigest(body),
12642
- item.expires
12643
- ];
12644
- const signature = toBase64Url(sign(utf8Encode(JSON.stringify(["ghostly-hold-bundle", fields])), this.seed));
12645
- const header = utf8Encode(JSON.stringify([fields, signature]));
12646
- const length = /* @__PURE__ */ new Uint8Array(4);
12647
- new DataView(length.buffer).setUint32(0, header.length);
12648
- const nonce = randomBytes(NONCE$2);
12649
- const box = xsalsa20poly1305(this.sealKey, nonce).encrypt(concatBytes$1(length, header, body));
12650
- return concatBytes$1(MAGIC$2, new Uint8Array([VERSION]), nonce, box);
12651
- }
12652
- /**
12653
- * Opens a bundle the contact sealed for me. Everything is checked before anything is returned: the
12654
- * authenticated encryption, the header's shape, that it names this link, this contact and me, the
12655
- * signature, the body digest and the limits of its kind.
12656
- */
12657
- open(bytes, expect = {}) {
12658
- const now = expect.now ?? Date.now();
12659
- if (bytes.length > (expect.maxBytes ?? HOLD_LIMITS.maxBundleBytes)) throw new HoldRefusedError("size", "The held item is larger than allowed");
12660
- 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");
12661
- if (bytes[MAGIC$2.length] !== VERSION) throw new HoldRefusedError("format", "Held item from a newer Ghostly");
12662
- let plain;
12663
- try {
12664
- 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));
12665
- } catch {
12666
- throw new HoldRefusedError("tampered", "The held item was changed or is not for this chat");
12667
- }
12668
- if (plain.length < 4) throw new HoldRefusedError("format", "Not a held item");
12669
- const headerLength = new DataView(plain.buffer, plain.byteOffset, 4).getUint32(0);
12670
- if (headerLength > 131072 || 4 + headerLength > plain.length) throw new HoldRefusedError("format", "Not a held item");
12671
- let parsed;
12642
+ function normalizeDomain(input) {
12643
+ if (typeof input !== "string") throw new Error("Enter a domain name, like example.com");
12644
+ let value = input.trim();
12645
+ if (!value || value.length > 300) throw new Error("Enter a domain name, like example.com");
12646
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
12647
+ let url;
12672
12648
  try {
12673
- parsed = JSON.parse(utf8Decode(plain.subarray(4, 4 + headerLength)));
12649
+ url = new URL(value);
12674
12650
  } catch {
12675
- throw new HoldRefusedError("format", "Not a held item");
12651
+ throw new Error("Enter a domain name, like example.com");
12676
12652
  }
12677
- 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");
12678
- const [fields, signature] = parsed;
12679
- const [v, from, to, author, recipient, mailbox, seq, id, ts, kind, meta, digest, expires] = fields;
12680
- 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");
12681
- if (from !== this.to || to !== this.from || recipient !== this.me) throw new HoldRefusedError("not-for-me", "The held item is for another chat");
12682
- if (author !== this.peer) throw new HoldRefusedError("author", "The held item was not signed by this contact");
12683
- if (expect.mailbox && mailbox !== expect.mailbox) throw new HoldRefusedError("not-for-me", "The held item is from another mailbox");
12684
- let valid;
12653
+ 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");
12654
+ value = url.hostname;
12655
+ } else {
12656
+ if (/[/:@?#\s\\]/.test(value)) throw new Error("Enter only the domain name, without a path or port");
12685
12657
  try {
12686
- valid = verify(fromBase64Url(signature), utf8Encode(JSON.stringify(["ghostly-hold-bundle", fields])), publicKeyFromZ32(author));
12658
+ value = new URL(`https://${value}`).hostname;
12687
12659
  } catch {
12688
- valid = false;
12660
+ throw new Error("That is not a valid domain name");
12689
12661
  }
12690
- if (!valid) throw new HoldRefusedError("signature", "The held item's signature does not check out");
12691
- const body = plain.subarray(4 + headerLength);
12692
- if (bodyDigest(body) !== digest) throw new HoldRefusedError("digest", "The held item's content does not match its header");
12693
- if (expires <= ts || expires > now + HOLD_LIMITS.ttlMs + HOLD_LIMITS.clockSkewMs) throw new HoldRefusedError("limits", "The held item's lifetime is out of bounds");
12694
- const header = {
12695
- v: 1,
12696
- from,
12697
- to,
12698
- author,
12699
- recipient,
12700
- mailbox,
12701
- seq,
12702
- id,
12703
- ts,
12704
- kind,
12705
- meta,
12706
- digest,
12707
- expires
12708
- };
12709
- checkKind(header, body);
12710
- return {
12711
- header,
12712
- body
12713
- };
12714
- }
12715
- /** My pointer, as records to publish under `identity`. Throws when it cannot fit a packet. */
12716
- pointerRecords(pointer, now = Date.now()) {
12717
- const body = [
12718
- VERSION,
12719
- pointer.rev,
12720
- pointer.issued,
12721
- pointer.expires,
12722
- pointer.manifestUrl,
12723
- pointer.top,
12724
- pointer.ack,
12725
- pointer.count,
12726
- pointer.bytes,
12727
- pointer.refused.slice(-32)
12728
- ];
12729
- const signature = toBase64Url(sign(utf8Encode(JSON.stringify([
12730
- "ghostly-hold-pointer",
12731
- this.from,
12732
- this.to,
12733
- body
12734
- ])), this.seed));
12735
- const ttl = Math.min(86400, Math.max(60, Math.ceil((pointer.expires - now) / 1e3)));
12736
- const records = [{
12737
- label: "_hold",
12738
- value: encrypt(JSON.stringify([body, signature]), this.sealKey),
12739
- ttl
12740
- }];
12741
- 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.");
12742
- return records;
12743
12662
  }
12744
- /**
12745
- * The contact's pointer out of its packet, or null when the packet is not one of its pointers. Its `issued` is the
12746
- * contact's clock and refuses nothing: an older pointer cannot take a newer one's place because of `rev`.
12747
- */
12748
- readPointer(packet) {
12749
- if (packet.pubKeyZ32 !== this.peerAddress || measureRecords(packet.pubKeyZ32, packet.records) > 1e3) return null;
12750
- const records = packet.records.filter((r) => r.label === "_hold");
12663
+ value = value.toLowerCase().replace(/\.$/, "");
12664
+ if (value.length > 244) throw new Error("That domain name is too long");
12665
+ const labels = value.split(".");
12666
+ if (labels.length < 2) throw new Error("Use a full domain name, like example.com");
12667
+ 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");
12668
+ const tld = labels[labels.length - 1];
12669
+ if (/^[0-9]+$/.test(tld) || /^\[/.test(value)) throw new Error("Use a domain name, not an IP address");
12670
+ if (SPECIAL_USE.includes(tld) || labels.length >= 2 && labels.slice(-2).join(".") === "home.arpa") throw new Error("That name only works on private networks");
12671
+ return value;
12672
+ }
12673
+ var dnsProofName = (domain) => `${DNS_LABEL}.${normalizeDomain(domain)}`;
12674
+ var wellKnownUrl = (domain, method = "https") => `https://${normalizeDomain(domain)}${method === "nip05" ? NIP05_PATH : WELL_KNOWN_PATH}`;
12675
+ function validDomainRecord(r) {
12676
+ if (!r || typeof r.key !== "string" || typeof r.proof !== "string" || !z32.test(r.key) || !hex64.test(r.proof)) return false;
12677
+ try {
12678
+ return toZ32(fromZ32(r.key)) === r.key;
12679
+ } catch {
12680
+ return false;
12681
+ }
12682
+ }
12683
+ /** The TXT record value, in the `tag=value; …` style people know from DKIM. */
12684
+ function domainTxtRecord(r) {
12685
+ if (!validDomainRecord(r)) throw new Error("Invalid domain record");
12686
+ return `v=ghostly1; key=${r.key}; proof=${r.proof}`;
12687
+ }
12688
+ /** Parses one TXT string. Undefined for anything that is not ours or not exactly
12689
+ * well-formed; unknown extra tags are ignored for forward compatibility. */
12690
+ function parseDomainTxt(value) {
12691
+ if (typeof value !== "string" || utf8Encode(value).length > 1024) return;
12692
+ const tags = /* @__PURE__ */ new Map();
12693
+ const parts = value.split(";").map((p) => p.trim()).filter(Boolean);
12694
+ for (const part of parts) {
12695
+ const eq = part.indexOf("=");
12696
+ if (eq < 1) return;
12697
+ const name = part.slice(0, eq).trim().toLowerCase();
12698
+ if (tags.has(name)) return;
12699
+ tags.set(name, part.slice(eq + 1).trim());
12700
+ }
12701
+ if (!parts[0]?.toLowerCase().startsWith("v=") || tags.get("v") !== "ghostly1") return;
12702
+ const r = {
12703
+ key: tags.get("key"),
12704
+ proof: tags.get("proof")
12705
+ };
12706
+ return validDomainRecord(r) ? r : void 0;
12707
+ }
12708
+ var addRecord = (list, r) => {
12709
+ if (!list.some((x) => x.key === r.key && x.proof === r.proof)) list.push(r);
12710
+ };
12711
+ /** All records among the TXT strings of `_ghostly.<domain>`; other strings are skipped. */
12712
+ function recordsFromTxt(values) {
12713
+ const out = [];
12714
+ for (const value of values.slice(0, 32)) {
12715
+ const r = parseDomainTxt(value);
12716
+ if (r) addRecord(out, r);
12717
+ }
12718
+ return out.slice(0, 8);
12719
+ }
12720
+ /** The body of `/.well-known/ghostly.json`. */
12721
+ function domainWellKnownFile(records) {
12722
+ if (!records.length || records.length > 8 || !records.every(validDomainRecord)) throw new Error("Invalid domain records");
12723
+ return JSON.stringify({
12724
+ ghostly: 1,
12725
+ proofs: records.map((r) => ({
12726
+ key: r.key,
12727
+ proof: r.proof
12728
+ }))
12729
+ }, null, 2) + "\n";
12730
+ }
12731
+ function parseJson(text) {
12732
+ if (typeof text !== "string" || utf8Encode(text).length > 16384) throw new Error("File too large");
12733
+ return JSON.parse(text);
12734
+ }
12735
+ /** Records from `ghostly.json`. Throws on a malformed file; [] when it names none. */
12736
+ function recordsFromWellKnown(text) {
12737
+ const body = parseJson(text);
12738
+ if (!body || typeof body !== "object" || Array.isArray(body) || body.ghostly !== 1 || !Array.isArray(body.proofs)) throw new Error("Not a Ghostly domain file");
12739
+ const out = [];
12740
+ for (const entry of body.proofs.slice(0, 32)) {
12741
+ if (!entry || typeof entry !== "object") continue;
12742
+ const r = {
12743
+ key: entry.key,
12744
+ proof: entry.proof
12745
+ };
12746
+ if (validDomainRecord(r)) addRecord(out, r);
12747
+ }
12748
+ return out.slice(0, 8);
12749
+ }
12750
+ /** NIP-05: only the root name `_` speaks for the domain itself. Undefined when absent. */
12751
+ function nip05RootKey(text) {
12752
+ const body = parseJson(text);
12753
+ if (!body || typeof body !== "object" || !body.names || typeof body.names !== "object" || Array.isArray(body.names)) throw new Error("Not a NIP-05 file");
12754
+ const root = body.names["_"];
12755
+ return typeof root === "string" && hex64.test(root) ? root : void 0;
12756
+ }
12757
+ var recordsName = (records, r) => records.some((x) => x.key === r.key && x.proof === r.proof);
12758
+ var DNS_TYPE = {
12759
+ A: 1,
12760
+ CNAME: 5,
12761
+ TXT: 16,
12762
+ AAAA: 28
12763
+ };
12764
+ /** A recursive query for one name and type. ID 0, as RFC 8484 recommends for caching;
12765
+ * the AD bit asks the resolver to report DNSSEC validation (RFC 6840 §5.7). */
12766
+ function encodeDnsQuery(name, type) {
12767
+ const labels = name.replace(/\.$/, "").split(".");
12768
+ const parts = [Uint8Array.of(0, 0, 1, 32, 0, 1, 0, 0, 0, 0, 0, 0)];
12769
+ for (const label of labels) {
12770
+ const bytes = utf8Encode(label);
12771
+ if (!bytes.length || bytes.length > 63) throw new Error("Invalid DNS name");
12772
+ parts.push(Uint8Array.of(bytes.length), bytes);
12773
+ }
12774
+ const code = DNS_TYPE[type];
12775
+ parts.push(Uint8Array.of(0, code >> 8, code & 255, 0, 1));
12776
+ return concatBytes$1(...parts);
12777
+ }
12778
+ function readName(data, start) {
12779
+ const labels = [];
12780
+ let pos = start, next = -1, jumps = 0, length = 0;
12781
+ for (;;) {
12782
+ if (pos >= data.length) throw new Error("DNS name out of bounds");
12783
+ const len = data[pos];
12784
+ if ((len & 192) === 192) {
12785
+ if (pos + 1 >= data.length) throw new Error("DNS pointer out of bounds");
12786
+ if (next < 0) next = pos + 2;
12787
+ pos = (len & 63) << 8 | data[pos + 1];
12788
+ if (++jumps > 32) throw new Error("DNS name loops");
12789
+ continue;
12790
+ }
12791
+ if (len & 192) throw new Error("Unsupported DNS label");
12792
+ pos += 1;
12793
+ if (len === 0) break;
12794
+ if (pos + len > data.length) throw new Error("DNS label out of bounds");
12795
+ length += len + 1;
12796
+ if (length > 255) throw new Error("DNS name too long");
12797
+ labels.push(utf8Decode(data.subarray(pos, pos + len)).toLowerCase());
12798
+ pos += len;
12799
+ }
12800
+ return {
12801
+ name: labels.join("."),
12802
+ next: next < 0 ? pos : next
12803
+ };
12804
+ }
12805
+ /**
12806
+ * Decodes a resolver's answer to `encodeDnsQuery(name, type)`. Refuses a reply
12807
+ * to another question or a truncated one, and keeps only records of the asked
12808
+ * type owned by the name or by the CNAME chain the answer itself follows.
12809
+ */
12810
+ function decodeDnsResponse(data, name, type) {
12811
+ if (data.length < 12) throw new Error("DNS response too short");
12812
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
12813
+ const flags = view.getUint16(2);
12814
+ if (!(flags & 32768) || flags & 30720 || flags & 512) throw new Error("Unusable DNS response");
12815
+ const questions = view.getUint16(4), count = view.getUint16(6);
12816
+ const want = name.replace(/\.$/, "").toLowerCase(), code = DNS_TYPE[type];
12817
+ if (questions !== 1) throw new Error("DNS response is for another question");
12818
+ const q = readName(data, 12);
12819
+ if (q.name !== want || q.next + 4 > data.length || view.getUint16(q.next) !== code) throw new Error("DNS response is for another question");
12820
+ let pos = q.next + 4;
12821
+ const all = [];
12822
+ for (let i = 0; i < count && i < 64; i++) {
12823
+ const owner = readName(data, pos);
12824
+ pos = owner.next;
12825
+ if (pos + 10 > data.length) throw new Error("DNS record out of bounds");
12826
+ const rtype = view.getUint16(pos), ttl = view.getUint32(pos + 4), len = view.getUint16(pos + 8);
12827
+ pos += 10;
12828
+ if (pos + len > data.length) throw new Error("DNS record out of bounds");
12829
+ const rdata = rtype === DNS_TYPE.CNAME ? utf8Encode(readName(data, pos).name) : data.slice(pos, pos + len);
12830
+ all.push({
12831
+ name: owner.name,
12832
+ type: rtype,
12833
+ ttl,
12834
+ data: rdata
12835
+ });
12836
+ pos += len;
12837
+ }
12838
+ const owners = /* @__PURE__ */ new Set([want]);
12839
+ for (let hop = 0; hop < 8; hop++) {
12840
+ const cname = all.find((a) => a.type === DNS_TYPE.CNAME && owners.has(a.name) && !owners.has(utf8Decode(a.data)));
12841
+ if (!cname) break;
12842
+ owners.add(utf8Decode(cname.data));
12843
+ }
12844
+ return {
12845
+ rcode: flags & 15,
12846
+ authenticated: !!(flags & 32),
12847
+ answers: all.filter((a) => a.type === code && owners.has(a.name))
12848
+ };
12849
+ }
12850
+ /** The TXT character-strings of one record, joined as RFC 7208 §3.3 does. */
12851
+ function txtValue(rdata) {
12852
+ const parts = [];
12853
+ for (let p = 0; p < rdata.length;) {
12854
+ const len = rdata[p];
12855
+ if (p + 1 + len > rdata.length) throw new Error("DNS text out of bounds");
12856
+ parts.push(rdata.subarray(p + 1, p + 1 + len));
12857
+ p += 1 + len;
12858
+ }
12859
+ return new TextDecoder("utf-8", { fatal: true }).decode(concatBytes$1(...parts));
12860
+ }
12861
+ /** False for loopback, private, link-local, shared (CGNAT), multicast, reserved
12862
+ * and unspecified addresses, including IPv4 embedded in IPv6. */
12863
+ function isPublicAddress(address) {
12864
+ if (address.length === 4) {
12865
+ const [a, b, c] = address;
12866
+ 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));
12867
+ }
12868
+ if (address.length !== 16) return false;
12869
+ const zeroTo = (n) => address.subarray(0, n).every((x) => x === 0);
12870
+ if (zeroTo(15) && (address[15] === 0 || address[15] === 1)) return false;
12871
+ if (zeroTo(10) && address[10] === 255 && address[11] === 255) return isPublicAddress(address.subarray(12));
12872
+ 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));
12873
+ if (zeroTo(12)) return false;
12874
+ if ((address[0] & 254) === 252) return false;
12875
+ if (address[0] === 254 && (address[1] & 192) === 128) return false;
12876
+ if (address[0] === 254 && (address[1] & 192) === 192) return false;
12877
+ if (address[0] === 255) return false;
12878
+ if (address[0] === 1 && address.subarray(1, 8).every((x) => x === 0)) return false;
12879
+ return (address[0] & 224) === 32;
12880
+ }
12881
+ //#endregion
12882
+ //#region ../core/src/storeForward.ts
12883
+ /**
12884
+ * Store-and-forward for an away contact (WISP 404 draft, `hold/1`).
12885
+ *
12886
+ * What a person sends while the contact is away is sealed into a *bundle* (signed by the sender's
12887
+ * participation key, then encrypted to a key only the two of them can derive), put in the sender's
12888
+ * own storage (WISP 1000), and named in a *manifest* the storage also holds. A small signed, encrypted
12889
+ * *pointer* record on a per-link Pkarr key tells the contact where the manifest is (a URL that expires)
12890
+ * and which of the contact's own bundles have been received. Storage and relays see ciphertext, sizes
12891
+ * and timing only. Nothing here promises forward secrecy or retention beyond the bounds below.
12892
+ */
12893
+ var HOLD_CAPABILITY = "hold/1";
12894
+ var HOLD_LIMITS = {
12895
+ /** One bundle, ciphertext and header included: a picture, not a video. */
12896
+ maxBundleBytes: 8388608,
12897
+ /** All bundles held for one contact at once. */
12898
+ maxMailboxBytes: 67108864,
12899
+ maxMailboxBundles: 64,
12900
+ /** A bundle not picked up by then is dropped by its sender. The longest a presigned URL lives, too. */
12901
+ ttlMs: 6048e5,
12902
+ maxTextBytes: 16384,
12903
+ maxPaymentRequestBytes: 65536,
12904
+ maxManifestBytes: 65536,
12905
+ /**
12906
+ * How far past one lifetime from now an expiry may reach by the reader's clock: the sender's clock may be ahead, and
12907
+ * a day covers any clock that is merely wrong. Nothing else about a pointer or a bundle is held against this clock.
12908
+ */
12909
+ clockSkewMs: 864e5
12910
+ };
12911
+ /**
12912
+ * How far before its own clock a sender dates its pointer and its manifest, and ends the lifetimes it gives. A reader
12913
+ * refuses nothing for those dates: `rev` orders pointers and `seq` orders items, both kept by the reader. Apps up to
12914
+ * 1.0.2 refuse a pointer or a manifest dated more than a minute past their clock, and an expiry more than a minute
12915
+ * past one lifetime from now, each without a word: the items of a sender whose clock ran two minutes fast were never
12916
+ * fetched. Dated back, a sender up to about eleven minutes ahead is read by them.
12917
+ */
12918
+ var HOLD_DATED_BACK_MS = 6e5;
12919
+ var KINDS$1 = [
12920
+ "text",
12921
+ "file",
12922
+ "pay-req",
12923
+ "manifest"
12924
+ ];
12925
+ var WIRE_ID = /^[A-Za-z0-9_-]{8,64}$/;
12926
+ var MAILBOX = /^[A-Za-z0-9_-]{22}$/;
12927
+ var SIG$3 = /^[A-Za-z0-9_-]{86}$/;
12928
+ var MAGIC$2 = utf8Encode("GHLD");
12929
+ var VERSION = 1;
12930
+ var NONCE$2 = 24;
12931
+ /** Why a bundle, manifest or pointer was refused. Never a reason to trust it a little. */
12932
+ var HoldRefusedError = class extends Error {
12933
+ reason;
12934
+ constructor(reason, message) {
12935
+ super(message);
12936
+ this.reason = reason;
12937
+ }
12938
+ };
12939
+ var bodyDigest = (body) => toBase64Url(sha256(body));
12940
+ var newMailbox = () => toBase64Url(randomBytes(16)).slice(0, 22);
12941
+ /**
12942
+ * Everything derived once per link for this feature: my pointer key, the contact's pointer address and
12943
+ * the key that seals bundles and pointers between the two participation keys. Needs a pinned contact.
12944
+ */
12945
+ var HoldKeys = class {
12946
+ from;
12947
+ to;
12948
+ me;
12949
+ peer;
12950
+ /** Publishes my pointer. */
12951
+ identity;
12952
+ /** Where the contact publishes its pointer. */
12953
+ peerAddress;
12954
+ seed;
12955
+ sealKey;
12956
+ constructor(params, participationSeedB64, peerKey) {
12957
+ this.from = identityFromSeedB64(params.seedB64).pubKeyZ32;
12958
+ this.to = params.peerPubKeyZ32;
12959
+ const mine = identityFromSeedB64(participationSeedB64);
12960
+ this.seed = mine.seed;
12961
+ this.me = mine.pubKeyZ32;
12962
+ this.peer = peerKey;
12963
+ const secret = fromBase64Url(params.encKeyB64);
12964
+ const context = JSON.stringify(["ghostly-hold/1", [this.from, this.to].sort()]);
12965
+ const derive = (label) => hkdf(sha256, secret, utf8Encode(context), utf8Encode(label), 32);
12966
+ this.identity = identityFromSeed(derive(`pointer:${this.from}`));
12967
+ this.peerAddress = identityFromSeed(derive(`pointer:${this.to}`)).pubKeyZ32;
12968
+ const shared = x25519.getSharedSecret(ed25519.utils.toMontgomerySecret(this.seed), ed25519.utils.toMontgomery(publicKeyFromZ32(peerKey)));
12969
+ this.sealKey = hkdf(sha256, shared, derive("envelope"), utf8Encode("ghostly-hold-envelope/1"), 32);
12970
+ }
12971
+ /** Seals one item for the contact: signed with my participation key, encrypted to our shared key. */
12972
+ seal(item, body) {
12973
+ const fields = [
12974
+ VERSION,
12975
+ this.from,
12976
+ this.to,
12977
+ this.me,
12978
+ this.peer,
12979
+ item.mailbox,
12980
+ item.seq,
12981
+ item.id,
12982
+ item.ts,
12983
+ item.kind,
12984
+ item.meta ?? null,
12985
+ bodyDigest(body),
12986
+ item.expires
12987
+ ];
12988
+ const signature = toBase64Url(sign(utf8Encode(JSON.stringify(["ghostly-hold-bundle", fields])), this.seed));
12989
+ const header = utf8Encode(JSON.stringify([fields, signature]));
12990
+ const length = /* @__PURE__ */ new Uint8Array(4);
12991
+ new DataView(length.buffer).setUint32(0, header.length);
12992
+ const nonce = randomBytes(NONCE$2);
12993
+ const box = xsalsa20poly1305(this.sealKey, nonce).encrypt(concatBytes$1(length, header, body));
12994
+ return concatBytes$1(MAGIC$2, new Uint8Array([VERSION]), nonce, box);
12995
+ }
12996
+ /**
12997
+ * Opens a bundle the contact sealed for me. Everything is checked before anything is returned: the
12998
+ * authenticated encryption, the header's shape, that it names this link, this contact and me, the
12999
+ * signature, the body digest and the limits of its kind. `loopback`: see `isHoldUrl`.
13000
+ */
13001
+ open(bytes, expect = {}) {
13002
+ const now = expect.now ?? Date.now();
13003
+ if (bytes.length > (expect.maxBytes ?? HOLD_LIMITS.maxBundleBytes)) throw new HoldRefusedError("size", "The held item is larger than allowed");
13004
+ 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");
13005
+ if (bytes[MAGIC$2.length] !== VERSION) throw new HoldRefusedError("format", "Held item from a newer Ghostly");
13006
+ let plain;
13007
+ try {
13008
+ 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));
13009
+ } catch {
13010
+ throw new HoldRefusedError("tampered", "The held item was changed or is not for this chat");
13011
+ }
13012
+ if (plain.length < 4) throw new HoldRefusedError("format", "Not a held item");
13013
+ const headerLength = new DataView(plain.buffer, plain.byteOffset, 4).getUint32(0);
13014
+ if (headerLength > 131072 || 4 + headerLength > plain.length) throw new HoldRefusedError("format", "Not a held item");
13015
+ let parsed;
13016
+ try {
13017
+ parsed = JSON.parse(utf8Decode(plain.subarray(4, 4 + headerLength)));
13018
+ } catch {
13019
+ throw new HoldRefusedError("format", "Not a held item");
13020
+ }
13021
+ 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");
13022
+ const [fields, signature] = parsed;
13023
+ const [v, from, to, author, recipient, mailbox, seq, id, ts, kind, meta, digest, expires] = fields;
13024
+ 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");
13025
+ if (from !== this.to || to !== this.from || recipient !== this.me) throw new HoldRefusedError("not-for-me", "The held item is for another chat");
13026
+ if (author !== this.peer) throw new HoldRefusedError("author", "The held item was not signed by this contact");
13027
+ if (expect.mailbox && mailbox !== expect.mailbox) throw new HoldRefusedError("not-for-me", "The held item is from another mailbox");
13028
+ let valid;
13029
+ try {
13030
+ valid = verify(fromBase64Url(signature), utf8Encode(JSON.stringify(["ghostly-hold-bundle", fields])), publicKeyFromZ32(author));
13031
+ } catch {
13032
+ valid = false;
13033
+ }
13034
+ if (!valid) throw new HoldRefusedError("signature", "The held item's signature does not check out");
13035
+ const body = plain.subarray(4 + headerLength);
13036
+ if (bodyDigest(body) !== digest) throw new HoldRefusedError("digest", "The held item's content does not match its header");
13037
+ if (expires <= ts || expires > now + HOLD_LIMITS.ttlMs + HOLD_LIMITS.clockSkewMs) throw new HoldRefusedError("limits", "The held item's lifetime is out of bounds");
13038
+ const header = {
13039
+ v: 1,
13040
+ from,
13041
+ to,
13042
+ author,
13043
+ recipient,
13044
+ mailbox,
13045
+ seq,
13046
+ id,
13047
+ ts,
13048
+ kind,
13049
+ meta,
13050
+ digest,
13051
+ expires
13052
+ };
13053
+ checkKind(header, body, expect.loopback);
13054
+ return {
13055
+ header,
13056
+ body
13057
+ };
13058
+ }
13059
+ /** My pointer, as records to publish under `identity`. Throws when it cannot fit a packet. */
13060
+ pointerRecords(pointer, now = Date.now()) {
13061
+ const body = [
13062
+ VERSION,
13063
+ pointer.rev,
13064
+ pointer.issued,
13065
+ pointer.expires,
13066
+ pointer.manifestUrl,
13067
+ pointer.top,
13068
+ pointer.ack,
13069
+ pointer.count,
13070
+ pointer.bytes,
13071
+ pointer.refused.slice(-32)
13072
+ ];
13073
+ const signature = toBase64Url(sign(utf8Encode(JSON.stringify([
13074
+ "ghostly-hold-pointer",
13075
+ this.from,
13076
+ this.to,
13077
+ body
13078
+ ])), this.seed));
13079
+ const ttl = Math.min(86400, Math.max(60, Math.ceil((pointer.expires - now) / 1e3)));
13080
+ const records = [{
13081
+ label: "_hold",
13082
+ value: encrypt(JSON.stringify([body, signature]), this.sealKey),
13083
+ ttl
13084
+ }];
13085
+ 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.");
13086
+ return records;
13087
+ }
13088
+ /**
13089
+ * The contact's pointer out of its packet, or null when the packet is not one of its pointers. Its `issued` is the
13090
+ * contact's clock and refuses nothing: an older pointer cannot take a newer one's place because of `rev`. The
13091
+ * manifest's address is held to what an item's is (`isHoldUrl`): it is fetched as soon as the pointer is read.
13092
+ */
13093
+ readPointer(packet, loopback = false) {
13094
+ if (packet.pubKeyZ32 !== this.peerAddress || measureRecords(packet.pubKeyZ32, packet.records) > 1e3) return null;
13095
+ const records = packet.records.filter((r) => r.label === "_hold");
12751
13096
  if (records.length !== 1) return null;
12752
13097
  const plain = tryDecrypt(records[0].value, this.sealKey);
12753
13098
  if (!plain) return null;
@@ -12768,7 +13113,7 @@ var HoldKeys = class {
12768
13113
  ack,
12769
13114
  count,
12770
13115
  bytes
12771
- ].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;
13116
+ ].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;
12772
13117
  try {
12773
13118
  if (!verify(fromBase64Url(signature), utf8Encode(JSON.stringify([
12774
13119
  "ghostly-hold-pointer",
@@ -12792,22 +13137,31 @@ var HoldKeys = class {
12792
13137
  };
12793
13138
  }
12794
13139
  };
12795
- /** A URL a bundle or manifest may be fetched from: HTTPS, or HTTP to this machine while testing. */
12796
- function isHoldUrl(value) {
13140
+ /**
13141
+ * A URL a bundle or manifest may be fetched from. The contact chose it and this device fetches it on its own, so it
13142
+ * is HTTPS, without a user or password, at a name or a public IPv4 address: never this machine or its network.
13143
+ * `loopback` adds plain HTTP to this machine, for a test against a local server; no shipped app passes it.
13144
+ */
13145
+ function isHoldUrl(value, loopback = false) {
12797
13146
  if (typeof value !== "string" || value.length > 2048) return false;
13147
+ let url;
12798
13148
  try {
12799
- const url = new URL(value);
12800
- return url.protocol === "https:" || url.protocol === "http:" && [
12801
- "localhost",
12802
- "127.0.0.1",
12803
- "[::1]"
12804
- ].includes(url.hostname);
13149
+ url = new URL(value);
12805
13150
  } catch {
12806
13151
  return false;
12807
13152
  }
13153
+ if (url.username || url.password) return false;
13154
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
13155
+ if (loopback && url.protocol === "http:" && [
13156
+ "localhost",
13157
+ "127.0.0.1",
13158
+ "[::1]"
13159
+ ].includes(host)) return true;
13160
+ if (url.protocol !== "https:" || !host || host.startsWith("[") || host === "localhost" || host.endsWith(".localhost")) return false;
13161
+ return !/^\d+\.\d+\.\d+\.\d+$/.test(host) || isPublicAddress(Uint8Array.from(host.split("."), Number));
12808
13162
  }
12809
13163
  /** The limits of each kind, on the way in. */
12810
- function checkKind(header, body) {
13164
+ function checkKind(header, body, loopback = false) {
12811
13165
  const bad = (message) => new HoldRefusedError("limits", message);
12812
13166
  switch (header.kind) {
12813
13167
  case "text":
@@ -12828,12 +13182,12 @@ function checkKind(header, body) {
12828
13182
  }
12829
13183
  case "manifest":
12830
13184
  if (body.length !== 0) throw bad("A manifest carries no body");
12831
- readManifest(header.meta);
13185
+ readManifest(header.meta, loopback);
12832
13186
  return;
12833
13187
  }
12834
13188
  }
12835
13189
  /** The entries of a manifest, checked: strictly increasing sequences, bounded sizes, fetchable URLs. */
12836
- function readManifest(meta) {
13190
+ function readManifest(meta, loopback = false) {
12837
13191
  const bad = (message) => new HoldRefusedError("limits", message);
12838
13192
  const entries = meta?.entries;
12839
13193
  if (!Array.isArray(entries) || entries.length > HOLD_LIMITS.maxMailboxBundles) throw bad("The manifest lists too many items");
@@ -12842,7 +13196,7 @@ function readManifest(meta) {
12842
13196
  for (const entry of entries) {
12843
13197
  if (!Array.isArray(entry) || entry.length !== 6) throw bad("The manifest is malformed");
12844
13198
  const [seq, id, kind, bytes, url, expires] = entry;
12845
- 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");
13199
+ 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");
12846
13200
  last = seq;
12847
13201
  total += bytes;
12848
13202
  if (total > HOLD_LIMITS.maxMailboxBytes) throw bad("The manifest lists more than a mailbox may hold");
@@ -14238,6 +14592,8 @@ var TypingReceiver = class {
14238
14592
  * receiver says where it stands (accept from an offset, wait, done, refuse) and the sender goes on from
14239
14593
  * there. Nothing but the offset a receiver confirms moves a sender forward. A receiver may say `pf-accept`
14240
14594
  * again at any time (nothing arrived for a while, or its person asked again): the sender goes on from that offset.
14595
+ * What a sender sends again that way is bounded (`FILE_LIMITS.resendBytes`, and the host's `resend`): past it the
14596
+ * sender ends the transfer.
14241
14597
  */
14242
14598
  var FILE_LIMITS = {
14243
14599
  /** Bytes in one `pf-data` frame: a frame stays far below the 60 KiB a session carries. */
@@ -14271,7 +14627,12 @@ var FILE_LIMITS = {
14271
14627
  /** A sender reads its file this much at a time. */
14272
14628
  readBytes: 1048576,
14273
14629
  /** The session refused a data frame (its send budget was full): the sender looks again after this long. */
14274
- sendRetryMs: 250
14630
+ sendRetryMs: 250,
14631
+ /**
14632
+ * What a sender sends of one file beyond its size (parts sent again after a drop, a restart of the receiver, or a
14633
+ * `pf-accept` from an earlier offset): twice the file's size and this much. Past it the sender ends the transfer (WISP 501 § Resume).
14634
+ */
14635
+ resendBytes: 268435456
14275
14636
  };
14276
14637
  var ID = /^[A-Za-z0-9_-]{8,64}$/;
14277
14638
  var DIGEST$1 = /^[A-Za-z0-9_-]{43}$/;
@@ -14357,6 +14718,7 @@ var ChatFiles = class {
14357
14718
  const entry = { record: { ...record } };
14358
14719
  if (record.direction === "out") entry.out = {
14359
14720
  next: record.confirmed,
14721
+ sent: 0,
14360
14722
  generation: 0
14361
14723
  };
14362
14724
  else entry.in = {
@@ -14388,6 +14750,10 @@ var ChatFiles = class {
14388
14750
  const entry = this.entries.get(key(direction, id));
14389
14751
  return entry ? this.moved(entry) : 0;
14390
14752
  }
14753
+ /** Bytes of data sent for a file so far, parts sent again included: what sending it cost, whatever the receiver confirmed. */
14754
+ sent(id) {
14755
+ return this.entries.get(key("out", id))?.out?.sent ?? 0;
14756
+ }
14391
14757
  moved(entry) {
14392
14758
  return entry.in && !transferEnded(entry.record) ? Math.max(entry.in.stored, entry.record.confirmed) : entry.record.confirmed;
14393
14759
  }
@@ -14468,6 +14834,7 @@ var ChatFiles = class {
14468
14834
  },
14469
14835
  out: {
14470
14836
  next: 0,
14837
+ sent: 0,
14471
14838
  generation: 0
14472
14839
  }
14473
14840
  };
@@ -14541,6 +14908,15 @@ var ChatFiles = class {
14541
14908
  const { record } = entry, out = entry.out;
14542
14909
  return this.attached && record.state === "active" && out.next < record.file.size && out.next - record.confirmed < FILE_LIMITS.windowBytes;
14543
14910
  }
14911
+ /**
14912
+ * Whether the next `length` bytes go. Within the file's size they always do; past it the receiver is having parts
14913
+ * sent again, which nothing it says bounds, so this side does.
14914
+ */
14915
+ mayResend(entry, length) {
14916
+ const { record } = entry, size = record.file.size, sent = entry.out.sent + length;
14917
+ if (sent <= size) return true;
14918
+ return sent - size <= 2 * size + FILE_LIMITS.resendBytes && this.host.resend?.({ ...record }, sent) !== false;
14919
+ }
14544
14920
  async pump(entry) {
14545
14921
  const out = entry.out;
14546
14922
  if (out.pumping === out.generation) return;
@@ -14554,6 +14930,14 @@ var ChatFiles = class {
14554
14930
  const room = this.host.writable?.();
14555
14931
  if (room) await room;
14556
14932
  if (generation !== out.generation || !this.shouldPump(entry) || out.next !== offset) break;
14933
+ if (!this.mayResend(entry, chunk.length)) {
14934
+ this.send({
14935
+ t: "pf-abort",
14936
+ id: entry.record.id
14937
+ });
14938
+ this.end(entry, "failed", "Your contact asked for the same part of this file too many times");
14939
+ break;
14940
+ }
14557
14941
  if (!this.send({
14558
14942
  t: "pf-data",
14559
14943
  id: entry.record.id,
@@ -14564,6 +14948,7 @@ var ChatFiles = class {
14564
14948
  break;
14565
14949
  }
14566
14950
  out.next = offset + chunk.length;
14951
+ out.sent += chunk.length;
14567
14952
  }
14568
14953
  } catch (error) {
14569
14954
  if (generation === out.generation) {
@@ -15272,6 +15657,7 @@ var ChatFiles = class {
15272
15657
  clearTimeout(entry.out.wake);
15273
15658
  entry.out = {
15274
15659
  next: 0,
15660
+ sent: 0,
15275
15661
  generation: entry.out.generation + 1
15276
15662
  };
15277
15663
  this.changed(entry);
@@ -16830,6 +17216,8 @@ var GhostLink = class {
16830
17216
  }
16831
17217
  /** The attempt (`connectionEpoch`) in which the data link began answering the contact's offer. */
16832
17218
  answeringEpoch = -1;
17219
+ /** Set while a dial waits for its WebRTC offer to be made: ends that wait, the contact's offer being answered instead. */
17220
+ offerReplaced;
16833
17221
  lastAutoConnectAt = 0;
16834
17222
  autoConnectFailures = 0;
16835
17223
  /** The look due when a failed dial's backoff ends (`lookAtRetry`). */
@@ -17175,6 +17563,7 @@ var GhostLink = class {
17175
17563
  }
17176
17564
  if (state === "answering") {
17177
17565
  this.answeringEpoch = this.connectionEpoch;
17566
+ this.offerReplaced?.();
17178
17567
  if (this.resumingOverRtc) {
17179
17568
  const epoch = this.connectionEpoch;
17180
17569
  setTimeout(() => this.maybeKnockAnswering(epoch), 0);
@@ -18352,7 +18741,12 @@ var GhostLink = class {
18352
18741
  } : void 0;
18353
18742
  const gate = this.dialGate;
18354
18743
  this.dialGate = void 0;
18355
- if (await this.dataLink.connect(gate) === "held") {
18744
+ const replaced = new Promise((resolve) => {
18745
+ this.offerReplaced = resolve;
18746
+ });
18747
+ const made = await Promise.race([this.dataLink.connect(gate), replaced]);
18748
+ this.offerReplaced = void 0;
18749
+ if (made === "held") {
18356
18750
  traceLink(this.myPubKeyZ32, "dial-held", {});
18357
18751
  this.afterRtc = void 0;
18358
18752
  this.offered = void 0;
@@ -20919,535 +21313,308 @@ function decodeBolt11(text) {
20919
21313
  at += 3 + length;
20920
21314
  if (tag === "d") description = new TextDecoder().decode(wordsToBytes(field)).trim() || void 0;
20921
21315
  else if (tag === "x") expiry = wordsToInt(field);
20922
- else if (tag === "p" && length === 52) paymentHash = [...wordsToBytes(field)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
20923
- else if (tag === "h" && length === 52) descriptionHash = [...wordsToBytes(field)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
20924
- }
20925
- return {
20926
- invoice,
20927
- network: NETWORKS[prefix[1]],
20928
- amountMsat,
20929
- amountSat: amountMsat === null ? null : Number((amountMsat + 999n) / 1000n),
20930
- createdAt,
20931
- expiresAt: createdAt + expiry,
20932
- description,
20933
- descriptionHash,
20934
- paymentHash
20935
- };
20936
- }
20937
- //#endregion
20938
- //#region ../core/src/lnurl.ts
20939
- var LNURL_LIMITS = {
20940
- timeoutMs: 15e3,
20941
- maxBytes: 65536,
20942
- maxUrlLength: 2048,
20943
- maxMetadataLength: 8192,
20944
- maxComment: 2e3
20945
- };
20946
- var ADDRESS = /^([a-z0-9._+-]{1,64})@([a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d{1,5})?)$/i;
20947
- var LNURL_BECH32 = /^lnurl1[02-9ac-hj-np-z]{6,}$/i;
20948
- var isLoopback = (hostname) => [
20949
- "localhost",
20950
- "127.0.0.1",
20951
- "[::1]"
20952
- ].includes(hostname);
20953
- /** HTTPS, or plain HTTP only for a hidden service or a test server on this machine; never credentials. */
20954
- function acceptUrl(text) {
20955
- if (text.length > LNURL_LIMITS.maxUrlLength) throw new Error("That LNURL is too long");
20956
- let url;
20957
- try {
20958
- url = new URL(text);
20959
- } catch {
20960
- throw new Error("That LNURL does not point to a valid address");
20961
- }
20962
- const plainAllowed = url.hostname.endsWith(".onion") || isLoopback(url.hostname);
20963
- if (url.protocol !== "https:" && !(url.protocol === "http:" && plainAllowed)) throw new Error("Lightning addresses and LNURLs must use HTTPS");
20964
- if (url.username || url.password) throw new Error("That LNURL carries credentials, which is not allowed");
20965
- return url;
20966
- }
20967
- /** 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. */
20968
- function parseLightningDestination(input) {
20969
- const text = input.trim().replace(/^(lightning:|⚡\s*)/i, "").trim();
20970
- if (!text) return null;
20971
- if (LNURL_BECH32.test(text)) {
20972
- let decoded;
20973
- try {
20974
- decoded = bech32.decode(text.toLowerCase(), LNURL_LIMITS.maxUrlLength);
20975
- } catch {
20976
- throw new Error("That LNURL is not valid");
20977
- }
20978
- const url = acceptUrl(new TextDecoder().decode(bech32.fromWords(decoded.words)));
20979
- return {
20980
- kind: "lnurl",
20981
- text: text.toLowerCase(),
20982
- url: url.href,
20983
- domain: url.host
20984
- };
20985
- }
20986
- if (/^lnurlp:\/\//i.test(text)) {
20987
- const bare = text.slice(9);
20988
- const host = bare.split(/[/?#]/)[0].toLowerCase();
20989
- const url = acceptUrl(`${host.endsWith(".onion") || isLoopback(host.replace(/:\d+$/, "")) ? "http" : "https"}://${bare}`);
20990
- return {
20991
- kind: "lnurl",
20992
- text: `lnurlp://${bare}`,
20993
- url: url.href,
20994
- domain: url.host
20995
- };
20996
- }
20997
- const address = ADDRESS.exec(text);
20998
- if (!address) return null;
20999
- const name = address[1].toLowerCase(), domain = address[2].toLowerCase();
21000
- const hostname = domain.replace(/:\d+$/, "");
21001
- if (!hostname.includes(".") && !isLoopback(hostname)) return null;
21002
- const url = acceptUrl(`${hostname.endsWith(".onion") || isLoopback(hostname) ? "http" : "https"}://${domain}/.well-known/lnurlp/${name}`);
21003
- return {
21004
- kind: "address",
21005
- text: `${name}@${domain}`,
21006
- url: url.href,
21007
- domain: url.host,
21008
- name
21009
- };
21010
- }
21011
- var hex$1 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
21012
- var reason = (value) => typeof value === "string" ? value.replace(/\s+/g, " ").slice(0, 200) : "";
21013
- var isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
21014
- async function readBounded(response, maxBytes) {
21015
- const reader = response.body?.getReader?.();
21016
- if (!reader) {
21017
- const text = await response.text();
21018
- if (text.length > maxBytes) throw new Error("The server's answer is too large");
21019
- return text;
21020
- }
21021
- const chunks = [];
21022
- let size = 0;
21023
- for (;;) {
21024
- const { done, value } = await reader.read();
21025
- if (done) break;
21026
- size += value.byteLength;
21027
- if (size > maxBytes) {
21028
- await reader.cancel().catch(() => {});
21029
- throw new Error("The server's answer is too large");
21030
- }
21031
- chunks.push(value);
21032
- }
21033
- const all = new Uint8Array(size);
21034
- let at = 0;
21035
- for (const chunk of chunks) {
21036
- all.set(chunk, at);
21037
- at += chunk.byteLength;
21038
- }
21039
- return new TextDecoder().decode(all);
21040
- }
21041
- /** One GET to an LNURL service: bounded in time and size, no credentials, no referrer, and the answer must be JSON. */
21042
- async function fetchLnurlJson(url, options = {}) {
21043
- const target = acceptUrl(url);
21044
- const doFetch = options.fetch ?? globalThis.fetch;
21045
- if (!doFetch) throw new Error("No way to reach the network here");
21046
- const controller = new AbortController();
21047
- const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("The server took too long to answer")), options.timeoutMs ?? LNURL_LIMITS.timeoutMs);
21048
- const onAbort = () => controller.abort(options.signal?.reason);
21049
- options.signal?.addEventListener("abort", onAbort, { once: true });
21050
- try {
21051
- let response;
21052
- try {
21053
- response = await doFetch(target.href, {
21054
- signal: controller.signal,
21055
- headers: { accept: "application/json" },
21056
- credentials: "omit",
21057
- referrerPolicy: "no-referrer",
21058
- redirect: "follow"
21059
- });
21060
- } catch {
21061
- if (controller.signal.aborted) throw controller.signal.reason instanceof Error ? controller.signal.reason : /* @__PURE__ */ new Error("The request was cancelled");
21062
- throw engineError("lnurlUnreachable", { host: target.host });
21063
- }
21064
- if (response.url) acceptUrl(response.url);
21065
- const text = await readBounded(response, options.maxBytes ?? LNURL_LIMITS.maxBytes);
21066
- let body;
21067
- try {
21068
- body = JSON.parse(text);
21069
- } catch {
21070
- throw new Error(`${target.host} did not answer with JSON${response.ok ? "" : ` (HTTP ${response.status})`}`);
21071
- }
21072
- if (isRecord(body) && body.status === "ERROR") throw new Error(`${target.host} refused: ${reason(body.reason) || "no reason given"}`);
21073
- if (!response.ok) throw new Error(`${target.host} answered HTTP ${response.status}`);
21074
- return body;
21075
- } finally {
21076
- clearTimeout(timer);
21077
- options.signal?.removeEventListener("abort", onAbort);
21078
- }
21079
- }
21080
- /** Checks a service's `payRequest` answer (LUD-06), and reads what a person needs to see of it. */
21081
- function parsePayParams(body, destination) {
21082
- if (!isRecord(body)) throw new Error(`${destination.domain} did not answer with a pay request`);
21083
- if (body.tag !== "payRequest") throw new Error(`${destination.domain} answered with something other than a pay request${typeof body.tag === "string" ? ` (${reason(body.tag)})` : ""}`);
21084
- if (typeof body.callback !== "string") throw new Error("The pay request has no callback");
21085
- const callback = acceptUrl(body.callback);
21086
- const { minSendable, maxSendable, metadata, commentAllowed } = body;
21087
- if (!Number.isSafeInteger(minSendable) || !Number.isSafeInteger(maxSendable) || minSendable <= 0 || maxSendable < minSendable) throw new Error("The pay request has invalid amount limits");
21088
- const minSat = Math.ceil(minSendable / 1e3), maxSat = Math.floor(maxSendable / 1e3);
21089
- if (minSat > maxSat) throw new Error("The pay request only takes fractions of a sat, which cannot be paid here");
21090
- if (typeof metadata !== "string" || metadata.length > LNURL_LIMITS.maxMetadataLength) throw new Error("The pay request has no usable metadata");
21091
- let entries;
21092
- try {
21093
- entries = JSON.parse(metadata);
21094
- } catch {
21095
- throw new Error("The pay request's metadata is not JSON");
21096
- }
21097
- if (!Array.isArray(entries) || !entries.every((e) => Array.isArray(e) && typeof e[0] === "string")) throw new Error("The pay request's metadata is malformed");
21098
- const entry = (type) => entries.find((e) => e[0] === type)?.[1];
21099
- const description = entry("text/plain");
21100
- if (typeof description !== "string") throw new Error("The pay request says nothing about what it is for (no text/plain metadata)");
21101
- const identifier = [entry("text/identifier"), entry("text/email")].find((v) => typeof v === "string")?.toLowerCase();
21102
- if (destination.kind === "address" && identifier && identifier !== destination.text) throw new Error(`${destination.domain} answered for ${identifier}, not ${destination.text}`);
21103
- const comment = Number.isSafeInteger(commentAllowed) && commentAllowed > 0 ? Math.min(commentAllowed, LNURL_LIMITS.maxComment) : 0;
21104
- return {
21105
- destination,
21106
- callback: callback.href,
21107
- callbackDomain: callback.host,
21108
- minSendable,
21109
- maxSendable,
21110
- minSat,
21111
- maxSat,
21112
- metadata,
21113
- descriptionHash: hex$1(sha256(new TextEncoder().encode(metadata))),
21114
- description: description.slice(0, 280),
21115
- identifier,
21116
- commentAllowed: comment
21117
- };
21118
- }
21119
- /** Where to ask for the invoice of `amountSat` (and a comment, LUD-12). Throws when the amount or comment is not allowed. */
21120
- function invoiceCallbackUrl(params, amountSat, comment) {
21121
- if (!Number.isSafeInteger(amountSat) || amountSat <= 0) throw new Error("Enter a whole number of sats");
21122
- if (amountSat < params.minSat || amountSat > params.maxSat) throw params.minSat === params.maxSat ? engineError("lnurlExactly", {
21123
- domain: params.destination.domain,
21124
- amount: params.minSat.toLocaleString()
21125
- }) : engineError("lnurlRange", {
21126
- domain: params.destination.domain,
21127
- min: params.minSat.toLocaleString(),
21128
- max: params.maxSat.toLocaleString()
21129
- });
21130
- const url = new URL(params.callback);
21131
- url.searchParams.set("amount", String(amountSat * 1e3));
21132
- const text = comment?.trim();
21133
- if (text) {
21134
- if (!params.commentAllowed) throw new Error(`${params.destination.domain} does not take a comment`);
21135
- if (text.length > params.commentAllowed) throw new Error(`The comment can be at most ${params.commentAllowed} characters`);
21136
- url.searchParams.set("comment", text);
21137
- }
21138
- return url.href;
21139
- }
21140
- /**
21141
- * Checks the invoice a service answered with (LUD-06): the amount asked, a payment hash, not expired, and
21142
- * a commitment to the metadata the person was shown: the `h` tag is its sha256, or the invoice carries the
21143
- * metadata itself as its description (the same commitment, unhashed).
21144
- */
21145
- function parseInvoiceCallback(body, params, amountSat) {
21146
- if (!isRecord(body) || typeof body.pr !== "string") throw new Error(`${params.callbackDomain} did not answer with an invoice`);
21147
- const invoice = decodeBolt11(body.pr);
21148
- if (!invoice) throw new Error(`${params.callbackDomain} answered with an invoice that does not decode`);
21149
- 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`);
21150
- if (!invoice.paymentHash) throw new Error("The invoice has no payment hash");
21151
- 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");
21152
- if (invoice.expiresAt * 1e3 < Date.now()) throw new Error("The invoice the service answered with has already expired");
21153
- let successAction;
21154
- const action = body.successAction;
21155
- if (isRecord(action)) {
21156
- if (action.tag === "message" && typeof action.message === "string") successAction = {
21157
- tag: "message",
21158
- message: reason(action.message)
21159
- };
21160
- else if (action.tag === "url" && typeof action.url === "string" && /^https:\/\//i.test(action.url) && action.url.length <= LNURL_LIMITS.maxUrlLength) successAction = {
21161
- tag: "url",
21162
- description: reason(action.description),
21163
- url: action.url
21164
- };
21316
+ else if (tag === "p" && length === 52) paymentHash = [...wordsToBytes(field)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
21317
+ else if (tag === "h" && length === 52) descriptionHash = [...wordsToBytes(field)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
21165
21318
  }
21166
21319
  return {
21167
21320
  invoice,
21168
- successAction
21321
+ network: NETWORKS[prefix[1]],
21322
+ amountMsat,
21323
+ amountSat: amountMsat === null ? null : Number((amountMsat + 999n) / 1000n),
21324
+ createdAt,
21325
+ expiresAt: createdAt + expiry,
21326
+ description,
21327
+ descriptionHash,
21328
+ paymentHash
21169
21329
  };
21170
21330
  }
21171
- /** Fetches and checks what a Lightning address or LNURL asks for. The service's domain learns of it. */
21172
- async function resolveLightningDestination(text, options = {}) {
21173
- const destination = parseLightningDestination(text);
21174
- if (!destination) throw new Error("That is not a Lightning address or an LNURL");
21175
- return parsePayParams(await fetchLnurlJson(destination.url, options), destination);
21176
- }
21177
- /** Asks the service for an invoice of `amountSat`, and checks it before it is handed to any wallet. */
21178
- async function requestLnurlInvoice(params, amountSat, comment, options = {}) {
21179
- return parseInvoiceCallback(await fetchLnurlJson(invoiceCallbackUrl(params, amountSat, comment), options), params, amountSat);
21180
- }
21181
21331
  //#endregion
21182
- //#region ../core/src/domainProofs.ts
21183
- var DNS_LABEL = "_ghostly";
21184
- var WELL_KNOWN_PATH = "/.well-known/ghostly.json";
21185
- var NIP05_PATH = "/.well-known/nostr.json?name=_";
21186
- /** Everything read from a domain is bounded; these match the fetch limits. */
21187
- var DOMAIN_FILE_MAX_BYTES = 16384;
21188
- var z32 = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/;
21189
- var hex64 = /^[a-f0-9]{64}$/;
21190
- /** Names that never resolve publicly (RFC 6761, 6762, 7686, 8375, 9476) or that
21191
- * only ever mean a private network. A proof for them could only point inward. */
21192
- var SPECIAL_USE = [
21193
- "localhost",
21194
- "local",
21195
- "internal",
21196
- "lan",
21197
- "home",
21198
- "corp",
21199
- "intranet",
21200
- "private",
21201
- "test",
21202
- "example",
21203
- "invalid",
21204
- "onion",
21205
- "alt",
21206
- "arpa"
21332
+ //#region ../core/src/lnurl.ts
21333
+ var LNURL_LIMITS = {
21334
+ timeoutMs: 15e3,
21335
+ maxBytes: 65536,
21336
+ maxRedirects: 5,
21337
+ maxUrlLength: 2048,
21338
+ maxMetadataLength: 8192,
21339
+ maxComment: 2e3
21340
+ };
21341
+ var ADDRESS = /^([a-z0-9._+-]{1,64})@([a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d{1,5})?)$/i;
21342
+ var LNURL_BECH32 = /^lnurl1[02-9ac-hj-np-z]{6,}$/i;
21343
+ var REDIRECTS = [
21344
+ 301,
21345
+ 302,
21346
+ 303,
21347
+ 307,
21348
+ 308
21207
21349
  ];
21208
21350
  /**
21209
- * Normalizes what a person typed ("Example.COM.", "https://example.com/") into
21210
- * a lowercase ASCII host name, or throws with a message fit for the UI.
21211
- * Internationalized names become punycode (xn--), the form DNS carries.
21351
+ * A host that is not on the public internet, as far as its name tells: this machine, a single label, `.local` and its
21352
+ * kin, private and reserved IP addresses. (What a public name resolves to is checked where the client resolves names
21353
+ * itself, as Ghostly Desktop does.)
21212
21354
  */
21213
- function normalizeDomain(input) {
21214
- if (typeof input !== "string") throw new Error("Enter a domain name, like example.com");
21215
- let value = input.trim();
21216
- if (!value || value.length > 300) throw new Error("Enter a domain name, like example.com");
21217
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
21218
- let url;
21219
- try {
21220
- url = new URL(value);
21221
- } catch {
21222
- throw new Error("Enter a domain name, like example.com");
21223
- }
21224
- 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");
21225
- value = url.hostname;
21226
- } else {
21227
- if (/[/:@?#\s\\]/.test(value)) throw new Error("Enter only the domain name, without a path or port");
21228
- try {
21229
- value = new URL(`https://${value}`).hostname;
21230
- } catch {
21231
- throw new Error("That is not a valid domain name");
21232
- }
21355
+ function isLocalHost(hostname) {
21356
+ const host = hostname.toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
21357
+ if (!host.includes(".") && !host.includes(":")) return true;
21358
+ if (/(^|\.)(localhost|local|internal|lan|home\.arpa)$/.test(host)) return true;
21359
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
21360
+ if (v4) {
21361
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
21362
+ 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;
21233
21363
  }
21234
- value = value.toLowerCase().replace(/\.$/, "");
21235
- if (value.length > 244) throw new Error("That domain name is too long");
21236
- const labels = value.split(".");
21237
- if (labels.length < 2) throw new Error("Use a full domain name, like example.com");
21238
- 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");
21239
- const tld = labels[labels.length - 1];
21240
- if (/^[0-9]+$/.test(tld) || /^\[/.test(value)) throw new Error("Use a domain name, not an IP address");
21241
- if (SPECIAL_USE.includes(tld) || labels.length >= 2 && labels.slice(-2).join(".") === "home.arpa") throw new Error("That name only works on private networks");
21242
- return value;
21364
+ if (host.includes(":")) return !/^[23][0-9a-f]{0,3}:/.test(host) || host.includes(".") || /^2001:0?db8:/.test(host);
21365
+ return false;
21243
21366
  }
21244
- var dnsProofName = (domain) => `${DNS_LABEL}.${normalizeDomain(domain)}`;
21245
- var wellKnownUrl = (domain, method = "https") => `https://${normalizeDomain(domain)}${method === "nip05" ? NIP05_PATH : WELL_KNOWN_PATH}`;
21246
- function validDomainRecord(r) {
21247
- if (!r || typeof r.key !== "string" || typeof r.proof !== "string" || !z32.test(r.key) || !hex64.test(r.proof)) return false;
21367
+ var isLoopback = (hostname) => [
21368
+ "localhost",
21369
+ "127.0.0.1",
21370
+ "[::1]"
21371
+ ].includes(hostname);
21372
+ /** HTTPS, or plain HTTP only for a hidden service or a test server on this machine; never credentials. */
21373
+ function acceptUrl(text) {
21374
+ if (text.length > LNURL_LIMITS.maxUrlLength) throw new Error("That LNURL is too long");
21375
+ let url;
21248
21376
  try {
21249
- return toZ32(fromZ32(r.key)) === r.key;
21377
+ url = new URL(text);
21250
21378
  } catch {
21251
- return false;
21252
- }
21253
- }
21254
- /** The TXT record value, in the `tag=value; …` style people know from DKIM. */
21255
- function domainTxtRecord(r) {
21256
- if (!validDomainRecord(r)) throw new Error("Invalid domain record");
21257
- return `v=ghostly1; key=${r.key}; proof=${r.proof}`;
21258
- }
21259
- /** Parses one TXT string. Undefined for anything that is not ours or not exactly
21260
- * well-formed; unknown extra tags are ignored for forward compatibility. */
21261
- function parseDomainTxt(value) {
21262
- if (typeof value !== "string" || utf8Encode(value).length > 1024) return;
21263
- const tags = /* @__PURE__ */ new Map();
21264
- const parts = value.split(";").map((p) => p.trim()).filter(Boolean);
21265
- for (const part of parts) {
21266
- const eq = part.indexOf("=");
21267
- if (eq < 1) return;
21268
- const name = part.slice(0, eq).trim().toLowerCase();
21269
- if (tags.has(name)) return;
21270
- tags.set(name, part.slice(eq + 1).trim());
21379
+ throw new Error("That LNURL does not point to a valid address");
21271
21380
  }
21272
- if (!parts[0]?.toLowerCase().startsWith("v=") || tags.get("v") !== "ghostly1") return;
21273
- const r = {
21274
- key: tags.get("key"),
21275
- proof: tags.get("proof")
21276
- };
21277
- return validDomainRecord(r) ? r : void 0;
21381
+ const plainAllowed = url.hostname.endsWith(".onion") || isLoopback(url.hostname);
21382
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && plainAllowed)) throw new Error("Lightning addresses and LNURLs must use HTTPS");
21383
+ if (url.username || url.password) throw new Error("That LNURL carries credentials, which is not allowed");
21384
+ return url;
21278
21385
  }
21279
- var addRecord = (list, r) => {
21280
- if (!list.some((x) => x.key === r.key && x.proof === r.proof)) list.push(r);
21281
- };
21282
- /** All records among the TXT strings of `_ghostly.<domain>`; other strings are skipped. */
21283
- function recordsFromTxt(values) {
21284
- const out = [];
21285
- for (const value of values.slice(0, 32)) {
21286
- const r = parseDomainTxt(value);
21287
- if (r) addRecord(out, r);
21386
+ /** 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. */
21387
+ function parseLightningDestination(input) {
21388
+ const text = input.trim().replace(/^(lightning:|⚡\s*)/i, "").trim();
21389
+ if (!text) return null;
21390
+ if (LNURL_BECH32.test(text)) {
21391
+ let decoded;
21392
+ try {
21393
+ decoded = bech32.decode(text.toLowerCase(), LNURL_LIMITS.maxUrlLength);
21394
+ } catch {
21395
+ throw new Error("That LNURL is not valid");
21396
+ }
21397
+ const url = acceptUrl(new TextDecoder().decode(bech32.fromWords(decoded.words)));
21398
+ return {
21399
+ kind: "lnurl",
21400
+ text: text.toLowerCase(),
21401
+ url: url.href,
21402
+ domain: url.host
21403
+ };
21288
21404
  }
21289
- return out.slice(0, 8);
21290
- }
21291
- /** The body of `/.well-known/ghostly.json`. */
21292
- function domainWellKnownFile(records) {
21293
- if (!records.length || records.length > 8 || !records.every(validDomainRecord)) throw new Error("Invalid domain records");
21294
- return JSON.stringify({
21295
- ghostly: 1,
21296
- proofs: records.map((r) => ({
21297
- key: r.key,
21298
- proof: r.proof
21299
- }))
21300
- }, null, 2) + "\n";
21301
- }
21302
- function parseJson(text) {
21303
- if (typeof text !== "string" || utf8Encode(text).length > 16384) throw new Error("File too large");
21304
- return JSON.parse(text);
21305
- }
21306
- /** Records from `ghostly.json`. Throws on a malformed file; [] when it names none. */
21307
- function recordsFromWellKnown(text) {
21308
- const body = parseJson(text);
21309
- if (!body || typeof body !== "object" || Array.isArray(body) || body.ghostly !== 1 || !Array.isArray(body.proofs)) throw new Error("Not a Ghostly domain file");
21310
- const out = [];
21311
- for (const entry of body.proofs.slice(0, 32)) {
21312
- if (!entry || typeof entry !== "object") continue;
21313
- const r = {
21314
- key: entry.key,
21315
- proof: entry.proof
21405
+ if (/^lnurlp:\/\//i.test(text)) {
21406
+ const bare = text.slice(9);
21407
+ const host = bare.split(/[/?#]/)[0].toLowerCase();
21408
+ const url = acceptUrl(`${host.endsWith(".onion") || isLoopback(host.replace(/:\d+$/, "")) ? "http" : "https"}://${bare}`);
21409
+ return {
21410
+ kind: "lnurl",
21411
+ text: `lnurlp://${bare}`,
21412
+ url: url.href,
21413
+ domain: url.host
21316
21414
  };
21317
- if (validDomainRecord(r)) addRecord(out, r);
21318
21415
  }
21319
- return out.slice(0, 8);
21320
- }
21321
- /** NIP-05: only the root name `_` speaks for the domain itself. Undefined when absent. */
21322
- function nip05RootKey(text) {
21323
- const body = parseJson(text);
21324
- if (!body || typeof body !== "object" || !body.names || typeof body.names !== "object" || Array.isArray(body.names)) throw new Error("Not a NIP-05 file");
21325
- const root = body.names["_"];
21326
- return typeof root === "string" && hex64.test(root) ? root : void 0;
21416
+ const address = ADDRESS.exec(text);
21417
+ if (!address) return null;
21418
+ const name = address[1].toLowerCase(), domain = address[2].toLowerCase();
21419
+ const hostname = domain.replace(/:\d+$/, "");
21420
+ if (!hostname.includes(".") && !isLoopback(hostname)) return null;
21421
+ const url = acceptUrl(`${hostname.endsWith(".onion") || isLoopback(hostname) ? "http" : "https"}://${domain}/.well-known/lnurlp/${name}`);
21422
+ return {
21423
+ kind: "address",
21424
+ text: `${name}@${domain}`,
21425
+ url: url.href,
21426
+ domain: url.host,
21427
+ name
21428
+ };
21327
21429
  }
21328
- var recordsName = (records, r) => records.some((x) => x.key === r.key && x.proof === r.proof);
21329
- var DNS_TYPE = {
21330
- A: 1,
21331
- CNAME: 5,
21332
- TXT: 16,
21333
- AAAA: 28
21334
- };
21335
- /** A recursive query for one name and type. ID 0, as RFC 8484 recommends for caching;
21336
- * the AD bit asks the resolver to report DNSSEC validation (RFC 6840 §5.7). */
21337
- function encodeDnsQuery(name, type) {
21338
- const labels = name.replace(/\.$/, "").split(".");
21339
- const parts = [Uint8Array.of(0, 0, 1, 32, 0, 1, 0, 0, 0, 0, 0, 0)];
21340
- for (const label of labels) {
21341
- const bytes = utf8Encode(label);
21342
- if (!bytes.length || bytes.length > 63) throw new Error("Invalid DNS name");
21343
- parts.push(Uint8Array.of(bytes.length), bytes);
21430
+ var hex$1 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
21431
+ var reason = (value) => typeof value === "string" ? value.replace(/\s+/g, " ").slice(0, 200) : "";
21432
+ var isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
21433
+ async function readBounded(response, maxBytes) {
21434
+ const reader = response.body?.getReader?.();
21435
+ if (!reader) {
21436
+ const text = await response.text();
21437
+ if (text.length > maxBytes) throw new Error("The server's answer is too large");
21438
+ return text;
21439
+ }
21440
+ const chunks = [];
21441
+ let size = 0;
21442
+ for (;;) {
21443
+ const { done, value } = await reader.read();
21444
+ if (done) break;
21445
+ size += value.byteLength;
21446
+ if (size > maxBytes) {
21447
+ await reader.cancel().catch(() => {});
21448
+ throw new Error("The server's answer is too large");
21449
+ }
21450
+ chunks.push(value);
21344
21451
  }
21345
- const code = DNS_TYPE[type];
21346
- parts.push(Uint8Array.of(0, code >> 8, code & 255, 0, 1));
21347
- return concatBytes$1(...parts);
21452
+ const all = new Uint8Array(size);
21453
+ let at = 0;
21454
+ for (const chunk of chunks) {
21455
+ all.set(chunk, at);
21456
+ at += chunk.byteLength;
21457
+ }
21458
+ return new TextDecoder().decode(all);
21348
21459
  }
21349
- function readName(data, start) {
21350
- const labels = [];
21351
- let pos = start, next = -1, jumps = 0, length = 0;
21352
- for (;;) {
21353
- if (pos >= data.length) throw new Error("DNS name out of bounds");
21354
- const len = data[pos];
21355
- if ((len & 192) === 192) {
21356
- if (pos + 1 >= data.length) throw new Error("DNS pointer out of bounds");
21357
- if (next < 0) next = pos + 2;
21358
- pos = (len & 63) << 8 | data[pos + 1];
21359
- if (++jumps > 32) throw new Error("DNS name loops");
21360
- continue;
21460
+ /** 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. */
21461
+ async function fetchLnurlJson(url, options = {}) {
21462
+ const target = acceptUrl(url);
21463
+ const doFetch = options.fetch ?? globalThis.fetch;
21464
+ if (!doFetch) throw new Error("No way to reach the network here");
21465
+ const controller = new AbortController();
21466
+ const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("The server took too long to answer")), options.timeoutMs ?? LNURL_LIMITS.timeoutMs);
21467
+ const onAbort = () => controller.abort(options.signal?.reason);
21468
+ options.signal?.addEventListener("abort", onAbort, { once: true });
21469
+ try {
21470
+ let response;
21471
+ for (let at = target, hops = 0;; hops++) {
21472
+ try {
21473
+ response = await doFetch(at.href, {
21474
+ signal: controller.signal,
21475
+ headers: { accept: "application/json" },
21476
+ credentials: "omit",
21477
+ referrerPolicy: "no-referrer",
21478
+ redirect: "manual"
21479
+ });
21480
+ } catch {
21481
+ if (controller.signal.aborted) throw controller.signal.reason instanceof Error ? controller.signal.reason : /* @__PURE__ */ new Error("The request was cancelled");
21482
+ throw engineError("lnurlUnreachable", { host: target.host });
21483
+ }
21484
+ if (response.url) acceptUrl(response.url);
21485
+ if (response.type === "opaqueredirect") throw new Error(`${target.host} redirects to another address, which cannot be checked here`);
21486
+ const location = REDIRECTS.includes(response.status) ? response.headers.get("location") : null;
21487
+ if (!location) break;
21488
+ await response.body?.cancel().catch(() => {});
21489
+ if (hops >= LNURL_LIMITS.maxRedirects) throw new Error(`${target.host} redirects too many times`);
21490
+ let next;
21491
+ try {
21492
+ next = new URL(location, at);
21493
+ } catch {
21494
+ throw new Error(`${target.host} redirects to an invalid address`);
21495
+ }
21496
+ next = acceptUrl(next.href);
21497
+ if (next.host !== at.host) {
21498
+ if (next.protocol !== "https:") throw new Error("Lightning addresses and LNURLs must use HTTPS");
21499
+ if (isLocalHost(next.hostname)) throw new Error(`${target.host} redirects to a local address`);
21500
+ }
21501
+ at = next;
21361
21502
  }
21362
- if (len & 192) throw new Error("Unsupported DNS label");
21363
- pos += 1;
21364
- if (len === 0) break;
21365
- if (pos + len > data.length) throw new Error("DNS label out of bounds");
21366
- length += len + 1;
21367
- if (length > 255) throw new Error("DNS name too long");
21368
- labels.push(utf8Decode(data.subarray(pos, pos + len)).toLowerCase());
21369
- pos += len;
21503
+ const text = await readBounded(response, options.maxBytes ?? LNURL_LIMITS.maxBytes);
21504
+ let body;
21505
+ try {
21506
+ body = JSON.parse(text);
21507
+ } catch {
21508
+ throw new Error(`${target.host} did not answer with JSON${response.ok ? "" : ` (HTTP ${response.status})`}`);
21509
+ }
21510
+ if (isRecord(body) && body.status === "ERROR") throw new Error(`${target.host} refused: ${reason(body.reason) || "no reason given"}`);
21511
+ if (!response.ok) throw new Error(`${target.host} answered HTTP ${response.status}`);
21512
+ return body;
21513
+ } finally {
21514
+ clearTimeout(timer);
21515
+ options.signal?.removeEventListener("abort", onAbort);
21516
+ }
21517
+ }
21518
+ /** Checks a service's `payRequest` answer (LUD-06), and reads what a person needs to see of it. */
21519
+ function parsePayParams(body, destination) {
21520
+ if (!isRecord(body)) throw new Error(`${destination.domain} did not answer with a pay request`);
21521
+ if (body.tag !== "payRequest") throw new Error(`${destination.domain} answered with something other than a pay request${typeof body.tag === "string" ? ` (${reason(body.tag)})` : ""}`);
21522
+ if (typeof body.callback !== "string") throw new Error("The pay request has no callback");
21523
+ const callback = acceptUrl(body.callback);
21524
+ const { minSendable, maxSendable, metadata, commentAllowed } = body;
21525
+ if (!Number.isSafeInteger(minSendable) || !Number.isSafeInteger(maxSendable) || minSendable <= 0 || maxSendable < minSendable) throw new Error("The pay request has invalid amount limits");
21526
+ const minSat = Math.ceil(minSendable / 1e3), maxSat = Math.floor(maxSendable / 1e3);
21527
+ if (minSat > maxSat) throw new Error("The pay request only takes fractions of a sat, which cannot be paid here");
21528
+ if (typeof metadata !== "string" || metadata.length > LNURL_LIMITS.maxMetadataLength) throw new Error("The pay request has no usable metadata");
21529
+ let entries;
21530
+ try {
21531
+ entries = JSON.parse(metadata);
21532
+ } catch {
21533
+ throw new Error("The pay request's metadata is not JSON");
21370
21534
  }
21535
+ if (!Array.isArray(entries) || !entries.every((e) => Array.isArray(e) && typeof e[0] === "string")) throw new Error("The pay request's metadata is malformed");
21536
+ const entry = (type) => entries.find((e) => e[0] === type)?.[1];
21537
+ const description = entry("text/plain");
21538
+ if (typeof description !== "string") throw new Error("The pay request says nothing about what it is for (no text/plain metadata)");
21539
+ const identifier = [entry("text/identifier"), entry("text/email")].find((v) => typeof v === "string")?.toLowerCase();
21540
+ if (destination.kind === "address" && identifier && identifier !== destination.text) throw new Error(`${destination.domain} answered for ${identifier}, not ${destination.text}`);
21541
+ const comment = Number.isSafeInteger(commentAllowed) && commentAllowed > 0 ? Math.min(commentAllowed, LNURL_LIMITS.maxComment) : 0;
21371
21542
  return {
21372
- name: labels.join("."),
21373
- next: next < 0 ? pos : next
21543
+ destination,
21544
+ callback: callback.href,
21545
+ callbackDomain: callback.host,
21546
+ minSendable,
21547
+ maxSendable,
21548
+ minSat,
21549
+ maxSat,
21550
+ metadata,
21551
+ descriptionHash: hex$1(sha256(new TextEncoder().encode(metadata))),
21552
+ description: description.slice(0, 280),
21553
+ identifier,
21554
+ commentAllowed: comment
21374
21555
  };
21375
21556
  }
21557
+ /** Where to ask for the invoice of `amountSat` (and a comment, LUD-12). Throws when the amount or comment is not allowed. */
21558
+ function invoiceCallbackUrl(params, amountSat, comment) {
21559
+ if (!Number.isSafeInteger(amountSat) || amountSat <= 0) throw new Error("Enter a whole number of sats");
21560
+ if (amountSat < params.minSat || amountSat > params.maxSat) throw params.minSat === params.maxSat ? engineError("lnurlExactly", {
21561
+ domain: params.destination.domain,
21562
+ amount: params.minSat.toLocaleString()
21563
+ }) : engineError("lnurlRange", {
21564
+ domain: params.destination.domain,
21565
+ min: params.minSat.toLocaleString(),
21566
+ max: params.maxSat.toLocaleString()
21567
+ });
21568
+ const url = new URL(params.callback);
21569
+ url.searchParams.set("amount", String(amountSat * 1e3));
21570
+ const text = comment?.trim();
21571
+ if (text) {
21572
+ if (!params.commentAllowed) throw new Error(`${params.destination.domain} does not take a comment`);
21573
+ if (text.length > params.commentAllowed) throw new Error(`The comment can be at most ${params.commentAllowed} characters`);
21574
+ url.searchParams.set("comment", text);
21575
+ }
21576
+ return url.href;
21577
+ }
21376
21578
  /**
21377
- * Decodes a resolver's answer to `encodeDnsQuery(name, type)`. Refuses a reply
21378
- * to another question or a truncated one, and keeps only records of the asked
21379
- * type owned by the name or by the CNAME chain the answer itself follows.
21579
+ * Checks the invoice a service answered with (LUD-06): the amount asked, a payment hash, not expired, and
21580
+ * a commitment to the metadata the person was shown: the `h` tag is its sha256, or the invoice carries the
21581
+ * metadata itself as its description (the same commitment, unhashed).
21380
21582
  */
21381
- function decodeDnsResponse(data, name, type) {
21382
- if (data.length < 12) throw new Error("DNS response too short");
21383
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
21384
- const flags = view.getUint16(2);
21385
- if (!(flags & 32768) || flags & 30720 || flags & 512) throw new Error("Unusable DNS response");
21386
- const questions = view.getUint16(4), count = view.getUint16(6);
21387
- const want = name.replace(/\.$/, "").toLowerCase(), code = DNS_TYPE[type];
21388
- if (questions !== 1) throw new Error("DNS response is for another question");
21389
- const q = readName(data, 12);
21390
- if (q.name !== want || q.next + 4 > data.length || view.getUint16(q.next) !== code) throw new Error("DNS response is for another question");
21391
- let pos = q.next + 4;
21392
- const all = [];
21393
- for (let i = 0; i < count && i < 64; i++) {
21394
- const owner = readName(data, pos);
21395
- pos = owner.next;
21396
- if (pos + 10 > data.length) throw new Error("DNS record out of bounds");
21397
- const rtype = view.getUint16(pos), ttl = view.getUint32(pos + 4), len = view.getUint16(pos + 8);
21398
- pos += 10;
21399
- if (pos + len > data.length) throw new Error("DNS record out of bounds");
21400
- const rdata = rtype === DNS_TYPE.CNAME ? utf8Encode(readName(data, pos).name) : data.slice(pos, pos + len);
21401
- all.push({
21402
- name: owner.name,
21403
- type: rtype,
21404
- ttl,
21405
- data: rdata
21406
- });
21407
- pos += len;
21408
- }
21409
- const owners = /* @__PURE__ */ new Set([want]);
21410
- for (let hop = 0; hop < 8; hop++) {
21411
- const cname = all.find((a) => a.type === DNS_TYPE.CNAME && owners.has(a.name) && !owners.has(utf8Decode(a.data)));
21412
- if (!cname) break;
21413
- owners.add(utf8Decode(cname.data));
21583
+ function parseInvoiceCallback(body, params, amountSat) {
21584
+ if (!isRecord(body) || typeof body.pr !== "string") throw new Error(`${params.callbackDomain} did not answer with an invoice`);
21585
+ const invoice = decodeBolt11(body.pr);
21586
+ if (!invoice) throw new Error(`${params.callbackDomain} answered with an invoice that does not decode`);
21587
+ 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`);
21588
+ if (!invoice.paymentHash) throw new Error("The invoice has no payment hash");
21589
+ 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");
21590
+ if (invoice.expiresAt * 1e3 < Date.now()) throw new Error("The invoice the service answered with has already expired");
21591
+ let successAction;
21592
+ const action = body.successAction;
21593
+ if (isRecord(action)) {
21594
+ if (action.tag === "message" && typeof action.message === "string") successAction = {
21595
+ tag: "message",
21596
+ message: reason(action.message)
21597
+ };
21598
+ else if (action.tag === "url" && typeof action.url === "string" && /^https:\/\//i.test(action.url) && action.url.length <= LNURL_LIMITS.maxUrlLength) successAction = {
21599
+ tag: "url",
21600
+ description: reason(action.description),
21601
+ url: action.url
21602
+ };
21414
21603
  }
21415
21604
  return {
21416
- rcode: flags & 15,
21417
- authenticated: !!(flags & 32),
21418
- answers: all.filter((a) => a.type === code && owners.has(a.name))
21605
+ invoice,
21606
+ successAction
21419
21607
  };
21420
21608
  }
21421
- /** The TXT character-strings of one record, joined as RFC 7208 §3.3 does. */
21422
- function txtValue(rdata) {
21423
- const parts = [];
21424
- for (let p = 0; p < rdata.length;) {
21425
- const len = rdata[p];
21426
- if (p + 1 + len > rdata.length) throw new Error("DNS text out of bounds");
21427
- parts.push(rdata.subarray(p + 1, p + 1 + len));
21428
- p += 1 + len;
21429
- }
21430
- return new TextDecoder("utf-8", { fatal: true }).decode(concatBytes$1(...parts));
21609
+ /** Fetches and checks what a Lightning address or LNURL asks for. The service's domain learns of it. */
21610
+ async function resolveLightningDestination(text, options = {}) {
21611
+ const destination = parseLightningDestination(text);
21612
+ if (!destination) throw new Error("That is not a Lightning address or an LNURL");
21613
+ return parsePayParams(await fetchLnurlJson(destination.url, options), destination);
21431
21614
  }
21432
- /** False for loopback, private, link-local, shared (CGNAT), multicast, reserved
21433
- * and unspecified addresses, including IPv4 embedded in IPv6. */
21434
- function isPublicAddress(address) {
21435
- if (address.length === 4) {
21436
- const [a, b, c] = address;
21437
- 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));
21438
- }
21439
- if (address.length !== 16) return false;
21440
- const zeroTo = (n) => address.subarray(0, n).every((x) => x === 0);
21441
- if (zeroTo(15) && (address[15] === 0 || address[15] === 1)) return false;
21442
- if (zeroTo(10) && address[10] === 255 && address[11] === 255) return isPublicAddress(address.subarray(12));
21443
- 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));
21444
- if (zeroTo(12)) return false;
21445
- if ((address[0] & 254) === 252) return false;
21446
- if (address[0] === 254 && (address[1] & 192) === 128) return false;
21447
- if (address[0] === 254 && (address[1] & 192) === 192) return false;
21448
- if (address[0] === 255) return false;
21449
- if (address[0] === 1 && address.subarray(1, 8).every((x) => x === 0)) return false;
21450
- return (address[0] & 224) === 32;
21615
+ /** Asks the service for an invoice of `amountSat`, and checks it before it is handed to any wallet. */
21616
+ async function requestLnurlInvoice(params, amountSat, comment, options = {}) {
21617
+ return parseInvoiceCallback(await fetchLnurlJson(invoiceCallbackUrl(params, amountSat, comment), options), params, amountSat);
21451
21618
  }
21452
21619
  //#endregion
21453
21620
  //#region ../core/src/pkdns.ts
@@ -25763,7 +25930,7 @@ var GroupSession = class {
25763
25930
  const text = edit.text.trim();
25764
25931
  if (!validEditText(text)) return { error: text ? "Message exceeds 16 KiB" : "An edit cannot be empty" };
25765
25932
  if (!this.secret(ref.e)) return { error: "This message is too old to edit: its epoch's key is gone" };
25766
- const named = validMentions(wireMentions(edit.mentions ?? []), text, rosterAdmin(commit.m) === this.myKey);
25933
+ const named = validMentions(wireMentions(edit.mentions ?? []), text, rosterAdmin(commit.m) === this.myKey && this.isAdmin);
25767
25934
  const plain = JSON.stringify({
25768
25935
  text,
25769
25936
  ...named.length ? { m: named } : {},
@@ -26087,7 +26254,7 @@ var GroupSession = class {
26087
26254
  return;
26088
26255
  }
26089
26256
  const id = first && !this.provisional.has(own) ? groupMessageId(raw.s, first.e, first.n) : own;
26090
- const mentions = this.openMentions(key, raw, text, rosterAdmin(commit.m) === raw.s);
26257
+ const mentions = this.openMentions(key, raw, text, rosterAdmin(commit.m) === raw.s && this.admin === raw.s);
26091
26258
  const reply = this.openReply(key, raw);
26092
26259
  const forwarded = readForwarded(raw.f);
26093
26260
  const card = this.openCard(key, raw);
@@ -26150,7 +26317,7 @@ var GroupSession = class {
26150
26317
  return;
26151
26318
  }
26152
26319
  if (!body || typeof body !== "object" || !validEditText(body.text)) return;
26153
- const k = validMentions(body.m, body.text, rosterAdmin(commit.m) === raw.s);
26320
+ const k = validMentions(body.m, body.text, rosterAdmin(commit.m) === raw.s && this.admin === raw.s);
26154
26321
  const sc = body.sc === void 0 ? void 0 : readStatusCard(body.sc);
26155
26322
  if (!validEditNumber(raw.v) && !sc) return;
26156
26323
  const copy = (this.state.relay ?? []).find((f) => f.s === raw.s && f.e === raw.e && f.n === raw.n);
@@ -26778,6 +26945,10 @@ var COMMUNITY_LIMITS = {
26778
26945
  side: 512,
26779
26946
  /** Commits waiting for their parent. */
26780
26947
  pendingCommits: 64,
26948
+ /** Epoch secrets waiting for their commit. */
26949
+ pendingSecrets: 32,
26950
+ /** 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. */
26951
+ pendingMs: 12e4,
26781
26952
  waiting: 64,
26782
26953
  waitingBytes: 1048576,
26783
26954
  replay: 256,
@@ -26837,6 +27008,17 @@ var FRESH = /* @__PURE__ */ new Set([
26837
27008
  "rotate"
26838
27009
  ]);
26839
27010
  /**
27011
+ * Commits that make the membership history longer and the group no smaller. A committer signs one only while the
27012
+ * history keeps room for everyone else to get out after it: two commits a member (a leave or a remove is one, and a
27013
+ * leaving admin hands the role over first). The history is never compacted, and one that filled with people in it
27014
+ * took no leave, remove or handover any more. Its own rule: a history up to `COMMUNITY_LIMITS.chain` verifies as before.
27015
+ */
27016
+ var GROWS = /* @__PURE__ */ new Set([
27017
+ "add",
27018
+ "rotate",
27019
+ "link"
27020
+ ]);
27021
+ /**
26840
27022
  * Kinds only the admin commits. They are final: a branch that drops one never replaces the branch
26841
27023
  * that has it, however long, so no member can undo a removal, a hand-over, a rotation or a link change
26842
27024
  * by outgrowing it with admissions.
@@ -27515,13 +27697,20 @@ var CommunitySession = class {
27515
27697
  if (typeof p !== "string" || !Number.isSafeInteger(e)) return false;
27516
27698
  const parent = this.known.get(p), parentRoster = parent ? this.rosterAt(p) : void 0;
27517
27699
  if (!parent || !parentRoster) {
27518
- if (this.pendingCommits.size < COMMUNITY_LIMITS.pendingCommits) {
27519
- this.pendingCommits.set(p + ":" + String(e), {
27700
+ const commit = parseCommit(raw);
27701
+ if (!commit || commit.g !== this.id || !verify(fromBase64Url(commit.sig), utf8Encode(tuple(commit)), publicKeyFromZ32(commit.by))) return false;
27702
+ if (this.wasAdmin(commit.by)) {
27703
+ const now = this.hooks.clock?.() ?? Date.now(), key = p + ":" + String(e);
27704
+ this.dropStale(this.pendingCommits, now);
27705
+ this.pendingCommits.delete(key);
27706
+ if (this.pendingCommits.size >= COMMUNITY_LIMITS.pendingCommits) this.pendingCommits.delete(this.pendingCommits.keys().next().value);
27707
+ this.pendingCommits.set(key, {
27520
27708
  from,
27521
- commit: raw
27709
+ commit,
27710
+ at: now
27522
27711
  });
27523
- this.ask(from);
27524
27712
  }
27713
+ this.ask(from);
27525
27714
  return false;
27526
27715
  }
27527
27716
  const result = verifyCommunityCommit(raw, parent, parentRoster, this.id);
@@ -27557,7 +27746,7 @@ var CommunitySession = class {
27557
27746
  const sealed = this.pendingSecrets.get(h);
27558
27747
  if (sealed) {
27559
27748
  this.pendingSecrets.delete(h);
27560
- this.takeSecret(h, sealed);
27749
+ this.takeSecret(h, sealed.sealed);
27561
27750
  }
27562
27751
  await this.chooseBranch();
27563
27752
  for (const [key, pending] of [...this.pendingCommits]) if (key.startsWith(h + ":")) {
@@ -27571,6 +27760,18 @@ var CommunitySession = class {
27571
27760
  }
27572
27761
  return true;
27573
27762
  }
27763
+ /** Is, or was on the branch I follow, the admin (and was not taken out since). */
27764
+ wasAdmin(key) {
27765
+ if (this.wasRemoved(key)) return false;
27766
+ return this.admin === key || this.state.chain.some((c, i) => i === 0 ? c.by === key : c.k === "role" && c.s === key);
27767
+ }
27768
+ /** What waited longer than `pendingMs` goes (the oldest first: a Map keeps the order they came in). */
27769
+ dropStale(pending, now) {
27770
+ for (const [key, { at }] of pending) {
27771
+ if (now - at < COMMUNITY_LIMITS.pendingMs) break;
27772
+ pending.delete(key);
27773
+ }
27774
+ }
27574
27775
  /**
27575
27776
  * Follows the best branch among the known tips (see `better`); reorganizes the main branch when it
27576
27777
  * changed. A branch that keeps the admin's changes, or a longer one, wins however far back it parts
@@ -27673,7 +27874,15 @@ var CommunitySession = class {
27673
27874
  takeSecret(h, sealed) {
27674
27875
  const c = this.known.get(h);
27675
27876
  if (!c) {
27676
- if (isSealed(sealed) && this.pendingSecrets.size < 32) this.pendingSecrets.set(h, sealed);
27877
+ if (!HASH.test(h) || !isSealed(sealed)) return false;
27878
+ const now = this.hooks.clock?.() ?? Date.now();
27879
+ this.dropStale(this.pendingSecrets, now);
27880
+ this.pendingSecrets.delete(h);
27881
+ if (this.pendingSecrets.size >= COMMUNITY_LIMITS.pendingSecrets) this.pendingSecrets.delete(this.pendingSecrets.keys().next().value);
27882
+ this.pendingSecrets.set(h, {
27883
+ sealed,
27884
+ at: now
27885
+ });
27677
27886
  return false;
27678
27887
  }
27679
27888
  if (this.state.secrets[h] || !isSealed(sealed) || !rosterHas(this.rosterAt(h) ?? [], this.myKey)) return false;
@@ -27738,6 +27947,7 @@ var CommunitySession = class {
27738
27947
  };
27739
27948
  const roster = communityRoster(this.roster, draftBase);
27740
27949
  if (!roster) throw new Error("This change is not allowed");
27950
+ 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.");
27741
27951
  const draft = {
27742
27952
  ...draftBase,
27743
27953
  m: rosterHash(roster)
@@ -28443,7 +28653,9 @@ var CommunitySession = class {
28443
28653
  }
28444
28654
  /** Something is missing here: the current epoch's secret, or the epoch of frames waiting. */
28445
28655
  get needsCatchUp() {
28446
- return this.isMember && (!this.state.secrets[this.topHash] || this.waiting.length > 0 || this.pendingCommits.size > 0);
28656
+ if (!this.isMember) return false;
28657
+ this.dropStale(this.pendingCommits, this.hooks.clock?.() ?? Date.now());
28658
+ return !this.state.secrets[this.topHash] || this.waiting.length > 0 || this.pendingCommits.size > 0;
28447
28659
  }
28448
28660
  /** Asks a member I have an edge to for what I lack (the engine calls it now and then while `needsCatchUp`). */
28449
28661
  catchUp(to) {
@@ -28877,6 +29089,8 @@ var APP_BUNDLE_LIMITS = {
28877
29089
  /** A path, in bytes (paths are ASCII). */
28878
29090
  pathBytes: 128,
28879
29091
  iconBytes: 262144,
29092
+ /** The icon's width and height, in pixels: a client decodes it whole to draw it, whatever its bytes. */
29093
+ iconEdge: 1024,
28880
29094
  screenshots: 8,
28881
29095
  /** Lengths in Unicode code points. */
28882
29096
  title: 40,
@@ -29272,6 +29486,11 @@ function readAppBundle(bytes) {
29272
29486
  reason: "bad-icon",
29273
29487
  detail: "icon.png is not a square PNG"
29274
29488
  };
29489
+ if (size.width > APP_BUNDLE_LIMITS.iconEdge) return {
29490
+ ok: false,
29491
+ reason: "bad-icon",
29492
+ detail: "icon.png is larger than 1024 × 1024 pixels"
29493
+ };
29275
29494
  }
29276
29495
  return {
29277
29496
  ok: true,
@@ -29425,6 +29644,13 @@ var STORE_REQUIRED = [
29425
29644
  "removed",
29426
29645
  "revoked"
29427
29646
  ];
29647
+ /** Runs a check written in steps to its end, in one go. */
29648
+ function inOneRun(steps) {
29649
+ for (;;) {
29650
+ const step = steps.next();
29651
+ if (step.done) return step.value;
29652
+ }
29653
+ }
29428
29654
  /**
29429
29655
  * Checks an index's fields and bounds, and every revocation it carries (one that does not verify refuses the index:
29430
29656
  * the store copied it, so a broken one is the store's fault). The signature and `expires` are `readAppStore`'s. An
@@ -29432,6 +29658,14 @@ var STORE_REQUIRED = [
29432
29658
  * statement and keeps its exact keys.
29433
29659
  */
29434
29660
  function checkAppStoreIndex(value, options = {}) {
29661
+ const fields = inOneRun(checkAppStoreFieldsSteps(value, options));
29662
+ return fields.ok ? inOneRun(checkAppStoreRevocationsSteps(value)) : fields;
29663
+ }
29664
+ /**
29665
+ * The first half of `checkAppStoreIndex`: the fields and bounds, and the shape of every revocation, with no signature
29666
+ * checked. A step after each listing, removal and revocation (see `readAppStoreSteps`).
29667
+ */
29668
+ function* checkAppStoreFieldsSteps(value, options = {}) {
29435
29669
  if (!isObject(value)) return {
29436
29670
  ok: false,
29437
29671
  reason: "bad-field",
@@ -29468,38 +29702,65 @@ function checkAppStoreIndex(value, options = {}) {
29468
29702
  detail: ref
29469
29703
  };
29470
29704
  refs.add(ref);
29705
+ yield;
29471
29706
  }
29472
29707
  if (!Array.isArray(removed) || removed.length > APP_STORE_LIMITS.removed) return bad("removed");
29473
29708
  for (const entry of removed) {
29474
29709
  const checked = checkRemoval(entry, options);
29475
29710
  if (!checked.ok) return checked;
29711
+ yield;
29476
29712
  }
29477
29713
  if (!Array.isArray(revoked) || revoked.length > APP_STORE_LIMITS.revoked) return bad("revoked");
29478
29714
  for (const entry of revoked) {
29479
- const read = readAppRevocation(entry);
29715
+ const read = readAppRevocationShape(entry);
29480
29716
  if (!read.ok) return {
29481
29717
  ok: false,
29482
29718
  reason: "bad-revocation",
29483
29719
  detail: read.detail ?? read.reason
29484
29720
  };
29721
+ yield;
29722
+ }
29723
+ return fine;
29724
+ }
29725
+ /** The second half: the signature of each revocation of an index whose fields hold, one step each. */
29726
+ function* checkAppStoreRevocationsSteps(index, checked) {
29727
+ for (const revocation of index.revoked) {
29728
+ const verified = verifyAppRevocation(revocation, checked);
29729
+ if (!verified.ok) return {
29730
+ ok: false,
29731
+ reason: "bad-revocation",
29732
+ detail: verified.reason
29733
+ };
29734
+ yield;
29485
29735
  }
29486
29736
  return fine;
29487
29737
  }
29488
29738
  /**
29489
- * Reads `ghostly-store.json` with its `ghostly-store.sig`, in this order: size, JSON, canonical bytes, fields and
29490
- * revocations, the signature statement, the key the reader holds for this store (when it holds one), the signature,
29491
- * then `expires` against `now` (seconds): more than 90 days ahead is refused, past is `expired`. A key it does not know
29492
- * is ignored (a later store's optional field) and stays in the bytes the signature covers.
29739
+ * Reads `ghostly-store.json` with its `ghostly-store.sig`, in this order: size, JSON, canonical bytes, fields and the
29740
+ * shape of each revocation, the signature statement, the key the reader holds for this store (when it holds one), the
29741
+ * signature, the signature of each revocation, then `expires` against `now` (seconds): more than 90 days ahead is
29742
+ * refused, past is `expired`. The index's own signature comes before its revocations': an index nobody signed costs
29743
+ * its reader one signature check, whatever it carries. A key it does not know is ignored (a later store's optional
29744
+ * field) and stays in the bytes the signature covers.
29493
29745
  */
29494
29746
  function readAppStore(indexBytes, sigBytes, now, heldKey) {
29747
+ return inOneRun(readAppStoreSteps(indexBytes, sigBytes, now, heldKey));
29748
+ }
29749
+ /**
29750
+ * `readAppStore` in steps, for a reader that shares its thread (a page): it stops after each listing, removal and
29751
+ * revocation, and after each revocation's signature check, so the caller can let the thread go between two `next()`
29752
+ * and an index of 4096 revocations never holds it for seconds. With `checked`, a revocation it holds is not checked again.
29753
+ */
29754
+ function* readAppStoreSteps(indexBytes, sigBytes, now, heldKey, checked) {
29495
29755
  if (indexBytes.length > APP_STORE_LIMITS.indexBytes) return {
29496
29756
  ok: false,
29497
29757
  reason: "too-large"
29498
29758
  };
29499
29759
  const read = readCanonicalJson(indexBytes);
29500
29760
  if (!read.ok) return read;
29501
- const checked = checkAppStoreIndex(read.value);
29502
- if (!checked.ok) return checked;
29761
+ yield;
29762
+ const fields = yield* checkAppStoreFieldsSteps(read.value);
29763
+ if (!fields.ok) return fields;
29503
29764
  const index = read.value;
29504
29765
  const statement = readAppSignature(sigBytes);
29505
29766
  if (!statement.ok) return statement;
@@ -29510,6 +29771,9 @@ function readAppStore(indexBytes, sigBytes, now, heldKey) {
29510
29771
  };
29511
29772
  const signed = verifyAppSignature(APP_PREFIXES.store, indexBytes, statement.signature, index.key);
29512
29773
  if (!signed.ok) return signed;
29774
+ yield;
29775
+ const revocations = yield* checkAppStoreRevocationsSteps(index, checked);
29776
+ if (!revocations.ok) return revocations;
29513
29777
  if (index.expires > now + APP_STORE_LIMITS.expiresAheadS) return {
29514
29778
  ok: false,
29515
29779
  reason: "expires-too-far"
@@ -29600,6 +29864,6 @@ function appStoreDecision(held, read) {
29600
29864
  return read.digest === held.digest ? "same" : "equivocation";
29601
29865
  }
29602
29866
  //#endregion
29603
- export { SSHSIG_NAMESPACE as $, readAppRevocations as $a, parseImageMeta as $i, engineError as $n, handoffContextProof as $o, pinNumberHolds as $r, HANDOFF_CAPABILITY as $s, PaymentPreflightError as $t, decodeGroupEntryLink as A, pairedReplyAuthor as Aa, withRequestOptions as Ac, IDENTITY_REVOCATION_LABEL as Ai, encodeDnsQuery as An, signalHasVideo as Ao, mergeBeacon as Ar, ENROLL_HELLO as As, didJwsAlg as At, groupWakes as B, statusCardText as Ba, publicKeyFromZ32 as Bc, lastSharedWithMe as Bi, resolveLightningDestination as Bn, HANDOFF_OFFER as Bo, FILE_LIMITS as Br, readEnrollHello as Bs, parseDidSignaturePaste as Bt, COMMUNITY_LIMITS as C, nextReactionNumber as Ca, RelayTransport as Cc, readManifest as Ci, homeserverWebEndpoint as Cn, RTC_CONFIG as Co, COMMUNITY_TOPOLOGY as Cr, readSetUpdate as Cs, DID_DOCUMENT_MAX_BYTES as Ct, decodeCommunityLink as D, readReaction as Da, traceLink as Dc, dialDescriptors as Di, dnsProofName as Dn, callSignalHeardAt as Do, freshHubs as Dr, verifySetUpdate as Ds, checkDidJws as Dt, communityHasFrame as E, reactionIsNewer as Ea, setLinkTraceSink as Ec, capsDescriptors as Ei, decodeDnsResponse as En, callRtcConfig as Eo, doorHubs as Er, signTombstone as Es, DidError as Et, mergeKnocks as F, RUN_RESULTS as Fa, openRelayPayload as Fc, identityRevocationValue as Fi, recordsFromWellKnown as Fn, HANDOFF_ACK as Fo, readBeacon as Fr, enrollDoneFrame as Fs, didWebFileUrl as Ft, COMMUNITY_EDIT_FRAME as G, shownTime as Ga, fromBase64Url as Gc, automaticTransport as Gi, findEndpoint as Gn, HANDOFF_VERIFIED as Go, TypingSender as Gr, OwnInviteError as Gs, didDhtFromPublicKey as Gt, GROUP_REACTED_FRAME as H, claimedTime as Ha, MAX_AVATAR_LENGTH as Hc, emptyProofLedger as Hi, ENDPOINT as Hn, HANDOFF_PIECE_BYTES as Ho, transferStalled as Hr, createDeviceInvite as Hs, staticDidDocument as Ht, readKnocks as I, STATUS_CARD_LIMITS as Ia, parseRelayPayload as Ic, identityStatement as Ii, recordsName as In, HANDOFF_CANCEL as Io, readBeaconHead as Ir, enrollGrantFrame as Is, didWebHost as It, carryMentions as J, appFingerprint as Ja, toZ32 as Jc, DHT_FILE_TOO_LARGE as Ji, parseFedimintRequestPayload as Jn, handoffAttemptFailed as Jo, GROUP_PIN_FRAME as Jr, encodeInviteCode as Js, resolveDidDht as Jt, GROUP_EDIT_FRAME as K, APP_PREFIXES as Ka, randomBytes as Kc, irohDescriptor as Ki, isFederationId as Kn, filePartName as Ko, sanitizeTypingStatus as Kr, createChatInvite as Ks, encodeDidDhtDeactivated as Kt, GROUP_WAKE_FRAME as L, buttonLabelClash as La, createIdentity as Lc, newIdentityBinding as Li, txtValue as Ln, HANDOFF_DONE as Lo, readLobby as Lr, enrollHelloFrame as Ls, normalizeDid as Lt, entryParams as M, replySnippet as Ma, RELAY_PAYLOAD_MAX_BYTES as Mc, IdentityExchange as Mi, nip05RootKey as Mn, HTTP_SERVICE_PROTO as Mo, newerHead as Mr, enrollCancelFrame as Ms, didWebDocumentUrl as Mt, knockIdentity as N, BUTTON_ID as Na, createRelayPayload as Nc, boundVerifiedIdentity as Ni, normalizeDomain as Nn, LEGACY_SERVICES as No, pickHubs as Nr, enrollCancelReason as Ns, didWebFile as Nt, encodeCommunityLink as O, validReactionNumber as Oa, budgetRetryMs as Oc, IDENTITY_MAX_EVIDENCE as Oi, domainTxtRecord as On, extractParamsFromSdp as Oo, lobbyKeys as Or, ENROLL_DONE as Os, checkDidSignature as Ot, knockRecords as P, ITEM_STATES as Pa, newerPacket as Pc, emptyIdentityLedger as Pi, recordsFromTxt as Pn, serviceIdFromName as Po, rankHubs as Pr, enrollDigits as Ps, didWebFileNames as Pt, SSHSIG_MAX_ARMOR as Q, isAppRef as Qa, dhtFileId as Qi, pairedMessageFrame as Qn, handoffContext as Qo, pinIsNewer as Qr, ENROLL_CAPABILITY as Qs, ONCHAIN_PROVIDER as Qt, GroupWakeLimiter as R, checkStatusCard as Ra, identityFromSeed as Rc, receivedIdentityStatus as Ri, wellKnownUrl as Rn, HANDOFF_HAVE as Ro, shouldBeHub as Rr, enrollProofFrame as Rs, parseDid as Rt, sealGroupTyping as S, ReactionWindow as Sa, DEFAULT_RELAYS as Sc, newMailbox as Si, pubkyProofPath as Sn, CALL_SIGNAL_MAX_AGE_MS as So, pickMeshHubs as Sr, SET_UPDATE as Ss, verifyAtprotoRecordProof as St, GROUP_READ_NOTE_COMMUNITY as T, reactionEmoji as Ta, normalizeRelayUrl as Tc, DHT_TEXT_CAPABILITY as Ti, DOMAIN_FILE_MAX_BYTES as Tn, buildSdpFromSignal as To, beaconRecords as Tr, setUpdateFrame as Ts, DID_JWS_MAX_LENGTH as Tt, GROUP_READ_NOTE as U, heardTime as Ua, sanitizeAvatar as Uc, validExternalKey as Ui, cashuRequestPayload as Un, HANDOFF_REQUEST as Uo, TYPING_KINDS as Ur, deviceInviteJoinerParams as Us, strictBase64Url as Ut, parseGroupWakeFrame as V, arrivalKey as Va, verify as Vc, PeerProofs as Vi, decodeBolt11 as Vn, HANDOFF_PAKE as Vo, transferEnded as Vr, verifyEnrollProof as Vs, signingKeys as Vt, GroupSession as W, receivedTimestamp as Wa, bytesEqual as Wc, TRANSPORTS as Wi, fedimintRequestPayload as Wn, HANDOFF_TIMINGS as Wo, TYPING_REFRESH_MS as Wr, readDeviceInvite as Ws, didDhtDocument as Wt, mentionsMember as X, checkAppRevokeStatement as Xa, utf8Encode as Xc, peekDhtMailbox as Xi, INVITE_TAKEN as Xn, handoffBusyFrame as Xo, mayPin as Xr, inviteOwnership as Xs, ETHEREUM_USDT as Xt, parseCommunityEdit as Y, appRef as Ya, utf8Decode as Yc, DHT_MESSAGE_TTL as Yi, GhostLink as Yn, handoffAttemptSucceeded as Yo, PIN_LIMITS as Yr, inviteLink as Ys, signDidDhtPacket as Yt, groupName as Z, isAppKey as Za, DHT_FILE_CAPABILITY as Zi, LIVENESS_PING_MS as Zn, handoffCancelFrame as Zo, nextPinNumber as Zr, DEVICES_CAPABILITY as Zs, EVM_TEST_CHAINS as Zt, readAppBundle as _, forwardedAgain as _a, readTurnPacket as _c, HOLD_CAPABILITY as _i, isPubkyProofFolder as _n, parseLocalTarget as _o, groupSignalFrame as _r, readHandoffPake as _s, isAtprotoDid as _t, planAppUpdate as a, PLAYABLE_AUDIO as aa, newDeviceSetSecret as ac, parseWakeFrame as ai, assertTokenUnits as an, MAX_GROUP_CHAIN as ao, fetchesByItself as ar, handoffOfferFrame as as, verifySshSignature as at, groupTypingEpoch as b, GROUP_REACTION_FRAME as ba, turnKeys as bc, HoldKeys as bi, pubkyProofCapability as bn, decodeControl as bo, mayBeHub as br, sealHandoffPiece as bs, shortAtprotoDid as bt, signAppStore as c, fileMessageText as ca, TURN_PUT_WINDOW_MS as cc, checkPushEndpoint as ci, validatePaymentTarget as cn, verifyCommitSignature as co, readGroupFileMeta as cr, handoffRetryAfter as cs, decodeAnyBitcoinAddress as ct, APP_ICON_PATH as d, parseVideoMeta as da, classifyTurnRead as dc, isAppVersion as di, SPARK_PROVIDER as dn, DHT_POLL_INTERVALS as do, readGroupWantNo as dr, newHandoffId as ds, ATPROTO_PROOF_COLLECTION as dt, readImageMeta as ea, deviceEchoFrame as ec, readPin as ei, SEPOLIA_TEST_USDT as en, signAppObject as eo, engineText as er, handoffDataFrame as es, SshSigError as et, appDigest as f, EDIT_BUFFER_MS as fa, turnPutSummary as fc, APPS_CAPABILITY as fi, sparkAddressKind as fn, EXPECT_PEER_MS as fo, CarriedTransport as fr, readHandoffAck as fs, ATPROTO_PROOF_SCOPE as ft, isAppUrl as g, parseLinkPreview as ga, nextTurnPosition as gc, UPGRADE_CAPABILITY as gi, isPubkyKey as gn, formatLocalTarget as go, SIGNAL_REPLY_MS as gr, readHandoffHello as gs, checkAtprotoProofRecord as gt, checkAppManifest as h, dhtEditId as ha, firstDeviceSetSecret as hc, STATUS_CARD_CAPABILITY as hi, PUBKY_PROOF_MAX_BYTES as hn, GhostlyHttpError as ho, SIGNAL_HOLD_MS as hr, readHandoffHave as hs, atprotoProofRkey as ht, checkAppBeforeRun as i, sanitizeMime as ia, devicePingFrame as ic, newWakeToken as ii, WALLET_NETWORKS as in, GROUP_MEMBER_CAP as io, GROUP_WANT_NO_FRAME as ir, handoffManifestFrames as is, sshSignCommand as it, encodeGroupEntryLink as j, readReply as ja, PacketTooLargeError as jc, IdentityCheckUnavailable as ji, isPublicAddress as jn, emptyLinkRecords as jo, mergeLobby as jr, ENROLL_PROOF as js, didSignedMessages as jt, KNOCK_TTL_MS as k, wireReaction as ka, isDiscoveryBudgetError as kc, IDENTITY_MAX_VALIDITY as ki, domainWellKnownFile as kn, parseCallSignal as ko, lobbyRecords as kr, ENROLL_GRANT as ks, didGhostlyServiceEntry as kt, APP_BUNDLE_LIMITS as l, parseVoiceMeta as la, TURN_RAISE_READ_TIMEOUT_MS as lc, vapidKeysMatch as li, walletNetworkOf as ln, edgeParams as lo, readGroupHave as lr, handoffStreamKey as ls, ATPROTO_DECLARED_SCOPE as lt, assembleAppBundle as m, RateWindow as ma, TURN_MAX as mc, EDIT_CAPABILITY as mi, isBitcoinAddress as mn, presenceSeenAt as mo, SIGNAL_FRESH_MS as mr, readHandoffDone as ms, atprotoProofRecord as mt, appStoreDecision as n, safeBlobType as na, deviceKeyZ32 as nc, WakeLimiter as ni, TEST_USDT_FAUCET_AMOUNT as nn, canonicalJson as no, GROUP_HAVE_FRAME as nr, handoffEphemeral as ns, parseSshSignature as nt, checkAppStoreIndex as o, VOICE_LIMITS as oa, seedSigner as oc, relayRequest as oi, assertWholeSats as on, MEMBER_KEY as oo, groupFileFallback as or, handoffPakeFrame as os, verifyBitcoinMessage as ot, appViewOf as p, EDIT_RATE_WINDOW_MS as pa, TURN_LAST_SEQUENCE as pc, BUTTONS_CAPABILITY as pi, sparkInvoiceDetails as pn, RELAY_POLL_INTERVALS as po, GROUP_SIGNAL_FRAME as pr, readHandoffCancel as ps, atprotoDidMethod as pt, GROUP_EDIT_TEXT_BYTES as q, APP_STATEMENT_LIMITS as qa, toBase64Url as qc, irohRelayUrl as qi, parseCashuRequestPayload as qn, handoffAttemptAllowed as qo, typingActivity as qr, decodeInviteCode as qs, encodeDidDhtPacket as qt, appUpdateDecision as r, sanitizeFileName as ra, deviceLinkPairing as rc, deviceWakeRequest as ri, USDT_PUBLIC_RPC as rn, canonicalJsonBytes as ro, GROUP_WANT_FRAME as rr, handoffHelloFrame as rs, sameSshKey as rt, readAppStore as s, baseMime as sa, webCryptoSigner as sc, wakeRequest as si, formatPaymentAmount as sn, rosterHas as so, isGroupMessageId as sr, handoffReleaseFrame as ss, BitcoinAddressError as st, appRevocationFor as t, formatFileSize as ta, deviceEchoNonce as tc, WAKE_CALL_INTERVAL_MS as ti, SEPOLIA_TEST_USDT_FAUCET as tn, signAppRevocation as to, GROUP_FILE_LIMITS as tr, handoffDigest as ts, parseSshPublicKey as tt, APP_BUNDLE_MAGIC as u, voicePeaksOf as ua, TURN_SETTLE_MS as uc, chatAppId as ui, SPARK_NETWORKS as un, DirectPathWatch as uo, readGroupWant as ur, handoffVersions as us, ATPROTO_FULL_SCOPE as ut, GROUP_TYPING_FRAME as v, readForwarded as va, signTurnPacket as vc, HOLD_DATED_BACK_MS as vi, newPubkyProofFolder as vn, webLocalFetch as vo, readGroupSignal as vr, readHandoffTurnFrame as vs, normalizeAtprotoHandle as vt, CommunitySession as w, queueReaction as wa, currentRelays as wc, CapsExchange as wi, pubkyHomeserverOf as wn, answersOffer as wo, beaconKeys as wr, setAckFrame as ws, DID_FILE_MAX_BYTES as wt, openGroupTyping as x, REACTION_LIMITS as xa, turnName as xc, HoldRefusedError as xi, pubkyProofFile as xn, encodeControl as xo, meshHubs as xr, SET_ACK as xs, AtprotoProofError as xt, GroupTypingBoard as y, COMMUNITY_REACTION_FRAME as ya, signTurnRelease as yc, HOLD_LIMITS as yi, normalizePubkyKey as yn, LIMITS as yo, MESH_HUBS as yr, readHandoffVerified as ys, parseAtprotoDidDocument as yt, groupWakeFrame as z, sameButtonText as za, identityFromSeedB64 as zc, revokesIdentity as zi, requestLnurlInvoice as zn, HANDOFF_HELLO as zo, ChatFiles as zr, readEnrollGrant as zs, parseDidDocument as zt };
29867
+ export { groupName as $, APP_PREFIXES as $a, sanitizeAvatar as $c, peekDhtMailbox as $i, readGroupWantNo as $n, HANDOFF_REQUEST as $o, isAppVersion as $r, deviceInviteJoinerParams as $s, EVM_TEST_CHAINS as $t, encodeCommunityLink as A, readReaction as Aa, turnKeys as Ac, dialDescriptors as Ai, ENDPOINT as An, decodeControl as Ao, transferStalled as Ar, sealHandoffPiece as As, checkDidSignature as At, GroupWakeLimiter as B, RUN_RESULTS as Ba, withRequestOptions as Bc, newIdentityBinding as Bi, pairedMessageFrame as Bn, signalHasVideo as Bo, pinIsNewer as Br, ENROLL_HELLO as Bs, parseDid as Bt, openGroupTyping as C, GROUP_REACTION_FRAME as Ca, TURN_LAST_SEQUENCE as Cc, recordsFromWellKnown as Ci, pubkyProofFile as Cn, RELAY_POLL_INTERVALS as Co, readBeacon as Cr, readHandoffCancel as Cs, AtprotoProofError as Ct, GROUP_READ_NOTE_COMMUNITY as D, queueReaction as Da, readTurnPacket as Dc, CapsExchange as Di, requestLnurlInvoice as Dn, parseLocalTarget as Do, ChatFiles as Dr, readHandoffPake as Ds, DID_JWS_MAX_LENGTH as Dt, CommunitySession as E, nextReactionNumber as Ea, nextTurnPosition as Ec, wellKnownUrl as Ei, pubkyHomeserverOf as En, formatLocalTarget as Eo, shouldBeHub as Er, readHandoffHello as Es, DID_FILE_MAX_BYTES as Et, knockIdentity as F, replySnippet as Fa, normalizeRelayUrl as Fc, IdentityExchange as Fi, parseCashuRequestPayload as Fn, buildSdpFromSignal as Fo, typingActivity as Fr, setUpdateFrame as Fs, didWebFile as Ft, GROUP_READ_NOTE as G, checkStatusCard as Ga, openRelayPayload as Gc, emptyProofLedger as Gi, GROUP_WANT_FRAME as Gn, HANDOFF_ACK as Go, deviceWakeRequest as Gr, enrollDoneFrame as Gs, strictBase64Url as Gt, groupWakes as H, buttonLabelClash as Ha, RELAY_PAYLOAD_MAX_BYTES as Hc, revokesIdentity as Hi, engineText as Hn, HTTP_SERVICE_PROTO as Ho, readPin as Hr, enrollCancelFrame as Hs, parseDidSignaturePaste as Ht, knockRecords as I, BUTTON_ID as Ia, setLinkTraceSink as Ic, boundVerifiedIdentity as Ii, parseFedimintRequestPayload as In, callRtcConfig as Io, GROUP_PIN_FRAME as Ir, signTombstone as Is, didWebFileNames as It, GROUP_EDIT_FRAME as J, arrivalKey as Ja, identityFromSeed as Jc, automaticTransport as Ji, groupFileFallback as Jn, HANDOFF_HAVE as Jo, relayRequest as Jr, enrollProofFrame as Js, encodeDidDhtDeactivated as Jt, GroupSession as K, sameButtonText as Ka, parseRelayPayload as Kc, validExternalKey as Ki, GROUP_WANT_NO_FRAME as Kn, HANDOFF_CANCEL as Ko, newWakeToken as Kr, enrollGrantFrame as Ks, didDhtDocument as Kt, mergeKnocks as L, ITEM_STATES as La, traceLink as Lc, emptyIdentityLedger as Li, GhostLink as Ln, callSignalHeardAt as Lo, PIN_LIMITS as Lr, verifySetUpdate as Ls, didWebFileUrl as Lt, decodeGroupEntryLink as M, wireReaction as Ma, DEFAULT_RELAYS as Mc, IDENTITY_MAX_VALIDITY as Mi, fedimintRequestPayload as Mn, CALL_SIGNAL_MAX_AGE_MS as Mo, TYPING_REFRESH_MS as Mr, SET_UPDATE as Ms, didJwsAlg as Mt, encodeGroupEntryLink as N, pairedReplyAuthor as Na, RelayTransport as Nc, IDENTITY_REVOCATION_LABEL as Ni, findEndpoint as Nn, RTC_CONFIG as No, TypingSender as Nr, readSetUpdate as Ns, didSignedMessages as Nt, communityHasFrame as O, reactionEmoji as Oa, signTurnPacket as Oc, DHT_TEXT_CAPABILITY as Oi, resolveLightningDestination as On, webLocalFetch as Oo, FILE_LIMITS as Or, readHandoffTurnFrame as Os, DidError as Ot, entryParams as P, readReply as Pa, currentRelays as Pc, IdentityCheckUnavailable as Pi, isFederationId as Pn, answersOffer as Po, sanitizeTypingStatus as Pr, setAckFrame as Ps, didWebDocumentUrl as Pt, mentionsMember as Q, shownTime as Qa, MAX_AVATAR_LENGTH as Qc, DHT_MESSAGE_TTL as Qi, readGroupWant as Qn, HANDOFF_PIECE_BYTES as Qo, chatAppId as Qr, createDeviceInvite as Qs, ETHEREUM_USDT as Qt, readKnocks as R, PR_CHECKS as Ra, budgetRetryMs as Rc, identityRevocationValue as Ri, INVITE_TAKEN as Rn, extractParamsFromSdp as Ro, mayPin as Rr, ENROLL_DONE as Rs, didWebHost as Rt, groupTypingEpoch as S, COMMUNITY_REACTION_FRAME as Sa, turnPutSummary as Sc, recordsFromTxt as Si, pubkyProofCapability as Sn, EXPECT_PEER_MS as So, rankHubs as Sr, readHandoffAck as Ss, shortAtprotoDid as St, COMMUNITY_LIMITS as T, ReactionWindow as Ta, firstDeviceSetSecret as Tc, txtValue as Ti, homeserverWebEndpoint as Tn, GhostlyHttpError as To, readLobby as Tr, readHandoffHave as Ts, DID_DOCUMENT_MAX_BYTES as Tt, parseGroupWakeFrame as U, cardLine as Ua, createRelayPayload as Uc, lastSharedWithMe as Ui, GROUP_FILE_LIMITS as Un, LEGACY_SERVICES as Uo, WAKE_CALL_INTERVAL_MS as Ur, enrollCancelReason as Us, signingKeys as Ut, groupWakeFrame as V, STATUS_CARD_LIMITS as Va, PacketTooLargeError as Vc, receivedIdentityStatus as Vi, engineError as Vn, emptyLinkRecords as Vo, pinNumberHolds as Vr, ENROLL_PROOF as Vs, parseDidDocument as Vt, GROUP_REACTED_FRAME as W, cardUrl as Wa, newerPacket as Wc, PeerProofs as Wi, GROUP_HAVE_FRAME as Wn, serviceIdFromName as Wo, WakeLimiter as Wr, enrollDigits as Ws, staticDidDocument as Wt, carryMentions as X, heardTime as Xa, publicKeyFromZ32 as Xc, irohRelayUrl as Xi, readGroupFileMeta as Xn, HANDOFF_OFFER as Xo, checkPushEndpoint as Xr, readEnrollHello as Xs, resolveDidDht as Xt, GROUP_EDIT_TEXT_BYTES as Y, claimedTime as Ya, identityFromSeedB64 as Yc, irohDescriptor as Yi, isGroupMessageId as Yn, HANDOFF_HELLO as Yo, wakeRequest as Yr, readEnrollGrant as Ys, encodeDidDhtPacket as Yt, parseCommunityEdit as Z, receivedTimestamp as Za, verify as Zc, DHT_FILE_TOO_LARGE as Zi, readGroupHave as Zn, HANDOFF_PAKE as Zo, vapidKeysMatch as Zr, verifyEnrollProof as Zs, signDidDhtPacket as Zt, checkAppManifest as _, RateWindow as _a, webCryptoSigner as _c, domainWellKnownFile as _i, PUBKY_PROOF_MAX_BYTES as _n, rosterHas as _o, lobbyRecords as _r, handoffReleaseFrame as _s, atprotoProofRkey as _t, planAppUpdate as a, safeBlobType as aa, inviteLink as ac, HOLD_CAPABILITY as ai, utf8Decode as al, USDT_PUBLIC_RPC as an, isAppRef as ao, groupSignalFrame as ar, handoffAttemptSucceeded as as, sameSshKey as at, GROUP_TYPING_FRAME as b, forwardedAgain as ba, TURN_SETTLE_MS as bc, nip05RootKey as bi, newPubkyProofFolder as bn, DirectPathWatch as bo, newerHead as br, handoffVersions as bs, normalizeAtprotoHandle as bt, readAppStore as c, PLAYABLE_AUDIO as ca, ENROLL_CAPABILITY as cc, HoldKeys as ci, assertWholeSats as cn, signAppObject as co, mayBeHub as cr, handoffContext as cs, verifyBitcoinMessage as ct, APP_BUNDLE_LIMITS as d, fileMessageText as da, deviceEchoNonce as dc, newMailbox as di, walletNetworkOf as dn, canonicalJsonBytes as do, COMMUNITY_TOPOLOGY as dr, handoffDigest as ds, ATPROTO_DECLARED_SCOPE as dt, DHT_FILE_CAPABILITY as ea, readDeviceInvite as ec, APPS_CAPABILITY as ei, bytesEqual as el, ONCHAIN_PROVIDER as en, APP_STATEMENT_LIMITS as eo, CarriedTransport as er, HANDOFF_TIMINGS as es, SSHSIG_MAX_ARMOR as et, APP_BUNDLE_MAGIC as f, parseVoiceMeta as fa, deviceKeyZ32 as fc, readManifest as fi, SPARK_NETWORKS as fn, readCanonicalJson as fo, beaconKeys as fr, handoffEphemeral as fs, ATPROTO_FULL_SCOPE as ft, assembleAppBundle as g, EDIT_RATE_WINDOW_MS as ga, seedSigner as gc, domainTxtRecord as gi, isBitcoinAddress as gn, commitHash as go, lobbyKeys as gr, handoffPakeFrame as gs, atprotoProofRecord as gt, appViewOf as h, EDIT_BUFFER_MS as ha, newDeviceSetSecret as hc, dnsProofName as hi, sparkInvoiceDetails as hn, MEMBER_KEY as ho, freshHubs as hr, handoffOfferFrame as hs, atprotoDidMethod as ht, checkAppBeforeRun as i, formatFileSize as ia, encodeInviteCode as ic, UPGRADE_CAPABILITY as ii, toZ32 as il, TEST_USDT_FAUCET_AMOUNT as in, isAppKey as io, SIGNAL_REPLY_MS as ir, handoffAttemptFailed as is, parseSshSignature as it, KNOCK_TTL_MS as j, validReactionNumber as ja, turnName as jc, IDENTITY_MAX_EVIDENCE as ji, cashuRequestPayload as jn, encodeControl as jo, TYPING_KINDS as jr, SET_ACK as js, didGhostlyServiceEntry as jt, decodeCommunityLink as k, reactionIsNewer as ka, signTurnRelease as kc, capsDescriptors as ki, decodeBolt11 as kn, LIMITS as ko, transferEnded as kr, readHandoffVerified as ks, checkDidJws as kt, readAppStoreSteps as l, VOICE_LIMITS as la, HANDOFF_CAPABILITY as lc, HoldRefusedError as li, formatPaymentAmount as ln, signAppRevocation as lo, meshHubs as lr, handoffContextProof as ls, BitcoinAddressError as lt, appDigest as m, parseVideoMeta as ma, devicePingFrame as mc, decodeDnsResponse as mi, sparkAddressKind as mn, MAX_GROUP_CHAIN as mo, doorHubs as mr, handoffManifestFrames as ms, ATPROTO_PROOF_SCOPE as mt, appStoreDecision as n, parseImageMeta as na, createChatInvite as nc, EDIT_CAPABILITY as ni, randomBytes as nl, SEPOLIA_TEST_USDT as nn, appRef as no, SIGNAL_FRESH_MS as nr, filePartName as ns, SshSigError as nt, APP_STORE_LIMITS as o, sanitizeFileName as oa, inviteOwnership as oc, HOLD_DATED_BACK_MS as oi, utf8Encode as ol, WALLET_NETWORKS as on, readAppRevocation as oo, readGroupSignal as or, handoffBusyFrame as os, sshSignCommand as ot, APP_ICON_PATH as p, voicePeaksOf as pa, deviceLinkPairing as pc, DOMAIN_FILE_MAX_BYTES as pi, SPARK_PROVIDER as pn, GROUP_MEMBER_CAP as po, beaconRecords as pr, handoffHelloFrame as ps, ATPROTO_PROOF_COLLECTION as pt, COMMUNITY_EDIT_FRAME as q, statusCardText as qa, createIdentity as qc, TRANSPORTS as qi, fetchesByItself as qn, HANDOFF_DONE as qo, parseWakeFrame as qr, enrollHelloFrame as qs, didDhtFromPublicKey as qt, appUpdateDecision as r, readImageMeta as ra, decodeInviteCode as rc, STATUS_CARD_CAPABILITY as ri, toBase64Url as rl, SEPOLIA_TEST_USDT_FAUCET as rn, checkAppRevokeStatement as ro, SIGNAL_HOLD_MS as rr, handoffAttemptAllowed as rs, parseSshPublicKey as rt, checkAppStoreIndex as s, sanitizeMime as sa, DEVICES_CAPABILITY as sc, HOLD_LIMITS as si, assertTokenUnits as sn, readAppRevocations as so, MESH_HUBS as sr, handoffCancelFrame as ss, verifySshSignature as st, appRevocationFor as t, dhtFileId as ta, OwnInviteError as tc, BUTTONS_CAPABILITY as ti, fromBase64Url as tl, PaymentPreflightError as tn, appFingerprint as to, GROUP_SIGNAL_FRAME as tr, HANDOFF_VERIFIED as ts, SSHSIG_NAMESPACE as tt, signAppStore as u, baseMime as ua, deviceEchoFrame as uc, isHoldUrl as ui, validatePaymentTarget as un, canonicalJson as uo, pickMeshHubs as ur, handoffDataFrame as us, decodeAnyBitcoinAddress as ut, isAppUrl as v, dhtEditId as va, TURN_PUT_WINDOW_MS as vc, encodeDnsQuery as vi, isPubkyKey as vn, verifyCommitSignature as vo, mergeBeacon as vr, handoffRetryAfter as vs, checkAtprotoProofRecord as vt, sealGroupTyping as w, REACTION_LIMITS as wa, TURN_MAX as wc, recordsName as wi, pubkyProofPath as wn, presenceSeenAt as wo, readBeaconHead as wr, readHandoffDone as ws, verifyAtprotoRecordProof as wt, GroupTypingBoard as x, readForwarded as xa, classifyTurnRead as xc, normalizeDomain as xi, normalizePubkyKey as xn, DHT_POLL_INTERVALS as xo, pickHubs as xr, newHandoffId as xs, parseAtprotoDidDocument as xt, readAppBundle as y, parseLinkPreview as ya, TURN_RAISE_READ_TIMEOUT_MS as yc, isPublicAddress as yi, isPubkyProofFolder as yn, edgeParams as yo, mergeLobby as yr, handoffStreamKey as ys, isAtprotoDid as yt, GROUP_WAKE_FRAME as z, PR_STATES as za, isDiscoveryBudgetError as zc, identityStatement as zi, LIVENESS_PING_MS as zn, parseCallSignal as zo, nextPinNumber as zr, ENROLL_GRANT as zs, normalizeDid as zt };
29604
29868
 
29605
- //# sourceMappingURL=src-BMUcYQ7b.js.map
29869
+ //# sourceMappingURL=src-XzcHzneo.js.map