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>
165 lines
8.0 KiB
Plaintext
165 lines
8.0 KiB
Plaintext
# ---------------------------------------------------------------------------
|
|
# Schulcloud instance
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Base URL of the instance, no trailing slash.
|
|
TSC_URL=https://schulcloud-thueringen.de
|
|
|
|
# The value of the `jwt` cookie from a logged-in browser session.
|
|
# The 30-day `exp` is only a ceiling; the real limit is a 2-hour sliding session
|
|
# TTL that the built-in keepalive holds open. IMPORTANT: close the Schulportal
|
|
# window after copying this — an open tab shares the session and its auto-logout
|
|
# will revoke this token ~2h after login. See docs/AUTH.md.
|
|
# Needed for the first start. Later tokens go in with `schulcloud token set` or
|
|
# the server's /token page, without a restart; a saved newer one wins over this.
|
|
TSC_JWT_COOKIE=
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# This MCP server
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Shared secret callers must present as `Authorization: Bearer <token>`.
|
|
# REQUIRED for the public deployment — without it the endpoint is open to
|
|
# anyone who finds the hostname. Generate one with:
|
|
# openssl rand -hex 32
|
|
MCP_AUTH_TOKEN=
|
|
|
|
# claude.ai: the token its connector sends as a request header
|
|
# (`authorization: Bearer <token>`). Accepted on /mcp only, never on /api — which
|
|
# can replace the Schulcloud token — because claude.ai stores it. At least 32
|
|
# characters, different from MCP_AUTH_TOKEN; unset = off. Generate one with:
|
|
# openssl rand -hex 32
|
|
# MCP_CONNECTOR_TOKEN=
|
|
|
|
# Clients that cannot send a header: serve MCP at /<this value>/mcp with no
|
|
# token at all. The URL becomes the credential — see "Connecting Claude" in
|
|
# docs/DEPLOYMENT.md before using it. At least 32 URL-safe characters; unset =
|
|
# off. Generate one with:
|
|
# openssl rand -hex 32
|
|
# MCP_PATH_SECRET=
|
|
|
|
# Where a Schulcloud token replaced at runtime (`schulcloud token set`, /token)
|
|
# is saved, so a restart keeps it. docker-compose.yml sets /data/state; unset =
|
|
# replacements last until the next restart.
|
|
# STATE_DIR=/data/state
|
|
|
|
# Listen address inside the container. Leave as-is when running behind Caddy.
|
|
PORT=8080
|
|
BIND_HOST=0.0.0.0
|
|
|
|
# Local Docker only: the loopback port docker-compose.override.yml publishes the
|
|
# container on. Default 8080; change it when that port is already taken.
|
|
# MCP_HOST_PORT=8080
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Index and file mirror (optional — without these the server runs live-only:
|
|
# search crawls on every call, and the CLI's /api surface is unavailable)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Postgres for crawl generations, full-text search and the file mirror index.
|
|
# On the Pi, point this at the existing instance with its own database and user.
|
|
DATABASE_URL=postgresql://schulcloud:schulcloud@postgres:5432/schulcloud
|
|
|
|
# Where mirrored file bytes are stored. Needs to be writable by the container.
|
|
# MIRROR_DIR=/data/mirror
|
|
|
|
# Files larger than this are indexed as metadata but not mirrored; they are
|
|
# still downloadable, proxied live. Default 64 MiB.
|
|
# MIRROR_MAX_BYTES=67108864
|
|
|
|
# Also index personal files ("Meine Dateien") and submitted / returned work,
|
|
# including teacher grade comments. This is what makes "what did the teacher
|
|
# say about X" searchable and lets what_changed report a re-grade. Costs roughly
|
|
# three extra requests per task on a full crawl, so it is off by default.
|
|
# INDEX_PERSONAL_FILES=false
|
|
|
|
# Also walk the file manager ("Dateien") when crawling: Kurs-Dateien for every
|
|
# course, plus Persönliche, Team- and Geteilte Dateien on a full crawl. Many
|
|
# teachers keep their material only there, so this is on by default. One page
|
|
# load per folder — about 160 on a 26-course account.
|
|
# INDEX_FILE_MANAGER=true
|
|
|
|
# How often to re-crawl on a timer, in ms. Default 21600000 (6h). 0 = on demand
|
|
# only. A re-crawl of unchanged content downloads nothing, because Schulcloud
|
|
# file records are immutable.
|
|
# CRAWL_INTERVAL_MS=21600000
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Your own lesson notes (optional — what you wrote down in class)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Directory of Markdown files holding your own notes. With it set, the server
|
|
# offers list_notes, get_note and add_note, indexes the notes so `search` finds
|
|
# them, and the German prompts consult them. Unset = none of that exists.
|
|
# docker-compose.yml sets /data/notes and gives it a volume; bind-mount a synced
|
|
# folder there instead to write notes from a phone. See docs/NOTES.md.
|
|
# NOTES_DIR=/data/notes
|
|
|
|
# Leave the files alone: list_notes and get_note still work, add_note and
|
|
# POST /api/notes are refused. Right for a deployment whose notes are synced in
|
|
# from somewhere else and should have exactly one writer.
|
|
# NOTES_READONLY=false
|
|
|
|
# Password for the web app at /app — the notes editor and the settings page
|
|
# where the Schulcloud token is replaced. Unset = the app is not served at all.
|
|
#
|
|
# Unlike every other credential here this one is typed by a person on a phone,
|
|
# so it is a passphrase rather than a random token: at least 12 characters, and
|
|
# three or four words is the right shape. It is hashed with scrypt at startup
|
|
# and the plain value is never stored, compared or logged. Changing it logs out
|
|
# every session, because the session signing key is derived from it.
|
|
#
|
|
# The app is on the internet like the rest of the endpoint. Failed logins are
|
|
# rate-limited per address, but the password is the thing protecting the notes
|
|
# and the Schulcloud token — pick a long one.
|
|
# WEB_PASSWORD=
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebUntis (optional — the timetable, which Schulcloud does not hold)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Where the school publishes its timetable. With these set, the server offers
|
|
# untis_timetable, untis_homework and untis_lesson_topics, plus the German
|
|
# "tagesvorbereitung" prompt; without them none of that exists. All four values
|
|
# are in one dialog: WebUntis → Profil → Freigaben → Untis Mobile → QR-Code.
|
|
#
|
|
# UNTIS_SECRET is the "Schlüssel" field and is a credential: it authenticates
|
|
# every request as this user, needs no password, works with an SSO login, and
|
|
# stays valid until you generate a new key in that dialog. It can do whatever
|
|
# the Untis Mobile app can — this server only ever calls read methods, by
|
|
# allowlist (src/core/untis.ts). See docs/AUTH.md.
|
|
#
|
|
# Authentication is a time-based code, so the host's clock must be in sync;
|
|
# WebUntis answers -8524 ("invalid client time") when it is not.
|
|
# UNTIS_SERVER=yourschool.webuntis.com
|
|
# UNTIS_SCHOOL=yourschool
|
|
# UNTIS_USER=your.username
|
|
# UNTIS_SECRET=
|
|
|
|
# How far back to read the class register ("Unterrichtsinhalt", plus the notes
|
|
# teachers leave on a period) into the search index, in days. Default 180; 0
|
|
# turns it off. Costs one timetable call per 90 days plus one per lesson series
|
|
# on a full crawl — a few dozen requests for a school year. This is what makes
|
|
# "what did we actually do before the test" searchable rather than something to
|
|
# reconstruct one tool call at a time.
|
|
# UNTIS_HISTORY_DAYS=180
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Limits (optional — sensible defaults are built in)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Largest file download_file will pull, in bytes. Default 25 MiB.
|
|
# Videos in Schulcloud routinely exceed this; they are not extractable anyway.
|
|
# MAX_DOWNLOAD_BYTES=26214400
|
|
|
|
# Characters of extracted text returned before truncation. Default 120000.
|
|
# MAX_EXTRACTED_CHARS=120000
|
|
|
|
# Per-request timeout against the Schulcloud API, in ms. Default 30000.
|
|
# REQUEST_TIMEOUT_MS=30000
|
|
|
|
# How often to call refresh-session to hold the session open, in ms. Default
|
|
# 1800000 (30 min). Must stay well under the instance's JWT_TIMEOUT_SECONDS —
|
|
# 7200s here, readable from GET /api/v3/config/public. Set to 0 to disable.
|
|
# KEEPALIVE_INTERVAL_MS=1800000
|