From ea11acdec833d82e814802d1c302a80aa1c29d52 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 19 May 2026 15:05:53 -0500 Subject: [PATCH] Align cloud upstream transfer validation --- cli/src/__tests__/cloud.test.ts | 28 +++++++++++++++++++++-- cli/src/commands/client/cloud-transfer.ts | 11 +++++++++ cli/src/commands/client/cloud.ts | 5 +++- server/src/services/cloud-upstreams.ts | 13 ++++++++++- 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/cli/src/__tests__/cloud.test.ts b/cli/src/__tests__/cloud.test.ts index a863978df21..b2707598c1b 100644 --- a/cli/src/__tests__/cloud.test.ts +++ b/cli/src/__tests__/cloud.test.ts @@ -84,6 +84,7 @@ describe("cloud CLI helpers", () => { it("hard-blocks incompatible transfer schema versions with the stable schema exit code", () => { expect(() => assertDiscoveryCompatible(discovery({ supportedSchemaMajor: 99 }))).toThrow(/schema mismatch/i); + expect(() => assertDiscoveryCompatible(discovery({ featureFlags: [] }))).toThrow(/cloud_sync/); expect(cloudCommandExitCodes.schemaMismatch).toBe(3); }); @@ -91,11 +92,34 @@ describe("cloud CLI helpers", () => { const bundle = await buildTestBundle(); expect(bundle.chunks).toHaveLength(2); + expect(bundle.manifest.perEntityTypeCounts.company).toBe(1); expect(bundle.chunks[0]?.sha256).toBe(normalizedContentHash(bundle.chunks[0]?.payload)); expect(bundle.manifest.chunks[0]?.manifestHash).toBe(bundle.manifest.manifestHash); expect(bundle.manifest.idempotencyKey).toBe((await buildTestBundle()).manifest.idempotencyKey); }); + it("fails device-code authorization when expiresAt is malformed", async () => { + globalThis.fetch = vi.fn(async (url, init) => { + const requestUrl = String(url); + if (requestUrl.endsWith("/.well-known/paperclip-upstream")) { + return jsonResponse(discovery()); + } + if (requestUrl.endsWith("/api/upstream-sync/device-code")) { + return jsonResponse({ + deviceCode: "device-1", + userCode: "ABCD-EFGH", + verificationUri: "https://cloud.example.test/api/upstream-sync/device-code/approve", + expiresAt: "not-a-date", + intervalSeconds: 0, + }); + } + throw new Error(`unexpected request: ${requestUrl} ${init?.method ?? "GET"}`); + }) as typeof fetch; + + await expect(connectCloud("https://cloud.example.test", { noBrowser: true, json: true })) + .rejects.toThrow(/valid expiresAt/); + }); + it("reuses the same manifest and chunk identity when an interrupted apply is retried", async () => { const bundle = await buildTestBundle(); const calls: Array<{ path: string; body: unknown }> = []; @@ -185,7 +209,7 @@ async function buildTestBundle(): Promise { }); } -function discovery(overrides: Partial<{ supportedSchemaMajor: number }> = {}) { +function discovery(overrides: Partial<{ supportedSchemaMajor: number; featureFlags: string[] }> = {}) { return { schema: "paperclip-upstream-discovery-v1", stack: { @@ -205,7 +229,7 @@ function discovery(overrides: Partial<{ supportedSchemaMajor: number }> = {}) { }, transfer: { supportedSchemaMajor: overrides.supportedSchemaMajor ?? 1, - featureFlags: ["cloud_sync"], + featureFlags: overrides.featureFlags ?? ["cloud_sync"], }, }; } diff --git a/cli/src/commands/client/cloud-transfer.ts b/cli/src/commands/client/cloud-transfer.ts index 1c2d41f23e1..61ee2c82e86 100644 --- a/cli/src/commands/client/cloud-transfer.ts +++ b/cli/src/commands/client/cloud-transfer.ts @@ -62,6 +62,7 @@ export interface UpstreamTransferManifest { idempotencyKey: string; generatedAt: string; entityCount: number; + perEntityTypeCounts: Record; entities: UpstreamTransferEntityRecord[]; chunks: UpstreamTransferChunk[]; warnings: UpstreamTransferWarning[]; @@ -229,6 +230,7 @@ export function buildLocalUpstreamExportBundle( idempotencyKey: input.idempotencyKey, generatedAt: new Date(0).toISOString(), entityCount: entities.length, + perEntityTypeCounts: countEntityTypesForManifest(entities), entities: entities.map((entity) => entity.record), chunks: chunks.map(({ payload: _payload, ...chunk }) => chunk), warnings: input.warnings ?? [], @@ -246,6 +248,15 @@ export function buildLocalUpstreamExportBundle( }; } +function countEntityTypesForManifest(entities: LocalUpstreamExportEntity[]): Record { + const counts: Record = {}; + for (const entity of entities) { + const entityType = entity.record.key.sourceEntityType; + counts[entityType] = (counts[entityType] ?? 0) + 1; + } + return counts; +} + export function normalizedContentHash(value: unknown): NormalizedSha256 { return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`; } diff --git a/cli/src/commands/client/cloud.ts b/cli/src/commands/client/cloud.ts index 891e44b1031..fdbcc76af82 100644 --- a/cli/src/commands/client/cloud.ts +++ b/cli/src/commands/client/cloud.ts @@ -406,8 +406,11 @@ async function authorizeWithDeviceCode( if (opts.openBrowser) openUrl(response.verificationUri); const expiresAt = Date.parse(response.expiresAt); + if (!Number.isFinite(expiresAt)) { + throw new Error("Device-code authorization response did not include a valid expiresAt timestamp."); + } const intervalMs = Math.max(500, (response.intervalSeconds ?? 5) * 1000); - while (!Number.isFinite(expiresAt) || Date.now() < expiresAt) { + while (Date.now() < expiresAt) { await sleep(intervalMs); try { return await requestCloudJson(device.tokenUrl, { diff --git a/server/src/services/cloud-upstreams.ts b/server/src/services/cloud-upstreams.ts index cf192f9e94e..1a6769ffdf2 100644 --- a/server/src/services/cloud-upstreams.ts +++ b/server/src/services/cloud-upstreams.ts @@ -699,7 +699,18 @@ async function fetchDiscovery(remoteUrl: string): Promise; + const discovery = await response.json() as Record; + if (discovery.schema !== "paperclip-upstream-discovery-v1") { + throw badRequest("Remote URL is not a Paperclip Cloud upstream target"); + } + const transfer = objectField(discovery, "transfer"); + const featureFlags = Array.isArray(transfer.featureFlags) + ? transfer.featureFlags.filter((flag): flag is string => typeof flag === "string") + : []; + if (!featureFlags.includes("cloud_sync")) { + throw badRequest("Remote Paperclip Cloud stack does not advertise the cloud_sync transfer flag"); + } + return discovery; } function firstPathSegment(pathname: string): string | null {