diff --git a/cli/src/__tests__/cloud.test.ts b/cli/src/__tests__/cloud.test.ts index 0c5aeae8b6d..a863978df21 100644 --- a/cli/src/__tests__/cloud.test.ts +++ b/cli/src/__tests__/cloud.test.ts @@ -14,7 +14,7 @@ import { normalizedContentHash, type LocalUpstreamExportBundle, } from "../commands/client/cloud-transfer.js"; -import { getCloudConnection } from "../commands/client/cloud-store.js"; +import { getCloudConnection, resolveCloudConnectionStorePath } from "../commands/client/cloud-store.js"; const originalEnv = { ...process.env }; const originalFetch = globalThis.fetch; @@ -75,6 +75,11 @@ describe("cloud CLI helpers", () => { expect(connection.accessToken).toBe("upt_test"); expect(getCloudConnection("https://cloud.example.test")?.token.id).toBe("token-1"); + const rawStore = fs.readFileSync(resolveCloudConnectionStorePath(), "utf8"); + expect(rawStore).not.toContain("upt_test"); + expect(rawStore).not.toContain("PRIVATE KEY"); + expect(rawStore).toContain("accessTokenMaterial"); + expect(rawStore).toContain("privateKeyMaterial"); }); it("hard-blocks incompatible transfer schema versions with the stable schema exit code", () => { @@ -116,6 +121,31 @@ describe("cloud CLI helpers", () => { expect(chunkBodies[0]).toEqual(chunkBodies[2]); expect(chunkBodies[1]).toEqual(chunkBodies[3]); }); + + it("uses the manifest-only request shape for CLI previews", async () => { + const bundle = await buildTestBundle(); + const calls: Array<{ path: string; body: unknown }> = []; + const coordinator = new LocalUpstreamPushCoordinator({ + targetOrigin: "https://cloud.example.test", + paperclipCompanyId: "target-company-1", + fetch: async (url, init) => { + const parsed = new URL(String(url)); + calls.push({ path: parsed.pathname, body: init?.body ? JSON.parse(String(init.body)) as unknown : {} }); + return jsonResponse({ warnings: [], conflicts: [] }); + }, + }); + + await coordinator.preview(bundle); + + expect(calls).toHaveLength(1); + expect(calls[0]?.path).toBe("/api/companies/target-company-1/upstream-imports/preview"); + expect(calls[0]?.body).toMatchObject({ + manifest: bundle.manifest, + previewShape: "manifest_only", + conflictKeysBySource: {}, + }); + expect(calls[0]?.body).not.toHaveProperty("entities"); + }); }); async function buildTestBundle(): Promise { diff --git a/cli/src/commands/client/cloud-store.ts b/cli/src/commands/client/cloud-store.ts index fa63c7133b0..9d18151d0f8 100644 --- a/cli/src/commands/client/cloud-store.ts +++ b/cli/src/commands/client/cloud-store.ts @@ -1,3 +1,4 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { resolvePaperclipInstanceRoot } from "../../config/home.js"; @@ -39,6 +40,13 @@ interface CloudConnectionStore { currentConnectionId?: string; } +interface EncryptedCloudCredential { + scheme: "local_encrypted_v1"; + iv: string; + tag: string; + ciphertext: string; +} + function defaultStore(): CloudConnectionStore { return { version: 1, @@ -76,7 +84,7 @@ export function writeCloudConnectionStore( storePath = resolveCloudConnectionStorePath(), ): void { fs.mkdirSync(path.dirname(storePath), { recursive: true }); - fs.writeFileSync(storePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 }); + fs.writeFileSync(storePath, `${JSON.stringify(serializeStore(store), null, 2)}\n`, { mode: 0o600 }); } export function upsertCloudConnection( @@ -130,11 +138,11 @@ function normalizeConnection(value: unknown): CloudConnection | null { const targetHost = stringValue(record.targetHost); const stackId = stringValue(record.stackId); const targetCompanyId = stringValue(record.targetCompanyId); - const accessToken = stringValue(record.accessToken); + const accessToken = readCredential(record, "accessToken", "accessTokenMaterial"); const token = typeof record.token === "object" && record.token !== null && !Array.isArray(record.token) ? record.token as CloudConnectionTokenRecord : null; - const privateKeyPem = stringValue(record.privateKeyPem); + const privateKeyPem = readCredential(record, "privateKeyPem", "privateKeyMaterial"); const sourcePublicKey = stringValue(record.sourcePublicKey); const sourceInstanceId = stringValue(record.sourceInstanceId); const sourceInstanceFingerprint = stringValue(record.sourceInstanceFingerprint); @@ -175,3 +183,117 @@ function stringValue(value: unknown): string | null { function stringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; } + +function readCredential(record: Record, plaintextKey: string, materialKey: string): string | null { + const material = record[materialKey]; + if (material && typeof material === "object" && !Array.isArray(material)) { + try { + return decryptCredential(material as Record); + } catch { + return null; + } + } + return stringValue(record[plaintextKey]); +} + +function serializeStore(store: CloudConnectionStore): Record { + const connections: Record = {}; + for (const [id, connection] of Object.entries(store.connections)) { + connections[id] = serializeConnection(connection); + } + return { + version: 1, + connections, + ...(store.currentConnectionId ? { currentConnectionId: store.currentConnectionId } : {}), + }; +} + +function serializeConnection(connection: CloudConnection): Record { + const { accessToken, privateKeyPem, ...rest } = connection; + return { + ...rest, + accessTokenMaterial: encryptCredential(accessToken), + privateKeyMaterial: encryptCredential(privateKeyPem), + }; +} + +function encryptCredential(value: string): EncryptedCloudCredential { + const iv = randomBytes(12); + const key = loadOrCreateCredentialKey(); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + return { + scheme: "local_encrypted_v1", + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + ciphertext: ciphertext.toString("base64"), + }; +} + +function decryptCredential(material: Record): string { + if ( + material.scheme !== "local_encrypted_v1" || + typeof material.iv !== "string" || + typeof material.tag !== "string" || + typeof material.ciphertext !== "string" + ) { + throw new Error("Invalid encrypted cloud credential material"); + } + const decipher = createDecipheriv( + "aes-256-gcm", + loadOrCreateCredentialKey(), + Buffer.from(material.iv, "base64"), + ); + decipher.setAuthTag(Buffer.from(material.tag, "base64")); + const plain = Buffer.concat([ + decipher.update(Buffer.from(material.ciphertext, "base64")), + decipher.final(), + ]); + return plain.toString("utf8"); +} + +function loadOrCreateCredentialKey(): Buffer { + const envKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; + if (envKey?.trim()) { + const decoded = decodeCredentialKey(envKey); + if (!decoded) throw new Error("Invalid PAPERCLIP_SECRETS_MASTER_KEY"); + return decoded; + } + + const keyPath = path.resolve( + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE?.trim() || + path.join(resolvePaperclipInstanceRoot(), "secrets", "master.key"), + ); + if (fs.existsSync(keyPath)) { + const decoded = decodeCredentialKey(fs.readFileSync(keyPath, "utf8")); + if (!decoded) throw new Error(`Invalid secrets master key at ${keyPath}`); + return decoded; + } + + fs.mkdirSync(path.dirname(keyPath), { recursive: true }); + const generated = randomBytes(32); + fs.writeFileSync(keyPath, generated.toString("base64"), { encoding: "utf8", mode: 0o600 }); + try { + fs.chmodSync(keyPath, 0o600); + } catch { + // Best effort; the credential store itself is also written 0600. + } + return generated; +} + +function decodeCredentialKey(raw: string): Buffer | null { + const trimmed = raw.trim(); + if (/^[A-Fa-f0-9]{64}$/.test(trimmed)) { + return Buffer.from(trimmed, "hex"); + } + try { + const decoded = Buffer.from(trimmed, "base64"); + if (decoded.length === 32) return decoded; + } catch { + // ignored + } + if (Buffer.byteLength(trimmed, "utf8") === 32) { + return Buffer.from(trimmed, "utf8"); + } + return null; +} diff --git a/cli/src/commands/client/cloud-transfer.ts b/cli/src/commands/client/cloud-transfer.ts index 9cd1ccbe74a..1c2d41f23e1 100644 --- a/cli/src/commands/client/cloud-transfer.ts +++ b/cli/src/commands/client/cloud-transfer.ts @@ -144,7 +144,8 @@ export class LocalUpstreamPushCoordinator { async preview(bundle: LocalUpstreamExportBundle): Promise { return this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/preview`, { manifest: bundle.manifest, - entities: bundle.entities, + previewShape: "manifest_only", + conflictKeysBySource: conflictKeysBySource(bundle.entities), }); } @@ -191,6 +192,21 @@ export class LocalUpstreamPushCoordinator { } } +function conflictKeysBySource(entities: LocalUpstreamExportEntity[]): Record { + const output: Record = {}; + for (const entity of entities) { + if (!entity.conflictKeys?.length) continue; + output[sourceEntityKeyString(entity.record.key)] = [...entity.conflictKeys]; + } + return output; +} + +function sourceEntityKeyString(key: SourceEntityKey): string { + return [key.sourceInstanceId, key.sourceCompanyId, key.sourceEntityType, key.sourceEntityId] + .map((part) => encodeURIComponent(part)) + .join("/"); +} + export function buildLocalUpstreamExportBundle( input: BuildLocalUpstreamExportBundleInput, ): LocalUpstreamExportBundle { diff --git a/server/src/services/cloud-upstreams.ts b/server/src/services/cloud-upstreams.ts index 4043b61e1d3..cf192f9e94e 100644 --- a/server/src/services/cloud-upstreams.ts +++ b/server/src/services/cloud-upstreams.ts @@ -340,7 +340,7 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } = } return updateRun(row.id, { status: "cancelled", - activeStep: "push", + activeStep: row.activeStep, progressPercent: 100, completedAt: new Date(), events: [ @@ -500,6 +500,8 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } = const completedAt = new Date(); const finalEvents = [ ...input.initialEvents, + event(completedAt.toISOString(), "scan", "completed", "Scanned the local company inventory."), + event(completedAt.toISOString(), "preview", "completed", "Generated the transfer manifest."), event(completedAt.toISOString(), "push", "completed", "Pushed mapped objects without duplicate creation."), event(completedAt.toISOString(), "verify", "completed", "Verified the cloud import ledger and generated a run report."), event(completedAt.toISOString(), "activate", "completed", "Activation checklist is ready for manual unpause decisions."),