From e65fe041fb22ba44c1cfc7739a793676342d8786 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Tue, 7 Apr 2026 17:01:11 -0400 Subject: [PATCH 1/2] feat(chrome-extension): add cloud OAuth sign-in skeleton --- .../background/__tests__/cloud-auth.test.ts | 191 ++++++++++++++++++ .../chrome-extension/background/cloud-auth.ts | 72 +++++++ clients/chrome-extension/manifest.json | 7 +- clients/chrome-extension/popup/popup.html | 37 ++++ clients/chrome-extension/popup/popup.ts | 54 +++++ clients/chrome-extension/tsconfig.json | 22 ++ .../chrome-extension/types/bun-test-shim.d.ts | 33 +++ .../types/chrome-globals.d.ts | 33 +++ 8 files changed, 446 insertions(+), 3 deletions(-) create mode 100644 clients/chrome-extension/background/__tests__/cloud-auth.test.ts create mode 100644 clients/chrome-extension/background/cloud-auth.ts create mode 100644 clients/chrome-extension/tsconfig.json create mode 100644 clients/chrome-extension/types/bun-test-shim.d.ts create mode 100644 clients/chrome-extension/types/chrome-globals.d.ts diff --git a/clients/chrome-extension/background/__tests__/cloud-auth.test.ts b/clients/chrome-extension/background/__tests__/cloud-auth.test.ts new file mode 100644 index 00000000000..ddc47c3e702 --- /dev/null +++ b/clients/chrome-extension/background/__tests__/cloud-auth.test.ts @@ -0,0 +1,191 @@ +/** + * Tests for the cloud OAuth state machine. + * + * These tests mock the `chrome.identity.launchWebAuthFlow` and + * `chrome.storage.local` surfaces so they can run under bun:test without + * a real Chrome runtime. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; + +import { + getStoredToken, + clearStoredToken, + signInCloud, + type CloudAuthConfig, + type StoredCloudToken, +} from '../cloud-auth.js'; + +const STORAGE_KEY = 'vellum.cloudAuthToken'; + +interface FakeStorage { + data: Record; + get(key: string | string[]): Promise>; + set(items: Record): Promise; + remove(key: string | string[]): Promise; +} + +function createFakeStorage(): FakeStorage { + const data: Record = {}; + return { + data, + async get(key) { + const keys = Array.isArray(key) ? key : [key]; + const result: Record = {}; + for (const k of keys) { + if (k in data) result[k] = data[k]; + } + return result; + }, + async set(items) { + Object.assign(data, items); + }, + async remove(key) { + const keys = Array.isArray(key) ? key : [key]; + for (const k of keys) delete data[k]; + }, + }; +} + +const originalChrome = (globalThis as { chrome?: unknown }).chrome; + +let fakeStorage: FakeStorage; +let launchWebAuthFlowImpl: (details: { url: string; interactive: boolean }) => Promise; + +beforeEach(() => { + fakeStorage = createFakeStorage(); + launchWebAuthFlowImpl = async () => undefined; + (globalThis as { chrome?: unknown }).chrome = { + storage: { + local: fakeStorage, + }, + identity: { + getRedirectURL: (path: string) => `https://fakeextid.chromiumapp.org/${path}`, + launchWebAuthFlow: (details: { url: string; interactive: boolean }) => launchWebAuthFlowImpl(details), + }, + }; +}); + +afterEach(() => { + (globalThis as { chrome?: unknown }).chrome = originalChrome; +}); + +const config: CloudAuthConfig = { + gatewayBaseUrl: 'https://api.vellum.ai', + clientId: 'test-client-id', +}; + +describe('signInCloud', () => { + test('happy path stores a token and returns it', async () => { + launchWebAuthFlowImpl = async (details) => { + // The redirect URL the gateway would send back. + expect(details.url).toContain('https://api.vellum.ai/oauth/chrome-extension/start'); + expect(details.url).toContain('client_id=test-client-id'); + expect(details.interactive).toBe(true); + return 'https://fakeextid.chromiumapp.org/cloud-auth#token=abc123&expires_in=3600&guardian_id=g-42'; + }; + + const before = Date.now(); + const result = await signInCloud(config); + const after = Date.now(); + + expect(result.token).toBe('abc123'); + expect(result.guardianId).toBe('g-42'); + expect(result.expiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000); + expect(result.expiresAt).toBeLessThanOrEqual(after + 3600 * 1000); + + // Verify it was persisted. + expect(fakeStorage.data[STORAGE_KEY]).toEqual(result); + }); + + test('missing token rejects with "incomplete payload"', async () => { + launchWebAuthFlowImpl = async () => + 'https://fakeextid.chromiumapp.org/cloud-auth#expires_in=3600&guardian_id=g-42'; + + await expect(signInCloud(config)).rejects.toThrow('incomplete payload'); + expect(fakeStorage.data[STORAGE_KEY]).toBeUndefined(); + }); + + test('missing expires_in rejects with "incomplete payload"', async () => { + launchWebAuthFlowImpl = async () => + 'https://fakeextid.chromiumapp.org/cloud-auth#token=abc123&guardian_id=g-42'; + + await expect(signInCloud(config)).rejects.toThrow('incomplete payload'); + }); + + test('missing guardian_id rejects with "incomplete payload"', async () => { + launchWebAuthFlowImpl = async () => + 'https://fakeextid.chromiumapp.org/cloud-auth#token=abc123&expires_in=3600'; + + await expect(signInCloud(config)).rejects.toThrow('incomplete payload'); + }); + + test('cancelled flow rejects with "cancelled"', async () => { + launchWebAuthFlowImpl = async () => undefined; + + await expect(signInCloud(config)).rejects.toThrow('cancelled'); + expect(fakeStorage.data[STORAGE_KEY]).toBeUndefined(); + }); + + test('trims trailing slash on gatewayBaseUrl', async () => { + let seenUrl = ''; + launchWebAuthFlowImpl = async (details) => { + seenUrl = details.url; + return 'https://fakeextid.chromiumapp.org/cloud-auth#token=abc&expires_in=60&guardian_id=g1'; + }; + await signInCloud({ gatewayBaseUrl: 'https://api.vellum.ai/', clientId: 'cid' }); + expect(seenUrl).toContain('https://api.vellum.ai/oauth/chrome-extension/start'); + expect(seenUrl).not.toContain('api.vellum.ai//oauth'); + }); +}); + +describe('getStoredToken', () => { + test('returns null when nothing is stored', async () => { + expect(await getStoredToken()).toBeNull(); + }); + + test('returns the stored token when valid', async () => { + const token: StoredCloudToken = { + token: 'valid-token', + expiresAt: Date.now() + 60_000, + guardianId: 'g-1', + }; + fakeStorage.data[STORAGE_KEY] = token; + + expect(await getStoredToken()).toEqual(token); + }); + + test('returns null when the token is expired', async () => { + fakeStorage.data[STORAGE_KEY] = { + token: 'expired', + expiresAt: Date.now() - 1_000, + guardianId: 'g-1', + } satisfies StoredCloudToken; + + expect(await getStoredToken()).toBeNull(); + }); + + test('returns null when the stored value is malformed', async () => { + fakeStorage.data[STORAGE_KEY] = { token: 42, expiresAt: 'soon' }; + + expect(await getStoredToken()).toBeNull(); + }); +}); + +describe('clearStoredToken', () => { + test('removes the key from storage', async () => { + fakeStorage.data[STORAGE_KEY] = { + token: 'to-clear', + expiresAt: Date.now() + 60_000, + guardianId: 'g-1', + } satisfies StoredCloudToken; + + await clearStoredToken(); + expect(fakeStorage.data[STORAGE_KEY]).toBeUndefined(); + }); + + test('is a no-op when nothing is stored', async () => { + await clearStoredToken(); + expect(fakeStorage.data[STORAGE_KEY]).toBeUndefined(); + }); +}); diff --git a/clients/chrome-extension/background/cloud-auth.ts b/clients/chrome-extension/background/cloud-auth.ts new file mode 100644 index 00000000000..e147dc6eae0 --- /dev/null +++ b/clients/chrome-extension/background/cloud-auth.ts @@ -0,0 +1,72 @@ +/** + * Cloud OAuth sign-in state machine for the Vellum chrome extension. + * + * Launches chrome.identity.launchWebAuthFlow against the Vellum gateway and + * persists the guardian-bound JWT in chrome.storage.local. The token is used + * by later PRs to authenticate the browser-relay WebSocket against the cloud + * gateway — this module is the storage + state machine layer only. + */ + +export interface CloudAuthConfig { + /** Gateway base URL, e.g. https://api.vellum.ai */ + gatewayBaseUrl: string; + /** OAuth client id registered for the chrome extension. */ + clientId: string; +} + +export interface StoredCloudToken { + token: string; + expiresAt: number; // ms since epoch + guardianId: string; +} + +const STORAGE_KEY = 'vellum.cloudAuthToken'; + +export async function getStoredToken(): Promise { + const result = await chrome.storage.local.get(STORAGE_KEY); + const raw = result[STORAGE_KEY]; + if (!raw || typeof raw !== 'object') return null; + const token = raw as StoredCloudToken; + if (typeof token.token !== 'string' || typeof token.expiresAt !== 'number') return null; + if (token.expiresAt <= Date.now()) return null; + return token; +} + +export async function clearStoredToken(): Promise { + await chrome.storage.local.remove(STORAGE_KEY); +} + +async function persistToken(token: StoredCloudToken): Promise { + await chrome.storage.local.set({ [STORAGE_KEY]: token }); +} + +/** + * Launches chrome.identity.launchWebAuthFlow to obtain a guardian-bound JWT. + * The extension receives the token via the redirect URI fragment. + */ +export async function signInCloud(config: CloudAuthConfig): Promise { + const redirectUri = chrome.identity.getRedirectURL('cloud-auth'); + const authUrl = + `${config.gatewayBaseUrl.replace(/\/$/, '')}/oauth/chrome-extension/start` + + `?client_id=${encodeURIComponent(config.clientId)}` + + `&redirect_uri=${encodeURIComponent(redirectUri)}`; + + const responseUrl = await chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true }); + if (!responseUrl) throw new Error('cloud sign-in cancelled'); + + const hash = new URL(responseUrl).hash.replace(/^#/, ''); + const params = new URLSearchParams(hash); + const token = params.get('token'); + const expiresIn = parseInt(params.get('expires_in') ?? '0', 10); + const guardianId = params.get('guardian_id') ?? ''; + if (!token || !expiresIn || !guardianId) { + throw new Error('cloud sign-in returned incomplete payload'); + } + const stored: StoredCloudToken = { + token, + expiresAt: Date.now() + expiresIn * 1000, + guardianId, + }; + await persistToken(stored); + return stored; +} diff --git a/clients/chrome-extension/manifest.json b/clients/chrome-extension/manifest.json index dd555c25676..803a0cd1e68 100644 --- a/clients/chrome-extension/manifest.json +++ b/clients/chrome-extension/manifest.json @@ -5,12 +5,13 @@ "description": "Bridges the Vellum assistant to your live browser session — no CDP, no spoofing.", "permissions": [ - "tabs", "activeTab", - "scripting", "cookies", + "debugger", + "identity", + "scripting", "storage", - "debugger" + "tabs" ], "host_permissions": [ "" diff --git a/clients/chrome-extension/popup/popup.html b/clients/chrome-extension/popup/popup.html index aad35a18b98..3c289efc036 100644 --- a/clients/chrome-extension/popup/popup.html +++ b/clients/chrome-extension/popup/popup.html @@ -95,6 +95,37 @@ } #btn-disconnect:hover:not(:disabled) { background: #e5e7eb; } + .divider { + height: 1px; + background: #e5e7eb; + margin: 16px 0 14px 0; + } + + .section-label { + font-size: 11px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 8px; + } + + #btn-cloud-signin { + width: 100%; + background: #111827; + color: #fff; + margin-bottom: 8px; + } + #btn-cloud-signin:hover:not(:disabled) { background: #1f2937; } + + .cloud-status { + font-size: 12px; + color: #4b5563; + margin-bottom: 4px; + word-break: break-all; + } + .cloud-status.signed-in { color: #0f766e; } + .hint { font-size: 11px; color: #9ca3af; @@ -163,6 +194,12 @@

Vellum Relay

Token is auto-fetched from the local gateway. Port defaults to 7830.

+
+ + +

Not signed in

+ + diff --git a/clients/chrome-extension/popup/popup.ts b/clients/chrome-extension/popup/popup.ts index c264360b7f4..c7193bfa5b7 100644 --- a/clients/chrome-extension/popup/popup.ts +++ b/clients/chrome-extension/popup/popup.ts @@ -3,9 +3,18 @@ * * Auto-fetches a bearer token from the local gateway on Connect. * Falls back to manual token entry if the gateway is unreachable. + * + * Also exposes a "Sign in with Vellum (cloud)" button that drives the + * OAuth flow in background/cloud-auth.ts. Cloud sign-in and self-hosted + * token entry coexist — they represent the two possible relay transports. */ +import { signInCloud, getStoredToken } from '../background/cloud-auth.js'; + const DEFAULT_RELAY_PORT = 7830; +// PR 14 will plumb this through config; hard-coded for the Phase 2 skeleton. +const CLOUD_GATEWAY_BASE_URL = 'https://api.vellum.ai'; +const CLOUD_OAUTH_CLIENT_ID = 'vellum-chrome-extension'; const tokenInput = document.getElementById('token-input') as HTMLInputElement; const portInput = document.getElementById('port-input') as HTMLInputElement; @@ -16,6 +25,8 @@ const statusText = document.getElementById('status-text') as HTMLParagraphElemen const errorText = document.getElementById('error-text') as HTMLParagraphElement; const manualToggle = document.getElementById('manual-toggle') as HTMLButtonElement; const tokenGroup = document.getElementById('token-group') as HTMLDivElement; +const btnCloudSignIn = document.getElementById('btn-cloud-signin') as HTMLButtonElement; +const cloudStatus = document.getElementById('cloud-status') as HTMLParagraphElement; let manualMode = false; @@ -143,3 +154,46 @@ btnDisconnect.addEventListener('click', () => { setConnected(false); }); }); + +// ── Cloud sign-in (new in Phase 2 PR 8) ──────────────────────────── +// +// This is a skeleton: the token is persisted but not yet used on any +// WebSocket. A later PR will plumb it through the relay connection so +// cloud-hosted users can connect to the Vellum gateway without running +// a local daemon. + +function setCloudStatus(text: string, signedIn: boolean): void { + cloudStatus.textContent = text; + cloudStatus.classList.toggle('signed-in', signedIn); +} + +async function refreshCloudStatus(): Promise { + try { + const existing = await getStoredToken(); + if (existing) { + setCloudStatus(`Signed in as guardian:${existing.guardianId}`, true); + } else { + setCloudStatus('Not signed in', false); + } + } catch (err) { + setCloudStatus(`Error: ${err instanceof Error ? err.message : String(err)}`, false); + } +} + +btnCloudSignIn.addEventListener('click', async () => { + btnCloudSignIn.disabled = true; + setCloudStatus('Signing in…', false); + try { + const stored = await signInCloud({ + gatewayBaseUrl: CLOUD_GATEWAY_BASE_URL, + clientId: CLOUD_OAUTH_CLIENT_ID, + }); + setCloudStatus(`Signed in as guardian:${stored.guardianId}`, true); + } catch (err) { + setCloudStatus(`Sign-in failed: ${err instanceof Error ? err.message : String(err)}`, false); + } finally { + btnCloudSignIn.disabled = false; + } +}); + +refreshCloudStatus(); diff --git a/clients/chrome-extension/tsconfig.json b/clients/chrome-extension/tsconfig.json new file mode 100644 index 00000000000..a29f90cd579 --- /dev/null +++ b/clients/chrome-extension/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noEmit": true, + "allowJs": false, + "isolatedModules": true, + "types": [] + }, + "include": [ + "background/cloud-auth.ts", + "background/__tests__/**/*.ts", + "types/**/*.d.ts" + ] +} diff --git a/clients/chrome-extension/types/bun-test-shim.d.ts b/clients/chrome-extension/types/bun-test-shim.d.ts new file mode 100644 index 00000000000..52e1aa00d62 --- /dev/null +++ b/clients/chrome-extension/types/bun-test-shim.d.ts @@ -0,0 +1,33 @@ +/** + * Minimal shim for the `bun:test` module so the chrome-extension tests + * can be type-checked without depending on bun-types being installed in + * the chrome-extension's own node_modules. The full bun-types package is + * available in assistant/node_modules and is the runtime source of truth; + * this shim only declares the surface used by the cloud-auth tests. + */ + +declare module 'bun:test' { + type TestFn = () => void | Promise; + + export function describe(name: string, fn: () => void): void; + export function test(name: string, fn: TestFn): void; + export function beforeEach(fn: TestFn): void; + export function afterEach(fn: TestFn): void; + + interface Matchers { + toBe(expected: unknown): R; + toEqual(expected: unknown): R; + toBeNull(): R; + toBeUndefined(): R; + toBeGreaterThanOrEqual(expected: number): R; + toBeLessThanOrEqual(expected: number): R; + toContain(expected: unknown): R; + not: Matchers; + rejects: { + toThrow(expected?: string | RegExp | Error): Promise; + }; + toThrow(expected?: string | RegExp | Error): R; + } + + export function expect(actual: T): Matchers; +} diff --git a/clients/chrome-extension/types/chrome-globals.d.ts b/clients/chrome-extension/types/chrome-globals.d.ts new file mode 100644 index 00000000000..85359b87db2 --- /dev/null +++ b/clients/chrome-extension/types/chrome-globals.d.ts @@ -0,0 +1,33 @@ +/// + +/** + * Minimal ambient declarations for the subset of the Chrome Extension API + * surface used by the Vellum browser-relay extension's typed modules. + * + * This is intentionally narrow — it covers what's needed by background/cloud-auth.ts + * and its tests. The full @types/chrome package is an option for the future if + * we type-check more of the package. + */ + +declare namespace chrome { + namespace storage { + interface StorageArea { + get(keys?: string | string[] | Record | null): Promise>; + set(items: Record): Promise; + remove(keys: string | string[]): Promise; + clear(): Promise; + } + const local: StorageArea; + const sync: StorageArea; + const session: StorageArea; + } + + namespace identity { + interface WebAuthFlowDetails { + url: string; + interactive?: boolean; + } + function getRedirectURL(path?: string): string; + function launchWebAuthFlow(details: WebAuthFlowDetails): Promise; + } +} From f3f6228b8e445866415e93255fbb9fde4bdbddd8 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Tue, 7 Apr 2026 17:21:20 -0400 Subject: [PATCH 2/2] fix(chrome-extension): run OAuth sign-in from service worker and validate guardianId - Popup now sends a message to the background worker to initiate cloud sign-in instead of running launchWebAuthFlow directly. This avoids the MV3 popup teardown race where the awaited OAuth promise never resolves if the popup blurs during the auth window. - Add guardianId type check to getStoredToken so malformed stored tokens can't leak 'Signed in as guardian:undefined' into the popup UI. --- .../background/__tests__/cloud-auth.test.ts | 17 +++++++ .../chrome-extension/background/cloud-auth.ts | 8 ++- clients/chrome-extension/background/worker.ts | 25 ++++++++++ clients/chrome-extension/popup/popup.ts | 50 ++++++++++++------- 4 files changed, 82 insertions(+), 18 deletions(-) diff --git a/clients/chrome-extension/background/__tests__/cloud-auth.test.ts b/clients/chrome-extension/background/__tests__/cloud-auth.test.ts index ddc47c3e702..f4cd705e63e 100644 --- a/clients/chrome-extension/background/__tests__/cloud-auth.test.ts +++ b/clients/chrome-extension/background/__tests__/cloud-auth.test.ts @@ -170,6 +170,23 @@ describe('getStoredToken', () => { expect(await getStoredToken()).toBeNull(); }); + + test('returns null when guardianId is missing or non-string', async () => { + // Missing guardianId entirely — would otherwise render as "guardian:undefined" in the popup. + fakeStorage.data[STORAGE_KEY] = { + token: 'valid-token', + expiresAt: Date.now() + 60_000, + }; + expect(await getStoredToken()).toBeNull(); + + // Non-string guardianId (e.g. a number). + fakeStorage.data[STORAGE_KEY] = { + token: 'valid-token', + expiresAt: Date.now() + 60_000, + guardianId: 42, + }; + expect(await getStoredToken()).toBeNull(); + }); }); describe('clearStoredToken', () => { diff --git a/clients/chrome-extension/background/cloud-auth.ts b/clients/chrome-extension/background/cloud-auth.ts index e147dc6eae0..b63d5690d21 100644 --- a/clients/chrome-extension/background/cloud-auth.ts +++ b/clients/chrome-extension/background/cloud-auth.ts @@ -27,7 +27,13 @@ export async function getStoredToken(): Promise { const raw = result[STORAGE_KEY]; if (!raw || typeof raw !== 'object') return null; const token = raw as StoredCloudToken; - if (typeof token.token !== 'string' || typeof token.expiresAt !== 'number') return null; + if ( + typeof token.token !== 'string' || + typeof token.expiresAt !== 'number' || + typeof token.guardianId !== 'string' + ) { + return null; + } if (token.expiresAt <= Date.now()) return null; return token; } diff --git a/clients/chrome-extension/background/worker.ts b/clients/chrome-extension/background/worker.ts index 6da667b313e..402781b7282 100644 --- a/clients/chrome-extension/background/worker.ts +++ b/clients/chrome-extension/background/worker.ts @@ -7,6 +7,16 @@ */ import type { ExtensionCommand, ExtensionResponse, ExtensionHeartbeat } from '../../../assistant/src/browser-extension-relay/protocol.js'; +import { signInCloud, type CloudAuthConfig, type StoredCloudToken } from './cloud-auth.js'; + +// Cloud OAuth defaults — kept here so the popup can stay a thin client and the +// service worker is the single owner of the launchWebAuthFlow lifecycle. This +// avoids the MV3 popup teardown race where closing the popup mid-auth kills +// the awaited promise before the token is persisted. +// +// PR 14 will plumb these through config; hard-coded for the Phase 2 skeleton. +const CLOUD_GATEWAY_BASE_URL = 'https://api.vellum.ai'; +const CLOUD_OAUTH_CLIENT_ID = 'vellum-chrome-extension'; const DEFAULT_RELAY_PORT = 7830; const HEARTBEAT_INTERVAL_MS = 30_000; @@ -343,6 +353,21 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { }); return false; } + if (message.type === 'cloud-auth-sign-in') { + // Run the OAuth flow in the service worker — not the popup — so the + // awaited promise survives the popup losing focus during the Chrome + // identity window. The popup just awaits this message response. + const config: CloudAuthConfig = { + gatewayBaseUrl: + typeof message.gatewayBaseUrl === 'string' ? message.gatewayBaseUrl : CLOUD_GATEWAY_BASE_URL, + clientId: + typeof message.clientId === 'string' ? message.clientId : CLOUD_OAUTH_CLIENT_ID, + }; + signInCloud(config) + .then((stored: StoredCloudToken) => sendResponse({ ok: true, token: stored })) + .catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) })); + return true; // async + } }); // Auto-connect on service worker start if previously connected. diff --git a/clients/chrome-extension/popup/popup.ts b/clients/chrome-extension/popup/popup.ts index c7193bfa5b7..40c421ae108 100644 --- a/clients/chrome-extension/popup/popup.ts +++ b/clients/chrome-extension/popup/popup.ts @@ -4,17 +4,18 @@ * Auto-fetches a bearer token from the local gateway on Connect. * Falls back to manual token entry if the gateway is unreachable. * - * Also exposes a "Sign in with Vellum (cloud)" button that drives the - * OAuth flow in background/cloud-auth.ts. Cloud sign-in and self-hosted - * token entry coexist — they represent the two possible relay transports. + * Also exposes a "Sign in with Vellum (cloud)" button. The actual OAuth + * flow runs in the background service worker (see worker.ts) — the popup + * only sends a message asking the worker to start it. This avoids the + * MV3 popup teardown race where closing the popup mid-auth would kill + * the awaited launchWebAuthFlow promise before the token was persisted. + * Cloud sign-in and self-hosted token entry coexist — they represent + * the two possible relay transports. */ -import { signInCloud, getStoredToken } from '../background/cloud-auth.js'; +import { getStoredToken, type StoredCloudToken } from '../background/cloud-auth.js'; const DEFAULT_RELAY_PORT = 7830; -// PR 14 will plumb this through config; hard-coded for the Phase 2 skeleton. -const CLOUD_GATEWAY_BASE_URL = 'https://api.vellum.ai'; -const CLOUD_OAUTH_CLIENT_ID = 'vellum-chrome-extension'; const tokenInput = document.getElementById('token-input') as HTMLInputElement; const portInput = document.getElementById('port-input') as HTMLInputElement; @@ -180,20 +181,35 @@ async function refreshCloudStatus(): Promise { } } +interface CloudSignInResponse { + ok: boolean; + token?: StoredCloudToken; + error?: string; +} + +function requestCloudSignIn(): Promise { + return new Promise((resolve) => { + chrome.runtime.sendMessage({ type: 'cloud-auth-sign-in' }, (response: CloudSignInResponse) => { + if (chrome.runtime.lastError) { + resolve({ ok: false, error: chrome.runtime.lastError.message ?? 'Unknown error' }); + return; + } + resolve(response ?? { ok: false, error: 'No response from service worker' }); + }); + }); +} + btnCloudSignIn.addEventListener('click', async () => { btnCloudSignIn.disabled = true; setCloudStatus('Signing in…', false); - try { - const stored = await signInCloud({ - gatewayBaseUrl: CLOUD_GATEWAY_BASE_URL, - clientId: CLOUD_OAUTH_CLIENT_ID, - }); - setCloudStatus(`Signed in as guardian:${stored.guardianId}`, true); - } catch (err) { - setCloudStatus(`Sign-in failed: ${err instanceof Error ? err.message : String(err)}`, false); - } finally { - btnCloudSignIn.disabled = false; + // Delegate to the service worker — see header comment for the rationale. + const response = await requestCloudSignIn(); + if (response.ok && response.token) { + setCloudStatus(`Signed in as guardian:${response.token.guardianId}`, true); + } else { + setCloudStatus(`Sign-in failed: ${response.error ?? 'Unknown error'}`, false); } + btnCloudSignIn.disabled = false; }); refreshCloudStatus();