Commit the accumulated infrastructure work that was living only in the working tree: monitoring stack, emergency access/bot, gyro allocator, grimmory, adguard, backup audit and the OpenCode agent definitions. Also ignore Python bytecode, local archives and Nix/direnv artifacts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
312 lines
10 KiB
JavaScript
312 lines
10 KiB
JavaScript
import { constants } from "node:fs";
|
|
import { open } from "node:fs/promises";
|
|
|
|
function unwrap(payload) {
|
|
if (
|
|
payload &&
|
|
typeof payload === "object" &&
|
|
!Array.isArray(payload) &&
|
|
Object.hasOwn(payload, "data")
|
|
) {
|
|
return payload.data;
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
export class GrimmoryClient {
|
|
constructor({ baseUrl, username, passwordFile, fetchImpl = fetch }) {
|
|
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
this.username = username;
|
|
this.passwordFile = passwordFile;
|
|
this.fetch = fetchImpl;
|
|
this.accessToken = null;
|
|
this.refreshToken = null;
|
|
this.loginPromise = null;
|
|
this.refreshPromise = null;
|
|
}
|
|
|
|
async credentials() {
|
|
let password;
|
|
let handle;
|
|
try {
|
|
handle = await open(
|
|
this.passwordFile,
|
|
constants.O_RDONLY | constants.O_NOFOLLOW,
|
|
);
|
|
const info = await handle.stat();
|
|
if (!info.isFile()) {
|
|
throw new Error("Grimmory password path must be a regular file");
|
|
}
|
|
if (process.getuid && info.uid !== process.getuid()) {
|
|
throw new Error("Grimmory password file must be owned by the current user");
|
|
}
|
|
if ((info.mode & 0o077) !== 0) {
|
|
throw new Error("Grimmory password file must not be accessible by group or others");
|
|
}
|
|
password = await handle.readFile("utf8");
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") {
|
|
throw new Error(
|
|
`Grimmory password file is missing: ${this.passwordFile}. Run npm run configure in the MCP directory.`,
|
|
);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
await handle?.close();
|
|
}
|
|
if (!password) throw new Error("Grimmory password file is empty");
|
|
return { username: this.username, password };
|
|
}
|
|
|
|
async login(attempt = 0) {
|
|
const response = await this.fetch(`${this.baseUrl}/api/v1/auth/login`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(await this.credentials()),
|
|
});
|
|
let payload;
|
|
try {
|
|
payload = await this.parseResponse(response);
|
|
} catch (error) {
|
|
// v3.2.4 can generate the same refresh JWT for concurrent logins in one second.
|
|
if (error.status === 400 && attempt === 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
return this.login(1);
|
|
}
|
|
throw error;
|
|
}
|
|
const tokens = unwrap(payload);
|
|
if (!tokens?.accessToken || !tokens?.refreshToken) {
|
|
throw new Error("Grimmory login response does not contain tokens");
|
|
}
|
|
this.accessToken = tokens.accessToken;
|
|
this.refreshToken = tokens.refreshToken;
|
|
}
|
|
|
|
async refresh() {
|
|
if (!this.refreshToken) return this.login();
|
|
if (!this.refreshPromise) {
|
|
this.refreshPromise = (async () => {
|
|
const response = await this.fetch(`${this.baseUrl}/api/v1/auth/refresh`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ refreshToken: this.refreshToken }),
|
|
});
|
|
if (!response.ok) {
|
|
this.accessToken = null;
|
|
this.refreshToken = null;
|
|
return this.login();
|
|
}
|
|
const tokens = unwrap(await this.parseResponse(response));
|
|
this.accessToken = tokens.accessToken;
|
|
this.refreshToken = tokens.refreshToken;
|
|
})().finally(() => {
|
|
this.refreshPromise = null;
|
|
});
|
|
}
|
|
return this.refreshPromise;
|
|
}
|
|
|
|
async ensureLogin() {
|
|
if (this.accessToken) return;
|
|
if (!this.loginPromise) {
|
|
this.loginPromise = this.login().finally(() => {
|
|
this.loginPromise = null;
|
|
});
|
|
}
|
|
await this.loginPromise;
|
|
}
|
|
|
|
async parseResponse(response) {
|
|
if (response.status === 204) return null;
|
|
const text = await response.text();
|
|
let payload = null;
|
|
if (text) {
|
|
try {
|
|
payload = JSON.parse(text);
|
|
} catch {
|
|
payload = text;
|
|
}
|
|
}
|
|
if (!response.ok) {
|
|
const message =
|
|
payload?.message || payload?.error || text || response.statusText;
|
|
throw Object.assign(
|
|
new Error(`Grimmory API ${response.status}: ${message}`),
|
|
{ status: response.status },
|
|
);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
async request(path, { optional = false, raw = false, retry = true } = {}) {
|
|
await this.ensureLogin();
|
|
const response = await this.fetch(`${this.baseUrl}${path}`, {
|
|
headers: { authorization: `Bearer ${this.accessToken}` },
|
|
});
|
|
if (response.status === 401 && retry) {
|
|
await this.refresh();
|
|
return this.request(path, { optional, raw, retry: false });
|
|
}
|
|
if (optional && [204, 404].includes(response.status)) return null;
|
|
if (raw) {
|
|
if (!response.ok) await this.parseResponse(response);
|
|
return response;
|
|
}
|
|
return unwrap(await this.parseResponse(response));
|
|
}
|
|
|
|
currentUser() {
|
|
return this.request("/api/v1/users/me");
|
|
}
|
|
|
|
async libraries() {
|
|
try {
|
|
return await this.request("/api/v1/app/libraries");
|
|
} catch (error) {
|
|
if (!error.message.startsWith("Grimmory API 500:")) throw error;
|
|
const user = await this.currentUser();
|
|
if (user.assignedLibraries?.length) return user.assignedLibraries;
|
|
const books = await this.allBooks();
|
|
const byLibrary = new Map();
|
|
for (const book of books) {
|
|
const entry = byLibrary.get(book.libraryId) || {
|
|
id: book.libraryId,
|
|
name: null,
|
|
bookCount: 0,
|
|
};
|
|
entry.bookCount += 1;
|
|
byLibrary.set(book.libraryId, entry);
|
|
}
|
|
for (const entry of byLibrary.values()) {
|
|
const sample = books.find((book) => book.libraryId === entry.id);
|
|
if (sample) {
|
|
const detail = await this.request(
|
|
`/api/v1/books/${sample.id}?withDescription=false`,
|
|
);
|
|
entry.name = detail.libraryName || `Library ${entry.id}`;
|
|
}
|
|
}
|
|
return [...byLibrary.values()];
|
|
}
|
|
}
|
|
|
|
shelves() {
|
|
return this.request("/api/v1/app/shelves");
|
|
}
|
|
|
|
authors(params = {}) {
|
|
return this.request(`/api/v1/app/authors?${query(params)}`);
|
|
}
|
|
|
|
filterOptions(params = {}) {
|
|
return this.request(`/api/v1/app/filter-options?${query(params)}`);
|
|
}
|
|
|
|
books(params = {}) {
|
|
return this.request(`/api/v1/app/books?${query(params)}`);
|
|
}
|
|
|
|
searchBooks(search, params = {}) {
|
|
return this.request(
|
|
`/api/v1/app/books/search?${query({ q: search, ...params })}`,
|
|
);
|
|
}
|
|
|
|
async allBooks() {
|
|
const books = [];
|
|
const seenPages = new Set();
|
|
for (let page = 0; page < 10_000; page += 1) {
|
|
const result = await this.books({ page, size: 50, sort: "title", dir: "asc" });
|
|
if (!Array.isArray(result?.content)) {
|
|
throw new Error("Grimmory books response has no content array");
|
|
}
|
|
const signature = result.content.map((book) => book.id).join(",");
|
|
if (seenPages.has(signature) && signature) {
|
|
throw new Error("Grimmory books pagination repeated a page");
|
|
}
|
|
seenPages.add(signature);
|
|
books.push(...result.content);
|
|
if (!hasNextPage(result, page, 50)) return books;
|
|
}
|
|
throw new Error("Grimmory books pagination exceeded the safety limit");
|
|
}
|
|
|
|
async springPage(path) {
|
|
const entries = [];
|
|
const seenPages = new Set();
|
|
for (let page = 0; page < 10_000; page += 1) {
|
|
const separator = path.includes("?") ? "&" : "?";
|
|
const result = await this.request(`${path}${separator}page=${page}&size=100`);
|
|
if (!Array.isArray(result?.content)) {
|
|
throw new Error("Grimmory session response has no content array");
|
|
}
|
|
const signature = JSON.stringify(result.content.map((entry) => entry.id));
|
|
if (seenPages.has(signature) && result.content.length) {
|
|
throw new Error("Grimmory session pagination repeated a page");
|
|
}
|
|
seenPages.add(signature);
|
|
entries.push(...result.content);
|
|
if (!hasNextPage(result, page, 100)) return entries;
|
|
}
|
|
throw new Error("Grimmory session pagination exceeded the safety limit");
|
|
}
|
|
|
|
async bookBundle(bookId) {
|
|
const id = Number(bookId);
|
|
const [book, progress, notebook, annotations, bookmarks, notes, cfiNotes, reviews, sessions] =
|
|
await Promise.all([
|
|
this.request(`/api/v1/books/${id}?withDescription=true`),
|
|
this.request(`/api/v1/app/books/${id}/progress`, { optional: true }),
|
|
this.request(`/api/v1/notebook/export?bookId=${id}`, { optional: true }),
|
|
this.request(`/api/v1/annotations/book/${id}`, { optional: true }),
|
|
this.request(`/api/v1/bookmarks/book/${id}`, { optional: true }),
|
|
this.request(`/api/v1/book-notes/book/${id}`, { optional: true }),
|
|
this.request(`/api/v2/book-notes/book/${id}`, { optional: true }),
|
|
this.request(`/api/v1/reviews/book/${id}`, { optional: true }),
|
|
this.springPage(`/api/v1/reading-sessions/book/${id}`),
|
|
]);
|
|
return {
|
|
book,
|
|
progress,
|
|
notebook: notebook || [],
|
|
annotations: annotations || [],
|
|
bookmarks: bookmarks || [],
|
|
notes: notes || [],
|
|
cfiNotes: cfiNotes || [],
|
|
reviews: reviews || [],
|
|
sessions,
|
|
};
|
|
}
|
|
|
|
cover(bookId) {
|
|
return this.request(`/api/v1/media/book/${Number(bookId)}/cover`, {
|
|
optional: true,
|
|
raw: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
export function query(values) {
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(values)) {
|
|
if (value === undefined || value === null || value === "") continue;
|
|
for (const item of Array.isArray(value) ? value : [value]) {
|
|
params.append(key, String(item));
|
|
}
|
|
}
|
|
return params.toString();
|
|
}
|
|
|
|
export function hasNextPage(result, currentPage, pageSize) {
|
|
if (typeof result.hasNext === "boolean") return result.hasNext;
|
|
if (typeof result.last === "boolean") return !result.last;
|
|
const number = result.page?.number ?? result.number ?? currentPage;
|
|
const totalPages = result.page?.totalPages ?? result.totalPages;
|
|
if (totalPages !== undefined) return number + 1 < totalPages;
|
|
if (result.content.length < pageSize) return false;
|
|
throw new Error("Grimmory books response has no pagination metadata");
|
|
}
|
|
|
|
export { unwrap };
|