feat(auth): add remembered local login to the frontend

This commit is contained in:
2026-08-17 04:52:39 +02:00
parent 8c67cb75dc
commit 202822f3ba
36 changed files with 2649 additions and 163 deletions
+114
View File
@@ -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<AuthPublicConfig["mode"]>(["local", "oidc", "upstream", "none", "mock"]);
const roles = new Set<AuthRole>(["user", "admin"]);
const sessionMethods = new Set<AuthSessionInfo["method"]>(["local", "oidc", "upstream"]);
let pendingLogoutResponse: Promise<void> | null = null;
function record(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: 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<AuthPublicConfig> {
return parseAuthConfig(await apiFetch<unknown>("/auth/config"));
}
export async function getMe(): Promise<AuthenticatedUser> {
return parseUser(await apiFetch<unknown>("/me"));
}
export async function loginLocal(username: string, password: string, remember: boolean): Promise<AuthenticatedUser> {
if (pendingLogoutResponse) await pendingLogoutResponse;
await apiFetch<void>("/auth/local/login", {
method: "POST",
body: JSON.stringify({ username, password, remember }),
});
const user = await getMe();
setAuthState(user);
return user;
}
export async function logout(): Promise<boolean> {
const logoutGeneration = getAuthGeneration();
if (pendingLogoutResponse) {
await pendingLogoutResponse;
return false;
}
let cleared = false;
const response = (async () => {
try {
await apiFetch<void>("/auth/logout", { method: "POST" });
} finally {
cleared = clearAuthStateIfCurrent(logoutGeneration);
}
})();
let trackedResponse: Promise<void>;
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;
}
+270 -1
View File
@@ -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<void>((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 = `<html>${secret}${"x".repeat(20_000)}</html>`;
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<unknown>("/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("<html>");
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<unknown>("/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: "<html>password=do-not-show</html>",
}), { status: 503, headers: { "content-type": "application/json" } }),
);
try {
const failure = await apiFetch<unknown>("/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>("/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<Uint8Array<ArrayBuffer>>);
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<Uint8Array<ArrayBuffer>>);
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<Uint8Array<ArrayBuffer>>);
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<unknown>("/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<unknown>("/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 });
});
+158 -10
View File
@@ -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<string, string> = {
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<number, string> = {
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 "<status> <body>" 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<string, unknown>;
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<Response> {
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<T>(path: string, init?: RequestInit): Promise<T> {
// 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
+5 -13
View File
@@ -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<PiManagementApiErrorCode>([
"pi_management_write_failed",
]);
function object(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: 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<PiManagementStatus>("/pi-management/status");
+40 -7
View File
@@ -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<WorkspacePreference>;
async function selectedPreferences(): Promise<WorkspacePreference> {
async function selectedPreferences(precondition?: AuthOperationPrecondition): Promise<WorkspacePreference> {
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<WorkspacePreference> {
async function ensureWorkspaceSelectionPolicy(precondition?: AuthOperationPrecondition): Promise<WorkspacePreference> {
while (true) {
requireAuthOperationPrecondition(precondition);
const preferences = workspacePreferences.load();
const workspaceId = preferences.workspaceId;
if (!workspaceId) return preferences;
@@ -54,10 +63,12 @@ async function ensureWorkspaceSelectionPolicy(): Promise<WorkspacePreference> {
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<WorkspacePreference> {
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<WorkspacePreference> {
(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<WorkspacePreference> {
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<void>("/runtime/prewarm", { method: "POST" });
export const getMe = () => apiFetch<Principal>("/me");
/** Compatibility export for existing shell tests/callers; AuthGate uses the validated auth API. */
export const getMe = () => apiFetch<AuthenticatedUser>("/me");
export const listSessions = (scope: SessionScope = "mine") =>
apiFetch<SessionSummary[]>(`/sessions?scope=${scope}`);
+33
View File
@@ -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 {
+5 -10
View File
@@ -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 {