feat(auth): add remembered local login to the frontend
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user