Moves the reusable half into src/core/ (client, types, board, extract, text, keepalive) and the MCP half into src/mcp/. The layering was already clean — nothing in core imported app code or read process.env — so this is a move, not a redesign, and the smoke suite stayed the oracle throughout. The substantive part is core/crawl.ts. The course->board->card->element ->file traversal previously existed only inside tools/search.ts, and the indexer, what's-new diff and file mirror all need it. It now returns a typed Snapshot with breadcrumbs, sorted so two crawls of unchanged content compare equal. Metadata only: downloading and extracting bytes is an order of magnitude more expensive and only the indexer wants it. core/match.ts holds the keyword matching, which makes it testable without a network, and core/text.ts gains the fold/tokenize/snippet helpers (accent folding is not optional for German). search now finds strictly more than before — 5 hits vs 3 for "Datenschutz" — because the snapshot surfaces file-name matches the old streaming walk skipped. 34 unit tests and 30/30 smoke checks pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
102 lines
4.0 KiB
TypeScript
102 lines
4.0 KiB
TypeScript
import type { SchulcloudClient } from './client.ts';
|
|
import { SchulcloudApiError } from './client.ts';
|
|
|
|
/**
|
|
* Keeps the Schulcloud session alive.
|
|
*
|
|
* The JWT's `exp` claim (30 days) is only an outer ceiling. The binding limit
|
|
* is a Valkey whitelist entry, `jwt:{accountId}:{jti}`, with a
|
|
* `JWT_TIMEOUT_SECONDS` TTL — 7200s on this instance, readable from
|
|
* `GET /api/v3/config/public`. Every authenticated request re-sets it, so the
|
|
* window slides and periodic traffic holds a session to the 30-day ceiling.
|
|
*
|
|
* A plain GET would therefore do. We call `refresh-session` instead, the
|
|
* endpoint behind the web UI's "Sitzung verlängern" button, for two reasons:
|
|
* it states the intent contractually rather than depending on a side effect of
|
|
* an unrelated read (upstream has refactored this whitelist twice in 2026, and
|
|
* a GET-based keepalive would fail *silently* if extend-on-check went away),
|
|
* and it returns the remaining budget, so the log answers "is the session
|
|
* healthy" directly.
|
|
*
|
|
* What this CANNOT protect against: a Schulportal tab left open on the same
|
|
* token. The browser's `jwt` cookie is the same session, and the front end's
|
|
* client-side timer calls logout roughly two hours after login, deleting the
|
|
* shared key out from under us. See docs/AUTH.md — the fix is to close the tab,
|
|
* not to ping harder.
|
|
*/
|
|
export class SessionKeepalive {
|
|
private timer: NodeJS.Timeout | undefined;
|
|
private stopped = false;
|
|
private readonly client: SchulcloudClient;
|
|
private readonly intervalMs: number;
|
|
/** Retry delay after a failed ping — shorter, to use up the remaining budget. */
|
|
private readonly retryMs: number;
|
|
private readonly log: (message: string) => void;
|
|
|
|
constructor(
|
|
client: SchulcloudClient,
|
|
intervalMs: number,
|
|
retryMs: number = Math.min(5 * 60_000, intervalMs),
|
|
log: (message: string) => void = (message) => console.error(message),
|
|
) {
|
|
this.client = client;
|
|
this.intervalMs = intervalMs;
|
|
this.retryMs = retryMs;
|
|
this.log = log;
|
|
}
|
|
|
|
/** Pings once now (validating the token at startup), then on the interval. */
|
|
start(): void {
|
|
this.stopped = false;
|
|
void this.tick();
|
|
}
|
|
|
|
stop(): void {
|
|
this.stopped = true;
|
|
if (this.timer) clearTimeout(this.timer);
|
|
this.timer = undefined;
|
|
}
|
|
|
|
private schedule(delayMs: number): void {
|
|
if (this.stopped) return;
|
|
this.timer = setTimeout(() => void this.tick(), delayMs);
|
|
// Never hold the process open just for a keepalive.
|
|
this.timer.unref();
|
|
}
|
|
|
|
private async tick(): Promise<void> {
|
|
if (this.stopped) return;
|
|
try {
|
|
const { expiresInSeconds } = await this.client.extendSession();
|
|
// A budget well below the instance's JWT_TIMEOUT_SECONDS means the
|
|
// extension is not taking effect — worth seeing in the log, because it
|
|
// is the early warning that the session is about to be lost.
|
|
this.log(
|
|
`[schulcloud-mcp] keepalive: session extended, ${expiresInSeconds}s ` +
|
|
`(${Math.round(expiresInSeconds / 60)} min) of budget left`,
|
|
);
|
|
this.schedule(this.intervalMs);
|
|
} catch (error) {
|
|
if (error instanceof SchulcloudApiError && error.isAuthFailure) {
|
|
// Past saving: the whitelist entry is gone, or the JWT hit its 30-day
|
|
// ceiling. Pinging harder cannot revive it — a human must paste a new
|
|
// token — so stop and say so loudly rather than logging every 30 min.
|
|
this.log(
|
|
'[schulcloud-mcp] keepalive: token rejected (401). The session is gone. ' +
|
|
'If this is ~2h after login, the likely cause is a Schulportal tab left open ' +
|
|
'on the same token, whose auto-logout revoked it — close the tab. Otherwise ' +
|
|
'the server was down past the 2h window, or the JWT hit its 30-day limit. ' +
|
|
'Put a fresh jwt cookie in TSC_JWT_COOKIE and restart. Keepalive stopped.',
|
|
);
|
|
this.stop();
|
|
return;
|
|
}
|
|
this.log(
|
|
`[schulcloud-mcp] keepalive: ping failed (${error instanceof Error ? error.message : String(error)}); ` +
|
|
`retrying in ${Math.round(this.retryMs / 1000)}s`,
|
|
);
|
|
this.schedule(this.retryMs);
|
|
}
|
|
}
|
|
}
|