The notes existed but there was nowhere to write them: a CLI command on a laptop, a tool call through Claude, or a file in a Docker volume. None of those is reachable from a phone in a lesson, which is where notes are actually taken. So: `/app`, served only when WEB_PASSWORD is set. A login, the day's notes, and a settings page for the Schulcloud token — the one surface here meant for a person rather than a program. The shape follows how the notes are written: one note per school day, one `##` heading per lesson, prose and lists and tables beneath. That turns out to be the design decision that matters, twice over. First, it is what lets WebUntis earn its keep. Opening a day with no note fills in that day's lessons — numbered, with times, teacher and room, cancellations dropped and substitutions marked. Retyping the timetable is exactly the work the second upstream exists to avoid, and "Stunden ergänzen" tops up a note started before the day ended without touching what is already written. Second, it changes how notes are indexed. A day note is indexed per lesson, not whole: search answers "my own note, Deutsch, 18.09.2026" rather than "my own note, Friday", and `list_notes subject=Deutsch` finds a day whose frontmatter names no subject at all. Indexed whole, every hit would read as a weekday and "what did we do in Deutsch" would match notes whose other five lessons were something else. `lessonHeading` and `subjectFromHeading` are a loop — the app writes the heading, the indexer reads the subject back out — and a test holds them to it. Notes taken in a lesson cannot be retaken, so the editor is built around not losing them: autosave, every keystroke mirrored to local storage, a save when the phone locks, and a fallback to the local copy when the request never arrives. A save that would overwrite a version the editor never saw is refused and the choice handed back — the notes folder is synced and open in more than one place, and a phone must not silently win over a laptop. `replaceNote` is separate from `writeNote` for that reason: never-overwrite is right for `add_note` and exactly wrong for an editor. WEB_PASSWORD is the first credential here a human types, so it is the first that can be guessed: scrypt at startup, never stored or compared in the clear, per-address rate limiting — which is not decoration, since the scrypt cost is itself a denial-of-service vector without it. The session is a signed HttpOnly SameSite=Strict cookie whose key is derived from the password, so changing it logs everyone out and there is no second secret to keep. It opens /api, because a session is the user, and never /mcp, because nothing in a browser speaks MCP. Also here, because the app made them matter: frontmatter now reads the indented `- item` list form editors write, so an Obsidian vault round-trips its tags; and a four-digit folder is a filing scheme, not a subject, so `2026/` does not file a school year under one. 357 tests; 106/107 smoke against the local instance, the one failure being the H5P service that instance does not run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
270 lines
11 KiB
TypeScript
270 lines
11 KiB
TypeScript
/**
|
|
* 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 `/<secret>/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(),
|
|
};
|
|
}
|