diff --git a/plugins/chassis/src/env.ts b/plugins/chassis/src/env.ts index 5125d76ac..3e8d10642 100644 --- a/plugins/chassis/src/env.ts +++ b/plugins/chassis/src/env.ts @@ -1,8 +1,10 @@ export const CORE_API_URL = (process.env.CORE_API_URL ?? "http://localhost:8080").replace(/\/$/, ""); export const CORE_ORG_ID = process.env.CORE_ORG_ID ?? "acme"; -export const CORE_SIGNING_SECRET = process.env.CORE_SIGNING_SECRET; -export const PORTAL_IDENTITY_SECRET = process.env.PORTAL_IDENTITY_SECRET ?? CORE_SIGNING_SECRET; -if (!process.env.PORTAL_IDENTITY_SECRET && CORE_SIGNING_SECRET) { +const secret = (raw: string | undefined): string | undefined => (raw?.trim() ? raw : undefined); + +export const CORE_SIGNING_SECRET = secret(process.env.CORE_SIGNING_SECRET); +export const PORTAL_IDENTITY_SECRET = secret(process.env.PORTAL_IDENTITY_SECRET) ?? CORE_SIGNING_SECRET; +if (!secret(process.env.PORTAL_IDENTITY_SECRET) && CORE_SIGNING_SECRET) { console.warn( "[chassis] PORTAL_IDENTITY_SECRET unset — signing portal identity with CORE_SIGNING_SECRET (dev fallback)", ); diff --git a/plugins/web-ui/server/index.ts b/plugins/web-ui/server/index.ts index 343194a72..be031e14b 100644 --- a/plugins/web-ui/server/index.ts +++ b/plugins/web-ui/server/index.ts @@ -239,9 +239,14 @@ function conversationForScope( return { kind, channelRef: ref, threadRef, ...(channelName ? { channelName } : {}) }; } -function resolveIdentity( - req: IncomingMessage, -): { user: string; name: string | null; impersonator: string | null } | null { +interface Identity { + user: string; + name: string | null; + impersonator: string | null; +} +type Denial = "unauthenticated" | "not_allowed"; + +function authenticate(req: IncomingMessage): { identity: Identity } | { denied: Denial } { let user: string | null | undefined; let name: string | null | undefined; let impersonator: string | null | undefined; @@ -254,20 +259,31 @@ function resolveIdentity( name = claims.n ?? null; impersonator = claims.imp ?? null; } else { - if (!COOKIE_AUTH) return null; + if (!COOKIE_AUTH) return { denied: "unauthenticated" }; user = cookie(req, "webuiuser"); name = cookie(req, "webuiuser_name"); impersonator = cookie(req, "webui_impersonator"); } - if (!user) return null; - if (ALLOW.length > 0 && !ALLOW.includes(user)) return null; - return { user, name: name?.trim() || null, impersonator: impersonator ?? null }; + if (!user) return { denied: "unauthenticated" }; + if (ALLOW.length > 0 && !ALLOW.includes(user)) return { denied: "not_allowed" }; + return { identity: { user, name: name?.trim() || null, impersonator: impersonator ?? null } }; +} + +function resolveIdentity(req: IncomingMessage): Identity | null { + const outcome = authenticate(req); + return "identity" in outcome ? outcome.identity : null; } function cookieUser(req: IncomingMessage): string | null { return resolveIdentity(req)?.user ?? null; } +function unauthorized(res: ServerResponse, req: IncomingMessage): void { + const outcome = authenticate(req); + const denied = "denied" in outcome ? outcome.denied : "unauthenticated"; + return json(res, 401, { error: "sign in", mode: AUTH_MODE, reason: denied }); +} + const SESSION_TTL_S = 90 * 24 * 60 * 60; function sessionCookie(id: string): string { return `webuiuser=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${SESSION_TTL_S}`; @@ -749,7 +765,7 @@ const routeRequest = async (req: IncomingMessage, res: ServerResponse) => { if (path === "/me" || path.startsWith("/api/")) { const user = cookieUser(req); - if (!user) return json(res, 401, { error: "sign in", mode: AUTH_MODE }); + if (!user) return unauthorized(res, req); if (path === "/me") { res.setHeader("set-cookie", sessionCookie(user)); @@ -1833,7 +1849,7 @@ const routeRequest = async (req: IncomingMessage, res: ServerResponse) => { if (method === "GET" && path.startsWith("/deployments/")) { const user = cookieUser(req); - if (!user) return json(res, 401, { error: "sign in", mode: AUTH_MODE }); + if (!user) return unauthorized(res, req); const rest = path.slice("/deployments/".length); const slash = rest.indexOf("/"); const id = decodeURIComponent(slash === -1 ? rest : rest.slice(0, slash)); diff --git a/plugins/web-ui/src/core-bridge.ts b/plugins/web-ui/src/core-bridge.ts index cddae92ab..6cea6c38e 100644 --- a/plugins/web-ui/src/core-bridge.ts +++ b/plugins/web-ui/src/core-bridge.ts @@ -344,7 +344,10 @@ async function toCoreAttachment(a: PiAttachment): Promise { headers: { "content-type": "application/octet-stream" }, body: bytes as unknown as BodyInit, }); - if (!r.ok) throw new ApiError(`attachment upload failed: HTTP ${r.status}`, r.status); + if (!r.ok) { + if (r.status === 401) reportSigninRequired(await r.json().catch(() => ({}))); + throw new ApiError(`attachment upload failed: HTTP ${r.status}`, r.status); + } const { blobId, sizeBytes } = (await r.json()) as { blobId: string; sizeBytes: number }; return { name: a.fileName, mimetype: a.mimeType, sizeBytes: sizeBytes ?? a.size, blobId }; } @@ -358,6 +361,21 @@ export class ApiError extends Error { } } +export interface SigninRequired { + mode?: "portal" | "dev"; + reason?: "unauthenticated" | "not_allowed"; +} + +let onSigninRequired: ((detail: SigninRequired) => void) | null = null; + +export function setSigninRequiredHandler(fn: (detail: SigninRequired) => void): void { + onSigninRequired = fn; +} + +export function reportSigninRequired(detail: SigninRequired): void { + onSigninRequired?.(detail); +} + export async function api(path: string, init?: RequestInit): Promise { const r = await fetch(withBase(path), { headers: { "content-type": "application/json" }, ...init }); const text = await r.text(); @@ -368,6 +386,7 @@ export async function api(path: string, init?: RequestInit): Promis swallow("web-ui: parse api response body", e); } if (!r.ok) { + if (r.status === 401 && path !== "/signin") reportSigninRequired(body as SigninRequired); const msg = (body as { error?: string; message?: string })?.message ?? (body as { error?: string })?.error ?? diff --git a/plugins/web-ui/src/files.ts b/plugins/web-ui/src/files.ts index 7a98a8687..36f1f6c1e 100644 --- a/plugins/web-ui/src/files.ts +++ b/plugins/web-ui/src/files.ts @@ -1,6 +1,6 @@ import { html, nothing, render } from "lit"; import { File, Image, Upload } from "lucide"; -import { api, withBase } from "./core-bridge"; +import { api, reportSigninRequired, type SigninRequired, withBase } from "./core-bridge"; import { errMessage } from "../../chassis/src/errors"; import { browserRenderableImage, formatBytes, icon, relTime } from "./ui"; import { contextsState, ensureContexts, personalScopeId, scopeChip, scopeFilterControl } from "./contexts"; @@ -221,7 +221,8 @@ async function uploadOne(file: globalThis.File): Promise { const text = await r.text(); let message = `Upload failed (${r.status})`; try { - const parsed = JSON.parse(text) as { message?: string; error?: string }; + const parsed = JSON.parse(text) as { message?: string; error?: string } & SigninRequired; + if (r.status === 401) reportSigninRequired(parsed); message = parsed.message ?? parsed.error ?? message; } catch { if (text.trim()) message = text.trim(); diff --git a/plugins/web-ui/src/main.ts b/plugins/web-ui/src/main.ts index 7e87a7ec3..0aa64e6bd 100644 --- a/plugins/web-ui/src/main.ts +++ b/plugins/web-ui/src/main.ts @@ -1,7 +1,6 @@ import "dockview-core/dist/styles/dockview.css"; import "./shell.css"; -import { swallow } from "../../chassis/src/errors"; -import { appState, boot, renderAuthGate } from "./shell"; +import { bootSafely } from "./shell"; import { closeFormMenus } from "./ui"; import { drawActiveChat } from "./chat"; import { composerState, slashQuery } from "./composer"; @@ -45,7 +44,4 @@ document.addEventListener("keydown", (e) => { if (changed) drawActiveChat(); }); -void boot().catch((e: unknown) => { - if (appState.me) swallow("web-ui: boot", e); - else renderAuthGate({ kind: "unreachable" }); -}); +void bootSafely(); diff --git a/plugins/web-ui/src/shell.ts b/plugins/web-ui/src/shell.ts index 40a8d7d73..776d3135a 100644 --- a/plugins/web-ui/src/shell.ts +++ b/plugins/web-ui/src/shell.ts @@ -17,9 +17,17 @@ import { type IconNode, } from "lucide"; import "@mariozechner/mini-lit/dist/ThemeToggle.js"; -import { api, fetchRuntimeConfig, fetchTranscript, TAIL_TURNS, withBase } from "./core-bridge"; +import { + api, + fetchRuntimeConfig, + fetchTranscript, + setSigninRequiredHandler, + type SigninRequired, + TAIL_TURNS, + withBase, +} from "./core-bridge"; import { applyRuntimeOptions } from "./model-options"; -import { errMessage } from "../../chassis/src/errors"; +import { errMessage, swallow } from "../../chassis/src/errors"; import { brandMark, brandName, icon, initials } from "./ui"; import { chatState, @@ -67,6 +75,12 @@ import { trapDialogFocus } from "./dialog-focus"; export { appState, can, type Me, type View } from "./shell-state"; let authMode: AuthMode = "portal"; +let shellMounted = false; + +setSigninRequiredHandler((detail) => { + authMode = detail.mode ?? authMode; + renderAuthGate(gateFor(authMode, detail.reason)); +}); export const ADMIN_BASE = (() => { const base = ((import.meta as unknown as { env?: { BASE_URL?: string } }).env?.BASE_URL ?? "/").replace(/\/$/, ""); @@ -180,10 +194,13 @@ const ICON = { }; export async function signOut(): Promise { - try { - await api("/signout", { method: "POST" }); - } catch { - void 0; + const portal = authMode === "portal"; + if (!portal) { + try { + await api("/signout", { method: "POST" }); + } catch { + void 0; + } } appState.me = null; clearAllDrafts(); @@ -195,16 +212,23 @@ export async function signOut(): Promise { resetContextsState(); resetKeychainState(); resetComposer(); - if (authMode === "portal") { - try { - await fetch("/auth/logout", { method: "POST", headers: { accept: "application/json" } }); - } catch { - void 0; - } - location.href = "/"; + if (!portal) { + renderAuthGate({ kind: "dev" }); return; } - renderAuthGate({ kind: "dev" }); + let endedSession: boolean; + try { + const r = await fetch("/auth/logout", { method: "POST", headers: { accept: "application/json" } }); + endedSession = r.ok; + } catch { + endedSession = false; + } + if (!endedSession) { + renderAuthGate({ kind: "portal" }); + return; + } + clearPortalAttempt(); + location.href = "/"; } export async function exitImpersonation(): Promise { @@ -248,12 +272,48 @@ function gateShell(body: unknown) { `; } +const PORTAL_ATTEMPT_KEY = "qm.portal.signin.attempt"; +const PORTAL_ATTEMPT_WINDOW_MS = 20_000; + +function portalAttemptedRecently(): boolean { + try { + const at = Number(sessionStorage.getItem(PORTAL_ATTEMPT_KEY) ?? ""); + return Number.isFinite(at) && Date.now() - at < PORTAL_ATTEMPT_WINDOW_MS; + } catch { + return false; + } +} + function signInWithPortal(): void { + try { + sessionStorage.setItem(PORTAL_ATTEMPT_KEY, String(Date.now())); + } catch { + void 0; + } const returnTo = `${location.pathname}${location.search}`; location.href = `/auth/login?returnTo=${encodeURIComponent(returnTo)}`; } +function clearPortalAttempt(): void { + try { + sessionStorage.removeItem(PORTAL_ATTEMPT_KEY); + } catch { + void 0; + } +} + function portalGate() { + if (portalAttemptedRecently()) + return gateShell(html` +

Sign in through the portal

+ +
+ If you opened this surface's own address, that's the cause — it can't authenticate anyone on its own. +
+ `); return gateShell(html`

Your session ended

@@ -261,31 +321,54 @@ function portalGate() { `); } +function deniedGate() { + return gateShell(html` +

You don't have access

+ + + ${ + authMode === "dev" + ? html`
This instance lists its principals in WEB_UI_PRINCIPALS.
` + : nothing + } + `); +} + +function retryBoot(): void { + void bootSafely(); +} + function unreachableGate() { return gateShell(html`

We couldn't reach the assistant

- +
If this keeps happening, the core service may be down.
`); } -function devGate(errorText: string) { +async function submitDevSignin(user: string): Promise { + renderAuthGate({ kind: "dev", value: user, pending: true }); + try { + await api("/signin", { method: "POST", body: JSON.stringify({ user }) }); + } catch (err) { + renderAuthGate({ kind: "dev", value: user, error: errMessage(err, "Sign-in failed.") }); + return; + } + await bootSafely(); +} + +function devGate(gate: { value?: string; error?: string; pending?: boolean }) { return gateShell(html`
{ + @submit=${(e: Event) => { e.preventDefault(); - const form = e.target as HTMLFormElement; - const input = form.querySelector("input") as HTMLInputElement | null; + if (gate.pending) return; + const input = (e.target as HTMLFormElement).querySelector("input") as HTMLInputElement | null; const user = input?.value.trim(); - if (!user || form.dataset.pending === "1") return; - form.dataset.pending = "1"; - try { - await api("/signin", { method: "POST", body: JSON.stringify({ user }) }); - await boot(); - } catch (err) { - renderAuthGate({ kind: "dev", error: errMessage(err, "Sign-in failed.") }); - } + if (user) void submitDevSignin(user); }} >

Dev sign-in

@@ -293,31 +376,56 @@ function devGate(errorText: string) { No identity provider is configured, so this instance trusts a local cookie. Set CORE_SIGNING_SECRET and run the portal to use real sign-in.

- - - - ${errorText ? html`` : nothing} + + + + ${gate.error ? html`` : nothing}
`); } -export type AuthGate = { kind: "portal" } | { kind: "unreachable" } | { kind: "dev"; error?: string }; +export type AuthGate = + | { kind: "portal" } + | { kind: "denied" } + | { kind: "unreachable" } + | { kind: "dev"; value?: string; error?: string; pending?: boolean }; export function renderAuthGate(gate: AuthGate): void { - if (gate.kind === "dev") authMode = "dev"; + shellMounted = false; const body = (() => { switch (gate.kind) { case "portal": return portalGate(); + case "denied": + return deniedGate(); case "unreachable": return unreachableGate(); default: - return devGate(gate.error ?? ""); + return devGate(gate); } })(); render(body, appEl as HTMLElement); } +function gateFor(mode: AuthMode, reason: "unauthenticated" | "not_allowed" | undefined): AuthGate { + if (reason === "not_allowed") return { kind: "denied" }; + return mode === "dev" ? { kind: "dev" } : { kind: "portal" }; +} + export function mountShell(): void { if (embedMode) { render( @@ -329,6 +437,7 @@ export function mountShell(): void { appState.topEl = null; appState.listEl = null; appState.mainEl = (appEl as HTMLElement).querySelector("#main"); + shellMounted = true; return; } applySavedSidebarWidth(); @@ -392,6 +501,7 @@ export function mountShell(): void { renderSidebarTop(); updateSidebarToggleLabels(); syncSidebarAccessibility(false); + shellMounted = true; } export function renderSidebarTop(): void { @@ -688,6 +798,15 @@ function openAppEditChat(slug: string): void { renderList(); } +export async function bootSafely(): Promise { + try { + await boot(); + } catch (e) { + if (shellMounted) swallow("web-ui: boot", e); + else renderAuthGate({ kind: "unreachable" }); + } +} + export async function boot(): Promise { let r: Response; try { @@ -697,12 +816,9 @@ export async function boot(): Promise { return; } if (r.status === 401) { - const mode = await r - .json() - .then((b: { mode?: AuthMode }) => b.mode) - .catch(() => undefined); - authMode = mode ?? "portal"; - renderAuthGate(authMode === "dev" ? { kind: "dev" } : { kind: "portal" }); + const body = (await r.json().catch(() => ({}))) as SigninRequired; + authMode = body.mode ?? "portal"; + renderAuthGate(gateFor(authMode, body.reason)); return; } if (!r.ok) { @@ -712,6 +828,7 @@ export async function boot(): Promise { resetKeychainState(); appState.me = (await r.json()) as Me; authMode = appState.me.mode ?? "portal"; + clearPortalAttempt(); const runtimeConfig = await fetchRuntimeConfig(`personal:${appState.me.user}`); if (runtimeConfig) applyRuntimeOptions( diff --git a/plugins/web-ui/test/auth-mode-dev.test.ts b/plugins/web-ui/test/auth-mode-dev.test.ts new file mode 100644 index 000000000..f2e61ff40 --- /dev/null +++ b/plugins/web-ui/test/auth-mode-dev.test.ts @@ -0,0 +1,75 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +const core = createServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end("{}"); +}); +await new Promise((r) => core.listen(0, r)); + +process.env.CORE_API_URL = `http://localhost:${(core.address() as AddressInfo).port}`; +delete process.env.CORE_SIGNING_SECRET; +delete process.env.PORTAL_IDENTITY_SECRET; +process.env.WEB_UI_PRINCIPALS = "alice"; +process.env.ALLOW_UNSIGNED_TEST_IDENTITY = "0"; + +const { handler } = await import("../server/index.ts"); +const surface = createServer((req, res) => void handler(req, res)); +await new Promise((r) => surface.listen(0, r)); +const base = `http://localhost:${(surface.address() as AddressInfo).port}`; + +const signin = (user: unknown) => + fetch(`${base}/signin`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ user }), + }); + +test.after(() => { + surface.close(); + core.close(); +}); + +test("with no signing secret the surface advertises dev mode", async () => { + const r = await fetch(`${base}/me`); + assert.equal(r.status, 401); + assert.deepEqual(await r.json(), { error: "sign in", mode: "dev", reason: "unauthenticated" }); +}); + +test("a bare principal id — not just an email — can sign in", async () => { + const r = await signin("alice"); + assert.equal(r.status, 200); + const cookie = r.headers.get("set-cookie") ?? ""; + assert.match(cookie, /webuiuser=alice/); + + const me = await fetch(`${base}/me`, { headers: { cookie: cookie.split(";")[0] } }); + assert.equal(me.status, 200); + const body = await me.json(); + assert.equal(body.user, "alice"); + assert.equal(body.mode, "dev"); +}); + +test("a principal outside the allowlist is refused with a message naming the env var", async () => { + const r = await signin("mallory"); + assert.equal(r.status, 403); + const body = await r.json(); + assert.equal(body.error, "not_allowed"); + assert.match(body.message, /WEB_UI_PRINCIPALS/); + assert.match(body.message, /mallory/); +}); + +test("an empty principal is a 400 with guidance, distinct from being refused", async () => { + const r = await signin(""); + assert.equal(r.status, 400); + const body = await r.json(); + assert.equal(body.error, "bad_request"); + assert.match(body.message, /Enter a principal/); +}); + +test("an over-long principal is truncated in the echoed error", async () => { + const r = await signin("z".repeat(500)); + assert.equal(r.status, 403); + assert.ok(((await r.json()).message as string).length < 400); +}); diff --git a/plugins/web-ui/test/auth-mode-portal.test.ts b/plugins/web-ui/test/auth-mode-portal.test.ts new file mode 100644 index 000000000..1becd15a5 --- /dev/null +++ b/plugins/web-ui/test/auth-mode-portal.test.ts @@ -0,0 +1,67 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { mintPortalIdentity, PORTAL_IDENTITY_HEADER } from "../../chassis/src/portal-identity.ts"; + +const core = createServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end("{}"); +}); +await new Promise((r) => core.listen(0, r)); + +const SECRET = "auth-mode-portal-test-secret"; +process.env.CORE_API_URL = `http://localhost:${(core.address() as AddressInfo).port}`; +process.env.CORE_SIGNING_SECRET = SECRET; +process.env.WEB_UI_PRINCIPALS = "alice"; +process.env.ALLOW_UNSIGNED_TEST_IDENTITY = "0"; + +const { handler } = await import("../server/index.ts"); +const surface = createServer((req, res) => void handler(req, res)); +await new Promise((r) => surface.listen(0, r)); +const base = `http://localhost:${(surface.address() as AddressInfo).port}`; + +test.after(() => { + surface.close(); + core.close(); +}); + +test("an unauthenticated request advertises portal mode so the client never offers the dev form", async () => { + const r = await fetch(`${base}/me`); + assert.equal(r.status, 401); + assert.deepEqual(await r.json(), { error: "sign in", mode: "portal", reason: "unauthenticated" }); +}); + +test("POST /signin does not exist once a signing secret makes cookie auth dead", async () => { + const r = await fetch(`${base}/signin`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ user: "alice" }), + }); + assert.equal(r.status, 404); + assert.equal((await r.json()).error, "not_found"); +}); + +test("a webuiuser cookie confers nothing in portal mode", async () => { + const r = await fetch(`${base}/me`, { headers: { cookie: "webuiuser=alice" } }); + assert.equal(r.status, 401); + assert.equal((await r.json()).reason, "unauthenticated"); +}); + +test("a verified principal outside WEB_UI_PRINCIPALS is not_allowed, not unauthenticated", async () => { + const token = mintPortalIdentity({ p: "mallory", exp: Date.now() + 60_000 }, SECRET); + const r = await fetch(`${base}/me`, { headers: { [PORTAL_IDENTITY_HEADER]: token } }); + assert.equal(r.status, 401); + const body = await r.json(); + assert.equal(body.reason, "not_allowed"); + assert.equal(body.mode, "portal"); +}); + +test("a verified allowed principal gets through and /me reports the mode", async () => { + const token = mintPortalIdentity({ p: "alice", exp: Date.now() + 60_000 }, SECRET); + const r = await fetch(`${base}/me`, { headers: { [PORTAL_IDENTITY_HEADER]: token } }); + assert.equal(r.status, 200); + const body = await r.json(); + assert.equal(body.user, "alice"); + assert.equal(body.mode, "portal"); +}); diff --git a/scripts/google-oauth-smoke.ts b/scripts/google-oauth-smoke.ts index 6a5a62e24..feb3f41af 100644 --- a/scripts/google-oauth-smoke.ts +++ b/scripts/google-oauth-smoke.ts @@ -12,6 +12,7 @@ import { createServer } from "../src/api/server.ts"; import { PROVIDERS } from "../src/connectors/oauth.ts"; import { scopeId } from "../src/types.ts"; import { buildGoogleWorkspaceReadSmokeCommand } from "./google-workspace-read-smoke-command.ts"; +import { mintPortalIdentity, PORTAL_IDENTITY_HEADER } from "../plugins/chassis/src/portal-identity.ts"; type Json = Record; @@ -218,21 +219,15 @@ try { }); await waitFor(`${webBase}/healthz`, 10_000); - const signin = await fetch(`${webBase}/signin`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ user: actor }), - }); - const signinBody = await readJson(signin); - assertOk(signin, signinBody, "web signin"); - const cookie = signin.headers.get("set-cookie")?.split(";")[0] ?? ""; - if (!cookie) throw new Error("web signin did not return a session cookie"); + const identity = { + [PORTAL_IDENTITY_HEADER]: mintPortalIdentity({ p: actor, exp: Date.now() + 10 * 60_000 }, secret), + }; - const statusBefore = await fetch(`${webBase}/api/connectors`, { headers: { cookie } }); + const statusBefore = await fetch(`${webBase}/api/connectors`, { headers: identity }); const statusBeforeBody = await readJson(statusBefore); assertOk(statusBefore, statusBeforeBody, "web connector status"); - const start = await fetch(`${webBase}/api/connectors/google/start`, { method: "POST", headers: { cookie } }); + const start = await fetch(`${webBase}/api/connectors/google/start`, { method: "POST", headers: identity }); const startBody = await readJson(start); assertOk(start, startBody, "web connector start"); const authorize = new URL(String(startBody.authorizeUrl ?? "")); @@ -274,7 +269,7 @@ try { const deadline = Date.now() + timeoutMs; let connected = false; while (Date.now() < deadline) { - const status = await fetch(`${webBase}/api/connectors`, { headers: { cookie } }); + const status = await fetch(`${webBase}/api/connectors`, { headers: identity }); const statusBody = await readJson(status); assertOk(status, statusBody, "web connector status during interactive wait"); connected = providerStatus(statusBody).connected;