import type { SchulcloudClient } from './schulcloud/client.ts'; import { SchulcloudApiError } from './schulcloud/client.ts'; /** * Keeps the Schulcloud session alive. * * The JWT's `exp` claim says 30 days, but the session dies far sooner, and the * mechanism is not what the upstream source suggests. Both the current * (`JwtWhitelistAdapter`) and legacy (Feathers `ensureTokenIsWhitelisted`) * implementations re-set a Valkey TTL on every authenticated request, which * would make the window slide with ordinary use. Measured against the live * instance, it does not: * * A keepalive doing only `GET /api/v3/me` every 30 min was pinged * successfully at t+0/30/60/90 and was nonetheless rejected by t+120 — * almost exactly two hours after *login*, not two hours after the last * request, which would have been t+210. * * So the binding clock is anchored at login and ordinary reads do not move it. * We therefore extend explicitly, via the endpoint the web UI's "Sitzung * verlängern" button uses, which also reports the remaining budget so the log * shows whether the extension actually took. * * Whether that can carry a session past login+2h at all is the open question; * `npm run session-diagnose` is the instrument for settling it. If it cannot, * no keepalive will help and the auth approach itself needs revisiting — see * docs/AUTH.md. */ 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 { 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 — ' + 'measured behaviour is that it ends roughly 2h after login regardless of ' + 'activity, and the JWT also has a 30-day hard 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); } } }