diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 0ad1a8e5..b9b03201 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -1,7 +1,15 @@
import { render, screen } from "@testing-library/react";
+import { http, HttpResponse } from "msw";
+import { server } from "./test/msw";
import { App } from "./App";
-test("App renders the shell with create affordance", () => {
+test("App renders the shell with create affordance after authentication", async () => {
+ server.use(
+ http.get("/api/settings", () => HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" })),
+ http.get("/api/workspaces", () => HttpResponse.json([])),
+ http.get("/api/models", () => HttpResponse.json({ models: [] })),
+ http.get("/api/sessions", () => HttpResponse.json([])),
+ );
render();
- expect(screen.getByRole("button", { name: /new/i })).toBeInTheDocument();
+ expect(await screen.findByRole("button", { name: /new/i })).toBeInTheDocument();
});
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 97b8d367..3ac9b56c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,11 +1,11 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "./app/queryClient";
-import { AppShell } from "./shell/AppShell";
+import { AuthGate } from "./auth/AuthGate";
export function App() {
return (
-
+
);
}
diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts
new file mode 100644
index 00000000..77636168
--- /dev/null
+++ b/frontend/src/api/auth.ts
@@ -0,0 +1,114 @@
+import { ApiError, apiFetch } from "./client";
+import type { AuthenticatedUser, AuthPublicConfig, AuthRole, AuthSessionInfo } from "./types";
+import { clearAuthStateIfCurrent, getAuthGeneration, setAuthState } from "../auth/authState";
+
+const authModes = new Set(["local", "oidc", "upstream", "none", "mock"]);
+const roles = new Set(["user", "admin"]);
+const sessionMethods = new Set(["local", "oidc", "upstream"]);
+let pendingLogoutResponse: Promise | null = null;
+
+function record(value: unknown): Record | undefined {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? value as Record
+ : undefined;
+}
+
+function parseAuthConfig(value: unknown): AuthPublicConfig {
+ const source = record(value);
+ if (!source || !authModes.has(source.mode as AuthPublicConfig["mode"])
+ || typeof source.localLogin !== "boolean" || typeof source.oidcLogin !== "boolean") {
+ throw new Error("Authentication service returned an invalid public configuration");
+ }
+ return {
+ mode: source.mode as AuthPublicConfig["mode"],
+ localLogin: source.localLogin,
+ oidcLogin: source.oidcLogin,
+ };
+}
+
+function parseUser(value: unknown): AuthenticatedUser {
+ const source = record(value);
+ if (!source || typeof source.issuer !== "string" || typeof source.subject !== "string") {
+ throw new Error("Authentication service returned an invalid user");
+ }
+ const parsedRoles = Array.isArray(source.roles)
+ ? source.roles.filter((role): role is AuthRole => typeof role === "string" && roles.has(role as AuthRole))
+ : [];
+ const parsedPermissions = Array.isArray(source.permissions)
+ ? source.permissions.filter((permission): permission is string => typeof permission === "string")
+ : [];
+ const csrfToken = source.csrfToken === undefined || source.csrfToken === null
+ ? null
+ : typeof source.csrfToken === "string" ? source.csrfToken : null;
+ const sessionSource = source.session === null || source.session === undefined
+ ? undefined
+ : record(source.session);
+ const session = sessionSource
+ && sessionMethods.has(sessionSource.method as AuthSessionInfo["method"])
+ && typeof sessionSource.remembered === "boolean"
+ && typeof sessionSource.idleExpiresAt === "string"
+ && typeof sessionSource.absoluteExpiresAt === "string"
+ ? {
+ method: sessionSource.method as AuthSessionInfo["method"],
+ remembered: sessionSource.remembered,
+ idleExpiresAt: sessionSource.idleExpiresAt,
+ absoluteExpiresAt: sessionSource.absoluteExpiresAt,
+ }
+ : null;
+ return {
+ issuer: source.issuer,
+ subject: source.subject,
+ ...(typeof source.displayName === "string" ? { displayName: source.displayName } : {}),
+ roles: parsedRoles,
+ permissions: parsedPermissions,
+ isAdmin: source.isAdmin === true,
+ csrfToken,
+ session,
+ };
+}
+
+export async function getAuthConfig(): Promise {
+ return parseAuthConfig(await apiFetch("/auth/config"));
+}
+
+export async function getMe(): Promise {
+ return parseUser(await apiFetch("/me"));
+}
+
+export async function loginLocal(username: string, password: string, remember: boolean): Promise {
+ if (pendingLogoutResponse) await pendingLogoutResponse;
+ await apiFetch("/auth/local/login", {
+ method: "POST",
+ body: JSON.stringify({ username, password, remember }),
+ });
+ const user = await getMe();
+ setAuthState(user);
+ return user;
+}
+
+export async function logout(): Promise {
+ const logoutGeneration = getAuthGeneration();
+ if (pendingLogoutResponse) {
+ await pendingLogoutResponse;
+ return false;
+ }
+ let cleared = false;
+ const response = (async () => {
+ try {
+ await apiFetch("/auth/logout", { method: "POST" });
+ } finally {
+ cleared = clearAuthStateIfCurrent(logoutGeneration);
+ }
+ })();
+ let trackedResponse: Promise;
+ trackedResponse = response.finally(() => {
+ if (pendingLogoutResponse === trackedResponse) pendingLogoutResponse = null;
+ });
+ pendingLogoutResponse = trackedResponse;
+ await trackedResponse;
+ return cleared;
+}
+
+export function authErrorStatus(error: unknown): number | undefined {
+ return error instanceof ApiError ? error.status : undefined;
+}
diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts
index 83d9094c..7f6474f2 100644
--- a/frontend/src/api/client.test.ts
+++ b/frontend/src/api/client.test.ts
@@ -1,6 +1,20 @@
import { http, HttpResponse } from "msw";
import { server } from "../test/msw";
-import { apiFetch } from "./client";
+import { apiErrorMessage, apiFetch, ApiError, assertSameOriginRequestUrl } from "./client";
+import { clearAuthState, setAuthState } from "../auth/authState";
+
+const authenticated = {
+ issuer: "local",
+ subject: "user-1",
+ roles: ["user"] as const,
+ permissions: ["session.use"],
+ isAdmin: false,
+ csrfToken: "c".repeat(43),
+ session: null,
+};
+
+beforeEach(() => setAuthState(authenticated));
+afterEach(() => clearAuthState());
test("body-less POST omits content-type (avoids Fastify empty-body 400)", async () => {
let contentType: string | null = "unset";
@@ -25,3 +39,258 @@ test("POST with a body sends application/json content-type", async () => {
await apiFetch("/sessions/s1/steer", { method: "POST", body: JSON.stringify({ text: "hi" }) });
expect(contentType).toContain("application/json");
});
+
+test("same-origin requests include credentials and overwrite the CSRF header from memory", async () => {
+ let observed: { credentials: string | null; csrf: string | null } | undefined;
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
+ server.use(
+ http.post("/api/sessions/s1/steer", ({ request }) => {
+ observed = {
+ credentials: request.headers.get("credentials"),
+ csrf: request.headers.get("x-thothii-csrf"),
+ };
+ return new HttpResponse(null, { status: 204 });
+ }),
+ );
+
+ await apiFetch("/sessions/s1/steer", {
+ method: "POST",
+ headers: { "X-ThothII-CSRF": "attacker-supplied" },
+ body: JSON.stringify({ text: "hi" }),
+ });
+
+ expect(observed?.csrf).toBe("c".repeat(43));
+ expect(observed?.credentials).toBeNull();
+ expect(fetchSpy.mock.calls.at(-1)?.[1]).toMatchObject({ credentials: "same-origin" });
+ fetchSpy.mockRestore();
+});
+
+test("a 401 clears the in-memory auth state and advances its generation", async () => {
+ const before = (await import("../auth/authState")).getAuthGeneration();
+ server.use(http.get("/api/private", () => new HttpResponse(null, { status: 401 })));
+
+ await expect(apiFetch("/private")).rejects.toMatchObject({ status: 401 });
+
+ const state = await import("../auth/authState");
+ expect(state.getAuthState()).toBeNull();
+ expect(state.getAuthGeneration()).toBeGreaterThan(before);
+});
+
+test("a delayed 401 from user A cannot clear user B after a new login", async () => {
+ let release!: () => void;
+ const delayed = new Promise((resolve) => { release = resolve; });
+ server.use(http.get("/api/stale-request", async () => {
+ await delayed;
+ return new HttpResponse(null, { status: 401 });
+ }));
+
+ const request = apiFetch("/stale-request");
+ const userB = { ...authenticated, subject: "user-b", csrfToken: "b".repeat(43) };
+ setAuthState(userB);
+ release();
+
+ await expect(request).rejects.toMatchObject({ status: 401 });
+ expect((await import("../auth/authState")).getAuthState()).toMatchObject({ subject: "user-b" });
+});
+
+test("bounds streamed error bodies and never exposes raw HTML or secrets", async () => {
+ const secret = "TOP-SECRET-token-123";
+ const hugeBody = `${secret}${"x".repeat(20_000)}`;
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode(hugeBody.slice(0, 4_000)));
+ controller.enqueue(new TextEncoder().encode(hugeBody.slice(4_000)));
+ controller.close();
+ },
+ }), { status: 500, headers: { "content-type": "text/html", "content-length": "1" } }),
+ );
+
+ try {
+ const result = await apiFetch("/oversized").catch((error: unknown) => error);
+ expect(result).toBeInstanceOf(ApiError);
+ const failure = result as ApiError;
+ expect(failure.status).toBe(500);
+ expect(failure.message).not.toContain(secret);
+ expect(failure.message).not.toContain("");
+ expect(failure.bodyText).not.toContain(secret);
+ expect(failure.payload).toBeUndefined();
+ } finally {
+ fetchSpy.mockRestore();
+ }
+});
+
+test("keeps only a known safe bounded JSON error payload", async () => {
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({
+ code: "dwh_unreachable",
+ error: "The data warehouse is unreachable.",
+ }), { status: 503, headers: { "content-type": "application/json" } }),
+ );
+
+ try {
+ const result = await apiFetch("/known-error").catch((error: unknown) => error);
+ const failure = result as ApiError;
+ expect(failure).toMatchObject({
+ status: 503,
+ code: "dwh_unreachable",
+ payload: { code: "dwh_unreachable" },
+ });
+ expect(failure.bodyText).toBe("");
+ expect(failure.message).toBe("The database is unreachable. Please retry.");
+ } finally {
+ fetchSpy.mockRestore();
+ }
+});
+
+test("derives local messages without retaining a malicious known-code message", async () => {
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({
+ code: "dwh_unreachable",
+ message: "Bearer eyJhbGciOiJIUzI1NiJ9.password=do-not-show",
+ error: "password=do-not-show",
+ }), { status: 503, headers: { "content-type": "application/json" } }),
+ );
+
+ try {
+ const failure = await apiFetch("/known-malicious").catch((error: unknown) => error) as ApiError;
+ expect(failure.code).toBe("dwh_unreachable");
+ expect(failure.message).toBe("The database is unreachable. Please retry.");
+ expect(failure.bodyText).toBe("");
+ expect(failure.payload).toEqual({ code: "dwh_unreachable" });
+ expect(failure.message).not.toMatch(/Bearer|password|html|do-not-show/i);
+ } finally {
+ fetchSpy.mockRestore();
+ }
+});
+
+test("uses a generic local message for unknown malicious codes", async () => {
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({
+ code: "unknown-secret-code",
+ message: "Bearer eyJhbGciOiJIUzI1NiJ9",
+ error: "password=do-not-show",
+ }), { status: 500, headers: { "content-type": "application/json" } }),
+ );
+
+ try {
+ const failure = await apiFetch("/unknown-malicious").catch((error: unknown) => error) as ApiError;
+ expect(failure.code).toBeUndefined();
+ expect(failure.message).toBe("Request failed. Please try again.");
+ expect(failure.bodyText).toBe("");
+ expect(failure.payload).toBeUndefined();
+ expect(apiErrorMessage(failure)).toBe("Request failed. Please try again.");
+ } finally {
+ fetchSpy.mockRestore();
+ }
+});
+
+test("releases the response reader after a successful bounded read", async () => {
+ const reader = {
+ read: vi.fn()
+ .mockResolvedValueOnce({ done: false, value: new TextEncoder().encode("{not-json") })
+ .mockResolvedValueOnce({ done: true, value: undefined }),
+ cancel: vi.fn().mockResolvedValue(undefined),
+ releaseLock: vi.fn(),
+ };
+ const response = new Response(new ReadableStream(), { status: 500 });
+ vi.spyOn(response.body!, "getReader").mockReturnValue(reader as unknown as ReadableStreamReader>);
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(response);
+
+ try {
+ await expect(apiFetch("/reader-success")).rejects.toBeInstanceOf(ApiError);
+ expect(reader.cancel).not.toHaveBeenCalled();
+ expect(reader.releaseLock).toHaveBeenCalledOnce();
+ } finally {
+ fetchSpy.mockRestore();
+ }
+});
+
+test("cancels and releases the response reader on overflow", async () => {
+ const reader = {
+ read: vi.fn()
+ .mockResolvedValueOnce({ done: false, value: new Uint8Array(9 * 1024) }),
+ cancel: vi.fn().mockResolvedValue(undefined),
+ releaseLock: vi.fn(),
+ };
+ const response = new Response(new ReadableStream(), { status: 500 });
+ vi.spyOn(response.body!, "getReader").mockReturnValue(reader as unknown as ReadableStreamReader>);
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(response);
+
+ try {
+ await expect(apiFetch("/reader-overflow")).rejects.toBeInstanceOf(ApiError);
+ expect(reader.cancel).toHaveBeenCalledOnce();
+ expect(reader.releaseLock).toHaveBeenCalledOnce();
+ } finally {
+ fetchSpy.mockRestore();
+ }
+});
+
+test("cancels and releases the response reader when a read throws", async () => {
+ const reader = {
+ read: vi.fn().mockRejectedValue(new Error("stream broke")),
+ cancel: vi.fn().mockResolvedValue(undefined),
+ releaseLock: vi.fn(),
+ };
+ const response = new Response(new ReadableStream(), { status: 500 });
+ vi.spyOn(response.body!, "getReader").mockReturnValue(reader as unknown as ReadableStreamReader>);
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(response);
+
+ try {
+ await expect(apiFetch("/reader-throws")).rejects.toThrow("stream broke");
+ expect(reader.cancel).toHaveBeenCalledOnce();
+ expect(reader.releaseLock).toHaveBeenCalledOnce();
+ } finally {
+ fetchSpy.mockRestore();
+ }
+});
+
+test("rejects malformed and sensitive JSON error bodies without surfacing their content", async () => {
+ const bodies = [
+ "{not-json",
+ JSON.stringify({ code: "unknown_secret_code", error: "password=super-secret" }),
+ ];
+
+ for (const body of bodies) {
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(body, { status: 500, headers: { "content-type": "application/json" } }),
+ );
+ try {
+ const result = await apiFetch("/unsafe-error").catch((error: unknown) => error);
+ const failure = result as ApiError;
+ expect(failure.payload).toBeUndefined();
+ expect(failure.message).not.toContain("super-secret");
+ expect(failure.message).not.toContain("not-json");
+ } finally {
+ fetchSpy.mockRestore();
+ }
+ }
+
+ const knownCode = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({ code: "auth_forbidden", error: "token=super-secret" }), {
+ status: 403, headers: { "content-type": "application/json" },
+ }),
+ );
+ try {
+ const result = await apiFetch("/known-sensitive-error").catch((error: unknown) => error);
+ const failure = result as ApiError;
+ expect(failure.payload).toEqual({ code: "auth_forbidden" });
+ expect(failure.message).toBe("Access is not permitted.");
+ expect(failure.message).not.toContain("super-secret");
+ } finally {
+ knownCode.mockRestore();
+ }
+});
+
+test("refuses a cross-origin request before sending credentials", () => {
+ expect(() => assertSameOriginRequestUrl("https://attacker.example/api/me")).toThrow(/same-origin/i);
+});
+
+test("preserves explicit 403 and 503 statuses for presentation", async () => {
+ server.use(
+ http.get("/api/forbidden", () => HttpResponse.json({ code: "auth_forbidden" }, { status: 403 })),
+ http.get("/api/unavailable", () => HttpResponse.json({ code: "auth_unavailable" }, { status: 503 })),
+ );
+ await expect(apiFetch("/forbidden")).rejects.toBeInstanceOf(ApiError);
+ await expect(apiFetch("/unavailable")).rejects.toMatchObject({ status: 503 });
+});
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index a84ea83a..ec92d02b 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -1,15 +1,137 @@
import { backendBaseUrl as BASE, joinBackendPath } from "./runtime-config";
+import {
+ clearAuthStateIfCurrent,
+ getAuthGeneration,
+ getAuthState,
+} from "../auth/authState";
+
+const MAX_ERROR_BODY_BYTES = 8 * 1024;
+const safeErrorCodes = new Set([
+ "auth_forbidden", "auth_invalid_credentials", "auth_not_authorized", "auth_unavailable",
+ "auth_not_implemented", "authentication_required", "invalid_credentials", "login_rate_limited",
+ "csrf_failed", "csrf_invalid", "dwh_unreachable", "model_unavailable",
+ "workspace_invalid", "binding_missing", "workspace_not_activatable", "workspace_stale",
+ "git_unavailable", "git_auth_failed", "git_non_fast_forward", "connector_unavailable",
+ "semantic_index_incompatible", "pi_management_forbidden", "pi_management_unavailable",
+ "pi_management_invalid_config", "pi_management_write_failed",
+]);
+
+type SafeErrorPayload = {
+ code: string;
+};
+
+const localCodeMessages: Record = {
+ auth_forbidden: "Access is not permitted.",
+ auth_invalid_credentials: "Invalid username or password.",
+ auth_not_authorized: "Access is not permitted.",
+ auth_unavailable: "Authentication is temporarily unavailable. Try again.",
+ auth_not_implemented: "This sign-in method is not available.",
+ authentication_required: "Please sign in to continue.",
+ invalid_credentials: "Invalid username or password.",
+ login_rate_limited: "Too many sign-in attempts. Try again later.",
+ csrf_failed: "The security check failed. Please retry.",
+ csrf_invalid: "The security check failed. Please retry.",
+ dwh_unreachable: "The database is unreachable. Please retry.",
+ model_unavailable: "The model provider is unavailable. Please retry.",
+ workspace_invalid: "The workspace configuration is invalid.",
+ binding_missing: "The workspace is missing a required binding.",
+ workspace_not_activatable: "The workspace cannot be activated.",
+ workspace_stale: "The workspace has changed. Refresh and try again.",
+ git_unavailable: "The workspace repository is unavailable.",
+ git_auth_failed: "The workspace repository could not be authenticated.",
+ git_non_fast_forward: "The workspace repository has moved. Refresh and try again.",
+ connector_unavailable: "A workspace connector is unavailable.",
+ semantic_index_incompatible: "The workspace semantic index is incompatible.",
+ pi_management_forbidden: "Pi management is not permitted",
+ pi_management_unavailable: "Pi management is unavailable.",
+ pi_management_invalid_config: "The Pi configuration is invalid.",
+ pi_management_write_failed: "The Pi configuration could not be saved.",
+};
+
+const localStatusMessages: Record = {
+ 401: "Please sign in to continue.",
+ 403: "Access is not permitted.",
+ 404: "The requested resource was not found.",
+ 409: "The request conflicts with current workspace state.",
+ 429: "Too many requests. Try again later.",
+ 500: "Request failed. Please try again.",
+ 502: "The service is unavailable. Please retry.",
+ 503: "The service is temporarily unavailable. Please retry.",
+};
+
+const GENERIC_ERROR_MESSAGE = "Request failed. Please try again.";
+
+function localErrorMessage(status: number, code?: string): string {
+ return (code && localCodeMessages[code]) || localStatusMessages[status] || GENERIC_ERROR_MESSAGE;
+}
/**
- * Error thrown for non-2xx responses. `.message` stays " " for
- * backward compatibility; `.status` and `.payload` (parsed JSON body, if any)
- * let callers branch on a specific failure — e.g. a `code: "dwh_unreachable"`.
+ * Error thrown for non-2xx responses. The message contains only status and a
+ * whitelisted error code; `.payload` contains a bounded, sanitized JSON shape.
*/
export class ApiError extends Error {
- constructor(readonly status: number, readonly bodyText: string, readonly payload: unknown) {
- super(`${status} ${bodyText}`);
+ constructor(
+ readonly status: number,
+ readonly bodyText: string,
+ readonly payload: SafeErrorPayload | undefined,
+ ) {
+ super(localErrorMessage(status, payload?.code));
this.name = "ApiError";
}
+
+ get code(): string | undefined {
+ return this.payload?.code;
+ }
+}
+
+export function apiErrorMessage(error: unknown): string {
+ return error instanceof ApiError ? error.message : GENERIC_ERROR_MESSAGE;
+}
+
+function parseSafeErrorPayload(text: string, truncated: boolean): SafeErrorPayload | undefined {
+ if (truncated || text.length === 0) return undefined;
+ let parsed: unknown;
+ try { parsed = JSON.parse(text); } catch { return undefined; }
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
+ const source = parsed as Record;
+ if (typeof source.code !== "string" || !safeErrorCodes.has(source.code)) return undefined;
+ return { code: source.code };
+}
+
+async function readBoundedText(response: Response): Promise<{ text: string; truncated: boolean }> {
+ const reader = response.body?.getReader();
+ if (!reader) return { text: "", truncated: false };
+ const decoder = new TextDecoder();
+ let text = "";
+ let bytes = 0;
+ let truncated = false;
+ try {
+ while (true) {
+ const next = await reader.read();
+ if (next.done) break;
+ const remaining = MAX_ERROR_BODY_BYTES - bytes;
+ if (remaining <= 0) {
+ truncated = true;
+ await reader.cancel();
+ break;
+ }
+ const chunk = next.value.byteLength > remaining ? next.value.slice(0, remaining) : next.value;
+ bytes += chunk.byteLength;
+ text += decoder.decode(chunk, { stream: next.value.byteLength <= remaining });
+ if (next.value.byteLength > remaining) {
+ truncated = true;
+ await reader.cancel();
+ break;
+ }
+ }
+ } catch (error) {
+ try { await reader.cancel(); } catch { /* preserve the original read failure */ }
+ throw error;
+ } finally {
+ text += decoder.decode();
+ try { reader.releaseLock(); } catch { /* a completed browser reader may already be released */ }
+ }
+ return { text, truncated };
}
function requestHeaders(init: RequestInit | undefined): Headers {
@@ -27,16 +149,42 @@ function requestHeaders(init: RequestInit | undefined): Headers {
}
async function request(path: string, init?: RequestInit): Promise {
- const res = await fetch(joinBackendPath(BASE, path), { ...init, headers: requestHeaders(init) });
+ const url = joinBackendPath(BASE, path);
+ assertSameOriginRequestUrl(url);
+ const dispatchGeneration = getAuthGeneration();
+ const headers = requestHeaders(init);
+ const method = (init?.method ?? "GET").toUpperCase();
+ if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
+ headers.delete("X-ThothII-CSRF");
+ const csrfToken = getAuthState()?.csrfToken;
+ if (csrfToken) headers.set("X-ThothII-CSRF", csrfToken);
+ }
+ const res = await fetch(url, {
+ ...init,
+ credentials: "same-origin",
+ headers,
+ });
+ if (res.status === 401) clearAuthStateIfCurrent(dispatchGeneration);
if (!res.ok) {
- const bodyText = await res.text().catch(() => "");
- let payload: unknown;
- try { payload = bodyText ? JSON.parse(bodyText) : undefined; } catch { payload = undefined; }
- throw new ApiError(res.status, bodyText, payload);
+ const { text, truncated } = await readBoundedText(res);
+ const payload = parseSafeErrorPayload(text, truncated);
+ throw new ApiError(res.status, "", payload);
}
return res;
}
+/** Refuse a credentialed cross-origin base before the browser can send a request. */
+export function assertSameOriginRequestUrl(url: string): void {
+ if (typeof window === "undefined") {
+ if (/^https?:\/\//i.test(url)) throw new Error("The browser must use the same-origin /api route");
+ return;
+ }
+ const parsed = new URL(url, window.location.origin);
+ if (parsed.origin !== window.location.origin) {
+ throw new Error("The browser must use the same-origin /api route");
+ }
+}
+
export async function apiFetch(path: string, init?: RequestInit): Promise {
// Only declare a JSON content-type when we actually send a body. Body-less
// POSTs (resume, close) would otherwise make Fastify reject the empty body
diff --git a/frontend/src/api/pi-management.ts b/frontend/src/api/pi-management.ts
index 78008d75..2b12a801 100644
--- a/frontend/src/api/pi-management.ts
+++ b/frontend/src/api/pi-management.ts
@@ -1,4 +1,4 @@
-import { ApiError, apiFetch } from "./client";
+import { ApiError, apiErrorMessage, apiFetch } from "./client";
export type PiReasoning = "low" | "medium" | "high";
@@ -54,22 +54,14 @@ const errorCodes = new Set([
"pi_management_write_failed",
]);
-function object(value: unknown): Record | undefined {
- return value && typeof value === "object" && !Array.isArray(value)
- ? value as Record
- : undefined;
-}
-
-/** Narrows the sanctioned error payload without ever surfacing its raw response body. */
+/** Narrows the sanctioned error code and derives its message locally. */
export function asPiManagementApiError(error: unknown): PiManagementApiError | undefined {
if (!(error instanceof ApiError)) return undefined;
- const payload = object(error.payload);
- const code = payload?.code;
- const message = payload?.error;
- if (typeof code !== "string" || !errorCodes.has(code as PiManagementApiErrorCode) || typeof message !== "string") {
+ const code = error.code;
+ if (typeof code !== "string" || !errorCodes.has(code as PiManagementApiErrorCode)) {
return undefined;
}
- return { status: error.status, code: code as PiManagementApiErrorCode, message };
+ return { status: error.status, code: code as PiManagementApiErrorCode, message: apiErrorMessage(error) };
}
export const getPiManagementStatus = () => apiFetch("/pi-management/status");
diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts
index 30e918e1..7284db61 100644
--- a/frontend/src/api/sessions.ts
+++ b/frontend/src/api/sessions.ts
@@ -1,4 +1,10 @@
import { apiFetch } from "./client";
+import {
+ captureAuthOperation,
+ isAuthOperationCurrent,
+ requireAuthOperationPrecondition,
+ type AuthOperationPrecondition,
+} from "../auth/authOperation";
import { getSettings } from "./settings";
import { getWorkspace, listWorkspaces } from "./workspaces";
import {
@@ -6,18 +12,20 @@ import {
workspacePreferences, type WorkspacePreference,
} from "../workspaces/drafts";
import type {
- Principal, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse,
+ AuthenticatedUser, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse,
} from "./types";
type NewSessionInput = { question: string; name?: string } & Partial;
-async function selectedPreferences(): Promise {
+async function selectedPreferences(precondition?: AuthOperationPrecondition): Promise {
+ requireAuthOperationPrecondition(precondition);
const saved = workspacePreferences.load();
if (saved.workspaceId && saved.provider && saved.model && saved.thinking) return saved;
// A direct new-session entry point can run before the composer has mounted. Seed current
// application memory from the installation defaults once, then keep choices ephemeral.
const legacy = await getSettings();
+ requireAuthOperationPrecondition(precondition);
return workspacePreferences.save({
workspaceId: saved.workspaceId ?? legacy.workspace,
provider: saved.provider ?? legacy.provider,
@@ -44,8 +52,9 @@ function reconcileWorkspacePolicy(preferences: WorkspacePreference, allowed: rea
});
}
-async function ensureWorkspaceSelectionPolicy(): Promise {
+async function ensureWorkspaceSelectionPolicy(precondition?: AuthOperationPrecondition): Promise {
while (true) {
+ requireAuthOperationPrecondition(precondition);
const preferences = workspacePreferences.load();
const workspaceId = preferences.workspaceId;
if (!workspaceId) return preferences;
@@ -54,10 +63,12 @@ async function ensureWorkspaceSelectionPolicy(): Promise {
try {
workspaces = await listWorkspaces();
} catch {
+ requireAuthOperationPrecondition(precondition);
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
workspacePolicyGate.rejectSummary(workspaceId);
throw new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR);
}
+ requireAuthOperationPrecondition(precondition);
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
const workspace = workspaces.find((candidate) => candidate.id === workspaceId);
if (!workspace) {
@@ -69,18 +80,22 @@ async function ensureWorkspaceSelectionPolicy(): Promise {
try {
installationDefault = (await getSettings()).workspace;
} catch {
+ requireAuthOperationPrecondition(precondition);
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
workspacePolicyGate.rejectSummary(workspaceId);
throw new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR);
}
+ requireAuthOperationPrecondition(precondition);
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
const replacement = workspaces.find(
(candidate) => candidate.id === installationDefault && candidate.revision,
) ?? workspaces.find((candidate) => candidate.revision);
if (!replacement) {
+ requireAuthOperationPrecondition(precondition);
workspacePolicyGate.reject(workspaceId);
throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
}
+ requireAuthOperationPrecondition(precondition);
workspacePreferences.save({ ...preferences, workspaceId: replacement.id });
continue;
}
@@ -98,16 +113,19 @@ async function ensureWorkspaceSelectionPolicy(): Promise {
(selection) => ({ kind: "selection" as const, selection }),
),
]);
+ requireAuthOperationPrecondition(precondition);
if (outcome.kind === "selection") {
if (outcome.selection.workspaceId !== workspaceId) continue;
return outcome.selection;
}
if (outcome.kind === "error") {
+ requireAuthOperationPrecondition(precondition);
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
workspacePolicyGate.reject(workspaceId);
throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
}
const { record } = outcome;
+ requireAuthOperationPrecondition(precondition);
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
let selection: WorkspacePreference;
try {
@@ -117,17 +135,31 @@ async function ensureWorkspaceSelectionPolicy(): Promise {
record.workspace.llm_policy.default,
);
} catch (error) {
+ requireAuthOperationPrecondition(precondition);
workspacePolicyGate.reject(workspaceId);
throw error;
}
+ requireAuthOperationPrecondition(precondition);
workspacePolicyGate.resolve(workspaceId);
+ requireAuthOperationPrecondition(precondition);
if (workspacePreferences.load().workspaceId === workspaceId) return selection;
}
}
-export async function createSession(i: NewSessionInput) {
- await selectedPreferences();
- const selection = await ensureWorkspaceSelectionPolicy();
+export async function createSession(i: NewSessionInput, precondition?: AuthOperationPrecondition) {
+ const initiatingOperation = captureAuthOperation();
+ const effectivePrecondition = precondition ?? (initiatingOperation ? {
+ operation: initiatingOperation,
+ isCurrent: () => isAuthOperationCurrent(initiatingOperation, {
+ sessionId: null,
+ disposalEpoch: initiatingOperation.disposalEpoch,
+ }),
+ } : undefined);
+ requireAuthOperationPrecondition(effectivePrecondition);
+ await selectedPreferences(effectivePrecondition);
+ requireAuthOperationPrecondition(effectivePrecondition);
+ const selection = await ensureWorkspaceSelectionPolicy(effectivePrecondition);
+ requireAuthOperationPrecondition(effectivePrecondition);
return apiFetch<{ id: string }>("/sessions", {
method: "POST",
body: JSON.stringify({ ...i, ...selection }),
@@ -138,7 +170,8 @@ export async function createSession(i: NewSessionInput) {
export const prewarmRuntime = () =>
apiFetch("/runtime/prewarm", { method: "POST" });
-export const getMe = () => apiFetch("/me");
+/** Compatibility export for existing shell tests/callers; AuthGate uses the validated auth API. */
+export const getMe = () => apiFetch("/me");
export const listSessions = (scope: SessionScope = "mine") =>
apiFetch(`/sessions?scope=${scope}`);
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 1f6b05d4..61d1b4f1 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -117,11 +117,44 @@ export interface SessionSummary {
export type SessionScope = "mine" | "all";
+export type AuthRole = "user" | "admin";
+
+export interface AuthSessionInfo {
+ method: "local" | "oidc" | "upstream";
+ remembered: boolean;
+ idleExpiresAt: string;
+ absoluteExpiresAt: string;
+}
+
+/** Safe browser representation returned by Task 8's /me route. */
+export interface AuthenticatedUser {
+ issuer: string;
+ subject: string;
+ displayName?: string;
+ roles: readonly AuthRole[];
+ permissions: readonly string[];
+ isAdmin: boolean;
+ /** Legacy upstream/none modes deliberately return null: there is no CSRF token. */
+ csrfToken: string | null;
+ /** Legacy upstream/none modes deliberately return null: there is no cookie session. */
+ session: AuthSessionInfo | null;
+}
+
+export interface AuthPublicConfig {
+ mode: "local" | "oidc" | "upstream" | "none" | "mock";
+ localLogin: boolean;
+ oidcLogin: boolean;
+}
+
export interface Principal {
issuer: string;
subject: string;
displayName?: string;
+ roles?: readonly AuthRole[];
+ permissions?: readonly string[];
isAdmin: boolean;
+ csrfToken?: string | null;
+ session?: AuthSessionInfo | null;
}
export interface ResumeSessionResult {
diff --git a/frontend/src/api/workspaces.ts b/frontend/src/api/workspaces.ts
index f868b502..93c29816 100644
--- a/frontend/src/api/workspaces.ts
+++ b/frontend/src/api/workspaces.ts
@@ -1,4 +1,4 @@
-import { ApiError, apiFetch } from "./client";
+import { ApiError, apiErrorMessage, apiFetch } from "./client";
import { sanitizeCanonicalWorkspace } from "../workspaces/drafts";
export type WorkspaceErrorCode =
@@ -238,19 +238,14 @@ function requireWorkspaceRevision(value: unknown, expectedId: string): Workspace
return revision;
}
-/** Sanitized registry error data; it intentionally excludes the raw response body. */
+/** Localized registry error data; it intentionally excludes the raw response body. */
export function asWorkspaceApiError(error: unknown): WorkspaceApiError | undefined {
if (!(error instanceof ApiError)) return undefined;
- const payload = object(error.payload);
- const code = payload?.code;
- const message = payload?.message;
- if (typeof code !== "string" || !workspaceErrorCodes.has(code as WorkspaceErrorCode) || typeof message !== "string") {
+ const code = error.code;
+ if (typeof code !== "string" || !workspaceErrorCodes.has(code as WorkspaceErrorCode)) {
return undefined;
}
- const fields = Array.isArray(payload?.fields) && payload.fields.every((field) => typeof field === "string")
- ? payload.fields
- : undefined;
- return { status: error.status, code: code as WorkspaceErrorCode, message, ...(fields ? { fields } : {}) };
+ return { status: error.status, code: code as WorkspaceErrorCode, message: apiErrorMessage(error) };
}
function requireCanonicalWorkspace(value: unknown): CanonicalWorkspace {
diff --git a/frontend/src/auth/AuthGate.test.tsx b/frontend/src/auth/AuthGate.test.tsx
new file mode 100644
index 00000000..3a022ce4
--- /dev/null
+++ b/frontend/src/auth/AuthGate.test.tsx
@@ -0,0 +1,150 @@
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { http, HttpResponse, delay } from "msw";
+import { beforeEach, afterEach, describe, expect, test, vi } from "vitest";
+import { StrictMode } from "react";
+import { AuthGate } from "./AuthGate";
+import { clearAuthState, getAuthGeneration, getAuthState, setAuthState } from "./authState";
+import { server } from "../test/msw";
+
+vi.mock("../shell/AppShell", () => ({
+ AppShell: () => (
+
+ Authenticated shell
+
+
+ ),
+}));
+
+const user = {
+ issuer: "local",
+ subject: "user-1",
+ displayName: "Analyst",
+ roles: ["user"] as const,
+ permissions: ["session.use"],
+ isAdmin: false,
+ csrfToken: "c".repeat(43),
+ session: {
+ method: "local" as const,
+ remembered: false,
+ idleExpiresAt: "2026-08-17T10:00:00.000Z",
+ absoluteExpiresAt: "2026-08-17T20:00:00.000Z",
+ },
+};
+
+const localConfig = { mode: "local", localLogin: true, oidcLogin: false };
+
+beforeEach(() => {
+ clearAuthState();
+ server.use(
+ http.get("/api/auth/config", () => HttpResponse.json(localConfig)),
+ http.get("/api/me", () => HttpResponse.json(user)),
+ );
+});
+
+afterEach(() => {
+ cleanup();
+ clearAuthState();
+});
+
+describe("AuthGate", () => {
+ test("shows a loading state while /me is unresolved", async () => {
+ server.use(http.get("/api/me", async () => {
+ await delay(100);
+ return HttpResponse.json(user);
+ }));
+
+ render();
+
+ expect(screen.getByRole("status", { name: /checking access/i })).toBeInTheDocument();
+ expect(screen.queryByTestId("authenticated-shell")).not.toBeInTheDocument();
+ });
+
+ test("renders the authenticated shell from the safe /me DTO", async () => {
+ render();
+
+ expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument();
+ expect(screen.queryByRole("heading", { name: /sign in/i })).not.toBeInTheDocument();
+ });
+
+ test("returns to local login after an expired session 401", async () => {
+ server.use(http.get("/api/me", () => new HttpResponse(null, { status: 401 })));
+
+ render();
+
+ expect(await screen.findByRole("heading", { name: /sign in to thothii/i })).toBeInTheDocument();
+ expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
+ });
+
+ test("presents a forbidden /me response explicitly", async () => {
+ setAuthState(user);
+ const generation = getAuthGeneration();
+ server.use(http.get("/api/me", () => HttpResponse.json(
+ { code: "auth_not_authorized", error: "This operation is not permitted" },
+ { status: 403 },
+ )));
+
+ render();
+
+ expect(await screen.findByRole("heading", { name: /access not permitted/i })).toBeInTheDocument();
+ expect(screen.getByText(/signed in without permission/i)).toBeInTheDocument();
+ expect(getAuthState()).toMatchObject({ subject: "user-1", csrfToken: "c".repeat(43) });
+ expect(getAuthGeneration()).toBe(generation);
+ });
+
+ test("offers retry when the authentication provider is unavailable", async () => {
+ let attempts = 0;
+ server.use(
+ http.get("/api/me", () => {
+ attempts += 1;
+ return attempts === 1
+ ? HttpResponse.json({ code: "auth_unavailable" }, { status: 503 })
+ : HttpResponse.json(user);
+ }),
+ );
+
+ render();
+
+ expect(await screen.findByRole("heading", { name: /authentication unavailable/i })).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: /retry/i }));
+ expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument();
+ });
+
+ test("accepts the nullable legacy /me session shape without inventing a token", async () => {
+ server.use(http.get("/api/me", () => HttpResponse.json({
+ issuer: "portal",
+ subject: "legacy-user",
+ displayName: "Legacy user",
+ roles: ["user"],
+ permissions: ["session.use"],
+ isAdmin: false,
+ csrfToken: null,
+ session: null,
+ })));
+
+ render();
+
+ expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument();
+ });
+
+ test("does not write credentials or tokens to browser storage", async () => {
+ const storageWrites = (["setItem", "removeItem", "clear"] as const).map((method) =>
+ vi.spyOn(Storage.prototype, method));
+ render();
+
+ await screen.findByTestId("authenticated-shell");
+ for (const write of storageWrites) expect(write).not.toHaveBeenCalled();
+ for (const write of storageWrites) write.mockRestore();
+ });
+
+ test("does not emit act warnings while StrictMode authenticates", async () => {
+ const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ try {
+ render();
+ expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument();
+ expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i);
+ } finally {
+ errors.mockRestore();
+ }
+ });
+});
diff --git a/frontend/src/auth/AuthGate.tsx b/frontend/src/auth/AuthGate.tsx
new file mode 100644
index 00000000..19e25d86
--- /dev/null
+++ b/frontend/src/auth/AuthGate.tsx
@@ -0,0 +1,111 @@
+import { useCallback, useEffect, useState } from "react";
+import { ApiError } from "../api/client";
+import { authErrorStatus, getAuthConfig, getMe } from "../api/auth";
+import type { AuthenticatedUser, AuthPublicConfig } from "../api/types";
+import { AppShell } from "../shell/AppShell";
+import {
+ clearAuthStateIfCurrent,
+ getAuthGeneration,
+ getAuthState,
+ isAuthGenerationCurrent,
+ setAuthState,
+ useAuthGeneration,
+ useAuthUser,
+} from "./authState";
+import { LoginPage } from "./LoginPage";
+import { Button } from "../components/ui/button";
+
+type GateStatus = "loading" | "login" | "authenticated" | "forbidden" | "unavailable";
+
+function AuthenticatedContent({ onExpired }: { onExpired: () => void }) {
+ const user = useAuthUser();
+ const authGeneration = useAuthGeneration();
+ useEffect(() => {
+ if (!user) onExpired();
+ }, [onExpired, user]);
+ return user
+ ?
+ : null;
+}
+
+export function AuthGate() {
+ const [status, setStatus] = useState("loading");
+ const [config, setConfig] = useState();
+ const [attempt, setAttempt] = useState(0);
+
+ const retry = useCallback(() => setAttempt((value) => value + 1), []);
+
+ useEffect(() => {
+ let cancelled = false;
+ const load = async () => {
+ setStatus("loading");
+ const loadGeneration = getAuthGeneration();
+ try {
+ const publicConfig = await getAuthConfig();
+ if (cancelled || !isAuthGenerationCurrent(loadGeneration)) return;
+ setConfig(publicConfig);
+ try {
+ const authenticated = await getMe();
+ if (cancelled || !isAuthGenerationCurrent(loadGeneration)) return;
+ setAuthState(authenticated);
+ setStatus("authenticated");
+ } catch (error) {
+ if (cancelled) return;
+ const statusCode = authErrorStatus(error);
+ if (statusCode === 401) {
+ if (isAuthGenerationCurrent(loadGeneration)) clearAuthStateIfCurrent(loadGeneration);
+ if (getAuthState() === null) setStatus("login");
+ return;
+ }
+ if (!isAuthGenerationCurrent(loadGeneration)) return;
+ if (statusCode === 403) setStatus("forbidden");
+ else if (statusCode === 503) setStatus("unavailable");
+ else setStatus("login");
+ }
+ } catch (error) {
+ if (cancelled || !isAuthGenerationCurrent(loadGeneration)) return;
+ if (error instanceof ApiError && error.status === 503) setStatus("unavailable");
+ else setStatus("unavailable");
+ }
+ };
+ void load();
+ return () => { cancelled = true; };
+ }, [attempt]);
+
+ if (status === "loading") {
+ return Checking access…
;
+ }
+
+ if (status === "authenticated") {
+ return setStatus("login")} />;
+ }
+
+ if (status === "unavailable") {
+ return (
+
+
+ ThothII access
+ Authentication unavailable
+ The authentication provider could not be reached. Try again in a moment.
+
+
+
+ );
+ }
+
+ if (status === "forbidden") {
+ return (
+
+
+ ThothII access
+ Access not permitted
+ You are signed in without permission to use this workspace. Contact the installation administrator.
+
+
+ );
+ }
+
+ return config ? {
+ setStatus("authenticated");
+ }} onRetry={retry} /> : null;
+}
diff --git a/frontend/src/auth/LoginPage.test.tsx b/frontend/src/auth/LoginPage.test.tsx
new file mode 100644
index 00000000..37392323
--- /dev/null
+++ b/frontend/src/auth/LoginPage.test.tsx
@@ -0,0 +1,239 @@
+import { act, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { http, HttpResponse, delay } from "msw";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { StrictMode } from "react";
+import { LoginPage } from "./LoginPage";
+import { clearAuthState, setAuthState } from "./authState";
+import * as authApi from "../api/auth";
+import { server } from "../test/msw";
+
+const localConfig = { mode: "local", localLogin: true, oidcLogin: false } as const;
+const oidcConfig = { mode: "oidc", localLogin: false, oidcLogin: true } as const;
+const authenticated = {
+ issuer: "local",
+ subject: "admin-1",
+ roles: ["admin"] as const,
+ permissions: ["session.use", "pi.manage"],
+ isAdmin: true,
+ csrfToken: "c".repeat(43),
+ session: {
+ method: "local" as const,
+ remembered: true,
+ idleExpiresAt: "2026-08-17T10:00:00.000Z",
+ absoluteExpiresAt: "2026-09-16T10:00:00.000Z",
+ },
+};
+
+beforeEach(() => clearAuthState());
+afterEach(() => clearAuthState());
+
+describe("LoginPage", () => {
+ test("shows an unchecked local Remember me control and clears the password after failure", async () => {
+ let submittedPassword = "";
+ server.use(http.post("/api/auth/local/login", async ({ request }) => {
+ const body = await request.json() as { password: string; remember: boolean };
+ submittedPassword = body.password;
+ expect(body.remember).toBe(false);
+ return HttpResponse.json({ code: "invalid_credentials" }, { status: 401 });
+ }));
+
+ render();
+
+ expect(screen.getByRole("checkbox", { name: /remember me/i })).not.toBeChecked();
+ await userEvent.type(screen.getByLabelText(/username/i), "alice");
+ await userEvent.type(screen.getByLabelText(/password/i), "not-a-real-password");
+ await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(/invalid username or password/i);
+ expect(submittedPassword).toBe("not-a-real-password");
+ expect(screen.getByLabelText(/password/i)).toHaveValue("");
+ });
+
+ test("submits a remembered local login once and returns the safe user", async () => {
+ let requests = 0;
+ server.use(
+ http.post("/api/auth/local/login", async ({ request }) => {
+ requests += 1;
+ const body = await request.json() as { remember: boolean };
+ expect(body.remember).toBe(true);
+ await delay(20);
+ return HttpResponse.json({});
+ }),
+ http.get("/api/me", () => HttpResponse.json(authenticated)),
+ );
+ const onAuthenticated = vi.fn();
+ render();
+
+ await userEvent.click(screen.getByRole("checkbox", { name: /remember me/i }));
+ await userEvent.type(screen.getByLabelText(/username/i), "admin");
+ await userEvent.type(screen.getByLabelText(/password/i), "correct-password");
+ const submit = screen.getByRole("button", { name: /sign in/i });
+ await Promise.all([userEvent.click(submit), userEvent.click(submit)]);
+
+ expect(requests).toBe(1);
+ await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledWith(expect.objectContaining({ subject: "admin-1" })));
+ expect(screen.getByLabelText(/password/i)).toHaveValue("");
+ });
+
+ test("shows OIDC only when public configuration enables it and uses same-origin navigation", () => {
+ const { rerender } = render();
+ expect(screen.queryByRole("link", { name: /single sign-on/i })).not.toBeInTheDocument();
+
+ rerender();
+ expect(screen.getByRole("link", { name: /single sign-on/i })).toHaveAttribute(
+ "href", "/api/auth/oidc/login",
+ );
+ });
+
+ test("does not dispatch local login until an in-flight logout response settles", async () => {
+ let releaseLogout!: () => void;
+ let logoutStarted!: () => void;
+ let loginDispatched = false;
+ const logoutGate = new Promise((resolve) => { releaseLogout = resolve; });
+ const logoutRequest = new Promise((resolve) => { logoutStarted = resolve; });
+ server.use(
+ http.post("/api/auth/logout", async () => {
+ logoutStarted();
+ await logoutGate;
+ return new HttpResponse(null, { status: 204 });
+ }),
+ http.post("/api/auth/local/login", () => {
+ loginDispatched = true;
+ return HttpResponse.json({});
+ }),
+ http.get("/api/me", () => HttpResponse.json(authenticated)),
+ );
+ setAuthState({ ...authenticated, subject: "user-a" });
+ const logoutPromise = authApi.logout();
+ await logoutRequest;
+ const loginPromise = authApi.loginLocal("admin", "password", false);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(loginDispatched).toBe(false);
+ releaseLogout();
+ await expect(logoutPromise).resolves.toBe(true);
+ await loginPromise;
+ expect(loginDispatched).toBe(true);
+ });
+
+ test("a failed logout response does not deadlock the next local login", async () => {
+ let logoutCalls = 0;
+ let loginDispatched = false;
+ server.use(
+ http.post("/api/auth/logout", () => {
+ logoutCalls += 1;
+ return HttpResponse.json({ code: "auth_unavailable" }, { status: 503 });
+ }),
+ http.post("/api/auth/local/login", () => {
+ loginDispatched = true;
+ return HttpResponse.json({});
+ }),
+ http.get("/api/me", () => HttpResponse.json(authenticated)),
+ );
+ setAuthState({ ...authenticated, subject: "user-a" });
+ await expect(authApi.logout()).rejects.toMatchObject({ status: 503 });
+ await authApi.loginLocal("admin", "password", false);
+ expect(logoutCalls).toBe(1);
+ expect(loginDispatched).toBe(true);
+ });
+
+ test("uses explicit provider-unavailable copy with retry affordance", async () => {
+ server.use(http.post("/api/auth/local/login", () => HttpResponse.json(
+ { code: "auth_unavailable" }, { status: 503 },
+ )));
+ const retry = vi.fn();
+ render();
+
+ await userEvent.type(screen.getByLabelText(/username/i), "alice");
+ await userEvent.type(screen.getByLabelText(/password/i), "correct-password");
+ await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(/temporarily unavailable/i);
+ await userEvent.click(screen.getByRole("button", { name: /retry/i }));
+ expect(retry).toHaveBeenCalledOnce();
+ });
+
+ test("never writes the password, remember choice, or returned token to browser storage", async () => {
+ server.use(
+ http.post("/api/auth/local/login", () => HttpResponse.json({})),
+ http.get("/api/me", () => HttpResponse.json(authenticated)),
+ );
+ const storageWrites = (["setItem", "removeItem", "clear"] as const).map((method) =>
+ vi.spyOn(Storage.prototype, method));
+ render();
+ await userEvent.type(screen.getByLabelText(/username/i), "alice");
+ await userEvent.type(screen.getByLabelText(/password/i), "correct-password");
+ await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
+ await vi.waitFor(() => expect(screen.getByLabelText(/password/i)).toHaveValue(""));
+ for (const write of storageWrites) expect(write).not.toHaveBeenCalled();
+ for (const write of storageWrites) write.mockRestore();
+ });
+
+ test("settles a successful login on the current StrictMode mount", async () => {
+ server.use(
+ http.post("/api/auth/local/login", () => HttpResponse.json({})),
+ http.get("/api/me", () => HttpResponse.json(authenticated)),
+ );
+ const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const onAuthenticated = vi.fn();
+ try {
+ render();
+ await userEvent.type(screen.getByLabelText(/username/i), "admin");
+ await userEvent.type(screen.getByLabelText(/password/i), "correct-password");
+ await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
+ await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledOnce());
+ expect(screen.getByLabelText(/password/i)).toHaveValue("");
+ expect(screen.getByRole("button", { name: /sign in/i })).toBeEnabled();
+ expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i);
+ } finally {
+ errors.mockRestore();
+ }
+ });
+
+ test("settles a failed login and clears the password on the current StrictMode mount", async () => {
+ server.use(http.post("/api/auth/local/login", () => HttpResponse.json({ code: "invalid_credentials" }, { status: 401 })));
+ const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ try {
+ render();
+ await userEvent.type(screen.getByLabelText(/username/i), "admin");
+ await userEvent.type(screen.getByLabelText(/password/i), "wrong-password");
+ await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(/invalid username or password/i);
+ expect(screen.getByLabelText(/password/i)).toHaveValue("");
+ expect(screen.getByRole("button", { name: /sign in/i })).toBeEnabled();
+ expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i);
+ } finally {
+ errors.mockRestore();
+ }
+ });
+
+ test("does not update an unmounted StrictMode login attempt", async () => {
+ let release!: () => void;
+ const pending = new Promise((resolve) => { release = resolve; });
+ let settled!: () => void;
+ const settledAfterUnmount = new Promise((resolve) => { settled = resolve; });
+ const loginLocal = vi.spyOn(authApi, "loginLocal").mockImplementation(async () => {
+ try {
+ await pending;
+ throw new Error("deferred invalid credentials");
+ } finally {
+ settled();
+ }
+ });
+ const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ try {
+ const view = render();
+ await userEvent.type(screen.getByLabelText(/username/i), "admin");
+ await userEvent.type(screen.getByLabelText(/password/i), "wrong-password");
+ await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
+ view.unmount();
+ release();
+ await act(async () => { await settledAfterUnmount; });
+ expect(loginLocal).toHaveBeenCalledOnce();
+ expect(errors.mock.calls.flat().join(" ")).not.toMatch(/can't perform a react state update|not wrapped in act/i);
+ } finally {
+ loginLocal.mockRestore();
+ errors.mockRestore();
+ }
+ });
+});
diff --git a/frontend/src/auth/LoginPage.tsx b/frontend/src/auth/LoginPage.tsx
new file mode 100644
index 00000000..ed81f8bd
--- /dev/null
+++ b/frontend/src/auth/LoginPage.tsx
@@ -0,0 +1,161 @@
+import { useEffect, useRef, useState } from "react";
+import type { FormEvent } from "react";
+import { AlertTriangle, ArrowRight, LockKeyhole } from "lucide-react";
+import { ApiError } from "../api/client";
+import { loginLocal } from "../api/auth";
+import type { AuthenticatedUser, AuthPublicConfig } from "../api/types";
+import { Button } from "../components/ui/button";
+
+interface LoginPageProps {
+ config: AuthPublicConfig;
+ onAuthenticated: (user: AuthenticatedUser) => void;
+ onRetry?: () => void;
+}
+
+function loginError(error: unknown): { message: string; retry: boolean } {
+ if (error instanceof ApiError && error.status === 503) {
+ return { message: "Authentication is temporarily unavailable. Try again.", retry: true };
+ }
+ if (error instanceof ApiError && error.status === 403) {
+ return { message: "This sign-in request was rejected. Open ThothII from its configured address and try again.", retry: false };
+ }
+ return { message: "Invalid username or password.", retry: false };
+}
+
+export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps) {
+ const localLogin = config.mode === "local" && config.localLogin;
+ const formRef = useRef(null);
+ const passwordRef = useRef(null);
+ const mountedRef = useRef(true);
+ const submittingRef = useRef(false);
+ const attemptRef = useRef(0);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState<{ message: string; retry: boolean }>();
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => { mountedRef.current = false; };
+ }, []);
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ if (submittingRef.current) return;
+ const form = event.currentTarget;
+ const values = new FormData(form);
+ const username = String(values.get("username") ?? "");
+ const password = String(values.get("password") ?? "");
+ const remember = values.get("remember") === "on";
+ const attempt = ++attemptRef.current;
+ submittingRef.current = true;
+ setError(undefined);
+ setSubmitting(true);
+ try {
+ const user = await loginLocal(username, password, remember);
+ if (mountedRef.current && attemptRef.current === attempt) onAuthenticated(user);
+ } catch (failure) {
+ if (mountedRef.current && attemptRef.current === attempt) setError(loginError(failure));
+ } finally {
+ submittingRef.current = false;
+ if (!mountedRef.current || attemptRef.current !== attempt) return;
+ // Passwords never enter React state and are cleared after every attempt,
+ // including operational failures and successful authentication.
+ form.reset();
+ setSubmitting(false);
+ passwordRef.current?.focus();
+ }
+ }
+
+ return (
+
+
+
+ Secure re-entry · governed workspace
+
+ Return to the analytical ledger.
+
+
+ ThothII keeps each question, review decision, and SQL artifact inside a traceable human-in-the-loop workflow.
+
+
+
+ Access is checked before the workspace opens.
+
+
+
+
+
+
+
+ ThothII access
+
+
Sign in to ThothII
+
Use your installation account to continue.
+
+
+ {error && (
+
+
{error.message}
+ {error.retry && onRetry && (
+
+ )}
+
+ )}
+
+ {localLogin && (
+
+ )}
+
+ {config.oidcLogin && (
+
+ Continue with single sign-on
+
+
+ )}
+
+ {!localLogin && !config.oidcLogin && (
+
+ No browser sign-in method is enabled for this installation.
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/auth/authOperation.test.ts b/frontend/src/auth/authOperation.test.ts
new file mode 100644
index 00000000..ccbd92cf
--- /dev/null
+++ b/frontend/src/auth/authOperation.test.ts
@@ -0,0 +1,40 @@
+import { beforeEach, expect, test } from "vitest";
+import { clearAuthState, setAuthState } from "./authState";
+import { captureAuthOperation, isAuthOperationCurrent } from "./authOperation";
+
+const userA = {
+ issuer: "local", subject: "user-a", roles: ["user"] as const,
+ permissions: ["session.use"], isAdmin: false, csrfToken: "a".repeat(43), session: null,
+};
+
+beforeEach(() => {
+ clearAuthState();
+ setAuthState(userA);
+});
+
+test("captures identity, generation, session target, and disposal epoch", () => {
+ const operation = captureAuthOperation({ sessionId: "s1", disposalEpoch: 4 });
+
+ expect(operation).toMatchObject({
+ authGeneration: expect.any(Number),
+ issuer: "local",
+ subject: "user-a",
+ sessionId: "s1",
+ disposalEpoch: 4,
+ });
+ expect(isAuthOperationCurrent(operation!, { sessionId: "s1", disposalEpoch: 4 })).toBe(true);
+});
+
+test("rejects a changed identity, target session, or disposal epoch", () => {
+ const operation = captureAuthOperation({ sessionId: "s1", disposalEpoch: 4 });
+ setAuthState({ ...userA, subject: "user-b", csrfToken: "b".repeat(43) });
+
+ expect(isAuthOperationCurrent(operation!, { sessionId: "s1", disposalEpoch: 4 })).toBe(false);
+ expect(isAuthOperationCurrent(operation!, { sessionId: "s2", disposalEpoch: 4 })).toBe(false);
+ expect(isAuthOperationCurrent(operation!, { sessionId: "s1", disposalEpoch: 5 })).toBe(false);
+});
+
+test("does not start an authenticated operation without a principal", () => {
+ clearAuthState();
+ expect(captureAuthOperation()).toBeNull();
+});
diff --git a/frontend/src/auth/authOperation.ts b/frontend/src/auth/authOperation.ts
new file mode 100644
index 00000000..28316744
--- /dev/null
+++ b/frontend/src/auth/authOperation.ts
@@ -0,0 +1,65 @@
+import { getAuthGeneration, getAuthState } from "./authState";
+
+export type AuthOperationGuard = Readonly<{
+ authGeneration: number;
+ issuer: string;
+ subject: string;
+ sessionId: string | null;
+ disposalEpoch: number;
+}>;
+
+export type AuthOperationPrecondition = Readonly<{
+ operation: AuthOperationGuard;
+ isCurrent: () => boolean;
+}>;
+
+export class StaleAuthOperationError extends Error {
+ constructor() {
+ super("The authenticated operation is no longer current");
+ this.name = "StaleAuthOperationError";
+ }
+}
+
+export function captureAuthOperation(options: {
+ sessionId?: string | null;
+ disposalEpoch?: number;
+} = {}): AuthOperationGuard | null {
+ const user = getAuthState();
+ if (!user) return null;
+ return {
+ authGeneration: getAuthGeneration(),
+ issuer: user.issuer,
+ subject: user.subject,
+ sessionId: options.sessionId ?? null,
+ disposalEpoch: options.disposalEpoch ?? 0,
+ };
+}
+
+export function isAuthOperationCurrent(
+ operation: AuthOperationGuard | null,
+ options: { sessionId?: string | null; disposalEpoch: number },
+): boolean {
+ if (!operation) return false;
+ const user = getAuthState();
+ return Boolean(
+ user
+ && getAuthGeneration() === operation.authGeneration
+ && user.issuer === operation.issuer
+ && user.subject === operation.subject
+ && operation.sessionId === (options.sessionId ?? null)
+ && operation.disposalEpoch === options.disposalEpoch,
+ );
+}
+
+export function requireCurrentAuthOperation(
+ operation: AuthOperationGuard | null,
+ options: { sessionId?: string | null; disposalEpoch: number },
+): asserts operation is AuthOperationGuard {
+ if (!isAuthOperationCurrent(operation, options)) throw new StaleAuthOperationError();
+}
+
+export function requireAuthOperationPrecondition(
+ precondition: AuthOperationPrecondition | undefined,
+): void {
+ if (precondition && !precondition.isCurrent()) throw new StaleAuthOperationError();
+}
diff --git a/frontend/src/auth/authState.test.ts b/frontend/src/auth/authState.test.ts
new file mode 100644
index 00000000..b4437cc6
--- /dev/null
+++ b/frontend/src/auth/authState.test.ts
@@ -0,0 +1,57 @@
+import { http, HttpResponse } from "msw";
+import { afterEach, beforeEach, expect, test } from "vitest";
+import { apiFetch } from "../api/client";
+import { queryClient } from "../app/queryClient";
+import { server } from "../test/msw";
+import { useSessionStore } from "../store/sessionStore";
+import { clearAuthState, getAuthState, setAuthState } from "./authState";
+
+const userA = {
+ issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false,
+ csrfToken: "a".repeat(43), session: null,
+};
+const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) };
+
+beforeEach(() => {
+ queryClient.clear();
+ useSessionStore.getState().resetSession();
+ clearAuthState();
+});
+
+afterEach(() => {
+ queryClient.clear();
+ useSessionStore.getState().resetSession();
+ clearAuthState();
+});
+
+function seedUserAData() {
+ setAuthState(userA);
+ queryClient.setQueryData(["sessions", "mine"], [{ id: "a-session" }]);
+ useSessionStore.getState().applyEvent({ type: "text_delta", text: "A transcript" });
+}
+
+test("an ordinary API 401 scrubs A data before B can log in", async () => {
+ server.use(http.get("/api/ordinary-expiry", () => new HttpResponse(null, { status: 401 })));
+ seedUserAData();
+
+ await expect(apiFetch("/ordinary-expiry")).rejects.toMatchObject({ status: 401 });
+ expect(getAuthState()).toBeNull();
+ expect(queryClient.getQueryData(["sessions", "mine"])).toBeUndefined();
+ expect(useSessionStore.getState().transcript).toEqual([]);
+
+ setAuthState(userB);
+ expect(getAuthState()).toMatchObject({ subject: "user-b" });
+ expect(queryClient.getQueryData(["sessions", "mine"])).toBeUndefined();
+ expect(useSessionStore.getState().transcript).toEqual([]);
+});
+
+test("logout followed by B login cannot retain A cache or live transcript", () => {
+ seedUserAData();
+
+ clearAuthState();
+ setAuthState(userB);
+
+ expect(getAuthState()).toMatchObject({ subject: "user-b" });
+ expect(queryClient.getQueryData(["sessions", "mine"])).toBeUndefined();
+ expect(useSessionStore.getState().transcript).toEqual([]);
+});
diff --git a/frontend/src/auth/authState.ts b/frontend/src/auth/authState.ts
new file mode 100644
index 00000000..fb46a65e
--- /dev/null
+++ b/frontend/src/auth/authState.ts
@@ -0,0 +1,71 @@
+import { useSyncExternalStore } from "react";
+import type { AuthenticatedUser } from "../api/types";
+import { queryClient } from "../app/queryClient";
+import { useSessionStore } from "../store/sessionStore";
+
+let current: AuthenticatedUser | null = null;
+let generation = 0;
+const listeners = new Set<() => void>();
+
+function notify() {
+ for (const listener of listeners) listener();
+}
+
+function scrubUserBoundState(): void {
+ queryClient.clear();
+ useSessionStore.getState().resetSession();
+}
+
+/** Authentication is intentionally process-local; no browser storage is involved. */
+export function getAuthState(): AuthenticatedUser | null {
+ return current;
+}
+
+export function setAuthState(user: AuthenticatedUser): void {
+ scrubUserBoundState();
+ current = user;
+ generation += 1;
+ notify();
+}
+
+export function clearAuthState(): void {
+ scrubUserBoundState();
+ current = null;
+ generation += 1;
+ notify();
+}
+
+export function isAuthGenerationCurrent(expectedGeneration: number): boolean {
+ return generation === expectedGeneration;
+}
+
+export function clearAuthStateIfCurrent(expectedGeneration: number): boolean {
+ if (!isAuthGenerationCurrent(expectedGeneration)) return false;
+ clearAuthState();
+ return true;
+}
+
+export function getAuthGeneration(): number {
+ return generation;
+}
+
+export function subscribeAuthState(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+export function useAuthState(): AuthenticatedUser | null {
+ return useSyncExternalStore(subscribeAuthState, getAuthState, getAuthState);
+}
+
+export function useAuthUser(): AuthenticatedUser | null {
+ return useAuthState();
+}
+
+export function useAuthGeneration(): number {
+ return useSyncExternalStore(subscribeAuthState, getAuthGeneration, getAuthGeneration);
+}
+
+export function hasPermission(user: Pick | null | undefined, permission: string): boolean {
+ return user?.permissions.includes(permission) ?? false;
+}
diff --git a/frontend/src/shell/AppShell.auth.test.tsx b/frontend/src/shell/AppShell.auth.test.tsx
new file mode 100644
index 00000000..3a13d381
--- /dev/null
+++ b/frontend/src/shell/AppShell.auth.test.tsx
@@ -0,0 +1,210 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { http, HttpResponse } from "msw";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { AppShell } from "./AppShell";
+import { clearAuthState, getAuthGeneration, getAuthState, setAuthState, useAuthGeneration, useAuthUser } from "../auth/authState";
+import { server } from "../test/msw";
+import { useSessionStore } from "../store/sessionStore";
+
+function renderShell(user: {
+ subject: string;
+ isAdmin: boolean;
+ roles: readonly ("user" | "admin")[];
+ permissions: readonly string[];
+}) {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ setAuthState({
+ issuer: "local", ...user, csrfToken: null, session: null,
+ });
+ return render();
+}
+
+function KeyedAuthenticatedShell() {
+ const user = useAuthUser();
+ const generation = useAuthGeneration();
+ return user ? : null;
+}
+
+beforeEach(() => {
+ clearAuthState();
+ useSessionStore.getState().resetSession();
+ server.use(
+ http.get("/api/sessions", () => HttpResponse.json([])),
+ http.get("/api/settings", () => HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
+ http.get("/api/workspaces", () => HttpResponse.json([])),
+ http.get("/api/workspace-registry/status", () => HttpResponse.json({
+ branch: "main", ahead: 0, behind: 0, degraded: false,
+ })),
+ http.get("/api/models", () => HttpResponse.json({ models: [] })),
+ http.get("/api/health/dwh", () => HttpResponse.json({ ok: true })),
+ );
+});
+
+describe("authenticated shell permissions", () => {
+ test("shows only read-safe workspace chrome to a session user", async () => {
+ renderShell({ subject: "user-1", isAdmin: false, roles: ["user"], permissions: ["session.use"] });
+
+ expect(await screen.findByRole("button", { name: "Workspace management" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "All sessions" })).not.toBeInTheDocument();
+ });
+
+ test("shows management and all-session chrome only for exact permissions", async () => {
+ renderShell({
+ subject: "admin-1",
+ isAdmin: true,
+ roles: ["admin"],
+ permissions: ["session.use", "session.read_all", "workspace.manage", "workspace.secrets.manage", "pi.manage"],
+ });
+
+ expect(await screen.findByRole("button", { name: "Pi management" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "All sessions" })).toBeInTheDocument();
+ });
+
+ test("logout revokes the cookie session and clears in-memory auth", async () => {
+ const user = {
+ issuer: "local" as const,
+ subject: "admin-1",
+ roles: ["admin"] as const,
+ permissions: ["session.use", "pi.manage"],
+ isAdmin: true,
+ csrfToken: "c".repeat(43),
+ session: null,
+ };
+ setAuthState(user);
+ let logoutCalls = 0;
+ server.use(http.post("/api/auth/logout", () => {
+ logoutCalls += 1;
+ return new HttpResponse(null, { status: 204 });
+ }));
+ renderShell(user);
+
+ await userEvent.click(screen.getByRole("button", { name: "Log out" }));
+
+ await vi.waitFor(() => expect(logoutCalls).toBe(1));
+ expect(getAuthState()).toBeNull();
+ });
+
+ test("a stale logout continuation cannot scrub user B after the logout response settles", async () => {
+ const userA = {
+ issuer: "local" as const, subject: "user-a", roles: ["user"] as const,
+ permissions: ["session.use"] as const, isAdmin: false,
+ csrfToken: "a".repeat(43), session: null,
+ };
+ const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) };
+ let releaseLogout!: () => void;
+ let logoutStarted!: () => void;
+ let logoutSettled!: () => void;
+ const logoutGate = new Promise((resolve) => { releaseLogout = resolve; });
+ const started = new Promise((resolve) => { logoutStarted = resolve; });
+ const settled = new Promise((resolve) => { logoutSettled = resolve; });
+ server.use(http.post("/api/auth/logout", async () => {
+ logoutStarted();
+ try {
+ await logoutGate;
+ return new HttpResponse(null, { status: 204 });
+ } finally {
+ logoutSettled();
+ }
+ }));
+
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ setAuthState(userA);
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Log out" }));
+ await started;
+
+ act(() => setAuthState(userB));
+ act(() => {
+ client.setQueryData(["b-only"], { owner: "user-b" });
+ useSessionStore.getState().applyEvent({ type: "text_delta", text: "B transcript" });
+ });
+ releaseLogout();
+ await act(async () => { await settled; });
+
+ expect(getAuthState()).toMatchObject({ subject: "user-b" });
+ expect(client.getQueryData(["b-only"])).toEqual({ owner: "user-b" });
+ expect(useSessionStore.getState().transcript).toEqual([{ role: "assistant", text: "B transcript" }]);
+ expect(screen.getByRole("button", { name: "Log out" })).toBeInTheDocument();
+ });
+
+ test("handles a failed shell logout without an unhandled rejection", async () => {
+ const user = {
+ issuer: "local" as const, subject: "user-a", roles: ["user"] as const,
+ permissions: ["session.use"] as const, isAdmin: false,
+ csrfToken: "a".repeat(43), session: null,
+ };
+ const rejection = vi.fn();
+ process.on("unhandledRejection", rejection);
+ server.use(http.post("/api/auth/logout", () => HttpResponse.json(
+ { code: "auth_unavailable" }, { status: 503 },
+ )));
+ try {
+ renderShell(user);
+ await userEvent.click(screen.getByRole("button", { name: "Log out" }));
+ await waitFor(() => expect(getAuthState()).toBeNull());
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(rejection).not.toHaveBeenCalled();
+ } finally {
+ process.off("unhandledRejection", rejection);
+ }
+ });
+
+ test("permission chrome follows current auth state after a stale admin identity disappears", async () => {
+ renderShell({
+ subject: "admin-1", isAdmin: true, roles: ["admin"],
+ permissions: ["session.use", "session.read_all", "workspace.manage", "pi.manage"],
+ });
+ expect(await screen.findByRole("button", { name: "Pi management" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "All sessions" })).toBeInTheDocument();
+
+ act(() => clearAuthState());
+ expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "All sessions" })).not.toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
+ expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Update workspace repository" })).not.toBeInTheDocument();
+ });
+
+ test("remounts the real shell so user-A panel and transcript state cannot survive user-B", async () => {
+ const userA = {
+ issuer: "local" as const,
+ subject: "user-a",
+ roles: ["user"] as const,
+ permissions: ["session.use"] as const,
+ isAdmin: false,
+ };
+ const userB = { ...userA, subject: "user-b" };
+ const panelSession = {
+ id: "panel-a", status: "finalized", question: "User A governed question", summary: null,
+ created_at: "2026-08-17T10:00:00Z", updated_at: null, author: "user-a", name: null,
+ group: null, archived: false,
+ };
+ server.use(
+ http.get("/api/sessions", () => HttpResponse.json([panelSession])),
+ http.get("/api/sessions/panel-a/documents", () => HttpResponse.json([
+ { key: "question", title: "Question", phase: "F1", format: "markdown", content: "A-private-document" },
+ ])),
+ );
+ setAuthState({ ...userA, csrfToken: null, session: null });
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ function renderUserShell() {
+ return render();
+ }
+ renderUserShell();
+
+ await userEvent.click(await screen.findByTestId("session-item-panel-a"));
+ expect(await screen.findByText("A-private-document")).toBeInTheDocument();
+ act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: "A-private-transcript" }));
+ expect(useSessionStore.getState().transcript).toEqual([{ role: "assistant", text: "A-private-transcript" }]);
+
+ act(() => setAuthState({ ...userB, csrfToken: null, session: null }));
+
+ await waitFor(() => expect(screen.queryByText("A-private-document")).not.toBeInTheDocument());
+ expect(screen.queryByRole("heading", { name: "User A governed question" })).not.toBeInTheDocument();
+ expect(useSessionStore.getState().transcript).toEqual([]);
+ expect(getAuthGeneration()).toBeGreaterThan(0);
+ });
+});
diff --git a/frontend/src/shell/AppShell.new-session.test.tsx b/frontend/src/shell/AppShell.new-session.test.tsx
index 737c91a1..e570ffd4 100644
--- a/frontend/src/shell/AppShell.new-session.test.tsx
+++ b/frontend/src/shell/AppShell.new-session.test.tsx
@@ -6,6 +6,7 @@ import { server } from "../test/msw";
import { FakeEventSource } from "../test/fakeEventSource";
import { useSessionStore } from "../store/sessionStore";
import { AppShell } from "./AppShell";
+import { clearAuthState, setAuthState } from "../auth/authState";
import { workspacePreferences } from "../workspaces/preferences";
function renderShell() {
@@ -14,6 +15,11 @@ function renderShell() {
}
beforeEach(() => {
+ clearAuthState();
+ setAuthState({
+ issuer: "test", subject: "test", roles: ["user"], permissions: ["session.use"],
+ isAdmin: false, csrfToken: null, session: null,
+ });
localStorage.clear();
FakeEventSource.instances = [];
(globalThis as any).EventSource = FakeEventSource;
diff --git a/frontend/src/shell/AppShell.notifications.test.tsx b/frontend/src/shell/AppShell.notifications.test.tsx
index 4555c9ab..9b1e9e74 100644
--- a/frontend/src/shell/AppShell.notifications.test.tsx
+++ b/frontend/src/shell/AppShell.notifications.test.tsx
@@ -18,6 +18,7 @@ beforeEach(() => {
});
test("session errors queued in the store become visible notifications", async () => {
render();
+ await screen.findByTestId("app-shell");
act(() => useSessionStore.getState().pushToast({
level: "error",
diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx
index 16cd5fff..3b2b3550 100644
--- a/frontend/src/shell/AppShell.session-mgmt.test.tsx
+++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx
@@ -5,10 +5,22 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { server } from "../test/msw";
import { FakeEventSource } from "../test/fakeEventSource";
import { AppShell } from "./AppShell";
+import type { AuthenticatedUser } from "../api/types";
+import { clearAuthState, setAuthState } from "../auth/authState";
import { useSessionStore } from "../store/sessionStore";
import { ACTIVITY_PANEL_STORAGE_KEY } from "./useActivityPanelResize";
-function wrap() {
+const regularUser: AuthenticatedUser = {
+ issuer: "portal", subject: "alice", displayName: "Alice", roles: ["user"] as const,
+ permissions: ["session.use", "pi.manage"] as const, isAdmin: false, csrfToken: null, session: null,
+};
+const adminUser: AuthenticatedUser = {
+ ...regularUser, subject: "alice-id", roles: ["admin"] as const,
+ permissions: ["session.use", "session.read_all", "pi.manage"] as const, isAdmin: true,
+};
+
+function wrap(user: AuthenticatedUser = regularUser) {
+ if (user !== regularUser) setAuthState(user);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render();
}
@@ -47,6 +59,8 @@ function deferred() {
}
beforeEach(() => {
+ clearAuthState();
+ setAuthState(regularUser);
localStorage.clear();
FakeEventSource.instances = [];
ControlledResizeObserver.instances = [];
@@ -57,13 +71,18 @@ beforeEach(() => {
useSessionStore.getState().resetSession();
server.use(
http.get("/api/me", () =>
- HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false }),
+ HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false, permissions: ["session.use", "pi.manage"] }),
),
+ http.get("/api/settings", () => HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" })),
+ http.get("/api/workspaces", () => HttpResponse.json([])),
+ http.get("/api/models", () => HttpResponse.json({ models: [] })),
http.get("/api/sessions", () => HttpResponse.json(LIST)),
+ http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
http.get("/api/sessions/:id/documents", () => HttpResponse.json([
{ phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Attiva uno" },
])),
http.post("/api/sessions/:id/archive", () => new HttpResponse(null, { status: 204 })),
+ http.post("/api/runtime/prewarm", () => new HttpResponse(null, { status: 202 })),
);
});
@@ -135,7 +154,7 @@ test("administrators can explicitly switch to all sessions and see owners", asyn
]);
}),
);
- wrap();
+ wrap(adminUser);
await screen.findByRole("button", { name: "All sessions" });
expect(screen.getByRole("button", { name: "My sessions" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "All sessions" })).toHaveAttribute("aria-pressed", "false");
@@ -162,7 +181,7 @@ test("administrator confirms before deleting a same-named user's session", async
return new HttpResponse(null, { status: 204 });
}),
);
- wrap();
+ wrap(adminUser);
await userEvent.click(await screen.findByRole("button", { name: "All sessions" }));
await screen.findByText("Owner: Alice");
await userEvent.click(screen.getByRole("checkbox", { name: "Select Attiva uno" }));
@@ -188,7 +207,7 @@ test("administrator confirms before archiving a same-named user's session", asyn
return new HttpResponse(null, { status: 204 });
}),
);
- wrap();
+ wrap(adminUser);
await userEvent.click(await screen.findByRole("button", { name: "All sessions" }));
await screen.findByText("Owner: Alice");
await userEvent.click(screen.getByRole("button", { name: "Session actions" }));
@@ -735,6 +754,63 @@ test("starting a new question invalidates a pending Resume intent", async () =>
expect(screen.getByText(/type your question/i)).toBeInTheDocument();
});
+test("a delayed Resume from user A cannot activate a session after user B logs in", async () => {
+ const resumeGate = deferred();
+ const resumeStarted = deferred();
+ const resumeSettled = deferred();
+ server.use(
+ http.post("/api/sessions/:id/resume", async () => {
+ resumeStarted.resolve();
+ try {
+ await resumeGate.promise;
+ return resumeResult("s1");
+ } finally {
+ resumeSettled.resolve();
+ }
+ }),
+ http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 4 })),
+ );
+ wrap();
+
+ await userEvent.click(await screen.findByText("Attiva uno"));
+ await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
+ await resumeStarted.promise;
+ act(() => setAuthState({ ...regularUser, subject: "bob", displayName: "Bob" }));
+ resumeGate.resolve();
+ await act(async () => { await resumeSettled.promise; });
+ expect(FakeEventSource.instances).toHaveLength(0);
+ expect(useSessionStore.getState().currentPhase).toBeNull();
+ expect(screen.queryByRole("button", { name: "Hide model activity" })).not.toBeInTheDocument();
+});
+
+test("a delayed session deletion from user A cannot refresh or toast into user B", async () => {
+ const deleteGate = deferred();
+ const deleteStarted = deferred();
+ const deleteSettled = deferred();
+ server.use(http.delete("/api/sessions/:id", async () => {
+ deleteStarted.resolve();
+ try {
+ await deleteGate.promise;
+ return new HttpResponse(null, { status: 204 });
+ } finally {
+ deleteSettled.resolve();
+ }
+ }));
+ wrap();
+
+ await screen.findByText("Attiva uno");
+ screen.getByRole("checkbox", { name: "Select Attiva uno" }).focus();
+ await userEvent.keyboard(" ");
+ await userEvent.click(await screen.findByRole("button", { name: "Delete 1 selected sessions" }));
+ await deleteStarted.promise;
+ act(() => setAuthState({ ...regularUser, subject: "bob", displayName: "Bob" }));
+ deleteGate.resolve();
+ await act(async () => { await deleteSettled.promise; });
+ expect(useSessionStore.getState().toasts).toEqual([]);
+ expect(screen.queryByText("Deleted 1 of 1 sessions.")).not.toBeInTheDocument();
+ expect(screen.getByTestId("session-item-s1")).toBeInTheDocument();
+});
+
test("a successful Delete invalidates an earlier pending Resume for the same target", async () => {
const resumeGate = deferred();
const resumeStarted = deferred();
diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx
index ecc7ab8a..889ffabc 100644
--- a/frontend/src/shell/AppShell.tsx
+++ b/frontend/src/shell/AppShell.tsx
@@ -21,19 +21,23 @@ import { Checkbox } from "../components/ui/checkbox";
import { Toaster } from "../components/ui/sonner";
import { toast } from "sonner";
import {
- closeSession, getMe, listSessions, resumeSession, getSession,
+ closeSession, listSessions, resumeSession, getSession,
renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, prewarmRuntime,
checkDwhHealth,
} from "../api/sessions";
+import { logout as logoutUser } from "../api/auth";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "../components/ui/dialog";
-import type { Principal, SessionScope, SessionSummary } from "../api/types";
+import type { SessionScope, SessionSummary } from "../api/types";
+import { useAuthGeneration, useAuthUser } from "../auth/authState";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
+import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError, type AuthOperationGuard } from "../auth/authOperation";
export function AppShell() {
+ const authenticatedUser = useAuthUser();
const [panelSession, setPanelSession] = useState(null);
const {
containerRef,
@@ -81,14 +85,29 @@ export function AppShell() {
const resumeInFlightRef = useRef(new Map;
+ guard: AuthOperationGuard;
}>());
const [streamCursorResetEpoch, setStreamCursorResetEpoch] = useState(0);
const [creatingSession, setCreatingSession] = useState(false);
const [awaitingQuestion, setAwaitingQuestion] = useState(false);
const [sessionScope, setSessionScope] = useState("mine");
- const { data: principal } = useQuery({ queryKey: ["me"], queryFn: getMe, staleTime: Infinity });
+ const principal = authenticatedUser;
+ const permissions = authenticatedUser?.permissions ?? [];
+ const canReadAllSessions = permissions.includes("session.read_all");
+ const canManageWorkspace = permissions.includes("workspace.manage");
+ const canManageWorkspaceSecrets = permissions.includes("workspace.secrets.manage");
+ const canManagePi = permissions.includes("pi.manage");
+ const authGeneration = useAuthGeneration();
const { data: sessions = [] } = useQuery({
- queryKey: ["sessions", sessionScope], queryFn: () => listSessions(sessionScope), refetchInterval: 10_000,
+ queryKey: ["sessions", sessionScope],
+ queryFn: async () => {
+ const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
+ if (!guard) throw new StaleAuthOperationError();
+ const result = await listSessions(sessionScope);
+ if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) throw new StaleAuthOperationError();
+ return result;
+ },
+ refetchInterval: 10_000,
});
const composerRef = useRef(null);
@@ -107,11 +126,15 @@ export function AppShell() {
const [dwhDown, setDwhDown] = useState(false);
const [dwhChecking, setDwhChecking] = useState(true);
const [dwhCheckEpoch, setDwhCheckEpoch] = useState(0);
+ const operationEpochRef = useRef(0);
+ useEffect(() => () => { operationEpochRef.current += 1; }, []);
useEffect(() => {
let cancelled = false;
+ const operation = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
+ if (!operation) return () => { cancelled = true; };
setDwhChecking(true);
checkDwhHealth().then((r) => {
- if (cancelled) return;
+ if (cancelled || !isAuthOperationCurrent(operation, { disposalEpoch: operationEpochRef.current })) return;
setDwhDown(!r.ok);
setDwhChecking(false);
});
@@ -125,7 +148,10 @@ export function AppShell() {
const activeList = sessions.filter((s) => !s.archived);
const ungroupedActive = activeList.filter((s) => !s.group);
const archivedList = sessions.filter((s) => s.archived);
- const refresh = () => queryClient.invalidateQueries({ queryKey: ["sessions"] });
+ const refresh = (operation?: AuthOperationGuard | null) => {
+ if (operation && !isAuthOperationCurrent(operation, { disposalEpoch: operationEpochRef.current })) return;
+ void queryClient.invalidateQueries({ queryKey: ["sessions"] });
+ };
const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null;
const finalized = activeSession?.status === "finalized";
const selectedSessions = sessions.filter((session) => selectedSessionIds.has(session.id));
@@ -158,6 +184,10 @@ export function AppShell() {
});
}, [sessions]);
+ useEffect(() => {
+ if (!canReadAllSessions && sessionScope === "all") setSessionScope("mine");
+ }, [canReadAllSessions, sessionScope]);
+
function setSessionSelected(id: string, selected: boolean) {
setSelectedSessionIds((current) => {
const next = new Set(current);
@@ -193,10 +223,12 @@ export function AppShell() {
});
}
async function doResume(id: string) {
+ const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
+ if (!guard) return;
const token = ++resumeInvocationRef.current;
latestResumeIntentRef.current = { token, id };
const inFlight = resumeInFlightRef.current.get(id);
- if (inFlight) {
+ if (inFlight && isAuthOperationCurrent(inFlight.guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) {
// Repeated intent for the same target shares one backend lifecycle operation and one
// commit path. Updating its token still lets s1→s2→s1 make the final s1 intent authoritative.
inFlight.latestToken = token;
@@ -206,8 +238,9 @@ export function AppShell() {
const operation = {
latestToken: token,
promise: Promise.resolve(),
+ guard,
};
- operation.promise = runResume(id, operation).finally(() => {
+ operation.promise = runResume(id, operation, guard).finally(() => {
if (resumeInFlightRef.current.get(id) === operation) {
resumeInFlightRef.current.delete(id);
}
@@ -219,9 +252,11 @@ export function AppShell() {
async function runResume(
id: string,
operation: { latestToken: number; promise: Promise },
+ guard: AuthOperationGuard,
) {
try {
const result = await resumeSession(id);
+ if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
const latest = latestResumeIntentRef.current;
if (latest?.token !== operation.latestToken || latest.id !== id) return;
const reconnectSameSession = activeSessionIdRef.current === id;
@@ -254,6 +289,7 @@ export function AppShell() {
// The manifest's `phase` is the 1-based current phase (1..8).
try {
const m = (await getSession(id)) as { phase?: number };
+ if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
const latestAfterManifest = latestResumeIntentRef.current;
if (
latestAfterManifest?.token !== operation.latestToken
@@ -267,7 +303,7 @@ export function AppShell() {
/* non-fatal: the first gate will set the phase */
}
} catch {
- if (
+ if (isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current }) &&
latestResumeIntentRef.current?.token === operation.latestToken
&& latestResumeIntentRef.current.id === id
) {
@@ -276,41 +312,73 @@ export function AppShell() {
}
}
async function move(s: SessionSummary, group: string) {
+ const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current });
+ if (!guard) return;
try {
- await setSessionGroup(s.id, group); refresh();
+ await setSessionGroup(s.id, group);
+ if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return;
+ refresh(guard);
} catch {
- toast.error("Failed to move session.");
+ if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error("Failed to move session.");
}
}
async function newGroup(s: SessionSummary) {
const name = window.prompt("New group:");
if (name && name.trim()) {
+ const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current });
+ if (!guard) return;
try {
- await setSessionGroup(s.id, name.trim()); refresh();
+ await setSessionGroup(s.id, name.trim());
+ if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return;
+ refresh(guard);
} catch {
- toast.error("Failed to update group.");
+ if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error("Failed to update group.");
}
}
}
- async function renameGroup(oldName: string, newName: string) {
+ async function renameGroup(oldName: string, newName: string, suppliedGuard?: AuthOperationGuard | null) {
const trimmed = newName.trim();
- if (!trimmed || trimmed === oldName) return;
+ if (!trimmed || trimmed === oldName) return false;
+ const guard = suppliedGuard ?? captureAuthOperation({ disposalEpoch: operationEpochRef.current });
+ if (!guard) return false;
try {
for (const s of sessions.filter((x) => x.group === oldName)) {
await setSessionGroup(s.id, trimmed);
+ if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return false;
}
- refresh();
+ refresh(guard);
+ return true;
} catch {
- toast.error("Failed to rename group.");
+ if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error("Failed to rename group.");
+ return false;
+ }
+ }
+ async function renameSessionFromDialog(id: string, name: string) {
+ const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
+ if (!guard) return;
+ try {
+ await renameSession(id, name);
+ if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
+ setRenameTarget(null);
+ refresh(guard);
+ } catch {
+ if (isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) {
+ toast.error("Failed to rename session.");
+ }
}
}
async function toggleArchive(s: SessionSummary) {
+ const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current });
+ if (!guard) return;
try {
await (s.archived ? unarchiveSession(s.id) : archiveSession(s.id));
+ if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return;
if (panelSession?.id === s.id) setPanelSession(null);
- refresh();
+ refresh(guard);
} catch {
- toast.error(s.archived ? "Failed to restore session." : "Failed to archive session.");
+ if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) {
+ toast.error(s.archived ? "Failed to restore session." : "Failed to archive session.");
+ }
}
}
@@ -323,8 +391,11 @@ export function AppShell() {
}
async function deleteSessions(targets: SessionSummary[]) {
+ const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
+ if (!guard) return;
try {
const results = await Promise.allSettled(targets.map((session) => deleteSession(session.id)));
+ if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return;
const deletedIds = new Set(
targets.filter((_, index) => results[index].status === "fulfilled").map((session) => session.id),
);
@@ -334,12 +405,12 @@ export function AppShell() {
if (deletedIds.has(panelSession?.id ?? "")) setPanelSession(null);
if (deletedActiveSession) { resetSession(); selectActiveSession(null); }
setSelectedSessionIds((current) => new Set([...current].filter((id) => !deletedIds.has(id))));
- refresh();
+ refresh(guard);
if (deletedIds.size !== targets.length) {
toast.error(`Deleted ${deletedIds.size} of ${targets.length} sessions.`);
}
} catch {
- toast.error("Failed to delete selected sessions.");
+ if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error("Failed to delete selected sessions.");
}
}
@@ -401,7 +472,13 @@ export function AppShell() {
// session sits idle or a gate awaits the reviewer (pendingWidget).
const running = working && !finalized;
- useSessionStream(activeSessionId, 0, streamCursorResetEpoch);
+ useSessionStream(
+ activeSessionId,
+ 0,
+ streamCursorResetEpoch,
+ Boolean(authenticatedUser),
+ authGeneration,
+ );
// A backend "session_exit" system event (e.g. the replay server emitting it
// when the reviewer picks "Esci") asks us to leave the live session view and
@@ -463,16 +540,24 @@ export function AppShell() {
async function stopSession() {
if (!activeSessionId) return;
+ const id = activeSessionId;
+ const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
+ if (!guard) return;
invalidateResumeIntent();
try {
- await closeSession(activeSessionId);
+ await closeSession(id);
} finally {
+ if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
resetSession();
selectActiveSession(null);
setAwaitingQuestion(false);
}
}
+ async function signOut() {
+ await logoutUser();
+ }
+
return (
Human In The Loop
+ {authenticatedUser && (
+
+
+ {authenticatedUser.displayName ?? authenticatedUser.subject}
+
+
+
+ )}
@@ -630,17 +723,19 @@ export function AppShell() {
>
Workspace management
-
+ {canManagePi && (
+
+ )}
- {principal?.isAdmin && (
+ {canReadAllSessions && (
Test workspace connections
@@ -408,13 +474,13 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
))}
)}
-
{ void testConnections(); }}>
+ {canManageWorkspace && { void testConnections(); }}>
Test workspace connections
-
+ }
-
+ {canManageSecrets &&
@@ -478,7 +544,12 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
)}
-
+
}
+ {!canManageSecrets && (
+
+ Runtime secret controls are available only to authorized workspace operators.
+
+ )}
>
)}
diff --git a/frontend/src/shell/f1-loop.test.tsx b/frontend/src/shell/f1-loop.test.tsx
index 4b3b43e3..27b7d8dd 100644
--- a/frontend/src/shell/f1-loop.test.tsx
+++ b/frontend/src/shell/f1-loop.test.tsx
@@ -33,7 +33,7 @@ test("F1: create session -> widget via SSE -> respond -> POST /response", async
render();
// Type the first question directly in the sticky composer and send it
- const composer = screen.getByRole("textbox", { name: /new question/i });
+ const composer = await screen.findByRole("textbox", { name: /new question/i });
await userEvent.type(composer, "Quante vendite?");
await userEvent.click(screen.getByRole("button", { name: /send/i }));
diff --git a/frontend/src/stream/useSessionStream.test.tsx b/frontend/src/stream/useSessionStream.test.tsx
index 70b604eb..da698f2a 100644
--- a/frontend/src/stream/useSessionStream.test.tsx
+++ b/frontend/src/stream/useSessionStream.test.tsx
@@ -1,13 +1,17 @@
-import { renderHook } from "@testing-library/react";
+import { renderHook, waitFor } from "@testing-library/react";
import { act, useLayoutEffect } from "react";
import { FakeEventSource } from "../test/fakeEventSource";
import { createStreamEventCoalescer, useSessionStream } from "./useSessionStream";
import { useSessionStore } from "../store/sessionStore";
+import { clearAuthState, getAuthGeneration, getAuthState, setAuthState } from "../auth/authState";
+import { http, HttpResponse } from "msw";
+import { server } from "../test/msw";
beforeEach(() => {
FakeEventSource.instances = [];
(globalThis as any).EventSource = FakeEventSource;
useSessionStore.getState().resetSession();
+ clearAuthState();
});
test("coalesces a burst of text deltas into bounded store updates", () => {
@@ -32,6 +36,73 @@ test("coalesces a burst of text deltas into bounded store updates", () => {
}
});
+test("disposal discards buffered deltas instead of flushing them", () => {
+ vi.useFakeTimers();
+ try {
+ const applied: Array<{ type: string; text?: string }> = [];
+ const coalescer = createStreamEventCoalescer((event) => applied.push(event), 100);
+
+ coalescer.push({ type: "text_delta", text: "a" });
+ coalescer.push({ type: "text_delta", text: "b" });
+ coalescer.dispose();
+ vi.advanceTimersByTime(100);
+
+ expect(applied).toEqual([{ type: "text_delta", text: "a" }]);
+ } finally {
+ vi.useRealTimers();
+ }
+});
+
+test("a stale coalescer timer checks source validity before applying text", () => {
+ vi.useFakeTimers();
+ try {
+ let current = true;
+ const applied: Array<{ type: string; text?: string }> = [];
+ const coalescer = createStreamEventCoalescer((event) => applied.push(event), 100, () => current);
+
+ coalescer.push({ type: "text_delta", text: "a" });
+ coalescer.push({ type: "text_delta", text: "b" });
+ current = false;
+ vi.advanceTimersByTime(100);
+
+ expect(applied).toEqual([{ type: "text_delta", text: "a" }]);
+ coalescer.dispose();
+ } finally {
+ vi.useRealTimers();
+ }
+});
+
+test("buffered user-A text cannot reappear after logout and user-B login", () => {
+ vi.useFakeTimers();
+ try {
+ const userA = {
+ issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false,
+ csrfToken: "a".repeat(43), session: null,
+ };
+ const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) };
+ server.use(http.get("/api/me", () => new HttpResponse(null, { status: 401 })));
+ setAuthState(userA);
+ const { rerender } = renderHook(
+ ({ authGeneration }) => useSessionStream("s1", 0, 0, true, authGeneration),
+ { initialProps: { authGeneration: getAuthGeneration() } },
+ );
+ const sourceA = FakeEventSource.instances[0];
+ act(() => {
+ sourceA.emit({ type: "text_delta", text: "A-one" });
+ sourceA.emit({ type: "text_delta", text: "A-two" });
+ sourceA.onerror?.(new Event("error"));
+ setAuthState(userB);
+ });
+ rerender({ authGeneration: getAuthGeneration() });
+ vi.advanceTimersByTime(1000);
+
+ expect(useSessionStore.getState().transcript).toEqual([]);
+ expect(FakeEventSource.instances[0].closed).toBe(true);
+ } finally {
+ vi.useRealTimers();
+ }
+});
+
test("flushes pending stream text before a structural event", () => {
vi.useFakeTimers();
try {
@@ -280,3 +351,130 @@ test("the old source is invalid before later layout effects can deliver a queued
expect(first.closed).toBe(true);
expect(useSessionStore.getState().transcript).toEqual([]);
});
+
+test("auth-generation changes close the old source, reset live state, and do not reconnect after logout", () => {
+ setAuthState({
+ issuer: "local", subject: "user-1", roles: ["user"], permissions: ["session.use"], isAdmin: false,
+ csrfToken: "c".repeat(43), session: null,
+ });
+ const { rerender } = renderHook(
+ ({ authenticated, authGeneration }) => useSessionStream("s1", 0, 0, authenticated, authGeneration),
+ { initialProps: { authenticated: true, authGeneration: getAuthGeneration() } },
+ );
+ const first = FakeEventSource.instances[0];
+ act(() => first.emitNamed("text_delta", { type: "text_delta", text: "stale" }));
+ expect(useSessionStore.getState().transcript).toHaveLength(1);
+
+ clearAuthState();
+ rerender({ authenticated: false, authGeneration: getAuthGeneration() });
+
+ expect(first.closed).toBe(true);
+ expect(FakeEventSource.instances).toHaveLength(1);
+ expect(useSessionStore.getState().transcript).toEqual([]);
+});
+
+test("a stale source error cannot clear or mutate the next user's live session", async () => {
+ const userA = {
+ issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false,
+ csrfToken: "a".repeat(43), session: null,
+ };
+ const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) };
+ setAuthState(userA);
+ const { rerender } = renderHook(
+ ({ authenticated, authGeneration }) => useSessionStream("s1", 0, 0, authenticated, authGeneration),
+ { initialProps: { authenticated: true, authGeneration: getAuthGeneration() } },
+ );
+ const sourceA = FakeEventSource.instances[0];
+
+ act(() => setAuthState(userB));
+ rerender({ authenticated: true, authGeneration: getAuthGeneration() });
+ const sourceB = FakeEventSource.instances[1];
+ act(() => sourceB.emitNamed("text_delta", { type: "text_delta", text: "B-data" }));
+
+ act(() => sourceA.onerror?.(new Event("error")));
+
+ expect((await import("../auth/authState")).getAuthState()).toMatchObject({ subject: "user-b" });
+ expect(sourceB.closed).toBe(false);
+ expect(FakeEventSource.instances).toHaveLength(2);
+ expect(useSessionStore.getState().transcript).toEqual([{ role: "assistant", text: "B-data" }]);
+});
+
+test("a native current-source error probes /me once and clears auth on a guarded 401", async () => {
+ let probes = 0;
+ server.use(http.get("/api/me", () => {
+ probes += 1;
+ return new HttpResponse(null, { status: 401 });
+ }));
+ setAuthState({
+ issuer: "local", subject: "user-a", roles: ["user"], permissions: ["session.use"], isAdmin: false,
+ csrfToken: "a".repeat(43), session: null,
+ });
+ renderHook(() => useSessionStream("s1", 0, 0, true, getAuthGeneration()));
+ const source = FakeEventSource.instances[0];
+
+ act(() => {
+ source.onerror?.(new Event("error"));
+ source.onerror?.(new Event("error"));
+ });
+ await waitFor(() => expect(getAuthState()).toBeNull());
+
+ expect(probes).toBe(1);
+ expect((await import("../auth/authState")).getAuthState()).toBeNull();
+});
+
+test("a native error keeps current auth connected after a 200 /me probe and does not storm", async () => {
+ let probes = 0;
+ const user = {
+ issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false,
+ csrfToken: "a".repeat(43), session: null,
+ };
+ server.use(http.get("/api/me", () => {
+ probes += 1;
+ return HttpResponse.json(user);
+ }));
+ setAuthState(user);
+ const result = renderHook(() => useSessionStream("s1", 0, 0, true, getAuthGeneration()));
+ const source = FakeEventSource.instances[0];
+
+ act(() => source.onerror?.(new Event("error")));
+ await waitFor(() => expect(result.result.current.connected).toBe(true));
+ act(() => source.onerror?.(new Event("error")));
+
+ expect(probes).toBe(1);
+ expect(getAuthState()).toMatchObject({ subject: "user-a" });
+});
+
+test("a stale source does not probe and a user-B login during an A probe cannot mutate B", async () => {
+ let release!: () => void;
+ let probes = 0;
+ const held = new Promise((resolve) => { release = resolve; });
+ const userA = {
+ issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false,
+ csrfToken: "a".repeat(43), session: null,
+ };
+ const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) };
+ server.use(http.get("/api/me", async () => {
+ probes += 1;
+ await held;
+ return HttpResponse.json(userA);
+ }));
+ setAuthState(userA);
+ const { rerender } = renderHook(
+ ({ sessionId, authGeneration }) => useSessionStream(sessionId, 0, 0, true, authGeneration),
+ { initialProps: { sessionId: "s1" as string | null, authGeneration: getAuthGeneration() } },
+ );
+ const sourceA = FakeEventSource.instances[0];
+ act(() => sourceA.onerror?.(new Event("error")));
+ await waitFor(() => expect(probes).toBe(1));
+
+ act(() => setAuthState(userB));
+ rerender({ sessionId: "s2", authGeneration: getAuthGeneration() });
+ const sourceB = FakeEventSource.instances[1];
+ act(() => sourceA.onerror?.(new Event("error")));
+ release();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(probes).toBe(1);
+ expect(getAuthState()).toMatchObject({ subject: "user-b" });
+ expect(sourceB.closed).toBe(false);
+});
diff --git a/frontend/src/stream/useSessionStream.ts b/frontend/src/stream/useSessionStream.ts
index 62f28ccd..c138abc2 100644
--- a/frontend/src/stream/useSessionStream.ts
+++ b/frontend/src/stream/useSessionStream.ts
@@ -3,6 +3,12 @@ import { BASE } from "../api/client";
import { joinBackendPath } from "../api/runtime-config";
import { useSessionStore } from "../store/sessionStore";
import type { StreamEvent } from "../api/types";
+import {
+ getAuthGeneration,
+ isAuthGenerationCurrent,
+} from "../auth/authState";
+import { getMe } from "../api/auth";
+import { captureAuthOperation, isAuthOperationCurrent } from "../auth/authOperation";
const STREAM_UPDATE_INTERVAL_MS = 100;
@@ -15,6 +21,7 @@ function isStreamingDelta(
export function createStreamEventCoalescer(
applyEvent: (event: StreamEvent) => void,
intervalMs = STREAM_UPDATE_INTERVAL_MS,
+ canApply: () => boolean = () => true,
) {
let pending: Extract | null = null;
let timer: ReturnType | null = null;
@@ -24,6 +31,7 @@ export function createStreamEventCoalescer(
if (!pending) return;
const event = pending;
pending = null;
+ if (disposed || !canApply()) return;
applyEvent(event);
};
@@ -31,14 +39,18 @@ export function createStreamEventCoalescer(
timer = setTimeout(() => {
timer = null;
if (!pending || disposed) return;
+ if (!canApply()) {
+ pending = null;
+ return;
+ }
flushPending();
- schedule();
+ if (pending && !disposed && canApply()) schedule();
}, intervalMs);
};
return {
push(event: StreamEvent) {
- if (disposed) return;
+ if (disposed || !canApply()) return;
if (!isStreamingDelta(event)) {
flushPending();
if (timer) clearTimeout(timer);
@@ -60,7 +72,7 @@ export function createStreamEventCoalescer(
if (disposed) return;
if (timer) clearTimeout(timer);
timer = null;
- flushPending();
+ pending = null;
disposed = true;
},
};
@@ -70,24 +82,33 @@ export function useSessionStream(
sessionId: string | null,
generation = 0,
cursorResetEpoch = 0,
+ authenticated = true,
+ authGeneration?: number,
) {
+ const effectiveAuthGeneration = authGeneration ?? getAuthGeneration();
const [connected, setConnected] = useState(false);
const applyEvent = useSessionStore((s) => s.applyEvent);
+ const resetSession = useSessionStore((s) => s.resetSession);
const cursor = useRef({
sessionId: null as string | null,
cursorResetEpoch,
+ authGeneration: null as number | null,
lastEventId: "",
});
const activeSource = useRef