@ghostlytools/cli 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +8 -2
  2. package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
  3. package/dist/assets/{endpoint-CaSbSFDw.js → endpoint-D_3bYafG.js} +76 -3
  4. package/dist/assets/endpoint-D_3bYafG.js.map +1 -0
  5. package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -1
  6. package/dist/assets/{fileBytes-DxiJHmTk.js → fileBytes-3AQ2tJzW.js} +5 -17
  7. package/dist/assets/{fileBytes-DxiJHmTk.js.map → fileBytes-3AQ2tJzW.js.map} +1 -1
  8. package/dist/assets/{fileBytes-BQ84L9Fe.js → fileBytes-CAZDydIe.js} +2 -2
  9. package/dist/assets/{fileBytes-BQ84L9Fe.js.map → fileBytes-CAZDydIe.js.map} +1 -1
  10. package/dist/assets/{fileBytesIdb-DQkQ10PS.js → fileBytesIdb-CmG00qE-.js} +3 -3
  11. package/dist/assets/{fileBytesIdb-DQkQ10PS.js.map → fileBytesIdb-CmG00qE-.js.map} +1 -1
  12. package/dist/assets/{fileBytesOpfs-BFfR_68b.js → fileBytesOpfs-CTgqt-p3.js} +3 -3
  13. package/dist/assets/{fileBytesOpfs-BFfR_68b.js.map → fileBytesOpfs-CTgqt-p3.js.map} +1 -1
  14. package/dist/assets/{hyperdhtRelay-BoxPHp7v.js → hyperdhtRelay-CnEkFl8R.js} +3 -3
  15. package/dist/assets/{hyperdhtRelay-BoxPHp7v.js.map → hyperdhtRelay-CnEkFl8R.js.map} +1 -1
  16. package/dist/assets/{idb-Co2lObpU.js → idb-BIdus0WX.js} +32 -8
  17. package/dist/assets/idb-BIdus0WX.js.map +1 -0
  18. package/dist/assets/joinTrace-CO8mlhNO.js +29 -0
  19. package/dist/assets/joinTrace-CO8mlhNO.js.map +1 -0
  20. package/dist/assets/{mainline-dkA94eh2.js → mainline-DQxDSWXm.js} +2 -2
  21. package/dist/assets/{mainline-dkA94eh2.js.map → mainline-DQxDSWXm.js.map} +1 -1
  22. package/dist/assets/{restoredRows-C1O9FfA4.js → restoredRows-lhTKGmCO.js} +2 -2
  23. package/dist/assets/{restoredRows-C1O9FfA4.js.map → restoredRows-lhTKGmCO.js.map} +1 -1
  24. package/dist/assets/rolldown-runtime-8H4AJuhK.js +14 -0
  25. package/dist/assets/{server-CXFTDnFI.js → server-HMzdsyn_.js} +1720 -284
  26. package/dist/assets/server-HMzdsyn_.js.map +1 -0
  27. package/dist/assets/{src-K3NijMq-.js → src-DtxVo2Rm.js} +2299 -164
  28. package/dist/assets/src-DtxVo2Rm.js.map +1 -0
  29. package/dist/ghostly.mjs +1944 -64
  30. package/dist/ghostly.mjs.map +1 -1
  31. package/package.json +1 -1
  32. package/dist/assets/endpoint-CaSbSFDw.js.map +0 -1
  33. package/dist/assets/idb-Co2lObpU.js.map +0 -1
  34. package/dist/assets/server-CXFTDnFI.js.map +0 -1
  35. package/dist/assets/src-K3NijMq-.js.map +0 -1
package/dist/ghostly.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { a as fileBytesOf, d as __exportAll, i as fileBytes, l as FILE_BYTES_STEP } from "./assets/fileBytes-DxiJHmTk.js";
3
- import { $ as checkAtprotoProofRecord, $i as replySnippet, $r as IdentityCheckUnavailable, $s as fromBase64Url$1, $t as isPubkyKey, Aa as CALL_SIGNAL_MAX_AGE_MS, Ai as parseVoiceMeta, B as sameSshKey, Bs as RELAY_PAYLOAD_MAX_BYTES, Ca as GhostlyHttpError, Cn as resolveLightningDestination, Ct as parseDidSignaturePaste, Da as LIMITS, Di as VOICE_LIMITS, Ei as PLAYABLE_AUDIO, En as decodeBolt11, Et as strictBase64Url, Fa as callSignalHeardAt, G as decodeAnyBitcoinAddress, H as verifySshSignature, I as SSHSIG_NAMESPACE, Ia as extractParamsFromSdp, Ir as BUTTONS_CAPABILITY, J as ATPROTO_PROOF_COLLECTION, K as ATPROTO_DECLARED_SCOPE, Kt as walletNetworkOf, L as SshSigError, La as parseCallSignal, Ma as answersOffer, Na as buildSdpFromSignal, Oi as baseMime, P as groupName, Pa as callRtcConfig, Pr as checkPushEndpoint, Ps as setLinkTraceSink, Q as atprotoProofRkey, Qt as PUBKY_PROOF_MAX_BYTES, R as parseSshPublicKey, Ra as signalHasVideo, Si as readImageMeta, Sn as requestLnurlInvoice, St as parseDidDocument, Ti as sanitizeMime, Tn as engineText, Tt as staticDidDocument, U as verifyBitcoinMessage, Us as openRelayPayload, V as sshSignCommand, Vt as WALLET_NETWORKS, W as BitcoinAddressError, X as atprotoDidMethod, Xs as MAX_AVATAR_LENGTH, Y as ATPROTO_PROOF_SCOPE, Z as atprotoProofRecord, Zs as sanitizeAvatar, _n as recordsFromTxt, _t as didWebFileNames, aa as checkStatusCard, an as pubkyProofPath, at as verifyAtprotoRecordProof, bn as txtValue, bt as normalizeDid, cn as DOMAIN_FILE_MAX_BYTES, dn as domainTxtRecord, dt as checkDidSignature, ec as randomBytes$1, en as isPubkyProofFolder, et as isAtprotoDid, fn as domainWellKnownFile, ft as didGhostlyServiceEntry, gn as normalizeDomain, gr as TYPING_REFRESH_MS, gt as didWebFile, hn as nip05RootKey, hr as TYPING_KINDS, ht as didWebDocumentUrl, ia as buttonLabelClash, ic as utf8Encode, ii as identityStatement, in as pubkyProofFile, it as AtprotoProofError, ja as RTC_CONFIG, ji as voicePeaksOf, jt as resolveDidDht, ln as decodeDnsResponse, lt as DidError, mn as isPublicAddress, mt as didSignedMessages, na as RUN_RESULTS, nn as normalizePubkyKey, nt as parseAtprotoDidDocument, on as homeserverWebEndpoint, ot as DID_DOCUMENT_MAX_BYTES$1, pn as encodeDnsQuery, pt as didJwsAlg, q as ATPROTO_FULL_SCOPE, ra as STATUS_CARD_LIMITS, rc as utf8Decode, rn as pubkyProofCapability, rs as inviteLink, rt as shortAtprotoDid, sn as pubkyHomeserverOf, st as DID_FILE_MAX_BYTES, ta as ITEM_STATES, tc as toBase64Url$1, ti as boundVerifiedIdentity, tn as newPubkyProofFolder, tt as normalizeAtprotoHandle, un as dnsProofName, ut as checkDidJws, vn as recordsFromWellKnown, vr as sanitizeTypingStatus, vt as didWebFileUrl, wi as sanitizeFileName, wn as engineError, wt as signingKeys, xn as wellKnownUrl, xt as parseDid, yn as recordsName, yt as didWebHost, z as parseSshSignature } from "./assets/src-K3NijMq-.js";
4
- import { d as wrap, i as databaseName, l as store$1, n as STORES, o as fileStore, s as openDb, u as transact } from "./assets/idb-Co2lObpU.js";
2
+ import { t as __exportAll } from "./assets/rolldown-runtime-8H4AJuhK.js";
3
+ import { $ as parseSshPublicKey, $a as signAppRevocation, $n as GROUP_FILE_LIMITS, Aa as replySnippet, An as nip05RootKey, At as didWebDocumentUrl, Bc as sanitizeAvatar, Bn as engineText, Bt as staticDidDocument, Cc as setLinkTraceSink, Cn as DOMAIN_FILE_MAX_BYTES, Co as callRtcConfig, Dn as domainWellKnownFile, Do as signalHasVideo, Dt as didGhostlyServiceEntry, En as domainTxtRecord, Eo as parseCallSignal, Et as checkDidSignature, Fa as buttonLabelClash, Fn as txtValue, Ft as normalizeDid, Ga as APP_STATEMENT_LIMITS, Gc as toZ32, Gi as irohRelayUrl, Hc as fromBase64Url$1, Hr as TYPING_REFRESH_MS, Ia as checkStatusCard, In as wellKnownUrl, It as parseDid, Ja as checkAppRevokeStatement, Ka as appFingerprint, Kc as utf8Decode, Ks as inviteLink, Kt as resolveDidDht, Ln as requestLnurlInvoice, Lr as ChatFiles, Lt as parseDidDocument, Ma as ITEM_STATES, Mc as openRelayPayload, Mn as recordsFromTxt, Mt as didWebFileNames, Na as RUN_RESULTS, Nn as recordsFromWellKnown, Nt as didWebFileUrl, On as encodeDnsQuery, Ot as didJwsAlg, Pa as STATUS_CARD_LIMITS, Pi as identityStatement, Pn as recordsName, Pt as didWebHost, Q as SshSigError, Qa as signAppObject, Qi as readImageMeta, Rn as resolveLightningDestination, Rt as parseDidSignaturePaste, Sn as pubkyHomeserverOf, So as buildSdpFromSignal, St as DID_FILE_MAX_BYTES, Tn as dnsProofName, To as extractParamsFromSdp, Tt as checkDidJws, Uc as randomBytes$1, Vn as decodeBolt11, Vr as TYPING_KINDS, Vt as strictBase64Url, Wa as APP_PREFIXES, Wc as toBase64Url$1, Wi as irohDescriptor, Wr as sanitizeTypingStatus, Y as groupName, Z as SSHSIG_NAMESPACE, Za as readAppRevocations, _n as normalizePubkyKey, _t as parseAtprotoDidDocument, a as checkAppStoreIndex, aa as baseMime, ar as isGroupMessageId, at as BitcoinAddressError, bn as pubkyProofPath, bo as RTC_CONFIG, bt as verifyAtprotoRecordProof, c as APP_BUNDLE_LIMITS, ca as voicePeaksOf, cr as readGroupWant, ct as ATPROTO_FULL_SCOPE, di as BUTTONS_CAPABILITY, dt as atprotoDidMethod, eo as canonicalJson, er as GROUP_HAVE_FRAME, et as parseSshSignature, f as assembleAppBundle, fa as RateWindow, fo as GhostlyHttpError, ft as atprotoProofRecord, gn as newPubkyProofFolder, go as LIMITS, gt as normalizeAtprotoHandle, h as readAppBundle, hn as isPubkyProofFolder, ht as isAtprotoDid, ia as VOICE_LIMITS, ir as groupFileFallback, it as verifyBitcoinMessage, ji as boundVerifiedIdentity, jn as normalizeDomain, jt as didWebFile, kc as RELAY_PAYLOAD_MAX_BYTES, ki as IdentityCheckUnavailable, kn as isPublicAddress, kt as didSignedMessages, lr as readGroupWantNo, lt as ATPROTO_PROOF_COLLECTION, m as isAppUrl, mn as isPubkyKey, mt as checkAtprotoProofRecord, na as sanitizeMime, nn as WALLET_NETWORKS, nr as GROUP_WANT_NO_FRAME, nt as sshSignCommand, o as readAppStore, oa as fileMessageText, oi as checkPushEndpoint, ot as decodeAnyBitcoinAddress, p as checkAppManifest, pn as PUBKY_PROOF_MAX_BYTES, pt as atprotoProofRkey, qa as appRef, qc as utf8Encode, ra as PLAYABLE_AUDIO, rc as seedSigner, rr as fetchesByItself, rt as verifySshSignature, s as signAppStore, sa as parseVoiceMeta, sn as walletNetworkOf, sr as readGroupHave, st as ATPROTO_DECLARED_SCOPE, t as appStoreDecision, ta as sanitizeFileName, to as canonicalJsonBytes, tt as sameSshKey, ut as ATPROTO_PROOF_SCOPE, vn as pubkyProofCapability, vt as shortAtprotoDid, wn as decodeDnsResponse, wo as callSignalHeardAt, wt as DidError, xn as homeserverWebEndpoint, xo as answersOffer, xt as DID_DOCUMENT_MAX_BYTES$1, yn as pubkyProofFile, yo as CALL_SIGNAL_MAX_AGE_MS, yt as AtprotoProofError, zc as MAX_AVATAR_LENGTH, zn as engineError, zr as transferEnded, zt as signingKeys } from "./assets/src-DtxVo2Rm.js";
4
+ import { c as fileStore, d as store$1, f as transact, i as STORES, l as openDb, o as databaseName, p as wrap } from "./assets/idb-BIdus0WX.js";
5
+ import { a as fileBytesOf, i as fileBytes, l as FILE_BYTES_STEP } from "./assets/fileBytes-3AQ2tJzW.js";
5
6
  import { createRequire } from "node:module";
6
7
  import { spawn } from "node:child_process";
7
8
  import { connect, createServer, isIP } from "node:net";
@@ -17,7 +18,7 @@ import { decode, npubEncode } from "nostr-tools/nip19";
17
18
  import { BunkerSigner } from "nostr-tools/nip46";
18
19
  import { SimplePool } from "nostr-tools/pool";
19
20
  import { generateSecretKey } from "nostr-tools/pure";
20
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
21
+ import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
21
22
  import { createServer as createServer$1 } from "node:http";
22
23
  import { Readable } from "node:stream";
23
24
  import { pipeline } from "node:stream/promises";
@@ -34,7 +35,7 @@ import { IDBFactory, IDBKeyRange as IDBKeyRange$1, IDBTransaction } from "fake-i
34
35
  import { format } from "node:util";
