With .env pointing at the real account, testing against the local instance became dangerous: process env beats --env-file, so a local smoke run would crawl fixtures straight into the live index, where a per-course refresh carries them forward indefinitely. mcp-env.sh now pins its own database (schulcloud_local) and mirror as well as the instance. The override publishes the server on MCP_HOST_PORT and takes CRAWL_INTERVAL_MS from .env, since what_changed can only report what happened between crawls. The build context leaves out tmp/, which holds a mirror of the account's files, and local-instance/. probe checks live what the gap fixes established locally: the /api/v1 course and user routes, classes, room allowedOperations as an object, and the preview enums on a real file. A failure there means a feature degrades rather than breaks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
225 lines
9.8 KiB
JavaScript
225 lines
9.8 KiB
JavaScript
#!/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/core/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))`);
|
|
}
|
|
|
|
// --- assumptions first established against the local 33.40 instance ----
|
|
// Each of these was verified locally and against the deployment's ingress
|
|
// table, not against this instance. A FAIL here means the matching feature
|
|
// degrades (to bare ids, to no timetable, to no preview) rather than breaks.
|
|
console.log('\nlegacy routes, classes, rooms:');
|
|
const firstCourse = courses[0];
|
|
if (firstCourse) {
|
|
await expect('GET /api/v1/courses/{id} (description, teachers, timetable)', async () => {
|
|
const legacy = await client.getLegacyCourse(firstCourse.id);
|
|
const parts = [
|
|
legacy.description ? 'description' : null,
|
|
legacy.teacherIds?.length ? `${legacy.teacherIds.length} teacher id(s)` : null,
|
|
legacy.times?.length ? `${legacy.times.length} timetable slot(s)` : null,
|
|
].filter(Boolean);
|
|
return parts.length > 0 ? parts.join(', ') : 'responds, but carries none of the fields';
|
|
});
|
|
}
|
|
await expect('GET /api/v1/users/{me} (the only id -> name route)', async () => {
|
|
const user = await client.getLegacyUser(me.user.id);
|
|
return user.firstName || user.fullName ? 'resolves your own name' : 'responds without a name';
|
|
});
|
|
const teacherId = firstCourse ? (await client.getLegacyCourse(firstCourse.id).catch(() => undefined))?.teacherIds?.[0] : undefined;
|
|
if (teacherId) {
|
|
// Expected to be refused for a student: names then degrade to "not
|
|
// visible to this account". Reported, not failed, either way.
|
|
const seen = await client.getLegacyUser(teacherId).then(() => 'readable', (error) => `refused (${error.status ?? error.message})`);
|
|
console.log(` — GET /api/v1/users/{teacher}: ${seen} — a student is expected to be refused`);
|
|
}
|
|
await expect('GET /api/v3/groups/class', async () => {
|
|
const classes = await client.listClasses();
|
|
const named = classes.filter((entry) => entry.teacherNames?.length).length;
|
|
return `${classes.length} class(es), ${named} with teacher names`;
|
|
});
|
|
const rooms = await client.listRooms().catch(() => []);
|
|
if (rooms[0]) {
|
|
await expect('room allowedOperations is an object, not a list', async () => {
|
|
const room = await client.getRoom(rooms[0].id);
|
|
const ops = room.allowedOperations;
|
|
if (Array.isArray(ops)) throw new Error('it is an array here — fix RoomItem.allowedOperations and rooms.ts');
|
|
return `${Object.values(ops ?? {}).filter(Boolean).length} operation(s) granted`;
|
|
});
|
|
} else {
|
|
console.log(' — no room to check allowedOperations against (in none is normal)');
|
|
}
|
|
|
|
// The preview route is the answer for image-only PDFs, and it has two
|
|
// undocumented enums. Only testable with a file whose preview is possible.
|
|
if (boardId) {
|
|
const skeleton = await client.getBoardSkeleton(boardId);
|
|
const cards = await client.getCards(skeleton.columns.flatMap((c) => c.cards.map((x) => x.cardId)).slice(0, 20));
|
|
const pdfElement = cards.flatMap((c) => c.elements).filter((e) => e.type === 'file' || e.type === 'fileFolder');
|
|
let previewed = false;
|
|
for (const element of pdfElement) {
|
|
const page = await client
|
|
.listFiles({ storageLocationId: me.school.id, parentType: 'boardnodes', parentId: element.id })
|
|
.catch(() => undefined);
|
|
const record = page?.data.find((file) => file.previewStatus === 'preview_possible');
|
|
if (!record) continue;
|
|
await expect(`GET /api/v3/file/preview (width=500, outputFormat=image/webp) on ${record.name}`, async () => {
|
|
const preview = await client.getFilePreview(record, 500);
|
|
return `${preview.mimeType}, ${preview.bytes.length} bytes`;
|
|
});
|
|
previewed = true;
|
|
break;
|
|
}
|
|
if (!previewed) console.log(' — no previewable file among the sampled cards');
|
|
}
|
|
}
|
|
|
|
async function expect(label, run) {
|
|
try {
|
|
const detail = await run();
|
|
console.log(` ok ${label}${detail ? ` (${detail})` : ''}`);
|
|
} catch (error) {
|
|
const status = error instanceof SchulcloudApiError ? error.status : '—';
|
|
console.log(` FAIL ${label} → ${status} ${String(error.message).slice(0, 120)}`);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|