Skip to content

Commit 354840e

Browse files
authored
feat(world-vercel): support new env vars for Vercel Deployment Protection (vercel#1824)
* feat(world-vercel): support WORKFLOW_VERCEL_PROTECTION_BYPASS env var Allows sending a Vercel Deployment Protection bypass secret via the `x-vercel-protection-bypass` header on all outbound requests made by the Vercel world, enabling use against protected deployments (e.g. previews, or workflow-server once protection is enabled). * feat(world-vercel): support VERCEL_WORKFLOW_SERVER_URL env var Replace hard-coded WORKFLOW_SERVER_URL_OVERRIDE constant with a function that reads from the VERCEL_WORKFLOW_SERVER_URL env var. Allows configuring the workflow-server URL per-deployment (e.g. workbench Preview envs pointing to a branch deployment) without editing source. * fix(world-vercel): preserve inline WORKFLOW_SERVER_URL_OVERRIDE const Keep the inline const as an empty-string literal so external CI rewrite tooling continues to work unmodified; the env var is a fallback when the inline value is empty. * refactor(world-vercel): rename to VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS Align env var naming with VERCEL_WORKFLOW_SERVER_URL. * ci: expose workflow-server protection bypass env vars to e2e-vercel-prod Set VERCEL_WORKFLOW_SERVER_URL and VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS on PR runs so e2e tests hit the protected workflow-server preview; leave unset on main so production runs use the public default URL. * refactor(world-vercel): address PR review comments - Consolidate bypass header logic in getHeaders() to reuse getProtectionBypassHeader() instead of duplicating env lookup. - Use consistent 'Authorization' casing in direct fetch() calls. - Add unit tests for getProtectionBypassHeader, getHttpUrl, and getHeaders covering env var toggling and proxy/override combinations.
1 parent b163860 commit 354840e

7 files changed

Lines changed: 200 additions & 15 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/world-vercel": minor
3+
---
4+
5+
Add `VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS` and `VERCEL_WORKFLOW_SERVER_URL` env vars.

.github/workflows/tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,10 @@ jobs:
289289
WORKFLOW_VERCEL_PROJECT: ${{ matrix.app.project-id }}
290290
WORKFLOW_VERCEL_PROJECT_SLUG: ${{ matrix.app.project-slug }}
291291
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
292+
# Point PRs at the protected workflow-server preview; unset on main
293+
# so production runs hit the public vercel-workflow.com URL.
294+
VERCEL_WORKFLOW_SERVER_URL: ${{ github.ref == 'refs/heads/main' && '' || secrets.VERCEL_WORKFLOW_SERVER_URL }}
295+
VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS: ${{ github.ref == 'refs/heads/main' && '' || secrets.VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS }}
292296

293297
- name: Generate E2E summary
294298
if: always()

packages/world-vercel/src/encryption.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { getVercelOidcToken } from '@vercel/oidc';
1515
import type { WorkflowRun, World } from '@workflow/world';
1616
import * as z from 'zod';
1717
import { getDispatcher } from './http-client.js';
18+
import { getProtectionBypassHeader } from './utils.js';
1819

1920
const KEY_BYTES = 32; // 256 bits = 32 bytes (AES-256)
2021

@@ -123,7 +124,8 @@ export async function fetchRunKey(
123124
{
124125
method: 'GET',
125126
headers: {
126-
authorization: `Bearer ${token}`,
127+
Authorization: `Bearer ${token}`,
128+
...getProtectionBypassHeader(),
127129
},
128130
// @ts-expect-error -- undici dispatcher is accepted by Node.js fetch but not in @types/node's RequestInit
129131
dispatcher: getDispatcher(),

packages/world-vercel/src/resolve-latest-deployment.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ describe('createResolveLatestDeploymentId', () => {
5656
expect.objectContaining({
5757
method: 'GET',
5858
headers: expect.objectContaining({
59-
authorization: 'Bearer test-token',
59+
Authorization: 'Bearer test-token',
6060
}),
6161
})
6262
);
@@ -114,7 +114,7 @@ describe('createResolveLatestDeploymentId', () => {
114114
expect.any(String),
115115
expect.objectContaining({
116116
headers: expect.objectContaining({
117-
authorization: 'Bearer env-token-123',
117+
Authorization: 'Bearer env-token-123',
118118
}),
119119
})
120120
);
@@ -152,7 +152,7 @@ describe('createResolveLatestDeploymentId', () => {
152152
expect.any(String),
153153
expect.objectContaining({
154154
headers: expect.objectContaining({
155-
authorization: 'Bearer oidc-token-456',
155+
Authorization: 'Bearer oidc-token-456',
156156
}),
157157
})
158158
);

packages/world-vercel/src/resolve-latest-deployment.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { getVercelOidcToken } from '@vercel/oidc';
1010
import * as z from 'zod';
1111
import { getDispatcher } from './http-client.js';
12-
import type { APIConfig } from './utils.js';
12+
import { type APIConfig, getProtectionBypassHeader } from './utils.js';
1313

1414
const ResolveLatestDeploymentResponseSchema = z.object({
1515
id: z.string(),
@@ -55,7 +55,8 @@ export function createResolveLatestDeploymentId(
5555
const response = await fetch(url, {
5656
method: 'GET',
5757
headers: {
58-
authorization: `Bearer ${token}`,
58+
Authorization: `Bearer ${token}`,
59+
...getProtectionBypassHeader(),
5960
},
6061
// @ts-expect-error -- undici dispatcher is accepted by Node.js fetch but not in @types/node's RequestInit
6162
dispatcher: getDispatcher(),
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2+
import { getHeaders, getHttpUrl, getProtectionBypassHeader } from './utils.js';
3+
4+
describe('getProtectionBypassHeader', () => {
5+
const originalEnv = process.env;
6+
7+
beforeEach(() => {
8+
process.env = { ...originalEnv };
9+
});
10+
11+
afterEach(() => {
12+
process.env = originalEnv;
13+
});
14+
15+
it('returns empty object when env var is unset', () => {
16+
delete process.env.VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS;
17+
expect(getProtectionBypassHeader()).toEqual({});
18+
});
19+
20+
it('returns empty object when env var is empty', () => {
21+
process.env.VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS = '';
22+
expect(getProtectionBypassHeader()).toEqual({});
23+
});
24+
25+
it('returns x-vercel-protection-bypass header when env var is set', () => {
26+
process.env.VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS = 'my-bypass-secret';
27+
expect(getProtectionBypassHeader()).toEqual({
28+
'x-vercel-protection-bypass': 'my-bypass-secret',
29+
});
30+
});
31+
});
32+
33+
describe('getHttpUrl', () => {
34+
const originalEnv = process.env;
35+
36+
beforeEach(() => {
37+
process.env = { ...originalEnv };
38+
delete process.env.VERCEL_WORKFLOW_SERVER_URL;
39+
delete process.env.WORKFLOW_VERCEL_BACKEND_URL;
40+
});
41+
42+
afterEach(() => {
43+
process.env = originalEnv;
44+
});
45+
46+
it('uses default workflow-server URL when no config and no env override', () => {
47+
expect(getHttpUrl()).toEqual({
48+
baseUrl: 'https://vercel-workflow.com/api',
49+
usingProxy: false,
50+
});
51+
});
52+
53+
it('respects VERCEL_WORKFLOW_SERVER_URL when set (no proxy)', () => {
54+
process.env.VERCEL_WORKFLOW_SERVER_URL = 'https://custom-host.example.com';
55+
expect(getHttpUrl()).toEqual({
56+
baseUrl: 'https://custom-host.example.com/api',
57+
usingProxy: false,
58+
});
59+
});
60+
61+
it('uses proxy when projectId + teamId are provided', () => {
62+
expect(
63+
getHttpUrl({
64+
projectConfig: { projectId: 'prj_123', teamId: 'team_456' },
65+
})
66+
).toEqual({
67+
baseUrl: 'https://api.vercel.com/v1/workflow',
68+
usingProxy: true,
69+
});
70+
});
71+
72+
it('respects WORKFLOW_VERCEL_BACKEND_URL for custom proxy URL', () => {
73+
process.env.WORKFLOW_VERCEL_BACKEND_URL = 'https://proxy.example.com/v1';
74+
expect(
75+
getHttpUrl({
76+
projectConfig: { projectId: 'prj_123', teamId: 'team_456' },
77+
})
78+
).toEqual({
79+
baseUrl: 'https://proxy.example.com/v1',
80+
usingProxy: true,
81+
});
82+
});
83+
});
84+
85+
describe('getHeaders', () => {
86+
const originalEnv = process.env;
87+
88+
beforeEach(() => {
89+
process.env = { ...originalEnv };
90+
delete process.env.VERCEL_WORKFLOW_SERVER_URL;
91+
delete process.env.VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS;
92+
});
93+
94+
afterEach(() => {
95+
process.env = originalEnv;
96+
});
97+
98+
it('omits x-vercel-protection-bypass when env var is unset', () => {
99+
const headers = getHeaders(undefined, { usingProxy: false });
100+
expect(headers.get('x-vercel-protection-bypass')).toBeNull();
101+
});
102+
103+
it('sets x-vercel-protection-bypass when env var is set', () => {
104+
process.env.VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS = 'my-secret';
105+
const headers = getHeaders(undefined, { usingProxy: false });
106+
expect(headers.get('x-vercel-protection-bypass')).toBe('my-secret');
107+
});
108+
109+
it('omits x-vercel-workflow-api-url when override is unset', () => {
110+
const headers = getHeaders(undefined, { usingProxy: true });
111+
expect(headers.get('x-vercel-workflow-api-url')).toBeNull();
112+
});
113+
114+
it('sets x-vercel-workflow-api-url when VERCEL_WORKFLOW_SERVER_URL is set and using proxy', () => {
115+
process.env.VERCEL_WORKFLOW_SERVER_URL = 'https://custom.example.com';
116+
const headers = getHeaders(undefined, { usingProxy: true });
117+
expect(headers.get('x-vercel-workflow-api-url')).toBe(
118+
'https://custom.example.com'
119+
);
120+
});
121+
122+
it('omits x-vercel-workflow-api-url when override is set but not using proxy', () => {
123+
// Direct-to-workflow-server mode uses baseUrl, so the header is redundant.
124+
process.env.VERCEL_WORKFLOW_SERVER_URL = 'https://custom.example.com';
125+
const headers = getHeaders(undefined, { usingProxy: false });
126+
expect(headers.get('x-vercel-workflow-api-url')).toBeNull();
127+
});
128+
129+
it('sets project config headers when provided', () => {
130+
const headers = getHeaders(
131+
{
132+
projectConfig: {
133+
projectId: 'prj_123',
134+
teamId: 'team_456',
135+
environment: 'preview',
136+
},
137+
},
138+
{ usingProxy: true }
139+
);
140+
expect(headers.get('x-vercel-project-id')).toBe('prj_123');
141+
expect(headers.get('x-vercel-team-id')).toBe('team_456');
142+
expect(headers.get('x-vercel-environment')).toBe('preview');
143+
});
144+
});

packages/world-vercel/src/utils.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,25 @@ function httpLog(
5353
);
5454
}
5555
}
56-
5756
/**
58-
* Hard-coded workflow-server URL override for testing.
59-
* Set this to test against a different workflow-server version.
60-
* Leave empty string for production (uses default vercel-workflow.com).
61-
*
62-
* Example: 'https://workflow-server-git-branch-name.vercel.sh'
57+
* Inline workflow-server URL override. Must remain an empty string on
58+
* `main` — rewritten by external CI for branch-deployment testing.
59+
* Prefer `VERCEL_WORKFLOW_SERVER_URL` for deployment-time configuration.
6360
*/
6461
const WORKFLOW_SERVER_URL_OVERRIDE = '';
6562

63+
/**
64+
* Effective workflow-server URL override. The inline constant wins when
65+
* set; otherwise falls back to the `VERCEL_WORKFLOW_SERVER_URL` env var.
66+
*
67+
* When set, requests bypass the default production host
68+
* (`https://vercel-workflow.com`). When using the proxy
69+
* (`api.vercel.com/v1/workflow`), this value is forwarded via the
70+
* `x-vercel-workflow-api-url` header so the proxy routes the request to
71+
* the override URL.
72+
*/
73+
const getWorkflowServerUrlOverride = (): string =>
74+
WORKFLOW_SERVER_URL_OVERRIDE || process.env.VERCEL_WORKFLOW_SERVER_URL || '';
6675
export interface APIConfig {
6776
token?: string;
6877
headers?: RequestInit['headers'];
@@ -190,12 +199,28 @@ export interface HttpConfig {
190199
usingProxy: boolean;
191200
}
192201

202+
/**
203+
* Returns an object with the Vercel Deployment Protection bypass header
204+
* if the `VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS` env var is set, otherwise
205+
* returns an empty object. Useful for spreading into a headers init object
206+
* for direct fetch() calls that don't go through `getHeaders()`.
207+
*
208+
* See: https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation
209+
*/
210+
export function getProtectionBypassHeader(): Record<string, string> {
211+
const bypassSecret = process.env.VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS;
212+
if (bypassSecret) {
213+
return { 'x-vercel-protection-bypass': bypassSecret };
214+
}
215+
return {};
216+
}
217+
193218
export const getHttpUrl = (
194219
config?: APIConfig
195220
): { baseUrl: string; usingProxy: boolean } => {
196221
const projectConfig = config?.projectConfig;
197222
const defaultHost =
198-
WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com';
223+
getWorkflowServerUrlOverride() || 'https://vercel-workflow.com';
199224
const customProxyUrl = process.env.WORKFLOW_VERCEL_BACKEND_URL;
200225
const defaultProxyUrl = 'https://api.vercel.com/v1/workflow';
201226
// Use proxy when we have project config (for authentication via Vercel API)
@@ -230,8 +255,12 @@ export const getHeaders = (
230255
// Only set workflow-api-url header when using the proxy, since the proxy
231256
// forwards it to the workflow-server. When not using proxy, requests go
232257
// directly to the workflow-server so this header has no effect.
233-
if (WORKFLOW_SERVER_URL_OVERRIDE && options.usingProxy) {
234-
headers.set('x-vercel-workflow-api-url', WORKFLOW_SERVER_URL_OVERRIDE);
258+
const workflowServerUrlOverride = getWorkflowServerUrlOverride();
259+
if (workflowServerUrlOverride && options.usingProxy) {
260+
headers.set('x-vercel-workflow-api-url', workflowServerUrlOverride);
261+
}
262+
for (const [key, value] of Object.entries(getProtectionBypassHeader())) {
263+
headers.set(key, value);
235264
}
236265
return headers;
237266
};

0 commit comments

Comments
 (0)