diff --git a/.changeset/world-vercel-protection-bypass.md b/.changeset/world-vercel-protection-bypass.md new file mode 100644 index 0000000000..cdb77b17c8 --- /dev/null +++ b/.changeset/world-vercel-protection-bypass.md @@ -0,0 +1,5 @@ +--- +"@workflow/world-vercel": minor +--- + +Add `VERCEL_WORKFLOW_SERVER_PROTECTION_BYPASS` and `VERCEL_WORKFLOW_SERVER_URL` env vars. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b3ed5bf0a2..36438509b0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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() diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index c2198d9643..947365f224 100644 --- a/packages/world-vercel/src/encryption.ts +++ b/packages/world-vercel/src/encryption.ts @@ -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) @@ -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(), diff --git a/packages/world-vercel/src/resolve-latest-deployment.test.ts b/packages/world-vercel/src/resolve-latest-deployment.test.ts index 3c316db687..fc40fbea1a 100644 --- a/packages/world-vercel/src/resolve-latest-deployment.test.ts +++ b/packages/world-vercel/src/resolve-latest-deployment.test.ts @@ -56,7 +56,7 @@ describe('createResolveLatestDeploymentId', () => { expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ - authorization: 'Bearer test-token', + Authorization: 'Bearer test-token', }), }) ); @@ -114,7 +114,7 @@ describe('createResolveLatestDeploymentId', () => { expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ - authorization: 'Bearer env-token-123', + Authorization: 'Bearer env-token-123', }), }) ); @@ -152,7 +152,7 @@ describe('createResolveLatestDeploymentId', () => { expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ - authorization: 'Bearer oidc-token-456', + Authorization: 'Bearer oidc-token-456', }), }) ); diff --git a/packages/world-vercel/src/resolve-latest-deployment.ts b/packages/world-vercel/src/resolve-latest-deployment.ts index aeaedf3c4c..67481dd1f7 100644 --- a/packages/world-vercel/src/resolve-latest-deployment.ts +++ b/packages/world-vercel/src/resolve-latest-deployment.ts @@ -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(), @@ -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(), diff --git a/packages/world-vercel/src/utils.test.ts b/packages/world-vercel/src/utils.test.ts new file mode 100644 index 0000000000..e36717239d --- /dev/null +++ b/packages/world-vercel/src/utils.test.ts @@ -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'); + }); +}); diff --git a/packages/world-vercel/src/utils.ts b/packages/world-vercel/src/utils.ts index 5db88afadd..dd13eeeadd 100644 --- a/packages/world-vercel/src/utils.ts +++ b/packages/world-vercel/src/utils.ts @@ -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']; @@ -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 { + 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) @@ -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; };