From bf8f0f8a2ff26d9ea103c549f16883b251540686 Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 23:24:48 -0500 Subject: [PATCH 1/9] fix: ask the app to reconnect when Vercel rejects the token A Vercel 401 used to become a generic 502, so an expired or revoked token looked like a network failure and never asked for reconnection. Map it to 401, which the client already treats as expired, and add nosniff to the callback response. --- integrations/vercel-worker/index.mjs | 4 +++- integrations/vercel-worker/index.test.mjs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/integrations/vercel-worker/index.mjs b/integrations/vercel-worker/index.mjs index 762fb45..a977a6c 100644 --- a/integrations/vercel-worker/index.mjs +++ b/integrations/vercel-worker/index.mjs @@ -45,7 +45,7 @@ export class Session { const result = await response.json(); if (typeof result.access_token !== 'string' || !result.access_token || typeof result.team_id !== 'string') return json({ error: 'Invalid authorization response' }, 502); await storage.put({ token: result.access_token, team: result.team_id }); - return new Response('Connected. Return to Agent Notch.', { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'Content-Security-Policy': "default-src 'none'" } }); + return new Response('Connected. Return to Agent Notch.', { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'" } }); }); } const verifier = request.headers.get('Authorization')?.replace(/^Bearer /, '') ?? ''; @@ -67,6 +67,8 @@ export class Session { const until = url.searchParams.get('until'); if (until && /^\d{1,16}$/.test(until)) endpoint.searchParams.set('until', until); const result = await fetch(endpoint, { redirect: 'error', headers: { Authorization: `Bearer ${token}` } }); + // An expired or revoked token must ask the app to reconnect, not look like a network failure. + if (result.status === 401) return json({ error: 'Expired' }, 401); if (!result.ok) return json({ error: 'Vercel request failed' }, result.status === 429 ? 429 : 502); const page = await result.json(); if (!Array.isArray(page.deployments)) return json({ error: 'Invalid Vercel response' }, 502); diff --git a/integrations/vercel-worker/index.test.mjs b/integrations/vercel-worker/index.test.mjs index 77bac74..e49799c 100644 --- a/integrations/vercel-worker/index.test.mjs +++ b/integrations/vercel-worker/index.test.mjs @@ -65,6 +65,25 @@ test('deployments proxy scopes Vercel requests and never returns its token', asy }); +test('an expired Vercel token asks the client to reconnect and the callback is hardened', async () => { + const { session } = setup(); + await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); + const oldFetch = globalThis.fetch; + globalThis.fetch = async input => { + const url = new URL(input); + if (url.pathname === '/v2/oauth/access_token') return Response.json({access_token:'private-token', team_id:'team_123'}); + return new Response('unauthorized', {status: 401}); + }; + try { + const callback = await session.fetch(req('/callback?code=example')); + assert.equal(callback.headers.get('X-Content-Type-Options'), 'nosniff'); + await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + secret}})); + const response = await session.fetch(req('/deployments', {headers:{Authorization:'Bearer ' + secret}})); + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), {error: 'Expired'}); + } finally { globalThis.fetch = oldFetch; } +}); + test('router rejects invalid state and rate-limited clients', async () => { assert.equal((await worker.fetch(req('/status?state=no'), {})).status, 400); const env = { RATE_LIMITER: { limit: async () => ({ success: false }) } }; From 863afaf22c731b6d5ade3d6bc39457329839ef24 Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 23:24:48 -0500 Subject: [PATCH 2/9] build: inject the Vercel Worker URL at packaging time The app reads VercelWorkerURL and VercelIntegrationSlug from its Info.plist, but nothing supplied them, so every build was inert. make app now fills them from VERCEL_WORKER_URL and VERCEL_INTEGRATION_SLUG; without them the keys are absent and the Vercel UI stays hidden. --- Makefile | 3 +++ scripts/release.sh | 3 +++ 2 files changed, 6 insertions(+) diff --git a/Makefile b/Makefile index f18838e..d40fdab 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,9 @@ app: build mkdir -p "$(APP)/Contents/Resources/scripts" cp -R bridge integrations "$(APP)/Contents/Resources/" cp scripts/install.sh scripts/uninstall.sh scripts/lib.sh "$(APP)/Contents/Resources/scripts/" + # Vercel is off unless the release supplies the Worker URL: nothing is committed, so a dev build stays inert. + @if [ -n "$$VERCEL_WORKER_URL" ]; then plutil -replace VercelWorkerURL -string "$$VERCEL_WORKER_URL" "$(APP)/Contents/Info.plist"; fi + @if [ -n "$$VERCEL_INTEGRATION_SLUG" ]; then plutil -replace VercelIntegrationSlug -string "$$VERCEL_INTEGRATION_SLUG" "$(APP)/Contents/Info.plist"; fi codesign --force -s - "$(APP)" @echo "Built $(APP)" diff --git a/scripts/release.sh b/scripts/release.sh index 26ecb28..1091eaa 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -9,6 +9,9 @@ # xcrun notarytool store-credentials --apple-id --team-id # (it prompts for the app-specific password, so it never lands in your shell history). # Needs DEVELOPER_ID too. Notarizes the DMG and staples the ticket. +# VERCEL_WORKER_URL Vercel integration Worker URL to build into the app (optional). +# VERCEL_INTEGRATION_SLUG Vercel integration slug, defaults to "agent-notch" (optional). +# Without them the Vercel UI stays hidden and the app makes no Vercel requests. # Full setup: docs/notarization.md set -euo pipefail From 94babab43f6c0d87b08b4c2801977a596cf6980b Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 23:24:48 -0500 Subject: [PATCH 3/9] docs: review the Vercel pairing flow and draft the privacy wording Record the pairing model, ten findings with severity and status (two fixed here), the residual risk before a public release, the exact README and CONTRIBUTING changes needed for the opt-in network exception, and the deployment steps that need the owner's accounts. --- docs/vercel-deployment-monitoring.md | 2 +- docs/vercel-privacy.md | 57 ++++++++++++++++++++++++++++ docs/vercel-security-review.md | 45 ++++++++++++++++++++++ integrations/vercel-worker/README.md | 55 +++++++++++++++++++++++++-- 4 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 docs/vercel-privacy.md create mode 100644 docs/vercel-security-review.md diff --git a/docs/vercel-deployment-monitoring.md b/docs/vercel-deployment-monitoring.md index 1cf7d3d..882c79f 100644 --- a/docs/vercel-deployment-monitoring.md +++ b/docs/vercel-deployment-monitoring.md @@ -19,6 +19,6 @@ Show production deployments from every project available to a Vercel integration ## Current status -An unreleased Worker and desktop prototype exist, with local pairing, polling, UI, notification code, and mocked tests. The Worker URL is unset and the Vercel menu is hidden. The integration form is not submitted. Security review, real installation, deployment, and end-to-end verification remain required; see `integrations/vercel-worker/README.md` before any deployment. +An unreleased Worker and desktop prototype exist, with local pairing, polling, UI, notification code, and mocked tests. The Worker URL is unset and the Vercel menu is hidden. The integration form is not submitted. A review of the pairing flow is in `docs/vercel-security-review.md` (not independent), the privacy wording to ship with it is drafted in `docs/vercel-privacy.md`, and the Worker URL is injected at packaging time with `VERCEL_WORKER_URL`. Independent review, real installation, deployment, and end-to-end verification remain required; see `integrations/vercel-worker/README.md` before any deployment. References: [Vercel integration authorization](https://vercel.com/docs/integrations/create-integration/vercel-api-integrations), [Vercel integration registration](https://vercel.com/docs/integrations/create-integration/submit-integration), [Cloudflare Worker secrets](https://developers.cloudflare.com/workers/configuration/secrets/). diff --git a/docs/vercel-privacy.md b/docs/vercel-privacy.md new file mode 100644 index 0000000..34f4322 --- /dev/null +++ b/docs/vercel-privacy.md @@ -0,0 +1,57 @@ +# Vercel privacy wording (proposal) + +The app currently promises, in all three READMEs and in `CONTRIBUTING.md`, that it never sends +anything anywhere. Connecting Vercel necessarily breaks that promise for users who opt in, so the +wording has to change *with* the feature, not after it. This is the proposed change; it is not +applied yet because it changes a public promise. + +## README.md + +Replace the opening sentence of the "Privacy" section: + +> It runs locally, listens only on `127.0.0.1`, and never sends anything anywhere. + +with: + +> It runs locally and listens only on `127.0.0.1`. It never sends your agent activity anywhere. +> The only outbound request the app can make is the optional Vercel connection described below, +> and only after you start it yourself. + +Then add, after the Vercel feature section: + +> ### Vercel (optional) +> +> Agent Notch can show production deployments from the Vercel projects your integration can see. +> It is off until you choose **Connect Vercel…**: before that, the app makes no network requests +> and the menu item is hidden. +> +> When you connect, the app keeps a device credential in the Keychain and talks only to the +> Agent Notch Worker. The Worker holds the integration's client secret and exchanges it for a +> Vercel token scoped to reading deployments; that token never reaches your Mac. The app asks for +> deployment read access only, and sends no prompts, code, session data or file paths. +> +> **Disconnect** removes the local credential. To revoke the integration's access as well, +> remove it in Vercel; the confirmation dialog says so. +> +> Deployment notifications are off by default and, when enabled, arrive silently (no sound, no +> banner that interrupts). + +## CONTRIBUTING.md + +Replace: + +> The server must stay loopback-only and the app must not make network requests. + +with: + +> The server must stay loopback-only. The app makes no network requests except the optional +> Vercel connection, which must stay off by default, hidden until configured, started only by an +> explicit user action, and documented in the README of every language. + +## Notes + +- The Spanish and German READMEs must carry the same wording; translate after the English text is + approved so the promise does not drift between languages. +- If the integration is never finished, this change should not land: the current promise is true + today and should stay that way. +- `SECURITY.md` should point at `docs/vercel-security-review.md` once the review is independent. diff --git a/docs/vercel-security-review.md b/docs/vercel-security-review.md new file mode 100644 index 0000000..a94b91d --- /dev/null +++ b/docs/vercel-security-review.md @@ -0,0 +1,45 @@ +# Vercel integration: security review + +Scope: the pairing flow in `integrations/vercel-worker/index.mjs` and its client in +`Sources/AgentNotch/Infrastructure/VercelMonitor.swift`. Reviewed against the design in +`docs/vercel-deployment-monitoring.md` on 2026-11-04. This is a code review by the author of the +change, not an independent audit; treat the "residual risk" items as decisions to confirm, not as +cleared findings. + +## Design summary + +1. The desktop app generates a 256-bit `state` and a 256-bit `verifier`, sends only + `SHA-256(verifier)` to the Worker, and opens Vercel's integration page with `state`. +2. Vercel redirects to the Worker with an authorization `code`; the Worker exchanges it using + `VERCEL_CLIENT_SECRET`, which never leaves Cloudflare, and stores the resulting token in the + Durable Object for that `state`. +3. The app proves possession of `verifier` on every later call. The Worker compares + `SHA-256(verifier)` with the stored challenge; the token is never returned to the app. +4. The app keeps `state` + `verifier` in the Keychain + (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`) and calls `/deployments` with the bearer. + +## Findings + +| # | Severity | Finding | Status | +|---|----------|---------|--------| +| F1 | Medium | **Disconnect deletes the local session but does not revoke the Vercel token.** Access remains until the user removes the integration in Vercel; the confirmation dialog says so, but a user who disconnects and forgets leaves a live token. | Open: needs a revocation endpoint decision. Add a `POST https://api.vercel.com/v2/oauth/revoke` call only after verifying Vercel documents it; otherwise keep the current copy and make it louder. | +| F2 | Medium | **An expired Vercel token was reported as a generic failure.** A `401` from Vercel became `502` to the client, so the app showed "Could not connect to Vercel" forever instead of asking to reconnect. | Fixed: Vercel `401` now maps to `401`, which the client already treats as expired and clears. | +| F3 | Low | The callback response carried no `X-Content-Type-Options`. | Fixed: `nosniff` added. | +| F4 | Low | **The callback is bound to Vercel only by the `code` and the secrecy of `state`.** Someone who learns a live `state` (it travels in the Vercel redirect URL, so it can appear in logs) could attach their own authorization to that session; they still cannot read deployments or disconnect, which require `verifier`. | Accepted: standard OAuth-with-state trade-off. Shorten the 10-minute window if that ever matters. | +| F5 | Low | Rate limiting is per `CF-Connecting-IP` and shared across all routes; clients behind one NAT share 120 requests/minute. | Accepted. The limit exists to stop brute force, not to meter legitimate use. | +| F6 | Info | The digest comparison is not constant-time. | Accepted: both values are 256-bit secrets; a timing attack needs an infeasible number of samples. | +| F7 | Info | `state` also selects the Durable Object, and any 64-hex value creates one. | Accepted: creations are rate-limited and unclaimed sessions delete themselves after 10 minutes. | +| F8 | Info | Token retention: a claimed session keeps the token until `/disconnect`; an unclaimed one is deleted by the alarm at 10 minutes. Cloudflare encrypts Durable Object storage at rest. | Documented. There is no periodic rotation; reconnecting is the rotation. | +| F9 | Info | The verifier never leaves the device, and the token never reaches it. A stolen `state` alone is not enough to read deployments. | Design property; keep it. | +| F10 | Info | The Worker returns only `uid`, `name`, `state` and git metadata for production deployments of the stored team. No project list, env vars or logs. | Design property; keep it. | + +## Residual risk before a public release + +1. **Independent review.** This document is not one. Someone who did not write the flow should + read it against the Vercel integration contract before real users connect. +2. **No real end-to-end run.** Pairing, callback, polling and revocation have only run against + mocks. A test with a throwaway Vercel account and a real deployment is still required. +3. **Secret handling in Cloudflare.** `VERCEL_CLIENT_SECRET` must be a Worker secret + (`wrangler secret put`), never a `var` or a committed file; rotation must be documented. +4. **Privacy promise.** The app currently states it never sends anything anywhere. Shipping this + requires the wording change drafted in `docs/vercel-privacy.md` to land with it. diff --git a/integrations/vercel-worker/README.md b/integrations/vercel-worker/README.md index 93fa285..827a752 100644 --- a/integrations/vercel-worker/README.md +++ b/integrations/vercel-worker/README.md @@ -1,7 +1,56 @@ # Vercel connection prototype -This Worker is an **unreleased local prototype**, not a deployable production service. It is not connected to Agent Notch. Do not deploy it or use real credentials until the authentication flow, token lifecycle, abuse controls, revocation and storage retention have been security-reviewed and integration-tested. +This Worker is an **unreleased prototype** and is not connected to Agent Notch. Do not deploy it or +use real credentials until the security review in `docs/vercel-security-review.md` has an +independent pass and the flow has run end to end. -Run the isolated tests with `node --test integrations/vercel-worker/index.test.mjs`. The tests use mocked Vercel responses and no credentials. +Run the isolated tests with `node --test integrations/vercel-worker/index.test.mjs`. The tests use +mocked Vercel responses and no credentials. -The intended deployment would require `VERCEL_CLIENT_ID`, `VERCEL_CLIENT_SECRET`, and `VERCEL_REDIRECT_URI` bindings and a Durable Object namespace. Use Cloudflare secret bindings for credentials, not `vars` or committed files. +## What only the owner can do + +The code side is ready; these steps need accounts, secrets and accepting Vercel's terms. + +### 1. Deploy the Worker + +```bash +cd integrations/vercel-worker +wrangler secret put VERCEL_CLIENT_ID +wrangler secret put VERCEL_CLIENT_SECRET # never a var, never a committed file +wrangler secret put VERCEL_REDIRECT_URI # https:///callback +wrangler deploy +``` + +The Durable Object namespace, its migration and the rate limit are already declared in +`wrangler.toml`. Rotating the client secret is `wrangler secret put VERCEL_CLIENT_SECRET` again; +devices keep working because they never see it. + +### 2. Register the Vercel integration + +1. Create an integration with **deployment read** permissions only. +2. Set its redirect URL to `https:///callback`. +3. Submit the integration for review if it is going to other people's accounts. + +The redirect URL must match `VERCEL_REDIRECT_URI` exactly, including the scheme and no trailing +slash, or the token exchange fails with `502`. + +### 3. Build a release that can reach the Worker + +The URL is injected at packaging time, so it is not committed: + +```bash +VERCEL_WORKER_URL=https:// VERCEL_INTEGRATION_SLUG= scripts/release.sh +``` + +Without those variables the app ships with the Vercel UI hidden and makes no Vercel requests. + +### 4. Prove it end to end + +- Connect from a build that has the Worker URL, complete the Vercel flow, and confirm the app says + connected. +- Start a real production deployment from a throwaway repository; watch the badge appear when idle + and the row in the expanded list. +- Disconnect, then confirm the Worker session is gone (`/status` returns 401) and the Vercel + integration's access was revoked or flagged for manual removal. +- Restart the app while a deployment runs; the deployment must not notify for work that finished + while it was closed. From a7207ee7d498ad95bc4b73bcb7bb72a7dd991825 Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 23:33:42 -0500 Subject: [PATCH 4/9] docs: explain where the integration credentials come from The runbook asked for wrangler secret put before the Worker existed, and never said that Vercel assigns the client ID and secret when the integration is created. Deploy first, then create the integration with the redirect URL, then store the three values. --- integrations/vercel-worker/README.md | 68 ++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/integrations/vercel-worker/README.md b/integrations/vercel-worker/README.md index 827a752..c465fa7 100644 --- a/integrations/vercel-worker/README.md +++ b/integrations/vercel-worker/README.md @@ -7,34 +7,66 @@ independent pass and the flow has run end to end. Run the isolated tests with `node --test integrations/vercel-worker/index.test.mjs`. The tests use mocked Vercel responses and no credentials. -## What only the owner can do +## Where the credentials come from -The code side is ready; these steps need accounts, secrets and accepting Vercel's terms. +There are three values, and only two are secrets: -### 1. Deploy the Worker +| Value | Where it comes from | +|-------|---------------------| +| `VERCEL_CLIENT_ID` | Vercel assigns it when you create the integration (bottom of its settings page) | +| `VERCEL_CLIENT_SECRET` | Same page; rotatable with **Rotate Secret** | +| `VERCEL_REDIRECT_URI` | Not a credential: the callback URL you type in the integration form, exactly | + +None of them live in a `.env` you generate. Vercel creates a client ID and secret per integration, +so the integration has to exist before the Worker can hold them. That forces the order below: the +Worker is deployed first so its URL can be used as the redirect URL. + +## 1. Deploy the Worker (no secrets yet) ```bash cd integrations/vercel-worker -wrangler secret put VERCEL_CLIENT_ID -wrangler secret put VERCEL_CLIENT_SECRET # never a var, never a committed file -wrangler secret put VERCEL_REDIRECT_URI # https:///callback -wrangler deploy +npx wrangler login +npx wrangler deploy ``` -The Durable Object namespace, its migration and the rate limit are already declared in -`wrangler.toml`. Rotating the client secret is `wrangler secret put VERCEL_CLIENT_SECRET` again; -devices keep working because they never see it. +`wrangler deploy` prints the URL, normally +`https://agent-notch-vercel..workers.dev`. Use that (or a custom domain) as the +callback host everywhere below. The Worker deploys fine without secrets because they are only read +at request time; `wrangler secret put` would also refuse to run before the Worker exists. + +The Durable Object namespace, its SQLite migration and the rate limit are declared in +`wrangler.toml`. Workers Free supports SQLite-backed Durable Objects; if `wrangler deploy` objects +to the `[[ratelimits]]` binding, that is the one piece that may need a paid plan — remove the binding +and the check in `index.mjs` to stay on Free. + +## 2. Create the Vercel integration (this is where the credentials are born) -### 2. Register the Vercel integration +1. Open the Vercel dashboard, pick your team, then **Integrations → Integrations Console → Create**. +2. Fill in the required profile fields (name, URL slug, developer, contact emails, logo, category, + website, documentation, EULA and privacy URLs, overview). +3. Set **Redirect URL** to `https:///callback` and keep the API scopes to + **deployments read** only. +4. Save. The **client ID and client secret** are at the bottom of the integration's settings page, + under **Credentials**. +5. The **URL slug** you chose is `VERCEL_INTEGRATION_SLUG`; it is also the install URL + `https://vercel.com/integrations//new`. -1. Create an integration with **deployment read** permissions only. -2. Set its redirect URL to `https:///callback`. -3. Submit the integration for review if it is going to other people's accounts. +You do not need to submit the integration for review to use it yourself; the review is for listing +it publicly. + +## 3. Store the values as Worker secrets + +```bash +npx wrangler secret put VERCEL_CLIENT_ID +npx wrangler secret put VERCEL_CLIENT_SECRET +npx wrangler secret put VERCEL_REDIRECT_URI # https:///callback +``` -The redirect URL must match `VERCEL_REDIRECT_URI` exactly, including the scheme and no trailing -slash, or the token exchange fails with `502`. +Secrets take effect immediately; no redeploy needed. Never put the secret in `wrangler.toml`, a +committed file, or a `var`. Rotating the client secret is `wrangler secret put` again with the new +value from **Rotate Secret**: devices keep working because they never see it. -### 3. Build a release that can reach the Worker +## 4. Build a release that can reach the Worker The URL is injected at packaging time, so it is not committed: @@ -44,7 +76,7 @@ VERCEL_WORKER_URL=https:// VERCEL_INTEGRATION_SLUG= scripts/r Without those variables the app ships with the Vercel UI hidden and makes no Vercel requests. -### 4. Prove it end to end +## 5. Prove it end to end - Connect from a build that has the Worker URL, complete the Vercel flow, and confirm the app says connected. From 8f879858a4dd834752e61ab0becff232aba59105 Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 23:40:26 -0500 Subject: [PATCH 5/9] docs: say where to run wrangler and how to smoke-test the Worker A cd failure sends wrangler deploy to a directory with no wrangler.toml, where it looks for a Pages project and fails; record that and add a 401 smoke test for the deployed Worker. --- integrations/vercel-worker/README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/integrations/vercel-worker/README.md b/integrations/vercel-worker/README.md index c465fa7..c87974d 100644 --- a/integrations/vercel-worker/README.md +++ b/integrations/vercel-worker/README.md @@ -23,14 +23,27 @@ Worker is deployed first so its URL can be used as the redirect URL. ## 1. Deploy the Worker (no secrets yet) +Run from the repository root. If `cd` fails, you are in the wrong directory: `wrangler deploy` +without a `wrangler.toml` looks for a Pages project and fails with "Could not detect a directory +containing static files", deploying nothing. + ```bash cd integrations/vercel-worker npx wrangler login +npx wrangler whoami # confirms the login npx wrangler deploy ``` `wrangler deploy` prints the URL, normally -`https://agent-notch-vercel..workers.dev`. Use that (or a custom domain) as the +`https://agent-notch-vercel..workers.dev`. Smoke-test it with any 64-hex state; a +`401` means the Worker and its Durable Object are up and the session is unauthenticated: + +```bash +curl -s -o /dev/null -w '%{http_code}\n' \ + "https:///status?state=$(openssl rand -hex 32)" # expect 401 +``` + +Use that URL (or a custom domain) as the callback host everywhere below. The Worker deploys fine without secrets because they are only read at request time; `wrangler secret put` would also refuse to run before the Worker exists. From 659571b61427c6256aba474073307760b64d69e4 Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 23:54:00 -0500 Subject: [PATCH 6/9] fix: support personal-account installs and Vercel's documented endpoint Vercel answers the token exchange with team_id null for a personal account; the Worker required a string and rejected a successful authorization with 502. Store an empty team and omit teamId in that case. Also move to /v6/deployments, the endpoint in Vercel's current reference, and cover both with tests. --- docs/vercel-security-review.md | 4 +++- integrations/vercel-worker/index.mjs | 10 ++++++---- integrations/vercel-worker/index.test.mjs | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/docs/vercel-security-review.md b/docs/vercel-security-review.md index a94b91d..c5f238b 100644 --- a/docs/vercel-security-review.md +++ b/docs/vercel-security-review.md @@ -22,7 +22,7 @@ cleared findings. | # | Severity | Finding | Status | |---|----------|---------|--------| -| F1 | Medium | **Disconnect deletes the local session but does not revoke the Vercel token.** Access remains until the user removes the integration in Vercel; the confirmation dialog says so, but a user who disconnects and forgets leaves a live token. | Open: needs a revocation endpoint decision. Add a `POST https://api.vercel.com/v2/oauth/revoke` call only after verifying Vercel documents it; otherwise keep the current copy and make it louder. | +| F1 | Medium | **Disconnect deletes the local session but does not revoke the Vercel token.** Access remains until the user removes the integration in Vercel; the confirmation dialog says so, but a user who disconnects and forgets leaves a live token. | Open: `POST https://api.vercel.com/v2/oauth/revoke` answers `415` instead of `404` to an empty request, so the endpoint likely exists, but its contract is not in the pages reviewed. Confirm the parameters before calling it; until then keep the current copy and make it louder. | | F2 | Medium | **An expired Vercel token was reported as a generic failure.** A `401` from Vercel became `502` to the client, so the app showed "Could not connect to Vercel" forever instead of asking to reconnect. | Fixed: Vercel `401` now maps to `401`, which the client already treats as expired and clears. | | F3 | Low | The callback response carried no `X-Content-Type-Options`. | Fixed: `nosniff` added. | | F4 | Low | **The callback is bound to Vercel only by the `code` and the secrecy of `state`.** Someone who learns a live `state` (it travels in the Vercel redirect URL, so it can appear in logs) could attach their own authorization to that session; they still cannot read deployments or disconnect, which require `verifier`. | Accepted: standard OAuth-with-state trade-off. Shorten the 10-minute window if that ever matters. | @@ -32,6 +32,8 @@ cleared findings. | F8 | Info | Token retention: a claimed session keeps the token until `/disconnect`; an unclaimed one is deleted by the alarm at 10 minutes. Cloudflare encrypts Durable Object storage at rest. | Documented. There is no periodic rotation; reconnecting is the rotation. | | F9 | Info | The verifier never leaves the device, and the token never reaches it. A stolen `state` alone is not enough to read deployments. | Design property; keep it. | | F10 | Info | The Worker returns only `uid`, `name`, `state` and git metadata for production deployments of the stored team. No project list, env vars or logs. | Design property; keep it. | +| F11 | Medium | **Personal-account installations failed.** Vercel answers the token exchange with `team_id: null` when the integration is installed on a personal account, and the Worker demanded a string, so the callback returned `502` after a successful authorization. | Fixed: a null or absent team is stored as empty and `teamId` is omitted from Vercel requests. Covered by a test. | +| F12 | Low | The Worker called `https://api.vercel.com/v7/deployments`, which is not the endpoint in Vercel's current reference. | Fixed: uses the documented `/v6/deployments`. | ## Residual risk before a public release diff --git a/integrations/vercel-worker/index.mjs b/integrations/vercel-worker/index.mjs index a977a6c..dcd6e1d 100644 --- a/integrations/vercel-worker/index.mjs +++ b/integrations/vercel-worker/index.mjs @@ -43,8 +43,9 @@ export class Session { }); if (!response.ok) return json({ error: 'Authorization failed' }, 502); const result = await response.json(); - if (typeof result.access_token !== 'string' || !result.access_token || typeof result.team_id !== 'string') return json({ error: 'Invalid authorization response' }, 502); - await storage.put({ token: result.access_token, team: result.team_id }); + // A personal account installs with team_id null; only a team install has an id to scope requests with. + if (typeof result.access_token !== 'string' || !result.access_token) return json({ error: 'Invalid authorization response' }, 502); + await storage.put({ token: result.access_token, team: typeof result.team_id === 'string' ? result.team_id : '' }); return new Response('Connected. Return to Agent Notch.', { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'" } }); }); } @@ -60,8 +61,9 @@ export class Session { return json({ status: 'connected' }); } if (url.pathname === '/deployments' && request.method === 'GET') { - const endpoint = new URL('https://api.vercel.com/v7/deployments'); - endpoint.searchParams.set('teamId', await storage.get('team')); + const endpoint = new URL('https://api.vercel.com/v6/deployments'); + const team = await storage.get('team'); + if (typeof team === 'string' && team) endpoint.searchParams.set('teamId', team); endpoint.searchParams.set('target', 'production'); endpoint.searchParams.set('limit', '100'); const until = url.searchParams.get('until'); diff --git a/integrations/vercel-worker/index.test.mjs b/integrations/vercel-worker/index.test.mjs index e49799c..db4cfdd 100644 --- a/integrations/vercel-worker/index.test.mjs +++ b/integrations/vercel-worker/index.test.mjs @@ -43,6 +43,24 @@ test('expired pairing deletes an unclaimed authorization', async () => { assert.equal(values.size, 0); }); +test('a personal account installs with team_id null and calls Vercel without teamId', async () => { + const { session } = setup(); + await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); + const oldFetch = globalThis.fetch; + globalThis.fetch = async input => { + const url = new URL(input); + if (url.pathname === '/v2/oauth/access_token') return Response.json({access_token:'private-token', team_id: null}); + assert.equal(url.pathname, '/v6/deployments'); + assert.equal(url.searchParams.get('teamId'), null); + return Response.json({deployments: [], pagination: {next: null}}); + }; + try { + assert.equal((await session.fetch(req('/callback?code=example'))).status, 200); + await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + secret}})); + assert.equal((await session.fetch(req('/deployments', {headers:{Authorization:'Bearer ' + secret}}))).status, 200); + } finally { globalThis.fetch = oldFetch; } +}); + test('deployments proxy scopes Vercel requests and never returns its token', async () => { const { session } = setup(); await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); From 31525fb907db2f5c5c218ed32c96a3404275fbee Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 23:56:46 -0500 Subject: [PATCH 7/9] fix: answer the callback instead of throwing a Cloudflare 1101 An exception during the token exchange surfaced as an opaque Error 1101 page, hiding the cause. The callback now catches it, returns a short JSON reason, and logs the exchange status plus whether each secret binding is present, without printing any value. Add tests for a network failure and a non-JSON authorization response. --- integrations/vercel-worker/index.mjs | 36 +++++++++++++++-------- integrations/vercel-worker/index.test.mjs | 24 +++++++++++++++ 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/integrations/vercel-worker/index.mjs b/integrations/vercel-worker/index.mjs index dcd6e1d..67692b2 100644 --- a/integrations/vercel-worker/index.mjs +++ b/integrations/vercel-worker/index.mjs @@ -34,19 +34,29 @@ export class Session { } if (url.pathname === '/callback' && request.method === 'GET') { return this.state.blockConcurrencyWhile(async () => { - if (!(await storage.get('challenge')) || Date.now() > (await storage.get('expires')) || await storage.get('token')) return json({ error: 'Expired or completed' }, 400); - const code = url.searchParams.get('code'); - if (!code || code.length > 1024) return json({ error: 'Invalid code' }, 400); - const response = await fetch('https://api.vercel.com/v2/oauth/access_token', { - method: 'POST', redirect: 'error', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ client_id: this.env.VERCEL_CLIENT_ID, client_secret: this.env.VERCEL_CLIENT_SECRET, code, redirect_uri: this.env.VERCEL_REDIRECT_URI }) - }); - if (!response.ok) return json({ error: 'Authorization failed' }, 502); - const result = await response.json(); - // A personal account installs with team_id null; only a team install has an id to scope requests with. - if (typeof result.access_token !== 'string' || !result.access_token) return json({ error: 'Invalid authorization response' }, 502); - await storage.put({ token: result.access_token, team: typeof result.team_id === 'string' ? result.team_id : '' }); - return new Response('Connected. Return to Agent Notch.', { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'" } }); + try { + if (!(await storage.get('challenge')) || Date.now() > (await storage.get('expires')) || await storage.get('token')) return json({ error: 'Expired or completed' }, 400); + const code = url.searchParams.get('code'); + if (!code || code.length > 1024) return json({ error: 'Invalid code' }, 400); + // Log presence, never values: a missing secret is the most common cause of a failed exchange. + console.log('callback', { hasCode: Boolean(code), clientId: Boolean(this.env.VERCEL_CLIENT_ID), clientSecret: Boolean(this.env.VERCEL_CLIENT_SECRET), redirectUri: this.env.VERCEL_REDIRECT_URI ?? null }); + const response = await fetch('https://api.vercel.com/v2/oauth/access_token', { + method: 'POST', redirect: 'error', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: this.env.VERCEL_CLIENT_ID, client_secret: this.env.VERCEL_CLIENT_SECRET, code, redirect_uri: this.env.VERCEL_REDIRECT_URI }) + }); + console.log('token exchange', response.status); + if (!response.ok) return json({ error: 'Authorization failed' }, 502); + let result; + try { result = JSON.parse(await response.text()); } catch { return json({ error: 'Authorization response was not JSON' }, 502); } + // A personal account installs with team_id null; only a team install has an id to scope requests with. + if (typeof result?.access_token !== 'string' || !result.access_token) return json({ error: 'Invalid authorization response' }, 502); + await storage.put({ token: result.access_token, team: typeof result.team_id === 'string' ? result.team_id : '' }); + return new Response('Connected. Return to Agent Notch.', { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'" } }); + } catch (error) { + // Never surface a 1101 here: the browser should show a short reason and the log keeps the stack. + console.log('callback failed', error?.stack ?? String(error)); + return json({ error: 'Callback failed' }, 502); + } }); } const verifier = request.headers.get('Authorization')?.replace(/^Bearer /, '') ?? ''; diff --git a/integrations/vercel-worker/index.test.mjs b/integrations/vercel-worker/index.test.mjs index db4cfdd..5042b51 100644 --- a/integrations/vercel-worker/index.test.mjs +++ b/integrations/vercel-worker/index.test.mjs @@ -61,6 +61,30 @@ test('a personal account installs with team_id null and calls Vercel without tea } finally { globalThis.fetch = oldFetch; } }); +test('a failing token exchange answers 502 instead of throwing a 1101', async () => { + const { session } = setup(); + await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); + const oldFetch = globalThis.fetch; + globalThis.fetch = async () => { throw new TypeError('network down'); }; + try { + const response = await session.fetch(req('/callback?code=example')); + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), {error: 'Callback failed'}); + } finally { globalThis.fetch = oldFetch; } +}); + +test('a non-JSON authorization response answers 502 instead of throwing', async () => { + const { session } = setup(); + await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); + const oldFetch = globalThis.fetch; + globalThis.fetch = async () => new Response('nope', {status: 200, headers: {'Content-Type': 'text/html'}}); + try { + const response = await session.fetch(req('/callback?code=example')); + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), {error: 'Authorization response was not JSON'}); + } finally { globalThis.fetch = oldFetch; } +}); + test('deployments proxy scopes Vercel requests and never returns its token', async () => { const { session } = setup(); await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); From 94eaa77f4923f8b72bf2c531f71e897300fe77c0 Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 23:58:38 -0500 Subject: [PATCH 8/9] fix: replace redirect error with manual, which the edge accepts Cloudflare Workers rejects fetch redirect: 'error' at runtime, even though Node accepts it, so the callback threw an unhandled exception and showed Error 1101 after a successful authorization. Use redirect: 'manual' and refuse any 3xx explicitly, in both the token exchange and the deployments call. Record the finding and cover the redirect case. --- docs/vercel-security-review.md | 1 + integrations/vercel-worker/index.mjs | 9 +++++++-- integrations/vercel-worker/index.test.mjs | 15 ++++++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/vercel-security-review.md b/docs/vercel-security-review.md index c5f238b..29da0a8 100644 --- a/docs/vercel-security-review.md +++ b/docs/vercel-security-review.md @@ -34,6 +34,7 @@ cleared findings. | F10 | Info | The Worker returns only `uid`, `name`, `state` and git metadata for production deployments of the stored team. No project list, env vars or logs. | Design property; keep it. | | F11 | Medium | **Personal-account installations failed.** Vercel answers the token exchange with `team_id: null` when the integration is installed on a personal account, and the Worker demanded a string, so the callback returned `502` after a successful authorization. | Fixed: a null or absent team is stored as empty and `teamId` is omitted from Vercel requests. Covered by a test. | | F12 | Low | The Worker called `https://api.vercel.com/v7/deployments`, which is not the endpoint in Vercel's current reference. | Fixed: uses the documented `/v6/deployments`. | +| F13 | High | **The callback threw an unhandled exception on the edge.** `fetch(..., { redirect: 'error' })` is accepted by Node but rejected by the Workers runtime ("must be one of follow or manual"), so the token exchange failed with a Cloudflare `1101` page after a successful authorization. The Node test suite could not catch it. | Fixed: `redirect: 'manual'` plus an explicit 3xx check, a `try/catch` that answers `502` with a reason, and logs that state whether each binding is present. Add `wrangler dev` to the test pass so runtime-only rejections surface before deploy. | ## Residual risk before a public release diff --git a/integrations/vercel-worker/index.mjs b/integrations/vercel-worker/index.mjs index 67692b2..81b5b63 100644 --- a/integrations/vercel-worker/index.mjs +++ b/integrations/vercel-worker/index.mjs @@ -2,6 +2,9 @@ const pattern = /^[a-f0-9]{64}$/; const json = (body, status = 200) => Response.json(body, { status, headers: { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' } }); const hex = bytes => Array.from(new Uint8Array(bytes), b => b.toString(16).padStart(2, '0')).join(''); const digest = async value => hex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))); +// Workers only accepts "follow" or "manual" (Node also takes "error", which the edge rejects at runtime). +const noRedirects = { redirect: 'manual' }; +const redirected = response => response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400); export default { async fetch(request, env) { @@ -41,10 +44,11 @@ export class Session { // Log presence, never values: a missing secret is the most common cause of a failed exchange. console.log('callback', { hasCode: Boolean(code), clientId: Boolean(this.env.VERCEL_CLIENT_ID), clientSecret: Boolean(this.env.VERCEL_CLIENT_SECRET), redirectUri: this.env.VERCEL_REDIRECT_URI ?? null }); const response = await fetch('https://api.vercel.com/v2/oauth/access_token', { - method: 'POST', redirect: 'error', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + ...noRedirects, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: this.env.VERCEL_CLIENT_ID, client_secret: this.env.VERCEL_CLIENT_SECRET, code, redirect_uri: this.env.VERCEL_REDIRECT_URI }) }); console.log('token exchange', response.status); + if (redirected(response)) return json({ error: 'Authorization failed' }, 502); if (!response.ok) return json({ error: 'Authorization failed' }, 502); let result; try { result = JSON.parse(await response.text()); } catch { return json({ error: 'Authorization response was not JSON' }, 502); } @@ -78,8 +82,9 @@ export class Session { endpoint.searchParams.set('limit', '100'); const until = url.searchParams.get('until'); if (until && /^\d{1,16}$/.test(until)) endpoint.searchParams.set('until', until); - const result = await fetch(endpoint, { redirect: 'error', headers: { Authorization: `Bearer ${token}` } }); + const result = await fetch(endpoint, { ...noRedirects, headers: { Authorization: `Bearer ${token}` } }); // An expired or revoked token must ask the app to reconnect, not look like a network failure. + if (redirected(result)) return json({ error: 'Vercel request failed' }, 502); if (result.status === 401) return json({ error: 'Expired' }, 401); if (!result.ok) return json({ error: 'Vercel request failed' }, result.status === 429 ? 429 : 502); const page = await result.json(); diff --git a/integrations/vercel-worker/index.test.mjs b/integrations/vercel-worker/index.test.mjs index 5042b51..7bcca2a 100644 --- a/integrations/vercel-worker/index.test.mjs +++ b/integrations/vercel-worker/index.test.mjs @@ -85,6 +85,18 @@ test('a non-JSON authorization response answers 502 instead of throwing', async } finally { globalThis.fetch = oldFetch; } }); +test('a redirect from Vercel is refused instead of followed', async () => { + const { session } = setup(); + await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); + const oldFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(null, {status: 302, headers: {Location: 'https://evil.test/'}}); + try { + const response = await session.fetch(req('/callback?code=example')); + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), {error: 'Authorization failed'}); + } finally { globalThis.fetch = oldFetch; } +}); + test('deployments proxy scopes Vercel requests and never returns its token', async () => { const { session } = setup(); await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); @@ -151,7 +163,8 @@ test('simulated installation stays isolated across devices and does not leak met }), env); const previousFetch = globalThis.fetch; globalThis.fetch = async (input, options) => { - assert.equal(options.redirect, 'error'); + // Workers rejects "error"; manual is the only way to refuse redirects at the edge. + assert.equal(options.redirect, 'manual'); if (new URL(input).pathname === '/v2/oauth/access_token') return Response.json({access_token:'server-secret',team_id:'team_123'}); assert.equal(options.headers.Authorization, 'Bearer server-secret'); return Response.json({deployments:[{uid:'dpl_1',name:'demo',state:'BUILDING',meta:{githubRepo:'repo',privateField:'sensitive'},secret:'sensitive'}],pagination:{next:null}}); From 70ca0b938d10c4c5ba7d8491f114d9ce6d3b3141 Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Wed, 4 Nov 2026 00:02:08 -0500 Subject: [PATCH 9/9] docs: record the first successful end-to-end pairing The flow completed against production on 2026-11-04 (team install: start, Vercel authorization, callback, connected). Record what that does and does not prove, and keep the checklist for the next change. --- docs/vercel-deployment-monitoring.md | 2 +- docs/vercel-security-review.md | 7 +++++-- integrations/vercel-worker/README.md | 6 +++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/vercel-deployment-monitoring.md b/docs/vercel-deployment-monitoring.md index 882c79f..29afd5b 100644 --- a/docs/vercel-deployment-monitoring.md +++ b/docs/vercel-deployment-monitoring.md @@ -19,6 +19,6 @@ Show production deployments from every project available to a Vercel integration ## Current status -An unreleased Worker and desktop prototype exist, with local pairing, polling, UI, notification code, and mocked tests. The Worker URL is unset and the Vercel menu is hidden. The integration form is not submitted. A review of the pairing flow is in `docs/vercel-security-review.md` (not independent), the privacy wording to ship with it is drafted in `docs/vercel-privacy.md`, and the Worker URL is injected at packaging time with `VERCEL_WORKER_URL`. Independent review, real installation, deployment, and end-to-end verification remain required; see `integrations/vercel-worker/README.md` before any deployment. +A Worker and desktop prototype exist, with local pairing, polling, UI, notification code, and mocked tests. The Worker is deployed at `agent-notch-vercel.onilabs-dev.workers.dev` with its three secrets set, the Vercel integration is registered but not submitted for public listing, and the pairing flow completed end to end once on 2026-11-04 (team installation). A review of the pairing flow is in `docs/vercel-security-review.md` (not independent), the privacy wording to ship with it is drafted in `docs/vercel-privacy.md`, and the Worker URL is injected at packaging time with `VERCEL_WORKER_URL`. Still required: an independent review, a personal-account run, revocation on disconnect, and a real deployment rendered end to end; see `integrations/vercel-worker/README.md` before any redeployment. References: [Vercel integration authorization](https://vercel.com/docs/integrations/create-integration/vercel-api-integrations), [Vercel integration registration](https://vercel.com/docs/integrations/create-integration/submit-integration), [Cloudflare Worker secrets](https://developers.cloudflare.com/workers/configuration/secrets/). diff --git a/docs/vercel-security-review.md b/docs/vercel-security-review.md index 29da0a8..ad2761e 100644 --- a/docs/vercel-security-review.md +++ b/docs/vercel-security-review.md @@ -40,8 +40,11 @@ cleared findings. 1. **Independent review.** This document is not one. Someone who did not write the flow should read it against the Vercel integration contract before real users connect. -2. **No real end-to-end run.** Pairing, callback, polling and revocation have only run against - mocks. A test with a throwaway Vercel account and a real deployment is still required. +2. **Real end-to-end run.** The pairing flow has now completed once against production on + 2026-11-04 (team installation: `/start` → Vercel authorization → callback → `/status` connected), + which is how F13 was found. Still unproven: a personal-account installation, deployment + rendering after a real build, disconnect with token revocation, and a run on a clean Mac. Repeat + the checklist in `integrations/vercel-worker/README.md` after any change to the Worker. 3. **Secret handling in Cloudflare.** `VERCEL_CLIENT_SECRET` must be a Worker secret (`wrangler secret put`), never a `var` or a committed file; rotation must be documented. 4. **Privacy promise.** The app currently states it never sends anything anywhere. Shipping this diff --git a/integrations/vercel-worker/README.md b/integrations/vercel-worker/README.md index c87974d..8671320 100644 --- a/integrations/vercel-worker/README.md +++ b/integrations/vercel-worker/README.md @@ -89,7 +89,11 @@ VERCEL_WORKER_URL=https:// VERCEL_INTEGRATION_SLUG= scripts/r Without those variables the app ships with the Vercel UI hidden and makes no Vercel requests. -## 5. Prove it end to end +## 5. Verify it end to end + +The pairing flow completed once against production on 2026-11-04 (a team installation on +`agent-notch-vercel.onilabs-dev.workers.dev`), which is how the `redirect: 'error'` rejection in F13 +was found. Repeat this list after any Worker change: - Connect from a build that has the Worker URL, complete the Vercel flow, and confirm the app says connected.