#!/usr/bin/env node /** * Verifies this server's assumptions against the live instance and prints what * it finds. Run it after a Schulcloud release, or when a tool starts failing, * to tell "the token expired" apart from "the API moved". * * Read-only. Usage: `node --env-file=.env scripts/probe.mjs` */ import { loadConfig } from '../dist/config.js'; import { SchulcloudClient, SchulcloudApiError } from '../dist/schulcloud/client.js'; const config = loadConfig(); const client = new SchulcloudClient(config); console.log(`instance: ${config.baseUrl}\n`); // --- token --------------------------------------------------------------- // Two independent clocks govern the token, and only one of them is in the JWT: // exp — a hard 30-day ceiling, cannot be extended. // whitelist TTL — JWT_TIMEOUT_SECONDS (2h here), reset by every request. // The second one is what actually kills idle sessions, so report both. const payload = decodeJwt(config.jwt); if (payload) { const daysLeft = (payload.exp * 1000 - Date.now()) / 86_400_000; console.log(`token: issued ${new Date(payload.iat * 1000).toISOString().slice(0, 16)}Z, ` + `hard expiry ${new Date(payload.exp * 1000).toISOString().slice(0, 10)} (${daysLeft.toFixed(1)} days left)`); if (daysLeft < 0) console.log(' *** PAST HARD EXPIRY — copy a fresh jwt cookie, see docs/AUTH.md'); else if (daysLeft < 5) console.log(' *** hard expiry approaching — plan to copy a fresh jwt cookie'); } else { console.log('token: could not decode (not a JWT?)'); } // The instance publishes its own session settings, unauthenticated. try { const publicConfig = await client.getJson('/api/v3/config/public'); console.log(`instance: JWT_TIMEOUT_SECONDS=${publicConfig.JWT_TIMEOUT_SECONDS} ` + `(idle timeout), warning shown at ${publicConfig.JWT_SHOW_TIMEOUT_WARNING_SECONDS}s remaining`); } catch { console.log('instance: could not read /api/v3/config/public'); } // refresh-session reports the whitelist TTL. It is the one non-GET call in this // repo, and it lives here in a diagnostic rather than in the server, whose every // upstream call is a GET. It extends the session no more than any GET does. try { const response = await fetch(`${config.baseUrl}/api/v3/authentication/refresh-session`, { method: 'POST', headers: { Authorization: `Bearer ${config.jwt}`, 'Content-Length': '0' }, }); if (response.ok) { const { expiresInSeconds } = await response.json(); console.log(`session: ${expiresInSeconds}s of idle budget left ` + `(${(expiresInSeconds / 60).toFixed(0)} min) — any request resets this`); } else { console.log(`session: refresh-session returned ${response.status}` + (response.status === 401 ? ' *** session expired through inactivity or hard expiry' : '')); } } catch (error) { console.log(`session: could not read TTL (${error.message})`); } // --- endpoints this server depends on ------------------------------------ const checks = [ ['GET /api/v3/me', () => client.me()], ['GET /api/v3/courses', () => client.listCourses({ limit: 1 })], ['GET /api/v3/dashboard', () => client.getDashboard()], ['GET /api/v3/tasks', () => client.listTasks({ limit: 1 })], ['GET /api/v3/tasks/finished', () => client.listFinishedTasks({ limit: 1 })], ['GET /api/v3/news', () => client.listNews({ limit: 1 })], ['GET /api/v3/docs-json', () => client.getJson('/api/v3/docs-json')], ['GET /api/v3/file/docs-json', () => client.getJson('/api/v3/file/docs-json')], ]; console.log('\ncore endpoints:'); let me; for (const [label, run] of checks) { try { const result = await run(); if (label.endsWith('/me')) me = result; console.log(` ok ${label}${summarize(result)}`); } catch (error) { const status = error instanceof SchulcloudApiError ? error.status : '—'; console.log(` FAIL ${label} → ${status} ${error.message.slice(0, 120)}`); } } // --- the chains that make the content tools work ------------------------- if (me) { console.log('\ncontent chain:'); const courses = await client.listAllCourses(); console.log(` ${courses.length} course(s) visible`); let boardId, lessonId; for (const course of courses) { const page = await client.getCourseBoard(course.id).catch(() => null); if (!page) continue; boardId ??= page.elements.find((e) => e.type === 'column-board')?.content.id; lessonId ??= page.elements.find((e) => e.type === 'lesson')?.content.id; if (boardId && lessonId) break; } if (boardId) { const skeleton = await client.getBoardSkeleton(boardId); const cardIds = skeleton.columns.flatMap((c) => c.cards.map((x) => x.cardId)); const cards = await client.getCards(cardIds.slice(0, 5)); console.log(` ok board → columns → cards (board ${boardId}: ${skeleton.columns.length} col, ${cardIds.length} cards)`); const fileElement = cards.flatMap((c) => c.elements).find((e) => e.type === 'file'); if (fileElement) { const files = await client.listFiles({ storageLocationId: me.school.id, parentType: 'boardnodes', parentId: fileElement.id, }); console.log(` ok file element → files-storage (${files.total} file(s) on element ${fileElement.id})`); } else { console.log(' — no file element among the sampled cards'); } } else { console.log(' — no column board found to test with'); } if (lessonId) { const lesson = await client.getLesson(lessonId); console.log(` ok lesson ${lessonId} ("${lesson.name}", ${lesson.contents?.length ?? 0} section(s))`); } } function summarize(result) { if (result && typeof result === 'object') { if ('total' in result) return ` (total ${result.total})`; if ('paths' in result) return ` (${Object.keys(result.paths).length} paths)`; if ('school' in result) return ` (${result.school.name})`; if ('gridElements' in result) return ` (${result.gridElements.length} tiles)`; } return ''; } function decodeJwt(token) { try { const part = token.split('.')[1]; return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); } catch { return null; } }