mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Align cloud upstream transfer validation
This commit is contained in:
1 parent
45ee787937
commit
ea11acdec8
4 files changed
+53
-4
No files matched your search
@@ -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"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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")}`;
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user