35
36
  var package_default = {
36
37
  name: "@ghostlytools/cli",
37
- version: "1.1.4",
38
+ version: "1.1.6",
38
39
  type: "module",
39
40
  description: "Ghostly for bots and terminals: the app's own engine, headless on Node, behind a daemon and a `ghostly` command with JSON output.",
40
41
  license: "MIT",
@@ -595,6 +596,10 @@ var ENGINE_METHODS = [
595
596
  "retryMessage",
596
597
  "react",
597
598
  "pinMessage",
599
+ "appId",
600
+ "appOpen",
601
+ "appClose",
602
+ "appSend",
598
603
  "messageDetails",
599
604
  "deleteMessage",
600
605
  "exportLinks",
@@ -669,6 +674,7 @@ var ENGINE_METHODS = [
669
674
  "disableGroupLink",
670
675
  "joinGroupByLink",
671
676
  "sendGroupMessage",
677
+ "sendGroupFile",
672
678
  "pressButton",
673
679
  "groupMessages",
674
680
  "messagePage",
@@ -713,7 +719,27 @@ var ENGINE_METHODS = [
713
719
  "deviceSetNoticeSeen",
714
720
  "deviceTurnCheck",
715
721
  "devicePushState",
716
- "devicePushSet"
722
+ "devicePushSet",
723
+ "appList",
724
+ "appStoreList",
725
+ "appStorePreview",
726
+ "appStoreAdd",
727
+ "appStoreRemove",
728
+ "appStoreRefresh",
729
+ "appPreview",
730
+ "appInstall",
731
+ "appUpdateAccept",
732
+ "appCheckUpdates",
733
+ "appUninstall",
734
+ "appRunCheck",
735
+ "appFetchFiles",
736
+ "appEntry",
737
+ "appFile",
738
+ "appStorageGet",
739
+ "appStorageSet",
740
+ "appStorageDelete",
741
+ "appStorageKeys",
742
+ "appDataExport"
717
743
  ];
718
744
  /** Reads the hosts use outside `EngineApi`: the whole state, and one chat's stored messages. */
719
745
  var ENGINE_READS = ["getState", "getMessages"];
@@ -1015,6 +1041,707 @@ function groupReply(reply, myKey) {
1015
1041
  };
1016
1042
  }
1017
1043
  //#endregion
1044
+ //#region ../browser/src/engine/groupFiles.ts
1045
+ /** A group file's local id: its group's and a random part, never anything a member sends. */
1046
+ var groupFileId = (groupId, direction, random = toBase64Url$1(randomBytes$1(12))) => `group-${groupId}-${direction}-${random}`;
1047
+ /** The group a local file id names, among `groupIds`. */
1048
+ var groupOfFile = (fileId, groupIds) => [...groupIds].find((g) => fileId.startsWith(`group-${g}-in-`) || fileId.startsWith(`group-${g}-out-`));
1049
+ /** What a file's message says: its caption, or (when the author wrote none) the file itself, not the line older apps show. */
1050
+ var groupFileText = (text, meta) => text === groupFileFallback(meta) ? fileMessageText(meta) : text;
1051
+ /** The profile's files store and file storage, as a 1:1 chat's files use them. */
1052
+ var storedGroupFiles = {
1053
+ list: (groupId) => fileStore.listForLink(`group:${groupId}`),
1054
+ get: (id) => fileStore.get(id),
1055
+ put: (file) => fileStore.put(file),
1056
+ patch: (id, fields) => fileStore.patch(id, fields),
1057
+ remove: (id) => removeStored(id),
1058
+ async write(id) {
1059
+ const stored = await fileStore.get(id);
1060
+ const bytes = stored?.bytes && await fileBytesOf(stored.bytes) || await fileBytes();
1061
+ if (await bytes.size(id) !== null) await bytes.truncate(id, 0);
1062
+ if (stored && stored.bytes !== bytes.kind) await fileStore.patch(id, { bytes: bytes.kind });
1063
+ const appender = new FileAppender(bytes, id, 0);
1064
+ return {
1065
+ append: (chunk) => appender.append(chunk),
1066
+ flush: () => appender.flush(),
1067
+ digest: async () => {
1068
+ await appender.close();
1069
+ return bytes.digest(id);
1070
+ },
1071
+ discard: async () => {
1072
+ await appender.close().catch(() => {});
1073
+ await bytes.remove(id);
1074
+ }
1075
+ };
1076
+ },
1077
+ read: (file, offset, length) => readStored(file, offset, length),
1078
+ room: async () => (await fileBytes()).room()
1079
+ };
1080
+ /** Why a want waits, as the person reads it. */
1081
+ var NOBODY_CONNECTED = "Nobody you are connected to has this file yet";
1082
+ var GroupFileDesk = class {
1083
+ deps;
1084
+ kept = /* @__PURE__ */ new Map();
1085
+ /** `<group>\n<message id>` → local id. */
1086
+ byMessage = /* @__PURE__ */ new Map();
1087
+ wants = /* @__PURE__ */ new Map();
1088
+ edgesById = /* @__PURE__ */ new Map();
1089
+ /** My file about to be announced, by `<group>\n<digest>`: the message that announces it takes this local id. */
1090
+ own = /* @__PURE__ */ new Map();
1091
+ /** Announcements sent per group, and taken per member of a group (WISP 503 § Rules for both, Pace). */
1092
+ sentPace = /* @__PURE__ */ new Map();
1093
+ takenPace = /* @__PURE__ */ new Map();
1094
+ /** `<group>\n<message id>` a `group-have` named before its message came → its holders, newest first. */
1095
+ ahead = /* @__PURE__ */ new Map();
1096
+ /** A community's ids for its next `group-have`, and when one last went. */
1097
+ communityHave = /* @__PURE__ */ new Map();
1098
+ /** Bytes served today, and the day (UTC days since the epoch). */
1099
+ served = {
1100
+ day: 0,
1101
+ bytes: 0
1102
+ };
1103
+ constructor(deps) {
1104
+ this.deps = deps;
1105
+ }
1106
+ /** At start: each group's files as they were kept. One fetched by itself, or asked for, and not here yet is asked for again. */
1107
+ async load(groupIds) {
1108
+ for (const groupId of groupIds) for (const file of await this.deps.store.list(groupId).catch(() => [])) {
1109
+ if (!file.group || !file.metadata || !file.digest || this.kept.has(file.id)) continue;
1110
+ const { name, size, mime, voice, video, image } = file.metadata;
1111
+ const kept = {
1112
+ id: file.id,
1113
+ groupId,
1114
+ message: file.group.message,
1115
+ author: file.group.author,
1116
+ meta: {
1117
+ name,
1118
+ size,
1119
+ mime,
1120
+ d: file.digest,
1121
+ ...voice && { voice },
1122
+ ...video && { video },
1123
+ ...image && { image }
1124
+ },
1125
+ mine: file.direction === "out",
1126
+ auto: !!file.group.auto,
1127
+ asked: !!file.group.asked,
1128
+ held: file.transfer?.state === "done",
1129
+ holders: []
1130
+ };
1131
+ this.add(kept);
1132
+ if (kept.held) this.show(kept, {
1133
+ state: "done",
1134
+ transferred: size,
1135
+ size,
1136
+ direction: kept.mine ? "out" : "in"
1137
+ });
1138
+ else if (kept.auto || kept.asked) this.wants.set(kept.id, this.newWant());
1139
+ else this.show(kept, {
1140
+ state: "transferring",
1141
+ stage: "asking",
1142
+ transferred: 0,
1143
+ size,
1144
+ direction: "in"
1145
+ });
1146
+ }
1147
+ for (const [id] of this.wants) this.ask(this.kept.get(id));
1148
+ this.deps.changed();
1149
+ }
1150
+ /** An error when this group took as many announcements of mine as it takes in a minute; null when one may go now. */
1151
+ mayAnnounce(groupId) {
1152
+ return this.pace(this.sentPace, groupId, GROUP_FILE_LIMITS.announcePerMinute, 6e4).wait() ? "You sent many files to this group just now. Wait a minute." : null;
1153
+ }
1154
+ /** My file, stored under `fileId` with the author's digest, is about to be announced in the group: its message takes it. */
1155
+ outgoing(groupId, fileId, meta) {
1156
+ this.own.set(`${groupId}\n${meta.d}`, fileId);
1157
+ }
1158
+ /** The announcement did not go: the file is not this group's after all. */
1159
+ notSent(groupId, meta) {
1160
+ this.own.delete(`${groupId}\n${meta.d}`);
1161
+ }
1162
+ /**
1163
+ * A message of the group announces a file (mine, or a member's): the file its row shows, or none when the member
1164
+ * announced more than the group takes (its text shows). A member's is kept here unfetched, and fetched now when it is
1165
+ * one this device takes by itself. The same message again (a catch-up, a whole copy) is the same file.
1166
+ */
1167
+ async announced(groupId, m, me) {
1168
+ const known = this.byMessage.get(`${groupId}\n${m.id}`);
1169
+ if (known) return messageFile(this.kept.get(known));
1170
+ const ownId = m.sender === me ? this.own.get(`${groupId}\n${m.file.d}`) : void 0;
1171
+ if (ownId) {
1172
+ this.own.delete(`${groupId}\n${m.file.d}`);
1173
+ this.pace(this.sentPace, groupId, GROUP_FILE_LIMITS.announcePerMinute, 6e4).take();
1174
+ const kept = {
1175
+ id: ownId,
1176
+ groupId,
1177
+ message: m.id,
1178
+ author: me,
1179
+ meta: m.file,
1180
+ mine: true,
1181
+ auto: false,
1182
+ asked: false,
1183
+ held: true,
1184
+ holders: []
1185
+ };
1186
+ this.add(kept);
1187
+ const transfer = {
1188
+ state: "done",
1189
+ transferred: m.file.size,
1190
+ size: m.file.size
1191
+ };
1192
+ await this.deps.store.patch(ownId, {
1193
+ group: {
1194
+ message: m.id,
1195
+ author: me
1196
+ },
1197
+ transfer
1198
+ }).catch(() => {});
1199
+ this.show(kept, {
1200
+ ...transfer,
1201
+ direction: "out"
1202
+ });
1203
+ this.queueHave(kept);
1204
+ return messageFile(kept);
1205
+ }
1206
+ if (!this.pace(this.takenPace, `${groupId}\n${m.sender}`, GROUP_FILE_LIMITS.announceTaken, GROUP_FILE_LIMITS.announceWindowMs).take()) return void 0;
1207
+ const id = groupFileId(groupId, "in");
1208
+ const auto = this.deps.settings().autoDownloads && fetchesByItself(m.file, this.keptAutomatically(groupId));
1209
+ const holders = this.ahead.get(`${groupId}\n${m.id}`) ?? [];
1210
+ this.ahead.delete(`${groupId}\n${m.id}`);
1211
+ const kept = {
1212
+ id,
1213
+ groupId,
1214
+ message: m.id,
1215
+ author: m.sender,
1216
+ meta: m.file,
1217
+ mine: false,
1218
+ auto,
1219
+ asked: false,
1220
+ held: false,
1221
+ holders
1222
+ };
1223
+ const { name, size, mime, voice, video, image } = m.file;
1224
+ this.add(kept);
1225
+ await this.deps.store.put({
1226
+ id,
1227
+ linkId: `group:${groupId}`,
1228
+ direction: "in",
1229
+ digest: m.file.d,
1230
+ createdAt: this.deps.now(),
1231
+ metadata: {
1232
+ name,
1233
+ size,
1234
+ mime,
1235
+ timestamp: this.deps.now(),
1236
+ voice,
1237
+ video,
1238
+ image
1239
+ },
1240
+ transfer: {
1241
+ state: "transferring",
1242
+ transferred: 0,
1243
+ size
1244
+ },
1245
+ group: {
1246
+ message: m.id,
1247
+ author: m.sender,
1248
+ ...auto && { auto: true }
1249
+ }
1250
+ });
1251
+ if (auto) {
1252
+ this.wants.set(id, this.newWant());
1253
+ this.ask(kept);
1254
+ } else this.show(kept, {
1255
+ state: "transferring",
1256
+ stage: "asking",
1257
+ transferred: 0,
1258
+ size,
1259
+ direction: "in"
1260
+ });
1261
+ return messageFile(kept);
1262
+ }
1263
+ /** The person asks for a file this device did not fetch by itself (Download), or for one that stopped coming. */
1264
+ async download(fileId) {
1265
+ const kept = this.kept.get(fileId);
1266
+ if (!kept) throw new Error("No such file");
1267
+ if (kept.held) return;
1268
+ const room = await this.deps.store.room().catch(() => null);
1269
+ if (room !== null && kept.meta.size > room) throw new Error("Not enough space on this device for this file");
1270
+ if (!kept.asked) {
1271
+ kept.asked = true;
1272
+ await this.deps.store.patch(kept.id, { group: this.groupField(kept) }).catch(() => {});
1273
+ }
1274
+ const want = this.wants.get(kept.id);
1275
+ if (!want) this.wants.set(kept.id, this.newWant());
1276
+ else if (!want.wire) want.tried.clear();
1277
+ this.ask(kept);
1278
+ }
1279
+ /** The group is gone from this device: its files go too. */
1280
+ async drop(groupId) {
1281
+ for (const kept of [...this.kept.values()].filter((k) => k.groupId === groupId)) {
1282
+ this.kept.delete(kept.id);
1283
+ this.byMessage.delete(`${groupId}\n${kept.message}`);
1284
+ this.wants.delete(kept.id);
1285
+ this.deps.transfers.delete(kept.id);
1286
+ await this.deps.store.remove(kept.id).catch(() => {});
1287
+ }
1288
+ for (const [linkId, edge] of this.edgesById) if (edge.groupId === groupId) {
1289
+ edge.files.detach();
1290
+ this.edgesById.delete(linkId);
1291
+ }
1292
+ for (const key of [...this.ahead.keys()]) if (key.startsWith(`${groupId}\n`)) this.ahead.delete(key);
1293
+ this.communityHave.delete(groupId);
1294
+ this.deps.changed();
1295
+ }
1296
+ /** An edge's files/3 session opened (both sides agreed it) or closed. */
1297
+ session(groupId, peer, linkId, open) {
1298
+ const edge = this.edge(groupId, peer, linkId);
1299
+ if (open) edge.files.attach();
1300
+ else edge.files.detach();
1301
+ if (open) this.retry(groupId);
1302
+ }
1303
+ /** An edge came up and both sides said where they are: what this device holds goes to it, and wants waiting for a holder go. */
1304
+ edgeReady(groupId, peer, linkId) {
1305
+ if (!this.deps.ready(linkId)) return;
1306
+ const edge = this.edge(groupId, peer, linkId);
1307
+ if (this.deps.settings().serveFiles) {
1308
+ const held = [...this.kept.values()].filter((k) => k.groupId === groupId && k.held).map((k) => k.message).slice(-GROUP_FILE_LIMITS.haveIds);
1309
+ if (held.length) this.sendHave(edge, held);
1310
+ }
1311
+ this.retry(groupId);
1312
+ }
1313
+ /** A `group-want`, `group-want-no` or `group-have` on an edge, from the member it is pinned to. */
1314
+ async frame(groupId, peer, linkId, frame) {
1315
+ if (frame.t === "group-want") {
1316
+ const want = readGroupWant(frame, groupId);
1317
+ if (want) await this.serve(this.edge(groupId, peer, linkId), want.id);
1318
+ } else if (frame.t === "group-want-no") {
1319
+ const no = readGroupWantNo(frame, groupId);
1320
+ if (no) this.refused(groupId, peer, linkId, no.id, no.why);
1321
+ } else if (frame.t === "group-have") {
1322
+ const ids = readGroupHave(frame, groupId);
1323
+ if (ids) this.heard(groupId, peer, ids);
1324
+ }
1325
+ }
1326
+ /** A community's `group-have`, an application frame its signer sent the group. */
1327
+ communityApp(groupId, sender, frame) {
1328
+ const ids = readGroupHave(frame);
1329
+ if (ids) this.heard(groupId, sender, ids);
1330
+ }
1331
+ /**
1332
+ * A files/3 frame on an edge. An offer is taken only for a file this device asked that member for (`gm` naming the
1333
+ * message): nobody can push a file into another's storage. What it brings belongs to the group's message, not to the
1334
+ * edge as a chat.
1335
+ */
1336
+ filesFrame(groupId, peer, linkId, frame) {
1337
+ const edge = this.edge(groupId, peer, linkId);
1338
+ if (frame.t === "pf-offer" && typeof frame.id === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(frame.id) && !edge.taking.has(frame.id)) {
1339
+ const kept = isGroupMessageId(frame.gm) ? this.kept.get(this.byMessage.get(`${groupId}\n${frame.gm}`) ?? "") : void 0;
1340
+ const want = kept && this.wants.get(kept.id);
1341
+ if (!kept || kept.held || !want || want.linkId !== linkId || want.wire) {
1342
+ this.deps.sendFiles(linkId, {
1343
+ t: "pf-refuse",
1344
+ id: frame.id,
1345
+ why: "invalid"
1346
+ });
1347
+ return Promise.resolve();
1348
+ }
1349
+ edge.taking.set(frame.id, kept.id);
1350
+ want.wire = frame.id;
1351
+ want.movedAt = this.deps.now();
1352
+ }
1353
+ return edge.files.handle(frame);
1354
+ }
1355
+ /** Wants that waited too long for an offer, or for bytes, go to the next holder; `group-have`s gathered go. */
1356
+ tick() {
1357
+ const now = this.deps.now();
1358
+ for (const [id, want] of this.wants) {
1359
+ const kept = this.kept.get(id);
1360
+ if (!kept) {
1361
+ this.wants.delete(id);
1362
+ continue;
1363
+ }
1364
+ if (now - (want.wire ? want.movedAt : want.at) < GROUP_FILE_LIMITS.wantWaitMs) continue;
1365
+ if (want.wire) {
1366
+ const edge = want.linkId ? this.edgesById.get(want.linkId) : void 0;
1367
+ if (edge?.files.get("in", want.wire) && !transferEnded(edge.files.get("in", want.wire))) {
1368
+ edge.files.cancel("in", want.wire);
1369
+ continue;
1370
+ }
1371
+ want.wire = void 0;
1372
+ }
1373
+ if (want.holder) want.tried.add(want.holder);
1374
+ else want.tried.clear();
1375
+ this.ask(kept);
1376
+ }
1377
+ for (const edge of this.edgesById.values()) {
1378
+ if (!edge.have.size || now - edge.haveAt < GROUP_FILE_LIMITS.haveEveryMs || !this.deps.ready(edge.linkId)) continue;
1379
+ this.sendHave(edge, [...edge.have]);
1380
+ }
1381
+ for (const [groupId, have] of this.communityHave) {
1382
+ if (!have.ids.size || now - have.at < GROUP_FILE_LIMITS.communityHaveEveryMs) continue;
1383
+ const ids = [...have.ids].slice(-GROUP_FILE_LIMITS.haveIds);
1384
+ have.ids.clear();
1385
+ have.at = now;
1386
+ this.deps.sendApp(groupId, {
1387
+ t: GROUP_HAVE_FRAME,
1388
+ ids
1389
+ }).catch(() => {});
1390
+ }
1391
+ }
1392
+ newWant() {
1393
+ return {
1394
+ at: this.deps.now(),
1395
+ tried: /* @__PURE__ */ new Set(),
1396
+ moved: 0,
1397
+ movedAt: 0
1398
+ };
1399
+ }
1400
+ /** Wants of this group that wait for a holder: asked now (an edge came up, or a member said it holds something). */
1401
+ retry(groupId) {
1402
+ for (const [id, want] of this.wants) {
1403
+ const kept = this.kept.get(id);
1404
+ if (kept?.groupId === groupId && !want.holder) this.ask(kept);
1405
+ }
1406
+ }
1407
+ /**
1408
+ * Asks the next holder for a file: the author first while its edge is up, then the holders whose edge is up. Only over
1409
+ * an edge this device has. (A private group past 16 members, or a community, reaches a holder it has no edge with
1410
+ * over a file session of the two members, WISP 503 § The file session; that session is not built yet, so such a
1411
+ * holder is not asked, and the file waits for one that is reachable.)
1412
+ */
1413
+ ask(kept) {
1414
+ const want = this.wants.get(kept.id);
1415
+ if (!want || kept.held || want.wire) return;
1416
+ const membership = this.deps.membership(kept.groupId);
1417
+ const edges = this.deps.edges(kept.groupId);
1418
+ const order = [kept.author, ...kept.holders.filter((key) => key !== kept.author)].filter((key) => membership && key !== membership.me && membership.inRoster(key) && !want.tried.has(key));
1419
+ for (const key of order) {
1420
+ const linkId = edges.get(key);
1421
+ if (!linkId || !this.deps.ready(linkId) || !this.edgesById.get(linkId)?.files.live) continue;
1422
+ if (!this.deps.send(linkId, {
1423
+ t: "group-want",
1424
+ g: kept.groupId,
1425
+ id: kept.message
1426
+ })) {
1427
+ want.tried.add(key);
1428
+ continue;
1429
+ }
1430
+ Object.assign(want, {
1431
+ holder: key,
1432
+ linkId,
1433
+ at: this.deps.now()
1434
+ });
1435
+ this.show(kept, {
1436
+ state: "transferring",
1437
+ stage: "queued",
1438
+ transferred: 0,
1439
+ size: kept.meta.size,
1440
+ direction: "in"
1441
+ });
1442
+ return;
1443
+ }
1444
+ Object.assign(want, {
1445
+ holder: void 0,
1446
+ linkId: void 0,
1447
+ at: this.deps.now()
1448
+ });
1449
+ this.show(kept, {
1450
+ state: "transferring",
1451
+ stage: "waiting",
1452
+ transferred: 0,
1453
+ size: kept.meta.size,
1454
+ direction: "in",
1455
+ note: NOBODY_CONNECTED
1456
+ });
1457
+ }
1458
+ /** A holder's no: the next one is asked. One that deleted the file is no holder any more. */
1459
+ refused(groupId, peer, linkId, messageId, why) {
1460
+ const kept = this.kept.get(this.byMessage.get(`${groupId}\n${messageId}`) ?? "");
1461
+ const want = kept && this.wants.get(kept.id);
1462
+ if (!kept || !want || want.linkId !== linkId || want.wire) return;
1463
+ if (why === "gone") kept.holders = kept.holders.filter((key) => key !== peer);
1464
+ want.tried.add(peer);
1465
+ want.holder = want.linkId = void 0;
1466
+ this.ask(kept);
1467
+ }
1468
+ /** A member says it holds these files: remembered as a holder of each (newest first), and asked when a want waits. */
1469
+ heard(groupId, from, ids) {
1470
+ const membership = this.deps.membership(groupId);
1471
+ if (!membership || from === membership.me || !membership.inRoster(from)) return;
1472
+ const newest = (holders) => [from, ...holders.filter((key) => key !== from)].slice(0, GROUP_FILE_LIMITS.holders);
1473
+ for (const message of ids) {
1474
+ const key = `${groupId}\n${message}`, kept = this.kept.get(this.byMessage.get(key) ?? "");
1475
+ if (!kept) {
1476
+ this.ahead.set(key, newest(this.ahead.get(key) ?? []));
1477
+ if (this.ahead.size > GROUP_FILE_LIMITS.heardAhead) this.ahead.delete(this.ahead.keys().next().value);
1478
+ continue;
1479
+ }
1480
+ if (kept.mine) continue;
1481
+ kept.holders = newest(kept.holders);
1482
+ const want = this.wants.get(kept.id);
1483
+ if (!want) continue;
1484
+ want.tried.delete(from);
1485
+ if (!want.holder) this.ask(kept);
1486
+ }
1487
+ }
1488
+ /** A member asks for a file: offered over files/3 on its edge, or a `group-want-no` saying why not. */
1489
+ async serve(edge, messageId) {
1490
+ const kept = this.kept.get(this.byMessage.get(`${edge.groupId}\n${messageId}`) ?? "");
1491
+ const here = !!kept?.held && !!await this.deps.store.get(kept.id).catch(() => void 0);
1492
+ const why = this.mayServe(edge, messageId, here ? kept : void 0);
1493
+ if (why || !kept) {
1494
+ this.deps.send(edge.linkId, {
1495
+ t: GROUP_WANT_NO_FRAME,
1496
+ g: edge.groupId,
1497
+ id: messageId,
1498
+ why: why ?? "gone"
1499
+ });
1500
+ return;
1501
+ }
1502
+ const wire = toBase64Url$1(randomBytes$1(12));
1503
+ edge.serving.set(wire, kept.id);
1504
+ const { name, mime, size, voice, video, image } = kept.meta;
1505
+ const file = {
1506
+ id: wire,
1507
+ name,
1508
+ mime,
1509
+ size,
1510
+ timestamp: this.deps.now(),
1511
+ ...voice && { voice },
1512
+ ...video && { video },
1513
+ ...image && { image }
1514
+ };
1515
+ edge.files.offer(file, kept.meta.d);
1516
+ }
1517
+ /**
1518
+ * Why this device does not serve a file now, or null when it does (WISP 503 § Asking, Serving limits): `refused` to
1519
+ * someone who could not read the announcement, `gone` when it is not here (`kept` undefined), `busy` when serving is
1520
+ * off or full.
1521
+ */
1522
+ mayServe(edge, messageId, kept) {
1523
+ const membership = this.deps.membership(edge.groupId);
1524
+ if (!membership || !membership.couldRead(edge.peer, messageId)) return "refused";
1525
+ if (!kept) return "gone";
1526
+ if (!this.deps.settings().serveFiles || !edge.files.live) return "busy";
1527
+ const active = this.servingNow();
1528
+ if (active.length >= GROUP_FILE_LIMITS.serveAtOnce || active.some((a) => a.peer === edge.peer)) return "busy";
1529
+ const today = Math.floor(this.deps.now() / 864e5);
1530
+ if (this.served.day !== today) this.served = {
1531
+ day: today,
1532
+ bytes: 0
1533
+ };
1534
+ const reserved = active.reduce((sum, a) => sum + a.size, 0);
1535
+ return this.served.bytes + reserved + kept.meta.size > GROUP_FILE_LIMITS.serveBytesPerDay ? "busy" : null;
1536
+ }
1537
+ /** Transfers served now, all groups together: to whom, and their size. */
1538
+ servingNow() {
1539
+ const active = [];
1540
+ for (const edge of this.edgesById.values()) for (const wire of edge.serving.keys()) {
1541
+ const record = edge.files.get("out", wire);
1542
+ if (record && !transferEnded(record)) active.push({
1543
+ peer: edge.peer,
1544
+ size: record.file.size
1545
+ });
1546
+ }
1547
+ return active;
1548
+ }
1549
+ /** Says on an edge which files this device holds. */
1550
+ sendHave(edge, ids) {
1551
+ for (let i = 0; i < ids.length; i += GROUP_FILE_LIMITS.haveIds) if (!this.deps.send(edge.linkId, {
1552
+ t: "group-have",
1553
+ g: edge.groupId,
1554
+ ids: ids.slice(i, i + GROUP_FILE_LIMITS.haveIds)
1555
+ })) return;
1556
+ edge.have.clear();
1557
+ edge.haveAt = this.deps.now();
1558
+ }
1559
+ /** This device holds a new file: the group hears it with the next `group-have` (batched, `haveEveryMs` at most on an edge). */
1560
+ queueHave(kept) {
1561
+ if (!this.deps.settings().serveFiles) return;
1562
+ if (this.deps.membership(kept.groupId)?.community) {
1563
+ let have = this.communityHave.get(kept.groupId);
1564
+ if (!have) this.communityHave.set(kept.groupId, have = {
1565
+ ids: /* @__PURE__ */ new Set(),
1566
+ at: 0
1567
+ });
1568
+ have.ids.add(kept.message);
1569
+ return;
1570
+ }
1571
+ for (const [key, linkId] of this.deps.edges(kept.groupId)) this.edge(kept.groupId, key, linkId).have.add(kept.message);
1572
+ }
1573
+ edge(groupId, peer, linkId) {
1574
+ const known = this.edgesById.get(linkId);
1575
+ if (known) return known;
1576
+ const edge = {
1577
+ linkId,
1578
+ groupId,
1579
+ peer,
1580
+ files: void 0,
1581
+ serving: /* @__PURE__ */ new Map(),
1582
+ taking: /* @__PURE__ */ new Map(),
1583
+ have: /* @__PURE__ */ new Set(),
1584
+ haveAt: 0
1585
+ };
1586
+ edge.files = new ChatFiles({
1587
+ send: (frame) => {
1588
+ const kept = frame.t === "pf-offer" ? this.kept.get(edge.serving.get(frame.id) ?? "") : void 0;
1589
+ return this.deps.sendFiles(linkId, kept ? {
1590
+ ...frame,
1591
+ gm: kept.message
1592
+ } : frame);
1593
+ },
1594
+ decide: (file) => this.decide(edge, file),
1595
+ openTarget: (record) => this.openTarget(edge, record),
1596
+ openSource: (record) => this.openSource(edge, record),
1597
+ changed: (record, transferred) => this.transferChanged(edge, record, transferred),
1598
+ room: () => this.deps.store.room(),
1599
+ writable: () => this.deps.writable?.(linkId)
1600
+ });
1601
+ this.edgesById.set(linkId, edge);
1602
+ return edge;
1603
+ }
1604
+ /** An offer that answers my want (`filesFrame` checked that): taken, unless it is not the file announced or there is no room. */
1605
+ async decide(edge, file) {
1606
+ const kept = this.kept.get(edge.taking.get(file.id) ?? ""), want = kept && this.wants.get(kept.id);
1607
+ if (!kept || kept.held || want?.linkId !== edge.linkId || want.wire !== file.id || file.size !== kept.meta.size) return { refuse: "invalid" };
1608
+ const room = await this.deps.store.room().catch(() => null);
1609
+ return room !== null && file.size > room ? {
1610
+ refuse: "no-room",
1611
+ room
1612
+ } : "accept";
1613
+ }
1614
+ async openTarget(edge, record) {
1615
+ const kept = this.kept.get(edge.taking.get(record.id) ?? "");
1616
+ if (!kept) throw new Error("No such file");
1617
+ await kept.idle;
1618
+ const writer = await this.deps.store.write(kept.id);
1619
+ return {
1620
+ offset: 0,
1621
+ append: (bytes) => writer.append(bytes),
1622
+ flush: () => writer.flush(),
1623
+ verify: async () => await writer.digest() === kept.meta.d,
1624
+ discard: () => kept.idle = writer.discard().catch(() => {})
1625
+ };
1626
+ }
1627
+ async openSource(edge, record) {
1628
+ const kept = this.kept.get(edge.serving.get(record.id) ?? "");
1629
+ const stored = kept && await this.deps.store.get(kept.id);
1630
+ if (!kept || !stored) throw new Error("The file is gone");
1631
+ return {
1632
+ read: (offset, length) => this.deps.store.read(stored, offset, length),
1633
+ digest: async () => kept.meta.d
1634
+ };
1635
+ }
1636
+ transferChanged(edge, record, transferred) {
1637
+ if (record.direction === "out") {
1638
+ if (transferEnded(record) && edge.serving.has(record.id)) {
1639
+ const today = Math.floor(this.deps.now() / 864e5);
1640
+ if (this.served.day !== today) this.served = {
1641
+ day: today,
1642
+ bytes: 0
1643
+ };
1644
+ this.served.bytes += record.confirmed;
1645
+ edge.serving.delete(record.id);
1646
+ }
1647
+ return;
1648
+ }
1649
+ const kept = this.kept.get(edge.taking.get(record.id) ?? ""), want = kept && this.wants.get(kept.id);
1650
+ if (!kept || !want || want.wire !== record.id || want.linkId !== edge.linkId) return;
1651
+ if (transferred !== want.moved) {
1652
+ want.moved = transferred;
1653
+ want.movedAt = this.deps.now();
1654
+ }
1655
+ const { size } = kept.meta;
1656
+ switch (record.state) {
1657
+ case "done":
1658
+ kept.held = true;
1659
+ this.wants.delete(kept.id);
1660
+ edge.taking.delete(record.id);
1661
+ this.deps.store.patch(kept.id, { transfer: {
1662
+ state: "done",
1663
+ transferred: size,
1664
+ size
1665
+ } }).catch(() => {});
1666
+ this.show(kept, {
1667
+ state: "done",
1668
+ transferred: size,
1669
+ size,
1670
+ direction: "in"
1671
+ });
1672
+ this.queueHave(kept);
1673
+ return;
1674
+ case "failed":
1675
+ case "declined":
1676
+ case "cancelled":
1677
+ edge.taking.delete(record.id);
1678
+ if (want.holder) want.tried.add(want.holder);
1679
+ Object.assign(want, {
1680
+ wire: void 0,
1681
+ holder: void 0,
1682
+ linkId: void 0,
1683
+ moved: 0
1684
+ });
1685
+ this.ask(kept);
1686
+ return;
1687
+ case "verifying":
1688
+ this.show(kept, {
1689
+ state: "transferring",
1690
+ stage: "verifying",
1691
+ transferred,
1692
+ size,
1693
+ direction: "in"
1694
+ });
1695
+ return;
1696
+ default: this.show(kept, {
1697
+ state: "transferring",
1698
+ transferred,
1699
+ size,
1700
+ direction: "in"
1701
+ });
1702
+ }
1703
+ }
1704
+ add(kept) {
1705
+ this.kept.set(kept.id, kept);
1706
+ this.byMessage.set(`${kept.groupId}\n${kept.message}`, kept.id);
1707
+ }
1708
+ /** Bytes this device fetched by itself from the group and keeps (or is fetching): what `autoBytesPerGroup` bounds. */
1709
+ keptAutomatically(groupId) {
1710
+ let sum = 0;
1711
+ for (const kept of this.kept.values()) if (kept.groupId === groupId && kept.auto && !kept.mine) sum += kept.meta.size;
1712
+ return sum;
1713
+ }
1714
+ groupField(kept) {
1715
+ return {
1716
+ message: kept.message,
1717
+ author: kept.author,
1718
+ ...kept.auto && { auto: true },
1719
+ ...kept.asked && { asked: true }
1720
+ };
1721
+ }
1722
+ pace(windows, key, limit, ms) {
1723
+ let window = windows.get(key);
1724
+ if (!window) windows.set(key, window = new RateWindow(limit, ms, () => this.deps.now()));
1725
+ return window;
1726
+ }
1727
+ show(kept, view) {
1728
+ this.deps.transfers.set(kept.id, view);
1729
+ this.deps.changed();
1730
+ }
1731
+ };
1732
+ function messageFile(kept) {
1733
+ const { name, size, mime, voice, video, image } = kept.meta;
1734
+ return {
1735
+ id: kept.id,
1736
+ name,
1737
+ size,
1738
+ mime,
1739
+ ...voice && { voice },
1740
+ ...video && { video },
1741
+ ...image && { image }
1742
+ };
1743
+ }
1744
+ //#endregion
1018
1745
  //#region src/typing.ts
1019
1746
  /**
1020
1747
  * `typing --for` and `group typing --for`: a `start` holds 6 s on the other side (WISP 401 § Typing, WISP 902 · Group
@@ -1433,6 +2160,9 @@ function decodeWithFfmpeg(path, command = process.env.GHOSTLY_FFMPEG || "ffmpeg"
1433
2160
  * Files and voice notes in a chat (WISP 1100, phase 3), as the app's composer sends them (packages/browser
1434
2161
  * platform/services.ts `sendFile`): the bytes go into the profile's file store first, then the engine offers them.
1435
2162
  * Large files follow files/3: the receiving side's person (here, a command) accepts or declines each offer.
2163
+ *
2164
+ * In a group (WISP 503 · Group Files) the file is announced as a group message, and each member's app fetches the
2165
+ * bytes from whoever holds them: by itself up to the automatic limits, on `file accept` past them.
1436
2166
  */
1437
2167
  var TYPES$1 = {
1438
2168
  ".png": "image/png",
@@ -1476,16 +2206,143 @@ function filesOf(messages, transfers) {
1476
2206
  }));
1477
2207
  }
