diff --git a/docs/vercel-deployment-monitoring.md b/docs/vercel-deployment-monitoring.md
index 39ab4f9..caf94b1 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
-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.
+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 shipped with 0.3.4, and the Worker URL is injected at packaging time with `VERCEL_WORKER_URL`. Disconnect now asks Vercel to revoke the token, best effort. Still required: an independent review, a personal-account run, a live confirmation that the revocation call returns 200, 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 ad2761e..476ac1e 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: `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. |
+| F1 | Medium | **Disconnect deleted the local session but did not revoke the Vercel token.** Access stayed alive until the user removed the integration in Vercel. | Fixed, best effort: on disconnect the Worker deletes the session and then calls the documented `POST https://api.vercel.com/login/oauth/token/revoke` with Basic client credentials (`client_id:client_secret`) and the token, logging the status. A revocation failure is logged and ignored, so the local session always ends. Caveat: Vercel documents that endpoint for Sign in with Vercel tokens; this is the first use against an integration token, so confirm the log says `revoke 200` on the next real disconnect. |
| 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. |
diff --git a/integrations/vercel-worker/README.md b/integrations/vercel-worker/README.md
index 8671320..c028e2d 100644
--- a/integrations/vercel-worker/README.md
+++ b/integrations/vercel-worker/README.md
@@ -96,10 +96,11 @@ The pairing flow completed once against production on 2026-11-04 (a team install
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.
+ connected. The browser shows a confirmation page you can close.
- 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.
+- Disconnect, then confirm the Worker session is gone (`/status` returns 401) and the Worker logs
+ `revoke 200` for the token revocation. If it logs another status, Vercel rejected the revocation
+ for that token and the integration must be removed manually.
- Restart the app while a deployment runs; the deployment must not notify for work that finished
while it was closed.
diff --git a/integrations/vercel-worker/index.mjs b/integrations/vercel-worker/index.mjs
index 81b5b63..b13948d 100644
--- a/integrations/vercel-worker/index.mjs
+++ b/integrations/vercel-worker/index.mjs
@@ -6,6 +6,28 @@ const digest = async value => hex(await crypto.subtle.digest('SHA-256', new Text
const noRedirects = { redirect: 'manual' };
const redirected = response => response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400);
+// Browser-facing page for the callback. Every string is static: no user input is interpolated, so no escaping is needed.
+const page = (status, title, message) => new Response(`
+
+${title} · Agent Notch
+
+
+${status < 400 ? '✓' : '!'}
+${title}
+${message}
+Agent Notch
+`, { status, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'" } });
+
export default {
async fetch(request, env) {
const url = new URL(request.url);
@@ -38,9 +60,10 @@ export class Session {
if (url.pathname === '/callback' && request.method === 'GET') {
return this.state.blockConcurrencyWhile(async () => {
try {
- if (!(await storage.get('challenge')) || Date.now() > (await storage.get('expires')) || await storage.get('token')) return json({ error: 'Expired or completed' }, 400);
+ if (!(await storage.get('challenge')) || Date.now() > (await storage.get('expires')) || await storage.get('token'))
+ return page(400, 'This connection expired', 'Start again from Agent Notch: choose Connect Vercel… and authorize.');
const code = url.searchParams.get('code');
- if (!code || code.length > 1024) return json({ error: 'Invalid code' }, 400);
+ if (!code || code.length > 1024) return page(400, 'Vercel did not send an authorization code', 'Start again from Agent Notch.');
// 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', {
@@ -48,24 +71,29 @@ export class Session {
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);
+ if (redirected(response)) return page(502, 'Vercel rejected the connection', 'Check that the redirect URL matches the integration settings, then try again from Agent Notch.');
+ if (!response.ok) return page(502, 'Vercel rejected the connection', 'Check that the client ID, secret and redirect URL match, then try again from Agent Notch.');
let result;
- try { result = JSON.parse(await response.text()); } catch { return json({ error: 'Authorization response was not JSON' }, 502); }
+ try { result = JSON.parse(await response.text()); } catch { return page(502, 'Unexpected response from Vercel', 'Try again from Agent Notch.'); }
// 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);
+ if (typeof result?.access_token !== 'string' || !result.access_token) return page(502, 'Unexpected response from Vercel', 'Try again from Agent Notch.');
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'" } });
+ return page(200, 'Agent Notch is connected', 'You can close this tab and return to the app. Deployments appear in the notch while they run.');
} 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);
+ return page(502, 'The connection could not be completed', 'Try again from Agent Notch. If it keeps failing, check the Worker logs.');
}
});
}
const verifier = request.headers.get('Authorization')?.replace(/^Bearer /, '') ?? '';
if (!pattern.test(verifier) || await digest(verifier) !== await storage.get('challenge')) return json({ error: 'Unauthorized' }, 401);
- if (url.pathname === '/disconnect' && request.method === 'POST') { await storage.deleteAll(); return json({ ok: true }); }
+ if (url.pathname === '/disconnect' && request.method === 'POST') {
+ const token = await storage.get('token');
+ await storage.deleteAll();
+ if (typeof token === 'string' && token) await this.revoke(token);
+ return json({ ok: true });
+ }
if (!await storage.get('claimed') && Date.now() > (await storage.get('expires'))) { await storage.deleteAll(); return json({ error: 'Expired' }, 410); }
const token = await storage.get('token');
if (!token) return Date.now() > (await storage.get('expires')) ? json({ error: 'Expired' }, 410) : json({ status: 'pending' });
@@ -100,4 +128,19 @@ export class Session {
}
async alarm() { if (!await this.state.storage.get('claimed')) await this.state.storage.deleteAll(); }
+
+ /// Best effort: revokes the Vercel token when the user disconnects. A failure here must never keep the local session alive.
+ async revoke(token) {
+ try {
+ const basic = btoa(`${this.env.VERCEL_CLIENT_ID}:${this.env.VERCEL_CLIENT_SECRET}`);
+ const response = await fetch('https://api.vercel.com/login/oauth/token/revoke', {
+ ...noRedirects, method: 'POST',
+ headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ token })
+ });
+ console.log('revoke', response.status);
+ } catch (error) {
+ console.log('revoke failed', String(error));
+ }
+ }
}
diff --git a/integrations/vercel-worker/index.test.mjs b/integrations/vercel-worker/index.test.mjs
index 7bcca2a..8ab5f3f 100644
--- a/integrations/vercel-worker/index.test.mjs
+++ b/integrations/vercel-worker/index.test.mjs
@@ -69,7 +69,8 @@ test('a failing token exchange answers 502 instead of throwing a 1101', async ()
try {
const response = await session.fetch(req('/callback?code=example'));
assert.equal(response.status, 502);
- assert.deepEqual(await response.json(), {error: 'Callback failed'});
+ assert.match(response.headers.get('Content-Type') ?? '', /text\/html/);
+ assert.match(await response.text(), /could not be completed/);
} finally { globalThis.fetch = oldFetch; }
});
@@ -81,7 +82,7 @@ test('a non-JSON authorization response answers 502 instead of throwing', async
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'});
+ assert.match(await response.text(), /Unexpected response from Vercel/);
} finally { globalThis.fetch = oldFetch; }
});
@@ -93,7 +94,70 @@ test('a redirect from Vercel is refused instead of followed', async () => {
try {
const response = await session.fetch(req('/callback?code=example'));
assert.equal(response.status, 502);
- assert.deepEqual(await response.json(), {error: 'Authorization failed'});
+ assert.match(await response.text(), /Vercel rejected the connection/);
+ } finally { globalThis.fetch = oldFetch; }
+});
+
+test('disconnect revokes the Vercel token with client credentials', async () => {
+ let revoked = null;
+ const { session } = setup();
+ await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}}));
+ const oldFetch = globalThis.fetch;
+ globalThis.fetch = async (input, options) => {
+ const url = new URL(input);
+ if (url.pathname === '/v2/oauth/access_token') return Response.json({access_token:'private-token', team_id:'team_123'});
+ if (url.pathname === '/login/oauth/token/revoke') {
+ revoked = { auth: options.headers.Authorization, token: new URLSearchParams(options.body).get('token') };
+ return new Response(null, {status: 200});
+ }
+ throw new Error('unexpected request: ' + url.pathname);
+ };
+ 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('/disconnect', {method:'POST', headers:{Authorization:'Bearer ' + secret}}))).status, 200);
+ assert.equal(revoked.token, 'private-token');
+ assert.match(revoked.auth, /^Basic /);
+ assert.equal((await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + secret}}))).status, 401);
+ } finally { globalThis.fetch = oldFetch; }
+});
+
+test('the callback renders a human page, including for an expired session', async () => {
+ const { session } = setup();
+ await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}}));
+ const oldFetch = globalThis.fetch;
+ globalThis.fetch = async () => Response.json({access_token:'private-token', team_id:'team_123'});
+ try {
+ const success = await session.fetch(req('/callback?code=example'));
+ assert.equal(success.status, 200);
+ assert.match(success.headers.get('Content-Type') ?? '', /text\/html/);
+ assert.match(success.headers.get('Content-Security-Policy') ?? '', /style-src 'unsafe-inline'/);
+ const html = await success.text();
+ assert.match(html, /Agent Notch is connected/);
+ assert.match(html, /close this tab/);
+ assert.doesNotMatch(html, /private-token/);
+
+ const replay = await session.fetch(req('/callback?code=example'));
+ assert.equal(replay.status, 400);
+ assert.match(await replay.text(), /This connection expired/);
+ } finally { globalThis.fetch = oldFetch; }
+});
+
+test('disconnect still succeeds when revocation throws', 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 path = new URL(input).pathname;
+ if (path === '/v2/oauth/access_token') return Response.json({access_token:'private-token', team_id:'team_123'});
+ if (path === '/login/oauth/token/revoke') throw new TypeError('network down');
+ throw new Error('unexpected request: ' + path);
+ };
+ 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('/disconnect', {method:'POST', headers:{Authorization:'Bearer ' + secret}}))).status, 200);
+ assert.equal((await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + secret}}))).status, 401);
} finally { globalThis.fetch = oldFetch; }
});
@@ -165,7 +229,9 @@ test('simulated installation stays isolated across devices and does not leak met
globalThis.fetch = async (input, options) => {
// 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'});
+ const path = new URL(input).pathname;
+ if (path === '/v2/oauth/access_token') return Response.json({access_token:'server-secret',team_id:'team_123'});
+ if (path === '/login/oauth/token/revoke') return new Response(null, {status: 200});
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}});
};