Files
Schulcloud-MCP/docs/API.md
MechaCat02 bed3923902 Browse the file manager ("Dateien") as a filesystem
Many teachers never use topics or boards; their material sits in the
course's file area, and the tools answered "0 files" for courses holding
dozens of worksheets — 21 of 26 courses on the live account. Persönliche,
Kurs-, Team- and Geteilte Dateien live in the legacy file store, not in
files-storage, and its service is not in the public ingress. The only way in
is the legacy client: HTML listings, and GET /files/signedurl for a
pre-signed download.

core/legacy-files.ts turns that into one path tree — /my, /courses/<course>,
/teams/<team>, /shared — resolving names that contain "/", ids anywhere in a
path, and wrong or ambiguous names with a message saying what is there. A
listing that does not parse throws; it never reads as an empty folder.

Some of the legacy client's GET routes write (GET /files/share/ mints a
share token), so getFileManagerPage allows only the listing routes, by
pattern. Signed URLs are fetched with no credentials and must be https.

- MCP: fs_list, fs_tree, fs_find and fs_read; get_course lists course files.
- CLI: schulcloud fs ls, tree, find and get, recursive and resumable.
- API: /api/fs/list, tree, find and file.
- Index: the crawl walks the file manager (INDEX_FILE_MANAGER, on by
  default), so search covers the text inside those files and sync mirrors
  them under <course>/Kurs-Dateien.

The local instance gains a fixture for all four areas. It needed a loopback,
so signed URLs open from the host, and a pre-created bucket, since MinIO
does not implement PutBucketCors.

135 tests. Smoke 55/55 live; 57/57 and 55/55 on the local instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:16 +02:00

16 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. 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, and the UI's naming hides it. Rooms ("Räume") are the newer standalone collaboration spaces. The sidebar's Kurse entry links to /rooms/courses-overview and lists courses (served by /api/v3/dashboard + /api/v3/courses), while Räume links to /rooms and lists rooms — so a url containing /rooms identifies neither.

A room holds boards and nothing else: no lessons, no tasks. GET /rooms answers {"data":[]} with no total, derived from real memberships, so an empty result means the account is in no rooms — which is also what it looks like after a teacher deletes a room or revokes access. GET /rooms/{id}/boards does report isVisible, unlike the course-page projection, so a room's draft boards can be identified without trying to open one.

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.

The file manager ("Dateien") is a third store

Persönliche Dateien, Kurs-Dateien, Team-Dateien and Geteilte Dateien are the legacy file system: a files collection with real folders (isDirectory, parent, owner, refOwnerModel), served by the legacy Feathers fileStorage service. It shares nothing with files-storage. Asking /api/v3/file/list/school/{school}/courses/{courseId} answers 0 for a course whose file manager holds dozens of worksheets — measured: 21 of 26 courses on the live account keep files here, some nothing else.

Its service is not in the public ingress, so the only way in is the legacy client. Verified live:

Route (legacy client, jwt cookie) Returns Notes
GET /files/my/ , /files/my/{folder} HTML listing personal root / one folder
GET /files/courses/ HTML listing the courses, as folders (ids = course ids)
GET /files/courses/{course} , /files/courses/{course}/{folder} HTML listing one folder segment at any depth
GET /files/teams/… HTML listing same shape as courses
GET /files/shared/ HTML listing flat; shared folders cannot be opened (no route; the UI's link 404s)
GET /files/signedurl?file={id}&name={name} {"url": …} pre-signed S3 url, another host (live: s3.hidrive.strato.com)
GET /files/permittedDirectories/ JSON tree lists every course with no folders in any — see below
GET /files/search/?q= HTML 504 on live: an unindexed regex over every file record

Listings are parsed on the attributes the page's own scripts use: data-folder-id with the name inside .card-title-directory (emitted unescaped, {{{stripOnlyScript name}}}), and data-file-id, data-file-name, data-file-size, data-file-viewer-type on each .card.file. A blocked file carries btn-file-danger and no viewer type. No dates are rendered.

permittedDirectories is broken for courses. The directory service's query matches course folders on refOwnerModel: 'courses'; the records say 'course'. Live result: 26 courses, 0 folders; personal folders come through. Listings are the only complete view — which is also what the UI shows.

Some GET routes write. GET /files/share/?file= mints a share token when the file has none (PATCH /fileStorage/shared/{id}), and GET /files/file?…&share=… grants the caller a permission on the file. GET /files/fileModel/{id}/proxy forwards to the latter. A GET-only client is therefore not read-only against this surface by itself: core/client.ts allows only the listing routes and /files/signedurl, by pattern.

The signed-url service returns its error instead of throwing it (.catch((err) => new Forbidden(err))), so a refused file is a 200 whose body has no url.

Course names contain / in real data ("LF07 - FIA24A/B - Sb/Ha", "FIA24/FIP24 IT LF12"), and a real file is literally called ..docx. Paths built from names cannot be split naively; core/legacy-files.ts resolves by trying joined segments, and accepts ids as segments.

Two more, seen while building the local fixture:

  • getRefOwnerModel(owner) answers "a course, or else teams". Any owner id that is not a course — a user included — is recorded as a team's. The upload page never sends an owner for personal files (data-owner=""), so the server defaults to the creator and records user; send the user id and every later permission check dereferences a team that does not exist.
  • The file permission service writes refOwnerModel where the "shared with me" query reads refPermModel, so a share made through it never appears under Geteilte Dateien. The share-link flow patches /files/{id} directly.

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.