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
+
+ This surface is reached through the portal, and signing in there didn't produce a session for it. Open the
+ portal address directly rather than this one.
+
+
+ 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
You've been signed out. Sign in again and you'll come back to this page.
@@ -261,31 +321,54 @@ function portalGate() {
`);
}
+function deniedGate() {
+ return gateShell(html`
+ You don't have access
+
+ Your account is signed in and verified — it just isn't allowed on this instance. Ask an administrator to add you.
+
+ Sign out
+ ${
+ 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
The service didn't respond. This is usually temporary.
- void boot()}>Try again
+ Try again
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`
- Work email
-
- Continue
- ${errorText ? html`${errorText}
` : nothing}
+ Principal
+
+
+ ${gate.pending ? "Signing in…" : "Continue"}
+
+ ${gate.error ? html`${gate.error}
` : 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;