Testing against a local instance turned up four things the server was
getting wrong, all of them invisible against the live account because the
data that exposes them had never been produced there.
`GET /lessons/{id}/tasks` returns a bare array, not the `{data,total}`
envelope every sibling endpoint uses, so `.data` was undefined and a
topic's tasks silently vanished. Its items also carry no id at all —
`LessonLinkedTaskResponse` has no id property — which leaves a
topic-attached task unidentifiable: it is not a task element on the
course page, and once past due it is in neither task list. So its
submission, and its grade, could not be reached by any route. That is 18
of 60 tasks on the real account, now reachable: the ids come off the
legacy topic page, where each task is linked as `/homework/{id}`.
The types said `id: string` and `status: TaskStatus` on something that
has neither, which is what let this stay quiet; `LessonLinkedTask` and
`ResolvedTask` now say what is actually there.
Collaborative text editor elements come back with `content: {}`, and the
tool said their contents were unavailable. They are available: the
content-element endpoint returns the pad url *and* an Etherpad session
cookie, and the pad exports itself as text to whoever holds it. No API
key needed. Pads are now shown by get_board and indexed for search.
The store's file digest covered id and size on the grounds that file
records are immutable. `PATCH /file/rename/{id}` renames one in place,
so a rename was reported as nothing at all.
Finally, get_board reported an unpublished board as "no permission",
which sends the reader hunting for an access problem that is not there.
smoke gains checks for topic tasks and for pads, and no longer assumes a
populated index or a search term that happens to match. 39/39 live-only
and 41/41 index-backed, against both the live instance and a local one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
225 lines
12 KiB
Markdown
225 lines
12 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.
|
|
|
|
**`GET /cards?ids=` accepts at most 20 ids.** Above that the request fails with
|
|
`400 "each value in ids must be a mongodb id"` — blaming the ids when the real
|
|
problem is how many there are. Express/NestJS parse the query string with `qs`,
|
|
whose default `arrayLimit` is 20; past it, repeated params become an object
|
|
keyed `"0"`, `"1"`, … and `@IsMongoId({ each: true })` then rejects every value.
|
|
Nothing in the controller or its DTO says so: the limit lives in the query
|
|
parser underneath them. Verified live — 20 ids return 200, 21 return 400 with
|
|
identical ids. A board with more than 20 cards is therefore unreadable in one
|
|
request; `getCards` chunks at 20.
|
|
|
|
**Submissions are nearly invisible.** The only route is
|
|
`GET /api/v3/submissions/status/task/{taskId}` — there is no `GET /submissions`
|
|
and no fetch-by-id, so a task id is the only way to reach a submission. The
|
|
response carries `{id, submitters, isSubmitted, isGraded, grade,
|
|
submittingCourseGroupName}` and **nothing else**: no submitted text, no grade
|
|
comment, no graded-at. Those lived on the legacy Feathers API, and `/api/v1` is
|
|
not served on this instance (404 across the board), so they are simply
|
|
unavailable. Submitted *files* are reachable through files-storage with
|
|
`parentType: 'submissions'`.
|
|
|
|
**Submitted text and written feedback exist only in the rendered web page.**
|
|
The legacy front end calls the Feathers API server-side for
|
|
`submission.comment`, `submission.grade` and `submission.gradeComment` and
|
|
renders them into `GET /homework/{taskId}`. That Feathers API is not exposed
|
|
publicly (`/api/v1/*` 404s), so the page is the only way to reach these from
|
|
outside. `core/homework-page.ts` parses it, hooked on the `data-testid`
|
|
attributes the project's own e2e tests use. Note the page authenticates with the
|
|
`jwt` **cookie** — an `Authorization` header is ignored and redirects to the
|
|
identity provider. Measured: 4 of 7 graded submissions in one course carried
|
|
feedback no API call can return.
|
|
|
|
**A grade is a percentage or nothing.** The submission schema is
|
|
`grade: { type: Number, min: 0, max: 100 }` and `gradeComment: { type: String }`
|
|
— there is no textual grade field. In practice teachers frequently grade with
|
|
`gradeComment` alone and leave `grade` unset, so a written "OK" is the whole
|
|
verdict for that submission. Treat a missing `grade` as "no percentage given",
|
|
never as "ungraded" (`graded` is its own boolean) and never as an error.
|
|
|
|
**files-storage ignores `parentType` when listing.** Asking for
|
|
`.../gradings/{submissionId}` returns the files parented to that id whatever
|
|
their type — the records come back saying `parentType: "submissions"`. The path
|
|
segment appears to serve authorisation, not filtering, so filter on each
|
|
record's own `parentType` or a student's own upload will be reported back as
|
|
teacher feedback.
|
|
|
|
**`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.
|
|
|
|
**`GET /lessons/{id}/tasks` returns a bare array, and its items have no id.**
|
|
Every other list endpoint returns `{data, total}`; this one returns the array
|
|
directly, so reading `.data` silently yields `undefined`. Worse, the items are
|
|
`LessonLinkedTaskResponse`, which has no id property at all — name, description
|
|
and dates only. A task attached to a topic is therefore unidentifiable from the
|
|
API: it is not a task element on the course page (the topic reports only
|
|
`numberOfPublishedTasks`), and once past due it is in neither `/tasks` nor
|
|
`/tasks/finished`. On the account this server was built for that hid 18 of 60
|
|
tasks, submissions and grades included. The ids are recoverable only from the
|
|
legacy topic page, which links each task as `/homework/{id}` —
|
|
`core/lesson-page.ts`.
|
|
|
|
**A student's task lists exclude past-due tasks.** `/tasks` drops a task once
|
|
its due date passes; `/tasks/finished` holds only what the student ticked off.
|
|
A submitted, graded, past-due task is in neither. Reach it through the course
|
|
page, or through its topic.
|
|
|
|
**An unpublished board is listed but cannot be opened.** The course-board
|
|
projection reports a draft board with its title, while `GET /boards/{id}`
|
|
answers 403 for anyone who cannot edit it. Treat a 403 there as "probably not
|
|
published yet", not as an access problem.
|
|
|
|
**`PATCH /file/rename/{fileRecordId}` mutates a file record in place.** The id
|
|
and size stay the same, so any change detection keyed on those alone misses it.
|
|
|
|
**`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 elements come back with `content: {}` — no pad id, no
|
|
url, nothing. `GET /api/v3/collaborative-text-editor/content-element/{elementId}`
|
|
returns the pad url, and **also sets an Etherpad `sessionID` cookie** in its
|
|
response. With that cookie, Etherpad's own `/etherpad/p/{padId}/export/txt`
|
|
returns the document as plain text. So the contents *are* reachable, in two
|
|
hops and without Etherpad's API key; `core/etherpad.ts` does this. The url is
|
|
built from the server's `ETHERPAD__PAD_URI`, so it must be checked against the
|
|
instance host before the session cookie is sent to it.
|
|
|
|
## 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.
|