From 030cc8adda2db1f3d90d840ec9a6b8b116b1e4bc Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Mon, 23 Feb 2026 16:38:31 -0800 Subject: [PATCH 1/3] [@workflow/world-vercel] Handle null key response from run-key API endpoint The API endpoint now returns { key: null } instead of a 404 when a deployment has no encryption key. Update fetchRunKey to accept the nullable response and return undefined, signaling that encryption is disabled for that workflow run. --- packages/world-vercel/src/encryption.test.ts | 54 +++++++++++++++++++- packages/world-vercel/src/encryption.ts | 10 ++-- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/packages/world-vercel/src/encryption.test.ts b/packages/world-vercel/src/encryption.test.ts index cb9354f88f..e0626e4ba7 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 { describe, expect, it, vi } from 'vitest'; +import { deriveRunKey, fetchRunKey } from './encryption.js'; const testProjectId = 'prj_test123'; const testRunId = 'wrun_abc123'; @@ -84,3 +84,53 @@ describe('deriveRunKey', () => { ).rejects.toThrow('projectId must be a non-empty string'); }); }); + +describe('fetchRunKey', () => { + const deploymentId = 'dpl_test123'; + + it('should return undefined when API returns null key', async () => { + const fetchSpy = 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(); + fetchSpy.mockRestore(); + }); + + it('should return a Uint8Array when API returns a valid key', async () => { + const keyBase64 = Buffer.from(testDeploymentKey).toString('base64'); + const fetchSpy = 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')); + fetchSpy.mockRestore(); + }); + + it('should throw on non-ok response', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response('Not found', { status: 404 })); + + await expect( + fetchRunKey(deploymentId, testProjectId, testRunId, { + token: 'test-token', + }) + ).rejects.toThrow('HTTP 404'); + + fetchSpy.mockRestore(); + }); +}); diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index 869ed9ac26..24cb7f3afd 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,10 +123,13 @@ 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'); } + if (result.data.key === null) { + return undefined; + } return Buffer.from(result.data.key, 'base64'); } From 3ea62db76b2f96292e625a610ad33b349389eea2 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Mon, 23 Feb 2026 16:45:40 -0800 Subject: [PATCH 2/3] [@workflow/world-vercel] Add changeset for null run-key handling --- .changeset/handle-null-run-key.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/handle-null-run-key.md 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 From a902e18daeff30b6963216f4b11a094945391fd9 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Mon, 23 Feb 2026 17:04:27 -0800 Subject: [PATCH 3/3] Address review feedback: improve error message and fix test mock cleanup - Include Zod error details in the validation error message for better debugging - Move fetch mock restoration to afterEach to prevent mock leakage on test failure --- packages/world-vercel/src/encryption.test.ts | 32 +++++++++----------- packages/world-vercel/src/encryption.ts | 4 ++- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/world-vercel/src/encryption.test.ts b/packages/world-vercel/src/encryption.test.ts index e0626e4ba7..7cce052b34 100644 --- a/packages/world-vercel/src/encryption.test.ts +++ b/packages/world-vercel/src/encryption.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { deriveRunKey, fetchRunKey } from './encryption.js'; const testProjectId = 'prj_test123'; @@ -88,28 +88,27 @@ describe('deriveRunKey', () => { describe('fetchRunKey', () => { const deploymentId = 'dpl_test123'; + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should return undefined when API returns null key', async () => { - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce( - new Response(JSON.stringify({ key: null }), { status: 200 }) - ); + 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(); - fetchSpy.mockRestore(); }); it('should return a Uint8Array when API returns a valid key', async () => { const keyBase64 = Buffer.from(testDeploymentKey).toString('base64'); - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce( - new Response(JSON.stringify({ key: keyBase64 }), { status: 200 }) - ); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ key: keyBase64 }), { status: 200 }) + ); const result = await fetchRunKey(deploymentId, testProjectId, testRunId, { token: 'test-token', @@ -117,20 +116,17 @@ describe('fetchRunKey', () => { expect(result).toBeInstanceOf(Uint8Array); expect(result).toEqual(Buffer.from(keyBase64, 'base64')); - fetchSpy.mockRestore(); }); it('should throw on non-ok response', async () => { - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(new Response('Not found', { status: 404 })); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response('Not found', { status: 404 }) + ); await expect( fetchRunKey(deploymentId, testProjectId, testRunId, { token: 'test-token', }) ).rejects.toThrow('HTTP 404'); - - fetchSpy.mockRestore(); }); }); diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index 24cb7f3afd..f836d38792 100644 --- a/packages/world-vercel/src/encryption.ts +++ b/packages/world-vercel/src/encryption.ts @@ -125,7 +125,9 @@ export async function fetchRunKey( const data = await response.json(); 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;