Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/handle-null-run-key.md
Original file line number Diff line number Diff line change
@@ -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
50 changes: 48 additions & 2 deletions packages/world-vercel/src/encryption.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
});
Comment thread
TooTallNate marked this conversation as resolved.

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'));
});
Comment thread
TooTallNate marked this conversation as resolved.

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');
});
Comment thread
TooTallNate marked this conversation as resolved.
});
14 changes: 10 additions & 4 deletions packages/world-vercel/src/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -94,7 +95,7 @@ export async function fetchRunKey(
/** Auth token (from config). Falls back to OIDC or VERCEL_TOKEN. */
token?: string;
}
): Promise<Uint8Array> {
): Promise<Uint8Array | undefined> {
// Authenticate via provided token (CLI/config), OIDC token (runtime),
// or VERCEL_TOKEN env var (external tooling)
const oidcToken = await getVercelOidcToken().catch(() => null);
Expand Down Expand Up @@ -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');
}
Expand Down