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/world-vercel-protection-bypass.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/world-vercel": minor
---

Add `VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS` and `VERCEL_WORKFLOW_SERVER_URL` env vars.
4 changes: 4 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,10 @@ jobs:
WORKFLOW_VERCEL_PROJECT: ${{ matrix.app.project-id }}
WORKFLOW_VERCEL_PROJECT_SLUG: ${{ matrix.app.project-slug }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
# Point PRs at the protected workflow-server preview; unset on main
# so production runs hit the public vercel-workflow.com URL.
VERCEL_WORKFLOW_SERVER_URL: ${{ github.ref == 'refs/heads/main' && '' || secrets.VERCEL_WORKFLOW_SERVER_URL }}
VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS: ${{ github.ref == 'refs/heads/main' && '' || secrets.VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS }}

- name: Generate E2E summary
if: always()
Expand Down
4 changes: 3 additions & 1 deletion packages/world-vercel/src/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { getVercelOidcToken } from '@vercel/oidc';
import type { WorkflowRun, World } from '@workflow/world';
import * as z from 'zod';
import { getDispatcher } from './http-client.js';
import { getProtectionBypassHeader } from './utils.js';

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

Expand Down Expand Up @@ -123,7 +124,8 @@ export async function fetchRunKey(
{
method: 'GET',
headers: {
authorization: `Bearer ${token}`,
Authorization: `Bearer ${token}`,
...getProtectionBypassHeader(),
},
// @ts-expect-error -- undici dispatcher is accepted by Node.js fetch but not in @types/node's RequestInit
dispatcher: getDispatcher(),
Expand Down
6 changes: 3 additions & 3 deletions packages/world-vercel/src/resolve-latest-deployment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe('createResolveLatestDeploymentId', () => {
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
authorization: 'Bearer test-token',
Authorization: 'Bearer test-token',
}),
})
);
Expand Down Expand Up @@ -114,7 +114,7 @@ describe('createResolveLatestDeploymentId', () => {
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
authorization: 'Bearer env-token-123',
Authorization: 'Bearer env-token-123',
}),
})
);
Expand Down Expand Up @@ -152,7 +152,7 @@ describe('createResolveLatestDeploymentId', () => {
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
authorization: 'Bearer oidc-token-456',
Authorization: 'Bearer oidc-token-456',
}),
})
);
Expand Down
5 changes: 3 additions & 2 deletions packages/world-vercel/src/resolve-latest-deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import { getVercelOidcToken } from '@vercel/oidc';
import * as z from 'zod';
import { getDispatcher } from './http-client.js';
import type { APIConfig } from './utils.js';
import { type APIConfig, getProtectionBypassHeader } from './utils.js';

const ResolveLatestDeploymentResponseSchema = z.object({
id: z.string(),
Expand Down Expand Up @@ -55,7 +55,8 @@ export function createResolveLatestDeploymentId(
const response = await fetch(url, {
method: 'GET',
headers: {
authorization: `Bearer ${token}`,
Authorization: `Bearer ${token}`,
...getProtectionBypassHeader(),
},
// @ts-expect-error -- undici dispatcher is accepted by Node.js fetch but not in @types/node's RequestInit
dispatcher: getDispatcher(),
Expand Down
144 changes: 144 additions & 0 deletions packages/world-vercel/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getHeaders, getHttpUrl, getProtectionBypassHeader } from './utils.js';

describe('getProtectionBypassHeader', () => {
const originalEnv = process.env;

beforeEach(() => {
process.env = { ...originalEnv };
});

afterEach(() => {
process.env = originalEnv;
});

it('returns empty object when env var is unset', () => {
delete process.env.VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS;
expect(getProtectionBypassHeader()).toEqual({});
});

it('returns empty object when env var is empty', () => {
process.env.VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS = '';
expect(getProtectionBypassHeader()).toEqual({});
});

it('returns x-vercel-protection-bypass header when env var is set', () => {
process.env.VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS = 'my-bypass-secret';
expect(getProtectionBypassHeader()).toEqual({
'x-vercel-protection-bypass': 'my-bypass-secret',
});
});
});

describe('getHttpUrl', () => {
const originalEnv = process.env;

beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.VERCEL_WORKFLOW_SERVER_URL;
delete process.env.WORKFLOW_VERCEL_BACKEND_URL;
});

afterEach(() => {
process.env = originalEnv;
});

it('uses default workflow-server URL when no config and no env override', () => {
expect(getHttpUrl()).toEqual({
baseUrl: 'https://vercel-workflow.com/api',
usingProxy: false,
});
});

it('respects VERCEL_WORKFLOW_SERVER_URL when set (no proxy)', () => {
process.env.VERCEL_WORKFLOW_SERVER_URL = 'https://custom-host.example.com';
expect(getHttpUrl()).toEqual({
baseUrl: 'https://custom-host.example.com/api',
usingProxy: false,
});
});

it('uses proxy when projectId + teamId are provided', () => {
expect(
getHttpUrl({
projectConfig: { projectId: 'prj_123', teamId: 'team_456' },
})
).toEqual({
baseUrl: 'https://api.vercel.com/v1/workflow',
usingProxy: true,
});
});

it('respects WORKFLOW_VERCEL_BACKEND_URL for custom proxy URL', () => {
process.env.WORKFLOW_VERCEL_BACKEND_URL = 'https://proxy.example.com/v1';
expect(
getHttpUrl({
projectConfig: { projectId: 'prj_123', teamId: 'team_456' },
})
).toEqual({
baseUrl: 'https://proxy.example.com/v1',
usingProxy: true,
});
});
});

describe('getHeaders', () => {
const originalEnv = process.env;

beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.VERCEL_WORKFLOW_SERVER_URL;
delete process.env.VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS;
});

afterEach(() => {
process.env = originalEnv;
});

it('omits x-vercel-protection-bypass when env var is unset', () => {
const headers = getHeaders(undefined, { usingProxy: false });
expect(headers.get('x-vercel-protection-bypass')).toBeNull();
});

it('sets x-vercel-protection-bypass when env var is set', () => {
process.env.VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS = 'my-secret';
const headers = getHeaders(undefined, { usingProxy: false });
expect(headers.get('x-vercel-protection-bypass')).toBe('my-secret');
});

it('omits x-vercel-workflow-api-url when override is unset', () => {
const headers = getHeaders(undefined, { usingProxy: true });
expect(headers.get('x-vercel-workflow-api-url')).toBeNull();
});

it('sets x-vercel-workflow-api-url when VERCEL_WORKFLOW_SERVER_URL is set and using proxy', () => {
process.env.VERCEL_WORKFLOW_SERVER_URL = 'https://custom.example.com';
const headers = getHeaders(undefined, { usingProxy: true });
expect(headers.get('x-vercel-workflow-api-url')).toBe(
'https://custom.example.com'
);
});

it('omits x-vercel-workflow-api-url when override is set but not using proxy', () => {
// Direct-to-workflow-server mode uses baseUrl, so the header is redundant.
process.env.VERCEL_WORKFLOW_SERVER_URL = 'https://custom.example.com';
const headers = getHeaders(undefined, { usingProxy: false });
expect(headers.get('x-vercel-workflow-api-url')).toBeNull();
});

it('sets project config headers when provided', () => {
const headers = getHeaders(
{
projectConfig: {
projectId: 'prj_123',
teamId: 'team_456',
environment: 'preview',
},
},
{ usingProxy: true }
);
expect(headers.get('x-vercel-project-id')).toBe('prj_123');
expect(headers.get('x-vercel-team-id')).toBe('team_456');
expect(headers.get('x-vercel-environment')).toBe('preview');
});
});
47 changes: 38 additions & 9 deletions packages/world-vercel/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,25 @@ function httpLog(
);
}
}

