Align cloud upstream transfer validation

This commit is contained in:
Dotta committed 2026-05-19 15:05:53 -05:00
1 parent 45ee787937
commit ea11acdec8
4 files changed
+53 -4

No files matched your search

+26 -2
View File
@@ -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<LocalUpstreamExportBundle> {
});
}
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"],
},
};
}
+11
View File
@@ -62,6 +62,7 @@ export interface UpstreamTransferManifest {
idempotencyKey: string;
generatedAt: string;
entityCount: number;
perEntityTypeCounts: Record<string, number>;
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<string, number> {
const counts: Record<string, number> = {};
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")}`;
}
+4 -1
View File
@@ -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<TokenResponse>(device.tokenUrl, {
+12 -1
View File
@@ -699,7 +699,18 @@ async function fetchDiscovery(remoteUrl: string): Promise<Record<string, unknown
if (!response.ok) {
throw badRequest(`Cloud upstream discovery failed: ${response.status}`);
}
return await response.json() as Record<string, unknown>;
const discovery = await response.json() as Record<string, unknown>;
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 {