@ghostlytools/cli 1.1.4 → 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/{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-BoxPHp7v.js → hyperdhtRelay-w9cwVqGZ.js} +3 -3
- package/dist/assets/{hyperdhtRelay-BoxPHp7v.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/{mainline-dkA94eh2.js → mainline-BabMhm8L.js} +2 -2
- package/dist/assets/{mainline-dkA94eh2.js.map → mainline-BabMhm8L.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-CXFTDnFI.js → server-TamKsfmC.js} +1400 -235
- package/dist/assets/server-TamKsfmC.js.map +1 -0
- package/dist/assets/{src-K3NijMq-.js → src-C6qQYrAH.js} +1721 -106
- package/dist/assets/src-C6qQYrAH.js.map +1 -0
- package/dist/ghostly.mjs +882 -21
- package/dist/ghostly.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/assets/idb-Co2lObpU.js.map +0 -1
- package/dist/assets/server-CXFTDnFI.js.map +0 -1
- 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 {
|
|
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",
|
|
@@ -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",
|
|
@@ -713,7 +718,27 @@ var ENGINE_METHODS = [
|
|
|
713
718
|
"deviceSetNoticeSeen",
|
|
714
719
|
"deviceTurnCheck",
|
|
715
720
|
"devicePushState",
|
|
716
|
-
"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"
|
|
717
742
|
];
|
|
718
743
|
/** Reads the hosts use outside `EngineApi`: the whole state, and one chat's stored messages. */
|
|
719
744
|
var ENGINE_READS = ["getState", "getMessages"];
|
|
@@ -6248,7 +6273,7 @@ function openPassportWindow(url) {
|
|
|
6248
6273
|
return window.open(url, "pubky-passport", "popup,width=520,height=760");
|
|
6249
6274
|
}
|
|
6250
6275
|
/** A 401 or 403 from the homeserver: the session it was given is not one it accepts. */
|
|
6251
|
-
var refused = (e) => {
|
|
6276
|
+
var refused$1 = (e) => {
|
|
6252
6277
|
const status = e?.data?.statusCode;
|
|
6253
6278
|
return status === 401 || status === 403;
|
|
6254
6279
|
};
|
|
@@ -6479,7 +6504,7 @@ async function approveAndWork(options, work, { grant, cookie, opened, settled, c
|
|
|
6479
6504
|
throw e;
|
|
6480
6505
|
}
|
|
6481
6506
|
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.");
|
|
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.");
|
|
6483
6508
|
throw failure(what, e);
|
|
6484
6509
|
};
|
|
6485
6510
|
const approved = session;
|
|
@@ -8489,7 +8514,7 @@ async function restoreProfile(home, name, file, passphrase) {
|
|
|
8489
8514
|
if (!reader) {
|
|
8490
8515
|
const payload = await readWhole(file, passphrase ?? "");
|
|
8491
8516
|
work = await restoringFolder(home, name);
|
|
8492
|
-
await writeWhole(work, payload);
|
|
8517
|
+
await writeWhole$1(work, payload);
|
|
8493
8518
|
return await finishRestore(home, name, work);
|
|
8494
8519
|
}
|
|
8495
8520
|
const next = async () => {
|
|
@@ -8582,7 +8607,7 @@ async function readWhole(file, passphrase) {
|
|
|
8582
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");
|
|
8583
8608
|
return payload;
|
|
8584
8609
|
}
|
|
8585
|
-
async function writeWhole(paths, payload) {
|
|
8610
|
+
async function writeWhole$1(paths, payload) {
|
|
8586
8611
|
await mkdir(paths.db, {
|
|
8587
8612
|
recursive: true,
|
|
8588
8613
|
mode: 448
|
|
@@ -12206,6 +12231,565 @@ function connectDaemon(path, timeoutMs = 2e3) {
|
|
|
12206
12231
|
});
|
|
12207
12232
|
}
|
|
12208
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
|
|
12209
12793
|
//#region src/commands/shared.ts
|
|
12210
12794
|
var wait = {
|
|
12211
12795
|
wait: {
|
|
@@ -14055,7 +14639,8 @@ var COMMANDS = {
|
|
|
14055
14639
|
...commands$1,
|
|
14056
14640
|
...commands$2,
|
|
14057
14641
|
...commands,
|
|
14058
|
-
...commands$7
|
|
14642
|
+
...commands$7,
|
|
14643
|
+
...commands$8
|
|
14059
14644
|
};
|
|
14060
14645
|
/** Commands that take text (argument or stdin), with the secret guard and delivery waits. */
|
|
14061
14646
|
var TEXT_COMMANDS = {
|
|
@@ -14668,10 +15253,11 @@ function loadCallStack() {
|
|
|
14668
15253
|
}
|
|
14669
15254
|
/**
|
|
14670
15255
|
* 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
|
|
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.
|
|
14672
15258
|
*/
|
|
14673
|
-
function callIceServers(extra = []) {
|
|
14674
|
-
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);
|
|
14675
15261
|
for (const server of callRtcConfig(extra).iceServers?.slice(RTC_CONFIG.iceServers?.length ?? 0) ?? []) for (const url of [server.urls].flat()) {
|
|
14676
15262
|
const m = /^(stun|turns?):([^:?\s]+)(?::(\d+))?(?:\?transport=(udp|tcp))?$/i.exec(url);
|
|
14677
15263
|
if (!m) continue;
|
|
@@ -16050,6 +16636,203 @@ function groupShape(group) {
|
|
|
16050
16636
|
};
|
|
16051
16637
|
}
|
|
16052
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
|
|
16053
16836
|
//#region ../browser/src/engine/paymentAdapters/fedimintSdk.ts
|
|
16054
16837
|
var fedimintDatabase = (id) => `ghostly-fedimint-${id}.db`;
|
|
16055
16838
|
var MODULE_WASM = () => import("./assets/fedimint_client_wasm_bg-BtHHlFpp.js");
|
|
@@ -16936,6 +17719,39 @@ function pkarrRelays(env = process.env) {
|
|
|
16936
17719
|
return relays;
|
|
16937
17720
|
}
|
|
16938
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
|
+
/**
|
|
16939
17755
|
* The Mainline DHT, for Pkarr beside the relays (`RelaysAndDht`): `GHOSTLY_DHT=0` leaves it out (relays only, as the web
|
|
16940
17756
|
* app), `GHOSTLY_DHT_BOOTSTRAP` ("host:port,…") replaces the public routers (a testnet; one all on loopback binds there).
|
|
16941
17757
|
* `GHOSTLY_PKARR_RELAYS` without a bootstrap leaves it out too, as on the Desktop: a private network stays off the
|
|
@@ -16971,12 +17787,43 @@ async function installWebRtc() {
|
|
|
16971
17787
|
"RTCPeerConnectionIceEvent",
|
|
16972
17788
|
"RTCCertificate"
|
|
16973
17789
|
]) scope[name] ??= polyfill[name];
|
|
17790
|
+
refuseDataAnswers(scope);
|
|
17791
|
+
if (!publicStun()) leaveOutPublicStun(scope);
|
|
16974
17792
|
return true;
|
|
16975
17793
|
} catch (error) {
|
|
16976
17794
|
process.stderr.write(`ghostly: WebRTC is unavailable (${error instanceof Error ? error.message : String(error)}); chats use HyperDHT, Iroh or the DHT\n`);
|
|
16977
17795
|
return false;
|
|
16978
17796
|
}
|
|
16979
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
|
+
}
|
|
16980
17827
|
/** Server-sent events, which browsers have and Node does not: Arkade follows its server with them. */
|
|
16981
17828
|
async function installEventSource() {
|
|
16982
17829
|
const scope = globalThis;
|
|
@@ -17017,13 +17864,17 @@ function leftOutIn(mark) {
|
|
|
17017
17864
|
}
|
|
17018
17865
|
async function startRuntime(paths, options = {}) {
|
|
17019
17866
|
const pinned = pkarrRelays();
|
|
17867
|
+
const pinnedIroh = irohRelays();
|
|
17020
17868
|
const trace = process.env.GHOSTLY_LINK_TRACE;
|
|
17021
|
-
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
|
+
}
|
|
17022
17873
|
const store = await openPersistentIndexedDb(paths.db);
|
|
17023
17874
|
const restored = join(paths.dir, RESTORED_MARK);
|
|
17024
17875
|
if (existsSync(restored)) {
|
|
17025
17876
|
try {
|
|
17026
|
-
const rows = await import("./assets/restoredRows-
|
|
17877
|
+
const rows = await import("./assets/restoredRows-lhTKGmCO.js");
|
|
17027
17878
|
await rows.markRestoredWallet();
|
|
17028
17879
|
await rows.markLeftOutFiles(leftOutIn(restored));
|
|
17029
17880
|
await store.compact();
|
|
@@ -17042,19 +17893,20 @@ async function startRuntime(paths, options = {}) {
|
|
|
17042
17893
|
await installEventSource();
|
|
17043
17894
|
await preloadWalletModules();
|
|
17044
17895
|
(await import("./assets/ghostly_iroh_web-Dvzh3Y6E.js")).initSync({ module: irohWasmBytes() });
|
|
17045
|
-
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")]);
|
|
17046
17897
|
registerFileBytes("native", async () => new NodeFileBytes(paths.files), true);
|
|
17047
|
-
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")]);
|
|
17048
17899
|
const network = hyperdhtNetwork();
|
|
17049
17900
|
const dht = mainlineNetwork();
|
|
17050
|
-
const mainline = dht.off ? null : new (await (import("./assets/mainline-
|
|
17901
|
+
const mainline = dht.off ? null : new (await (import("./assets/mainline-BabMhm8L.js"))).Mainline({
|
|
17051
17902
|
bootstrap: dht.bootstrap,
|
|
17052
17903
|
host: dht.host
|
|
17053
17904
|
});
|
|
17054
|
-
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;
|
|
17055
17906
|
const server = new EngineServer({
|
|
17056
17907
|
...transport ? { transport } : {},
|
|
17057
17908
|
irohWeb: true,
|
|
17909
|
+
...pinnedIroh ? { irohRelays: pinnedIroh } : {},
|
|
17058
17910
|
singleDevice: true,
|
|
17059
17911
|
nativeTransports: { "hyperdht/1": (seedB64) => createHyperEndpoint(fromBase64Url$1(seedB64), network) },
|
|
17060
17912
|
automaticWallets: false,
|
|
@@ -18448,6 +19300,15 @@ async function tableCommand(name, argv) {
|
|
|
18448
19300
|
const g = globals(parsed);
|
|
18449
19301
|
pretty = g.pretty;
|
|
18450
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
|
+
}
|
|
18451
19312
|
const params = command.params?.(parsed, args) ?? {};
|
|
18452
19313
|
for (const key of Object.keys(params)) if (params[key] === void 0) delete params[key];
|
|
18453
19314
|
if (command.method === "identity.complete") {
|
|
@@ -18623,6 +19484,6 @@ async function run(argv) {
|
|
|
18623
19484
|
//#region src/bin.ts
|
|
18624
19485
|
run(process.argv.slice(2));
|
|
18625
19486
|
//#endregion
|
|
18626
|
-
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 };
|
|
18627
19488
|
|
|
18628
19489
|
//# sourceMappingURL=ghostly.mjs.map
|