/** * Runtime configuration, read once from the environment — except `jwt`, which * can be replaced while the server runs. * * The two Schulcloud values are named after the browser artefacts they come * from (`TSC_URL`, `TSC_JWT_COOKIE`) so that copying a fresh token out of * DevTools stays an obvious, mechanical step — see docs/AUTH.md. */ import { resolve } from 'node:path'; import type { UntisConfig } from './core/untis.ts'; export interface Config { /** Instance base URL, no trailing slash, e.g. `https://schulcloud-thueringen.de`. */ baseUrl: string; /** * Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`. * Replaced at runtime by core/session-token.ts, so read it at the moment of * use and never keep a copy. */ jwt: string; /** Shared secret callers must present to this MCP server. Unused in stdio mode. */ authToken: string | undefined; /** * A second token, accepted on `/mcp` only: the one claude.ai's connector * sends as a request header. It is stored by a third party, so it opens the * read-only MCP tools and not `/api`, which can replace the session token * and stream the file mirror — and it can be revoked on its own. */ connectorToken: string | undefined; /** * Serves MCP at `//mcp` without a bearer token, for clients that can * send none — claude.ai's connector dialog takes only a URL. The path is then * the credential, so it must never be logged. */ mcpPathSecret: string | undefined; /** Where state that must survive a restart is kept: a replaced session token. Unset = memory only. */ stateDir: string | undefined; port: number; bindHost: string; /** Hard ceiling on how many bytes `download_file` will pull from the instance. */ maxDownloadBytes: number; /** Characters of extracted text returned before truncation kicks in. */ maxExtractedChars: number; requestTimeoutMs: number; /** * How often to ping the instance to hold the session open. Must stay well * under the instance's `JWT_TIMEOUT_SECONDS` (7200s here) — see * src/core/keepalive.ts. Zero disables the keepalive. */ keepaliveIntervalMs: number; /** Postgres for the search index and file mirror. Unset = live-only mode. */ databaseUrl: string | undefined; /** Where mirrored file bytes live on disk. */ mirrorDir: string; /** Files larger than this are indexed as metadata but not mirrored. */ mirrorMaxBytes: number; /** Index personal files and submitted/returned work as well as course content. */ indexPersonalFiles: boolean; /** Walk the file manager (Kurs-, Persönliche, Team- and Geteilte Dateien) when crawling. */ indexFileManager: boolean; /** How often to re-crawl on a timer. Zero = only on demand. */ crawlIntervalMs: number; /** * Password for the web app at `/app` — the notes editor and the settings * page. Unset = the app is not served at all, by the same rule the untis_* * tools follow: a login screen no password can open is worse than no page. * * A credential, and the only one here a person types: it is hashed at * startup and the plain value is never compared, stored or logged. */ webPassword: string | undefined; /** * Where the user's own lesson notes live, as Markdown files. Unset = the * note tools are not offered, the same rule the untis_* tools follow. */ notesDir: string | undefined; /** * Whether add_note may write. The notes directory is the only thing in this * server anything can write to, so turning it off is a real setting and not * a theoretical one — a deployment that syncs its notes in from elsewhere * wants the files left alone. */ notesWritable: boolean; /** * How far back to read the WebUntis class register into the index. Zero = * not at all. Costs one timetable call per 90 days plus one per lesson * series, so a school year is a few dozen requests on a background crawl. */ untisHistoryDays: number; /** * WebUntis, where the school keeps the timetable. Unset = the untis_* tools * are not offered at all, which is the right answer for a school that does * not use it — Schulcloud alone cannot say what happens when. */ untis: UntisConfig | undefined; } function required(name: string): string { const value = process.env[name]?.trim(); if (!value) throw new Error(`Missing required environment variable ${name}`); return value; } /** `1`, `true`, `yes` and `on` are all true; anything else falls back. */ function bool(name: string, fallback: boolean): boolean { const raw = process.env[name]?.trim().toLowerCase(); if (!raw) return fallback; return ['1', 'true', 'yes', 'on'].includes(raw); } function int(name: string, fallback: number): number { const raw = process.env[name]?.trim(); if (!raw) return fallback; const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed) || parsed <= 0) { throw new Error(`Environment variable ${name} must be a positive integer, got ${raw}`); } return parsed; } /** A URL-safe secret of at least 32 characters, or undefined when unset. */ function pathSecret(name: string): string | undefined { const value = process.env[name]?.trim(); if (!value) return undefined; // The value is a credential: the error states the rule and never echoes it. if (!/^[A-Za-z0-9_-]{32,}$/.test(value)) { throw new Error( `Environment variable ${name} must be at least 32 characters of A-Z, a-z, 0-9, "-" or "_". ` + 'Generate one with: openssl rand -hex 32', ); } return value; } /** A token of at least 32 characters without whitespace, or undefined when unset. */ function secretToken(name: string): string | undefined { const value = process.env[name]?.trim(); if (!value) return undefined; // A credential: the error states the rule and never echoes the value. if (value.length < 32 || /\s/.test(value)) { throw new Error( `Environment variable ${name} must be at least 32 characters without spaces. ` + 'Generate one with: openssl rand -hex 32', ); } return value; } /** * The four WebUntis values, or undefined when none is set. * * All four or nothing: three of them are harmless identifiers and the fourth is * a credential, so a half-filled block is a copy-paste that went wrong, not a * configuration to guess at. They come from one dialog — WebUntis → Profil → * Freigaben → Untis Mobile → QR-Code — and the error says so, because that is * the only place to find them. */ function untisConfig(): UntisConfig | undefined { const server = process.env.UNTIS_SERVER?.trim(); const school = process.env.UNTIS_SCHOOL?.trim(); const user = process.env.UNTIS_USER?.trim(); const secret = process.env.UNTIS_SECRET?.trim(); const missing = Object.entries({ UNTIS_SERVER: server, UNTIS_SCHOOL: school, UNTIS_USER: user, UNTIS_SECRET: secret }) .filter(([, value]) => !value) .map(([name]) => name); if (missing.length === 4) return undefined; if (!server || !school || !user || !secret) { throw new Error( `WebUntis needs all four of UNTIS_SERVER, UNTIS_SCHOOL, UNTIS_USER and UNTIS_SECRET — missing: ` + `${missing.join(', ')}. All four are in WebUntis → Profil → Freigaben → Untis Mobile → QR-Code.`, ); } if (!/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(server)) { throw new Error( `UNTIS_SERVER must be the bare host from the QR dialog's "Url" field, e.g. "ags-erfurt.webuntis.com" — ` + `no scheme and no path, got "${server}".`, ); } // A credential: the error states the rule and never echoes the value. if (!/^[A-Za-z2-7]{8,}$/.test(secret)) { throw new Error( 'UNTIS_SECRET must be the key from the Untis Mobile QR dialog: at least 8 characters of A-Z and 2-7 ' + '(base32), no spaces.', ); } return { server, school, user, secret }; } /** * The app password, or undefined when the app is switched off. * * A length floor and nothing else: this one is typed by a person on a phone, * so demanding punctuation would buy little and cost the thing that actually * matters, which is that they pick something long. The error states the rule * and never echoes the value. */ function webPassword(): string | undefined { const value = process.env.WEB_PASSWORD; if (!value) return undefined; if (value.length < 12) { throw new Error( 'WEB_PASSWORD must be at least 12 characters — it is the only thing between the internet and the ' + 'notes app. A passphrase of three or four words is ideal.', ); } return value; } /** Like `int`, but 0 is meaningful (it disables the feature) rather than invalid. */ function intAllowingZero(name: string, fallback: number): number { const raw = process.env[name]?.trim(); if (!raw) return fallback; const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed) || parsed < 0) { throw new Error(`Environment variable ${name} must be a non-negative integer, got ${raw}`); } return parsed; } export function loadConfig(): Config { const authToken = process.env.MCP_AUTH_TOKEN?.trim() || undefined; const connectorToken = secretToken('MCP_CONNECTOR_TOKEN'); if (connectorToken && !authToken) { // Without the main token /api would be open while /mcp is not. throw new Error('MCP_CONNECTOR_TOKEN needs MCP_AUTH_TOKEN as well, or /api would be left unauthenticated.'); } if (connectorToken && connectorToken === authToken) { throw new Error('MCP_CONNECTOR_TOKEN must differ from MCP_AUTH_TOKEN, or it cannot be limited to /mcp or revoked on its own.'); } return { baseUrl: required('TSC_URL').replace(/\/+$/, ''), jwt: required('TSC_JWT_COOKIE'), authToken, connectorToken, mcpPathSecret: pathSecret('MCP_PATH_SECRET'), stateDir: process.env.STATE_DIR?.trim() ? resolve(process.env.STATE_DIR.trim()) : undefined, port: int('PORT', 8080), bindHost: process.env.BIND_HOST?.trim() || '0.0.0.0', maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024), maxExtractedChars: int('MAX_EXTRACTED_CHARS', 120_000), requestTimeoutMs: int('REQUEST_TIMEOUT_MS', 30_000), keepaliveIntervalMs: intAllowingZero('KEEPALIVE_INTERVAL_MS', 30 * 60_000), databaseUrl: process.env.DATABASE_URL?.trim() || undefined, // Absolute: res.sendFile rejects a relative path, and resolveWithin only // returns an absolute path if the root it is given is one. mirrorDir: resolve(process.env.MIRROR_DIR?.trim() || '/var/lib/schulcloud-mcp/mirror'), mirrorMaxBytes: int('MIRROR_MAX_BYTES', 64 * 1024 * 1024), // Off by default: submissions are per task, so this roughly doubles the // cost of a full crawl. Worth turning on to make your own handed-in work // searchable, which no other route offers. indexPersonalFiles: bool('INDEX_PERSONAL_FILES', false), // On by default: many teachers keep their material only in Kurs-Dateien, // so an index without it misses whole courses. One page load per folder. indexFileManager: bool('INDEX_FILE_MANAGER', true), crawlIntervalMs: intAllowingZero('CRAWL_INTERVAL_MS', 6 * 60 * 60_000), // Absolute for the same reason as the mirror: resolveWithin only returns // an absolute path when the root it is given is one. webPassword: webPassword(), notesDir: process.env.NOTES_DIR?.trim() ? resolve(process.env.NOTES_DIR.trim()) : undefined, notesWritable: !bool('NOTES_READONLY', false), untisHistoryDays: intAllowingZero('UNTIS_HISTORY_DAYS', 180), untis: untisConfig(), }; }