diff --git a/.changeset/handle-null-run-key.md b/.changeset/handle-null-run-key.md new file mode 100644 index 0000000000..f7062190b8 --- /dev/null +++ b/.changeset/handle-null-run-key.md @@ -0,0 +1,5 @@ +--- +"@workflow/world-vercel": patch +--- + +Handle `{ key: null }` response from the run-key API endpoint, returning `undefined` to signal encryption is disabled for that workflow run diff --git a/packages/world-vercel/src/encryption.test.ts b/packages/world-vercel/src/encryption.test.ts index cb9354f88f..7cce052b34 100644 --- a/packages/world-vercel/src/encryption.test.ts +++ b/packages/world-vercel/src/encryption.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest'; -import { deriveRunKey } from './encryption.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { deriveRunKey, fetchRunKey } from './encryption.js'; const testProjectId = 'prj_test123'; const testRunId = 'wrun_abc123'; @@ -84,3 +84,49 @@ describe('deriveRunKey', () => { ).rejects.toThrow('projectId must be a non-empty string'); }); }); + +describe('fetchRunKey', () => { + const deploymentId = 'dpl_test123'; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return undefined when API returns null key', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ key: null }), { status: 200 }) + ); + + const result = await fetchRunKey(deploymentId, testProjectId, testRunId, { + token: 'test-token', + }); + + expect(result).toBeUndefined(); + }); + + it('should return a Uint8Array when API returns a valid key', async () => { + const keyBase64 = Buffer.from(testDeploymentKey).toString('base64'); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ key: keyBase64 }), { status: 200 }) + ); + + const result = await fetchRunKey(deploymentId, testProjectId, testRunId, { + token: 'test-token', + }); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toEqual(Buffer.from(keyBase64, 'base64')); + }); + + it('should throw on non-ok response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response('Not found', { status: 404 }) + ); + + await expect( + fetchRunKey(deploymentId, testProjectId, testRunId, { + token: 'test-token', + }) + ).rejects.toThrow('HTTP 404'); + }); +}); diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index 869ed9ac26..f836d38792 100644 --- a/packages/world-vercel/src/encryption.ts +++ b/packages/world-vercel/src/encryption.ts @@ -84,7 +84,8 @@ export async function deriveRunKey( * @param projectId - The project ID for HKDF context isolation * @param runId - The workflow run ID for per-run key derivation * @param options.token - Auth token (from config). Falls back to OIDC or VERCEL_TOKEN. - * @returns Derived 32-byte per-run AES-256 key + * @returns Derived 32-byte per-run AES-256 key, or `undefined` when the + * deployment has no key (encryption disabled for that run) */ export async function fetchRunKey( deploymentId: string, @@ -94,7 +95,7 @@ export async function fetchRunKey( /** Auth token (from config). Falls back to OIDC or VERCEL_TOKEN. */ token?: string; } -): Promise { +): Promise { // Authenticate via provided token (CLI/config), OIDC token (runtime), // or VERCEL_TOKEN env var (external tooling) const oidcToken = await getVercelOidcToken().catch(() => null); @@ -122,9 +123,14 @@ export async function fetchRunKey( } const data = await response.json(); - const result = z.object({ key: z.string() }).safeParse(data); + const result = z.object({ key: z.string().nullable() }).safeParse(data); if (!result.success) { - throw new Error('Invalid response from Vercel API, missing "key" field'); + throw new Error( + `Invalid response from Vercel API: expected { key: string | null }. Zod error: ${result.error.message}` + ); + } + if (result.data.key === null) { + return undefined; } return Buffer.from(result.data.key, 'base64'); }