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