/**
* Hard-coded workflow-server URL override for testing.
* Set this to test against a different workflow-server version.
* Leave empty string for production (uses default vercel-workflow.com).
*
* Example: 'https://workflow-server-git-branch-name.vercel.sh'
* Inline workflow-server URL override. Must remain an empty string on
* `main` — rewritten by external CI for branch-deployment testing.
* Prefer `VERCEL_WORKFLOW_SERVER_URL` for deployment-time configuration.
*/
const WORKFLOW_SERVER_URL_OVERRIDE = '';

/**
* Effective workflow-server URL override. The inline constant wins when
* set; otherwise falls back to the `VERCEL_WORKFLOW_SERVER_URL` env var.
*
* When set, requests bypass the default production host
* (`https://vercel-workflow.com`). When using the proxy
* (`api.vercel.com/v1/workflow`), this value is forwarded via the
* `x-vercel-workflow-api-url` header so the proxy routes the request to
* the override URL.
*/
const getWorkflowServerUrlOverride = (): string =>
WORKFLOW_SERVER_URL_OVERRIDE || process.env.VERCEL_WORKFLOW_SERVER_URL || '';
export interface APIConfig {
token?: string;
headers?: RequestInit['headers'];
Expand Down Expand Up @@ -190,12 +199,28 @@ export interface HttpConfig {
usingProxy: boolean;
}

/**
* Returns an object with the Vercel Deployment Protection bypass header
* if the `VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS` env var is set, otherwise
* returns an empty object. Useful for spreading into a headers init object
* for direct fetch() calls that don't go through `getHeaders()`.
*
* See: https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation
*/
export function getProtectionBypassHeader(): Record<string, string> {
const bypassSecret = process.env.VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS;
if (bypassSecret) {
return { 'x-vercel-protection-bypass': bypassSecret };
}
return {};
}

export const getHttpUrl = (
config?: APIConfig
): { baseUrl: string; usingProxy: boolean } => {
const projectConfig = config?.projectConfig;
const defaultHost =
WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com';
getWorkflowServerUrlOverride() || 'https://vercel-workflow.com';
const customProxyUrl = process.env.WORKFLOW_VERCEL_BACKEND_URL;
const defaultProxyUrl = 'https://api.vercel.com/v1/workflow';
// Use proxy when we have project config (for authentication via Vercel API)
Expand Down Expand Up @@ -230,8 +255,12 @@ export const getHeaders = (
// Only set workflow-api-url header when using the proxy, since the proxy
// forwards it to the workflow-server. When not using proxy, requests go
// directly to the workflow-server so this header has no effect.
if (WORKFLOW_SERVER_URL_OVERRIDE && options.usingProxy) {
headers.set('x-vercel-workflow-api-url', WORKFLOW_SERVER_URL_OVERRIDE);
const workflowServerUrlOverride = getWorkflowServerUrlOverride();
if (workflowServerUrlOverride && options.usingProxy) {
headers.set('x-vercel-workflow-api-url', workflowServerUrlOverride);
}
for (const [key, value] of Object.entries(getProtectionBypassHeader())) {
headers.set(key, value);
}
return headers;
};
Expand Down
Loading