1478
2208
  /**
1479
- * The chat a file belongs to: a file id starts with its chat's id, so the chat may be left out; given, it must be
1480
- * the file's own.
2209
+ * The chat a file command names, or else a group (`group:<id>`, or a group's id, prefix or name where no chat has it),
2210
+ * as `forward` reads them. A name that is neither says what a chat would.
2211
+ */
2212
+ function chatOrGroupOf(ctx, params) {
2213
+ const ref = str(params, "chat", true);
2214
+ if (ref.startsWith("group:")) return { group: findGroup(state(ctx).groups, ref.slice(6)) };
2215
+ try {
2216
+ return { link: chatOf(ctx, params) };
2217
+ } catch (error) {
2218
+ if (!(error instanceof CliError) || error.code !== "not_found") throw error;
2219
+ try {
2220
+ return { group: findGroup(state(ctx).groups, ref) };
2221
+ } catch {
2222
+ throw error;
2223
+ }
2224
+ }
2225
+ }
2226
+ /**
2227
+ * The chat or group a file belongs to: a file id starts with its chat's id (a group's file with `group-<id>-`), so it
2228
+ * may be left out; given, it must be the file's own. `where` is what the answer names it by.
1481
2229
  */
1482
2230
  function fileChat(ctx, params, fileId) {
2231
+ const groupId = groupOfFile(fileId, state(ctx).groups.map((g) => g.id));
2232
+ if (groupId) {
2233
+ const named = params.chat === void 0 ? void 0 : chatOrGroupOf(ctx, params);
2234
+ if (named && !("group" in named && named.group.id === groupId)) throw new CliError("not_found", `No file ${fileId} in this ${"group" in named ? "group" : "chat"}`);
2235
+ return {
2236
+ linkId: `group:${groupId}`,
2237
+ where: { group: groupId }
2238
+ };
2239
+ }
1483
2240
  const link = params.chat === void 0 ? state(ctx).links.find((l) => fileId.startsWith(`${l.id}-in-`) || fileId.startsWith(`${l.id}-out-`)) : chatOf(ctx, params);
1484
2241
  if (!link) throw new CliError("not_found", `No file ${fileId}`);
1485
2242
  if (!fileId.startsWith(`${link.id}-`)) throw new CliError("not_found", `No file ${fileId} in this chat`);
1486
- return link;
2243
+ return {
2244
+ linkId: link.id,
2245
+ where: { chat: link.id }
2246
+ };
1487
2247
  }
