Files
infra/tools/grimmory-mcp/src/client.js
T
DmitryandClaude Opus 5 c676be81ec Capture current Ansible control plane state
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
2026-08-26 21:39:28 +03:00

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 };