A token lasts 30 days and only a browser login yields one — the account is
federated, so the server cannot mint it. Replacing it meant editing .env and
recreating the container, every month.
`schulcloud token set` (a hidden prompt, or piped input) and a /token page
both send it to PUT /api/token. The server checks it with Schulcloud first —
well-formed, unexpired, still logged in, the same account — then swaps it
into the config every request reads, restarts the keepalive and saves it in
STATE_DIR, a new volume, with mode 0600. At startup the newer of the saved
token and TSC_JWT_COOKIE wins, unless they belong to different accounts. A
refused paste changes nothing, and the token is never logged.
The keepalive's pings carry a generation, so a 401 for the old token that
arrives after a swap cannot stop the new cycle. `schulcloud token`, whoami
and the log report the expiry and warn a week ahead.
Found on the way: a host that is off for more than two hours loses the
session however long the token has left — this machine lost it overnight —
which is what the always-on Pi is for.
174 tests. Smoke 72/72 on the local instance, and a real swap verified end to
end there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Store: crawl generations as the sync cursor. Diffs compare generations
on entity identity plus a content digest, never on upstream timestamps —
GET /course-rooms/{id}/board returns request time as updatedAt for most
elements, so a timestamp cursor would report every board as changed on
every crawl. Identity diffing also yields deletions, which no timestamp
scheme can. A per-course crawl carries the other courses' rows forward
so every completed generation is a complete picture and any two diff
directly; without that a partial crawl reads as a mass deletion.
FTS uses the german dictionary with weighted title/body, plus a pg_trgm
arm because stemming will not match "Datenschutz" inside
"Datenschutzgrundverordnung" and German compounds make that the common
case. file_texts is keyed by file record id and deliberately outlives
generations: records are immutable upstream, so text extracted once is
valid forever and a re-crawl of unchanged content costs nothing.
Store.open returns undefined instead of throwing when Postgres is
unreachable — the index is an accelerator, and a Pi that loses its
database should get slower, not broken.
core/paths.ts is the security boundary for the mirror. Course titles,
card titles and filenames are all user-supplied upstream, so this is
where a hostile name stops being text and becomes a path. Two bugs found
by its own tests: "///" produced "---" instead of falling back, and dot
runs survived mid-component. Now no ".." can survive anywhere, which
makes the invariant checkable rather than a claim about ordering.
Indexer coalesces concurrent refreshes onto one run and enforces a
minimum interval, since a full crawl is ~270 requests from an account
that looks like a student.
9 store tests against a real Postgres (mocks would test nothing here)
and 13 path tests; 47 total.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read-only MCP server exposing a Schulcloud account to Claude: courses,
column boards, lessons, tasks, and file downloads with text extraction.
The API surface was verified against the live instance rather than
inferred from upstream source, which changed several design decisions:
- The `jwt` cookie works verbatim as `Authorization: Bearer` and lasts 30
days, so there is no cookie jar and no refresh-session timer.
- Course contents live at /api/v3/course-rooms/{courseId}/board; there is
no GET /api/v3/courses/{id}.
- Files are a separate service (/api/v3/file/*) with its own OpenAPI doc.
- Board file elements carry no file id; attachments are resolved by
listing files-storage with parentType=boardnodes and the element id.
Read-only by construction: every client method is a GET, including the
api_get escape hatch. The endpoint is internet-facing by necessity, so a
leaked token being unable to act as the user is the key safety property.
Deploys as a container behind the Pi's existing Caddy, guarded by a
constant-time bearer check. Stateless — no database.
Verified: 28 unit tests, plus a 30-check end-to-end run driving a real
MCP client over Streamable HTTP against the live account.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>