1488
2248
  var VOICE_ONLY = "A voice note is audio (webm, ogg, mp4, mpeg, aac, m4a or wav) of at most 15 minutes, with peaks from 0 to 255";
2249
+ /** `--voice [ms] [--peaks …]`: the voice description, its length and waveform measured from the sound when not given. */
2250
+ async function voiceOf(params, path, mime) {
2251
+ if (params.voice === void 0) return {};
2252
+ if (!PLAYABLE_AUDIO.test(baseMime(mime))) throw new CliError("bad_request", VOICE_ONLY);
2253
+ let duration = params.voice === true ? void 0 : num(params, "voice", 0, { min: 1 });
2254
+ let peaks = list(params, "peaks").map(Number);
2255
+ let warning;
2256
+ if (duration === void 0 || !peaks.length) {
2257
+ const measured = await measureVoice(path);
2258
+ if ("voice" in measured) {
2259
+ duration ??= measured.voice.duration;
2260
+ if (!peaks.length) peaks = measured.voice.peaks;
2261
+ } else if (duration === void 0) throw new CliError("bad_request", `Could not measure the voice note (${measured.problem}): give its length as --voice <ms>`);
2262
+ else warning = `Sent without a waveform: could not read the sound (${measured.problem})`;
2263
+ }
2264
+ const voice = parseVoiceMeta({
2265
+ duration,
2266
+ peaks: peaks.length ? peaks : new Array(VOICE_LIMITS.bars).fill(0)
2267
+ }, mime);
2268
+ if (!voice) throw new CliError("bad_request", VOICE_ONLY);
2269
+ return {
2270
+ voice,
2271
+ ...warning ? { warning } : {}
2272
+ };
2273
+ }
2274
+ /**
2275
+ * A file announced in a group (WISP 503): its bytes staged under a group file id with their digest, as a chat's are, then
2276
+ * the announcement. This device is its first holder and serves the members who ask.
2277
+ */
2278
+ async function sendToGroup(ctx, params, group, path, size) {
2279
+ if (size > GROUP_FILE_LIMITS.maxBytes) throw new CliError("refused", `Too large for a group (at most ${GROUP_FILE_LIMITS.maxBytes} bytes)`);
2280
+ if (!size) throw new CliError("refused", "An empty file cannot go to a group");
2281
+ const mime = mimeOf(path, str(params, "mime"));
2282
+ const replyTo = str(params, "reply");
2283
+ if (replyTo) {
2284
+ if (!(await node(ctx).groupMessages({ groupId: group.id })).some((m) => m.id === replyTo || replyRef(m) === replyTo)) throw new CliError("not_found", "That message is not in this group, or cannot be replied to");
2285
+ }
2286
+ const { voice, warning } = await voiceOf(params, path, mime);
2287
+ const source = await openAsBlob(path, { type: mime });
2288
+ const image = voice ? void 0 : await readImageMeta(source, mime);
2289
+ const fileId = groupFileId(group.id, "out");
2290
+ const file = {
2291
+ id: fileId,
2292
+ name: sanitizeFileName(str(params, "name") ?? basename(path)),
2293
+ size,
2294
+ mime,
2295
+ ...voice ? { voice } : {},
2296
+ ...image ? { image } : {}
2297
+ };
2298
+ const timestamp = Date.now();
2299
+ const bytes = await fileBytes();
2300
+ const digest = await bytes.stage(fileId, source);
2301
+ await fileStore.put({
2302
+ id: fileId,
2303
+ linkId: `group:${group.id}`,
2304
+ bytes: bytes.kind,
2305
+ digest,
2306
+ createdAt: timestamp,
2307
+ direction: "out",
2308
+ metadata: {
2309
+ name: file.name,
2310
+ size,
2311
+ mime,
2312
+ timestamp,
2313
+ ...voice ? { voice } : {},
2314
+ ...image ? { image } : {}
2315
+ },
2316
+ transfer: {
2317
+ state: "transferring",
2318
+ transferred: 0,
2319
+ size
2320
+ }
2321
+ });
2322
+ endTyping(ctx, { groupId: group.id }, false);
2323
+ const result = await node(ctx).sendGroupFile({
2324
+ groupId: group.id,
2325
+ file,
2326
+ ...replyTo ? { replyTo } : {}
2327
+ });
2328
+ if (result.error) {
2329
+ await removeStored(fileId).catch(() => {});
2330
+ throw result.refused ? new CliError("refused", result.error) : groupSendRefused(result.error);
2331
+ }
2332
+ return {
2333
+ group: group.id,
2334
+ messageId: result.messageId ?? null,
2335
+ file: {
2336
+ id: fileId,
2337
+ name: file.name,
2338
+ size,
2339
+ mime,
2340
+ ...voice ? { voice: true } : {},
2341
+ ...image ? { image } : {}
2342
+ },
2343
+ ...warning ? { warning } : {}
2344
+ };
2345
+ }
1489
2346
  var ACTIONS = [
1490
2347
  "accept",
1491
2348
  "decline",
@@ -1497,7 +2354,7 @@ var ACTIONS = [
1497
2354
  ];
1498
2355
  var FILE_METHODS = {
1499
2356
  async "file.send"(ctx, params) {
1500
- const link = chatOf(ctx, params);
2357
+ const target = chatOrGroupOf(ctx, params);
1501
2358
  const path = resolve(str(params, "path", true));
1502
2359
  let info;
1503
2360
  try {
@@ -1506,6 +2363,8 @@ var FILE_METHODS = {
1506
2363
  throw new CliError("not_found", `No file ${path}`);
1507
2364
  }
1508
2365
  if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file`);
2366
+ if ("group" in target) return sendToGroup(ctx, params, target.group, path, info.size);
2367
+ const { link } = target;
1509
2368
  if (link.profile && link.dataLink === "open" && !link.capabilities?.files) throw new CliError("unavailable", "The contact's app takes no files");
1510
2369
  if (!link.capabilities?.largeFiles && info.size > LIMITS.maxFileBytes) throw new CliError("refused", `Too large for the contact's app (at most ${LIMITS.maxFileBytes} bytes)`);
1511
2370
  if (typeof link.peerFileRoom === "number" && info.size > link.peerFileRoom) throw new CliError("refused", `Not enough space on the contact's device (${link.peerFileRoom} bytes free)`);
@@ -1514,25 +2373,7 @@ var FILE_METHODS = {
1514
2373
  if (replyTo) {
1515
2374
  if (!(await node(ctx).getMessages(link.id)).some((m) => m.id === replyTo || replyRef(m) === replyTo)) throw new CliError("not_found", "That message is not in this chat, or cannot be replied to");
1516
2375
  }
1517
- let voice, warning;
1518
- if (params.voice !== void 0) {
1519
- if (!PLAYABLE_AUDIO.test(baseMime(mime))) throw new CliError("bad_request", VOICE_ONLY);
1520
- let duration = params.voice === true ? void 0 : num(params, "voice", 0, { min: 1 });
1521
- let peaks = list(params, "peaks").map(Number);
1522
- if (duration === void 0 || !peaks.length) {
1523
- const measured = await measureVoice(path);
1524
- if ("voice" in measured) {
1525
- duration ??= measured.voice.duration;
1526
- if (!peaks.length) peaks = measured.voice.peaks;
1527
- } else if (duration === void 0) throw new CliError("bad_request", `Could not measure the voice note (${measured.problem}): give its length as --voice <ms>`);
1528
- else warning = `Sent without a waveform: could not read the sound (${measured.problem})`;
1529
- }
1530
- voice = parseVoiceMeta({
1531
- duration,
1532
- peaks: peaks.length ? peaks : new Array(VOICE_LIMITS.bars).fill(0)
1533
- }, mime);
1534
- if (!voice) throw new CliError("bad_request", VOICE_ONLY);
1535
- }
2376
+ const { voice, warning } = await voiceOf(params, path, mime);
1536
2377
  const source = await openAsBlob(path, { type: mime });
1537
2378
  const image = voice ? void 0 : await readImageMeta(source, mime);
1538
2379
  const wireId = toBase64Url$1(randomBytes$1(12));
@@ -1595,34 +2436,58 @@ var FILE_METHODS = {
1595
2436
  };
1596
2437
  },
1597
2438
  async "file.list"(ctx, params) {
1598
- const link = chatOf(ctx, params);
2439
+ const target = chatOrGroupOf(ctx, params);
2440
+ if ("group" in target) return {
2441
+ group: target.group.id,
2442
+ files: filesOf(await node(ctx).groupMessages({ groupId: target.group.id }), state(ctx).transfers)
2443
+ };
1599
2444
  return {
1600
- chat: link.id,
1601
- files: filesOf(await node(ctx).getMessages(link.id), state(ctx).transfers)
2445
+ chat: target.link.id,
2446
+ files: filesOf(await node(ctx).getMessages(target.link.id), state(ctx).transfers)
1602
2447
  };
1603
2448
  },
1604
2449
  /**
1605
2450
  * files/3: answer an offer, or pause, resume or cancel a transfer, either way; `resend` a file sent from here that
1606
2451
  * stopped moving (or failed), `request` again one that stopped arriving. Without a chat, the file's id names it.
2452
+ * A group's file (WISP 503) takes `accept` (download one this device did not fetch by itself) and `request`.
1607
2453
  */
1608
2454
  async "file.action"(ctx, params) {
1609
2455
  const fileId = str(params, "file", true);
1610
- const link = fileChat(ctx, params, fileId);
2456
+ const { linkId, where } = fileChat(ctx, params, fileId);
1611
2457
  const action = oneOf(params, "action", ACTIONS, "accept");
2458
+ if ("group" in where) {
2459
+ if (action !== "accept" && action !== "request") throw new CliError("bad_request", "A group's file is downloaded (file accept) or asked for again (file request)");
2460
+ if (state(ctx).transfers[fileId]?.state === "done") throw new CliError("refused", "Nothing to ask for: the file is all here");
2461
+ try {
2462
+ await node(ctx).fileAction({
2463
+ linkId,
2464
+ fileId,
2465
+ action
2466
+ });
2467
+ } catch (error) {
2468
+ throw new CliError("refused", error instanceof Error ? error.message : String(error));
2469
+ }
2470
+ return {
2471
+ ...where,
2472
+ file: fileId,
2473
+ action,
2474
+ transfer: state(ctx).transfers[fileId] ?? null
2475
+ };
2476
+ }
1612
2477
  if (action === "resend" || action === "request") {
1613
- const outgoing = fileId.startsWith(`${link.id}-out-`);
2478
+ const outgoing = fileId.startsWith(`${linkId}-out-`);
1614
2479
  if (outgoing !== (action === "resend")) throw new CliError("bad_request", outgoing ? "A file sent from here is sent again: file resend" : "A received file is asked for again: file request");
1615
2480
  const transfer = state(ctx).transfers[fileId];
1616
2481
  if (!transfer || transfer.state === "done") throw new CliError("refused", outgoing ? "Nothing to send again: it arrived whole" : "Nothing to ask for: the file is all here");
1617
2482
  if (transfer.state === "failed" && (!outgoing || !transfer.retry)) throw new CliError("refused", `It ended: ${transfer.error ?? "failed"}`);
1618
2483
  }
1619
2484
  await node(ctx).fileAction({
1620
- linkId: link.id,
2485
+ linkId,
1621
2486
  fileId,
1622
2487
  action
1623
2488
  });
1624
2489
  return {
1625
- chat: link.id,
2490
+ ...where,
1626
2491
  file: fileId,
1627
2492
  action,
1628
2493
  transfer: state(ctx).transfers[fileId] ?? null
@@ -1634,7 +2499,7 @@ var FILE_METHODS = {
1634
2499
  */
1635
2500
  async "file.wait"(ctx, params) {
1636
2501
  const fileId = str(params, "file", true);
1637
- const link = fileChat(ctx, params, fileId);
2502
+ const { where } = fileChat(ctx, params, fileId);
1638
2503
  const stored = await fileStore.get(fileId);
1639
2504
  if (!stored && !state(ctx).transfers[fileId]) throw new CliError("not_found", `No file ${fileId}`);
1640
2505
  const transfer = await waitForState(ctx, (s) => {
@@ -1655,11 +2520,11 @@ var FILE_METHODS = {
1655
2520
  });
1656
2521
  if (transfer.state === "failed") throw new CliError("engine", transfer.error ?? "The transfer failed", {
1657
2522
  file: fileId,
1658
- chat: link.id,
2523
+ ...where,
1659
2524
  retry: !!transfer.retry
1660
2525
  });
1661
2526
  return {
1662
- chat: link.id,
2527
+ ...where,
1663
2528
  file: fileId,
1664
2529
  state: "done",
1665
2530
  size: transfer.size
@@ -1911,7 +2776,7 @@ async function sendCard(ctx, params, kind) {
1911
2776
  status: "running",
1912
2777
  startedAt: now,
1913
2778
  updatedAt: now
1914
- } : { state: "active" },
2779
+ } : kind === "routine" ? { state: "active" } : {},
1915
2780
  ...fields,
1916
2781
  kind,
1917
2782
  id
@@ -2034,6 +2899,82 @@ async function flushCardUpdates(ctx) {
2034
2899
  for (const pace of waiting) pace.flush();
2035
2900
  await Promise.all(waiting.map((pace) => pace.flushed));
2036
2901
  }
2902
+ /** A usage card's fields: all of them are said again by each report, so one not given goes from the card. */
2903
+ var USAGE_FIELDS = [
2904
+ "left",
2905
+ "used",
2906
+ "limit",
2907
+ "label",
2908
+ "account",
2909
+ "window",
2910
+ "resetsAt",
2911
+ "updatedAt",
2912
+ "windows"
2913
+ ];
2914
+ /** The id a bot's usage card goes by when it names none: one live card per chat. */
2915
+ var USAGE_CARD_ID = "usage";
2916
+ /**
2917
+ * One chat's usage report (WISP 405 § Usage): the bot's usage card there, kept as one message. The first report sends
2918
+ * it; every later one edits it in place (quiet: no sound, no unread, no move in the list), paced as a task's updates.
2919
+ * A report is whole: what it does not say goes from the card. A card at its edits' cap gives way to a new message.
2920
+ */
2921
+ async function reportUsage(ctx, ref, fields, params) {
2922
+ const target = chatOrGroup(ctx, ref);
2923
+ const id = typeof fields.id === "string" && fields.id ? fields.id : USAGE_CARD_ID;
2924
+ const report = {
2925
+ updatedAt: Date.now(),
2926
+ ...fields,
2927
+ kind: "usage",
2928
+ id
2929
+ };
2930
+ checked$1(report);
2931
+ const sent = await cardMessage(ctx, target, "usage", id).catch(() => void 0);
2932
+ const wait = oneOf(params, "wait", ["none", "sent"], "none");
2933
+ if (!sent || (sent.edit?.seq ?? 0) >= STATUS_CARD_LIMITS.edits) return {
2934
+ ...await sendCard(ctx, {
2935
+ ...params,
2936
+ chat: ref,
2937
+ card: report,
2938
+ wait
2939
+ }, "usage"),
2940
+ updated: false
2941
+ };
2942
+ const patch = {};
2943
+ for (const key of USAGE_FIELDS) patch[key] = report[key] ?? null;
2944
+ return {
2945
+ ...await updateCard(ctx, {
2946
+ ...params,
2947
+ chat: ref,
2948
+ usage: id,
2949
+ card: patch,
2950
+ wait: wait === "none" ? "none" : target.group ? "sent" : "confirmed"
2951
+ }, "usage"),
2952
+ updated: true
2953
+ };
2954
+ }
2955
+ /** `usage send`: one chat or group, or (`all`) every 1:1 chat with a contact; a chat that refuses is said, not fatal. */
2956
+ async function sendUsage(ctx, params) {
2957
+ const fields = fieldsOf(params);
2958
+ if (!bool(params, "all")) return reportUsage(ctx, str(params, "chat", true), fields, params);
2959
+ if (str(params, "chat")) throw new CliError("bad_request", "Name a chat or --all, not both");
2960
+ checked$1({
2961
+ updatedAt: Date.now(),
2962
+ ...fields,
2963
+ kind: "usage",
2964
+ id: typeof fields.id === "string" && fields.id ? fields.id : USAGE_CARD_ID
2965
+ });
2966
+ const chats = state(ctx).links.filter((link) => link.profile && link.peerPubKeyZ32);
2967
+ const results = [];
2968
+ for (const link of chats) try {
2969
+ results.push(await reportUsage(ctx, link.id, fields, params));
2970
+ } catch (error) {
2971
+ results.push({
2972
+ chat: link.id,
2973
+ error: error instanceof Error ? error.message : String(error)
2974
+ });
2975
+ }
2976
+ return { chats: results };
2977
+ }
2037
2978
  var STATUS_CARD_METHODS = {
2038
2979
  /** WISP 405 · Status Cards: a task card in a chat or a group; the answer names the task's id and its message. */
2039
2980
  "task.send": (ctx, params) => sendCard(ctx, params, "task"),
@@ -2042,7 +2983,9 @@ var STATUS_CARD_METHODS = {
2042
2983
  /** A routine card (its schedule, state, runs); `run` records one. */
2043
2984
  "routine.send": (ctx, params) => sendCard(ctx, params, "routine"),
2044
2985
  /** A routine's update, as a task's; `run` records a run as its last and the newest of its recent ones. */
2045
- "routine.update": (ctx, params) => updateCard(ctx, params, "routine")
2986
+ "routine.update": (ctx, params) => updateCard(ctx, params, "routine"),
2987
+ /** WISP 405 § Usage: how much of a quota the bot has left, kept as one card per chat; `all` for every 1:1 chat. */
2988
+ "usage.send": sendUsage
2046
2989
  };
2047
2990
  //#endregion
2048
2991
  //#region src/buttons.ts
@@ -6248,7 +7191,7 @@ function openPassportWindow(url) {
6248
7191
  return window.open(url, "pubky-passport", "popup,width=520,height=760");
6249
7192
  }
6250
7193
  /** A 401 or 403 from the homeserver: the session it was given is not one it accepts. */
6251
- var refused = (e) => {
7194
+ var refused$1 = (e) => {
6252
7195
  const status = e?.data?.statusCode;
6253
7196
  return status === 401 || status === 403;
6254
7197
  };
@@ -6479,7 +7422,7 @@ async function approveAndWork(options, work, { grant, cookie, opened, settled, c
6479
7422
  throw e;
6480
7423
  }
6481
7424
  const fail = (what) => (e) => {
6482
- if (viaCookie && !cookieKept && refused(e)) throw new Error("Pubky Ring approved, but this browser blocks your homeserver's sign-in cookie (Vanadium and Safari block cookies from other sites), so nothing was written. Approve with Pubky Passport instead, or use Firefox or the Ghostly desktop app.");
7425
+ if (viaCookie && !cookieKept && refused$1(e)) throw new Error("Pubky Ring approved, but this browser blocks your homeserver's sign-in cookie (Vanadium and Safari block cookies from other sites), so nothing was written. Approve with Pubky Passport instead, or use Firefox or the Ghostly desktop app.");
6483
7426
  throw failure(what, e);
6484
7427
  };
6485
7428
  const approved = session;
@@ -8489,7 +9432,7 @@ async function restoreProfile(home, name, file, passphrase) {
8489
9432
  if (!reader) {
8490
9433
  const payload = await readWhole(file, passphrase ?? "");
8491
9434
  work = await restoringFolder(home, name);
8492
- await writeWhole(work, payload);
9435
+ await writeWhole$1(work, payload);
8493
9436
  return await finishRestore(home, name, work);
8494
9437
  }
8495
9438
  const next = async () => {
@@ -8582,7 +9525,7 @@ async function readWhole(file, passphrase) {
8582
9525
  if (payload?.format !== FORMAT_1 || typeof payload.store !== "string" || !Array.isArray(payload.files)) throw new CliError("refused", "This backup is not a headless Ghostly profile");
8583
9526
  return payload;
8584
9527
  }
8585
- async function writeWhole(paths, payload) {
9528
+ async function writeWhole$1(paths, payload) {
8586
9529
  await mkdir(paths.db, {
8587
9530
  recursive: true,
8588
9531
  mode: 448
@@ -12206,6 +13149,565 @@ function connectDaemon(path, timeoutMs = 2e3) {
12206
13149
  });
12207
13150
  }
12208
13151
  //#endregion
13152
+ //#region src/apps.ts
13153
+ var APP_SOURCE = "ghostly-app.json";
13154
+ var APP_REVOKE_FILE = "ghostly-revoke.json";
13155
+ var STORE_INDEX_FILE = "ghostly-store.json";
13156
+ var STORE_SIG_FILE = "ghostly-store.sig";
13157
+ /** How long `app verify` waits for a URL, and how many redirects it follows (each checked as the first). */
13158
+ var FETCH_TIMEOUT_MS = 6e4;
13159
+ var MAX_REDIRECTS = 5;
13160
+ var KEY_HEADER = {
13161
+ publisher: "Ghostly publisher key (WISP 1200). Secret: never share or commit it. Back it up: an app signed with it is updated only with it.",
13162
+ store: "Ghostly store key (WISP 1200). Secret: keep it offline, never share or commit it. Back it up: a store is its key."
13163
+ };
13164
+ var PEM = /-----BEGIN PRIVATE KEY-----\r?\n([A-Za-z0-9+/=\r\n]+)-----END PRIVATE KEY-----/;
13165
+ /** Refuses a key file that is not a plain file of this user's, or that anyone else may read or write. */
13166
+ function checkKeyFile(path) {
13167
+ const info = statSync(path);
13168
+ if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file: --key names a key file`);
13169
+ if (process.platform === "win32") return;
13170
+ if (process.getuid && info.uid !== process.getuid()) throw new CliError("refused", `${path} is not this user's: a key file must be yours alone`);
13171
+ if ((info.mode & 63) !== 0) throw new CliError("refused", `${path} may be read by others (mode ${(info.mode & 511).toString(8)}): run chmod 600 ${path}, and think whether the key was seen`);
13172
+ }
13173
+ /** Reads a key file: the PEM block, an Ed25519 key, and the kind its first line names (when it names one). */
13174
+ function readKey(path, kind) {
13175
+ checkKeyFile(path);
13176
+ const text = readFileSync(path, "utf8");
13177
+ const other = kind === "publisher" ? "store" : "publisher";
13178
+ if (text.startsWith(`Ghostly ${other} key`)) throw new CliError("refused", `${path} is a ${other} key: ${kind === "publisher" ? "a store key never signs an app" : "a publisher key never signs a store index"} (WISP 1200 · Stores)`);
13179
+ const block = PEM.exec(text);
13180
+ let seed;
13181
+ if (block) try {
13182
+ const key = createPrivateKey(block[0]);
13183
+ const d = key.asymmetricKeyType === "ed25519" ? key.export({ format: "jwk" }).d : void 0;
13184
+ if (d) seed = fromBase64Url$1(d);
13185
+ } catch {}
13186
+ if (!seed || seed.length !== 32) throw new CliError("bad_request", `${path} holds no Ed25519 private key (a PEM "PRIVATE KEY" block, as openssl genpkey -algorithm ed25519 writes)`);
13187
+ const signer = seedSigner(seed);
13188
+ return {
13189
+ signer,
13190
+ key: toZ32(signer.publicKey),
13191
+ created: false
13192
+ };
13193
+ }
13194
+ /** Makes a key file: owner-only from the start, never over a file that is there. */
13195
+ function makeKey(path, kind) {
13196
+ mkdirSync(dirname(path), {
13197
+ recursive: true,
13198
+ mode: 448
13199
+ });
13200
+ const { privateKey } = generateKeyPairSync("ed25519");
13201
+ const pem = privateKey.export({
13202
+ type: "pkcs8",
13203
+ format: "pem"
13204
+ });
13205
+ const fd = openSync(path, "wx", 384);
13206
+ try {
13207
+ writeSync(fd, `${KEY_HEADER[kind]}\n${pem}`);
13208
+ } finally {
13209
+ closeSync(fd);
13210
+ }
13211
+ return {
13212
+ ...readKey(path, kind),
13213
+ created: true
13214
+ };
13215
+ }
13216
+ /** What the person is told on stderr when a key was made: where, which, and to back it up. Never the key. */
13217
+ function madeKeyNote(path, kind, key) {
13218
+ return kind === "publisher" ? `new publisher key written to ${path}; back it up (${appFingerprint(key)}: an app is updated only with the key that signed it, and phase 1 has no recovery)` : `new store key written to ${path}; back it up and keep it offline (${appFingerprint(key)}: a store is its key)`;
13219
+ }
13220
+ var refused = (what, reading, extra = {}) => new CliError("refused", `${what} (${reading.reason}${reading.detail ? `: ${reading.detail}` : ""})`, {
13221
+ reason: reading.reason,
13222
+ ...reading.detail ? { detail: reading.detail } : {},
13223
+ ...extra
13224
+ });
13225
+ /** A bundle file's bytes: its size is checked before it is read, so a huge file is refused unread. */
13226
+ function readBundleFile(path) {
13227
+ const info = statSync(path);
13228
+ if (info.isDirectory()) throw new CliError("bad_request", `${path} is a folder: name a .ghostlyapp file`);
13229
+ if (info.size > APP_BUNDLE_LIMITS.bundleBytes) throw refused(`${path} is not a valid bundle`, {
13230
+ reason: "too-large",
13231
+ detail: `${info.size} bytes`
13232
+ });
13233
+ return new Uint8Array(readFileSync(path));
13234
+ }
13235
+ /**
13236
+ * A bundle at an https URL, as a client fetches one: `https:` only, no user or password, jsDelivr only at a full
13237
+ * commit (WISP 1200 · Manifest, "Every URL"); every redirect is held to the same rule, and the download stops past
13238
+ * 16 MiB.
13239
+ */
13240
+ async function fetchBundle(url) {
13241
+ let at = url;
13242
+ for (let hop = 0;; hop++) {
13243
+ if (!isAppUrl(at)) throw new CliError(hop ? "refused" : "usage", hop ? `${url} redirects to ${at}, which a client does not read (https only, no user or password, jsDelivr only at a full commit)` : `Not a URL a client reads: https only, at most ${APP_BUNDLE_LIMITS.url} characters, no user or password, and jsDelivr only as https://cdn.jsdelivr.net/gh/<owner>/<repo>@<40-hex commit>/<path>`);
13244
+ let response;
13245
+ try {
13246
+ response = await fetch(at, {
13247
+ redirect: "manual",
13248
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
13249
+ });
13250
+ } catch (error) {
13251
+ if (error instanceof Error && error.name === "TimeoutError") throw new CliError("timeout", `${at} did not answer in ${FETCH_TIMEOUT_MS / 1e3} s`);
13252
+ throw new CliError("unavailable", `Could not read ${at}: ${error instanceof Error ? error.message : String(error)}`);
13253
+ }
13254
+ if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
13255
+ await response.body?.cancel();
13256
+ if (hop >= MAX_REDIRECTS) throw new CliError("refused", `${url} redirects more than ${MAX_REDIRECTS} times`);
13257
+ at = new URL(response.headers.get("location"), at).href;
13258
+ continue;
13259
+ }
13260
+ if (response.status === 404 || response.status === 410) {
13261
+ await response.body?.cancel();
13262
+ throw new CliError("not_found", `${at} answered ${response.status}`);
13263
+ }
13264
+ if (!response.ok || !response.body) {
13265
+ await response.body?.cancel();
13266
+ throw new CliError("unavailable", `${at} answered ${response.status}`);
13267
+ }
13268
+ const limit = APP_BUNDLE_LIMITS.bundleBytes;
13269
+ const tooLarge = (bytes) => refused(`${url} is not a valid bundle`, {
13270
+ reason: "too-large",
13271
+ detail: `${bytes} bytes`
13272
+ });
13273
+ const length = Number(response.headers.get("content-length"));
13274
+ if (Number.isFinite(length) && length > limit) {
13275
+ await response.body.cancel();
13276
+ throw tooLarge(length);
13277
+ }
13278
+ const chunks = [];
13279
+ let total = 0;
13280
+ const reader = response.body.getReader();
13281
+ try {
13282
+ for (;;) {
13283
+ const { done, value } = await reader.read();
13284
+ if (done) break;
13285
+ total += value.length;
13286
+ if (total > limit) {
13287
+ await reader.cancel();
13288
+ throw tooLarge(`more than ${limit}`);
13289
+ }
13290
+ chunks.push(value);
13291
+ }
13292
+ } catch (error) {
13293
+ if (error instanceof CliError) throw error;
13294
+ if (error instanceof Error && error.name === "TimeoutError") throw new CliError("timeout", `${at} did not finish in ${FETCH_TIMEOUT_MS / 1e3} s`);
13295
+ throw new CliError("unavailable", `Could not read ${at}: ${error instanceof Error ? error.message : String(error)}`);
13296
+ }
13297
+ const out = new Uint8Array(total);
13298
+ let offset = 0;
13299
+ for (const chunk of chunks) {
13300
+ out.set(chunk, offset);
13301
+ offset += chunk.length;
13302
+ }
13303
+ return out;
13304
+ }
13305
+ }
13306
+ /** What a checked bundle is, as `app verify` and `app publish` print it. */
13307
+ function described(bundle, bytes) {
13308
+ const m = bundle.manifest;
13309
+ return {
13310
+ ref: appRef(m.publisher, m.name),
13311
+ publisher: m.publisher,
13312
+ fingerprint: appFingerprint(m.publisher),
13313
+ name: m.name,
13314
+ title: m.title,
13315
+ version: m.version,
13316
+ sequence: m.sequence,
13317
+ digest: bundle.digest,
13318
+ bytes: bytes.length,
13319
+ sha256: toBase64Url$1(createHash("sha256").update(bytes).digest())
13320
+ };
13321
+ }
13322
+ /** `app verify <bundle|url>`: every check a client makes before it stores a bundle (WISP 1200 · Reading a bundle). */
13323
+ async function verifyApp(source) {
13324
+ const bytes = /^[a-z][a-z0-9+.-]*:\/\//i.test(source) ? await fetchBundle(source) : readBundleFile(resolve(source));
13325
+ const read = readAppBundle(bytes);
13326
+ if (!read.ok) throw refused(`${source} is not a valid bundle`, read);
13327
+ return {
13328
+ valid: true,
13329
+ ...described(read.bundle, bytes),
13330
+ manifest: read.bundle.manifest
13331
+ };
13332
+ }
13333
+ /** Every file of the folder, as the bundle names it; dot files and folders are left out and listed. */
13334
+ function collectFiles(dir, leaveOut) {
13335
+ const files = [];
13336
+ const skipped = [];
13337
+ let bytes = 0;
13338
+ const walk = (folder) => {
13339
+ for (const name of readdirSync(folder).sort()) {
13340
+ const full = join(folder, name);
13341
+ const path = relative(dir, full).split(sep).join("/");
13342
+ if (leaveOut.has(full)) continue;
13343
+ if (name.startsWith(".")) {
13344
+ skipped.push(path);
13345
+ continue;
13346
+ }
13347
+ const info = lstatSync(full);
13348
+ if (info.isSymbolicLink()) throw new CliError("bad_request", `${path} is a link: a bundle holds the folder's own files (copy it in, or remove it)`);
13349
+ if (info.isDirectory()) {
13350
+ walk(full);
13351
+ continue;
13352
+ }
13353
+ if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file`);
13354
+ files.push({
13355
+ path,
13356
+ full,
13357
+ size: info.size
13358
+ });
13359
+ bytes += info.size;
13360
+ if (files.length > APP_BUNDLE_LIMITS.files) throw refused(`${dir} holds more files than a bundle may`, {
13361
+ reason: "too-many-files",
13362
+ detail: `more than ${APP_BUNDLE_LIMITS.files}`
13363
+ });
13364
+ if (bytes > APP_BUNDLE_LIMITS.bundleBytes) throw refused(`${dir} holds more than a bundle may`, {
13365
+ reason: "too-large",
13366
+ detail: `more than ${APP_BUNDLE_LIMITS.bundleBytes} bytes of files`
13367
+ });
13368
+ }
13369
+ };
13370
+ walk(dir);
13371
+ return {
13372
+ files,
13373
+ skipped
13374
+ };
13375
+ }
13376
+ /** Fields of `ghostly-app.json` the command writes itself. */
13377
+ var WRITTEN = ["publisher", "files"];
13378
+ function readSource(path) {
13379
+ if (!existsSync(path)) throw new CliError("not_found", `No ${APP_SOURCE} in ${dirname(path)}: it holds the manifest's fields (name, version, title, tagline, entry, permissions, runtime, license, ...) but publisher, sequence and files, which publish writes`);
13380
+ let value;
13381
+ try {
13382
+ value = JSON.parse(readFileSync(path, "utf8"));
13383
+ } catch (error) {
13384
+ throw new CliError("bad_request", `${path} is not JSON: ${error instanceof Error ? error.message : String(error)}`);
13385
+ }
13386
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new CliError("bad_request", `${path} is not a JSON object`);
13387
+ const draft = value;
13388
+ const written = WRITTEN.filter((k) => k in draft);
13389
+ if (written.length) throw new CliError("bad_request", `${path} has ${written.join(" and ")}: publish writes ${written.length > 1 ? "them" : "it"} from the key and the folder's files`);
13390
+ if (draft.ghostlyApp !== void 0 && draft.ghostlyApp !== 1) throw refused(`${path} is not a manifest this release makes`, {
13391
+ reason: "unsupported-format",
13392
+ detail: "ghostlyApp is not 1"
13393
+ });
13394
+ if (draft.sequence !== void 0 && !(Number.isSafeInteger(draft.sequence) && draft.sequence >= 1)) throw new CliError("bad_request", `${path}: sequence is a whole number from 1 (or leave it out: publish raises it by itself)`);
13395
+ return draft;
13396
+ }
13397
+ /** Writes a file whole or not at all: a reader never sees half a bundle (and a dot file left by a crash is never bundled). */
13398
+ function writeWhole(path, bytes) {
13399
+ const tmp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
13400
+ writeFileSync(tmp, bytes);
13401
+ renameSync(tmp, path);
13402
+ }
13403
+ /**
13404
+ * `app publish <dir> --key <file> [--out <file>]`: `<dir>/ghostly-app.json` (the manifest without `publisher`,
13405
+ * `sequence` and `files`) and every other file in `<dir>` become one signed bundle, by default `<dir>/app.ghostlyapp`.
13406
+ * The sequence is `--sequence` (higher than the bundle already at `--out`'s), else one more than that bundle's (or
13407
+ * the source's `sequence`, if higher, else 1). The
13408
+ * key file is made when it is missing and no earlier bundle is there to update.
13409
+ */
13410
+ async function publishApp(dirArg, keyArg, outArg, sequenceArg) {
13411
+ if (sequenceArg !== void 0 && !(Number.isSafeInteger(sequenceArg) && sequenceArg >= 1)) throw new CliError("usage", `--sequence takes a whole number from 1, not ${sequenceArg}`);
13412
+ const dir = resolve(dirArg);
13413
+ if (!statSync(dir).isDirectory()) throw new CliError("bad_request", `${dir} is not a folder`);
13414
+ const keyPath = resolve(keyArg);
13415
+ const out = resolve(outArg ?? join(dir, "app.ghostlyapp"));
13416
+ const sourcePath = join(dir, APP_SOURCE);
13417
+ const draft = readSource(sourcePath);
13418
+ let previous = null;
13419
+ if (existsSync(out)) {
13420
+ const read = readAppBundle(readBundleFile(out));
13421
+ if (!read.ok) throw refused(`${out} is there and is not a valid bundle: move it away, or name another --out`, read);
13422
+ previous = read.bundle.manifest;
13423
+ }
13424
+ let key;
13425
+ if (existsSync(keyPath)) key = readKey(keyPath, "publisher");
13426
+ else if (previous) throw new CliError("not_found", `No key file ${keyPath}: ${out} is signed by ${appFingerprint(previous.publisher)}, and only that key publishes its next version`);
13427
+ else key = makeKey(keyPath, "publisher");
13428
+ if (previous && previous.publisher !== key.key) throw refused(`${out} is ${appRef(previous.publisher, previous.name)}, signed by another key (${appFingerprint(previous.publisher)}): a version of an app is signed by its own key`, { reason: "other-app" }, { publisher: previous.publisher });
13429
+ if (previous && previous.name !== draft.name) throw refused(`${out} is the app ${JSON.stringify(previous.name)}, not ${JSON.stringify(draft.name)}: name another --out for another app`, { reason: "other-app" });
13430
+ if (sequenceArg !== void 0 && previous && sequenceArg <= previous.sequence) throw refused(`--sequence ${sequenceArg} is not higher than the ${previous.sequence} of ${out}: every client refuses a lower one, and the same one with other content is equivocation`, { reason: sequenceArg < previous.sequence ? "rollback" : "equivocation" }, { previous: previous.sequence });
13431
+ const sequence = sequenceArg ?? Math.max(previous ? previous.sequence + 1 : 1, draft.sequence ?? 1);
13432
+ const { files, skipped } = collectFiles(dir, /* @__PURE__ */ new Set([
13433
+ sourcePath,
13434
+ out,
13435
+ join(dir, APP_REVOKE_FILE)
13436
+ ]));
13437
+ const contents = files.map((f) => ({
13438
+ path: f.path,
13439
+ bytes: new Uint8Array(readFileSync(f.full))
13440
+ }));
13441
+ const manifest = {
13442
+ ...draft,
13443
+ ghostlyApp: 1,
13444
+ publisher: key.key,
13445
+ sequence,
13446
+ files: contents.map((f) => ({
13447
+ path: f.path,
13448
+ size: f.bytes.length,
13449
+ sha256: toBase64Url$1(createHash("sha256").update(f.bytes).digest())
13450
+ }))
13451
+ };
13452
+ const checked = checkAppManifest(JSON.parse(JSON.stringify(manifest)));
13453
+ if (!checked.ok) throw refused(`${sourcePath} and the folder's files make no valid manifest`, checked);
13454
+ const { bytes: manifestBytes, signature } = await signAppObject(APP_PREFIXES.app, checked.manifest, key.signer);
13455
+ const bundle = assembleAppBundle(manifestBytes, canonicalJsonBytes(signature), contents.map((f) => f.bytes));
13456
+ const read = readAppBundle(bundle);
13457
+ if (!read.ok) throw refused("The bundle made is not valid", read);
13458
+ mkdirSync(dirname(out), { recursive: true });
13459
+ writeWhole(out, bundle);
13460
+ return {
13461
+ ...described(read.bundle, bundle),
13462
+ previous: previous?.sequence ?? null,
13463
+ out,
13464
+ files: contents.map((f) => f.path),
13465
+ ...skipped.length ? { skipped } : {},
13466
+ keyCreated: key.created,
13467
+ ...key.created ? { warning: madeKeyNote(keyPath, "publisher", key.key) } : {}
13468
+ };
13469
+ }
13470
+ /**
13471
+ * `app revoke <dir> --key <file> (--digest <digest>... | --up-to <sequence>) [--reason <text>] [--bundle <file>]`: a
13472
+ * `ghostly-revoke/1` statement (WISP 1200 · Publisher keys, Revocation) for the app whose bundle is `<dir>/app.ghostlyapp`
13473
+ * (or `--bundle`), signed by that app's publisher key and added to `<dir>/ghostly-revoke.json`, which stays canonical.
13474
+ * The key is never made here: only the key that signed the app may revoke its versions.
13475
+ */
13476
+ async function revokeApp(dirArg, keyArg, what) {
13477
+ const dir = resolve(dirArg);
13478
+ const bundlePath = resolve(what.bundle ?? join(dir, "app.ghostlyapp"));
13479
+ const listPath = join(dir, APP_REVOKE_FILE);
13480
+ const usage = "ghostly app revoke <dir> --key <file> (--digest <digest>... | --up-to <sequence>) [--reason <text>]";
13481
+ const digests = what.digests ?? [];
13482
+ if (digests.length > 0 === (what.upTo !== void 0)) throw new CliError("usage", `Give --digest (again for each) or --up-to, one of the two: ${usage}`);
13483
+ if (what.upTo !== void 0 && !(Number.isSafeInteger(what.upTo) && what.upTo >= 1)) throw new CliError("usage", `--up-to takes a sequence, a whole number from 1, not ${what.upTo}`);
13484
+ if (!existsSync(bundlePath)) throw new CliError("not_found", `No bundle ${bundlePath}: a revocation names the app of the bundle in <dir> (or --bundle)`);
13485
+ const read = readAppBundle(readBundleFile(bundlePath));
13486
+ if (!read.ok) throw refused(`${bundlePath} is not a valid bundle`, read);
13487
+ const manifest = read.bundle.manifest;
13488
+ const ref = appRef(manifest.publisher, manifest.name);
13489
+ const keyPath = resolve(keyArg);
13490
+ if (!existsSync(keyPath)) throw new CliError("not_found", `No key file ${keyPath}: only the key that signed ${ref} (${appFingerprint(manifest.publisher)}) revokes its versions`);
13491
+ const key = readKey(keyPath, "publisher");
13492
+ if (key.key !== manifest.publisher) throw refused(`${keyPath} is not the publisher key of ${ref} (${appFingerprint(manifest.publisher)}): a revocation by any other key is refused by every client`, { reason: "signature-key" });
13493
+ const statement = digests.length ? {
13494
+ ghostlyRevoke: 1,
13495
+ app: ref,
13496
+ digests
13497
+ } : {
13498
+ ghostlyRevoke: 1,
13499
+ app: ref,
13500
+ upTo: what.upTo
13501
+ };
13502
+ if (what.reason !== void 0) statement.reason = what.reason;
13503
+ const problem = checkAppRevokeStatement(statement);
13504
+ if (problem) throw refused("Not a revocation a client reads", {
13505
+ reason: "bad-revocation",
13506
+ detail: problem
13507
+ });
13508
+ let list = [];
13509
+ if (existsSync(listPath)) {
13510
+ const held = readAppRevocations(new Uint8Array(readFileSync(listPath)));
13511
+ if (!held.ok) throw refused(`${listPath} is there and is not a valid ghostly-revoke.json: fix or move it first`, held);
13512
+ list = held.revocations;
13513
+ }
13514
+ const signed = await signAppRevocation(statement, key.signer);
13515
+ const same = list.some((r) => canonicalJson(r.statement) === canonicalJson(signed.statement));
13516
+ if (!same) {
13517
+ if (list.length >= APP_STATEMENT_LIMITS.revocations) throw refused(`${listPath} holds ${list.length} revocations already`, {
13518
+ reason: "bad-revocation",
13519
+ detail: `at most ${APP_STATEMENT_LIMITS.revocations}`
13520
+ });
13521
+ list = [...list, signed];
13522
+ const bytes = canonicalJsonBytes(list);
13523
+ const check = readAppRevocations(bytes);
13524
+ if (!check.ok) throw refused("The revocation list made is not valid", check);
13525
+ writeWhole(listPath, bytes);
13526
+ }
13527
+ return {
13528
+ app: ref,
13529
+ ...digests.length ? {
13530
+ digests,
13531
+ current: digests.includes(read.bundle.digest)
13532
+ } : {
13533
+ upTo: what.upTo,
13534
+ current: manifest.sequence <= what.upTo
13535
+ },
13536
+ ...what.reason !== void 0 ? { reason: what.reason } : {},
13537
+ added: !same,
13538
+ revocations: list.length,
13539
+ file: listPath
13540
+ };
13541
+ }
13542
+ /**
13543
+ * `store sign <index> --key <file> [--out <dir>]`: a store index written by hand (any JSON layout; `key` may be left
13544
+ * out and is then the signing key's) becomes `ghostly-store.json`, canonical, and `ghostly-store.sig`, in `--out` or
13545
+ * beside the index. It is read back as a client reads it (`expires` at most 90 days ahead), and refused when the index
13546
+ * already there, signed by the same key, has a higher `sequence`, or the same one with other content.
13547
+ */
13548
+ async function signStore(indexArg, keyArg, outArg, now = Math.floor(Date.now() / 1e3)) {
13549
+ const indexPath = resolve(indexArg);
13550
+ const keyPath = resolve(keyArg);
13551
+ const outDir = resolve(outArg ?? dirname(indexPath));
13552
+ let value;
13553
+ try {
13554
+ value = JSON.parse(readFileSync(indexPath, "utf8"));
13555
+ } catch (error) {
13556
+ if (error instanceof SyntaxError) throw new CliError("bad_request", `${indexPath} is not JSON: ${error.message}`);
13557
+ throw error;
13558
+ }
13559
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new CliError("bad_request", `${indexPath} is not a JSON object`);
13560
+ const index = value;
13561
+ let key;
13562
+ if (existsSync(keyPath)) key = readKey(keyPath, "store");
13563
+ else if (index.key !== void 0) throw new CliError("not_found", `No key file ${keyPath}: the index names the store key ${typeof index.key === "string" ? appFingerprint(index.key) : JSON.stringify(index.key)}`);
13564
+ else key = makeKey(keyPath, "store");
13565
+ if (index.key !== void 0 && index.key !== key.key) throw refused(`${indexPath} names another store key than ${keyPath}'s (${appFingerprint(key.key)})`, { reason: "store-key" });
13566
+ index.key = key.key;
13567
+ const checked = checkAppStoreIndex(index);
13568
+ if (!checked.ok) throw refused(`${indexPath} is not a valid store index`, checked);
13569
+ const { indexBytes, sigBytes } = await signAppStore(index, key.signer);
13570
+ const read = readAppStore(indexBytes, sigBytes, now, key.key);
13571
+ if (!read.ok) throw refused(`${indexPath} would be refused by a client`, read);
13572
+ const store = read.store;
13573
+ const indexOut = join(outDir, STORE_INDEX_FILE), sigOut = join(outDir, STORE_SIG_FILE);
13574
+ let previous = null;
13575
+ if (existsSync(indexOut) && existsSync(sigOut)) {
13576
+ const held = readAppStore(new Uint8Array(readFileSync(indexOut)), new Uint8Array(readFileSync(sigOut)), now);
13577
+ if (held.ok && held.store.index.key === key.key) {
13578
+ previous = held.store.index.sequence;
13579
+ const decision = appStoreDecision({
13580
+ key: key.key,
13581
+ sequence: held.store.index.sequence,
13582
+ digest: held.store.digest
13583
+ }, {
13584
+ key: key.key,
13585
+ sequence: store.index.sequence,
13586
+ digest: store.digest
13587
+ });
13588
+ if (decision === "rollback" || decision === "equivocation") throw refused(`${indexOut} already holds sequence ${held.store.index.sequence} of this store: ${decision === "rollback" ? "a lower one is refused by every client" : "the same one with other content is refused as equivocation"}; raise sequence`, { reason: decision }, { held: held.store.index.sequence });
13589
+ }
13590
+ }
13591
+ mkdirSync(outDir, { recursive: true });
13592
+ writeWhole(indexOut, indexBytes);
13593
+ writeWhole(sigOut, sigBytes);
13594
+ const notes = [...key.created ? [madeKeyNote(keyPath, "store", key.key)] : [], ...store.expired ? [`expires (${(/* @__PURE__ */ new Date(store.index.expires * 1e3)).toISOString()}) is past: clients still install from it and say the store was not updated since then`] : []];
13595
+ return {
13596
+ key: key.key,
13597
+ fingerprint: appFingerprint(key.key),
13598
+ name: store.index.name,
13599
+ kind: store.index.kind,
13600
+ sequence: store.index.sequence,
13601
+ previous,
13602
+ expires: store.index.expires,
13603
+ expired: store.expired,
13604
+ apps: store.index.apps.length,
13605
+ removed: store.index.removed.length,
13606
+ revoked: store.index.revoked.length,
13607
+ digest: store.digest,
13608
+ index: indexOut,
13609
+ sig: sigOut,
13610
+ keyCreated: key.created,
13611
+ ...notes.length ? { warning: notes.join("; ") } : {}
13612
+ };
13613
+ }
13614
+ //#endregion
13615
+ //#region src/commands/apps.ts
13616
+ /** The key file a command signs with: always named, never a default, never on the command line itself. */
13617
+ function keyFile(value, usage) {
13618
+ if (typeof value !== "string" || !value) throw new CliError("usage", `--key <file> is needed: ghostly ${usage}`);
13619
+ return value;
13620
+ }
13621
+ var key = {
13622
+ type: "string",
13623
+ description: "The key file (made, owner-only, when it is missing)"
13624
+ };
13625
+ /**
13626
+ * The publisher tools of WISP 1200 (Apps · Publishing): they run here, on files, with no profile or daemon. One entry
13627
+ * per command, in alphabetical order (test/commands.test.ts checks).
13628
+ */
13629
+ var commands$8 = {
13630
+ "app publish": {
13631
+ method: "app.publish",
13632
+ usage: "app publish <dir> --key <file> [--out <file>]",
13633
+ summary: "Bundle and sign an app (WISP 1200): <dir>/ghostly-app.json and every other file in <dir> become one .ghostlyapp, its sequence one more than the bundle at --out (default <dir>/app.ghostlyapp)",
13634
+ args: ["dir"],
13635
+ options: {
13636
+ key: {
13637
+ ...key,
13638
+ description: "The publisher key file (made, owner-only, on first use; back it up)"
13639
+ },
13640
+ out: {
13641
+ type: "string",
13642
+ description: "Where the bundle goes (default <dir>/app.ghostlyapp); the bundle there is the version this one follows"
13643
+ },
13644
+ sequence: {
13645
+ type: "number",
13646
+ description: "This version's sequence, higher than the bundle at --out's (default: one more than it)"
13647
+ }
13648
+ },
13649
+ run: ({ options }, a) => publishApp(a.dir, keyFile(options.key, "app publish <dir> --key <file> [--out <file>] [--sequence n]"), options.out, options.sequence)
13650
+ },
13651
+ "app revoke": {
13652
+ method: "app.revoke",
13653
+ usage: "app revoke <dir> --key <file> (--digest <digest>... | --up-to <sequence>) [--reason <text>] [--bundle <file>]",
13654
+ summary: "Revoke versions of your app (WISP 1200 · Revocation): a ghostly-revoke/1 statement signed with its publisher key, added to <dir>/ghostly-revoke.json",
13655
+ args: ["dir"],
13656
+ options: {
13657
+ key: {
13658
+ type: "string",
13659
+ description: "The app's publisher key file (never made here)"
13660
+ },
13661
+ digest: {
13662
+ type: "list",
13663
+ description: "A version's digest, base64url as app verify prints it (again for each, up to 64)"
13664
+ },
13665
+ "up-to": {
13666
+ type: "number",
13667
+ description: "Every version with this sequence or a lower one"
13668
+ },
13669
+ reason: {
13670
+ type: "string",
13671
+ description: "Why, one line of up to 200 characters, shown to people"
13672
+ },
13673
+ bundle: {
13674
+ type: "string",
13675
+ description: "The bundle naming the app (default <dir>/app.ghostlyapp)"
13676
+ }
13677
+ },
13678
+ run: ({ options }, a) => revokeApp(a.dir, keyFile(options.key, "app revoke <dir> --key <file> (--digest <digest>... | --up-to <sequence>) [--reason <text>]"), {
13679
+ digests: options.digest,
13680
+ upTo: options["up-to"],
13681
+ reason: options.reason,
13682
+ bundle: options.bundle
13683
+ })
13684
+ },
13685
+ "app verify": {
13686
+ method: "app.verify",
13687
+ usage: "app verify <bundle|url>",
13688
+ summary: "Check a .ghostlyapp as a client would, from a file or an https URL: its manifest and digest, or refused (exit 1) with the reason",
13689
+ args: ["bundle"],
13690
+ run: (_, a) => verifyApp(a.bundle)
13691
+ },
13692
+ "store sign": {
13693
+ method: "store.sign",
13694
+ usage: "store sign <index> --key <file> [--out <dir>]",
13695
+ summary: "Sign a store index (WISP 1200 · Stores): writes ghostly-store.json, canonical, and ghostly-store.sig beside it or in --out",
13696
+ args: ["index"],
13697
+ options: {
13698
+ key: {
13699
+ ...key,
13700
+ description: "The store key file (made, owner-only, when it is missing and the index names no key; keep it offline)"
13701
+ },
13702
+ out: {
13703
+ type: "string",
13704
+ description: "The folder to write ghostly-store.json and ghostly-store.sig in (default: the index's)"
13705
+ }
13706
+ },
13707
+ run: ({ options }, a) => signStore(a.index, keyFile(options.key, "store sign <index> --key <file> [--out <dir>]"), options.out)
13708
+ }
13709
+ };
13710
+ //#endregion
12209
13711
  //#region src/commands/shared.ts
12210
13712
  var wait = {
12211
13713
  wait: {
@@ -12556,6 +14058,82 @@ var taskOptions = {
12556
14058
  },
12557
14059
  ...cardOptions
12558
14060
  };
14061
+ /**
14062
+ * `--also week=80` or `--also week=80@2026-10-10T00:00:00Z`: another window of a usage card, its percent left and, after
14063
+ * `@`, when it resets.
14064
+ */
14065
+ function usageWindowOf(value) {
14066
+ const match = /^([^=]+)=(\d+(?:\.\d+)?)(?:@(.+))?$/.exec(value.trim());
14067
+ if (!match) throw new CliError("usage", `--also takes window=percent or window=percent@time, like week=80@2026-10-10T00:00:00Z, not ${JSON.stringify(value)}`);
14068
+ const resetsAt = cardTime(match[3], "also");
14069
+ return {
14070
+ window: match[1].trim(),
14071
+ left: Number(match[2]),
14072
+ ...resetsAt !== void 0 && { resetsAt }
14073
+ };
14074
+ }
14075
+ /** A usage card's fields from its flags (WISP 405 § Usage); `--json` gives any of them, the flags win over it. */
14076
+ function usageFields(options) {
14077
+ const fields = { ...cardJson(options.json) };
14078
+ for (const key of [
14079
+ "id",
14080
+ "left",
14081
+ "used",
14082
+ "limit",
14083
+ "label",
14084
+ "account",
14085
+ "window"
14086
+ ]) if (options[key] !== void 0) fields[key] = options[key];
14087
+ const resets = cardTime(options.resets, "resets");
14088
+ if (resets !== void 0) fields.resetsAt = resets;
14089
+ if (Array.isArray(options.also) && options.also.length) fields.windows = options.also.map(usageWindowOf);
14090
+ return fields;
14091
+ }
14092
+ /** Options `usage send` takes. */
14093
+ var usageOptions = {
14094
+ left: {
14095
+ type: "number",
14096
+ description: "Percent of the quota left, 0 to 100"
14097
+ },
14098
+ used: {
14099
+ type: "number",
14100
+ description: "How much is used, with --limit (instead of --left; the percent is worked out)"
14101
+ },
14102
+ limit: {
14103
+ type: "number",
14104
+ description: "The quota, with --used"
14105
+ },
14106
+ label: {
14107
+ type: "string",
14108
+ description: "What the quota is of, like Claude (24 characters)"
14109
+ },
14110
+ account: {
14111
+ type: "string",
14112
+ description: "Which of your accounts it is, short (24 characters)"
14113
+ },
14114
+ window: {
14115
+ type: "string",
14116
+ description: "The quota's window, as people say it: \"5 h\", \"week\" (16 characters)"
14117
+ },
14118
+ resets: {
14119
+ type: "string",
14120
+ description: "When the window starts again: milliseconds or a date like 2026-10-07T18:00:00Z"
14121
+ },
14122
+ also: {
14123
+ type: "list",
14124
+ description: "Another window, window=percent[@time], like week=80@2026-10-10T00:00:00Z (up to 3; shown in the details)"
14125
+ },
14126
+ id: {
14127
+ type: "string",
14128
+ description: "The card's id (default usage: one card per chat)"
14129
+ },
14130
+ all: {
14131
+ type: "boolean",
14132
+ description: "Every 1:1 chat with a contact, instead of one chat"
14133
+ },
14134
+ json: cardOptions.json,
14135
+ text: cardOptions.text
14136
+ };
12559
14137
  //#endregion
12560
14138
  //#region src/commands/calls.ts
12561
14139
  /** Voice calls: one entry per command, in alphabetical order (test/commands.test.ts checks). */
@@ -13054,6 +14632,27 @@ var commands$6 = {
13054
14632
  status: options.status,
13055
14633
  for: options.for
13056
14634
  })
14635
+ },
14636
+ "usage send": {
14637
+ method: "usage.send",
14638
+ usage: "usage send [<chat|group>] [--all] --left <percent> [--window \"5 h\"] [--resets <time>] [--label Claude] [--account <name>] [--also week=80@<time>]... [--used n --limit n] [--id id] [--json json|-|file] [--wait none|sent] [--timeout s]",
14639
+ summary: "Say how much of your quota is left (a bot's usage card, one per chat, updated in place); --all for every 1:1 chat",
14640
+ args: ["chat?"],
14641
+ options: {
14642
+ ...usageOptions,
14643
+ ...groupWait
14644
+ },
14645
+ params: ({ options }, { chat }) => {
14646
+ if (!chat && options.all !== true) throw new CliError("usage", "Name a chat or group, or give --all: ghostly usage send <chat|--all> --left <percent>");
14647
+ return {
14648
+ chat,
14649
+ all: options.all === true,
14650
+ card: usageFields(options),
14651
+ text: options.text,
14652
+ wait: options.wait,
14653
+ timeout: options.timeout
14654
+ };
14655
+ }
13057
14656
  }
13058
14657
  };
13059
14658
  //#endregion
@@ -13063,7 +14662,7 @@ var commands$5 = {
13063
14662
  "file accept": {
13064
14663
  method: "file.action",
13065
14664
  usage: "file accept [<chat>] <file>",
13066
- summary: "Take a file the contact offers",
14665
+ summary: "Take a file the contact offers, or download a group's file that did not come by itself (<chat> may be the group)",
13067
14666
  args: ["chat?", "file"],
13068
14667
  params: (_, a) => ({
13069
14668
  chat: a.chat,
@@ -13095,8 +14694,8 @@ var commands$5 = {
13095
14694
  },
13096
14695
  "file list": {
13097
14696
  method: "file.list",
13098
- usage: "file list <chat>",
13099
- summary: "A chat's files and their transfers",
14697
+ usage: "file list <chat|group>",
14698
+ summary: "A chat's or a group's files and their transfers",
13100
14699
  args: ["chat"],
13101
14700
  params: (_, { chat }) => ({ chat })
13102
14701
  },
@@ -13114,7 +14713,7 @@ var commands$5 = {
13114
14713
  "file request": {
13115
14714
  method: "file.action",
13116
14715
  usage: "file request [<chat>] <file>",
13117
- summary: "Ask again for a file that stopped arriving: it goes on from what is here",
14716
+ summary: "Ask again for a file that stopped arriving: it goes on from what is here (a group's file: asked of whoever holds it)",
13118
14717
  args: ["chat?", "file"],
13119
14718
  params: (_, a) => ({
13120
14719
  chat: a.chat,
@@ -13183,13 +14782,13 @@ var commands$5 = {
13183
14782
  },
13184
14783
  "file send": {
13185
14784
  method: "file.send",
13186
- usage: "file send <chat> <path> [--name n] [--mime t] [--voice [ms] [--peaks 0,40,…]] [--reply <message>]",
13187
- summary: "Send a file (or, with --voice, a voice note), as a reply with --reply",
14785
+ usage: "file send <chat|group> <path> [--name n] [--mime t] [--voice [ms] [--peaks 0,40,…]] [--reply <message>]",
14786
+ summary: `Send a file (or, with --voice, a voice note), as a reply with --reply; to a group, every member's app fetches it (up to ${GROUP_FILE_LIMITS.maxBytes / 1024 / 1024} MiB)`,
13188
14787
  args: ["chat", "path"],
13189
14788
  options: {
13190
14789
  name: {
13191
14790
  type: "string",
13192
- description: "The name the contact sees"
14791
+ description: "The name the contact (or the group) sees"
13193
14792
  },
13194
14793
  mime: {
13195
14794
  type: "string",
@@ -14055,7 +15654,8 @@ var COMMANDS = {
14055
15654
  ...commands$1,
14056
15655
  ...commands$2,
14057
15656
  ...commands,
14058
- ...commands$7
15657
+ ...commands$7,
15658
+ ...commands$8
14059
15659
  };
14060
15660
  /** Commands that take text (argument or stdin), with the secret guard and delivery waits. */
14061
15661
  var TEXT_COMMANDS = {
@@ -14668,10 +16268,11 @@ function loadCallStack() {
14668
16268
  }
14669
16269
  /**
14670
16270
  * The ICE servers of a call, as libdatachannel takes them: the apps' STUN servers (RTC_CONFIG; libjuice uses the
14671
- * first), then the profile's own (Settings `iceServers`: a TURN relay, typically), as the apps' calls use them.
16271
+ * first; none with `GHOSTLY_STUN=0`), then the profile's own (Settings `iceServers`: a TURN relay, typically), as the
16272
+ * apps' calls use them.
14672
16273
  */
14673
- function callIceServers(extra = []) {
14674
- const out = (RTC_CONFIG.iceServers ?? []).flatMap((server) => [server.urls].flat()).slice(0, 2);
16274
+ function callIceServers(extra = [], env = process.env) {
16275
+ const out = env.GHOSTLY_STUN === "0" ? [] : (RTC_CONFIG.iceServers ?? []).flatMap((server) => [server.urls].flat()).slice(0, 2);
14675
16276
  for (const server of callRtcConfig(extra).iceServers?.slice(RTC_CONFIG.iceServers?.length ?? 0) ?? []) for (const url of [server.urls].flat()) {
14676
16277
  const m = /^(stun|turns?):([^:?\s]+)(?::(\d+))?(?:\?transport=(udp|tcp))?$/i.exec(url);
14677
16278
  if (!m) continue;
@@ -15734,8 +17335,11 @@ var EventHub = class {
15734
17335
  const shape = `${transfer.state}|${transfer.stage ?? ""}`, before = this.transfers.get(id);
15735
17336
  this.transfers.set(id, shape);
15736
17337
  if (quiet || before === shape) continue;
17338
+ const chat = state.links.find((l) => id.startsWith(`${l.id}-`))?.id ?? null;
17339
+ const group = chat ? void 0 : state.groups.find((g) => id.startsWith(`group-${g.id}-`))?.id;
15737
17340
  const fields = {
15738
- chat: state.links.find((l) => id.startsWith(`${l.id}-`))?.id ?? null,
17341
+ chat,
17342
+ ...group ? { group } : {},
15739
17343
  file: id,
15740
17344
  direction: transfer.direction ?? (id.includes("-in-") ? "in" : "out"),
15741
17345
  size: transfer.size,
@@ -16050,6 +17654,203 @@ function groupShape(group) {
16050
17654
  };
16051
17655
  }
16052
17656
  //#endregion
17657
+ //#region ../browser/src/platform/irohWeb.ts
17658
+ /**
17659
+ * Iroh (WISP 102) in a browser: the same QUIC/TLS session and binding as the
17660
+ * Desktop's native Iroh, but relay only. A page cannot send UDP, so every
17661
+ * packet goes through an Iroh relay over a WebSocket; the relay sees who talks
17662
+ * to whom and when, never the frames.
17663
+ *
17664
+ * The wasm (about 1 MB gzip) loads on the first chat that starts an endpoint,
17665
+ * never on the app's first load.
17666
+ */
17667
+ /**
17668
+ * n0's public relays, the same ones the Desktop's Iroh homes on by default. Iroh itself names them with the trailing
17669
+ * dot of a full domain name (`use1-1.relay.n0.iroh.link.`), and compares relay URLs as text; here they have none,
17670
+ * because WebKit opens no host name that ends in a dot. `BROWSER_SPELLING` is what keeps the two one relay.
17671
+ */
17672
+ var DEFAULT_IROH_RELAYS = [
17673
+ "https://use1-1.relay.n0.iroh.link/",
17674
+ "https://euc1-1.relay.n0.iroh.link/",
17675
+ "https://aps1-1.relay.n0.iroh.link/",
17676
+ "https://usw1-1.relay.n0.iroh.link/"
17677
+ ];
17678
+ /**
17679
+ * The spelling of every relay URL handed to the wasm Iroh (`irohRelayUrl`): the relays it homes on and a contact's
17680
+ * relay before a dial. One spelling, so a contact on this endpoint's relay is reached over the connection already
17681
+ * open to it; without the dot, so Safari can open it (a Desktop's record names its relay with the dot).
17682
+ */
17683
+ var BROWSER_SPELLING = "plain";
17684
+ /**
17685
+ * Why a relay URL cannot be used, or null. TLS, except on this machine (a local `iroh-relay --dev`): a page
17686
+ * may not open plain WebSockets elsewhere, and the relay's operator would see traffic metadata in the clear.
17687
+ */
17688
+ function irohRelayProblem(value) {
17689
+ let url;
17690
+ try {
17691
+ url = new URL(value);
17692
+ } catch {
17693
+ return `Not a relay address: ${value}`;
17694
+ }
17695
+ const local = [
17696
+ "localhost",
17697
+ "127.0.0.1",
17698
+ "[::1]"
17699
+ ].includes(url.hostname);
17700
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) return `Use an https:// relay address: ${value}`;
17701
+ if (url.username || url.password || url.search || url.hash || value.length > 256) return `Not a relay address: ${value}`;
17702
+ return null;
17703
+ }
17704
+ var loaded = null;
17705
+ /** Loads the wasm once per page. Tests pass their own `load` (Node reads the file). */
17706
+ function loadIrohWasm() {
17707
+ loaded ??= import("./assets/ghostly_iroh_web-Dvzh3Y6E.js").then(async (module) => {
17708
+ await module.default();
17709
+ return module;
17710
+ }).catch((error) => {
17711
+ loaded = null;
17712
+ throw error;
17713
+ });
17714
+ return loaded;
17715
+ }
17716
+ var SEND_BUDGET = 122880;
17717
+ var MAX_FRAME = 61440;
17718
+ /** One paired channel over one QUIC stream. Sends are queued in order. */
17719
+ var IrohWebChannel = class {
17720
+ conn;
17721
+ forget;
17722
+ reader = null;
17723
+ buffered = [];
17724
+ queue = Promise.resolve();
17725
+ closed = false;
17726
+ bufferedAmount = 0;
17727
+ sendBudget = SEND_BUDGET;
17728
+ onClose = null;
17729
+ constructor(conn, forget) {
17730
+ this.conn = conn;
17731
+ this.forget = forget;
17732
+ this.read();
17733
+ }
17734
+ get onMessage() {
17735
+ return this.reader;
17736
+ }
17737
+ set onMessage(reader) {
17738
+ this.reader = reader;
17739
+ if (reader) for (const text of this.buffered.splice(0)) reader(text);
17740
+ }
17741
+ async read() {
17742
+ try {
17743
+ for (;;) {
17744
+ const text = await this.conn.recv();
17745
+ if (text === void 0 || this.closed) break;
17746
+ if (this.reader) this.reader(text);
17747
+ else if (this.buffered.length < 64) this.buffered.push(text);
17748
+ else break;
17749
+ }
17750
+ } catch {}
17751
+ this.close();
17752
+ }
17753
+ send(data) {
17754
+ if (typeof data !== "string" || this.closed || !data.length || data.length > MAX_FRAME || this.bufferedAmount + data.length > SEND_BUDGET) throw new Error("Iroh channel is closed or its send budget is full");
17755
+ this.bufferedAmount += data.length;
17756
+ this.queue = this.queue.then(() => this.closed ? void 0 : this.conn.send(data)).catch(() => this.close()).finally(() => {
17757
+ this.bufferedAmount -= data.length;
17758
+ });
17759
+ }
17760
+ drained() {
17761
+ return this.queue;
17762
+ }
17763
+ get rttMs() {
17764
+ const rtt = this.conn.rtt();
17765
+ return rtt >= 0 ? rtt : void 0;
17766
+ }
17767
+ close() {
17768
+ if (this.closed) return;
17769
+ this.closed = true;
17770
+ this.buffered = [];
17771
+ try {
17772
+ this.conn.close();
17773
+ } catch {}
17774
+ this.forget();
17775
+ this.onClose?.();
17776
+ }
17777
+ };
17778
+ /** A NativeEndpoint for `iroh/1`, as the Desktop's is, for one chat's transport seed. */
17779
+ async function createIrohWebEndpoint(seedB64, options = {}) {
17780
+ const seed = fromBase64Url$1(seedB64);
17781
+ if (seed.length !== 32) throw new Error("Invalid transport seed");
17782
+ const relays = [...new Set((options.relays?.length ? options.relays : DEFAULT_IROH_RELAYS).map((relay) => irohRelayUrl(relay, BROWSER_SPELLING)))];
17783
+ const node = await (await (options.load ?? loadIrohWasm)()).IrohNode.start(seed, relays, options.onlineMs ?? 1e4);
17784
+ const channels = /* @__PURE__ */ new Set();
17785
+ let stopped = false;
17786
+ let handler = null;
17787
+ const early = [];
17788
+ const bind = (conn) => {
17789
+ const channel = new IrohWebChannel(conn, () => channels.delete(channel));
17790
+ channels.add(channel);
17791
+ return {
17792
+ channel,
17793
+ binding: conn.binding()
17794
+ };
17795
+ };
17796
+ const endpoint = {
17797
+ transport: "iroh/1",
17798
+ descriptor: {
17799
+ ...node.address(),
17800
+ relayed: true
17801
+ },
17802
+ onDescriptor: null,
17803
+ get onConnection() {
17804
+ return handler;
17805
+ },
17806
+ set onConnection(value) {
17807
+ handler = value;
17808
+ if (value) for (const bound of early.splice(0)) value(bound);
17809
+ },
17810
+ async connect(descriptor) {
17811
+ if (stopped) throw new Error("Iroh endpoint is stopped");
17812
+ const conn = await node.connect(irohDescriptor(descriptor, BROWSER_SPELLING), options.connectMs ?? 2e4);
17813
+ if (stopped) {
17814
+ conn.close();
17815
+ throw new Error("Iroh endpoint is stopped");
17816
+ }
17817
+ return bind(conn);
17818
+ },
17819
+ async close() {
17820
+ if (stopped) return;
17821
+ stopped = true;
17822
+ for (const channel of [...channels]) channel.close();
17823
+ for (const bound of early.splice(0)) bound.channel.close();
17824
+ await node.close().catch(() => {});
17825
+ }
17826
+ };
17827
+ (async () => {
17828
+ while (!stopped) {
17829
+ let conn;
17830
+ try {
17831
+ conn = await node.accept();
17832
+ } catch {
17833
+ break;
17834
+ }
17835
+ if (!conn) break;
17836
+ if (stopped) {
17837
+ conn.close();
17838
+ break;
17839
+ }
17840
+ const bound = bind(conn);
17841
+ const accept = endpoint.onConnection;
17842
+ if (accept) accept(bound);
17843
+ else if (early.length < 4) early.push(bound);
17844
+ else bound.channel.close();
17845
+ }
17846
+ if (!stopped) {
17847
+ endpoint.onUnavailable?.();
17848
+ await endpoint.close();
17849
+ }
17850
+ })();
17851
+ return endpoint;
17852
+ }
17853
+ //#endregion
16053
17854
  //#region ../browser/src/engine/paymentAdapters/fedimintSdk.ts
16054
17855
  var fedimintDatabase = (id) => `ghostly-fedimint-${id}.db`;
16055
17856
  var MODULE_WASM = () => import("./assets/fedimint_client_wasm_bg-BtHHlFpp.js");
@@ -16936,6 +18737,39 @@ function pkarrRelays(env = process.env) {
16936
18737
  return relays;
16937
18738
  }
16938
18739
  /**
18740
+ * The Iroh relays of a private network or a test, `GHOSTLY_IROH_RELAYS` ("https://…,…"; http:// on loopback): the only
18741
+ * relays this process's Iroh homes on, whatever the profile's `irohRelays` setting says. Null when unset (the setting,
18742
+ * else n0's public relays).
18743
+ */
18744
+ function irohRelays(env = process.env) {
18745
+ const relays = (env.GHOSTLY_IROH_RELAYS ?? "").split(",").map((url) => url.trim()).filter(Boolean);
18746
+ if (!relays.length) return null;
18747
+ const bad = relays.map((url) => irohRelayProblem(url)).find(Boolean);
18748
+ if (bad) throw new CliError("usage", `GHOSTLY_IROH_RELAYS: ${bad}`);
18749
+ return relays;
18750
+ }
18751
+ /**
18752
+ * The apps' public STUN servers (Google's, `RTC_CONFIG`), which every WebRTC connection asks for its public address:
18753
+ * `GHOSTLY_STUN=0` leaves them out (a private network, or tests on one machine, where host candidates connect). The
18754
+ * profile's own `iceServers` stay.
18755
+ */
18756
+ function publicStun(env = process.env) {
18757
+ return env.GHOSTLY_STUN !== "0";
18758
+ }
18759
+ /** An RTC configuration without the apps' public STUN servers. */
18760
+ function withoutPublicStun(config) {
18761
+ if (!config?.iceServers) return config;
18762
+ const ours = new Set((RTC_CONFIG.iceServers ?? []).flatMap((server) => [server.urls].flat()));
18763
+ const iceServers = config.iceServers.map((server) => ({
18764
+ ...server,
18765
+ urls: [server.urls].flat().filter((url) => !ours.has(url))
18766
+ })).filter((server) => server.urls.length);
18767
+ return {
18768
+ ...config,
18769
+ iceServers
18770
+ };
18771
+ }
18772
+ /**
16939
18773
  * The Mainline DHT, for Pkarr beside the relays (`RelaysAndDht`): `GHOSTLY_DHT=0` leaves it out (relays only, as the web
16940
18774
  * app), `GHOSTLY_DHT_BOOTSTRAP` ("host:port,…") replaces the public routers (a testnet; one all on loopback binds there).
16941
18775
  * `GHOSTLY_PKARR_RELAYS` without a bootstrap leaves it out too, as on the Desktop: a private network stays off the
@@ -16971,12 +18805,43 @@ async function installWebRtc() {
16971
18805
  "RTCPeerConnectionIceEvent",
16972
18806
  "RTCCertificate"
16973
18807
  ]) scope[name] ??= polyfill[name];
18808
+ refuseDataAnswers(scope);
18809
+ if (!publicStun()) leaveOutPublicStun(scope);
16974
18810
  return true;
16975
18811
  } catch (error) {
16976
18812
  process.stderr.write(`ghostly: WebRTC is unavailable (${error instanceof Error ? error.message : String(error)}); chats use HyperDHT, Iroh or the DHT\n`);
16977
18813
  return false;
16978
18814
  }
16979
18815
  }
18816
+ /**
18817
+ * For tests: `GHOSTLY_TEST_REFUSE_DATA_ANSWERS=<n>` refuses one in every n answers this process applies to its offers
18818
+ * (the first, the n+1st, and so on), as libdatachannel 0.24.5 refuses one in its race (`ANSWER_REFUSED_REDIALS` in
18819
+ * packages/core/src/datalink.ts): the connection closes, and setRemoteDescription throws.
18820
+ */
18821
+ function refuseDataAnswers(scope) {
18822
+ const every = Number(process.env.GHOSTLY_TEST_REFUSE_DATA_ANSWERS) || 0;
18823
+ if (every <= 0) return;
18824
+ let answers = 0;
18825
+ const Base = scope.RTCPeerConnection;
18826
+ scope.RTCPeerConnection = class extends Base {
18827
+ async setRemoteDescription(description) {
18828
+ if (description.type === "answer" && answers++ % every === 0) {
18829
+ this.close();
18830
+ throw new Error("libdatachannel error while adding remote description: Got a remote candidate without ICE transport (GHOSTLY_TEST_REFUSE_DATA_ANSWERS)");
18831
+ }
18832
+ return super.setRemoteDescription(description);
18833
+ }
18834
+ };
18835
+ }
18836
+ /** `GHOSTLY_STUN=0`: every connection the engine makes leaves the apps' public STUN servers out of its configuration. */
18837
+ function leaveOutPublicStun(scope) {
18838
+ const Base = scope.RTCPeerConnection;
18839
+ scope.RTCPeerConnection = class extends Base {
18840
+ constructor(config) {
18841
+ super(withoutPublicStun(config));
18842
+ }
18843
+ };
18844
+ }
16980
18845
  /** Server-sent events, which browsers have and Node does not: Arkade follows its server with them. */
16981
18846
  async function installEventSource() {
16982
18847
  const scope = globalThis;
@@ -17017,13 +18882,17 @@ function leftOutIn(mark) {
17017
18882
  }
17018
18883
  async function startRuntime(paths, options = {}) {
17019
18884
  const pinned = pkarrRelays();
18885
+ const pinnedIroh = irohRelays();
17020
18886
  const trace = process.env.GHOSTLY_LINK_TRACE;
17021
- if (trace) setLinkTraceSink((line) => appendFileSync(trace, line + "\n", { mode: 384 }));
18887
+ if (trace) {
18888
+ setLinkTraceSink((line) => appendFileSync(trace, line + "\n", { mode: 384 }));
18889
+ (await import("./assets/joinTrace-CO8mlhNO.js").then((n) => n.t)).setJoinTraceSink((line) => appendFileSync(trace, line + "\n", { mode: 384 }));
18890
+ }
17022
18891
  const store = await openPersistentIndexedDb(paths.db);
17023
18892
  const restored = join(paths.dir, RESTORED_MARK);
17024
18893
  if (existsSync(restored)) {
17025
18894
  try {
17026
- const rows = await import("./assets/restoredRows-C1O9FfA4.js");
18895
+ const rows = await import("./assets/restoredRows-lhTKGmCO.js");
17027
18896
  await rows.markRestoredWallet();
17028
18897
  await rows.markLeftOutFiles(leftOutIn(restored));
17029
18898
  await store.compact();
@@ -17042,19 +18911,20 @@ async function startRuntime(paths, options = {}) {
17042
18911
  await installEventSource();
17043
18912
  await preloadWalletModules();
17044
18913
  (await import("./assets/ghostly_iroh_web-Dvzh3Y6E.js")).initSync({ module: irohWasmBytes() });
17045
- const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-DxiJHmTk.js").then((n) => n.o), import("./assets/fileBytes-BQ84L9Fe.js")]);
18914
+ const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-3AQ2tJzW.js").then((n) => n.o), import("./assets/fileBytes-CAZDydIe.js")]);
17046
18915
  registerFileBytes("native", async () => new NodeFileBytes(paths.files), true);
17047
- const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-CXFTDnFI.js").then((n) => n.t), import("./assets/endpoint-CaSbSFDw.js")]);
18916
+ const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-HMzdsyn_.js").then((n) => n.t), import("./assets/endpoint-D_3bYafG.js")]);
17048
18917
  const network = hyperdhtNetwork();
17049
18918
  const dht = mainlineNetwork();
17050
- const mainline = dht.off ? null : new (await (import("./assets/mainline-dkA94eh2.js"))).Mainline({
18919
+ const mainline = dht.off ? null : new (await (import("./assets/mainline-DQxDSWXm.js"))).Mainline({
17051
18920
  bootstrap: dht.bootstrap,
17052
18921
  host: dht.host
17053
18922
  });
17054
- const transport = mainline || pinned ? new (await (import("./assets/mainline-dkA94eh2.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
18923
+ const transport = mainline || pinned ? new (await (import("./assets/mainline-DQxDSWXm.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
17055
18924
  const server = new EngineServer({
17056
18925
  ...transport ? { transport } : {},
17057
18926
  irohWeb: true,
18927
+ ...pinnedIroh ? { irohRelays: pinnedIroh } : {},
17058
18928
  singleDevice: true,
17059
18929
  nativeTransports: { "hyperdht/1": (seedB64) => createHyperEndpoint(fromBase64Url$1(seedB64), network) },
17060
18930
  automaticWallets: false,
@@ -17574,6 +19444,7 @@ async function warnVersion(client) {
17574
19444
  */
17575
19445
  function chatOnly(method, params) {
17576
19446
  if (method.startsWith("task.") || method.startsWith("routine.") || method.startsWith("button.")) return false;
19447
+ if (method === "usage.send") return params.all === true;
17577
19448
  return method.startsWith("chat.") || params.chat !== void 0 && params.group === void 0 && !method.startsWith("group.");
17578
19449
  }
17579
19450
  async function session(g, chat = false) {
@@ -18448,6 +20319,15 @@ async function tableCommand(name, argv) {
18448
20319
  const g = globals(parsed);
18449
20320
  pretty = g.pretty;
18450
20321
  const args = positionals(command, parsed.positionals);
20322
+ if (command.run) {
20323
+ const result = await command.run(parsed, args);
20324
+ if (typeof result.warning === "string") {
20325
+ process.stderr.write(`ghostly: ${result.warning}\n`);
20326
+ delete result.warning;
20327
+ }
20328
+ print(result);
20329
+ return;
20330
+ }
18451
20331
  const params = command.params?.(parsed, args) ?? {};
18452
20332
  for (const key of Object.keys(params)) if (params[key] === void 0) delete params[key];
18453
20333
  if (command.method === "identity.complete") {
@@ -18623,6 +20503,6 @@ async function run(argv) {
18623
20503
  //#region src/bin.ts
18624
20504
  run(process.argv.slice(2));
18625
20505
  //#endregion
18626
- export { onAdaptersChanged as $, intentRepository as A, MESSAGE_DETAILS_MAX_BYTES as B, ProviderSources as C, FileAppender as Ct, requireTurn as D, streamStored as Dt, refuseAway as E, storedSize as Et, ModeGate as F, fakesLightning as G, BITCOIN_INVOICE_ON_TESTNET as H, WrongNetworkError as I, mintNetwork as J, isTestMint as K, networkLabel as L, sealSeed as M, unsealSeed as N, setAwayWallets as O, ModeChanged as P, identityProviders as Q, CASHU_MINT_SOURCE as R, lightningPaying as S, paymentWireId as St, awayFrom as T, removeStored as Tt, DEFAULT_MINTS as U, WALLET_TYPES as V, TEST_MINT as W, BACKUP_MEDIA_TYPE as X, paysItsOwnInvoices as Y, MAX_BACKUP_BYTES as Z, normalizePhrase as _, pairedWireReply as _t, createFailure as a, SourceConfigError as at, breezDatabasesFor as b, replyTo as bt, paymentNetwork as c, networkMode as ct, networkReason as d, resolveAtprotoDid as dt, registeredLightningProviders as et, rpcHost as f, setOwnDidSource as ft, isRecoveryPhrase as g, groupReply as gt, cardsKey as h, chosenResolver as ht, assertConfirmedReal as i, PROVIDER_PLATFORMS as it, newDeviceKey as j, setSingleWriterGate as k, paymentNetworksOf as l, redact as lt, LightningCards as m, verifyIdentity as mt, loadFedimintSdk as n, reserveAdapterIds as nt, createTiming as o, SourceUnreachableError as ot, CASHU_CARD as p, boundedIdentityFetch as pt, isWorthlessMint as q, WALLET_NAMES as r, NothingSpentError as rt, crossNetwork as s, isNothingSpentError as st, fedimintDatabase as t, registeredOnchainProviders as tt, walletInstances as u, assertPublicServer as ut, breezDatabase as v, receivedPairedReply as vt, sourceKey as w, readStored as wt, dropBreezDatabase as x, paymentAlias as xt, breezDatabaseInUse as y, replyRef as yt, cashuMint as z };
20506
+ export { BACKUP_MEDIA_TYPE as $, requireTurn as A, paymentWireId as At, networkLabel as B, breezDatabasesFor as C, storedGroupFiles as Ct, sourceKey as D, replyRef as Dt, ProviderSources as E, receivedPairedReply as Et, sealSeed as F, streamStored as Ft, BITCOIN_INVOICE_ON_TESTNET as G, cashuMint as H, unsealSeed as I, fakesLightning as J, DEFAULT_MINTS as K, ModeChanged as L, setSingleWriterGate as M, readStored as Mt, intentRepository as N, removeStored as Nt, awayFrom as O, replyTo as Ot, newDeviceKey as P, storedSize as Pt, paysItsOwnInvoices as Q, ModeGate as R, breezDatabaseInUse as S, groupOfFile as St, lightningPaying as T, pairedWireReply as Tt, MESSAGE_DETAILS_MAX_BYTES as U, CASHU_MINT_SOURCE as V, WALLET_TYPES as W, isWorthlessMint as X, isTestMint as Y, mintNetwork as Z, LightningCards as _, verifyIdentity as _t, irohRelayProblem as a, reserveAdapterIds as at, normalizePhrase as b, groupFileId as bt, createFailure as c, SourceConfigError as ct, paymentNetwork as d, networkMode as dt, MAX_BACKUP_BYTES as et, paymentNetworksOf as f, redact as ft, CASHU_CARD as g, boundedIdentityFetch as gt, rpcHost as h, setOwnDidSource as ht, createIrohWebEndpoint as i, registeredOnchainProviders as it, setAwayWallets as j, FileAppender as jt, refuseAway as k, paymentAlias as kt, createTiming as l, SourceUnreachableError as lt, networkReason as m, resolveAtprotoDid as mt, loadFedimintSdk as n, onAdaptersChanged as nt, WALLET_NAMES as o, NothingSpentError as ot, walletInstances as p, assertPublicServer as pt, TEST_MINT as q, DEFAULT_IROH_RELAYS as r, registeredLightningProviders as rt, assertConfirmedReal as s, PROVIDER_PLATFORMS as st, fedimintDatabase as t, identityProviders as tt, crossNetwork as u, isNothingSpentError as ut, cardsKey as v, chosenResolver as vt, dropBreezDatabase as w, groupReply as wt, breezDatabase as x, groupFileText as xt, isRecoveryPhrase as y, GroupFileDesk as yt, WrongNetworkError as z };
18627
20507
 
18628
20508
  //# sourceMappingURL=ghostly.mjs.map