@ghostlytools/cli 1.1.3 → 1.1.5
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.
- package/README.md +7 -2
- package/dist/assets/argon2id-088uQfnH.js.map +1 -1
- package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
- package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
- package/dist/assets/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -1
- package/dist/assets/{fileBytes-DxiJHmTk.js → fileBytes-3AQ2tJzW.js} +5 -17
- package/dist/assets/{fileBytes-DxiJHmTk.js.map → fileBytes-3AQ2tJzW.js.map} +1 -1
- package/dist/assets/{fileBytes-BQ84L9Fe.js → fileBytes-CAZDydIe.js} +2 -2
- package/dist/assets/{fileBytes-BQ84L9Fe.js.map → fileBytes-CAZDydIe.js.map} +1 -1
- package/dist/assets/{fileBytesIdb-DQkQ10PS.js → fileBytesIdb-CmG00qE-.js} +3 -3
- package/dist/assets/{fileBytesIdb-DQkQ10PS.js.map → fileBytesIdb-CmG00qE-.js.map} +1 -1
- package/dist/assets/{fileBytesOpfs-BFfR_68b.js → fileBytesOpfs-CTgqt-p3.js} +3 -3
- package/dist/assets/{fileBytesOpfs-BFfR_68b.js.map → fileBytesOpfs-CTgqt-p3.js.map} +1 -1
- package/dist/assets/{hyperdhtRelay-CHMS7_KO.js → hyperdhtRelay-w9cwVqGZ.js} +3 -3
- package/dist/assets/{hyperdhtRelay-CHMS7_KO.js.map → hyperdhtRelay-w9cwVqGZ.js.map} +1 -1
- package/dist/assets/{idb-Co2lObpU.js → idb-BIdus0WX.js} +32 -8
- package/dist/assets/idb-BIdus0WX.js.map +1 -0
- package/dist/assets/joinTrace-CO8mlhNO.js +29 -0
- package/dist/assets/joinTrace-CO8mlhNO.js.map +1 -0
- package/dist/assets/legacy_ciphers-JJOu0JXL.js.map +1 -1
- package/dist/assets/{mainline-B0UTU0-4.js → mainline-BabMhm8L.js} +2 -2
- package/dist/assets/{mainline-B0UTU0-4.js.map → mainline-BabMhm8L.js.map} +1 -1
- package/dist/assets/nacl-fast-CDhNCYHs.js.map +1 -1
- package/dist/assets/noble_curves-DDWn5AH0.js.map +1 -1
- package/dist/assets/noble_hashes-B7AZyFyS.js.map +1 -1
- package/dist/assets/openpgp-BBFr-zHQ.js.map +1 -1
- package/dist/assets/{restoredRows-C1O9FfA4.js → restoredRows-lhTKGmCO.js} +2 -2
- package/dist/assets/{restoredRows-C1O9FfA4.js.map → restoredRows-lhTKGmCO.js.map} +1 -1
- package/dist/assets/rolldown-runtime-8H4AJuhK.js +14 -0
- package/dist/assets/{server-9AM0ta6q.js → server-TamKsfmC.js} +1601 -262
- package/dist/assets/server-TamKsfmC.js.map +1 -0
- package/dist/assets/sha512-BYId-G-T.js.map +1 -1
- package/dist/assets/{src-C_E99PGx.js → src-C6qQYrAH.js} +2190 -207
- package/dist/assets/src-C6qQYrAH.js.map +1 -0
- package/dist/assets/storage-Bya5b2Xt.js.map +1 -1
- package/dist/assets/tree-store-N362nuzZ.js.map +1 -1
- package/dist/assets/unbzip2-stream-B1C-JX9L.js.map +1 -1
- package/dist/assets/{web-OUQl4Q7L.js → web-DwgdhfcY.js} +16 -16
- package/dist/assets/{web-OUQl4Q7L.js.map → web-DwgdhfcY.js.map} +1 -1
- package/dist/ghostly.mjs +905 -29
- package/dist/ghostly.mjs.map +1 -1
- package/package.json +7 -7
- package/dist/assets/idb-Co2lObpU.js.map +0 -1
- package/dist/assets/server-9AM0ta6q.js.map +0 -1
- package/dist/assets/src-C_E99PGx.js.map +0 -1
package/dist/ghostly.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { $ as
|
|
4
|
-
import {
|
|
2
|
+
import { t as __exportAll } from "./assets/rolldown-runtime-8H4AJuhK.js";
|
|
3
|
+
import { $ as parseSshSignature, Aa as APP_STATEMENT_LIMITS, Ac as toZ32, Ai as irohDescriptor, An as normalizeDomain, Ar as TYPING_REFRESH_MS, At as didWebFile, Ba as canonicalJsonBytes, Bi as sanitizeFileName, Bn as decodeBolt11, Bt as strictBase64Url, Cn as decodeDnsResponse, Ct as DidError, Dc as fromBase64Url$1, Dn as encodeDnsQuery, Dt as didJwsAlg, En as domainWellKnownFile, Et as didGhostlyServiceEntry, Fn as wellKnownUrl, Ft as parseDid, Gt as resolveDidDht, Hi as PLAYABLE_AUDIO, Hs as seedSigner, Ia as readAppRevocations, In as requestLnurlInvoice, It as parseDidDocument, J as groupName, Jr as checkPushEndpoint, Ki as parseVoiceMeta, La as signAppObject, Ln as resolveLightningDestination, Lt as parseDidSignaturePaste, Ma as appRef, Mc as utf8Encode, Mn as recordsFromWellKnown, Mr as sanitizeTypingStatus, Mt as didWebFileUrl, Na as checkAppRevokeStatement, Nn as recordsName, Nt as didWebHost, Oc as randomBytes$1, On as isPublicAddress, Ot as didSignedMessages, Pn as txtValue, Pt as normalizeDid, Q as parseSshPublicKey, Qr as BUTTONS_CAPABILITY, Ra as signAppRevocation, Ri as readImageMeta, Rn as engineError, Rt as signingKeys, Sn as DOMAIN_FILE_MAX_BYTES, Tc as sanitizeAvatar, Tn as domainTxtRecord, Tt as checkDidSignature, Ui as VOICE_LIMITS, Vi as sanitizeMime, Wi as baseMime, X as SSHSIG_NAMESPACE, Z as SshSigError, Za as GhostlyHttpError, _a as ITEM_STATES, _c as openRelayPayload, _i as boundVerifiedIdentity, _n as pubkyProofCapability, _t as shortAtprotoDid, a as checkAppStoreIndex, ao as RTC_CONFIG, at as decodeAnyBitcoinAddress, ba as buttonLabelClash, bi as identityStatement, bn as homeserverWebEndpoint, bt as DID_DOCUMENT_MAX_BYTES$1, c as APP_BUNDLE_LIMITS, cc as setLinkTraceSink, co as callRtcConfig, ct as ATPROTO_PROOF_COLLECTION, d as assembleAppBundle, do as parseCallSignal, dt as atprotoProofRecord, et as sameSshKey, f as checkAppManifest, fn as PUBKY_PROOF_MAX_BYTES, fo as signalHasVideo, ft as atprotoProofRkey, gn as normalizePubkyKey, gt as parseAtprotoDidDocument, ha as replySnippet, hi as IdentityCheckUnavailable, hn as newPubkyProofFolder, ht as normalizeAtprotoHandle, io as CALL_SIGNAL_MAX_AGE_MS, it as BitcoinAddressError, ja as appFingerprint, jc as utf8Decode, ji as irohRelayUrl, jn as recordsFromTxt, js as inviteLink, jt as didWebFileNames, ka as APP_PREFIXES, kc as toBase64Url$1, kn as nip05RootKey, kr as TYPING_KINDS, kt as didWebDocumentUrl, lo as callSignalHeardAt, lt as ATPROTO_PROOF_SCOPE, m as readAppBundle, mc as RELAY_PAYLOAD_MAX_BYTES, mn as isPubkyProofFolder, mt as isAtprotoDid, nt as verifySshSignature, o as readAppStore, on as walletNetworkOf, oo as answersOffer, ot as ATPROTO_DECLARED_SCOPE, p as isAppUrl, pn as isPubkyKey, pt as checkAtprotoProofRecord, qi as voicePeaksOf, rt as verifyBitcoinMessage, s as signAppStore, so as buildSdpFromSignal, st as ATPROTO_FULL_SCOPE, t as appStoreDecision, tn as WALLET_NETWORKS, to as LIMITS, tt as sshSignCommand, uo as extractParamsFromSdp, ut as atprotoDidMethod, va as RUN_RESULTS, vn as pubkyProofFile, vt as AtprotoProofError, wc as MAX_AVATAR_LENGTH, wn as dnsProofName, wt as checkDidJws, xa as checkStatusCard, xn as pubkyHomeserverOf, xt as DID_FILE_MAX_BYTES, ya as STATUS_CARD_LIMITS, yn as pubkyProofPath, yt as verifyAtprotoRecordProof, za as canonicalJson, zn as engineText, zt as staticDidDocument } from "./assets/src-C6qQYrAH.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.
|
|
38
|
+
version: "1.1.5",
|
|
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",
|
|
@@ -60,9 +61,9 @@ var package_default = {
|
|
|
60
61
|
"test": "vitest run"
|
|
61
62
|
},
|
|
62
63
|
dependencies: {
|
|
63
|
-
"@arkade-os/sdk": "0.4.
|
|
64
|
+
"@arkade-os/sdk": "0.4.78",
|
|
64
65
|
"@atproto/jwk-webcrypto": "^0.3.4",
|
|
65
|
-
"@atproto/oauth-client": "^0.8.
|
|
66
|
+
"@atproto/oauth-client": "^0.8.8",
|
|
66
67
|
"@bitcoindevkit/bdk-wallet-web": "0.3.0",
|
|
67
68
|
"@bitcoinerlab/descriptors-scure": "^3.2.0",
|
|
68
69
|
"@cashu/cashu-ts": "^4.11.0",
|
|
@@ -80,7 +81,7 @@ var package_default = {
|
|
|
80
81
|
"@secondts/bark": "0.25.0",
|
|
81
82
|
"@serenity-kit/opaque": "1.1.0",
|
|
82
83
|
"@synonymdev/pubky": "0.14.0",
|
|
83
|
-
"@tetherto/wdk-wallet-evm": "1.0.0-beta.
|
|
84
|
+
"@tetherto/wdk-wallet-evm": "1.0.0-beta.20",
|
|
84
85
|
"b4a": "1.9.0",
|
|
85
86
|
"bittorrent-dht": "11.0.12",
|
|
86
87
|
"ethers": "6.17.0",
|
|
@@ -98,10 +99,10 @@ var package_default = {
|
|
|
98
99
|
"@ghostly/browser": "*",
|
|
99
100
|
"@ghostly/core": "*",
|
|
100
101
|
"@ghostly/iroh-web": "*",
|
|
101
|
-
"@types/node": "^26.6.
|
|
102
|
+
"@types/node": "^26.6.4",
|
|
102
103
|
"typescript": "^5.9.3",
|
|
103
|
-
"vite": "^8.3.
|
|
104
|
-
"vitest": "^5.0.
|
|
104
|
+
"vite": "^8.3.2",
|
|
105
|
+
"vitest": "^5.0.3"
|
|
105
106
|
}
|
|
106
107
|
};
|
|
107
108
|
//#endregion
|
|
@@ -339,16 +340,29 @@ function findSecret(text) {
|
|
|
339
340
|
* 12 to 24 words in a row from the English BIP39 list whose checksum holds, anywhere in the text; or a message that
|
|
340
341
|
* is nothing but 12, 15, 18, 21 or 24 of those words (a seed with one word mistyped is still most of a seed). Prose
|
|
341
342
|
* breaks a run at once: "the", "a", "is", "to", "and", "of" and "I" are not on the list.
|
|
343
|
+
*
|
|
344
|
+
* One word said over and over is not a seed, though one in 16 such runs passes a 12-word checksum ("word" does): a
|
|
345
|
+
* window of a single word never counts, and a window mostly of one word ("abandon … about") counts only when that
|
|
346
|
+
* word does not go on past it on either side.
|
|
342
347
|
*/
|
|
343
348
|
function hasMnemonic(text) {
|
|
344
349
|
const tokens = text.toLowerCase().match(/[a-z]+/g);
|
|
345
350
|
if (!tokens || tokens.length < 12) return false;
|
|
346
351
|
let budget = CHECKSUM_BUDGET;
|
|
347
352
|
let run = [];
|
|
353
|
+
const seedLike = (start, length) => {
|
|
354
|
+
const window = run.slice(start, start + length);
|
|
355
|
+
const counts = /* @__PURE__ */ new Map();
|
|
356
|
+
for (const word of window) counts.set(word, (counts.get(word) ?? 0) + 1);
|
|
357
|
+
if (counts.size === 1) return false;
|
|
358
|
+
const [most, times] = [...counts].reduce((a, b) => b[1] > a[1] ? b : a);
|
|
359
|
+
if (times > length / 2 && (run[start - 1] === most || run[start + length] === most)) return false;
|
|
360
|
+
return checksumHolds(window);
|
|
361
|
+
};
|
|
348
362
|
const check = () => {
|
|
349
363
|
if (run.length >= 12) {
|
|
350
|
-
for (const length of MNEMONIC_LENGTHS) for (let start = 0; start + length <= run.length && budget > 0; start++, budget--) if (
|
|
351
|
-
if (run.length === tokens.length && MNEMONIC_LENGTHS.includes(run.length)) return true;
|
|
364
|
+
for (const length of MNEMONIC_LENGTHS) for (let start = 0; start + length <= run.length && budget > 0; start++, budget--) if (seedLike(start, length)) return true;
|
|
365
|
+
if (run.length === tokens.length && MNEMONIC_LENGTHS.includes(run.length) && new Set(run).size > 1) return true;
|
|
352
366
|
}
|
|
353
367
|
run = [];
|
|
354
368
|
return false;
|
|
@@ -582,6 +596,10 @@ var ENGINE_METHODS = [
|
|
|
582
596
|
"retryMessage",
|
|
583
597
|
"react",
|
|
584
598
|
"pinMessage",
|
|
599
|
+
"appId",
|
|
600
|
+
"appOpen",
|
|
601
|
+
"appClose",
|
|
602
|
+
"appSend",
|
|
585
603
|
"messageDetails",
|
|
586
604
|
"deleteMessage",
|
|
587
605
|
"exportLinks",
|
|
@@ -645,6 +663,7 @@ var ENGINE_METHODS = [
|
|
|
645
663
|
"setWakeSubscription",
|
|
646
664
|
"setWakeMuted",
|
|
647
665
|
"wakeForCall",
|
|
666
|
+
"setCallOn",
|
|
648
667
|
"wakeConfirm",
|
|
649
668
|
"setFastPoll",
|
|
650
669
|
"createGroup",
|
|
@@ -675,6 +694,7 @@ var ENGINE_METHODS = [
|
|
|
675
694
|
"deviceEnrollCancel",
|
|
676
695
|
"deviceEnrollView",
|
|
677
696
|
"deviceEnrollJoin",
|
|
697
|
+
"deviceEnrollReady",
|
|
678
698
|
"deviceEnrollFinish",
|
|
679
699
|
"deviceEnrollRemove",
|
|
680
700
|
"deviceSet",
|
|
@@ -698,7 +718,27 @@ var ENGINE_METHODS = [
|
|
|
698
718
|
"deviceSetNoticeSeen",
|
|
699
719
|
"deviceTurnCheck",
|
|
700
720
|
"devicePushState",
|
|
701
|
-
"devicePushSet"
|
|
721
|
+
"devicePushSet",
|
|
722
|
+
"appList",
|
|
723
|
+
"appStoreList",
|
|
724
|
+
"appStorePreview",
|
|
725
|
+
"appStoreAdd",
|
|
726
|
+
"appStoreRemove",
|
|
727
|
+
"appStoreRefresh",
|
|
728
|
+
"appPreview",
|
|
729
|
+
"appInstall",
|
|
730
|
+
"appUpdateAccept",
|
|
731
|
+
"appCheckUpdates",
|
|
732
|
+
"appUninstall",
|
|
733
|
+
"appRunCheck",
|
|
734
|
+
"appFetchFiles",
|
|
735
|
+
"appEntry",
|
|
736
|
+
"appFile",
|
|
737
|
+
"appStorageGet",
|
|
738
|
+
"appStorageSet",
|
|
739
|
+
"appStorageDelete",
|
|
740
|
+
"appStorageKeys",
|
|
741
|
+
"appDataExport"
|
|
702
742
|
];
|
|
703
743
|
/** Reads the hosts use outside `EngineApi`: the whole state, and one chat's stored messages. */
|
|
704
744
|
var ENGINE_READS = ["getState", "getMessages"];
|
|
@@ -6233,7 +6273,7 @@ function openPassportWindow(url) {
|
|
|
6233
6273
|
return window.open(url, "pubky-passport", "popup,width=520,height=760");
|
|
6234
6274
|
}
|
|
6235
6275
|
/** A 401 or 403 from the homeserver: the session it was given is not one it accepts. */
|
|
6236
|
-
var refused = (e) => {
|
|
6276
|
+
var refused$1 = (e) => {
|
|
6237
6277
|
const status = e?.data?.statusCode;
|
|
6238
6278
|
return status === 401 || status === 403;
|
|
6239
6279
|
};
|
|
@@ -6464,7 +6504,7 @@ async function approveAndWork(options, work, { grant, cookie, opened, settled, c
|
|
|
6464
6504
|
throw e;
|
|
6465
6505
|
}
|
|
6466
6506
|
const fail = (what) => (e) => {
|
|
6467
|
-
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.");
|
|
6507
|
+
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.");
|
|
6468
6508
|
throw failure(what, e);
|
|
6469
6509
|
};
|
|
6470
6510
|
const approved = session;
|
|
@@ -8474,7 +8514,7 @@ async function restoreProfile(home, name, file, passphrase) {
|
|
|
8474
8514
|
if (!reader) {
|
|
8475
8515
|
const payload = await readWhole(file, passphrase ?? "");
|
|
8476
8516
|
work = await restoringFolder(home, name);
|
|
8477
|
-
await writeWhole(work, payload);
|
|
8517
|
+
await writeWhole$1(work, payload);
|
|
8478
8518
|
return await finishRestore(home, name, work);
|
|
8479
8519
|
}
|
|
8480
8520
|
const next = async () => {
|
|
@@ -8567,7 +8607,7 @@ async function readWhole(file, passphrase) {
|
|
|
8567
8607
|
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");
|
|
8568
8608
|
return payload;
|
|
8569
8609
|
}
|
|
8570
|
-
async function writeWhole(paths, payload) {
|
|
8610
|
+
async function writeWhole$1(paths, payload) {
|
|
8571
8611
|
await mkdir(paths.db, {
|
|
8572
8612
|
recursive: true,
|
|
8573
8613
|
mode: 448
|
|
@@ -12191,6 +12231,565 @@ function connectDaemon(path, timeoutMs = 2e3) {
|
|
|
12191
12231
|
});
|
|
12192
12232
|
}
|
|
12193
12233
|
//#endregion
|
|
12234
|
+
//#region src/apps.ts
|
|
12235
|
+
var APP_SOURCE = "ghostly-app.json";
|
|
12236
|
+
var APP_REVOKE_FILE = "ghostly-revoke.json";
|
|
12237
|
+
var STORE_INDEX_FILE = "ghostly-store.json";
|
|
12238
|
+
var STORE_SIG_FILE = "ghostly-store.sig";
|
|
12239
|
+
/** How long `app verify` waits for a URL, and how many redirects it follows (each checked as the first). */
|
|
12240
|
+
var FETCH_TIMEOUT_MS = 6e4;
|
|
12241
|
+
var MAX_REDIRECTS = 5;
|
|
12242
|
+
var KEY_HEADER = {
|
|
12243
|
+
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.",
|
|
12244
|
+
store: "Ghostly store key (WISP 1200). Secret: keep it offline, never share or commit it. Back it up: a store is its key."
|
|
12245
|
+
};
|
|
12246
|
+
var PEM = /-----BEGIN PRIVATE KEY-----\r?\n([A-Za-z0-9+/=\r\n]+)-----END PRIVATE KEY-----/;
|
|
12247
|
+
/** Refuses a key file that is not a plain file of this user's, or that anyone else may read or write. */
|
|
12248
|
+
function checkKeyFile(path) {
|
|
12249
|
+
const info = statSync(path);
|
|
12250
|
+
if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file: --key names a key file`);
|
|
12251
|
+
if (process.platform === "win32") return;
|
|
12252
|
+
if (process.getuid && info.uid !== process.getuid()) throw new CliError("refused", `${path} is not this user's: a key file must be yours alone`);
|
|
12253
|
+
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`);
|
|
12254
|
+
}
|
|
12255
|
+
/** Reads a key file: the PEM block, an Ed25519 key, and the kind its first line names (when it names one). */
|
|
12256
|
+
function readKey(path, kind) {
|
|
12257
|
+
checkKeyFile(path);
|
|
12258
|
+
const text = readFileSync(path, "utf8");
|
|
12259
|
+
const other = kind === "publisher" ? "store" : "publisher";
|
|
12260
|
+
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)`);
|
|
12261
|
+
const block = PEM.exec(text);
|
|
12262
|
+
let seed;
|
|
12263
|
+
if (block) try {
|
|
12264
|
+
const key = createPrivateKey(block[0]);
|
|
12265
|
+
const d = key.asymmetricKeyType === "ed25519" ? key.export({ format: "jwk" }).d : void 0;
|
|
12266
|
+
if (d) seed = fromBase64Url$1(d);
|
|
12267
|
+
} catch {}
|
|
12268
|
+
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)`);
|
|
12269
|
+
const signer = seedSigner(seed);
|
|
12270
|
+
return {
|
|
12271
|
+
signer,
|
|
12272
|
+
key: toZ32(signer.publicKey),
|
|
12273
|
+
created: false
|
|
12274
|
+
};
|
|
12275
|
+
}
|
|
12276
|
+
/** Makes a key file: owner-only from the start, never over a file that is there. */
|
|
12277
|
+
function makeKey(path, kind) {
|
|
12278
|
+
mkdirSync(dirname(path), {
|
|
12279
|
+
recursive: true,
|
|
12280
|
+
mode: 448
|
|
12281
|
+
});
|
|
12282
|
+
const { privateKey } = generateKeyPairSync("ed25519");
|
|
12283
|
+
const pem = privateKey.export({
|
|
12284
|
+
type: "pkcs8",
|
|
12285
|
+
format: "pem"
|
|
12286
|
+
});
|
|
12287
|
+
const fd = openSync(path, "wx", 384);
|
|
12288
|
+
try {
|
|
12289
|
+
writeSync(fd, `${KEY_HEADER[kind]}\n${pem}`);
|
|
12290
|
+
} finally {
|
|
12291
|
+
closeSync(fd);
|
|
12292
|
+
}
|
|
12293
|
+
return {
|
|
12294
|
+
...readKey(path, kind),
|
|
12295
|
+
created: true
|
|
12296
|
+
};
|
|
12297
|
+
}
|
|
12298
|
+
/** What the person is told on stderr when a key was made: where, which, and to back it up. Never the key. */
|
|
12299
|
+
function madeKeyNote(path, kind, key) {
|
|
12300
|
+
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)`;
|
|
12301
|
+
}
|
|
12302
|
+
var refused = (what, reading, extra = {}) => new CliError("refused", `${what} (${reading.reason}${reading.detail ? `: ${reading.detail}` : ""})`, {
|
|
12303
|
+
reason: reading.reason,
|
|
12304
|
+
...reading.detail ? { detail: reading.detail } : {},
|
|
12305
|
+
...extra
|
|
12306
|
+
});
|
|
12307
|
+
/** A bundle file's bytes: its size is checked before it is read, so a huge file is refused unread. */
|
|
12308
|
+
function readBundleFile(path) {
|
|
12309
|
+
const info = statSync(path);
|
|
12310
|
+
if (info.isDirectory()) throw new CliError("bad_request", `${path} is a folder: name a .ghostlyapp file`);
|
|
12311
|
+
if (info.size > APP_BUNDLE_LIMITS.bundleBytes) throw refused(`${path} is not a valid bundle`, {
|
|
12312
|
+
reason: "too-large",
|
|
12313
|
+
detail: `${info.size} bytes`
|
|
12314
|
+
});
|
|
12315
|
+
return new Uint8Array(readFileSync(path));
|
|
12316
|
+
}
|
|
12317
|
+
/**
|
|
12318
|
+
* A bundle at an https URL, as a client fetches one: `https:` only, no user or password, jsDelivr only at a full
|
|
12319
|
+
* commit (WISP 1200 · Manifest, "Every URL"); every redirect is held to the same rule, and the download stops past
|
|
12320
|
+
* 16 MiB.
|
|
12321
|
+
*/
|
|
12322
|
+
async function fetchBundle(url) {
|
|
12323
|
+
let at = url;
|
|
12324
|
+
for (let hop = 0;; hop++) {
|
|
12325
|
+
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>`);
|
|
12326
|
+
let response;
|
|
12327
|
+
try {
|
|
12328
|
+
response = await fetch(at, {
|
|
12329
|
+
redirect: "manual",
|
|
12330
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
12331
|
+
});
|
|
12332
|
+
} catch (error) {
|
|
12333
|
+
if (error instanceof Error && error.name === "TimeoutError") throw new CliError("timeout", `${at} did not answer in ${FETCH_TIMEOUT_MS / 1e3} s`);
|
|
12334
|
+
throw new CliError("unavailable", `Could not read ${at}: ${error instanceof Error ? error.message : String(error)}`);
|
|
12335
|
+
}
|
|
12336
|
+
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
|
|
12337
|
+
await response.body?.cancel();
|
|
12338
|
+
if (hop >= MAX_REDIRECTS) throw new CliError("refused", `${url} redirects more than ${MAX_REDIRECTS} times`);
|
|
12339
|
+
at = new URL(response.headers.get("location"), at).href;
|
|
12340
|
+
continue;
|
|
12341
|
+
}
|
|
12342
|
+
if (response.status === 404 || response.status === 410) {
|
|
12343
|
+
await response.body?.cancel();
|
|
12344
|
+
throw new CliError("not_found", `${at} answered ${response.status}`);
|
|
12345
|
+
}
|
|
12346
|
+
if (!response.ok || !response.body) {
|
|
12347
|
+
await response.body?.cancel();
|
|
12348
|
+
throw new CliError("unavailable", `${at} answered ${response.status}`);
|
|
12349
|
+
}
|
|
12350
|
+
const limit = APP_BUNDLE_LIMITS.bundleBytes;
|
|
12351
|
+
const tooLarge = (bytes) => refused(`${url} is not a valid bundle`, {
|
|
12352
|
+
reason: "too-large",
|
|
12353
|
+
detail: `${bytes} bytes`
|
|
12354
|
+
});
|
|
12355
|
+
const length = Number(response.headers.get("content-length"));
|
|
12356
|
+
if (Number.isFinite(length) && length > limit) {
|
|
12357
|
+
await response.body.cancel();
|
|
12358
|
+
throw tooLarge(length);
|
|
12359
|
+
}
|
|
12360
|
+
const chunks = [];
|
|
12361
|
+
let total = 0;
|
|
12362
|
+
const reader = response.body.getReader();
|
|
12363
|
+
try {
|
|
12364
|
+
for (;;) {
|
|
12365
|
+
const { done, value } = await reader.read();
|
|
12366
|
+
if (done) break;
|
|
12367
|
+
total += value.length;
|
|
12368
|
+
if (total > limit) {
|
|
12369
|
+
await reader.cancel();
|
|
12370
|
+
throw tooLarge(`more than ${limit}`);
|
|
12371
|
+
}
|
|
12372
|
+
chunks.push(value);
|
|
12373
|
+
}
|
|
12374
|
+
} catch (error) {
|
|
12375
|
+
if (error instanceof CliError) throw error;
|
|
12376
|
+
if (error instanceof Error && error.name === "TimeoutError") throw new CliError("timeout", `${at} did not finish in ${FETCH_TIMEOUT_MS / 1e3} s`);
|
|
12377
|
+
throw new CliError("unavailable", `Could not read ${at}: ${error instanceof Error ? error.message : String(error)}`);
|
|
12378
|
+
}
|
|
12379
|
+
const out = new Uint8Array(total);
|
|
12380
|
+
let offset = 0;
|
|
12381
|
+
for (const chunk of chunks) {
|
|
12382
|
+
out.set(chunk, offset);
|
|
12383
|
+
offset += chunk.length;
|
|
12384
|
+
}
|
|
12385
|
+
return out;
|
|
12386
|
+
}
|
|
12387
|
+
}
|
|
12388
|
+
/** What a checked bundle is, as `app verify` and `app publish` print it. */
|
|
12389
|
+
function described(bundle, bytes) {
|
|
12390
|
+
const m = bundle.manifest;
|
|
12391
|
+
return {
|
|
12392
|
+
ref: appRef(m.publisher, m.name),
|
|
12393
|
+
publisher: m.publisher,
|
|
12394
|
+
fingerprint: appFingerprint(m.publisher),
|
|
12395
|
+
name: m.name,
|
|
12396
|
+
title: m.title,
|
|
12397
|
+
version: m.version,
|
|
12398
|
+
sequence: m.sequence,
|
|
12399
|
+
digest: bundle.digest,
|
|
12400
|
+
bytes: bytes.length,
|
|
12401
|
+
sha256: toBase64Url$1(createHash("sha256").update(bytes).digest())
|
|
12402
|
+
};
|
|
12403
|
+
}
|
|
12404
|
+
/** `app verify <bundle|url>`: every check a client makes before it stores a bundle (WISP 1200 · Reading a bundle). */
|
|
12405
|
+
async function verifyApp(source) {
|
|
12406
|
+
const bytes = /^[a-z][a-z0-9+.-]*:\/\//i.test(source) ? await fetchBundle(source) : readBundleFile(resolve(source));
|
|
12407
|
+
const read = readAppBundle(bytes);
|
|
12408
|
+
if (!read.ok) throw refused(`${source} is not a valid bundle`, read);
|
|
12409
|
+
return {
|
|
12410
|
+
valid: true,
|
|
12411
|
+
...described(read.bundle, bytes),
|
|
12412
|
+
manifest: read.bundle.manifest
|
|
12413
|
+
};
|
|
12414
|
+
}
|
|
12415
|
+
/** Every file of the folder, as the bundle names it; dot files and folders are left out and listed. */
|
|
12416
|
+
function collectFiles(dir, leaveOut) {
|
|
12417
|
+
const files = [];
|
|
12418
|
+
const skipped = [];
|
|
12419
|
+
let bytes = 0;
|
|
12420
|
+
const walk = (folder) => {
|
|
12421
|
+
for (const name of readdirSync(folder).sort()) {
|
|
12422
|
+
const full = join(folder, name);
|
|
12423
|
+
const path = relative(dir, full).split(sep).join("/");
|
|
12424
|
+
if (leaveOut.has(full)) continue;
|
|
12425
|
+
if (name.startsWith(".")) {
|
|
12426
|
+
skipped.push(path);
|
|
12427
|
+
continue;
|
|
12428
|
+
}
|
|
12429
|
+
const info = lstatSync(full);
|
|
12430
|
+
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)`);
|
|
12431
|
+
if (info.isDirectory()) {
|
|
12432
|
+
walk(full);
|
|
12433
|
+
continue;
|
|
12434
|
+
}
|
|
12435
|
+
if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file`);
|
|
12436
|
+
files.push({
|
|
12437
|
+
path,
|
|
12438
|
+
full,
|
|
12439
|
+
size: info.size
|
|
12440
|
+
});
|
|
12441
|
+
bytes += info.size;
|
|
12442
|
+
if (files.length > APP_BUNDLE_LIMITS.files) throw refused(`${dir} holds more files than a bundle may`, {
|
|
12443
|
+
reason: "too-many-files",
|
|
12444
|
+
detail: `more than ${APP_BUNDLE_LIMITS.files}`
|
|
12445
|
+
});
|
|
12446
|
+
if (bytes > APP_BUNDLE_LIMITS.bundleBytes) throw refused(`${dir} holds more than a bundle may`, {
|
|
12447
|
+
reason: "too-large",
|
|
12448
|
+
detail: `more than ${APP_BUNDLE_LIMITS.bundleBytes} bytes of files`
|
|
12449
|
+
});
|
|
12450
|
+
}
|
|
12451
|
+
};
|
|
12452
|
+
walk(dir);
|
|
12453
|
+
return {
|
|
12454
|
+
files,
|
|
12455
|
+
skipped
|
|
12456
|
+
};
|
|
12457
|
+
}
|
|
12458
|
+
/** Fields of `ghostly-app.json` the command writes itself. */
|
|
12459
|
+
var WRITTEN = ["publisher", "files"];
|
|
12460
|
+
function readSource(path) {
|
|
12461
|
+
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`);
|
|
12462
|
+
let value;
|
|
12463
|
+
try {
|
|
12464
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
12465
|
+
} catch (error) {
|
|
12466
|
+
throw new CliError("bad_request", `${path} is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
12467
|
+
}
|
|
12468
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new CliError("bad_request", `${path} is not a JSON object`);
|
|
12469
|
+
const draft = value;
|
|
12470
|
+
const written = WRITTEN.filter((k) => k in draft);
|
|
12471
|
+
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`);
|
|
12472
|
+
if (draft.ghostlyApp !== void 0 && draft.ghostlyApp !== 1) throw refused(`${path} is not a manifest this release makes`, {
|
|
12473
|
+
reason: "unsupported-format",
|
|
12474
|
+
detail: "ghostlyApp is not 1"
|
|
12475
|
+
});
|
|
12476
|
+
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)`);
|
|
12477
|
+
return draft;
|
|
12478
|
+
}
|
|
12479
|
+
/** 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). */
|
|
12480
|
+
function writeWhole(path, bytes) {
|
|
12481
|
+
const tmp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
|
|
12482
|
+
writeFileSync(tmp, bytes);
|
|
12483
|
+
renameSync(tmp, path);
|
|
12484
|
+
}
|
|
12485
|
+
/**
|
|
12486
|
+
* `app publish <dir> --key <file> [--out <file>]`: `<dir>/ghostly-app.json` (the manifest without `publisher`,
|
|
12487
|
+
* `sequence` and `files`) and every other file in `<dir>` become one signed bundle, by default `<dir>/app.ghostlyapp`.
|
|
12488
|
+
* The sequence is `--sequence` (higher than the bundle already at `--out`'s), else one more than that bundle's (or
|
|
12489
|
+
* the source's `sequence`, if higher, else 1). The
|
|
12490
|
+
* key file is made when it is missing and no earlier bundle is there to update.
|
|
12491
|
+
*/
|
|
12492
|
+
async function publishApp(dirArg, keyArg, outArg, sequenceArg) {
|
|
12493
|
+
if (sequenceArg !== void 0 && !(Number.isSafeInteger(sequenceArg) && sequenceArg >= 1)) throw new CliError("usage", `--sequence takes a whole number from 1, not ${sequenceArg}`);
|
|
12494
|
+
const dir = resolve(dirArg);
|
|
12495
|
+
if (!statSync(dir).isDirectory()) throw new CliError("bad_request", `${dir} is not a folder`);
|
|
12496
|
+
const keyPath = resolve(keyArg);
|
|
12497
|
+
const out = resolve(outArg ?? join(dir, "app.ghostlyapp"));
|
|
12498
|
+
const sourcePath = join(dir, APP_SOURCE);
|
|
12499
|
+
const draft = readSource(sourcePath);
|
|
12500
|
+
let previous = null;
|
|
12501
|
+
if (existsSync(out)) {
|
|
12502
|
+
const read = readAppBundle(readBundleFile(out));
|
|
12503
|
+
if (!read.ok) throw refused(`${out} is there and is not a valid bundle: move it away, or name another --out`, read);
|
|
12504
|
+
previous = read.bundle.manifest;
|
|
12505
|
+
}
|
|
12506
|
+
let key;
|
|
12507
|
+
if (existsSync(keyPath)) key = readKey(keyPath, "publisher");
|
|
12508
|
+
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`);
|
|
12509
|
+
else key = makeKey(keyPath, "publisher");
|
|
12510
|
+
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 });
|
|
12511
|
+
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" });
|
|
12512
|
+
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 });
|
|
12513
|
+
const sequence = sequenceArg ?? Math.max(previous ? previous.sequence + 1 : 1, draft.sequence ?? 1);
|
|
12514
|
+
const { files, skipped } = collectFiles(dir, /* @__PURE__ */ new Set([
|
|
12515
|
+
sourcePath,
|
|
12516
|
+
out,
|
|
12517
|
+
join(dir, APP_REVOKE_FILE)
|
|
12518
|
+
]));
|
|
12519
|
+
const contents = files.map((f) => ({
|
|
12520
|
+
path: f.path,
|
|
12521
|
+
bytes: new Uint8Array(readFileSync(f.full))
|
|
12522
|
+
}));
|
|
12523
|
+
const manifest = {
|
|
12524
|
+
...draft,
|
|
12525
|
+
ghostlyApp: 1,
|
|
12526
|
+
publisher: key.key,
|
|
12527
|
+
sequence,
|
|
12528
|
+
files: contents.map((f) => ({
|
|
12529
|
+
path: f.path,
|
|
12530
|
+
size: f.bytes.length,
|
|
12531
|
+
sha256: toBase64Url$1(createHash("sha256").update(f.bytes).digest())
|
|
12532
|
+
}))
|
|
12533
|
+
};
|
|
12534
|
+
const checked = checkAppManifest(JSON.parse(JSON.stringify(manifest)));
|
|
12535
|
+
if (!checked.ok) throw refused(`${sourcePath} and the folder's files make no valid manifest`, checked);
|
|
12536
|
+
const { bytes: manifestBytes, signature } = await signAppObject(APP_PREFIXES.app, checked.manifest, key.signer);
|
|
12537
|
+
const bundle = assembleAppBundle(manifestBytes, canonicalJsonBytes(signature), contents.map((f) => f.bytes));
|
|
12538
|
+
const read = readAppBundle(bundle);
|
|
12539
|
+
if (!read.ok) throw refused("The bundle made is not valid", read);
|
|
12540
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
12541
|
+
writeWhole(out, bundle);
|
|
12542
|
+
return {
|
|
12543
|
+
...described(read.bundle, bundle),
|
|
12544
|
+
previous: previous?.sequence ?? null,
|
|
12545
|
+
out,
|
|
12546
|
+
files: contents.map((f) => f.path),
|
|
12547
|
+
...skipped.length ? { skipped } : {},
|
|
12548
|
+
keyCreated: key.created,
|
|
12549
|
+
...key.created ? { warning: madeKeyNote(keyPath, "publisher", key.key) } : {}
|
|
12550
|
+
};
|
|
12551
|
+
}
|
|
12552
|
+
/**
|
|
12553
|
+
* `app revoke <dir> --key <file> (--digest <digest>... | --up-to <sequence>) [--reason <text>] [--bundle <file>]`: a
|
|
12554
|
+
* `ghostly-revoke/1` statement (WISP 1200 · Publisher keys, Revocation) for the app whose bundle is `<dir>/app.ghostlyapp`
|
|
12555
|
+
* (or `--bundle`), signed by that app's publisher key and added to `<dir>/ghostly-revoke.json`, which stays canonical.
|
|
12556
|
+
* The key is never made here: only the key that signed the app may revoke its versions.
|
|
12557
|
+
*/
|
|
12558
|
+
async function revokeApp(dirArg, keyArg, what) {
|
|
12559
|
+
const dir = resolve(dirArg);
|
|
12560
|
+
const bundlePath = resolve(what.bundle ?? join(dir, "app.ghostlyapp"));
|
|
12561
|
+
const listPath = join(dir, APP_REVOKE_FILE);
|
|
12562
|
+
const usage = "ghostly app revoke <dir> --key <file> (--digest <digest>... | --up-to <sequence>) [--reason <text>]";
|
|
12563
|
+
const digests = what.digests ?? [];
|
|
12564
|
+
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}`);
|
|
12565
|
+
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}`);
|
|
12566
|
+
if (!existsSync(bundlePath)) throw new CliError("not_found", `No bundle ${bundlePath}: a revocation names the app of the bundle in <dir> (or --bundle)`);
|
|
12567
|
+
const read = readAppBundle(readBundleFile(bundlePath));
|
|
12568
|
+
if (!read.ok) throw refused(`${bundlePath} is not a valid bundle`, read);
|
|
12569
|
+
const manifest = read.bundle.manifest;
|
|
12570
|
+
const ref = appRef(manifest.publisher, manifest.name);
|
|
12571
|
+
const keyPath = resolve(keyArg);
|
|
12572
|
+
if (!existsSync(keyPath)) throw new CliError("not_found", `No key file ${keyPath}: only the key that signed ${ref} (${appFingerprint(manifest.publisher)}) revokes its versions`);
|
|
12573
|
+
const key = readKey(keyPath, "publisher");
|
|
12574
|
+
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" });
|
|
12575
|
+
const statement = digests.length ? {
|
|
12576
|
+
ghostlyRevoke: 1,
|
|
12577
|
+
app: ref,
|
|
12578
|
+
digests
|
|
12579
|
+
} : {
|
|
12580
|
+
ghostlyRevoke: 1,
|
|
12581
|
+
app: ref,
|
|
12582
|
+
upTo: what.upTo
|
|
12583
|
+
};
|
|
12584
|
+
if (what.reason !== void 0) statement.reason = what.reason;
|
|
12585
|
+
const problem = checkAppRevokeStatement(statement);
|
|
12586
|
+
if (problem) throw refused("Not a revocation a client reads", {
|
|
12587
|
+
reason: "bad-revocation",
|
|
12588
|
+
detail: problem
|
|
12589
|
+
});
|
|
12590
|
+
let list = [];
|
|
12591
|
+
if (existsSync(listPath)) {
|
|
12592
|
+
const held = readAppRevocations(new Uint8Array(readFileSync(listPath)));
|
|
12593
|
+
if (!held.ok) throw refused(`${listPath} is there and is not a valid ghostly-revoke.json: fix or move it first`, held);
|
|
12594
|
+
list = held.revocations;
|
|
12595
|
+
}
|
|
12596
|
+
const signed = await signAppRevocation(statement, key.signer);
|
|
12597
|
+
const same = list.some((r) => canonicalJson(r.statement) === canonicalJson(signed.statement));
|
|
12598
|
+
if (!same) {
|
|
12599
|
+
if (list.length >= APP_STATEMENT_LIMITS.revocations) throw refused(`${listPath} holds ${list.length} revocations already`, {
|
|
12600
|
+
reason: "bad-revocation",
|
|
12601
|
+
detail: `at most ${APP_STATEMENT_LIMITS.revocations}`
|
|
12602
|
+
});
|
|
12603
|
+
list = [...list, signed];
|
|
12604
|
+
const bytes = canonicalJsonBytes(list);
|
|
12605
|
+
const check = readAppRevocations(bytes);
|
|
12606
|
+
if (!check.ok) throw refused("The revocation list made is not valid", check);
|
|
12607
|
+
writeWhole(listPath, bytes);
|
|
12608
|
+
}
|
|
12609
|
+
return {
|
|
12610
|
+
app: ref,
|
|
12611
|
+
...digests.length ? {
|
|
12612
|
+
digests,
|
|
12613
|
+
current: digests.includes(read.bundle.digest)
|
|
12614
|
+
} : {
|
|
12615
|
+
upTo: what.upTo,
|
|
12616
|
+
current: manifest.sequence <= what.upTo
|
|
12617
|
+
},
|
|
12618
|
+
...what.reason !== void 0 ? { reason: what.reason } : {},
|
|
12619
|
+
added: !same,
|
|
12620
|
+
revocations: list.length,
|
|
12621
|
+
file: listPath
|
|
12622
|
+
};
|
|
12623
|
+
}
|
|
12624
|
+
/**
|
|
12625
|
+
* `store sign <index> --key <file> [--out <dir>]`: a store index written by hand (any JSON layout; `key` may be left
|
|
12626
|
+
* out and is then the signing key's) becomes `ghostly-store.json`, canonical, and `ghostly-store.sig`, in `--out` or
|
|
12627
|
+
* beside the index. It is read back as a client reads it (`expires` at most 90 days ahead), and refused when the index
|
|
12628
|
+
* already there, signed by the same key, has a higher `sequence`, or the same one with other content.
|
|
12629
|
+
*/
|
|
12630
|
+
async function signStore(indexArg, keyArg, outArg, now = Math.floor(Date.now() / 1e3)) {
|
|
12631
|
+
const indexPath = resolve(indexArg);
|
|
12632
|
+
const keyPath = resolve(keyArg);
|
|
12633
|
+
const outDir = resolve(outArg ?? dirname(indexPath));
|
|
12634
|
+
let value;
|
|
12635
|
+
try {
|
|
12636
|
+
value = JSON.parse(readFileSync(indexPath, "utf8"));
|
|
12637
|
+
} catch (error) {
|
|
12638
|
+
if (error instanceof SyntaxError) throw new CliError("bad_request", `${indexPath} is not JSON: ${error.message}`);
|
|
12639
|
+
throw error;
|
|
12640
|
+
}
|
|
12641
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new CliError("bad_request", `${indexPath} is not a JSON object`);
|
|
12642
|
+
const index = value;
|
|
12643
|
+
let key;
|
|
12644
|
+
if (existsSync(keyPath)) key = readKey(keyPath, "store");
|
|
12645
|
+
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)}`);
|
|
12646
|
+
else key = makeKey(keyPath, "store");
|
|
12647
|
+
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" });
|
|
12648
|
+
index.key = key.key;
|
|
12649
|
+
const checked = checkAppStoreIndex(index);
|
|
12650
|
+
if (!checked.ok) throw refused(`${indexPath} is not a valid store index`, checked);
|
|
12651
|
+
const { indexBytes, sigBytes } = await signAppStore(index, key.signer);
|
|
12652
|
+
const read = readAppStore(indexBytes, sigBytes, now, key.key);
|
|
12653
|
+
if (!read.ok) throw refused(`${indexPath} would be refused by a client`, read);
|
|
12654
|
+
const store = read.store;
|
|
12655
|
+
const indexOut = join(outDir, STORE_INDEX_FILE), sigOut = join(outDir, STORE_SIG_FILE);
|
|
12656
|
+
let previous = null;
|
|
12657
|
+
if (existsSync(indexOut) && existsSync(sigOut)) {
|
|
12658
|
+
const held = readAppStore(new Uint8Array(readFileSync(indexOut)), new Uint8Array(readFileSync(sigOut)), now);
|
|
12659
|
+
if (held.ok && held.store.index.key === key.key) {
|
|
12660
|
+
previous = held.store.index.sequence;
|
|
12661
|
+
const decision = appStoreDecision({
|
|
12662
|
+
key: key.key,
|
|
12663
|
+
sequence: held.store.index.sequence,
|
|
12664
|
+
digest: held.store.digest
|
|
12665
|
+
}, {
|
|
12666
|
+
key: key.key,
|
|
12667
|
+
sequence: store.index.sequence,
|
|
12668
|
+
digest: store.digest
|
|
12669
|
+
});
|
|
12670
|
+
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 });
|
|
12671
|
+
}
|
|
12672
|
+
}
|
|
12673
|
+
mkdirSync(outDir, { recursive: true });
|
|
12674
|
+
writeWhole(indexOut, indexBytes);
|
|
12675
|
+
writeWhole(sigOut, sigBytes);
|
|
12676
|
+
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`] : []];
|
|
12677
|
+
return {
|
|
12678
|
+
key: key.key,
|
|
12679
|
+
fingerprint: appFingerprint(key.key),
|
|
12680
|
+
name: store.index.name,
|
|
12681
|
+
kind: store.index.kind,
|
|
12682
|
+
sequence: store.index.sequence,
|
|
12683
|
+
previous,
|
|
12684
|
+
expires: store.index.expires,
|
|
12685
|
+
expired: store.expired,
|
|
12686
|
+
apps: store.index.apps.length,
|
|
12687
|
+
removed: store.index.removed.length,
|
|
12688
|
+
revoked: store.index.revoked.length,
|
|
12689
|
+
digest: store.digest,
|
|
12690
|
+
index: indexOut,
|
|
12691
|
+
sig: sigOut,
|
|
12692
|
+
keyCreated: key.created,
|
|
12693
|
+
...notes.length ? { warning: notes.join("; ") } : {}
|
|
12694
|
+
};
|
|
12695
|
+
}
|
|
12696
|
+
//#endregion
|
|
12697
|
+
//#region src/commands/apps.ts
|
|
12698
|
+
/** The key file a command signs with: always named, never a default, never on the command line itself. */
|
|
12699
|
+
function keyFile(value, usage) {
|
|
12700
|
+
if (typeof value !== "string" || !value) throw new CliError("usage", `--key <file> is needed: ghostly ${usage}`);
|
|
12701
|
+
return value;
|
|
12702
|
+
}
|
|
12703
|
+
var key = {
|
|
12704
|
+
type: "string",
|
|
12705
|
+
description: "The key file (made, owner-only, when it is missing)"
|
|
12706
|
+
};
|
|
12707
|
+
/**
|
|
12708
|
+
* The publisher tools of WISP 1200 (Apps · Publishing): they run here, on files, with no profile or daemon. One entry
|
|
12709
|
+
* per command, in alphabetical order (test/commands.test.ts checks).
|
|
12710
|
+
*/
|
|
12711
|
+
var commands$8 = {
|
|
12712
|
+
"app publish": {
|
|
12713
|
+
method: "app.publish",
|
|
12714
|
+
usage: "app publish <dir> --key <file> [--out <file>]",
|
|
12715
|
+
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)",
|
|
12716
|
+
args: ["dir"],
|
|
12717
|
+
options: {
|
|
12718
|
+
key: {
|
|
12719
|
+
...key,
|
|
12720
|
+
description: "The publisher key file (made, owner-only, on first use; back it up)"
|
|
12721
|
+
},
|
|
12722
|
+
out: {
|
|
12723
|
+
type: "string",
|
|
12724
|
+
description: "Where the bundle goes (default <dir>/app.ghostlyapp); the bundle there is the version this one follows"
|
|
12725
|
+
},
|
|
12726
|
+
sequence: {
|
|
12727
|
+
type: "number",
|
|
12728
|
+
description: "This version's sequence, higher than the bundle at --out's (default: one more than it)"
|
|
12729
|
+
}
|
|
12730
|
+
},
|
|
12731
|
+
run: ({ options }, a) => publishApp(a.dir, keyFile(options.key, "app publish <dir> --key <file> [--out <file>] [--sequence n]"), options.out, options.sequence)
|
|
12732
|
+
},
|
|
12733
|
+
"app revoke": {
|
|
12734
|
+
method: "app.revoke",
|
|
12735
|
+
usage: "app revoke <dir> --key <file> (--digest <digest>... | --up-to <sequence>) [--reason <text>] [--bundle <file>]",
|
|
12736
|
+
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",
|
|
12737
|
+
args: ["dir"],
|
|
12738
|
+
options: {
|
|
12739
|
+
key: {
|
|
12740
|
+
type: "string",
|
|
12741
|
+
description: "The app's publisher key file (never made here)"
|
|
12742
|
+
},
|
|
12743
|
+
digest: {
|
|
12744
|
+
type: "list",
|
|
12745
|
+
description: "A version's digest, base64url as app verify prints it (again for each, up to 64)"
|
|
12746
|
+
},
|
|
12747
|
+
"up-to": {
|
|
12748
|
+
type: "number",
|
|
12749
|
+
description: "Every version with this sequence or a lower one"
|
|
12750
|
+
},
|
|
12751
|
+
reason: {
|
|
12752
|
+
type: "string",
|
|
12753
|
+
description: "Why, one line of up to 200 characters, shown to people"
|
|
12754
|
+
},
|
|
12755
|
+
bundle: {
|
|
12756
|
+
type: "string",
|
|
12757
|
+
description: "The bundle naming the app (default <dir>/app.ghostlyapp)"
|
|
12758
|
+
}
|
|
12759
|
+
},
|
|
12760
|
+
run: ({ options }, a) => revokeApp(a.dir, keyFile(options.key, "app revoke <dir> --key <file> (--digest <digest>... | --up-to <sequence>) [--reason <text>]"), {
|
|
12761
|
+
digests: options.digest,
|
|
12762
|
+
upTo: options["up-to"],
|
|
12763
|
+
reason: options.reason,
|
|
12764
|
+
bundle: options.bundle
|
|
12765
|
+
})
|
|
12766
|
+
},
|
|
12767
|
+
"app verify": {
|
|
12768
|
+
method: "app.verify",
|
|
12769
|
+
usage: "app verify <bundle|url>",
|
|
12770
|
+
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",
|
|
12771
|
+
args: ["bundle"],
|
|
12772
|
+
run: (_, a) => verifyApp(a.bundle)
|
|
12773
|
+
},
|
|
12774
|
+
"store sign": {
|
|
12775
|
+
method: "store.sign",
|
|
12776
|
+
usage: "store sign <index> --key <file> [--out <dir>]",
|
|
12777
|
+
summary: "Sign a store index (WISP 1200 · Stores): writes ghostly-store.json, canonical, and ghostly-store.sig beside it or in --out",
|
|
12778
|
+
args: ["index"],
|
|
12779
|
+
options: {
|
|
12780
|
+
key: {
|
|
12781
|
+
...key,
|
|
12782
|
+
description: "The store key file (made, owner-only, when it is missing and the index names no key; keep it offline)"
|
|
12783
|
+
},
|
|
12784
|
+
out: {
|
|
12785
|
+
type: "string",
|
|
12786
|
+
description: "The folder to write ghostly-store.json and ghostly-store.sig in (default: the index's)"
|
|
12787
|
+
}
|
|
12788
|
+
},
|
|
12789
|
+
run: ({ options }, a) => signStore(a.index, keyFile(options.key, "store sign <index> --key <file> [--out <dir>]"), options.out)
|
|
12790
|
+
}
|
|
12791
|
+
};
|
|
12792
|
+
//#endregion
|
|
12194
12793
|
//#region src/commands/shared.ts
|
|
12195
12794
|
var wait = {
|
|
12196
12795
|
wait: {
|
|
@@ -14040,7 +14639,8 @@ var COMMANDS = {
|
|
|
14040
14639
|
...commands$1,
|
|
14041
14640
|
...commands$2,
|
|
14042
14641
|
...commands,
|
|
14043
|
-
...commands$7
|
|
14642
|
+
...commands$7,
|
|
14643
|
+
...commands$8
|
|
14044
14644
|
};
|
|
14045
14645
|
/** Commands that take text (argument or stdin), with the secret guard and delivery waits. */
|
|
14046
14646
|
var TEXT_COMMANDS = {
|
|
@@ -14653,10 +15253,11 @@ function loadCallStack() {
|
|
|
14653
15253
|
}
|
|
14654
15254
|
/**
|
|
14655
15255
|
* The ICE servers of a call, as libdatachannel takes them: the apps' STUN servers (RTC_CONFIG; libjuice uses the
|
|
14656
|
-
* first), then the profile's own (Settings `iceServers`: a TURN relay, typically), as the
|
|
15256
|
+
* first; none with `GHOSTLY_STUN=0`), then the profile's own (Settings `iceServers`: a TURN relay, typically), as the
|
|
15257
|
+
* apps' calls use them.
|
|
14657
15258
|
*/
|
|
14658
|
-
function callIceServers(extra = []) {
|
|
14659
|
-
const out = (RTC_CONFIG.iceServers ?? []).flatMap((server) => [server.urls].flat()).slice(0, 2);
|
|
15259
|
+
function callIceServers(extra = [], env = process.env) {
|
|
15260
|
+
const out = env.GHOSTLY_STUN === "0" ? [] : (RTC_CONFIG.iceServers ?? []).flatMap((server) => [server.urls].flat()).slice(0, 2);
|
|
14660
15261
|
for (const server of callRtcConfig(extra).iceServers?.slice(RTC_CONFIG.iceServers?.length ?? 0) ?? []) for (const url of [server.urls].flat()) {
|
|
14661
15262
|
const m = /^(stun|turns?):([^:?\s]+)(?::(\d+))?(?:\?transport=(udp|tcp))?$/i.exec(url);
|
|
14662
15263
|
if (!m) continue;
|
|
@@ -16035,6 +16636,203 @@ function groupShape(group) {
|
|
|
16035
16636
|
};
|
|
16036
16637
|
}
|
|
16037
16638
|
//#endregion
|
|
16639
|
+
//#region ../browser/src/platform/irohWeb.ts
|
|
16640
|
+
/**
|
|
16641
|
+
* Iroh (WISP 102) in a browser: the same QUIC/TLS session and binding as the
|
|
16642
|
+
* Desktop's native Iroh, but relay only. A page cannot send UDP, so every
|
|
16643
|
+
* packet goes through an Iroh relay over a WebSocket; the relay sees who talks
|
|
16644
|
+
* to whom and when, never the frames.
|
|
16645
|
+
*
|
|
16646
|
+
* The wasm (about 1 MB gzip) loads on the first chat that starts an endpoint,
|
|
16647
|
+
* never on the app's first load.
|
|
16648
|
+
*/
|
|
16649
|
+
/**
|
|
16650
|
+
* n0's public relays, the same ones the Desktop's Iroh homes on by default. Iroh itself names them with the trailing
|
|
16651
|
+
* dot of a full domain name (`use1-1.relay.n0.iroh.link.`), and compares relay URLs as text; here they have none,
|
|
16652
|
+
* because WebKit opens no host name that ends in a dot. `BROWSER_SPELLING` is what keeps the two one relay.
|
|
16653
|
+
*/
|
|
16654
|
+
var DEFAULT_IROH_RELAYS = [
|
|
16655
|
+
"https://use1-1.relay.n0.iroh.link/",
|
|
16656
|
+
"https://euc1-1.relay.n0.iroh.link/",
|
|
16657
|
+
"https://aps1-1.relay.n0.iroh.link/",
|
|
16658
|
+
"https://usw1-1.relay.n0.iroh.link/"
|
|
16659
|
+
];
|
|
16660
|
+
/**
|
|
16661
|
+
* The spelling of every relay URL handed to the wasm Iroh (`irohRelayUrl`): the relays it homes on and a contact's
|
|
16662
|
+
* relay before a dial. One spelling, so a contact on this endpoint's relay is reached over the connection already
|
|
16663
|
+
* open to it; without the dot, so Safari can open it (a Desktop's record names its relay with the dot).
|
|
16664
|
+
*/
|
|
16665
|
+
var BROWSER_SPELLING = "plain";
|
|
16666
|
+
/**
|
|
16667
|
+
* Why a relay URL cannot be used, or null. TLS, except on this machine (a local `iroh-relay --dev`): a page
|
|
16668
|
+
* may not open plain WebSockets elsewhere, and the relay's operator would see traffic metadata in the clear.
|
|
16669
|
+
*/
|
|
16670
|
+
function irohRelayProblem(value) {
|
|
16671
|
+
let url;
|
|
16672
|
+
try {
|
|
16673
|
+
url = new URL(value);
|
|
16674
|
+
} catch {
|
|
16675
|
+
return `Not a relay address: ${value}`;
|
|
16676
|
+
}
|
|
16677
|
+
const local = [
|
|
16678
|
+
"localhost",
|
|
16679
|
+
"127.0.0.1",
|
|
16680
|
+
"[::1]"
|
|
16681
|
+
].includes(url.hostname);
|
|
16682
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) return `Use an https:// relay address: ${value}`;
|
|
16683
|
+
if (url.username || url.password || url.search || url.hash || value.length > 256) return `Not a relay address: ${value}`;
|
|
16684
|
+
return null;
|
|
16685
|
+
}
|
|
16686
|
+
var loaded = null;
|
|
16687
|
+
/** Loads the wasm once per page. Tests pass their own `load` (Node reads the file). */
|
|
16688
|
+
function loadIrohWasm() {
|
|
16689
|
+
loaded ??= import("./assets/ghostly_iroh_web-Dvzh3Y6E.js").then(async (module) => {
|
|
16690
|
+
await module.default();
|
|
16691
|
+
return module;
|
|
16692
|
+
}).catch((error) => {
|
|
16693
|
+
loaded = null;
|
|
16694
|
+
throw error;
|
|
16695
|
+
});
|
|
16696
|
+
return loaded;
|
|
16697
|
+
}
|
|
16698
|
+
var SEND_BUDGET = 122880;
|
|
16699
|
+
var MAX_FRAME = 61440;
|
|
16700
|
+
/** One paired channel over one QUIC stream. Sends are queued in order. */
|
|
16701
|
+
var IrohWebChannel = class {
|
|
16702
|
+
conn;
|
|
16703
|
+
forget;
|
|
16704
|
+
reader = null;
|
|
16705
|
+
buffered = [];
|
|
16706
|
+
queue = Promise.resolve();
|
|
16707
|
+
closed = false;
|
|
16708
|
+
bufferedAmount = 0;
|
|
16709
|
+
sendBudget = SEND_BUDGET;
|
|
16710
|
+
onClose = null;
|
|
16711
|
+
constructor(conn, forget) {
|
|
16712
|
+
this.conn = conn;
|
|
16713
|
+
this.forget = forget;
|
|
16714
|
+
this.read();
|
|
16715
|
+
}
|
|
16716
|
+
get onMessage() {
|
|
16717
|
+
return this.reader;
|
|
16718
|
+
}
|
|
16719
|
+
set onMessage(reader) {
|
|
16720
|
+
this.reader = reader;
|
|
16721
|
+
if (reader) for (const text of this.buffered.splice(0)) reader(text);
|
|
16722
|
+
}
|
|
16723
|
+
async read() {
|
|
16724
|
+
try {
|
|
16725
|
+
for (;;) {
|
|
16726
|
+
const text = await this.conn.recv();
|
|
16727
|
+
if (text === void 0 || this.closed) break;
|
|
16728
|
+
if (this.reader) this.reader(text);
|
|
16729
|
+
else if (this.buffered.length < 64) this.buffered.push(text);
|
|
16730
|
+
else break;
|
|
16731
|
+
}
|
|
16732
|
+
} catch {}
|
|
16733
|
+
this.close();
|
|
16734
|
+
}
|
|
16735
|
+
send(data) {
|
|
16736
|
+
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");
|
|
16737
|
+
this.bufferedAmount += data.length;
|
|
16738
|
+
this.queue = this.queue.then(() => this.closed ? void 0 : this.conn.send(data)).catch(() => this.close()).finally(() => {
|
|
16739
|
+
this.bufferedAmount -= data.length;
|
|
16740
|
+
});
|
|
16741
|
+
}
|
|
16742
|
+
drained() {
|
|
16743
|
+
return this.queue;
|
|
16744
|
+
}
|
|
16745
|
+
get rttMs() {
|
|
16746
|
+
const rtt = this.conn.rtt();
|
|
16747
|
+
return rtt >= 0 ? rtt : void 0;
|
|
16748
|
+
}
|
|
16749
|
+
close() {
|
|
16750
|
+
if (this.closed) return;
|
|
16751
|
+
this.closed = true;
|
|
16752
|
+
this.buffered = [];
|
|
16753
|
+
try {
|
|
16754
|
+
this.conn.close();
|
|
16755
|
+
} catch {}
|
|
16756
|
+
this.forget();
|
|
16757
|
+
this.onClose?.();
|
|
16758
|
+
}
|
|
16759
|
+
};
|
|
16760
|
+
/** A NativeEndpoint for `iroh/1`, as the Desktop's is, for one chat's transport seed. */
|
|
16761
|
+
async function createIrohWebEndpoint(seedB64, options = {}) {
|
|
16762
|
+
const seed = fromBase64Url$1(seedB64);
|
|
16763
|
+
if (seed.length !== 32) throw new Error("Invalid transport seed");
|
|
16764
|
+
const relays = [...new Set((options.relays?.length ? options.relays : DEFAULT_IROH_RELAYS).map((relay) => irohRelayUrl(relay, BROWSER_SPELLING)))];
|
|
16765
|
+
const node = await (await (options.load ?? loadIrohWasm)()).IrohNode.start(seed, relays, options.onlineMs ?? 1e4);
|
|
16766
|
+
const channels = /* @__PURE__ */ new Set();
|
|
16767
|
+
let stopped = false;
|
|
16768
|
+
let handler = null;
|
|
16769
|
+
const early = [];
|
|
16770
|
+
const bind = (conn) => {
|
|
16771
|
+
const channel = new IrohWebChannel(conn, () => channels.delete(channel));
|
|
16772
|
+
channels.add(channel);
|
|
16773
|
+
return {
|
|
16774
|
+
channel,
|
|
16775
|
+
binding: conn.binding()
|
|
16776
|
+
};
|
|
16777
|
+
};
|
|
16778
|
+
const endpoint = {
|
|
16779
|
+
transport: "iroh/1",
|
|
16780
|
+
descriptor: {
|
|
16781
|
+
...node.address(),
|
|
16782
|
+
relayed: true
|
|
16783
|
+
},
|
|
16784
|
+
onDescriptor: null,
|
|
16785
|
+
get onConnection() {
|
|
16786
|
+
return handler;
|
|
16787
|
+
},
|
|
16788
|
+
set onConnection(value) {
|
|
16789
|
+
handler = value;
|
|
16790
|
+
if (value) for (const bound of early.splice(0)) value(bound);
|
|
16791
|
+
},
|
|
16792
|
+
async connect(descriptor) {
|
|
16793
|
+
if (stopped) throw new Error("Iroh endpoint is stopped");
|
|
16794
|
+
const conn = await node.connect(irohDescriptor(descriptor, BROWSER_SPELLING), options.connectMs ?? 2e4);
|
|
16795
|
+
if (stopped) {
|
|
16796
|
+
conn.close();
|
|
16797
|
+
throw new Error("Iroh endpoint is stopped");
|
|
16798
|
+
}
|
|
16799
|
+
return bind(conn);
|
|
16800
|
+
},
|
|
16801
|
+
async close() {
|
|
16802
|
+
if (stopped) return;
|
|
16803
|
+
stopped = true;
|
|
16804
|
+
for (const channel of [...channels]) channel.close();
|
|
16805
|
+
for (const bound of early.splice(0)) bound.channel.close();
|
|
16806
|
+
await node.close().catch(() => {});
|
|
16807
|
+
}
|
|
16808
|
+
};
|
|
16809
|
+
(async () => {
|
|
16810
|
+
while (!stopped) {
|
|
16811
|
+
let conn;
|
|
16812
|
+
try {
|
|
16813
|
+
conn = await node.accept();
|
|
16814
|
+
} catch {
|
|
16815
|
+
break;
|
|
16816
|
+
}
|
|
16817
|
+
if (!conn) break;
|
|
16818
|
+
if (stopped) {
|
|
16819
|
+
conn.close();
|
|
16820
|
+
break;
|
|
16821
|
+
}
|
|
16822
|
+
const bound = bind(conn);
|
|
16823
|
+
const accept = endpoint.onConnection;
|
|
16824
|
+
if (accept) accept(bound);
|
|
16825
|
+
else if (early.length < 4) early.push(bound);
|
|
16826
|
+
else bound.channel.close();
|
|
16827
|
+
}
|
|
16828
|
+
if (!stopped) {
|
|
16829
|
+
endpoint.onUnavailable?.();
|
|
16830
|
+
await endpoint.close();
|
|
16831
|
+
}
|
|
16832
|
+
})();
|
|
16833
|
+
return endpoint;
|
|
16834
|
+
}
|
|
16835
|
+
//#endregion
|
|
16038
16836
|
//#region ../browser/src/engine/paymentAdapters/fedimintSdk.ts
|
|
16039
16837
|
var fedimintDatabase = (id) => `ghostly-fedimint-${id}.db`;
|
|
16040
16838
|
var MODULE_WASM = () => import("./assets/fedimint_client_wasm_bg-BtHHlFpp.js");
|
|
@@ -16921,6 +17719,39 @@ function pkarrRelays(env = process.env) {
|
|
|
16921
17719
|
return relays;
|
|
16922
17720
|
}
|
|
16923
17721
|
/**
|
|
17722
|
+
* The Iroh relays of a private network or a test, `GHOSTLY_IROH_RELAYS` ("https://…,…"; http:// on loopback): the only
|
|
17723
|
+
* relays this process's Iroh homes on, whatever the profile's `irohRelays` setting says. Null when unset (the setting,
|
|
17724
|
+
* else n0's public relays).
|
|
17725
|
+
*/
|
|
17726
|
+
function irohRelays(env = process.env) {
|
|
17727
|
+
const relays = (env.GHOSTLY_IROH_RELAYS ?? "").split(",").map((url) => url.trim()).filter(Boolean);
|
|
17728
|
+
if (!relays.length) return null;
|
|
17729
|
+
const bad = relays.map((url) => irohRelayProblem(url)).find(Boolean);
|
|
17730
|
+
if (bad) throw new CliError("usage", `GHOSTLY_IROH_RELAYS: ${bad}`);
|
|
17731
|
+
return relays;
|
|
17732
|
+
}
|
|
17733
|
+
/**
|
|
17734
|
+
* The apps' public STUN servers (Google's, `RTC_CONFIG`), which every WebRTC connection asks for its public address:
|
|
17735
|
+
* `GHOSTLY_STUN=0` leaves them out (a private network, or tests on one machine, where host candidates connect). The
|
|
17736
|
+
* profile's own `iceServers` stay.
|
|
17737
|
+
*/
|
|
17738
|
+
function publicStun(env = process.env) {
|
|
17739
|
+
return env.GHOSTLY_STUN !== "0";
|
|
17740
|
+
}
|
|
17741
|
+
/** An RTC configuration without the apps' public STUN servers. */
|
|
17742
|
+
function withoutPublicStun(config) {
|
|
17743
|
+
if (!config?.iceServers) return config;
|
|
17744
|
+
const ours = new Set((RTC_CONFIG.iceServers ?? []).flatMap((server) => [server.urls].flat()));
|
|
17745
|
+
const iceServers = config.iceServers.map((server) => ({
|
|
17746
|
+
...server,
|
|
17747
|
+
urls: [server.urls].flat().filter((url) => !ours.has(url))
|
|
17748
|
+
})).filter((server) => server.urls.length);
|
|
17749
|
+
return {
|
|
17750
|
+
...config,
|
|
17751
|
+
iceServers
|
|
17752
|
+
};
|
|
17753
|
+
}
|
|
17754
|
+
/**
|
|
16924
17755
|
* The Mainline DHT, for Pkarr beside the relays (`RelaysAndDht`): `GHOSTLY_DHT=0` leaves it out (relays only, as the web
|
|
16925
17756
|
* app), `GHOSTLY_DHT_BOOTSTRAP` ("host:port,…") replaces the public routers (a testnet; one all on loopback binds there).
|
|
16926
17757
|
* `GHOSTLY_PKARR_RELAYS` without a bootstrap leaves it out too, as on the Desktop: a private network stays off the
|
|
@@ -16956,12 +17787,43 @@ async function installWebRtc() {
|
|
|
16956
17787
|
"RTCPeerConnectionIceEvent",
|
|
16957
17788
|
"RTCCertificate"
|
|
16958
17789
|
]) scope[name] ??= polyfill[name];
|
|
17790
|
+
refuseDataAnswers(scope);
|
|
17791
|
+
if (!publicStun()) leaveOutPublicStun(scope);
|
|
16959
17792
|
return true;
|
|
16960
17793
|
} catch (error) {
|
|
16961
17794
|
process.stderr.write(`ghostly: WebRTC is unavailable (${error instanceof Error ? error.message : String(error)}); chats use HyperDHT, Iroh or the DHT\n`);
|
|
16962
17795
|
return false;
|
|
16963
17796
|
}
|
|
16964
17797
|
}
|
|
17798
|
+
/**
|
|
17799
|
+
* For tests: `GHOSTLY_TEST_REFUSE_DATA_ANSWERS=<n>` refuses one in every n answers this process applies to its offers
|
|
17800
|
+
* (the first, the n+1st, and so on), as libdatachannel 0.24.5 refuses one in its race (`ANSWER_REFUSED_REDIALS` in
|
|
17801
|
+
* packages/core/src/datalink.ts): the connection closes, and setRemoteDescription throws.
|
|
17802
|
+
*/
|
|
17803
|
+
function refuseDataAnswers(scope) {
|
|
17804
|
+
const every = Number(process.env.GHOSTLY_TEST_REFUSE_DATA_ANSWERS) || 0;
|
|
17805
|
+
if (every <= 0) return;
|
|
17806
|
+
let answers = 0;
|
|
17807
|
+
const Base = scope.RTCPeerConnection;
|
|
17808
|
+
scope.RTCPeerConnection = class extends Base {
|
|
17809
|
+
async setRemoteDescription(description) {
|
|
17810
|
+
if (description.type === "answer" && answers++ % every === 0) {
|
|
17811
|
+
this.close();
|
|
17812
|
+
throw new Error("libdatachannel error while adding remote description: Got a remote candidate without ICE transport (GHOSTLY_TEST_REFUSE_DATA_ANSWERS)");
|
|
17813
|
+
}
|
|
17814
|
+
return super.setRemoteDescription(description);
|
|
17815
|
+
}
|
|
17816
|
+
};
|
|
17817
|
+
}
|
|
17818
|
+
/** `GHOSTLY_STUN=0`: every connection the engine makes leaves the apps' public STUN servers out of its configuration. */
|
|
17819
|
+
function leaveOutPublicStun(scope) {
|
|
17820
|
+
const Base = scope.RTCPeerConnection;
|
|
17821
|
+
scope.RTCPeerConnection = class extends Base {
|
|
17822
|
+
constructor(config) {
|
|
17823
|
+
super(withoutPublicStun(config));
|
|
17824
|
+
}
|
|
17825
|
+
};
|
|
17826
|
+
}
|
|
16965
17827
|
/** Server-sent events, which browsers have and Node does not: Arkade follows its server with them. */
|
|
16966
17828
|
async function installEventSource() {
|
|
16967
17829
|
const scope = globalThis;
|
|
@@ -17002,13 +17864,17 @@ function leftOutIn(mark) {
|
|
|
17002
17864
|
}
|
|
17003
17865
|
async function startRuntime(paths, options = {}) {
|
|
17004
17866
|
const pinned = pkarrRelays();
|
|
17867
|
+
const pinnedIroh = irohRelays();
|
|
17005
17868
|
const trace = process.env.GHOSTLY_LINK_TRACE;
|
|
17006
|
-
if (trace)
|
|
17869
|
+
if (trace) {
|
|
17870
|
+
setLinkTraceSink((line) => appendFileSync(trace, line + "\n", { mode: 384 }));
|
|
17871
|
+
(await import("./assets/joinTrace-CO8mlhNO.js").then((n) => n.t)).setJoinTraceSink((line) => appendFileSync(trace, line + "\n", { mode: 384 }));
|
|
17872
|
+
}
|
|
17007
17873
|
const store = await openPersistentIndexedDb(paths.db);
|
|
17008
17874
|
const restored = join(paths.dir, RESTORED_MARK);
|
|
17009
17875
|
if (existsSync(restored)) {
|
|
17010
17876
|
try {
|
|
17011
|
-
const rows = await import("./assets/restoredRows-
|
|
17877
|
+
const rows = await import("./assets/restoredRows-lhTKGmCO.js");
|
|
17012
17878
|
await rows.markRestoredWallet();
|
|
17013
17879
|
await rows.markLeftOutFiles(leftOutIn(restored));
|
|
17014
17880
|
await store.compact();
|
|
@@ -17027,19 +17893,20 @@ async function startRuntime(paths, options = {}) {
|
|
|
17027
17893
|
await installEventSource();
|
|
17028
17894
|
await preloadWalletModules();
|
|
17029
17895
|
(await import("./assets/ghostly_iroh_web-Dvzh3Y6E.js")).initSync({ module: irohWasmBytes() });
|
|
17030
|
-
const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-
|
|
17896
|
+
const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-3AQ2tJzW.js").then((n) => n.o), import("./assets/fileBytes-CAZDydIe.js")]);
|
|
17031
17897
|
registerFileBytes("native", async () => new NodeFileBytes(paths.files), true);
|
|
17032
|
-
const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-
|
|
17898
|
+
const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-TamKsfmC.js").then((n) => n.t), import("./assets/endpoint-CaSbSFDw.js")]);
|
|
17033
17899
|
const network = hyperdhtNetwork();
|
|
17034
17900
|
const dht = mainlineNetwork();
|
|
17035
|
-
const mainline = dht.off ? null : new (await (import("./assets/mainline-
|
|
17901
|
+
const mainline = dht.off ? null : new (await (import("./assets/mainline-BabMhm8L.js"))).Mainline({
|
|
17036
17902
|
bootstrap: dht.bootstrap,
|
|
17037
17903
|
host: dht.host
|
|
17038
17904
|
});
|
|
17039
|
-
const transport = mainline || pinned ? new (await (import("./assets/mainline-
|
|
17905
|
+
const transport = mainline || pinned ? new (await (import("./assets/mainline-BabMhm8L.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
|
|
17040
17906
|
const server = new EngineServer({
|
|
17041
17907
|
...transport ? { transport } : {},
|
|
17042
17908
|
irohWeb: true,
|
|
17909
|
+
...pinnedIroh ? { irohRelays: pinnedIroh } : {},
|
|
17043
17910
|
singleDevice: true,
|
|
17044
17911
|
nativeTransports: { "hyperdht/1": (seedB64) => createHyperEndpoint(fromBase64Url$1(seedB64), network) },
|
|
17045
17912
|
automaticWallets: false,
|
|
@@ -18433,6 +19300,15 @@ async function tableCommand(name, argv) {
|
|
|
18433
19300
|
const g = globals(parsed);
|
|
18434
19301
|
pretty = g.pretty;
|
|
18435
19302
|
const args = positionals(command, parsed.positionals);
|
|
19303
|
+
if (command.run) {
|
|
19304
|
+
const result = await command.run(parsed, args);
|
|
19305
|
+
if (typeof result.warning === "string") {
|
|
19306
|
+
process.stderr.write(`ghostly: ${result.warning}\n`);
|
|
19307
|
+
delete result.warning;
|
|
19308
|
+
}
|
|
19309
|
+
print(result);
|
|
19310
|
+
return;
|
|
19311
|
+
}
|
|
18436
19312
|
const params = command.params?.(parsed, args) ?? {};
|
|
18437
19313
|
for (const key of Object.keys(params)) if (params[key] === void 0) delete params[key];
|
|
18438
19314
|
if (command.method === "identity.complete") {
|
|
@@ -18608,6 +19484,6 @@ async function run(argv) {
|
|
|
18608
19484
|
//#region src/bin.ts
|
|
18609
19485
|
run(process.argv.slice(2));
|
|
18610
19486
|
//#endregion
|
|
18611
|
-
export {
|
|
19487
|
+
export { BACKUP_MEDIA_TYPE as $, requireTurn as A, streamStored as At, networkLabel as B, breezDatabasesFor as C, replyTo as Ct, sourceKey as D, readStored as Dt, ProviderSources as E, FileAppender as Et, sealSeed as F, 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, intentRepository as N, awayFrom as O, removeStored as Ot, newDeviceKey as P, paysItsOwnInvoices as Q, ModeGate as R, breezDatabaseInUse as S, replyRef as St, lightningPaying as T, paymentWireId 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, pairedWireReply 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, refuseAway as k, storedSize 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, paymentAlias as wt, breezDatabase as x, receivedPairedReply as xt, isRecoveryPhrase as y, groupReply as yt, WrongNetworkError as z };
|
|
18612
19488
|
|
|
18613
19489
|
//# sourceMappingURL=ghostly.mjs.map
|