The endurance test refuted the sliding-window model I committed earlier. A keepalive doing only GET /api/v3/me succeeded at t+0/30/60/90 and was still rejected by t+120 — consistent with the session ending ~2h after LOGIN (t+107), and inconsistent with 2h after the last request, which would have been t+210. This is a live-vs-source divergence, not a misreading: both the current JwtWhitelistAdapter and the legacy Feathers ensureTokenIsWhitelisted re-set the Valkey TTL on every authenticated request, so the source reads as a sliding window. The instance does not behave that way. So the keepalive now calls POST /authentication/refresh-session, the endpoint behind the UI's "Sitzung verlängern" button, which a separate 100s test showed does hold the reported budget at 7200s. It is the only non-GET request in the server: no body, touches only our own session, cannot read or modify user data, and is not exposed as a tool, so no model-driven call can ever be a POST. It logs the returned budget, which makes a failing extension visible before the session is lost. Whether this is sufficient is NOT established. Two mechanisms still fit: an idle TTL that reads fail to refresh (keepalive works), or an absolute cap/revocation anchored at login — e.g. the IDP's back-channel logout, which clears every token for the account rather than one. Added scripts/session-diagnose.mjs to settle it: it logs the budget every 10 min, so a decaying series indicates the former and an abrupt 401 at 7200s the latter. Docs state the open question rather than asserting a mechanism. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.2 KiB
The Schulcloud API, as verified against this instance
Everything here was confirmed against https://schulcloud-thueringen.de with a
real student account on 2026-09-11, not inferred from source. Where upstream
source and live behaviour disagreed, live behaviour won.
Two services, one origin
| Service | Source repo | Base path | Self-documenting at |
|---|---|---|---|
| Main server (NestJS) | schulcloud-server |
/api/v3/ |
/api/v3/docs, /api/v3/docs-json |
| Files storage | file-storage |
/api/v3/file/ |
/api/v3/file/docs, /api/v3/file/docs-json |
Both accept the same bearer token. The files service was split out of
schulcloud-server into its own repository, which is why no file paths
appear in the main docs-json — a detail that will send you in circles if you
only read the main spec. Fetch both:
curl -s "$TSC_URL/api/v3/docs-json" -o docs-v3.json # 212 paths
curl -s "$TSC_URL/api/v3/file/docs-json" -o docs-file.json # 26 paths
These are the authoritative reference for this instance's deployed version.
Prefer them over the GitHub sources, which track main and may be ahead.
Which repositories matter
The hpi-schul-cloud org has ~100 repos, most archived or superseded. The live
ones relevant here:
schulcloud-server— the API. Readapps/server/src/modules/<module>/api/for controllers and DTOs.file-storage— the files service, extracted from the above.src/modules/files-storage/api/controller/files-storage.controller.tsis the whole surface.nuxt-client— the current web front end. Useful for seeing which API calls the real UI makes in which order.schulcloud-client— the legacy Handlebars front end. Still receives commits, but it is not where new features land.
Superseded/archived and worth ignoring: authorization-service,
schulcloud-editor, nexboard-api-js, end-to-end-tests, docker-compose,
H5P-Nodejs-library, shd-client.
Note the naming: /api/v1 is the old Feathers surface. On this instance
/api/v1/docs 404s, and the v3 NestJS API covers everything this server needs.
Content model
Course ─┬─ column board ─── column ─── card ─── element ─┬─ richText
│ ├─ file ──── fileRecord(s)
│ ├─ link
│ └─ …
├─ lesson (Thema) ─── contents[] + materials[]
└─ task (Aufgabe) ─── description + fileRecord(s)
On the account this was built against: 26 courses holding 30 column boards, 18 lessons, 42 tasks and 175 files. Column boards hold the great majority of current material; lessons are the older format.
Endpoints this server uses
| Purpose | Call |
|---|---|
| Identity, school id, permissions | GET /api/v3/me |
| Courses | GET /api/v3/courses?skip&limit |
| One course's contents | GET /api/v3/course-rooms/{courseId}/board |
| Dashboard tiles | GET /api/v3/dashboard |
| Tasks | GET /api/v3/tasks, GET /api/v3/tasks/finished |
| Lesson body | GET /api/v3/lessons/{lessonId} |
| Lesson's tasks | GET /api/v3/lessons/{lessonId}/tasks |
| Board structure | GET /api/v3/boards/{boardId} |
| What a board belongs to | GET /api/v3/boards/{boardId}/context |
| Card bodies | GET /api/v3/cards?ids=<id>&ids=<id> |
| Files of an entity | GET /api/v3/file/list/{storageLocation}/{storageLocationId}/{parentType}/{parentId} |
| One file's metadata | GET /api/v3/file/{fileRecordId} |
| File bytes | GET /api/v3/file/download/{fileRecordId}/{fileName} |
| News | GET /api/v3/news |
| Instance settings (no auth) | GET /api/v3/config/public |
| Remaining idle budget | POST /api/v3/authentication/refresh-session → {expiresInSeconds} |
Gotchas that cost real time
course-rooms, not courses, for course contents. GET /api/v3/courses/{id}
does not exist. The route that returns a course's lessons/tasks/boards is
GET /api/v3/course-rooms/{roomId}/board, and its :roomId is the course id.
Nothing in the naming suggests this.
/api/v3/rooms is a different feature. "Rooms" are the newer standalone
collaboration spaces, unrelated to courses. On this instance the account has
none, so GET /api/v3/rooms returns {"data":[]} — which reads like a broken
endpoint but is simply an empty feature.
limit maxima are enforced and mis-documented. The OpenAPI schema says
maximum: 99; the runtime validator rejects anything > 100. Page at 99 to
satisfy both. Asking for 200 returns a 400 API_VALIDATION_ERROR, not a
truncated list.
There is no GET /tasks/{id}. Single-task detail has to be assembled: the
list endpoints give metadata but omit description, which appears only on the
course page's task element. get_task does this join.
Board files need three calls. A file element's content carries only
{caption, alternativeText} — no file id. The bytes are found by listing
files-storage with parentType: 'boardnodes' and the element id as
parentId. This is the single least discoverable part of the API, and applies
equally to fileFolder and drawing elements.
storageLocationId is the school id (from /me), with
storageLocation: 'school', for every parent type in normal use.
Lesson ids come back as buffers. GET /api/v3/lessons/{id} returns nested
ids as {buffer:{type:'Buffer',data:[...]}} rather than hex strings — a leak
from the legacy Mongo serialisation. normalizeObjectId in src/render.ts
converts them.
The JWT's exp is not the session lifetime, and the source misleads here.
Both the current and legacy whitelist implementations re-set a Valkey TTL on
every authenticated request, which reads as a sliding window. The live instance
does not behave that way: a session ends ~2 h after login, and successful
reads in between do not extend it (measured — see docs/AUTH.md). This is the
clearest case in this API of live behaviour diverging from upstream source.
GET /api/v3/config/public is unauthenticated and useful. 78 keys of
instance configuration, including the session timeouts and feature flags. Handy
for checking deployed settings without a token.
Content-Disposition on downloads is malformed. It comes back as
attachment;; filename="…" — note the doubled semicolon — and the filename is
percent-encoded inside the quotes. Parse defensively.
Content element types
From ContentElementType in schulcloud-server, all seen live except where
noted: richText, file, fileFolder, link, drawing,
collaborativeTextEditor, externalTool, videoConference, h5p, deleted.
Collaborative text editor contents are not retrievable through the API —
GET /api/v3/collaborative-text-editor/{parentType}/{parentId} returns a URL to
the Etherpad-style editor, not the document text.
Re-verifying after an upstream release
npm run probe re-checks every assumption above against the live instance and
prints what it finds — both token clocks included: days until hard expiry, and
seconds of idle budget remaining.