Files
FabledGrimoire/frontend/src/api/client.ts
T
bvandeusenandClaude Opus 5.5 e99f1169f7
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 36s
rename(547): what people read says Grimoire
Step 1 of the Scribe -> Grimoire rename: every capitalised "Scribe" in prose
becomes "Grimoire" ("Fabled Scribe" -> "Fabled Grimoire") across the UI,
the PWA manifest and page title, the MCP instructions and tool docstrings,
hook and skill text, docs and README, with the tests that assert on that
prose moving with it. Old migrations keep their wording.

Identifiers are untouched here: the package, plugin name, MCP server key,
hook file names, env vars, the .scribe marker and FabledScribe repo/image
names move in steps 2 and 3, along with prose that quotes them.

The sweep matched "Scribe" only as a whole word, so describe/subscribe and
friends are untouched. The longer name took the MCP instructions one
character over their 1,600 budget; two phrases are shortened to fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:46:37 -04:00

519 lines
18 KiB
TypeScript

export class ApiError extends Error {
status: number;
body: Record<string, unknown>;
constructor(status: number, body: Record<string, unknown>) {
const msg = (body.error as string) || `API error: ${status}`;
super(msg);
this.name = "ApiError";
this.status = status;
this.body = body;
}
}
async function handleResponse<T>(res: Response, path: string): Promise<T> {
if (!res.ok) {
let body: Record<string, unknown> = {};
try {
body = await res.json();
} catch {
body = { error: `API error: ${res.status}` };
}
// Redirect to login on 401 (except for auth endpoints)
if (res.status === 401 && !path.startsWith("/api/auth/")) {
const { default: router } = await import("@/router/index");
const currentPath = window.location.pathname;
if (currentPath !== "/login" && currentPath !== "/register") {
router.push({ name: "login", query: { redirect: currentPath } });
}
}
throw new ApiError(res.status, body);
}
// Handle 204 No Content
if (res.status === 204) {
return undefined as T;
}
return res.json() as Promise<T>;
}
/**
* The server's `{"error": "..."}` message from a failed call, or `fallback`
* when the failure carried none (network error, non-JSON body). The one place
* the error envelope is unpacked on the client, so no view restates it as a
* `"body" in e` branch at its catch site.
*/
export function apiErrorMessage(e: unknown, fallback: string): string {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: unknown } }).body;
if (body && typeof body.error === "string" && body.error) return body.error;
}
return fallback;
}
/**
* How long an ordinary JSON call may wait before it is declared failed.
*
* A wait with no deadline is a bug. `fetch`'s own default is to wait
* as long as the browser will, which is not a deadline — it is the absence of
* one, and it renders as a spinner that never resolves. There is no state a
* surface can show for "pending forever" that is not a lie.
*
* 30s is chosen to be longer than anything healthy: it has to clear a cold
* embedding call and a list view under connection-pool contention, so
* tripping it means something is genuinely wrong rather than merely busy. Slow
* BY DESIGN is a different case and passes its own value — see the callers in
* SettingsView that do.
*/
const DEFAULT_TIMEOUT_MS = 30_000;
/** HTTP 408. Not a status any Grimoire route returns, so it unambiguously means
* "the client gave up" rather than anything the server said. */
const CLIENT_TIMEOUT_STATUS = 408;
/**
* How long a STREAM may take to answer with its headers.
*
* Streams are the one case a wall-clock deadline would break: a long-lived SSE
* connection is *supposed* to stay open, and `AbortSignal.timeout` would kill
* it mid-flight along with the body. But that does not exempt them from rule
* 156 — it relocates the deadline. Two different waits are involved:
*
* connect — the server answering with headers. CAN fail to answer, so it
* carries this deadline, cleared the moment headers arrive.
* stream — the body, open indefinitely on purpose. Its failure mode is
* going quiet, which a timeout cannot tell from being idle; that
* is what reconnection and Last-Event-ID are for, not this.
*
* Reading the connect as exempt because "the stream is long-lived" is the easy
* mistake here, and it leaves an unreachable server looking like a quiet one.
*/
const STREAM_CONNECT_TIMEOUT_MS = 15_000;
/**
* A signal that aborts if headers do not arrive in time, plus the `settle` to
* call once they do. After `settle()` the returned signal never fires, so the
* stream body runs unbounded — which is the intent.
*/
function connectDeadline(base: AbortSignal): { signal: AbortSignal; settle: () => void } {
const gate = new AbortController();
const timer = setTimeout(
() => gate.abort(new DOMException("stream did not connect in time", "TimeoutError")),
STREAM_CONNECT_TIMEOUT_MS,
);
return {
signal: AbortSignal.any([base, gate.signal]),
settle: () => clearTimeout(timer),
};
}
export interface RequestOpts {
/** Override the deadline. Pass one when the call is slow BY DESIGN. */
timeoutMs?: number;
}
/**
* The one place a request is actually made — every verb below goes through
* here, so the deadline cannot be forgotten by adding a sixth.
*
* EXPIRY SURFACES AS AN `ApiError`, the deadline's second half: the
* failure has to arrive in the shape the caller already handles. A bare
* `DOMException: TimeoutError` would reach `apiErrorMessage(e, fallback)` as
* an object with no `body`, so every catch site in the app would report its
* generic fallback and the timeout would be invisible in the very situation it
* exists to expose. Rethrowing as `ApiError` means ~330 existing call sites
* report it correctly without being touched.
*
* Only a TIMEOUT is converted. A deliberate cancellation aborts with
* `AbortError` and is left alone — a caller that cancelled its own request
* does not want it reported as a server failure.
*/
async function request<T>(path: string, init: RequestInit, opts?: RequestOpts): Promise<T> {
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
let res: Response;
try {
res = await fetch(path, { ...init, signal: AbortSignal.timeout(timeoutMs) });
} catch (e) {
if (e instanceof DOMException && e.name === "TimeoutError") {
throw new ApiError(CLIENT_TIMEOUT_STATUS, {
error: `The server did not answer within ${Math.round(timeoutMs / 1000)}s.`,
});
}
throw e;
}
return handleResponse<T>(res, path);
}
/** JSON body headers — the three write verbs sent an identical literal each. */
const JSON_HEADERS = { "Content-Type": "application/json" };
export function apiGet<T>(path: string, opts?: RequestOpts): Promise<T> {
return request<T>(path, {}, opts);
}
export function apiPost<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
return request<T>(path, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
}
export function apiPut<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
return request<T>(path, { method: "PUT", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
}
export function apiPatch<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
return request<T>(path, { method: "PATCH", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
}
export function apiDelete(path: string, opts?: RequestOpts): Promise<void> {
return request<void>(path, { method: "DELETE" }, opts);
}
// ---------------------------------------------------------------------------
// Sharing, Groups, Notifications, User search
// ---------------------------------------------------------------------------
export interface ShareEntry {
id: number
permission: string
shared_with_user_id: number | null
shared_with_group_id: number | null
username?: string
group_name?: string
invited_by: number
created_at: string
}
export interface GroupEntry {
id: number
name: string
description: string | null
created_by: number | null
member_count: number
is_member: boolean
created_at: string
updated_at: string
}
export interface GroupMember {
id: number
group_id: number
user_id: number
role: string
username: string
email: string | null
created_at: string
}
export interface NotificationEntry {
id: number
user_id: number
type: string
payload: Record<string, unknown>
read_at: string | null
created_at: string
}
export interface UserSearchResult {
id: number
username: string
}
// --- User search ---
export const searchUsers = (q: string) =>
apiGet<{ users: UserSearchResult[] }>(`/api/users/search?q=${encodeURIComponent(q)}`).then(r => r.users)
// --- Groups ---
export const listGroups = () => apiGet<{ groups: GroupEntry[] }>('/api/groups').then(r => r.groups)
export const createGroup = (name: string, description?: string) =>
apiPost<GroupEntry>('/api/groups', { name, description })
export const updateGroup = (id: number, data: { name?: string; description?: string }) =>
apiPatch<GroupEntry>(`/api/groups/${id}`, data)
export const deleteGroup = (id: number) => apiDelete(`/api/groups/${id}`)
export const getGroupDetail = (id: number) =>
apiGet<GroupEntry & { members: GroupMember[] }>(`/api/groups/${id}`)
export const listGroupMembers = (id: number) =>
apiGet<{ members: GroupMember[] }>(`/api/groups/${id}/members`).then(r => r.members)
export const addGroupMember = (groupId: number, userId: number, role: string) =>
apiPost<GroupMember>(`/api/groups/${groupId}/members`, { user_id: userId, role })
export const updateGroupMember = (groupId: number, userId: number, role: string) =>
apiPatch<GroupMember>(`/api/groups/${groupId}/members/${userId}`, { role })
export const removeGroupMember = (groupId: number, userId: number) =>
apiDelete(`/api/groups/${groupId}/members/${userId}`)
// --- Project shares ---
export const listProjectShares = (projectId: number) =>
apiGet<{ shares: ShareEntry[] }>(`/api/projects/${projectId}/shares`).then(r => r.shares)
export const createProjectShare = (projectId: number, body: { user_id?: number; group_id?: number; permission: string }) =>
apiPost<ShareEntry>(`/api/projects/${projectId}/shares`, body)
export const updateProjectShare = (projectId: number, shareId: number, permission: string) =>
apiPatch<ShareEntry>(`/api/projects/${projectId}/shares/${shareId}`, { permission })
export const deleteProjectShare = (projectId: number, shareId: number) =>
apiDelete(`/api/projects/${projectId}/shares/${shareId}`)
// --- Note / task shares ---
export const listNoteShares = (noteId: number) =>
apiGet<{ shares: ShareEntry[] }>(`/api/notes/${noteId}/shares`).then(r => r.shares)
export const createNoteShare = (noteId: number, body: { user_id?: number; group_id?: number; permission: string }) =>
apiPost<ShareEntry>(`/api/notes/${noteId}/shares`, body)
export const updateNoteShare = (noteId: number, shareId: number, permission: string) =>
apiPatch<ShareEntry>(`/api/notes/${noteId}/shares/${shareId}`, { permission })
export const deleteNoteShare = (noteId: number, shareId: number) =>
apiDelete(`/api/notes/${noteId}/shares/${shareId}`)
// --- Shared-with-me ---
export const getSharedWithMe = () =>
apiGet<{ projects: Record<string, unknown>[]; notes: Record<string, unknown>[] }>('/api/shared-with-me')
// --- In-app notifications ---
export const getNotifications = (all = false) =>
apiGet<{ notifications: NotificationEntry[] }>(`/api/notifications${all ? '?all=true' : ''}`).then(r => r.notifications)
export const getNotificationCount = () =>
apiGet<{ count: number }>('/api/notifications/count').then(r => r.count)
export const markNotificationRead = (id: number) => apiPost<void>(`/api/notifications/${id}/read`, {})
export const markAllNotificationsRead = () => apiPost<{ marked: number }>('/api/notifications/read-all', {})
export interface SSEStreamHandle {
close(): void;
/** Resolves when the stream closes (normally or via error/abort). */
done: Promise<void>;
}
export interface SSENamedEvent {
id: number;
event: string;
data: Record<string, unknown>;
}
export function apiSSEStream(
path: string,
onEvent: (event: SSENamedEvent) => void,
options?: { lastEventId?: number; signal?: AbortSignal },
): SSEStreamHandle {
const controller = new AbortController();
const combinedSignal = options?.signal
? AbortSignal.any([controller.signal, options.signal])
: controller.signal;
const headers: Record<string, string> = {};
if (options?.lastEventId !== undefined) {
headers["Last-Event-ID"] = String(options.lastEventId);
}
const done = (async () => {
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
const connect = connectDeadline(combinedSignal);
let res: Response;
try {
res = await fetch(path, { headers, signal: connect.signal });
} finally {
connect.settle();
}
if (!res.ok) {
let body: Record<string, unknown> = {};
try {
body = await res.json();
} catch {
body = { error: `API error: ${res.status}` };
}
if (res.status === 401 && !path.startsWith("/api/auth/")) {
const { default: router } = await import("@/router/index");
router.push({ name: "login" });
}
throw new ApiError(res.status, body);
}
const reader = res.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
// SSE field parsing state
let currentId = -1;
let currentEvent = "message";
let currentData = "";
function dispatch() {
if (!currentData) return;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(currentData);
} catch {
return;
}
onEvent({ id: currentId, event: currentEvent, data: parsed });
// Reset for next event
currentEvent = "message";
currentData = "";
}
function processLine(line: string) {
if (line === "") {
// Empty line = end of event
dispatch();
return;
}
if (line.startsWith(":")) return; // comment / keepalive
const colonIdx = line.indexOf(":");
let field: string;
let value: string;
if (colonIdx === -1) {
field = line;
value = "";
} else {
field = line.slice(0, colonIdx);
value = line.slice(colonIdx + 1);
if (value.startsWith(" ")) value = value.slice(1);
}
switch (field) {
case "id":
currentId = parseInt(value, 10);
break;
case "event":
currentEvent = value;
break;
case "data":
currentData += value;
break;
}
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
processLine(line);
}
}
// Process remaining buffer
if (buffer) {
processLine(buffer);
}
dispatch();
})().catch(() => {
// Stream closed or aborted — handled by caller via reconnection
});
return { close: () => controller.abort(), done };
}
export async function apiStreamPost(
path: string,
body: unknown,
onChunk: (data: Record<string, unknown>) => void
): Promise<void> {
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
const connect = connectDeadline(new AbortController().signal);
let res: Response;
try {
res = await fetch(path, {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify(body),
signal: connect.signal,
});
} finally {
connect.settle();
}
if (!res.ok) {
let errBody: Record<string, unknown> = {};
try {
errBody = await res.json();
} catch {
errBody = { error: `API error: ${res.status}` };
}
if (res.status === 401 && !path.startsWith("/api/auth/")) {
const { default: router } = await import("@/router/index");
router.push({ name: "login" });
}
throw new ApiError(res.status, errBody);
}
const reader = res.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
function processLine(line: string) {
const trimmed = line.trim();
if (trimmed.startsWith("data: ")) {
let data;
try {
data = JSON.parse(trimmed.slice(6));
} catch {
return; // Skip malformed JSON lines
}
onChunk(data);
}
}
function processBuffer() {
const lines = buffer.split("\n");
// Keep the last (possibly incomplete) line in the buffer
buffer = lines.pop() || "";
for (const line of lines) {
processLine(line);
}
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
processBuffer();
}
} catch {
// Stream may close with a network error after all data was sent.
}
// Flush any remaining complete lines in the buffer
if (buffer.trim()) {
// The buffer may contain one or more unprocessed lines
const remaining = buffer;
buffer = "";
for (const line of remaining.split("\n")) {
processLine(line);
}
}
}
// ─── API Keys ─────────────────────────────────────────────────────────────────
export interface ApiKeyEntry {
id: number
name: string
scope: string
key_prefix: string
last_used_at: string | null
}
export const listApiKeys = () =>
apiGet<{ api_keys: ApiKeyEntry[] }>('/api/api-keys').then(r => r.api_keys)
export const createApiKey = (name: string, scope: 'read' | 'write') =>
apiPost<{ key: string; api_key: ApiKeyEntry }>('/api/api-keys', { name, scope })
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
// ── Note Versions (pinning) ──────────────────────────────────────────────────
import type { NoteVersion } from '../types/task'
/** Mark a note version as manually pinned, optionally with a commit-note
* label. Re-calling with a different label updates the label. */
export const pinNoteVersion = (noteId: number, versionId: number, label?: string | null) =>
apiPost<NoteVersion>(
`/api/notes/${noteId}/versions/${versionId}/pin`,
{ label: label ?? null },
)
/** Downgrade a manually-pinned version back to rolling. Does NOT delete
* the row — older rows may be FIFO-pruned by the next autosave. */
export const unpinNoteVersion = (noteId: number, versionId: number) =>
apiDelete(`/api/notes/${noteId}/versions/${versionId}/pin`)