Files
Schulcloud-MCP/scripts/session-diagnose.mjs
MechaCat02 237395e4f4 Run the tests on Node 22.16, and fix what a fresh setup tripped over
Node 22.16 runs TypeScript only behind --experimental-strip-types (on by
default from 22.18), so `npm test` failed on every file with
ERR_UNKNOWN_FILE_EXTENSION. The flag is harmless on newer versions.

probe and session-diagnose still imported dist/schulcloud/client.js, which
the move to core/ renamed, so both scripts died at import.

A relative MIRROR_DIR broke file serving: res.sendFile refuses a relative
path, and resolveWithin returns an absolute path only when its root is one.
The config resolves it once, at load.

package-lock.json is what `npm install` records today: the `schulcloud`
bin, and no stale peer flags. 101 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:15 +02:00

92 lines
4.1 KiB
JavaScript

#!/usr/bin/env node
/**
* Watches a Schulcloud session to confirm it is actually being held.
*
* The session is a Valkey whitelist entry with a 7200 s TTL that every
* authenticated request re-sets, so the keepalive should hold it to the JWT's
* 30-day ceiling. The thing that breaks it is not a clock: a Schulportal tab
* left open shares the same token and its client-side auto-logout issues
* `POST /api/v3/logout` ~2 h after login, deleting the shared key. See
* docs/AUTH.md.
*
* This calls refresh-session every 10 minutes and logs the reported budget, so
* the shape of the log at death tells you which it was:
*
* budget steady at 7200, then an abrupt 401 → revoked (open tab, or logout)
* budget decaying 7200 → 0 across pings → extension not taking effect
*
* Read-only with respect to user data. Usage: `npm run session-diagnose [minutes]`
* (default 150 — enough to pass the ~2 h mark where an open tab would strike).
*/
import { loadConfig } from '../dist/config.js';
import { SchulcloudClient, SchulcloudApiError } from '../dist/core/client.js';
const totalMinutes = Number(process.argv[2] ?? 150);
const INTERVAL_MS = 10 * 60_000;
const config = loadConfig();
const client = new SchulcloudClient(config);
const payload = JSON.parse(Buffer.from(config.jwt.split('.')[1], 'base64url').toString('utf8'));
const login = new Date(payload.iat * 1000);
const start = Date.now();
const stamp = () => {
const now = new Date();
const sinceStart = ((Date.now() - start) / 60000).toFixed(1);
const sinceLogin = ((Date.now() - login.getTime()) / 60000).toFixed(1);
return `${now.toISOString().slice(11, 19)}Z t+${sinceStart.padStart(6)}min login+${sinceLogin.padStart(6)}min`;
};
console.log(`session diagnosis — instance ${config.baseUrl}`);
console.log(`token jti ${payload.jti}`);
console.log(`login (iat) ${login.toISOString()} → login+2h = ${new Date(login.getTime() + 7200_000).toISOString()}`);
console.log(`pinging refresh-session every ${INTERVAL_MS / 60000} min for ${totalMinutes} min\n`);
const samples = [];
const deadline = start + totalMinutes * 60_000;
while (Date.now() < deadline) {
try {
const { expiresInSeconds } = await client.extendSession();
samples.push(expiresInSeconds);
console.log(`${stamp()} budget ${expiresInSeconds}s (${Math.round(expiresInSeconds / 60)} min)`);
} catch (error) {
const status = error instanceof SchulcloudApiError ? error.status : '—';
console.log(`${stamp()} FAILED ${status} — session is gone`);
verdict(samples, true);
process.exit(0);
}
await new Promise((resolve) => setTimeout(resolve, Math.min(INTERVAL_MS, deadline - Date.now())));
}
console.log(`\n${stamp()} reached the end of the window with the session still alive`);
verdict(samples, false);
function verdict(series, died) {
const minutesAlive = (Date.now() - login.getTime()) / 60000;
console.log('\n--- verdict ---');
console.log(`budget series: ${series.join(', ')}`);
if (!died) {
console.log(`PASS: session survived ${minutesAlive.toFixed(0)} min since login with refresh-session pings.`);
console.log('=> refresh-session DOES hold the session. The keepalive works; ship it.');
return;
}
const decayed = series.length >= 2 && series.at(-1) < series[0] - 60;
if (decayed) {
console.log(`Session ended ${minutesAlive.toFixed(0)} min after login, and the budget was DECAYING.`);
console.log('=> the extension is not taking effect. Shorten KEEPALIVE_INTERVAL_MS; the');
console.log(' series above shows the real decay rate.');
} else {
console.log(`Session ended ${minutesAlive.toFixed(0)} min after login while the budget still read ${series.at(-1)}s.`);
console.log('=> REVOKED from outside, not expired. The budget was healthy right up to the 401.');
if (minutesAlive > 100 && minutesAlive < 160) {
console.log(' ~2h after login is the signature of a Schulportal tab left open on this');
console.log(' token: its auto-logout calls POST /api/v3/logout and deletes the shared');
console.log(' key. Close every Schulportal window and use a fresh token (docs/AUTH.md).');
} else {
console.log(' Check for an explicit logout, or an IDP back-channel logout.');
}
}
}