From b246b8b3ec52a67bc6612160cab9e0c1ccd355a2 Mon Sep 17 00:00:00 2001 From: Rinse Date: Sat, 6 Jun 2026 12:29:02 +0000 Subject: [PATCH 1/5] chore(deps): upgrade @pkcprotocol/pkc-js to 0.0.41 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 94b0e43..6917cc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@oclif/plugin-help": "6.2.36", "@oclif/plugin-not-found": "3.2.73", "@oclif/table": "0.5.1", - "@pkcprotocol/pkc-js": "0.0.40", + "@pkcprotocol/pkc-js": "0.0.41", "dataobject-parser": "1.2.22", "decompress": "4.2.1", "env-paths": "2.2.1", @@ -5421,9 +5421,9 @@ } }, "node_modules/@pkcprotocol/pkc-js": { - "version": "0.0.40", - "resolved": "https://registry.npmjs.org/@pkcprotocol/pkc-js/-/pkc-js-0.0.40.tgz", - "integrity": "sha512-fgelKG6eIdHRZsBKUJbh7473sdHy5nO3TLsqW57RVSs9kI2vWJREWqMHCddcE18um1p+ObJieqhhL0NqVn1suw==", + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@pkcprotocol/pkc-js/-/pkc-js-0.0.41.tgz", + "integrity": "sha512-XC8McKK230mcydB8m+5qBeeYYjtgQ04G4AtfZX8G7GzjNyOii+Cj09MYrwutTg8gCZq6B9jNWttakGQ17wnuhg==", "license": "GPL-3.0-or-later", "dependencies": { "@enhances/with-resolvers": "0.0.5", diff --git a/package.json b/package.json index b92bb6f..5b382e2 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "@oclif/plugin-help": "6.2.36", "@oclif/plugin-not-found": "3.2.73", "@oclif/table": "0.5.1", - "@pkcprotocol/pkc-js": "0.0.40", + "@pkcprotocol/pkc-js": "0.0.41", "dataobject-parser": "1.2.22", "decompress": "4.2.1", "env-paths": "2.2.1", From 53821e4c8ad76d4d9dcd0cd04021efa5b1f5218c Mon Sep 17 00:00:00 2001 From: Rinse Date: Sat, 6 Jun 2026 12:29:02 +0000 Subject: [PATCH 2/5] feat(community): add `bitsocial community export` command (#64) Exposes the pkc-js 0.0.41 community export API (pkcprotocol/pkc-js#100): - `bitsocial community export [ADDRESS]` triggers an export on the RPC server, streams progress to stderr, downloads the finished SQLite snapshot via GET /exports/, verifies its sha256 against the export record (.partial file + atomic rename so a corrupted download never clobbers the destination), and prints the destination path. Flags: --name/--publicKey lookup, -o/--path, --includePrivateKey, --force, --quiet. Ctrl+C cancels the in-flight export server-side. - `bitsocial daemon --allowPrivateKeyExport` (negatable, default true, matching pkc-js) controls whether RPC clients may request exports that include the community signer's private key. --- src/cli/commands/community/export.ts | 220 ++++++++++++++++++++++++ src/cli/commands/daemon.ts | 12 +- src/webui/daemon-server.ts | 10 +- test/cli/export.community.test.ts | 240 +++++++++++++++++++++++++++ 4 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 src/cli/commands/community/export.ts create mode 100644 test/cli/export.community.test.ts diff --git a/src/cli/commands/community/export.ts b/src/cli/commands/community/export.ts new file mode 100644 index 0000000..274eeb7 --- /dev/null +++ b/src/cli/commands/community/export.ts @@ -0,0 +1,220 @@ +import { Args, Flags } from "@oclif/core"; +import { BaseCommand } from "../../base-command.js"; +import { PKCLogger } from "../../../util.js"; + +import { createHash } from "node:crypto"; +import { createWriteStream } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +// Minimal shape of the export records emitted by pkc-js over RPC (CommunityExportRecord). +// Kept local so we don't deep-import from pkc-js dist paths (see daemon.ts's @ts-expect-error imports). +type ExportRecord = { + exportId: string; + name?: string; + publicKey: string; + includePrivateKey: boolean; + progress: number; + size?: number; + sha256?: string; + url?: string; + error?: { code: string; message: string }; +}; + +type ExportableCommunity = { + address: string; + exports: ExportRecord[]; + export: (options?: { includePrivateKey?: boolean; signal?: AbortSignal }) => Promise<{ exportId: string }>; + on: (event: string, listener: (records: ExportRecord[]) => void) => void; + removeListener: (event: string, listener: (records: ExportRecord[]) => void) => void; +}; + +export default class Export extends BaseCommand { + static override description = + "Export a local community to a SQLite snapshot file. The export runs on the RPC server (daemon); once finished the snapshot is downloaded and its sha256 checksum is verified. Pass --includePrivateKey to produce a restorable backup that keeps the community's address."; + + static override examples = [ + "bitsocial community export plebmusic.bso", + "bitsocial community export plebmusic.bso --includePrivateKey -o ./backups/plebmusic.sqlite", + "bitsocial community export --name my-community", + "bitsocial community export --publicKey 12D3KooWG3XbzoVyAE6Y9vHZKF64Yuuu4TjdgQKedk14iYmTEPWu" + ]; + + static override args = { + address: Args.string({ + name: "address", + required: false, + description: "Address of the community to export" + }) + }; + + static override flags = { + name: Flags.string({ + description: "Name of the community to export" + }), + publicKey: Flags.string({ + description: "Public key of the community to export" + }), + path: Flags.string({ + char: "o", + description: "Destination file for the downloaded snapshot (default: ./
.sqlite)" + }), + includePrivateKey: Flags.boolean({ + default: false, + description: + "Ask the RPC server to include the community signer's private key in the export. Required for a restorable backup that keeps the same community address. The daemon may refuse (see `bitsocial daemon --no-allowPrivateKeyExport`)" + }), + force: Flags.boolean({ + default: false, + description: "Overwrite the destination file if it already exists" + }), + quiet: Flags.boolean({ + char: "q", + default: false, + description: "Suppress progress output; only print the path of the downloaded snapshot" + }) + }; + + private _printProgress(quiet: boolean, message: string) { + if (!quiet) process.stderr.write(message); + } + + async run(): Promise { + const { args, flags } = await this.parse(Export); + + const log = PKCLogger("bitsocial-cli:commands:community:export"); + log(`args: `, args); + log(`flags: `, flags); + + const lookupParam: Record = {}; + if (args.address) lookupParam.address = args.address; + if (flags.name) lookupParam.name = flags.name; + if (flags.publicKey) lookupParam.publicKey = flags.publicKey; + + if (Object.keys(lookupParam).length === 0) { + this.error("At least one of address argument, --name, or --publicKey must be provided"); + } + + const pkc = await this._connectToPkcRpc(flags.pkcRpcUrl.toString()); + + // Cancel the in-flight export server-side on Ctrl+C. A second Ctrl+C force-exits + // (the handler is registered with `once`, so the default SIGINT behavior is restored). + const abortController = new AbortController(); + const onSigint = () => { + this._printProgress(flags.quiet, "\nCancelling export... (Ctrl+C again to force exit)\n"); + abortController.abort(); + }; + process.once("SIGINT", onSigint); + + try { + const community = (await pkc.createCommunity(lookupParam)) as unknown as Partial; + if (typeof community.export !== "function") { + this.error( + `Community is not local to the RPC server at ${flags.pkcRpcUrl}. Only communities created on this daemon can be exported` + ); + } + const exportableCommunity = community as ExportableCommunity; + + const destPath = path.resolve(flags.path ?? `${exportableCommunity.address}.sqlite`); + const destExists = await fs + .stat(destPath) + .then(() => true) + .catch(() => false); + if (destExists && !flags.force) { + this.error(`Destination file already exists: ${destPath}. Use --force to overwrite it`); + } + + const finishedRecord = await this._runExport(exportableCommunity, flags.includePrivateKey, abortController.signal, flags.quiet); + log("Export finished on the RPC server", finishedRecord); + + if (!finishedRecord.url) { + this.error(`Export ${finishedRecord.exportId} finished but the RPC server did not provide a download URL`); + } + + await this._downloadAndVerify(finishedRecord, destPath, abortController.signal, flags.quiet); + + this.log(destPath); + } catch (e) { + console.error(e); + await pkc.destroy(); + this.exit(1); + } finally { + process.removeListener("SIGINT", onSigint); + } + await pkc.destroy(); + } + + /** Start the export on the RPC server and resolve with the terminal record (progress === 1). */ + private async _runExport( + community: ExportableCommunity, + includePrivateKey: boolean, + signal: AbortSignal, + quiet: boolean + ): Promise { + const { exportId } = await community.export({ includePrivateKey, signal }); + + return new Promise((resolve, reject) => { + let lastPrintedPercent = -1; + const checkRecords = (records: ExportRecord[]) => { + const record = records.find((rec) => rec.exportId === exportId); + if (!record) return; + if (record.error) { + community.removeListener("exportschange", checkRecords); + reject(new Error(`Export failed (${record.error.code}): ${record.error.message}`)); + } else if (record.progress === 1) { + community.removeListener("exportschange", checkRecords); + this._printProgress(quiet, `\rExporting ${community.address}: 100%\n`); + resolve(record); + } else { + const percent = Math.floor(record.progress * 100); + if (percent !== lastPrintedPercent) { + lastPrintedPercent = percent; + this._printProgress(quiet, `\rExporting ${community.address}: ${percent}%`); + } + } + }; + community.on("exportschange", checkRecords); + // The terminal notification may have arrived before the listener was attached + checkRecords(community.exports); + }); + } + + /** Download the finished snapshot to destPath, verifying its sha256 against the export record. */ + private async _downloadAndVerify(record: ExportRecord, destPath: string, signal: AbortSignal, quiet: boolean) { + this._printProgress(quiet, `Downloading snapshot from ${record.url}\n`); + const response = await fetch(record.url!, { signal }); + if (!response.ok || !response.body) { + this.error(`Failed to download export from ${record.url}: HTTP ${response.status}`); + } + + await fs.mkdir(path.dirname(destPath), { recursive: true }); + // Download to a .partial file so an interrupted/corrupted download never clobbers destPath + const partialPath = destPath + ".partial"; + const hash = createHash("sha256"); + try { + await pipeline( + Readable.fromWeb(response.body as import("node:stream/web").ReadableStream), + async function* (source) { + for await (const chunk of source) { + hash.update(chunk as Buffer); + yield chunk; + } + }, + createWriteStream(partialPath) + ); + const downloadedSha256 = hash.digest("hex"); + if (record.sha256 && downloadedSha256 !== record.sha256) { + throw new Error( + `sha256 mismatch for downloaded export: expected ${record.sha256} but downloaded file hashes to ${downloadedSha256}` + ); + } + await fs.rename(partialPath, destPath); + } catch (e) { + await fs.rm(partialPath, { force: true }).catch(() => {}); + throw e; + } + this._printProgress(quiet, `Verified sha256 (${record.sha256}) and saved snapshot${record.size ? ` (${record.size} bytes)` : ""}\n`); + } +} diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 47aafe5..cbe7054 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -107,6 +107,13 @@ export default class Daemon extends Command { description: "RPC URL(s) for .bso name resolution. Can be specified multiple times.", multiple: true, default: DEFAULT_PROVIDERS + }), + + allowPrivateKeyExport: Flags.boolean({ + description: + "Allow RPC clients to request community exports that include the community signer's private key (`bitsocial community export --includePrivateKey`). Disable with --no-allowPrivateKeyExport when exposing the RPC to untrusted clients", + allowNo: true, + default: true }) }; @@ -116,6 +123,7 @@ export default class Daemon extends Command { "bitsocial daemon --pkcOptions.dataPath /tmp/bitsocial-datapath/", "bitsocial daemon --pkcOptions.kuboRpcClientsOptions[0] https://remoteipfsnode.com", "bitsocial daemon --chainProviderUrls https://mainnet.infura.io/v3/YOUR_KEY", + "bitsocial daemon --no-allowPrivateKeyExport", ]; private _setupLogger(Logger: PKCLoggerType) { @@ -446,7 +454,9 @@ export default class Daemon extends Command { const loadedChallenges = await loadChallengesIntoPKC(mergedPkcOptions.dataPath); if (loadedChallenges.length > 0) console.log(`Loaded challenge packages: ${loadedChallenges.join(", ")}`); - daemonServer = await startDaemonServer(pkcRpcUrl, ipfsGatewayEndpoint, mergedPkcOptions); + daemonServer = await startDaemonServer(pkcRpcUrl, ipfsGatewayEndpoint, mergedPkcOptions, { + allowPrivateKeyExport: flags.allowPrivateKeyExport + }); startedOwnRpc = true; console.log(`pkc rpc: listening on ${pkcRpcUrl} (local connections only)`); diff --git a/src/webui/daemon-server.ts b/src/webui/daemon-server.ts index 6c50431..17fd4b9 100644 --- a/src/webui/daemon-server.ts +++ b/src/webui/daemon-server.ts @@ -45,7 +45,12 @@ async function _generateRpcAuthKeyIfNotExisting(pkcDataPath: string) { } // The daemon server will host both RPC and webui on the same port -export async function startDaemonServer(rpcUrl: URL, ipfsGatewayUrl: URL, pkcOptions: any) { +export async function startDaemonServer( + rpcUrl: URL, + ipfsGatewayUrl: URL, + pkcOptions: any, + rpcServerOptions?: { allowPrivateKeyExport?: boolean } +) { // Start pkc-js RPC const log = PKCLogger("bitsocial-cli:daemon:startDaemonServer"); const webuiExpressApp = express(); @@ -77,7 +82,8 @@ export async function startDaemonServer(rpcUrl: URL, ipfsGatewayUrl: URL, pkcOpt const rpcServer = await PKCRpc.default.PKCWsServer({ server: httpServer, pkcOptions: pkcOptions, - authKey: rpcAuthKey + authKey: rpcAuthKey, + allowPrivateKeyExport: rpcServerOptions?.allowPrivateKeyExport }); const webuisDir = path.join(__dirname, "..", "..", "dist", "webuis"); diff --git a/test/cli/export.community.test.ts b/test/cli/export.community.test.ts new file mode 100644 index 0000000..86c0497 --- /dev/null +++ b/test/cli/export.community.test.ts @@ -0,0 +1,240 @@ +import { describe, it, beforeAll, afterAll, afterEach, beforeEach, expect } from "vitest"; +import Sinon from "sinon"; +import { EventEmitter } from "node:events"; +import { createHash } from "node:crypto"; +import http from "node:http"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { directory as randomDirectory } from "tempy"; +import { clearPkcRpcConnectOverride, setPkcRpcConnectOverride } from "../helpers/pkc-test-overrides.js"; +import { runCliCommand } from "../helpers/run-cli.js"; +import Daemon from "../../src/cli/commands/daemon.js"; + +const EXPORT_ID = "11111111-2222-3333-4444-555555555555"; +const COMMUNITY_ADDRESS = "plebbit.bso"; + +const snapshotContent = Buffer.from("fake sqlite snapshot content for community export test"); +const snapshotSha256 = createHash("sha256").update(snapshotContent).digest("hex"); + +type FakeExportRecord = { + exportId: string; + publicKey: string; + includePrivateKey: boolean; + progress: number; + size?: number; + sha256?: string; + url?: string; + error?: { code: string; message: string }; +}; + +describe("bitsocial community export", () => { + const sandbox = Sinon.createSandbox(); + + let httpServer: http.Server; + let downloadUrl: string; + let servedContent: Buffer; // what the http server streams for the download + let serverRequestPaths: string[] = []; + + let exportFake: Sinon.SinonSpy; + const destroyFake = sandbox.fake(); + let createCommunityFake: Sinon.SinonSpy; + let failExportWith: { code: string; message: string } | undefined; + let tmpDir: string; + + const makeFakeCommunity = () => { + const emitter = new EventEmitter(); + const records: FakeExportRecord[] = []; + exportFake = sandbox.fake(async (options?: { includePrivateKey?: boolean; signal?: AbortSignal }) => { + const record: FakeExportRecord = { + exportId: EXPORT_ID, + publicKey: "12D3KooWTest", + includePrivateKey: Boolean(options?.includePrivateKey), + progress: 0 + }; + records.push(record); + // Emit progress transitions asynchronously, like the real RPC subscription does + setTimeout(() => { + record.progress = 0.5; + emitter.emit("exportschange", [...records]); + if (failExportWith) record.error = failExportWith; + else { + record.progress = 1; + record.size = snapshotContent.length; + record.sha256 = snapshotSha256; + record.url = downloadUrl; + } + emitter.emit("exportschange", [...records]); + }, 10); + return { exportId: EXPORT_ID }; + }); + return { + address: COMMUNITY_ADDRESS, + on: emitter.on.bind(emitter), + removeListener: emitter.removeListener.bind(emitter), + get exports() { + return [...records]; + }, + export: exportFake + }; + }; + + beforeAll(async () => { + httpServer = http.createServer((req, res) => { + serverRequestPaths.push(req.url ?? ""); + res.statusCode = 200; + res.end(servedContent); + }); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const port = (httpServer.address() as import("node:net").AddressInfo).port; + downloadUrl = `http://127.0.0.1:${port}/exports/${EXPORT_ID}`; + + createCommunityFake = sandbox.fake(async () => makeFakeCommunity()); + const pkcInstanceFake = sandbox.fake.resolves({ + createCommunity: createCommunityFake, + destroy: destroyFake + }); + setPkcRpcConnectOverride(pkcInstanceFake); + }); + + beforeEach(() => { + tmpDir = randomDirectory(); + servedContent = snapshotContent; + serverRequestPaths = []; + failExportWith = undefined; + }); + + afterEach(() => { + createCommunityFake.resetHistory(); + destroyFake.resetHistory(); + }); + + afterAll(async () => { + clearPkcRpcConnectOverride(); + sandbox.restore(); + await new Promise((resolve, reject) => httpServer.close((err) => (err ? reject(err) : resolve()))); + }); + + it("Exports a community, downloads the snapshot, verifies sha256 and prints the destination path", async () => { + const destPath = path.join(tmpDir, "plebbit.sqlite"); + const { result, stdout } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath}`); + + expect(result.error).toBeUndefined(); + expect(createCommunityFake.calledOnceWith({ address: COMMUNITY_ADDRESS })).toBe(true); + expect(exportFake.calledOnce).toBe(true); + expect(exportFake.args[0][0].includePrivateKey).toBe(false); + expect(serverRequestPaths).toEqual([`/exports/${EXPORT_ID}`]); + expect(destroyFake.calledOnce).toBe(true); + + expect(stdout.trim()).toBe(destPath); + const downloaded = await fs.readFile(destPath); + expect(downloaded.equals(snapshotContent)).toBe(true); + // No leftover .partial file + await expect(fs.stat(destPath + ".partial")).rejects.toThrow(); + }); + + it("Requests the private key with --includePrivateKey", async () => { + const destPath = path.join(tmpDir, "with-key.sqlite"); + const { result } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --includePrivateKey --quiet -o ${destPath}`); + + expect(result.error).toBeUndefined(); + expect(exportFake.calledOnce).toBe(true); + expect(exportFake.args[0][0].includePrivateKey).toBe(true); + }); + + it("Looks up community by --name", async () => { + const destPath = path.join(tmpDir, "by-name.sqlite"); + const { result } = await runCliCommand(`community export --name my-community --quiet -o ${destPath}`); + + expect(result.error).toBeUndefined(); + expect(createCommunityFake.calledOnceWith({ name: "my-community" })).toBe(true); + }); + + it("Looks up community by --publicKey", async () => { + const destPath = path.join(tmpDir, "by-publickey.sqlite"); + const { result } = await runCliCommand(`community export --publicKey 12D3KooWTest --quiet -o ${destPath}`); + + expect(result.error).toBeUndefined(); + expect(createCommunityFake.calledOnceWith({ publicKey: "12D3KooWTest" })).toBe(true); + }); + + it("Errors when no identifier is provided", async () => { + const { result } = await runCliCommand("community export"); + + expect(result.error).toBeDefined(); + expect(createCommunityFake.called).toBe(false); + }); + + it("Refuses to overwrite an existing destination file without --force", async () => { + const destPath = path.join(tmpDir, "existing.sqlite"); + await fs.writeFile(destPath, "previous backup"); + + const { result, stderr } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath}`); + + expect(result.error).toBeDefined(); + expect(stderr).toContain("already exists"); + // Existing file untouched, no export started + expect((await fs.readFile(destPath)).toString()).toBe("previous backup"); + expect(exportFake.called).toBe(false); + }); + + it("Overwrites an existing destination file with --force", async () => { + const destPath = path.join(tmpDir, "existing.sqlite"); + await fs.writeFile(destPath, "previous backup"); + + const { result } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet --force -o ${destPath}`); + + expect(result.error).toBeUndefined(); + const downloaded = await fs.readFile(destPath); + expect(downloaded.equals(snapshotContent)).toBe(true); + }); + + it("Fails and leaves no file behind when the downloaded sha256 does not match the export record", async () => { + servedContent = Buffer.from("corrupted content that does not match the record's sha256"); + const destPath = path.join(tmpDir, "corrupted.sqlite"); + + const { result, stderr } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath}`); + + expect(result.error).toBeDefined(); + expect(stderr).toContain("sha256 mismatch"); + await expect(fs.stat(destPath)).rejects.toThrow(); + await expect(fs.stat(destPath + ".partial")).rejects.toThrow(); + }); + + it("Fails when the export record reports an error (e.g. private key export not allowed)", async () => { + failExportWith = { code: "ERR_PRIVATE_KEY_EXPORT_NOT_ALLOWED", message: "The RPC server does not allow private key exports" }; + const destPath = path.join(tmpDir, "not-allowed.sqlite"); + + const { result, stderr } = await runCliCommand( + `community export ${COMMUNITY_ADDRESS} --includePrivateKey --quiet -o ${destPath}` + ); + + expect(result.error).toBeDefined(); + expect(stderr).toContain("ERR_PRIVATE_KEY_EXPORT_NOT_ALLOWED"); + await expect(fs.stat(destPath)).rejects.toThrow(); + }); + + it("Errors when the community is not local to the RPC server", async () => { + // Remote communities returned by createCommunity have no export() method + const remoteOnlyCreateFake = sandbox.fake(async () => ({ address: COMMUNITY_ADDRESS })); + setPkcRpcConnectOverride(sandbox.fake.resolves({ createCommunity: remoteOnlyCreateFake, destroy: destroyFake })); + + const destPath = path.join(tmpDir, "remote.sqlite"); + const { result, stderr } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath}`); + + expect(result.error).toBeDefined(); + expect(stderr).toContain("not local"); + + // Restore the default override for any following test + setPkcRpcConnectOverride(sandbox.fake.resolves({ createCommunity: createCommunityFake, destroy: destroyFake })); + }); +}); + +describe("bitsocial daemon --allowPrivateKeyExport flag", () => { + it("Is defined as a negatable boolean defaulting to true", () => { + const flag = Daemon.flags.allowPrivateKeyExport; + expect(flag).toBeDefined(); + expect(flag.type).toBe("boolean"); + expect(flag.allowNo).toBe(true); + expect(flag.default).toBe(true); + }); +}); From 779a85fb4f3d14785ce47b4bcadd55d0e0a952a0 Mon Sep 17 00:00:00 2001 From: Rinse Date: Sun, 7 Jun 2026 04:18:58 +0000 Subject: [PATCH 3/5] fix(community): address CodeRabbit review on export command (#65) - Reject _runExport() on abort instead of waiting for the server's terminal ERR_EXPORT_CANCELLED record, which never arrives if the daemon died or the connection dropped. pkc-js's own abort listener (registered first inside community.export()) still dispatches cancelExport() to the server. - Validate the export record's download URL against the RPC server's http origin (ws[s]:// swapped to http[s]://, mirroring pkc-js's rpcHttpOrigin) and require a /exports/ path, so a misconfigured or compromised RPC server can't use the CLI to fetch arbitrary URLs. - Add tests for both: wrong-origin URL is refused with no request made, and an aborted export with a silent daemon rejects instead of hanging. --- src/cli/commands/community/export.ts | 37 +++++++++++++++--- test/cli/export.community.test.ts | 58 +++++++++++++++++++++++----- 2 files changed, 80 insertions(+), 15 deletions(-) diff --git a/src/cli/commands/community/export.ts b/src/cli/commands/community/export.ts index 274eeb7..b6bbd27 100644 --- a/src/cli/commands/community/export.ts +++ b/src/cli/commands/community/export.ts @@ -133,7 +133,11 @@ export default class Export extends BaseCommand { this.error(`Export ${finishedRecord.exportId} finished but the RPC server did not provide a download URL`); } - await this._downloadAndVerify(finishedRecord, destPath, abortController.signal, flags.quiet); + // Mirrors pkc-js's rpcHttpOrigin: the ws[s]:// RPC URL with the protocol swapped to http[s]:// + const parsedRpcUrl = new URL(flags.pkcRpcUrl.toString()); + const expectedDownloadOrigin = `${parsedRpcUrl.protocol === "wss:" ? "https:" : "http:"}//${parsedRpcUrl.host}`; + + await this._downloadAndVerify(finishedRecord, destPath, abortController.signal, flags.quiet, expectedDownloadOrigin); this.log(destPath); } catch (e) { @@ -157,14 +161,25 @@ export default class Export extends BaseCommand { return new Promise((resolve, reject) => { let lastPrintedPercent = -1; + const cleanup = () => { + community.removeListener("exportschange", checkRecords); + signal.removeEventListener("abort", onAbort); + }; + // Don't wait for the server's terminal ERR_EXPORT_CANCELLED record — it never arrives if the + // daemon died or the connection dropped. pkc-js's own abort listener (registered inside + // community.export(), before this one) already dispatched cancelExport() to the server. + const onAbort = () => { + cleanup(); + reject(new Error("Export cancelled")); + }; const checkRecords = (records: ExportRecord[]) => { const record = records.find((rec) => rec.exportId === exportId); if (!record) return; if (record.error) { - community.removeListener("exportschange", checkRecords); + cleanup(); reject(new Error(`Export failed (${record.error.code}): ${record.error.message}`)); } else if (record.progress === 1) { - community.removeListener("exportschange", checkRecords); + cleanup(); this._printProgress(quiet, `\rExporting ${community.address}: 100%\n`); resolve(record); } else { @@ -176,15 +191,25 @@ export default class Export extends BaseCommand { } }; community.on("exportschange", checkRecords); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) return onAbort(); // The terminal notification may have arrived before the listener was attached checkRecords(community.exports); }); } /** Download the finished snapshot to destPath, verifying its sha256 against the export record. */ - private async _downloadAndVerify(record: ExportRecord, destPath: string, signal: AbortSignal, quiet: boolean) { - this._printProgress(quiet, `Downloading snapshot from ${record.url}\n`); - const response = await fetch(record.url!, { signal }); + private async _downloadAndVerify(record: ExportRecord, destPath: string, signal: AbortSignal, quiet: boolean, expectedOrigin: string) { + // The export download contract is GET /exports/ — refuse anything else + // so a misconfigured/compromised RPC server can't use the CLI to fetch arbitrary URLs + const downloadUrl = new URL(record.url!); + if (downloadUrl.origin !== expectedOrigin || !downloadUrl.pathname.startsWith("/exports/")) { + this.error( + `Refusing to download export from unexpected URL ${record.url} (expected ${expectedOrigin}/exports/)` + ); + } + this._printProgress(quiet, `Downloading snapshot from ${downloadUrl}\n`); + const response = await fetch(downloadUrl, { signal }); if (!response.ok || !response.body) { this.error(`Failed to download export from ${record.url}: HTTP ${response.status}`); } diff --git a/test/cli/export.community.test.ts b/test/cli/export.community.test.ts index 86c0497..2b0130d 100644 --- a/test/cli/export.community.test.ts +++ b/test/cli/export.community.test.ts @@ -32,6 +32,8 @@ describe("bitsocial community export", () => { let httpServer: http.Server; let downloadUrl: string; + let defaultDownloadUrl: string; + let rpcUrlFlag: string; // --pkcRpcUrl matching the test http server's origin, so the download URL passes origin validation let servedContent: Buffer; // what the http server streams for the download let serverRequestPaths: string[] = []; @@ -86,7 +88,9 @@ describe("bitsocial community export", () => { }); await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); const port = (httpServer.address() as import("node:net").AddressInfo).port; - downloadUrl = `http://127.0.0.1:${port}/exports/${EXPORT_ID}`; + defaultDownloadUrl = `http://127.0.0.1:${port}/exports/${EXPORT_ID}`; + downloadUrl = defaultDownloadUrl; + rpcUrlFlag = `--pkcRpcUrl ws://127.0.0.1:${port}`; createCommunityFake = sandbox.fake(async () => makeFakeCommunity()); const pkcInstanceFake = sandbox.fake.resolves({ @@ -101,6 +105,7 @@ describe("bitsocial community export", () => { servedContent = snapshotContent; serverRequestPaths = []; failExportWith = undefined; + downloadUrl = defaultDownloadUrl; }); afterEach(() => { @@ -116,7 +121,7 @@ describe("bitsocial community export", () => { it("Exports a community, downloads the snapshot, verifies sha256 and prints the destination path", async () => { const destPath = path.join(tmpDir, "plebbit.sqlite"); - const { result, stdout } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath}`); + const { result, stdout } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath} ${rpcUrlFlag}`); expect(result.error).toBeUndefined(); expect(createCommunityFake.calledOnceWith({ address: COMMUNITY_ADDRESS })).toBe(true); @@ -134,7 +139,7 @@ describe("bitsocial community export", () => { it("Requests the private key with --includePrivateKey", async () => { const destPath = path.join(tmpDir, "with-key.sqlite"); - const { result } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --includePrivateKey --quiet -o ${destPath}`); + const { result } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --includePrivateKey --quiet -o ${destPath} ${rpcUrlFlag}`); expect(result.error).toBeUndefined(); expect(exportFake.calledOnce).toBe(true); @@ -143,7 +148,7 @@ describe("bitsocial community export", () => { it("Looks up community by --name", async () => { const destPath = path.join(tmpDir, "by-name.sqlite"); - const { result } = await runCliCommand(`community export --name my-community --quiet -o ${destPath}`); + const { result } = await runCliCommand(`community export --name my-community --quiet -o ${destPath} ${rpcUrlFlag}`); expect(result.error).toBeUndefined(); expect(createCommunityFake.calledOnceWith({ name: "my-community" })).toBe(true); @@ -151,7 +156,7 @@ describe("bitsocial community export", () => { it("Looks up community by --publicKey", async () => { const destPath = path.join(tmpDir, "by-publickey.sqlite"); - const { result } = await runCliCommand(`community export --publicKey 12D3KooWTest --quiet -o ${destPath}`); + const { result } = await runCliCommand(`community export --publicKey 12D3KooWTest --quiet -o ${destPath} ${rpcUrlFlag}`); expect(result.error).toBeUndefined(); expect(createCommunityFake.calledOnceWith({ publicKey: "12D3KooWTest" })).toBe(true); @@ -168,7 +173,7 @@ describe("bitsocial community export", () => { const destPath = path.join(tmpDir, "existing.sqlite"); await fs.writeFile(destPath, "previous backup"); - const { result, stderr } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath}`); + const { result, stderr } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath} ${rpcUrlFlag}`); expect(result.error).toBeDefined(); expect(stderr).toContain("already exists"); @@ -181,7 +186,7 @@ describe("bitsocial community export", () => { const destPath = path.join(tmpDir, "existing.sqlite"); await fs.writeFile(destPath, "previous backup"); - const { result } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet --force -o ${destPath}`); + const { result } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet --force -o ${destPath} ${rpcUrlFlag}`); expect(result.error).toBeUndefined(); const downloaded = await fs.readFile(destPath); @@ -192,7 +197,7 @@ describe("bitsocial community export", () => { servedContent = Buffer.from("corrupted content that does not match the record's sha256"); const destPath = path.join(tmpDir, "corrupted.sqlite"); - const { result, stderr } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath}`); + const { result, stderr } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath} ${rpcUrlFlag}`); expect(result.error).toBeDefined(); expect(stderr).toContain("sha256 mismatch"); @@ -213,13 +218,48 @@ describe("bitsocial community export", () => { await expect(fs.stat(destPath)).rejects.toThrow(); }); + it("Refuses to download when the export record's URL is not on the RPC server's origin", async () => { + downloadUrl = `http://evil.example.com/exports/${EXPORT_ID}`; + const destPath = path.join(tmpDir, "wrong-origin.sqlite"); + + const { result, stderr } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath} ${rpcUrlFlag}`); + + expect(result.error).toBeDefined(); + expect(stderr).toContain("Refusing to download"); + expect(serverRequestPaths).toEqual([]); // nothing was fetched + await expect(fs.stat(destPath)).rejects.toThrow(); + }); + + it("Rejects instead of hanging when the export is aborted and no terminal record arrives", async () => { + // Simulates Ctrl+C with a dead daemon: cancelExport never produces a terminal record. + // Calls the private _runExport directly since the command's SIGINT handler can't be + // triggered safely inside the test runner (vitest has its own SIGINT listeners). + const emitter = new EventEmitter(); + const silentCommunity = { + address: COMMUNITY_ADDRESS, + on: emitter.on.bind(emitter), + removeListener: emitter.removeListener.bind(emitter), + exports: [], + export: async () => ({ exportId: EXPORT_ID }) // never emits exportschange + }; + const ExportCommand = (await import("../../src/cli/commands/community/export.js")).default; + const commandInstance = Object.create(ExportCommand.prototype) as { _runExport: Function }; + + const abortController = new AbortController(); + const exportPromise = commandInstance._runExport(silentCommunity, false, abortController.signal, true) as Promise; + setTimeout(() => abortController.abort(), 10); + + await expect(exportPromise).rejects.toThrow("Export cancelled"); + expect(emitter.listenerCount("exportschange")).toBe(0); // listener cleaned up + }); + it("Errors when the community is not local to the RPC server", async () => { // Remote communities returned by createCommunity have no export() method const remoteOnlyCreateFake = sandbox.fake(async () => ({ address: COMMUNITY_ADDRESS })); setPkcRpcConnectOverride(sandbox.fake.resolves({ createCommunity: remoteOnlyCreateFake, destroy: destroyFake })); const destPath = path.join(tmpDir, "remote.sqlite"); - const { result, stderr } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath}`); + const { result, stderr } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath} ${rpcUrlFlag}`); expect(result.error).toBeDefined(); expect(stderr).toContain("not local"); From 52c5ac1ada50d96f3b9cc18a0e7f3dea7dd51785 Mon Sep 17 00:00:00 2001 From: Rinse Date: Sun, 7 Jun 2026 05:05:20 +0000 Subject: [PATCH 4/5] feat(community): default export destination to /exports/
_.sqlite The default -o/--path was ./
.sqlite (cwd-relative). Snapshots now land in the exports directory inside the data path, with an ISO 8601 datetime (':' -> '-', matching the daemon log filename convention) so repeated exports never collide and sort chronologically. -o still overrides entirely. Also closes the export command's test coverage gaps found in review: - HTTP non-200 download response and mid-stream connection drop (no destination file or .partial left behind) - finished record with no download URL - same-origin-but-non-/exports download URL refused (second half of the URL validation), wss:// RPC URL -> https:// expected download origin - record without sha256 skips verification (pins current behavior) - default destination directory + filename pattern (XDG_DATA_HOME pointed at a temp dir so tests never write into a real data path) - intermediate destination directories created; destination-is-a-directory with --force fails cleanly - address + --name combined into a single lookup - non-quiet mode: progress on stderr, stdout only the destination path; --quiet asserted to suppress progress - pkc.destroy() asserted on all failure paths (connection-leak coverage) - _runExport unit tests: terminal success/error record already present before listener attach, pre-aborted signal, records of other exportIds ignored --- src/cli/commands/community/export.ts | 8 +- test/cli/export.community.test.ts | 294 ++++++++++++++++++++++++++- 2 files changed, 294 insertions(+), 8 deletions(-) diff --git a/src/cli/commands/community/export.ts b/src/cli/commands/community/export.ts index b6bbd27..d577e24 100644 --- a/src/cli/commands/community/export.ts +++ b/src/cli/commands/community/export.ts @@ -1,5 +1,6 @@ import { Args, Flags } from "@oclif/core"; import { BaseCommand } from "../../base-command.js"; +import defaults from "../../../common-utils/defaults.js"; import { PKCLogger } from "../../../util.js"; import { createHash } from "node:crypto"; @@ -59,7 +60,7 @@ export default class Export extends BaseCommand { }), path: Flags.string({ char: "o", - description: "Destination file for the downloaded snapshot (default: ./
.sqlite)" + description: "Destination file for the downloaded snapshot (default: /exports/
_.sqlite)" }), includePrivateKey: Flags.boolean({ default: false, @@ -117,7 +118,10 @@ export default class Export extends BaseCommand { } const exportableCommunity = community as ExportableCommunity; - const destPath = path.resolve(flags.path ?? `${exportableCommunity.address}.sqlite`); + // Datetime in the filename matches the daemon log convention (ISO 8601 with ':' → '-') + // so repeated exports never collide and snapshots sort chronologically + const defaultFilename = `${exportableCommunity.address}_${new Date().toISOString().replace(/:/g, "-")}.sqlite`; + const destPath = path.resolve(flags.path ?? path.join(defaults.PKC_DATA_PATH, "exports", defaultFilename)); const destExists = await fs .stat(destPath) .then(() => true) diff --git a/test/cli/export.community.test.ts b/test/cli/export.community.test.ts index 2b0130d..2ba31da 100644 --- a/test/cli/export.community.test.ts +++ b/test/cli/export.community.test.ts @@ -6,6 +6,7 @@ import http from "node:http"; import path from "node:path"; import fs from "node:fs/promises"; import { directory as randomDirectory } from "tempy"; +import envPaths from "env-paths"; import { clearPkcRpcConnectOverride, setPkcRpcConnectOverride } from "../helpers/pkc-test-overrides.js"; import { runCliCommand } from "../helpers/run-cli.js"; import Daemon from "../../src/cli/commands/daemon.js"; @@ -31,16 +32,21 @@ describe("bitsocial community export", () => { const sandbox = Sinon.createSandbox(); let httpServer: http.Server; + let serverPort: number; let downloadUrl: string; let defaultDownloadUrl: string; let rpcUrlFlag: string; // --pkcRpcUrl matching the test http server's origin, so the download URL passes origin validation let servedContent: Buffer; // what the http server streams for the download let serverRequestPaths: string[] = []; + let serverStatusCode: number; // status code the http server responds with + let destroyMidResponse: boolean; // server kills the socket after a few bytes let exportFake: Sinon.SinonSpy; const destroyFake = sandbox.fake(); let createCommunityFake: Sinon.SinonSpy; let failExportWith: { code: string; message: string } | undefined; + let omitUrl: boolean; // terminal export record has no url + let omitSha256: boolean; // terminal export record has no sha256 let tmpDir: string; const makeFakeCommunity = () => { @@ -62,8 +68,8 @@ describe("bitsocial community export", () => { else { record.progress = 1; record.size = snapshotContent.length; - record.sha256 = snapshotSha256; - record.url = downloadUrl; + if (!omitSha256) record.sha256 = snapshotSha256; + if (!omitUrl) record.url = downloadUrl; } emitter.emit("exportschange", [...records]); }, 10); @@ -81,16 +87,30 @@ describe("bitsocial community export", () => { }; beforeAll(async () => { + // Isolate the default data path (used for the default export destination) to a temp dir. + // env-paths reads XDG_DATA_HOME on linux, and dist's defaults.ts evaluates it lazily when + // oclif first loads the export command — i.e. after this hook. Each test file runs in its + // own fork (vitest pool: "forks"), so this doesn't leak to other files. + process.env["XDG_DATA_HOME"] = randomDirectory(); + httpServer = http.createServer((req, res) => { serverRequestPaths.push(req.url ?? ""); - res.statusCode = 200; + if (destroyMidResponse) { + // Announce the full length, send a few bytes, then kill the socket so the + // client sees a premature close mid-download + res.writeHead(200, { "Content-Length": String(servedContent.length) }); + res.write(servedContent.subarray(0, 10)); + res.destroy(); + return; + } + res.statusCode = serverStatusCode; res.end(servedContent); }); await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); - const port = (httpServer.address() as import("node:net").AddressInfo).port; - defaultDownloadUrl = `http://127.0.0.1:${port}/exports/${EXPORT_ID}`; + serverPort = (httpServer.address() as import("node:net").AddressInfo).port; + defaultDownloadUrl = `http://127.0.0.1:${serverPort}/exports/${EXPORT_ID}`; downloadUrl = defaultDownloadUrl; - rpcUrlFlag = `--pkcRpcUrl ws://127.0.0.1:${port}`; + rpcUrlFlag = `--pkcRpcUrl ws://127.0.0.1:${serverPort}`; createCommunityFake = sandbox.fake(async () => makeFakeCommunity()); const pkcInstanceFake = sandbox.fake.resolves({ @@ -104,7 +124,11 @@ describe("bitsocial community export", () => { tmpDir = randomDirectory(); servedContent = snapshotContent; serverRequestPaths = []; + serverStatusCode = 200; + destroyMidResponse = false; failExportWith = undefined; + omitUrl = false; + omitSha256 = false; downloadUrl = defaultDownloadUrl; }); @@ -131,6 +155,9 @@ describe("bitsocial community export", () => { expect(destroyFake.calledOnce).toBe(true); expect(stdout.trim()).toBe(destPath); + // --quiet suppresses all progress output + expect(result.stderr).not.toContain("Exporting"); + expect(result.stderr).not.toContain("Downloading"); const downloaded = await fs.readFile(destPath); expect(downloaded.equals(snapshotContent)).toBe(true); // No leftover .partial file @@ -180,6 +207,7 @@ describe("bitsocial community export", () => { // Existing file untouched, no export started expect((await fs.readFile(destPath)).toString()).toBe("previous backup"); expect(exportFake.called).toBe(false); + expect(destroyFake.calledOnce).toBe(true); // no leaked RPC connection on failure }); it("Overwrites an existing destination file with --force", async () => { @@ -203,6 +231,7 @@ describe("bitsocial community export", () => { expect(stderr).toContain("sha256 mismatch"); await expect(fs.stat(destPath)).rejects.toThrow(); await expect(fs.stat(destPath + ".partial")).rejects.toThrow(); + expect(destroyFake.calledOnce).toBe(true); // no leaked RPC connection on failure }); it("Fails when the export record reports an error (e.g. private key export not allowed)", async () => { @@ -216,6 +245,7 @@ describe("bitsocial community export", () => { expect(result.error).toBeDefined(); expect(stderr).toContain("ERR_PRIVATE_KEY_EXPORT_NOT_ALLOWED"); await expect(fs.stat(destPath)).rejects.toThrow(); + expect(destroyFake.calledOnce).toBe(true); // no leaked RPC connection on failure }); it("Refuses to download when the export record's URL is not on the RPC server's origin", async () => { @@ -228,6 +258,7 @@ describe("bitsocial community export", () => { expect(stderr).toContain("Refusing to download"); expect(serverRequestPaths).toEqual([]); // nothing was fetched await expect(fs.stat(destPath)).rejects.toThrow(); + expect(destroyFake.calledOnce).toBe(true); // no leaked RPC connection on failure }); it("Rejects instead of hanging when the export is aborted and no terminal record arrives", async () => { @@ -263,10 +294,261 @@ describe("bitsocial community export", () => { expect(result.error).toBeDefined(); expect(stderr).toContain("not local"); + expect(destroyFake.calledOnce).toBe(true); // no leaked RPC connection on failure // Restore the default override for any following test setPkcRpcConnectOverride(sandbox.fake.resolves({ createCommunity: createCommunityFake, destroy: destroyFake })); }); + + it("Fails and leaves no file behind when the download returns a non-200 response", async () => { + serverStatusCode = 500; + const destPath = path.join(tmpDir, "http-error.sqlite"); + + const { result, stderr } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath} ${rpcUrlFlag}`); + + expect(result.error).toBeDefined(); + expect(stderr).toContain("HTTP 500"); + expect(serverRequestPaths).toEqual([`/exports/${EXPORT_ID}`]); + await expect(fs.stat(destPath)).rejects.toThrow(); + await expect(fs.stat(destPath + ".partial")).rejects.toThrow(); + expect(destroyFake.calledOnce).toBe(true); + }); + + it("Fails and cleans up the .partial file when the connection drops mid-download", async () => { + destroyMidResponse = true; + const destPath = path.join(tmpDir, "dropped.sqlite"); + + const { result } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath} ${rpcUrlFlag}`); + + expect(result.error).toBeDefined(); + await expect(fs.stat(destPath)).rejects.toThrow(); + await expect(fs.stat(destPath + ".partial")).rejects.toThrow(); + expect(destroyFake.calledOnce).toBe(true); + }); + + it("Errors when the finished export record has no download URL", async () => { + omitUrl = true; + const destPath = path.join(tmpDir, "no-url.sqlite"); + + const { result, stderr } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath} ${rpcUrlFlag}`); + + expect(result.error).toBeDefined(); + expect(stderr).toContain("did not provide a download URL"); + expect(serverRequestPaths).toEqual([]); // nothing was fetched + await expect(fs.stat(destPath)).rejects.toThrow(); + }); + + it("Refuses to download when the URL is on the RPC server's origin but outside /exports/", async () => { + downloadUrl = `http://127.0.0.1:${serverPort}/secrets/${EXPORT_ID}`; + const destPath = path.join(tmpDir, "wrong-path.sqlite"); + + const { result, stderr } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath} ${rpcUrlFlag}`); + + expect(result.error).toBeDefined(); + expect(stderr).toContain("Refusing to download"); + expect(serverRequestPaths).toEqual([]); // nothing was fetched + await expect(fs.stat(destPath)).rejects.toThrow(); + }); + + it("Expects an https:// download origin when the RPC URL is wss://", async () => { + // The connect override never dials, so a wss:// RPC URL works without a TLS server. + // The record's http:// URL must be refused because the expected origin is https:// + const destPath = path.join(tmpDir, "wss-origin.sqlite"); + + const { result, stderr } = await runCliCommand( + `community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath} --pkcRpcUrl wss://127.0.0.1:${serverPort}` + ); + + expect(result.error).toBeDefined(); + expect(stderr).toContain("Refusing to download"); + expect(stderr).toContain(`https://127.0.0.1:${serverPort}`); + expect(serverRequestPaths).toEqual([]); // nothing was fetched + }); + + it("Skips sha256 verification when the export record has none (current behavior)", async () => { + // Pins the `if (record.sha256 && ...)` branch: a record without a checksum downloads + // without verification instead of failing. Tighten deliberately if this should error. + omitSha256 = true; + const destPath = path.join(tmpDir, "no-sha.sqlite"); + + const { result } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath} ${rpcUrlFlag}`); + + expect(result.error).toBeUndefined(); + const downloaded = await fs.readFile(destPath); + expect(downloaded.equals(snapshotContent)).toBe(true); + }); + + it("Defaults the destination to /exports/
_.sqlite when -o is not given", async () => { + const { result, stdout } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet ${rpcUrlFlag}`); + + expect(result.error).toBeUndefined(); + const printedPath = stdout.trim(); + // Same computation as dist's defaults.PKC_DATA_PATH — both read XDG_DATA_HOME set in beforeAll + const expectedDir = path.join(envPaths("bitsocial", { suffix: "" }).data, "exports"); + expect(path.dirname(printedPath)).toBe(expectedDir); + //
_.sqlite, like the daemon log filenames + expect(path.basename(printedPath)).toMatch( + new RegExp(`^${COMMUNITY_ADDRESS.replaceAll(".", "\\.")}_\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\.\\d{3}Z\\.sqlite$`) + ); + const downloaded = await fs.readFile(printedPath); + expect(downloaded.equals(snapshotContent)).toBe(true); + // Don't leave snapshots behind in the (possibly real, if XDG_DATA_HOME is ignored) data path + await fs.rm(printedPath, { force: true }); + }); + + it("Creates intermediate directories for the destination path", async () => { + const destPath = path.join(tmpDir, "nested", "deeper", "out.sqlite"); + + const { result, stdout } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet -o ${destPath} ${rpcUrlFlag}`); + + expect(result.error).toBeUndefined(); + expect(stdout.trim()).toBe(destPath); + const downloaded = await fs.readFile(destPath); + expect(downloaded.equals(snapshotContent)).toBe(true); + }); + + it("Fails without leaving a .partial behind when the destination is a directory (even with --force)", async () => { + const destPath = path.join(tmpDir, "dest-is-dir"); + await fs.mkdir(destPath); + + const { result } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} --quiet --force -o ${destPath} ${rpcUrlFlag}`); + + expect(result.error).toBeDefined(); + expect((await fs.stat(destPath)).isDirectory()).toBe(true); // directory untouched + await expect(fs.stat(destPath + ".partial")).rejects.toThrow(); + }); + + it("Combines the address argument with --name into a single lookup", async () => { + const destPath = path.join(tmpDir, "combined.sqlite"); + + const { result } = await runCliCommand( + `community export ${COMMUNITY_ADDRESS} --name my-community --quiet -o ${destPath} ${rpcUrlFlag}` + ); + + expect(result.error).toBeUndefined(); + expect(createCommunityFake.calledOnceWith({ address: COMMUNITY_ADDRESS, name: "my-community" })).toBe(true); + }); + + it("Prints progress to stderr and only the destination path to stdout without --quiet", async () => { + const destPath = path.join(tmpDir, "progress.sqlite"); + + const { result, stdout } = await runCliCommand(`community export ${COMMUNITY_ADDRESS} -o ${destPath} ${rpcUrlFlag}`); + + expect(result.error).toBeUndefined(); + // stdout stays scriptable: only the destination path + expect(stdout.trim()).toBe(destPath); + // progress goes to stderr + expect(result.stderr).toContain(`Exporting ${COMMUNITY_ADDRESS}`); + expect(result.stderr).toContain("100%"); + expect(result.stderr).toContain("Downloading snapshot from"); + expect(result.stderr).toContain("Verified sha256"); + }); +}); + +describe("community export _runExport", () => { + const makeBareExportCommand = async () => { + const ExportCommand = (await import("../../src/cli/commands/community/export.js")).default; + return Object.create(ExportCommand.prototype) as { _runExport: Function }; + }; + + const TERMINAL_RECORD = { + exportId: EXPORT_ID, + publicKey: "12D3KooWTest", + includePrivateKey: false, + progress: 1, + size: snapshotContent.length, + sha256: snapshotSha256, + url: `http://127.0.0.1:1/exports/${EXPORT_ID}` + }; + + it("Resolves when the terminal record is already present before the listener is attached", async () => { + // Covers the race where the exportschange notification fired before _runExport subscribed: + // the record is read from community.exports, no event is ever emitted + const emitter = new EventEmitter(); + const community = { + address: COMMUNITY_ADDRESS, + on: emitter.on.bind(emitter), + removeListener: emitter.removeListener.bind(emitter), + exports: [TERMINAL_RECORD], + export: async () => ({ exportId: EXPORT_ID }) // never emits exportschange + }; + const commandInstance = await makeBareExportCommand(); + + const record = await commandInstance._runExport(community, false, new AbortController().signal, true); + + expect(record).toEqual(TERMINAL_RECORD); + expect(emitter.listenerCount("exportschange")).toBe(0); // listener cleaned up + }); + + it("Rejects when an error record is already present before the listener is attached", async () => { + const emitter = new EventEmitter(); + const community = { + address: COMMUNITY_ADDRESS, + on: emitter.on.bind(emitter), + removeListener: emitter.removeListener.bind(emitter), + exports: [ + { + ...TERMINAL_RECORD, + progress: 0.5, + error: { code: "ERR_EXPORT_FAILED", message: "boom" } + } + ], + export: async () => ({ exportId: EXPORT_ID }) // never emits exportschange + }; + const commandInstance = await makeBareExportCommand(); + + await expect(commandInstance._runExport(community, false, new AbortController().signal, true)).rejects.toThrow( + "ERR_EXPORT_FAILED" + ); + expect(emitter.listenerCount("exportschange")).toBe(0); + }); + + it("Rejects immediately when the signal is already aborted", async () => { + const emitter = new EventEmitter(); + const community = { + address: COMMUNITY_ADDRESS, + on: emitter.on.bind(emitter), + removeListener: emitter.removeListener.bind(emitter), + exports: [], + export: async () => ({ exportId: EXPORT_ID }) + }; + const commandInstance = await makeBareExportCommand(); + const abortController = new AbortController(); + abortController.abort(); // aborted before _runExport attaches its listeners + + await expect(commandInstance._runExport(community, false, abortController.signal, true)).rejects.toThrow("Export cancelled"); + expect(emitter.listenerCount("exportschange")).toBe(0); + }); + + it("Ignores records of other exports (including errored ones)", async () => { + const staleErrorRecord = { + ...TERMINAL_RECORD, + exportId: "99999999-8888-7777-6666-555555555555", + progress: 0.5, + error: { code: "ERR_EXPORT_CANCELLED", message: "previous export was cancelled" } + }; + const emitter = new EventEmitter(); + const community = { + address: COMMUNITY_ADDRESS, + on: emitter.on.bind(emitter), + removeListener: emitter.removeListener.bind(emitter), + exports: [staleErrorRecord], + export: async () => { + setTimeout(() => { + // First notification holds only the stale record — must be ignored, not rejected + emitter.emit("exportschange", [staleErrorRecord]); + emitter.emit("exportschange", [staleErrorRecord, TERMINAL_RECORD]); + }, 10); + return { exportId: EXPORT_ID }; + } + }; + const commandInstance = await makeBareExportCommand(); + + const record = await commandInstance._runExport(community, false, new AbortController().signal, true); + + expect(record).toEqual(TERMINAL_RECORD); + expect(emitter.listenerCount("exportschange")).toBe(0); + }); }); describe("bitsocial daemon --allowPrivateKeyExport flag", () => { From 0e40b4f8720081eab8d77e1db3098716136c1f6e Mon Sep 17 00:00:00 2001 From: Rinse Date: Sun, 7 Jun 2026 05:05:32 +0000 Subject: [PATCH 5/5] fix(daemon): stop express's catch-all 404 from clobbering /exports downloads Live `community export` against a real daemon always failed with HTTP 404 even though the export completed server-side. The daemon serves everything on one port: startDaemonServer creates the http.Server via express().listen() and hands it to PKCWsServer, which attaches a second request listener to stream GET /exports/. Both listeners get every request; express finds no matching route and 404s synchronously while pkc-js's handler is still doing async fs work, so express wins the race. Fix: an express middleware that swallows /exports/ paths (responds nothing, calls nothing) so pkc-js's listener owns the response. All other /exports/* paths fall through to express's 404 because pkc-js ignores them on a caller-supplied server and the request would otherwise hang. The middleware is deliberately NOT mounted at app.use("/exports", ...): a mounted middleware strips the mount prefix from the shared req.url while the request is held, so pkc-js's listener would no longer recognize the URL and the download would hang instead. Regression test mimics pkc-js's listener answering after a short delay (a synchronous fake wins the race and hides the bug); verified it fails with 404 against the unfixed build. Also adds plumbing tests asserting daemon --allowPrivateKeyExport reaches PKCWsServer verbatim (true/false/omitted). --- src/webui/daemon-server.ts | 12 ++++ test/webui/daemon-server.test.ts | 101 ++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/src/webui/daemon-server.ts b/src/webui/daemon-server.ts index 17fd4b9..2bd350a 100644 --- a/src/webui/daemon-server.ts +++ b/src/webui/daemon-server.ts @@ -54,6 +54,18 @@ export async function startDaemonServer( // Start pkc-js RPC const log = PKCLogger("bitsocial-cli:daemon:startDaemonServer"); const webuiExpressApp = express(); + // GET /exports/ is streamed by pkc-js's own request listener, attached to this same + // http.Server inside PKCWsServer. Express must stay silent for those paths — its catch-all 404 + // races the async pkc-js handler and clobbers the download (the CLI's `community export` would + // see HTTP 404). Other /exports/ paths fall through to express's 404 because pkc-js ignores + // them on a caller-supplied server and the request would otherwise hang unanswered. + // NOT mounted at "/exports": a mounted middleware strips the mount prefix from the shared + // req.url while the request is held, so pkc-js's listener would no longer recognize it. + webuiExpressApp.use((req, res, next) => { + const isExportDownload = /^\/exports\/[0-9a-fA-F-]{36}$/.test(req.path); + if (!isExportDownload) return next(); + // intentionally neither responds nor calls next(): pkc-js's listener owns this request + }); // Wait for bind to actually complete before returning. Calling express.listen() without // awaiting 'listening' lets startup proceed before the port is accepting connections, // and without an 'error' handler a bind failure becomes an uncaughtException that kills diff --git a/test/webui/daemon-server.test.ts b/test/webui/daemon-server.test.ts index 3fa6eba..d18f06a 100644 --- a/test/webui/daemon-server.test.ts +++ b/test/webui/daemon-server.test.ts @@ -1,8 +1,14 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import net from "net"; import { directory as randomDirectory } from "tempy"; import { startDaemonServer } from "../../dist/webui/daemon-server.js"; +// Intercept the PKCWsServer constructor so the plumbing tests below can assert the exact +// options startDaemonServer passes to it, without booting a real pkc instance. +// The bind-contract test is unaffected: it rejects before the RPC server is created. +const { pkcWsServerFake } = vi.hoisted(() => ({ pkcWsServerFake: vi.fn() })); +vi.mock("@pkcprotocol/pkc-js/rpc", () => ({ default: { PKCWsServer: pkcWsServerFake } })); + // Regression test for issue #42: // startDaemonServer used to call webuiExpressApp.listen(port) fire-and-forget — no // await on 'listening', no 'error' handler. If bind failed, the promise still resolved @@ -50,3 +56,96 @@ describe("startDaemonServer bind contract", () => { } }); }); + +// Regression coverage for the `daemon --allowPrivateKeyExport` plumbing added with +// `community export` (PR #65): the flag must reach the pkc-js RPC server verbatim, +// otherwise --no-allowPrivateKeyExport silently stops protecting the private key. +describe("startDaemonServer → PKCWsServer plumbing", () => { + beforeEach(() => { + pkcWsServerFake.mockReset(); + pkcWsServerFake.mockResolvedValue({ + pkc: { communities: [] }, + destroy: vi.fn(async () => {}) + }); + }); + + const startOnRandomPort = (rpcServerOptions?: { allowPrivateKeyExport?: boolean }) => + startDaemonServer( + new URL("ws://127.0.0.1:0"), // port 0 → ephemeral port, no collisions + new URL("http://127.0.0.1:6754"), + { dataPath: randomDirectory() }, + rpcServerOptions + ); + + it("passes allowPrivateKeyExport: false through to PKCWsServer", async () => { + const daemonServer = await startOnRandomPort({ allowPrivateKeyExport: false }); + try { + expect(pkcWsServerFake).toHaveBeenCalledTimes(1); + expect(pkcWsServerFake.mock.calls[0][0].allowPrivateKeyExport).toBe(false); + } finally { + await daemonServer.destroy(); + } + }); + + it("passes allowPrivateKeyExport: true through to PKCWsServer", async () => { + const daemonServer = await startOnRandomPort({ allowPrivateKeyExport: true }); + try { + expect(pkcWsServerFake).toHaveBeenCalledTimes(1); + expect(pkcWsServerFake.mock.calls[0][0].allowPrivateKeyExport).toBe(true); + } finally { + await daemonServer.destroy(); + } + }); + + it("leaves allowPrivateKeyExport undefined (pkc-js default) when no rpcServerOptions are given", async () => { + const daemonServer = await startOnRandomPort(); + try { + expect(pkcWsServerFake).toHaveBeenCalledTimes(1); + expect(pkcWsServerFake.mock.calls[0][0].allowPrivateKeyExport).toBeUndefined(); + } finally { + await daemonServer.destroy(); + } + }); + + it("does not 404 GET /exports/ — the PKCWsServer request listener owns it", async () => { + // Regression: express's catch-all 404 raced pkc-js's async /exports handler on the shared + // http.Server and clobbered the download — a live `community export` failed with HTTP 404 + // even though the export itself succeeded on the daemon. + const exportId = "11111111-2222-3333-4444-555555555555"; + let boundPort: number | undefined; + pkcWsServerFake.mockImplementation(async (options: { server: import("http").Server }) => { + boundPort = (options.server.address() as import("net").AddressInfo).port; + // Mimic pkc-js: attach a request listener that streams export downloads and stays + // silent for every other path on a caller-supplied server. The delay mirrors the + // async fs work the real handler does before writing headers — that latency is what + // loses the race against express's synchronous catch-all 404. + options.server.on("request", (req, res) => { + if (req.url === `/exports/${exportId}` && req.method === "GET") { + setTimeout(() => { + if (res.writableEnded) return; // someone else (express's 404) already answered + res.statusCode = 200; + res.end("fake snapshot"); + }, 20); + } + }); + return { pkc: { communities: [] }, destroy: vi.fn(async () => {}) }; + }); + + const daemonServer = await startOnRandomPort(); + try { + // The export download must reach pkc-js's listener, not express's 404 + const download = await fetch(`http://127.0.0.1:${boundPort}/exports/${exportId}`); + expect(download.status).toBe(200); + expect(await download.text()).toBe("fake snapshot"); + + // Paths under /exports/ that pkc-js ignores must still get express's 404 + // instead of hanging with no response + const nonDownload = await fetch(`http://127.0.0.1:${boundPort}/exports/not-a-uuid`, { + signal: AbortSignal.timeout(5000) + }); + expect(nonDownload.status).toBe(404); + } finally { + await daemonServer.destroy(); + } + }); +});