Files
Schulcloud-MCP/docs/API.md
MechaCat02 d657ece436 Fix session lifetime: 2h sliding idle timeout, not 30 days
The JWT's exp claim says 30 days, and I took that as the session
lifetime. It is only an outer ceiling. The server also keeps a per-token
whitelist entry in Valkey (jwt:{accountId}:{jti}) whose TTL is
JWT_TIMEOUT_SECONDS — 7200s on this instance — and JwtStrategy.validate
re-sets it on every authenticated request. Two hours idle and the token
is rejected with 29 days still on exp.

Proven, not inferred: the token from yesterday returned 401 at 13.8h old.
The live instance publishes the values unauthenticated at
GET /api/v3/config/public — JWT_TIMEOUT_SECONDS 7200,
JWT_SHOW_TIMEOUT_WARNING_SECONDS 3600, the latter being exactly the
one-hour UI prompt that prompted this investigation.

refresh-session turns out not to be special: it extends through the same
guard as any other route, and uniquely only in returning the remaining
TTL. So the keepalive uses GET /api/v3/me instead, and the server stays
GET-only; the one POST in the repo is in scripts/probe.mjs, where it
reports the idle budget.

JWT_EXTENDED_TIMEOUT_SECONDS (~1 month) exists in the config schema but
is vestigial: privateDevice has no references in the current NestJS
source, and generateJwtAndAddToWhitelist never overrides the TTL.

Also fixes a real breakage this surfaced: TypeScript parameter
properties are rejected by Node's type stripping, so `npm run dev` and
`npm test` both failed on any file reaching them. Rewritten as explicit
fields, and noted in CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:07:22 +02:00

149 lines
7.1 KiB
Markdown

# 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`](https://github.com/hpi-schul-cloud/schulcloud-server) | `/api/v3/` | `/api/v3/docs`, `/api/v3/docs-json` |
| Files storage | [`file-storage`](https://github.com/hpi-schul-cloud/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:
```bash
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. Read `apps/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.ts` is 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.** A server-side whitelist entry
in Valkey (`jwt:{accountId}:{jti}`) expires after `JWT_TIMEOUT_SECONDS` — 7200 s
on this instance — and every authenticated request re-sets it. Two hours idle
and the token is rejected with 29 days still on `exp`. The live values are
public at `GET /api/v3/config/public`. Full write-up in `docs/AUTH.md`.
**`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.