import type { Config } from '../config.ts'; import type { BoardContext, BoardSkeleton, CardResponse, ClassItem, CourseBoardResponse, CourseMetadata, DashboardResponse, FileParentType, FileRecord, GroupItem, LegacyCourse, LegacyUser, LessonResponse, MeResponse, NewsResponse, Paginated, ParentFileStats, PreviewWidth, SubmissionStatus, LessonLinkedTask, RoomApplicant, RoomBoardItem, RoomDetails, RoomInvitationLink, RoomItem, RoomMember, TaskContent, } from './types.ts'; /** * Maximum repeated query parameters the API's parser will still treat as an * array — the `qs` default. See `getCards` for what happens above it. */ export const MAX_IDS_PER_QUERY = 20; /** The only output format the preview endpoint accepts; anything else is a 400. */ const PREVIEW_OUTPUT_FORMAT = 'image/webp'; /** Statuses worth retrying: transient by definition, and every call here is a GET. */ const RETRYABLE = new Set([429, 500, 502, 503, 504]); const MAX_RETRIES = 3; /** Exponential backoff with jitter, so parallel workers do not retry in lockstep. */ function backoffMs(attempt: number): number { const base = 400 * 2 ** (attempt - 1); return Math.round(base + Math.random() * base * 0.5); } function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** An API response outside the 2xx range, carrying the status for callers to branch on. */ export class SchulcloudApiError extends Error { readonly status: number; readonly path: string; readonly body: string; constructor(status: number, path: string, body: string) { super(`Schulcloud API ${status} for ${path}${body ? `: ${truncate(body, 400)}` : ''}`); this.name = 'SchulcloudApiError'; this.status = status; this.path = path; this.body = body; } /** True when the instance rejected our JWT — the one error the user must act on. */ get isAuthFailure(): boolean { return this.status === 401; } } function truncate(value: string, max: number): string { return value.length > max ? `${value.slice(0, max)}…` : value; } export interface DownloadedFile { bytes: Buffer; mimeType: string; fileName: string; /** True when the file was longer than `maxDownloadBytes` and got cut short. */ truncated: boolean; } /** * HTTP client for a Schulcloud instance. Read-only apart from `extendSession`, * which touches only the caller's own session — see its doc comment. * * Two services sit behind the same origin and both accept the same bearer * token: the main server under `/api/v3/*`, and the files-storage service * under `/api/v3/file/*`. The JWT from the browser's `jwt` cookie works * verbatim as `Authorization: Bearer` — no cookie jar or session refresh is * involved, and the token is valid for 30 days (see docs/AUTH.md). * * Every method that touches user data is a GET. Keeping the client incapable of * writing is the main safety property of this server: whoever reaches the MCP * endpoint can read this account's data but cannot act as the user inside * Schulcloud. `extendSession` is the single exception and is not exposed as a * tool, so no model-driven call can ever be a POST. */ export class SchulcloudClient { private readonly config: Config; constructor(config: Config) { this.config = config; } // --- transport ------------------------------------------------------- private url(path: string, query?: Record): URL { const url = new URL(`${this.config.baseUrl}${path}`); for (const [key, value] of Object.entries(query ?? {})) { if (value === undefined) continue; if (Array.isArray(value)) for (const v of value) url.searchParams.append(key, v); else url.searchParams.set(key, String(value)); } return url; } /** * One upstream GET, with retries for the transient failures. * * `auth` picks how the session travels. The v3 API takes it as a bearer * token; the legacy client's pages take it only as the `jwt` cookie; and a * pre-signed storage URL must get **nothing** — it lives on another host, and * the session token has no business leaving this instance. Anything but the * bearer form is fetched with redirects off, so a login bounce or a hop to a * third host is seen rather than silently followed with credentials attached. */ private async request( url: URL, accept: string, auth: 'bearer' | 'cookie' | 'none' = 'bearer', options: { idleTimeout?: boolean; token?: string } = {}, ): Promise { let lastError: unknown; const headers: Record = { Accept: accept }; // Read at the moment of use, never earlier: the token can be replaced // while the server runs (core/session-token.ts). const jwt = options.token ?? this.config.jwt; if (auth === 'bearer') headers.Authorization = `Bearer ${jwt}`; if (auth === 'cookie') headers.Cookie = `jwt=${jwt}`; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { if (attempt > 0) await delay(backoffMs(attempt)); let response: Response; const deadline = options.idleTimeout ? idleDeadline(this.config.requestTimeoutMs) : undefined; try { response = await fetch(url, { headers, signal: deadline?.signal ?? AbortSignal.timeout(this.config.requestTimeoutMs), redirect: auth === 'bearer' ? 'follow' : 'manual', }); } catch (error) { deadline?.stop(); // Connection reset or timeout: worth one more try, since every call // here is an idempotent GET. lastError = error; if (attempt === MAX_RETRIES) throw error; continue; } if (response.ok) return deadline ? deadline.watch(response) : response; deadline?.stop(); const body = await response.text().catch(() => ''); // A pre-signed URL's query string is its credential, so it never goes // into an error message; nor does the storage host's error body. const error = auth === 'none' ? new SchulcloudApiError(response.status, `${url.host} (pre-signed download)`, '') : new SchulcloudApiError(response.status, url.pathname + url.search, body); // A crawl issues hundreds of requests and the instance answers some of // them with a 503 front-page when it decides we are going too fast. // Observed live: 4 of 26 course pages failed that way in one crawl, and // all of them succeeded on a retry. 429 and the other gateway errors // are the same kind of "come back shortly". if (!RETRYABLE.has(response.status) || attempt === MAX_RETRIES) throw error; const retryAfter = Number(response.headers.get('retry-after')); if (Number.isFinite(retryAfter) && retryAfter > 0) await delay(retryAfter * 1000); lastError = error; } throw lastError instanceof Error ? lastError : new Error('request failed'); } /** Authenticated GET returning JSON. `path` is absolute, e.g. `/api/v3/courses`. */ async getJson(path: string, query?: Record): Promise { const response = await this.request(this.url(path, query), 'application/json'); return (await response.json()) as T; } /** * Authenticated GET returning bytes, capped at `maxDownloadBytes`. * * The cap is enforced while streaming rather than via Content-Length, so a * mis-declared or chunked response still can't exhaust memory. */ async getBytes(path: string, fallbackName: string): Promise { const url = this.url(path); const response = await this.request(url, '*/*', 'bearer', { idleTimeout: true }); return this.readCapped(response, fallbackName); } /** Reads a response body up to `maxDownloadBytes`, flagging anything cut off. */ private async readCapped(response: Response, fallbackName: string): Promise { const limit = this.config.maxDownloadBytes; const chunks: Buffer[] = []; let total = 0; let truncated = false; if (response.body) { const reader = response.body.getReader(); try { while (total < limit) { const { done, value } = await reader.read(); if (done) break; const chunk = Buffer.from(value); const room = limit - total; if (chunk.length > room) { chunks.push(chunk.subarray(0, room)); total = limit; truncated = true; break; } chunks.push(chunk); total += chunk.length; } if (total >= limit) { // Anything still queued is beyond the cap; drop the rest. const { done } = await reader.read(); if (!done) truncated = true; } } finally { await reader.cancel().catch(() => {}); } } return { bytes: Buffer.concat(chunks), mimeType: response.headers.get('content-type')?.split(';')[0]?.trim() || 'application/octet-stream', fileName: filenameFromDisposition(response.headers.get('content-disposition')) ?? fallbackName, truncated, }; } // --- identity -------------------------------------------------------- me(): Promise { return this.getJson('/api/v3/me'); } /** * `/me` as another token sees it, leaving the token in use untouched — how a * replacement is checked before it is swapped in. */ async meAs(token: string): Promise { const response = await this.request(this.url('/api/v3/me'), 'application/json', 'bearer', { token }); return (await response.json()) as MeResponse; } // --- session --------------------------------------------------------- /** * Extends the current session and reports its remaining budget. * * **The only non-GET request in this server, and deliberately so.** It is * what the web UI's "Sitzung verlängern" button calls. It takes no body, * touches nothing but the caller's own session, and cannot read or change * any user data — so it does not weaken the property that matters: nobody * reaching this server can act as the user inside Schulcloud. No MCP tool * exposes it, so Claude can never cause a POST; only the keepalive calls it. * * Using a plain GET here was tried and does not work — see docs/AUTH.md for * the endurance test that ruled it out. */ async extendSession(): Promise<{ expiresInSeconds: number }> { const url = this.url('/api/v3/authentication/refresh-session'); const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${this.config.jwt}`, Accept: 'application/json', 'Content-Length': '0', }, signal: AbortSignal.timeout(this.config.requestTimeoutMs), }); if (!response.ok) { const body = await response.text().catch(() => ''); throw new SchulcloudApiError(response.status, url.pathname, body); } return (await response.json()) as { expiresInSeconds: number }; } // --- courses and the classic course board ---------------------------- listCourses(params: { skip?: number; limit?: number } = {}): Promise> { return this.getJson>('/api/v3/courses', { skip: params.skip, limit: clampPageSize(params.limit), }); } /** Every course the account can see, paging past the API's per-page ceiling. */ listAllCourses(max = 500): Promise { return collectPages((skip, limit) => this.listCourses({ skip, limit }), max); } /** * The contents of one course, as the course page shows them: lessons, * tasks and column boards interleaved. The route is `course-rooms`, and * its `:roomId` is the *course* id. */ getCourseBoard(courseId: string): Promise { return this.getJson(`/api/v3/course-rooms/${encodeURIComponent(courseId)}/board`); } getDashboard(): Promise { return this.getJson('/api/v3/dashboard'); } // --- tasks ----------------------------------------------------------- listTasks(params: { skip?: number; limit?: number } = {}): Promise> { return this.getJson>('/api/v3/tasks', { skip: params.skip, limit: clampPageSize(params.limit), }); } listFinishedTasks(params: { skip?: number; limit?: number } = {}): Promise> { return this.getJson>('/api/v3/tasks/finished', { skip: params.skip, limit: clampPageSize(params.limit), }); } // --- submissions ----------------------------------------------------- /** * Submission statuses for one task. * * The only way to obtain a submission id: there is no `GET /submissions` and * no `GET /submissions/{id}`. What comes back depends on the account — a * student sees their own submission, a teacher sees the whole class's. */ async listSubmissionStatuses(taskId: string): Promise { const page = await this.getJson<{ data: SubmissionStatus[] }>( `/api/v3/submissions/status/task/${encodeURIComponent(taskId)}`, ); return page.data ?? []; } // --- lessons --------------------------------------------------------- getLesson(lessonId: string): Promise { return this.getJson(`/api/v3/lessons/${encodeURIComponent(lessonId)}`); } /** * A lesson's tasks. * * Returns a bare array, not the `{data, total}` envelope every other list * endpoint uses — checked against both the live instance and a local 33.40. * Typing it as `Paginated` made `.data` undefined, which silently dropped * every task attached to a topic: they vanished from get_lesson, get_task * reported them as non-existent, and their submissions — grades included — * could not be reached at all. The envelope branch is kept in case the * endpoint is ever normalised to match its siblings. */ async getLessonTasks(lessonId: string): Promise { const body = await this.getJson>( `/api/v3/lessons/${encodeURIComponent(lessonId)}/tasks`, ); return Array.isArray(body) ? body : (body.data ?? []); } // --- rooms ------------------------------------------------------------ /** * The rooms this account belongs to. * * Returns `{data}` with no `total` — not the usual paginated envelope. The * server derives the list from actual room memberships, so an empty result * means exactly that, and a room a teacher revoked access to simply stops * appearing. */ async listRooms(): Promise { const body = await this.getJson<{ data?: RoomItem[] }>('/api/v3/rooms'); return body.data ?? []; } getRoom(roomId: string): Promise { return this.getJson(`/api/v3/rooms/${encodeURIComponent(roomId)}`); } async listRoomBoards(roomId: string): Promise { const body = await this.getJson>( `/api/v3/rooms/${encodeURIComponent(roomId)}/boards`, ); return body.data ?? []; } /** * A room's members. Needs no special permission for a member to see who else * is in the room, but it can still be refused — callers treat that as "not * available" rather than as an error worth surfacing. */ async listRoomMembers(roomId: string): Promise { const body = await this.getJson<{ data?: RoomMember[] }>( `/api/v3/rooms/${encodeURIComponent(roomId)}/members`, ); return body.data ?? []; } /** * People waiting to be let into a room, and the room's invitation links. * * Both are room-admin surface: a viewer gets 403, which is ordinary rather * than exceptional. `allowedOperations` on the room says which of these the * account may ask for, so callers can skip the ones that would be refused. */ async listRoomApplicants(roomId: string): Promise { const body = await this.getJson<{ data?: RoomApplicant[] }>( `/api/v3/rooms/${encodeURIComponent(roomId)}/applicants`, ); return body.data ?? []; } async listRoomInvitationLinks(roomId: string): Promise { const body = await this.getJson<{ data?: RoomInvitationLink[] }>( `/api/v3/rooms/${encodeURIComponent(roomId)}/room-invitation-links`, ); return body.data ?? []; } // --- column boards --------------------------------------------------- getBoardSkeleton(boardId: string): Promise { return this.getJson(`/api/v3/boards/${encodeURIComponent(boardId)}`); } getBoardContext(boardId: string): Promise { return this.getJson(`/api/v3/boards/${encodeURIComponent(boardId)}/context`); } /** * The content behind an H5P element: a quiz with all of its questions. * * `params` is what the player loads before it renders anything, so one GET * returns the whole exercise — every question, every answer option and which * of them is correct — even though the player then shows one question at a * time. There is nothing to step through and no page to scrape. * * The shape belongs to whichever H5P library the content uses, so it stays * `unknown` here and `core/h5p.ts` interprets it. */ getH5pParams(contentId: string): Promise { return this.getJson(`/api/v3/h5p-editor/params/${encodeURIComponent(contentId)}`); } /** * Card bodies for the given ids. * * **Never request more than 20 at once.** Express/NestJS parse the query * string with `qs`, whose default `arrayLimit` is 20: past that, repeated * `ids=` params stop becoming an array and become an object keyed `"0"`, * `"1"`, … `@IsMongoId({ each: true })` then iterates something that is not * an array and rejects every value, so the API answers * `"each value in ids must be a mongodb id"` — blaming the ids when the real * problem is how many there are. Verified against the live instance: 20 ids * return 200, 21 return 400, with identical ids. * * Nothing in the controller or its DTO says this; the limit lives in the * query parser underneath them. */ async getCards(cardIds: string[]): Promise { const CHUNK = MAX_IDS_PER_QUERY; const out: CardResponse[] = []; for (let i = 0; i < cardIds.length; i += CHUNK) { const chunk = cardIds.slice(i, i + CHUNK); const page = await this.getJson<{ data: CardResponse[] }>('/api/v3/cards', { ids: chunk }); out.push(...page.data); } return out; } // --- files ----------------------------------------------------------- /** * Files attached to one parent entity. * * `storageLocationId` is the school id for `storageLocation: 'school'`, * which is what every parent type in normal use resolves to. Board file * elements are addressed with `parentType: 'boardnodes'` and the *element* * id as `parentId`. */ listFiles(args: { storageLocationId: string; parentType: FileParentType; parentId: string; storageLocation?: 'school' | 'instance'; }): Promise> { const location = args.storageLocation ?? 'school'; const path = `/api/v3/file/list/${location}/${encodeURIComponent(args.storageLocationId)}` + `/${args.parentType}/${encodeURIComponent(args.parentId)}`; return this.getJson>(path); } getFileRecord(fileRecordId: string): Promise { return this.getJson(`/api/v3/file/${encodeURIComponent(fileRecordId)}`); } downloadFile(record: Pick): Promise { const path = `/api/v3/file/download/${encodeURIComponent(record.id)}/${encodeURIComponent(record.name)}`; return this.getBytes(path, record.name); } // --- misc ------------------------------------------------------------ listNews(params: { skip?: number; limit?: number } = {}): Promise> { return this.getJson>('/api/v3/news', { skip: params.skip, limit: clampPageSize(params.limit), }); } /** File count and total bytes under one parent, without listing the records. */ getParentFileStats(parentType: FileParentType, parentId: string): Promise { return this.getJson( `/api/v3/file/stats/${parentType}/${encodeURIComponent(parentId)}`, ); } /** * A rasterised preview of one file. * * The reason this exists: many course PDFs are image-only scans, so text * extraction legitimately yields nothing and their contents are otherwise * unreadable. A preview is a picture of the page, which Claude can read * directly. Only meaningful when the record's `previewStatus` is * `preview_possible`; anything else 404s or returns the placeholder. */ async getFilePreview( record: Pick, width?: PreviewWidth, ): Promise { // Two traps here, both of which answer with a 400 that names the value but // not the permitted set: // - `width` is an enum (50 | 150 | 500), not a free number; // - `outputFormat` accepts only `image/webp`. Omitting it is worse than // wrong: the preview is then rendered in the *source* format, so a PDF // comes back as a PDF and the whole point — a picture of the page — is // lost. const query = new URLSearchParams({ outputFormat: PREVIEW_OUTPUT_FORMAT }); if (width) query.set('width', String(width)); const path = `/api/v3/file/preview/${encodeURIComponent(record.id)}/${encodeURIComponent(record.name)}` + `?${query.toString()}`; const file = await this.getBytes(path, record.name); // The response labels itself `webp` rather than `image/webp`, which no // image consumer would accept. We asked for the format, so we know it. return file.mimeType.startsWith('image/') ? file : { ...file, mimeType: PREVIEW_OUTPUT_FORMAT }; } // --- legacy /api/v1 --------------------------------------------------- // // Exactly three legacy routes survive in the deployment's ingress table // (dof_app_deploy .../all/x_ingress.yml): courses, users and classes. They // are production surface, not a leftover — the table even notes why each // one is still needed. Everything else under /api/v1 is unrouted and 404s, // so do not reach for it. /** One course with the fields v3 drops: description, members, timetable. */ getLegacyCourse(courseId: string): Promise { return this.getJson(`/api/v1/courses/${encodeURIComponent(courseId)}`); } /** * One user's name. * * The only id-to-name mapping available: submission `submitters`, file * `creatorId` and course `teacherIds` are all bare ids, and no v3 route * resolves them for a non-admin. */ getLegacyUser(userId: string): Promise { return this.getJson(`/api/v1/users/${encodeURIComponent(userId)}`); } // --- groups and classes ------------------------------------------------ /** Classes ("Klassen") this account belongs to, with teacher names. */ async listClasses(): Promise { const body = await this.getJson>('/api/v3/groups/class', { limit: MAX_PAGE_SIZE }); return body.data ?? []; } /** Groups this account belongs to — room membership groups, classes, courses. */ async listGroups(): Promise { const body = await this.getJson>('/api/v3/groups', { limit: MAX_PAGE_SIZE }); return body.data ?? []; } // --- the "Dateien" file manager ------------------------------------------ // // Persönliche Dateien, Kurs-Dateien, Team-Dateien and Geteilte Dateien live in // the legacy file system, a different store from files-storage: listing a // course through /api/v3/file answers 0 files for a course holding dozens. // Its Feathers service is not in the public ingress, so the only way in is // the legacy client — HTML pages for listings, one JSON route for downloads. // See core/legacy-files.ts for the parsing and the path model. /** * One file-manager page, as HTML. * * **Only the listing routes are reachable here, by construction.** Several of * the legacy client's GET routes write: `GET /files/share/` mints a share * token when the file has none, and `GET /files/file?share=…` grants the * caller a permission on someone else's file. A GET-only client is therefore * not read-only against this surface by itself; the allowlist is what makes * the invariant hold. */ async getFileManagerPage(path: string): Promise { if (!FILE_MANAGER_PAGE.test(path)) { throw new Error(`refusing file-manager path outside the listing routes: ${path}`); } const response = await this.legacyRequest(path, 'text/html'); return response.text(); } /** * A pre-signed download URL for one legacy file. * * `name` only sets the download's filename; the server checks read access on * the id. The route is `/files/signedurl` rather than `/files/file`, which * answers the same thing as a redirect but also accepts `share`, the * parameter that writes. */ async getFileManagerSignedUrl(fileId: string, name: string): Promise { if (!/^[0-9a-f]{24}$/i.test(fileId)) throw new Error(`not a file id: ${fileId}`); const query = new URLSearchParams({ file: fileId, name: name || fileId }); const response = await this.legacyRequest(`/files/signedurl?${query.toString()}`, 'application/json'); // The server's error path *returns* its Forbidden rather than throwing it, // so a refused file arrives as a 200 whose body has no url. const body = (await response.json().catch(() => ({}))) as { url?: unknown; message?: unknown }; if (typeof body.url !== 'string' || !body.url) { throw new SchulcloudApiError(403, '/files/signedurl', typeof body.message === 'string' ? body.message : 'no download url'); } return body.url; } /** Downloads one legacy file: signed URL, then the bytes, capped like every download. */ async downloadFileManagerFile(fileId: string, name: string): Promise { const signed = await this.getFileManagerSignedUrl(fileId, name); const response = await this.openSignedUrl(signed); return this.readCapped(response, name); } /** * Opens a pre-signed storage URL — with no credentials at all. * * The URL names another host (live: an S3 endpoint at the storage provider), * so neither the bearer nor the cookie may go with it. It must also be * https whenever the instance is, which keeps a URL the server hands back from * pointing this process at a plaintext service on its own network. */ async openSignedUrl(signedUrl: string): Promise { const target = checkSignedUrl(signedUrl, this.config.baseUrl); return this.request(target, '*/*', 'none', { idleTimeout: true }); } private async legacyRequest(path: string, accept: string): Promise { try { return await this.request(this.url(path), accept, 'cookie'); } catch (error) { // The legacy client answers a rejected cookie with a redirect to its // login page. Report it as what it is, so tools say "token expired" // rather than "HTTP 302". if (error instanceof SchulcloudApiError && error.status >= 300 && error.status < 400) { throw new SchulcloudApiError(401, path, 'redirected to login: the session is not accepted'); } throw error; } } } /** * A timeout that measures silence rather than total time, for downloads. * * The request timeout is right for an API call and wrong for a file: it bounds * the whole transfer, so an 11 MB scan from a slow storage host was cut off at * 30 seconds while its bytes were still arriving — four files on the live * account, recorded as failures. Here the clock starts over with every chunk, * so only a transfer that stalls is abandoned. */ function idleDeadline(ms: number) { const controller = new AbortController(); const expire = () => controller.abort(new Error(`no data received for ${Math.round(ms / 1000)}s`)); let timer = setTimeout(expire, ms); // Never the reason a process stays alive: a caller that stops reading early // (the download cap) leaves the last timer behind. timer.unref(); const rearm = () => { clearTimeout(timer); timer = setTimeout(expire, ms); timer.unref(); }; const stop = () => clearTimeout(timer); const watch = (response: Response): Response => { if (!response.body) { stop(); return response; } rearm(); const body = response.body.pipeThrough( new TransformStream({ transform(chunk, output) { rearm(); output.enqueue(chunk); }, flush() { stop(); }, }), ); return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers }); }; return { signal: controller.signal, stop, watch }; } /** * The file-manager listing routes, and nothing else. * * Folders are addressed by id alone — `/files/courses/{course}/{folder}` holds * one folder segment however deep the folder is — so every listing fits one of * these shapes. `/files/my/{a}/{b}` exists too, but lists `b` exactly as * `/files/my/{b}` does, so it is not needed. */ const FILE_MANAGER_PAGE = /^\/files\/(?:(?:my|courses|teams|shared)\/|my\/[0-9a-f]{24}|(?:courses|teams)\/[0-9a-f]{24}(?:\/[0-9a-f]{24})?)$/i; /** Validates a pre-signed URL before anything is sent to it. Exported for testing. */ export function checkSignedUrl(signedUrl: string, baseUrl: string): URL { let target: URL; try { target = new URL(signedUrl); } catch { throw new Error('the download url the server returned is not a url'); } const instanceIsHttps = new URL(baseUrl).protocol === 'https:'; const allowed = instanceIsHttps ? ['https:'] : ['https:', 'http:']; if (!allowed.includes(target.protocol)) { throw new Error(`refusing a ${target.protocol} download url from an ${instanceIsHttps ? 'https' : 'http'} instance`); } if (target.username || target.password) { throw new Error('refusing a download url that carries credentials'); } return target; } /** * The list endpoints reject `limit` above 100 and document a maximum of 99, so * page at 99 and let `collectPages` stitch the results back together. */ export const MAX_PAGE_SIZE = 99; function clampPageSize(limit: number | undefined): number | undefined { if (limit === undefined) return undefined; return Math.min(Math.max(1, Math.trunc(limit)), MAX_PAGE_SIZE); } /** Follows `skip`/`limit` paging until `max` items or the server runs out. */ async function collectPages( fetchPage: (skip: number, limit: number) => Promise>, max: number, ): Promise { const items: T[] = []; let skip = 0; while (items.length < max) { const page = await fetchPage(skip, Math.min(MAX_PAGE_SIZE, max - items.length)); items.push(...page.data); skip += page.data.length; // Stop on an empty page too, so a server that ignores `skip` can't loop forever. if (page.data.length === 0 || skip >= page.total) break; } return items.slice(0, max); } function filenameFromDisposition(header: string | null): string | undefined { if (!header) return undefined; // Prefer RFC 5987 `filename*`, which carries the encoding explicitly. const extended = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(header); if (extended?.[1]) return safeDecode(extended[1].trim()); const plain = /filename="?([^";]+)"?/.exec(header); if (plain?.[1]) return safeDecode(plain[1].trim()); return undefined; } function safeDecode(value: string): string { try { return decodeURIComponent(value); } catch { return value; } }