5 Commits

Author SHA1 Message Date
MechaCat02
3a168e37e5 local-instance: simulate a teacher, and run the files-storage consumer
`scripts/simulate-teacher.mjs` creates, edits and deletes what teachers
create — course, room, topic, task, board, columns, cards, rich text,
link, Etherpad pad, folder, files — so the MCP server can be exercised
against content the real account has never held. It is the only thing
here that writes to a Schulcloud and refuses any non-localhost address.
`scripts/mcp-env.sh` points the server and CLI at the instance.

Two gaps it exposed in the stack itself:

The files-storage AMQP consumer is a separate entrypoint, and we were
running only the HTTP one. Nothing was bound to the `files-storage`
exchange, so `TaskService.delete` — which awaits deleteFilesOfParent
over AMQP before touching the task — hung until the request timeout.
Deleting any task or topic answered 408 with the entity still there.

The demo data is dated 2017-2018 and the v3 endpoints filter on those
dates, so a student saw no tasks at all. seed.sh now brings courses and
homework into the present, which is the difference between a fixture
that exercises the student-facing surface and one that looks empty.

Teams turn out to be uncreatable through the API (the legacy service
registers no `create`, v3 has no route), and a new board is unpublished
and 403s for students, so the simulation adopts a seeded team and leaves
one board a draft on purpose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 15:40:46 +02:00
MechaCat02
521c21f7ae Reach tasks attached to topics, and read Etherpad pads
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>
2026-09-13 15:39:51 +02:00
MechaCat02
0ae9198428 Run Etherpad by default: without it every topic page 500s
The legacy client requests an Etherpad session on every topic page whose
lesson has contents, without ever checking whether the lesson contains a
pad — controllers/topics.js collects `etherpadPads` and then ignores it.
With Etherpad unreachable the request fails, `validUntil` comes back
undefined, and `new Date(undefined * 1000)` makes Express reject the
session cookie: "option expires is invalid", rendered as a 500.

So Etherpad was only nominally optional. Moving it out of the `tools`
profile also matches the live deployment, which always runs it.

The topic pages that still 500 are courses the signed-in user is not a
member of; the same page returns 200 for its own teacher. That is the
legacy client rendering a 403 as a 500, upstream behaviour we don't own.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 14:34:12 +02:00
MechaCat02
a3aded110c Add a local Schulcloud instance modelled on the live target
A Docker Compose stack that runs the deployed images
(quay.io/schulcloudverbund/*, thr theme, tag 33.40 — the versions
schulcloud-thueringen.de reports) rather than a rebuild of main, so what we
develop against is the deployed artefact. It exists to produce the states we
can otherwise only observe read-only: log in as the teacher, grade, then read
it back the way the MCP server does.

Faithful where it matters and honest where it isn't:

- Feature flags in env/api.env are a replay of GET /api/v3/config/public from
  the live instance, not a hand-picked set; instance identity mirrors the thr
  group_vars from dof_app_deploy.
- The proxy is generated from the deployment's own ingress table
  (scripts/gen-proxy-conf.py) so the legacy-client / SPA / API path split
  matches production; getting it wrong tests a different application.
- Valkey runs in `single` mode so the JWT whitelist expires sessions the way
  production does, rather than the in-memory shortcut that hides it.
- No external OAuth / Schulportal login (excluded by request and not
  reproducible locally), no BigBlueButton; each divergence is marked at the
  line it affects. Everything binds to 127.0.0.1 and uses the upstream dev
  credentials, which are public.

Profiles keep the heavy pieces opt-in: `tools` adds Etherpad/H5P/tldraw/
Collabora, `av` adds ClamAV, `preview` adds thumbnailing.

seed.sh loads the upstream demo school (the same call the deployment's init job
makes) and registers MinIO as the legacy storage provider, which has no seed
data on purpose. The demo data already contains the grading states that are
hard to obtain from the real account — a feedback-only grade and a 100% one —
which is what surfaced the past-due submitted-text scrape gap.

One config finding baked in: file-storage and h5p validate a token's
issuer/audience against JWT_DOMAIN (default "localhost"), while the API stamps
SC_DOMAIN; without keeping them equal, the homework page's file lookups 401.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 14:17:30 +02:00
MechaCat02
290b352b07 Scrape submitted text for past-due submissions, not just editable ones
Once a submission's due date passes, the legacy homework page renders the
student's text read-only in a `<div class="comment">` that is a sibling
*after* `</section id="submission">`, which then holds only the file list.
`parseHomeworkPage` searched for that div *inside* the submission section, so
for every past-due submission it silently returned no submitted text while
still reporting the grade and feedback — the reader would conclude the student
handed in nothing.

Search the whole page instead. `class="comment"` (quote right after the word)
stays specific: the teacher's feedback is `class="comment ckcontent"` and does
not match, and the editable-textarea branch is still tried first.

Found by standing up a local instance from the deployed images and reading a
real 33.40 page for a seeded past-due submission; the earlier test fixture had
nested the div inside the section, which is why the gap was invisible. The
fixtures now match the real DOM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 14:17:14 +02:00
38 changed files with 2786 additions and 44 deletions

3
.gitignore vendored
View File

@@ -9,3 +9,6 @@ vendor/
# Local scratch
tmp/
files.zip
# Ids of the fixture the teacher simulation currently has in place.
local-instance/.simulate-teacher.json

View File

@@ -123,6 +123,20 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
- **Never swallow a per-item crawl error.** Board failures used to be caught and
dropped, so the index lost whole boards while the crawl reported success —
which is how the 20-id limit went unnoticed. They go into `Snapshot.failures`.
- **`GET /lessons/{id}/tasks` is a bare array whose items carry no id.** Not the
`{data,total}` envelope, and `LessonLinkedTaskResponse` has no id field at
all. A topic-attached task is thus unidentifiable from the API and invisible
in both task lists once past due — 18 of 60 tasks on the real account.
`core/lesson-page.ts` scrapes the ids off the legacy topic page.
- **Collaborative text editor (Etherpad) contents are reachable, in two hops.**
`GET /api/v3/collaborative-text-editor/content-element/{id}` returns the pad
url *and* sets an Etherpad `sessionID` cookie; `/etherpad/p/{id}/export/txt`
then returns the text. No Etherpad API key needed. `core/etherpad.ts` checks
the url's host before sending the cookie to it.
- **A draft board is listed on the course page but 403s when opened.** Say "not
published yet", not "no access".
- **File records are mutable**: `PATCH /file/rename/{id}` keeps the id and size,
so the store's digest has to include the name.
- **Submissions: only `GET /submissions/status/task/{taskId}` exists.** No list,
no fetch-by-id, and the payload has no submitted text, grade comment or
graded-at — `/api/v1`, which had them, is not served here. Don't imply absent

View File

@@ -173,6 +173,31 @@ clearest case in this API of live behaviour diverging from upstream source.
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.
@@ -183,9 +208,14 @@ 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.
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

251
local-instance/README.md Normal file
View File

@@ -0,0 +1,251 @@
# A local Schulcloud
A Docker Compose stack that runs a real Schulcloud instance on this machine,
modelled on **schulcloud-thueringen.de** — the instance this MCP server reads.
It exists so that the parts of the product we can only observe from a student's
account can also be *produced*: log in as the teacher, grade a submission, then
look at it through `list_submissions` and see whether what we render matches
what the student is shown.
> Everything here is a throwaway development instance. The credentials are the
> upstream development defaults, published in the upstream repositories. Bind
> nothing beyond `127.0.0.1`.
## Quick start
```bash
cd local-instance
docker compose up -d # ~5 min on a cold cache
./scripts/seed.sh # loads the demo school; takes another minute or two
```
Then open <http://localhost:4400> and sign in as
`demo-schueler@schul-cloud.org` / `schulcloud` (Fritz Schmidt, a student with
graded submissions). `seed.sh` prints the full account list on completion.
> The demo password differs by account — an upstream quirk, not ours. The two
> `demo-*` accounts use `schulcloud`, the built-in `admin`/`lehrer` and the
> teacher `klara.fall` use `Schulcloud1!`, and the `*.qa` accounts use
> `Schulcloud1qa!`.
To add the remaining external tools (H5P, tldraw, Collabora):
```bash
docker compose --profile tools up -d
```
## Why the images are the interesting part
`quay.io/schulcloudverbund/*` are the **same images the live instance runs**,
built for the `thr` theme, and they are public. So this is not a rebuild of
`main` that may have drifted — it is the deployed artefact, pinned to the
version the live instance reports:
```console
$ curl -s https://schulcloud-thueringen.de/version
{"client": {"version": "33.40.0"}, "nuxt-client": {"version": "33.40.1"},
"server": {"version": "33.40.2"}, "dof_app_deploy": "33.40.2"}
```
Hence `SC_VERSION` defaults to `33.40`. To pin something else:
```bash
SC_VERSION=33.27 docker compose up -d
```
`CLAUDE.md` says *live behaviour beats upstream source* — the clones in
`vendor/` track `main` and may be ahead of what is deployed. Running the
deployed images is how that rule is honoured here rather than worked around.
## What is faithful, and what is not
Faithful, and load-bearing for what we test:
| | |
|---|---|
| **Images** | the live instance's own, `thr` theme, tag `33.40` |
| **Feature flags** | `env/api.env` is a replay of `GET /api/v3/config/public` from the live instance, not a hand-picked set |
| **Instance identity** | `env/shared.env` mirrors `dof_app_deploy/ansible/group_vars/thr/instance_cfg.yml` |
| **URL routing** | `proxy/nginx.conf` is generated from the deployment's own ingress table (see below) |
| **Session model** | Valkey in `single` mode, so the JWT whitelist expires sessions exactly as production does |
| **Seed data** | the upstream demo school, including graded and ungraded submissions |
Deliberately different:
- **No external OAuth / Schulportal login.** Excluded by request, and it is the
one part that cannot be stood up locally. Local login is username + password
against the seeded accounts.
- **No BigBlueButton.** `FEATURE_VIDEOCONFERENCE_ENABLED` is off; upstream it is
on. BBB is a separate product of its own scale.
- **`FEATURE_CONSENT_NECESSARY=false`**, or every seeded user hits a consent
wall before reaching any content.
- **No LDAP / TSP sync, no Nextcloud, no calendar service.**
- **Antivirus off by default** — see the `av` profile.
Every one of these is marked in the env files at the line it affects.
## Layout
```
docker-compose.yml the stack; profiles: (default) | tools | av | preview
env/ one file per service, all values local-only
proxy/nginx.conf GENERATED — the single origin, see scripts/gen-proxy-conf.py
scripts/seed.sh loads the demo school via the management app
scripts/minio-init.sh creates the S3 buckets each service expects
etherpad/APIKEY.txt fixed Etherpad API key, matching env/api.env
```
### Ports
| Port | |
|---|---|
| **4400** | **the instance** — everything a browser touches |
| 3030 | server API, direct |
| 3333 | management app (seeding only, not part of a running instance) |
| 3100 / 4000 | legacy client / SPA, direct |
| 4444 | file-storage |
| 9900 / 9901 | MinIO S3 API / console (`miniouser` / `miniouser`) |
| 9980 | Collabora (`tools` profile) |
| 27019 | MongoDB |
| 6381 | Valkey |
| 15673 | RabbitMQ management |
Everything binds to `127.0.0.1`. Port numbers are shifted off their upstream
defaults where those commonly collide (Mongo, Valkey, RabbitMQ, MinIO) — note
`docker-compose.override.yml` in the repo root already uses 8080 and 55432.
## The proxy is generated, not written
The live instance is one origin whose paths are split across the legacy client,
the SPA, and several APIs. That split is not cosmetic: `/rooms/courses-list` is
the SPA, `/courses/:id` is the legacy client, and `/api/v3/file/` is a
different service from `/api/v3/`. Get it wrong and you are testing a different
application from the one students use.
The rules live in `dof_app_deploy/ansible/group_vars/all/x_ingress.yml` (46 of
them) plus a per-path ingress inside each service repo. Transcribing that by
hand invites drift, so `scripts/gen-proxy-conf.py` reads the deployment repo and
emits `proxy/nginx.conf`:
```bash
python3 scripts/gen-proxy-conf.py > proxy/nginx.conf
docker compose restart proxy
```
It needs the upstream clones in `../vendor` (gitignored — see the root README),
which is why the output is committed.
Two details in there are worth keeping:
- Every `proxy_pass` goes through a **variable plus a `resolver`**, so names are
resolved per request. With a literal upstream, nginx refuses to start
whenever an optional profile is down — which is the normal case.
- **Etherpad gets the deployment's own rewrite rules**, not a plain
`proxy_pass`. It is mounted under a prefix it knows nothing about; without the
rewrites a pad loads and then silently never syncs.
## Seeding
`scripts/seed.sh` does what the real deployment's init job does: it asks the
management app to load `backup/setup/*.json`, which ships **inside the server
image**. It also registers MinIO in the `storageproviders` collection, which has
no seed data on purpose (it holds credentials) and without which legacy file
uploads fail.
Re-running it is safe; collections are replaced, not appended to.
### What the demo data already contains
The seed includes 50 tasks and 24 submissions covering the grading states that
are otherwise hard to obtain — including the two this server renders:
| | |
|---|---|
| `graded: true`, `grade: null`, `gradeComment` set | graded by feedback alone |
| `graded: true`, `grade: 100`, `gradeComment` set | a percentage **and** feedback |
| `graded: false`, `submitted: true` | handed in, not yet looked at |
The second row matters: no submission in the real account has ever had a
numeric grade, so `formatGradeState`'s percentage branch had never been seen
against real data. Here it can be.
## Simulating a teacher
`scripts/simulate-teacher.mjs` creates, edits and deletes the things teachers
create, so the MCP server can be run against content the real account has never
held. It is the only thing in this repository that writes to a Schulcloud, and
it refuses to run against anything but a localhost address.
```bash
node scripts/simulate-teacher.mjs create # course, room, topic, task, board,
# columns, cards, rich text, link,
# Etherpad pad, folder, files
node scripts/simulate-teacher.mjs update # rename and rewrite all of it
node scripts/simulate-teacher.mjs delete # remove it again
```
Ids are kept in `.simulate-teacher.json` between phases, so the MCP server can
be pointed at the instance in between:
```bash
eval "$(./scripts/mcp-env.sh)" # as the demo student
cd .. && npm run smoke # 39 checks against the local instance
```
Two things it does **not** do, because the API does not allow them:
- **Teams cannot be created.** The legacy service registers
`['find','get','update','patch','remove']` and no `create`, and v3 has no team
route beyond news and create-room; `POST /teams` answers 405. The script
adopts a seeded team and edits that instead.
- **A board is created unpublished.** Students get 403 on it while the course
page still lists its title, so `create` publishes the main board and leaves a
second one as a draft on purpose — both states are worth testing against.
`seed.sh` also drags the demo data into the present. The seed ships courses that
ended in 2018 and homework due in 2017, and the v3 endpoints filter on those
dates: a student sees no tasks at all in an ended course, which makes the whole
student-facing surface look empty for reasons that have nothing to do with the
code under test.
## Profiles
| Profile | Services | Cost |
|---|---|---|
| *(default)* | Mongo, Valkey, RabbitMQ, MinIO, api, management, board-collaboration, admin-api, file-storage, client, nuxt, Etherpad, proxy | ~4.7 GB images |
| `tools` | H5P editor + static files + library install, tldraw server + worker, Collabora | ~3.3 GB more, Collabora is the bulk |
| `av` | ClamAV + Clammit | ~1.5 GB resident for the signature database |
| `preview` | file-preview generator (thumbnails) | small |
Etherpad is in the default profile even though it is an external tool. The
legacy client requests an Etherpad session on **every** topic page whose lesson
has contents — it never checks whether a pad is actually present. With Etherpad
down that call fails, `validUntil` comes back undefined, and Express rejects the
resulting session cookie with *"option expires is invalid"*: a 500 on every
topic page, pad or no pad. The live deployment always runs it, so this is the
faithful configuration as well as the working one.
`av` is opt-in for a reason beyond size: with `ENABLE_FILE_SECURITY_CHECK=true`
but no scanner reachable, every upload stays at
`securityCheck.status=pending` and can never be downloaded. Turn the flag in
`env/file-storage.env` on **together with** the profile, or neither.
## Troubleshooting
**Everything 502s.** The proxy is up before the apps are. `docker compose logs
-f api` — the server waits for Mongo to become primary.
**Mongo never becomes healthy.** It runs as a single-node replica set because
the migration runner opens transactions, which Mongo refuses on a standalone.
The healthcheck initiates the set on first boot; give it ~30 s.
**Login succeeds, then immediately bounces back.** The `jwt` cookie is being
dropped. Check `COOKIE__SECURE=false` in `env/client.env` — the proxy speaks
plain HTTP locally.
**A file uploads but will not download.** See the `av` note above.
**H5P element stays empty.** `docker compose --profile tools run --rm
h5p-libraries` and watch it finish; the editor has nothing to offer until the
content types are in the bucket.

View File

@@ -0,0 +1,296 @@
# A local Schulcloud, as close to schulcloud-thueringen.de as it can be made
# without its external identity provider.
#
# The application images are the *same* images the real instance runs
# (quay.io/schulcloudverbund, thr theme, tag 33.40 — see README.md), so the
# behaviour under test is the deployed behaviour, not a rebuild of main.
#
# docker compose up -d core: login, courses, boards, files
# docker compose --profile tools up -d + h5p, tldraw, collabora
# docker compose --profile av up -d + virus scanning of uploads
#
# Everything here is a throwaway dev instance: the credentials are the upstream
# development defaults and are published in the upstream repositories. Do not
# expose any of it beyond localhost.
x-sc-version: &sc-version "${SC_VERSION:-33.40}"
x-server-image: &server-image
image: quay.io/schulcloudverbund/schulcloud-server:${SC_VERSION:-33.40}
env_file: [env/shared.env, env/jwt.env, env/api.env]
depends_on:
mongo: {condition: service_healthy}
valkey: {condition: service_started}
rabbitmq: {condition: service_healthy}
restart: unless-stopped
services:
# ---------------------------------------------------------------- infra ---
mongo:
image: docker.io/mongo:7
# Single-node replica set rather than a bare mongod: the server's migration
# runner opens transactions, which mongo refuses outside a replica set.
command: ["--replSet", "rs0", "--bind_ip_all"]
volumes:
- mongo-data:/data/db
ports: ["127.0.0.1:27019:27017"]
healthcheck:
# Initiates the replica set on first start and reports healthy once the
# node is actually primary, which is what every other service waits for.
test: >-
mongosh --quiet --eval '
try { rs.status() } catch (e) { rs.initiate({_id:"rs0",members:[{_id:0,host:"mongo:27017"}]}) }
quit(db.hello().isWritablePrimary ? 0 : 1)'
interval: 5s
timeout: 10s
retries: 30
start_period: 10s
restart: unless-stopped
valkey:
# The JWT whitelist. Sessions die when their key expires, exactly as in
# production — this is the piece that makes local session testing honest.
image: docker.io/valkey/valkey:8-alpine
ports: ["127.0.0.1:6381:6379"]
restart: unless-stopped
rabbitmq:
image: docker.io/rabbitmq:4-management-alpine
ports: ["127.0.0.1:15673:15672"]
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 10s
retries: 20
start_period: 20s
restart: unless-stopped
minio:
# Stands in for the S3 provider the real instance uses. Buckets are created
# by minio-init below.
image: quay.io/minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: miniouser
MINIO_ROOT_PASSWORD: miniouser
volumes:
- minio-data:/data
ports:
- "127.0.0.1:9900:9000" # S3 API
- "127.0.0.1:9901:9001" # console (miniouser / miniouser)
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 30
restart: unless-stopped
minio-init:
image: quay.io/minio/mc:latest
depends_on:
minio: {condition: service_healthy}
entrypoint: ["/bin/sh", "/init.sh"]
volumes:
- ./scripts/minio-init.sh:/init.sh:ro
restart: "no"
# ------------------------------------------------------- schulcloud api ---
api:
<<: *server-image
container_name: sc-api
command: ["dist/apps/server/apps/server.app"]
ports: ["127.0.0.1:3030:3030"]
management:
# Not part of the running instance — it exposes the seeding and migration
# endpoints that the real deployment's init job calls, and nothing else.
<<: *server-image
command: ["dist/apps/server/apps/management.app"]
# Port and base path are hardcoded to 3333 and /api in management.app.ts;
# PORT is not read here.
ports: ["127.0.0.1:3333:3333"]
board-collaboration:
# The websocket behind column boards. Without it a board renders once and
# then never updates.
<<: *server-image
command: ["dist/apps/server/apps/board-collaboration.app"]
environment:
PORT: "4450"
admin-api:
<<: *server-image
command: ["dist/apps/server/apps/admin-api-server.app"]
environment:
PORT: "4030"
file-storage:
image: quay.io/schulcloudverbund/file-storage:${SC_VERSION:-33.40}
env_file: [env/shared.env, env/jwt.env, env/file-storage.env]
depends_on:
mongo: {condition: service_healthy}
rabbitmq: {condition: service_healthy}
minio: {condition: service_healthy}
ports: ["127.0.0.1:4444:4444"]
restart: unless-stopped
file-storage-consumer:
# The AMQP half of files-storage, and a separate entrypoint from the HTTP
# one: only `files-storage-consumer.app` registers FilesStorageConsumer, so
# running the HTTP app alone leaves the `files-storage` exchange with no
# queue bound to it.
#
# The symptom is not a file problem. TaskService.delete awaits
# deleteFilesOfParent over AMQP before touching the task, so with nothing
# consuming, deleting a task or a topic hangs until the request timeout and
# answers 408 REQUEST_TIMEOUT with the entity still there. Copying a course
# goes the same way.
image: quay.io/schulcloudverbund/file-storage:${SC_VERSION:-33.40}
command: ["dist/apps/files-storage-consumer.app.js"]
env_file: [env/shared.env, env/jwt.env, env/file-storage.env]
depends_on:
mongo: {condition: service_healthy}
rabbitmq: {condition: service_healthy}
minio: {condition: service_healthy}
restart: unless-stopped
file-preview:
# Generates thumbnails via ImageMagick, driven off RabbitMQ. Optional: with
# it absent, files still upload and download, they just have no preview.
image: quay.io/schulcloudverbund/file-storage:file-preview-${SC_VERSION:-33.40}
profiles: ["preview"]
env_file: [env/shared.env, env/jwt.env, env/file-storage.env]
depends_on:
rabbitmq: {condition: service_healthy}
minio: {condition: service_healthy}
restart: unless-stopped
# -------------------------------------------------------------- clients ---
client:
# The legacy UI. Still owns "/" and much of the course view.
image: quay.io/schulcloudverbund/schulcloud-client-thr:${SC_VERSION:-33.40}
env_file: [env/shared.env, env/jwt.env, env/client.env]
depends_on: [api]
ports: ["127.0.0.1:3100:3100"]
restart: unless-stopped
nuxt:
# The Vue SPA, built for the thr theme. The image is an nginx that
# templates env vars into its config at start.
image: quay.io/schulcloudverbund/schulcloud-frontend-thr:${SC_VERSION:-33.40}
env_file: [env/nuxt.env]
ports: ["127.0.0.1:4000:4000"]
restart: unless-stopped
etherpad:
# The collaborative text editor element.
#
# Core, not a "tool", however much it looks like one: the legacy client asks
# the server for an Etherpad session on *every* topic page whose lesson has
# contents, without checking whether the lesson contains a pad at all
# (controllers/topics.js builds `etherpadPads` and then never reads it).
# Unreachable, that call fails, `validUntil` arrives undefined, and
# `new Date(undefined * 1000)` makes Express reject the session cookie —
# "option expires is invalid", a 500 on every topic page. The live
# deployment always runs Etherpad (ETHERPAD_REPLICAS: 1), so keeping it in
# the default profile is both the working and the faithful choice.
image: docker.io/etherpad/etherpad:3.3.3
env_file: [env/etherpad.env]
volumes:
- ./etherpad/APIKEY.txt:/opt/etherpad-lite/APIKEY.txt:ro
depends_on:
mongo: {condition: service_healthy}
restart: unless-stopped
proxy:
# The single origin. Everything a browser touches goes through here, so the
# app sees one host the way it does in production.
image: docker.io/nginx:1.29-alpine
volumes:
- ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
ports: ["127.0.0.1:4400:4400"]
depends_on: [api, client, nuxt]
restart: unless-stopped
# -------------------------------------------------- external tools -------
h5p-editor:
image: quay.io/schulcloudverbund/h5p-server:${SC_VERSION:-33.40}
profiles: ["tools"]
command: ["dist/apps/h5p-editor.app"]
env_file: [env/shared.env, env/jwt.env, env/h5p.env]
environment:
PORT: "4448"
depends_on:
mongo: {condition: service_healthy}
minio: {condition: service_healthy}
restart: unless-stopped
h5p-staticfiles:
image: quay.io/schulcloudverbund/h5p-server:static-files-${SC_VERSION:-33.40}
profiles: ["tools"]
restart: unless-stopped
h5p-libraries:
# One-shot: installs the H5P content types listed in env/h5p.env into the
# library bucket. Exits when done; re-run it after changing that list.
image: quay.io/schulcloudverbund/h5p-server:${SC_VERSION:-33.40}
profiles: ["tools"]
command: ["dist/apps/h5p-library-management.app"]
env_file: [env/shared.env, env/jwt.env, env/h5p.env]
depends_on:
mongo: {condition: service_healthy}
minio-init: {condition: service_completed_successfully}
restart: "no"
tldraw-server:
# The whiteboard element.
image: quay.io/schulcloudverbund/tldraw-server:${SC_VERSION:-33.40}
profiles: ["tools"]
command: ["dist/apps/tldraw-server.app.js"]
env_file: [env/shared.env, env/jwt.env, env/tldraw.env]
depends_on: [valkey, minio]
restart: unless-stopped
tldraw-worker:
image: quay.io/schulcloudverbund/tldraw-server:${SC_VERSION:-33.40}
profiles: ["tools"]
command: ["dist/apps/tldraw-worker.app.js"]
env_file: [env/shared.env, env/jwt.env, env/tldraw.env]
depends_on: [valkey, minio]
restart: unless-stopped
collabora:
# Office document editing. Reached by the browser directly on :9980, the
# way the real deployment puts it on its own hostname.
image: docker.io/collabora/code:latest
profiles: ["tools"]
environment:
extra_params: --o:ssl.enable=false --o:ssl.termination=false
domain: ".*"
aliasgroup1: "http://localhost:4400"
ports: ["127.0.0.1:9980:9980"]
cap_add: ["MKNOD"]
restart: unless-stopped
# ------------------------------------------------------------ antivirus ---
clamav:
# ~1.5 GB resident once the signature database loads, hence its own profile.
image: docker.io/clamav/clamav:1.5.3
profiles: ["av"]
volumes:
- clamav-db:/var/lib/clamav
restart: unless-stopped
clammit:
image: ghcr.io/dbildungsplattform/clammit:0.9.1
profiles: ["av"]
environment:
CLAMMIT_CLAMD_URL: tcp://clamav:3310
CLAMMIT_LISTEN: 0.0.0.0:8438
depends_on: [clamav]
restart: unless-stopped
volumes:
mongo-data:
minio-data:
clamav-db:

100
local-instance/env/api.env vendored Normal file
View File

@@ -0,0 +1,100 @@
# The schulcloud-server API app (apps/server.app).
#
# The FEATURE_* block below is not hand-picked: it is a replay of
# GET /api/v3/config/public from the live schulcloud-thueringen.de, so the local
# instance exposes the same surface to students that the real one does.
# Regenerate with ./scripts/sync-feature-flags.sh.
PORT=3030
# Internal service addresses (compose DNS), not the browser-facing origin.
API_HOST=http://api:3030/api
FILES_STORAGE__SERVICE_BASE_URL=http://file-storage:4444
LICENSE_SUMMARY_URL=http://api:3030/api/licenses/summary
ROOM_MEMBER_INFO_URL=http://api:3030/api/rooms/member-info
ALERT_STATUS_URL=https://status.schulcloud-thueringen.de/
CALENDAR_SERVICE_ENABLED=false
NEST_LOG_LEVEL=info
# Etherpad (see ../etherpad/APIKEY.txt)
ETHERPAD__URI=http://etherpad:9001/api/1
ETHERPAD__PAD_URI=http://localhost:4400/etherpad/p
ETHERPAD__API_KEY=381d67e6347d235ac9446da3ea10a82efd6f8ae09fa2e90efeda80f82feeb4fd
# tldraw + admin API
TLDRAW__WEBSOCKET_URL=ws://localhost:4400/tldraw-server
TLDRAW_ADMIN_API_CLIENT__BASE_URL=http://tldraw-server:3349
TLDRAW_ADMIN_API_CLIENT__API_KEY=tldraw-admin-key
ADMIN_API__ALLOWED_API_KEYS=thisisasupersecureapikeythatisabsolutelysave
# Teacher/student visibility, as configured for thr.
TEACHER_STUDENT_VISIBILITY__IS_CONFIGURABLE=false
TEACHER_STUDENT_VISIBILITY__IS_ENABLED_BY_DEFAULT=true
TEACHER_STUDENT_VISIBILITY__IS_VISIBLE=false
# --- feature flags, mirrored from the live instance ----------------------
FEATURE_ADMINISTRATE_ROOMS_ENABLED=true
FEATURE_AI_TUTOR_ENABLED=false
FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED=false
FEATURE_BOARD_LAYOUT_ENABLED=true
FEATURE_BOARD_READERS_CAN_EDIT_TOGGLE=true
FEATURE_COLUMN_BOARD_COLLABORATIVE_TEXT_EDITOR_ENABLED=true
FEATURE_COLUMN_BOARD_COLLABORA_ENABLED=true
FEATURE_COLUMN_BOARD_ENABLED=true
FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED=true
FEATURE_COLUMN_BOARD_FILE_FOLDER_ENABLED=true
FEATURE_COLUMN_BOARD_H5P_ENABLED=true
FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED=true
FEATURE_COLUMN_BOARD_SHARE=true
FEATURE_COLUMN_BOARD_SOCKET_ENABLED=true
# local override (live: true) — needs infrastructure we do not run
FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED=false
FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED=false
FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED=false
FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_MAX_FILE_SIZE=1073741824
# local override (live: true) — otherwise every seeded user hits a consent wall
FEATURE_CONSENT_NECESSARY=false
FEATURE_COPY_SERVICE_ENABLED=true
FEATURE_COURSE_SHARE=true
FEATURE_CTL_TOOLS_COPY_ENABLED=true
FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION=false
FEATURE_EXTERNAL_PERSON_REGISTRATION_ENABLED=false
FEATURE_EXTERNAL_SYSTEM_LOGOUT_ENABLED=false
FEATURE_FWU_CONTENT_ENABLED=false
FEATURE_LESSON_SHARE=true
FEATURE_LOGIN_LINK_ENABLED=false
FEATURE_MEDIA_SHELF_ENABLED=true
FEATURE_NOTIFICATIONS_ENABLED=false
FEATURE_PREFERRED_CTL_TOOLS_ENABLED=true
FEATURE_ROOM_ADD_EXTERNAL_PERSONS_ENABLED=false
FEATURE_ROOM_COPY_ENABLED=true
FEATURE_ROOM_LINK_INVITATION_EXTERNAL_PERSONS_ENABLED=false
FEATURE_ROOM_REGISTER_EXTERNAL_PERSONS_ENABLED=false
FEATURE_ROOM_SHARE=true
FEATURE_SCHOOL_POLICY_ENABLED_NEW=true
FEATURE_SCHOOL_TERMS_OF_USE_ENABLED=true
FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED=false
FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED=false
FEATURE_SHOW_MIGRATION_WIZARD=false
FEATURE_SHOW_OUTDATED_USERS=false
FEATURE_TASK_SHARE=true
FEATURE_TEAMS_ENABLED=true
FEATURE_TEAM_CREATE_ROOM_ENABLED=true
FEATURE_TLDRAW_ENABLED=true
FEATURE_USER_LOGIN_MIGRATION_ENABLED=false
FEATURE_USER_MIGRATION_ENABLED=false
# local override (live: true) — needs infrastructure we do not run
FEATURE_VIDEOCONFERENCE_ENABLED=false
FEATURE_VIDIS_MEDIA_ACTIVATIONS_ENABLED=false
# --- required-but-unused endpoints ---------------------------------------
# The config classes validate these as present strings even when the feature
# is off, so they get a placeholder rather than a real service. Hydra is the
# OAuth2 provider behind external tool launches; we do not run it.
HYDRA_URI=http://hydra.invalid:4444
# Calendar. The live deployment runs a schulcloud-calendar service and this
# stack does not. Deletions still succeed: the calendar call fails and is
# tolerated. Nothing here reads calendars.
CALENDAR_SERVICE_ENABLED=false

31
local-instance/env/client.env vendored Normal file
View File

@@ -0,0 +1,31 @@
# schulcloud-client, the legacy Express/Handlebars UI. Still serves "/" and
# large parts of the course view, so it is not optional.
PORT=3100
# HOST is what the client puts into redirects, so it must be the browser-facing
# origin (the proxy), not this container's own address.
HOST=http://localhost:4400
API_HOST=http://api:3030/api
PUBLIC_BACKEND_URL=http://localhost:4400/api
FILES_STORAGE__SERVICE_BASE_URL=http://file-storage:4444
ETHERPAD__PAD_URI=http://localhost:4400/etherpad/p
ETHERPAD__PAD_PATH=/etherpad/p
ETHERPAD__DOMAIN=localhost
FEATURE_ETHERPAD_ENABLED=true
SESSION_VALKEY__MODE=single
SESSION_VALKEY__URI=redis://valkey:6379
SESSION_COOKIE_SAME_SITE=lax
# The proxy terminates plain HTTP locally; without this the client marks the
# jwt cookie Secure and the browser silently drops it.
COOKIE__SECURE=false
COOKIE__SAME_SITE=lax
COOKIE__HTTP_ONLY=false
# Signs the session cookie. Local-only value; the app refuses to start without it.
COOKIE_SECRET=local-instance-cookie-secret-not-a-real-secret
LOG_LEVEL=info

11
local-instance/env/etherpad.env vendored Normal file
View File

@@ -0,0 +1,11 @@
# Etherpad, the collaborative text editor element on column boards.
# Settings taken from docs/topics/etherpad/Local setup.md.
REQUIRE_SESSION=true
PAD_OPTIONS_SHOW_CHAT=true
DISABLE_IP_LOGGING=true
DEFAULT_PAD_TEXT=Schreib etwas!
DB_TYPE=mongodb
DB_URL=mongodb://mongo:27017/etherpad
AUTHENTICATION_METHOD=apikey
TRUST_PROXY=true

30
local-instance/env/file-storage.env vendored Normal file
View File

@@ -0,0 +1,30 @@
# file-storage service (repo hpi-schul-cloud/file-storage), the /api/v3/file/* API.
FILES_STORAGE__SERVICE_BASE_URL=http://file-storage:4444
FILE_STORAGE_SERVICE_URL=http://file-storage:4444
AUTHORIZATION_API_URL=http://api:3030/api/v3
FILES_STORAGE_S3_ENDPOINT=http://minio:9000/
FILES_STORAGE_S3_BUCKET=schulcloud
FILES_STORAGE_S3_REGION=eu-central-1
FILES_STORAGE_S3_ACCESS_KEY_ID=miniouser
FILES_STORAGE_S3_SECRET_ACCESS_KEY=miniouser
# Antivirus. Off by default: ClamAV wants ~1.5 GB of RAM for its signature DB.
# Turn it on together with the `av` profile — with the scanner absent but the
# check enabled, every upload stays stuck in securityCheck.status=pending and
# can never be downloaded.
ENABLE_FILE_SECURITY_CHECK=false
ANTIVIRUS_SERVICE_HOSTNAME=clamav
ANTIVIRUS_SERVICE_PORT=3310
FILES_STORAGE_USE_STREAM_TO_ANTIVIRUS=false
PREVIEW_PRODUCER_INCOMING_REQUEST_TIMEOUT=10000
# Collabora must be reachable by the *browser*, so this one is a host URL.
COLLABORA_ONLINE_URL=http://localhost:9980
WOPI_URL=http://localhost:4400/api/v3/wopi/files
WOPI_POST_MESSAGE_ORIGIN=http://localhost:4400
FEATURE_COLUMN_BOARD_COLLABORA_ENABLED=true
LOGGER_LOG_LEVEL=info

18
local-instance/env/h5p.env vendored Normal file
View File

@@ -0,0 +1,18 @@
# h5p-server: the h5p-editor app plus the one-shot library-management job.
H5P_EDITOR__S3_ENDPOINT=http://minio:9000
H5P_EDITOR__S3_REGION=eu-central-1
H5P_EDITOR__S3_ACCESS_KEY_ID=miniouser
H5P_EDITOR__S3_SECRET_ACCESS_KEY=miniouser
H5P_EDITOR__S3_BUCKET_CONTENT=h5p-content-bucket
H5P_EDITOR__S3_BUCKET_LIBRARIES=h5p-library-bucket
H5P_EDITOR__LIBRARIES_S3_ACCESS_KEY_ID=miniouser
H5P_EDITOR__LIBRARIES_S3_SECRET_ACCESS_KEY=miniouser
# A short list keeps the one-off library install to a couple of minutes; the
# upstream default installs ~40 libraries and takes far longer.
H5P_EDITOR__LIBRARY_LIST=H5P.ArithmeticQuiz,H5P.Chart,H5P.MultiChoice,H5P.Blanks
API_HOST=http://api:3030/api
CORE_INCOMING_REQUEST_TIMEOUT_MS=8000
LOGGER_LOG_LEVEL=info

5
local-instance/env/jwt.env vendored Normal file
View File

@@ -0,0 +1,5 @@
# Development RSA keypair, copied verbatim from schulcloud-server/.env.development
# (upstream, public, committed in their repo). It exists so every local service
# validates the same tokens. NEVER use these keys anywhere reachable from outside.
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIJKAIBAAKCAgEA0/oW2sIZWvVt0AEgQ8PS80/udJzfWXu6t2QWjUcQA2THGvDS\nXXMH6YMMY2czyBgf6L7hHV/9p1Trfpe7YgxYhOoGsxhXG1keAYQ4+mdveaUAa3ui\nACdEodsB0OFjVUdgOHCyUIXFfhSsp2p2tmZeFi/bE2v/05kYO+ExgQuzUDbB8bCr\n1sc7gMS/2dC2iE/BVw/I0F14oZkZn0fshojg4qoaLbLVKB7Iw53IXF2878zXp81J\ndnnvHdwVbGWqoII6sHZFQs8ob5S/WGMl4QnBHN98x0KmORUFyTv5kK4cdcC8LJ1H\npoVWNC6js84iF9yFRhYXY2RHqh7BwaZZ4XZym/MetTdQTBDaSvhXe0A3WdahNG+D\nGriehd6doWk98Adb49InaodH64ZRkurxiX61GEtzjMRq9EfGS5R/IfcWyPQbiir6\nymKXfOUtywRjcm3FZzmT7j3c0UHzQVEH0NBfTMj+QKz5NILNP230j0DcjNImDbHH\ncVH1quSb6e0WXjKANTkf4gaTOw7jdQDFw0Ou3aEmwPg+Xk1cwCwSHOOmPSSssZwg\njpzGodPO3vsMGfRYTwcGbzgdQFFj0qTmvgnM5MHtEy8qCyvM4OsAPnE0zQWn48p7\nPVdJm6j0H/1BYgVw1KxecIVk/HryoTOkgS9lhLu8iEIyrpAlWascIK7Uw58CAwEA\nAQKCAgAA0/lC4X83272SEm8N1LX+PVGxIuu8bb9M+BcediiZ2srsUASCWPCu+NQT\nj1OkdHOrdRNsCfPzs2E4HV+eAm5WFpPwHyg38yEq4FlYoQ7OataVlOYNGhoqh7B6\nIGdC7gRyM/5+UgdzdqE2BjRwgfXcIFO6v7FAIlj14utOlb0dkxku2IHTVPPmjN4y\n+5266pTWwjkGl1bhSrfO53kFDYPTXta7Vvd+MKCYIwWlVrhmN2agQS0ISXGlrDZp\nNfx0pA2Wot+iYyzFQs98iOac+mzGsBjMrnX3wx1Cq/lNl2CFFTum8PZWsC6mBYie\nKy/25+WdYHi26q1c/MHE/+FaABxyfa3PCXc4qmA9BHcrxVB3EtvFYxOUrGuI//S9\n7PLswRiPd80amo2NpAg15k03ubK9i8jD1PYjgKmhDayd9fmLSAtUrTdvP1MINBiu\nswEmJRyARMW2DCJc4E6+xDObSpy7zWsVEQWRKVt4g+73/zgOgFpPqdDgz7BTcBa9\niRVw1FrjI4TbRMlJpfD+gcyNYiXy7oJ94oHxDU/m8lwFcyMnRboz8QdjisIGG/Vy\n8U+chaAClGbr2CWTFyRHqXuXd2RIRQ3gU9To0Elpff9Scy8KnARohr5xzcFku9Os\nAyQ+rTXx7vDFoWilLQLQmMo2mNSSjRTvaD2vcb1AD4VeMDlYAQKCAQEA+Au90TYy\nVArIdN5d+xXqD5nYkcfKgR2EvVmrW8H1yAI3MbAmYtA8HpLHQhJSm+SDSnaszLZh\nV/nDmHsPUGs1U0O8RjkHxmljTbTH469CIeGvnR8ODcqH8C5Ds1vfrxYjG9Axih3I\nOp+mJs4HyBsCU6LmPJUCKuYtsxY8s/qhTmXHxDxnkW1niIlBTE4pqhThFTojPWfE\nHR7niK5PpayYsEGRbYceXGcrn7Rl26+FvbQCJ3XrhAwrG9+U18V3KLs87VePfBz3\nfEuej6x35e83z0l0aSqQW5sJmunlmxvWJMQLir16oebpLsgcjtBnhdl/Q/JSbHMC\nCnbuZcnDoIPHCwKCAQEA2sZAH9f4I+gdz0jgyOUMdC8dBMOQN0uVo4YUXKJGOgkc\nQ+TcfE990eTdJcEv+FlWeq1CPbwcIqrQrlhwDypSCjsVWKVL2eaSdpY3cNsKCT5W\nVnoOV6lGpiXqq0xy6UK/hkuTCDk9W4u536qZSLPSFbMjVKfOlexcx1gNZiHTGGLv\nDOSw0JdkS7XA6Whq5kToFoA4uwMK70mWYGv+FV87kvF080TeGs6YOIuSXM6++hwY\ndhBEoqXYfiVwCeBT5VH+fnAh/dBufUd68oNUCcfKJ1nkOlggyHwU1aJjkeO6bA2k\nPuxjtTd9pCzpCgS2nmCj0E24qKf9GPyef+SndsjCPQKCAQEAwCTgSoMwI1gjBh0H\nMiw8nw8u62aX4MLMA53FlxO938yPkucAJUVnfMt4nR7ybR5r8a/SldWlvG+W67RQ\nHZyetzxeSQt+kV0r9pLW0PH/SZ242v6mdVpxSUWdXgAKW2fLlI0HAxWk+HyZSbAJ\n6SG7AKzMqxtGjZK2zeao6UZ50/AV+lZMaCQWsnaYZZKaxczcuwPJLpUGHwTEmGVm\n/1CfCtIP5IdppmypJ1KoILBr6pLZpFW9NhHzBumANFEbyCqavMQ6Owt5TwiI8ITK\ncAyJ8AHXsmutXbjQjPcozKmYjexrgHLc3zOvaHTNYnff6Zic9DZvUOEaMJ8Gd0T/\nTIUoFwKCAQBaiA+hHc4hjbxIOvBKMf6lVZm8jvDu8OhLcwCaFMza10pLDjnvdzWp\n1ftt1DP1oYKX4Xq38U/zSJxyiUZWAD1S3oBG3qA026VgTWlD2mCc0p8HyhqFTBdg\nSfCCUnB69pQrDrsZfBZX+8o/NGmaHE+jiy3jqk1i3RzHoThqOzUPsmEaBMjmiL+I\nVP4vmHYkM/+W0BipyuiLfPgtjoLmdTJB7Ilo4ebHURbMz3UR0rxU46t7r9+3LsoX\n6YYjkCEnlHar+9sVHVubnCjUkmQEaBjPj/NR8YYfcLlubnSluoc6j6qYH1pjc0Ma\n3TrSWoD3qSYg3Qi9QkcKP/+XDRf/n7RBAoIBAEdAxaD/vUW7DwGPIAbziMtkx03R\nCc7Tdp+v8XURUu5HrAxXdGK1J8ufgevFhJ6jXre/25BV9RVGAUzAK95xEkZh/ulB\nuFtxUN2CRh92EWGiC8FYtMkJEFnkjAxBjucFOWkRHjzJMF7+PuNeQSb4TEiGMEZg\nt1VWdHgL+FpNuZsKzuZ9jwfALj27LAkkJLjpH9DXDo6e7aJlCqbe8ili1gLo80FZ\np65W4wIRQSChoMcOHgZCbOBebUSW0zXLvccXoq+BGlt+qLM830Y0UFolbckHrF1O\nCTSPG6IaRisx3D2hNNrZIcyZaIwZeHhvj7fib/5hMRerXzSTH1QMXPc2bH4=\n-----END RSA PRIVATE KEY-----\n"
JWT_PUBLIC_KEY="-----BEGIN RSA PUBLIC KEY-----\nMIICCgKCAgEA0/oW2sIZWvVt0AEgQ8PS80/udJzfWXu6t2QWjUcQA2THGvDSXXMH\n6YMMY2czyBgf6L7hHV/9p1Trfpe7YgxYhOoGsxhXG1keAYQ4+mdveaUAa3uiACdE\nodsB0OFjVUdgOHCyUIXFfhSsp2p2tmZeFi/bE2v/05kYO+ExgQuzUDbB8bCr1sc7\ngMS/2dC2iE/BVw/I0F14oZkZn0fshojg4qoaLbLVKB7Iw53IXF2878zXp81Jdnnv\nHdwVbGWqoII6sHZFQs8ob5S/WGMl4QnBHN98x0KmORUFyTv5kK4cdcC8LJ1HpoVW\nNC6js84iF9yFRhYXY2RHqh7BwaZZ4XZym/MetTdQTBDaSvhXe0A3WdahNG+DGrie\nhd6doWk98Adb49InaodH64ZRkurxiX61GEtzjMRq9EfGS5R/IfcWyPQbiir6ymKX\nfOUtywRjcm3FZzmT7j3c0UHzQVEH0NBfTMj+QKz5NILNP230j0DcjNImDbHHcVH1\nquSb6e0WXjKANTkf4gaTOw7jdQDFw0Ou3aEmwPg+Xk1cwCwSHOOmPSSssZwgjpzG\nodPO3vsMGfRYTwcGbzgdQFFj0qTmvgnM5MHtEy8qCyvM4OsAPnE0zQWn48p7PVdJ\nm6j0H/1BYgVw1KxecIVk/HryoTOkgS9lhLu8iEIyrpAlWascIK7Uw58CAwEAAQ==\n-----END RSA PUBLIC KEY-----\n"

10
local-instance/env/nuxt.env vendored Normal file
View File

@@ -0,0 +1,10 @@
# schulcloud-frontend (nuxt-client). The image is an nginx serving the built
# SPA; these values are substituted into its config template at container start
# (see nuxt-client/config/docker/nginx.conf.template).
PUBLIC_BACKEND_URL=http://localhost:4400/api
LEGACY_CLIENT_URL=http://client:3100
COLLABORA_OFFICE_URL=http://localhost:9980
LICENSE_SUMMARY_URL_FOR_CSP=http://localhost:4400
H5P_SCRIPT_SRC_URLS=http://localhost:4400
H5P_IMG_SRC_URLS=http://localhost:4400

46
local-instance/env/shared.env vendored Normal file
View File

@@ -0,0 +1,46 @@
# Shared by every Schulcloud app in the stack.
# Everything in this directory is local-only and deliberately non-secret.
NODE_ENV=production
NO_COLOR=true
TZ=Europe/Berlin
# --- identity of this instance -------------------------------------------
# Mirrors dof_app_deploy/ansible/group_vars/thr/instance_cfg.yml so the local
# instance looks and behaves like schulcloud-thueringen.de.
SC_THEME=thr
SC_SHORTNAME=thr
SC_TITLE=Thüringer Schulcloud
SC_PRODUCTNAME=Thüringer Schulcloud
SC_NAV_TITLE=Thüringer Schulcloud
SC_CONTACT_EMAIL=schulcloud-support@thillm.de
ACCESSIBILITY_REPORT_EMAIL=institut@thillm.de
# The single origin the browser talks to (the nginx in ./proxy).
SC_DOMAIN=localhost:4400
HOST=http://localhost:4400
# The API stamps SC_DOMAIN into every JWT as both issuer and audience. The
# satellite services (file-storage, h5p) validate iss/aud against JWT_DOMAIN,
# which defaults to a bare "localhost" — so without this they reject every
# token the API issued and the homework page's file lookups 401. Keep the two
# in lockstep.
JWT_DOMAIN=localhost:4400
PUBLIC_BACKEND_URL=http://localhost:4400/api
CTL_TOOLS_BACKEND_URL=http://localhost:4400/api
# --- infrastructure ------------------------------------------------------
DB_URL=mongodb://mongo:27017/schulcloud
DB_ENSURE_INDEXES=true
RABBITMQ_URI=amqp://guest:guest@rabbitmq:5672
# The JWT whitelist. `single` reproduces production: every authenticated
# request re-sets a Valkey key with a JWT_TIMEOUT_SECONDS TTL, and losing that
# key logs the session out. `in-memory` would hide that behaviour entirely,
# which is exactly the behaviour this instance exists to test.
SESSION_VALKEY__MODE=single
SESSION_VALKEY__URI=redis://valkey:6379
JWT_TIMEOUT_SECONDS=7200
JWT_SHOW_TIMEOUT_WARNING_SECONDS=3600
AES_KEY=randomStringWithAtLeast16Chars;
S3_KEY=abcdefghijklmnop

17
local-instance/env/tldraw.env vendored Normal file
View File

@@ -0,0 +1,17 @@
# tldraw-server: the whiteboard element on column boards. Server + worker.
REDIS_URL=redis://valkey:6379
AUTHORIZATION_API_HOST=http://api:3030
S3_ACCESS_KEY=miniouser
S3_SECRET_KEY=miniouser
S3_BUCKET=ydocs
S3_ENDPOINT=minio
S3_PORT=9000
S3_SSL=false
FEATURE_TLDRAW_ENABLED=true
TLDRAW_WEBSOCKET_URL=ws://localhost:4400/tldraw-server
X_API_ALLOWED_KEYS=tldraw-admin-key
NOT_AUTHENTICATED_REDIRECT_URL=http://localhost:4400/login
LOGGER_LOG_LEVEL=info

View File

@@ -0,0 +1 @@
381d67e6347d235ac9446da3ea10a82efd6f8ae09fa2e90efeda80f82feeb4fd

View File

@@ -0,0 +1,585 @@
# GENERATED by scripts/gen-proxy-conf.py — do not edit by hand.
#
# One origin in front of the whole stack, the way the real instance is fronted
# by its Kubernetes ingress. The path split between the legacy client and the
# new SPA is not cosmetic: get it wrong and you are testing a different
# application from the one the students use.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 4400;
server_name localhost;
# Docker's embedded DNS. Every proxy_pass below goes through a variable so
# that names resolve per request rather than at startup — otherwise this
# container refuses to boot whenever an optional profile (tools, av) is
# down, which is the normal case.
resolver 127.0.0.11 valid=10s ipv6=off;
# Course files and H5P uploads are large; the ingress allows the same.
client_max_body_size 2600m;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# version-aggregator-svc upstream; /serverversion and /nuxtversion are the
# real per-app endpoints and are routed below.
location = /version {
default_type application/json;
return 200 '{"local-instance":true,"see":["/serverversion","/nuxtversion"]}';
}
# --- service-owned ingresses and websockets ---
location /api/v3/file/ {
set $up_api_v3_file file-storage:4444;
proxy_pass http://$up_api_v3_file;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/v3/wopi/ {
set $up_api_v3_wopi file-storage:4444;
proxy_pass http://$up_api_v3_wopi;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/v3/h5p-editor/h5pstatics/ {
set $up_api_v3_h5p_editor_h5pstatics h5p-staticfiles:8080;
rewrite ^/api/v3/h5p-editor/h5pstatics/(.*)$ /h5pstatics/$1 break;
proxy_pass http://$up_api_v3_h5p_editor_h5pstatics;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/v3/h5p-editor/ {
set $up_api_v3_h5p_editor h5p-editor:4448;
proxy_pass http://$up_api_v3_h5p_editor;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/v3/ {
set $up_api_v3 api:3030;
proxy_pass http://$up_api_v3;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /admin/api/v1 {
set $up_admin_api_v1 admin-api:4030;
proxy_pass http://$up_admin_api_v1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /board-collaboration {
set $up_board_collaboration board-collaboration:4450;
proxy_pass http://$up_board_collaboration;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location /tldraw-server {
set $up_tldraw_server tldraw-server:3345;
proxy_pass http://$up_tldraw_server;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location /api/tldraw {
set $up_api_tldraw tldraw-server:3345;
proxy_pass http://$up_api_tldraw;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# --- from dof_app_deploy x_ingress.yml ---
# default
location / {
set $up_root client:3100;
proxy_pass http://$up_root;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# themes
location /favicon.png {
set $up_favicon_png nuxt:4000;
proxy_pass http://$up_favicon_png;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# bbb_presentation_pdf
location /bbb-presentation.pdf {
set $up_bbb_presentation_pdf nuxt:4000;
proxy_pass http://$up_bbb_presentation_pdf;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# runtime
location /runtime.config.json {
set $up_runtime_config_json nuxt:4000;
proxy_pass http://$up_runtime_config_json;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# metrics
location /metrics {
return 404;
}
# login
location /login {
set $up_login client:3100;
proxy_pass http://$up_login;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# error
location /error {
set $up_error nuxt:4000;
proxy_pass http://$up_error;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# imprint
location /imprint {
set $up_imprint nuxt:4000;
proxy_pass http://$up_imprint;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# mint-ec
location /mint-ec {
set $up_mint_ec nuxt:4000;
proxy_pass http://$up_mint_ec;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# news
location /news {
set $up_news nuxt:4000;
proxy_pass http://$up_news;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# tasks
location /tasks {
set $up_tasks nuxt:4000;
proxy_pass http://$up_tasks;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# nuxtversion
location /nuxtversion {
set $up_nuxtversion nuxt:4000;
proxy_pass http://$up_nuxtversion;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# content
location /content {
set $up_content nuxt:4000;
proxy_pass http://$up_content;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# _nuxt
location /_nuxt {
set $up__nuxt nuxt:4000;
proxy_pass http://$up__nuxt;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# administration_ldap
location /administration/ldap {
set $up_administration_ldap nuxt:4000;
proxy_pass http://$up_administration_ldap;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# administration_migration
location /administration/migration {
set $up_administration_migration nuxt:4000;
proxy_pass http://$up_administration_migration;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# administration_school-settings
location /administration/school-settings {
set $up_administration_school_settings nuxt:4000;
proxy_pass http://$up_administration_school_settings;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# administration_students
location /administration/students {
set $up_administration_students nuxt:4000;
proxy_pass http://$up_administration_students;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# administration_teachers
location /administration/teachers {
set $up_administration_teachers nuxt:4000;
proxy_pass http://$up_administration_teachers;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# administration_rooms
location /administration/rooms/manage {
set $up_administration_rooms_manage nuxt:4000;
proxy_pass http://$up_administration_rooms_manage;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# administration_groups_classes
location /administration/groups/classes {
set $up_administration_groups_classes nuxt:4000;
proxy_pass http://$up_administration_groups_classes;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# boards
location /boards {
set $up_boards nuxt:4000;
proxy_pass http://$up_boards;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# administration_rooms_new
location /administration/rooms/new {
set $up_administration_rooms_new nuxt:4000;
proxy_pass http://$up_administration_rooms_new;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# rooms-overview
location /rooms-overview {
set $up_rooms_overview nuxt:4000;
proxy_pass http://$up_rooms_overview;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# dashboard
location /dashboard {
set $up_dashboard nuxt:4000;
proxy_pass http://$up_dashboard;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# rooms
location /rooms {
set $up_rooms nuxt:4000;
proxy_pass http://$up_rooms;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# folder
location /folder {
set $up_folder nuxt:4000;
proxy_pass http://$up_folder;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# collabora
location /collabora {
set $up_collabora nuxt:4000;
proxy_pass http://$up_collabora;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# h5p-player
location /h5p/player {
set $up_h5p_player nuxt:4000;
proxy_pass http://$up_h5p_player;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# h5p-editor
location /h5p/editor {
set $up_h5p_editor nuxt:4000;
proxy_pass http://$up_h5p_editor;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# migration
location /migration {
set $up_migration nuxt:4000;
proxy_pass http://$up_migration;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# tools_context
location /tools/context {
set $up_tools_context nuxt:4000;
proxy_pass http://$up_tools_context;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# media_shelf
location /media-shelf {
set $up_media_shelf nuxt:4000;
proxy_pass http://$up_media_shelf;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# licenses
location /licenses {
set $up_licenses nuxt:4000;
proxy_pass http://$up_licenses;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# registration-external-members
location /registration-external-members {
set $up_registration_external_members nuxt:4000;
proxy_pass http://$up_registration_external_members;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# security
location /system/security {
set $up_system_security nuxt:4000;
proxy_pass http://$up_system_security;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# api
location /api {
return 404;
}
# api_v1_roster
location /api/v1/roster {
return 404;
}
# api_v1_consentVersions
location /api/v1/consentVersions {
set $up_api_v1_consentVersions api:3030;
proxy_pass http://$up_api_v1_consentVersions;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# api_v1_ldap-config
location /api/v1/ldap-config {
set $up_api_v1_ldap_config api:3030;
proxy_pass http://$up_api_v1_ldap_config;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# api_version
location /serverversion {
set $up_serverversion api:3030;
proxy_pass http://$up_serverversion;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# api_v1_courses
location /api/v1/courses {
set $up_api_v1_courses api:3030;
proxy_pass http://$up_api_v1_courses;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# api_v1_users
location /api/v1/users {
set $up_api_v1_users api:3030;
proxy_pass http://$up_api_v1_users;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# api_v1_classes
location /api/v1/classes {
set $up_api_v1_classes api:3030;
proxy_pass http://$up_api_v1_classes;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /etherpad/admin { return 404; }
location ^~ /etherpad/stats { return 404; }
location /etherpad/socket.io {
set $up_etherpad etherpad:9001;
rewrite /etherpad/socket.io/(.*) /socket.io/$1 break;
proxy_pass http://$up_etherpad;
proxy_redirect / /etherpad/;
proxy_set_header Host $http_host;
proxy_buffering off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location /etherpad {
set $up_etherpad etherpad:9001;
rewrite ^/etherpad$ /etherpad/ permanent;
rewrite /etherpad/(.*) /$1 break;
proxy_pass http://$up_etherpad;
proxy_pass_header Server;
proxy_redirect / /etherpad/;
proxy_set_header Host $host;
proxy_buffering off;
}
# version_aggregator
location /version {
return 404;
}
}

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Regenerate proxy/nginx.conf from the real deployment's ingress table.
The live instance is a Kubernetes ingress that splits one origin across the
legacy client, the new SPA and several APIs. Which path goes where is not
documented in prose — it is the table in
dof_app_deploy/ansible/group_vars/all/x_ingress.yml plus a per-path ingress in
each service repo. Transcribing 46 rules by hand invites exactly the drift that
would make this instance lie about the real one, so we generate them.
Usage: python3 scripts/gen-proxy-conf.py > proxy/nginx.conf
Needs the upstream clones in ../vendor (see README.md).
"""
import pathlib
import sys
try:
import yaml
except ImportError:
sys.exit('needs PyYAML: pip install pyyaml')
VENDOR = pathlib.Path(__file__).resolve().parents[2] / 'vendor'
INGRESS = VENDOR / 'dof_app_deploy/ansible/group_vars/all/x_ingress.yml'
# Kubernetes service name -> compose upstream. None means the real deployment
# deliberately 404s that path.
UPSTREAM = {
'client-svc': 'client:3100',
'nuxtclient-svc': 'nuxt:4000',
'api-svc': 'api:3030',
'default-backend-404-svc': None,
'version-aggregator-svc': None, # replaced by our own /version below
None: None,
}
# Routes that live in the individual service repos' own ingress templates
# rather than the shared table, plus the two websocket endpoints.
# (path, upstream, websocket, rewrite-or-None)
EXTRA = [
('/api/v3/file/', 'file-storage:4444', False, None),
('/api/v3/wopi/', 'file-storage:4444', False, None),
('/api/v3/h5p-editor/h5pstatics/', 'h5p-staticfiles:8080', False,
'^/api/v3/h5p-editor/h5pstatics/(.*)$ /h5pstatics/$1'),
('/api/v3/h5p-editor/', 'h5p-editor:4448', False, None),
('/api/v3/', 'api:3030', False, None),
('/admin/api/v1', 'admin-api:4030', False, None),
('/board-collaboration', 'board-collaboration:4450', True, None),
('/tldraw-server', 'tldraw-server:3345', True, None),
('/api/tldraw', 'tldraw-server:3345', False, None),
]
PREAMBLE = '''# GENERATED by scripts/gen-proxy-conf.py — do not edit by hand.
#
# One origin in front of the whole stack, the way the real instance is fronted
# by its Kubernetes ingress. The path split between the legacy client and the
# new SPA is not cosmetic: get it wrong and you are testing a different
# application from the one the students use.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 4400;
server_name localhost;
# Docker's embedded DNS. Every proxy_pass below goes through a variable so
# that names resolve per request rather than at startup — otherwise this
# container refuses to boot whenever an optional profile (tools, av) is
# down, which is the normal case.
resolver 127.0.0.11 valid=10s ipv6=off;
# Course files and H5P uploads are large; the ingress allows the same.
client_max_body_size 2600m;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# version-aggregator-svc upstream; /serverversion and /nuxtversion are the
# real per-app endpoints and are routed below.
location = /version {
default_type application/json;
return 200 '{"local-instance":true,"see":["/serverversion","/nuxtversion"]}';
}
'''
COMMON = ''' proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
'''
WS = ''' proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
'''
GONE = '''
location {path} {{
return 404;
}}
'''
# Etherpad is mounted under a prefix it knows nothing about, so the deployment
# runs a dedicated nginx in front of it that rewrites the prefix away and
# proxies socket.io separately. Copied from
# dof_app_deploy/ansible/roles/dof_etherpad/templates/nginx-configmap-files.yml.j2
# — a plain proxy_pass gets you a pad that loads and then never syncs.
ETHERPAD = '''
location ^~ /etherpad/admin { return 404; }
location ^~ /etherpad/stats { return 404; }
location /etherpad/socket.io {
set $up_etherpad etherpad:9001;
rewrite /etherpad/socket.io/(.*) /socket.io/$1 break;
proxy_pass http://$up_etherpad;
proxy_redirect / /etherpad/;
proxy_set_header Host $http_host;
proxy_buffering off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location /etherpad {
set $up_etherpad etherpad:9001;
rewrite ^/etherpad$ /etherpad/ permanent;
rewrite /etherpad/(.*) /$1 break;
proxy_pass http://$up_etherpad;
proxy_pass_header Server;
proxy_redirect / /etherpad/;
proxy_set_header Host $host;
proxy_buffering off;
}
'''
def block(path: str, upstream: str, ws: bool, rewrite: str | None, var: str) -> str:
out = [f'\n\tlocation {path} {{\n', f'\t\tset ${var} {upstream};\n']
if rewrite:
out.append(f'\t\trewrite {rewrite} break;\n')
out.append(f'\t\tproxy_pass http://${var};\n')
out.append(COMMON)
if ws:
out.append(WS)
out.append('\t}\n')
return ''.join(out)
def varname(path: str) -> str:
safe = ''.join(c if c.isalnum() else '_' for c in path.strip('/')) or 'root'
return f'up_{safe}'
def main() -> None:
table = yaml.safe_load(INGRESS.read_text())['default_ingress']
seen: set[str] = set()
out = [PREAMBLE]
out.append('\n\t# --- service-owned ingresses and websockets ---\n')
for path, upstream, ws, rewrite in EXTRA:
seen.add(path)
out.append(block(path, upstream, ws, rewrite, varname(path)))
out.append('\n\t# --- from dof_app_deploy x_ingress.yml ---\n')
for name, entry in table.items():
path = entry.get('path')
if path is None or path in seen:
continue
seen.add(path)
if path == '/etherpad':
out.append(ETHERPAD)
continue
upstream = UPSTREAM.get(entry.get('serviceName'), 'MISSING')
if upstream == 'MISSING':
sys.exit(f'unknown serviceName for {name}: {entry.get("serviceName")}')
out.append(f'\n\t# {name}')
out.append(GONE.format(path=path) if upstream is None
else block(path, upstream, False, None, varname(path)))
out.append('}\n')
sys.stdout.write(''.join(out))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Mint a session on the local instance and print the environment the MCP server
# and CLI expect, so they can be pointed at it instead of the live Schulcloud.
#
# eval "$(./scripts/mcp-env.sh)" # as the demo student
# eval "$(./scripts/mcp-env.sh klara.fall@schul-cloud.org Schulcloud1\!)"
#
# Nothing is written to the repo: the output contains a live session token, and
# a throwaway instance is still no reason to start committing those.
set -euo pipefail
# `localhost`, not `127.0.0.1`: it must match SC_DOMAIN, because urls the server
# hands back (Etherpad pads, for one) are built from it, and the client refuses
# to follow a url onto a different host rather than leak a session cookie there.
URL=${LOCAL_SC_URL:-http://localhost:4400}
USER=${1:-demo-schueler@schul-cloud.org}
PASS=${2:-schulcloud}
token=$(curl -fsS -X POST "$URL/api/v3/authentication/local" \
-H 'Content-Type: application/json' \
-d "$(printf '{"username":%s,"password":%s}' \
"$(printf '%s' "$USER" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \
"$(printf '%s' "$PASS" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')")" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["accessToken"])')
cat <<ENV
export TSC_URL=$URL
export TSC_JWT_COOKIE=$token
export MCP_AUTH_TOKEN=local-instance-token
ENV

View File

@@ -0,0 +1,19 @@
#!/bin/sh
# Creates the buckets each service expects. MinIO does not create them on
# demand: file-storage reports a generic 500 on upload if its bucket is
# missing, and the h5p library job fails halfway through.
set -eu
mc alias set local http://minio:9000 miniouser miniouser
for bucket in \
schulcloud ` # files-storage (the /api/v3/file API)` \
h5p-content-bucket ` # h5p-editor content` \
h5p-library-bucket ` # h5p content types` \
ydocs ` # tldraw whiteboard documents` \
fwu-content # FWU media, unused but cheap to create
do
mc mb --ignore-existing "local/$bucket"
done
mc ls local

106
local-instance/scripts/seed.sh Executable file
View File

@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Seed the local instance with the upstream demo school.
#
# This is what the real deployment's init job does (dof_app_deploy
# .../schulcloud-server-init/templates/configmap_file_init.yml.j2): it asks the
# management app to load backup/setup/*.json, which ships inside the server
# image. Safe to re-run — collections are replaced, not appended to.
set -euo pipefail
cd "$(dirname "$0")/.."
MGMT=http://127.0.0.1:3333/api/management/database
COMPOSE=(docker compose)
echo "==> waiting for the management app"
for _ in $(seq 1 60); do
if curl -fsS -o /dev/null "$MGMT/../../docs" 2>/dev/null || curl -fsS -o /dev/null -X POST "$MGMT/sync-indexes" 2>/dev/null; then
break
fi
sleep 5
done
echo "==> seeding collections (this takes a minute or two)"
curl -fsS --retry 30 --retry-all-errors --retry-delay 10 \
-X POST "$MGMT/seed?with-indexes=true" >/dev/null
echo " done"
# The legacy file service (course/topic attachments, as opposed to the newer
# /api/v3/file API) reads its S3 credentials from a storageproviders document
# rather than from the environment, and there is deliberately no seed data for
# it. Without this, legacy uploads fail with a provider-not-found error.
echo "==> registering MinIO as the legacy storage provider"
S3_KEY=$(grep -E '^S3_KEY=' env/shared.env | cut -d= -f2-)
SECRET=$(curl -fsS -X POST "$MGMT/encrypt-plain-text" \
-H 'Content-Type: application/json' \
-d "$(printf '{"plainText":"miniouser","key":"%s"}' "$S3_KEY")")
"${COMPOSE[@]}" exec -T mongo mongosh schulcloud --quiet --eval "
const id = ObjectId('62949a4003839b6162aa566b');
db.storageproviders.replaceOne({ _id: id }, {
_id: id, isShared: true, region: 'eu-central-1', type: 'S3',
endpointUrl: 'http://minio:9000',
accessKeyId: 'miniouser',
secretAccessKey: '$SECRET',
maxBuckets: 150, freeBuckets: 138,
createdAt: new Date(), updatedAt: new Date(), __v: 0,
}, { upsert: true });
const r = db.schools.updateMany({}, { \$set: { storageProvider: id } });
print(' schools linked to the provider: ' + r.modifiedCount);
"
# The demo data is dated 2017-2018, and the v3 endpoints filter on those dates:
# a course that has ended shows a student no tasks, and a task whose due date
# passed years ago is in neither the open nor the finished list and has dropped
# off the course page. The result is a student account that looks empty for
# reasons that have nothing to do with whatever is being tested against it.
#
# So move the stale data into the present. Courses get a term around today.
# Homework is shifted by one offset per date cluster, which keeps the relative
# order — and so which tasks are past due, the ones carrying the graded
# submissions — while landing the newest of them two weeks ago.
echo "==> dating the demo data to the present"
"${COMPOSE[@]}" exec -T mongo mongosh schulcloud --quiet --eval "
const now = new Date();
const courses = db.courses.updateMany({ untilDate: { \$lt: now } }, { \$set: {
startDate: new Date(now.getTime() - 180 * 86400000),
untilDate: new Date(now.getTime() + 185 * 86400000),
} });
print(' courses given a current term: ' + courses.modifiedCount);
let moved = 0;
for (let pass = 0; pass < 10; pass++) {
const cutoff = new Date(Date.now() - 365 * 86400000);
const newest = db.homeworks.find({ dueDate: { \$lt: cutoff } }).sort({ dueDate: -1 }).limit(1).toArray()[0];
if (!newest) break;
const offset = (Date.now() - 14 * 86400000) - newest.dueDate.getTime();
db.homeworks.find({ dueDate: { \$lt: cutoff } }).forEach((h) => {
const set = {};
for (const field of ['dueDate', 'availableDate', 'createdAt', 'updatedAt']) {
if (h[field] instanceof Date) set[field] = new Date(h[field].getTime() + offset);
}
db.homeworks.updateOne({ _id: h._id }, { \$set: set });
moved++;
});
}
print(' homework brought forward: ' + moved);
"
cat <<'ACCOUNTS'
==> ready — http://localhost:4400
Seeded accounts (the demo password differs by account — upstream quirk):
demo-schueler@schul-cloud.org student Fritz Schmidt schulcloud
^ has graded submissions, incl. a feedback-only and a 100% one
demo-lehrer@schul-cloud.org teacher Erika Meier schulcloud
klara.fall@schul-cloud.org teacher Klara Fall Schulcloud1!
^ owns Fritz's graded Biologie submissions
lehrer@schul-cloud.org teacher Cord Carl Schulcloud1!
admin@schul-cloud.org admin Thorsten Test Schulcloud1!
*.qa@schul-cloud.org various Schulcloud1qa!
Sign in as the teacher to grade, as the student to see what grading looks
like from the side our MCP server reads.
ACCOUNTS

View File

@@ -0,0 +1,356 @@
#!/usr/bin/env node
/**
* Simulate a teacher's work against the LOCAL instance: create, change and
* delete the things teachers actually create, so the MCP server can be exercised
* against content the real account has never contained.
*
* This is the only thing in this repository that writes to a Schulcloud, and the
* guard below is what keeps it that way. The MCP server itself stays read-only;
* nothing here runs through it.
*
* node scripts/simulate-teacher.mjs create # build the fixture, print ids
* node scripts/simulate-teacher.mjs update # rename/edit everything it made
* node scripts/simulate-teacher.mjs delete # remove it again
*
* State lives in .simulate-teacher.json so the phases can be run one at a time
* with MCP checks in between.
*/
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const STATE = join(ROOT, '.simulate-teacher.json');
// The server's own port, not the nginx origin: `/api/v1` is deliberately not
// routed through the ingress (the live deployment does the same), and the
// legacy services are the only way to create tasks and topics.
const API = process.env.LOCAL_SC_API ?? 'http://127.0.0.1:3030';
const FILES = process.env.LOCAL_SC_FILES ?? 'http://127.0.0.1:4444';
for (const [name, url] of [['LOCAL_SC_API', API], ['LOCAL_SC_FILES', FILES]]) {
const { hostname } = new URL(url);
if (hostname !== '127.0.0.1' && hostname !== 'localhost' && hostname !== '::1') {
console.error(`refusing to run: ${name}=${url} is not a localhost address.`);
process.exit(2);
}
}
const TEACHER = process.env.SIM_TEACHER ?? 'klara.fall@schul-cloud.org';
const PASSWORD = process.env.SIM_PASSWORD ?? 'Schulcloud1!';
/** Everything is created here, because the demo student is a member. */
const COURSE = process.env.SIM_COURSE ?? '59a3c657a2049554a93fec3a'; // Biologie 9b
const STUDENT_EMAIL = 'demo-schueler@schul-cloud.org';
let jwt = '';
let me;
async function req(base, method, path, body, { raw = false } = {}) {
const headers = { Authorization: `Bearer ${jwt}` };
let payload;
if (body instanceof FormData) {
payload = body;
} else if (body !== undefined) {
headers['Content-Type'] = 'application/json';
payload = JSON.stringify(body);
}
const res = await fetch(`${base}${path}`, { method, headers, body: payload });
const text = await res.text();
if (!res.ok) {
throw new Error(`${method} ${path} -> ${res.status} ${text.slice(0, 400)}`);
}
if (raw) return text;
return text ? JSON.parse(text) : undefined;
}
const v3 = (method, path, body, opts) => req(API, method, `/api/v3${path}`, body, opts);
const v1 = (method, path, body, opts) => req(API, method, `/api/v1${path}`, body, opts);
const files = (method, path, body, opts) => req(FILES, method, `/api/v3/file${path}`, body, opts);
const log = (...a) => console.log(' ', ...a);
const step = (s) => console.log(`\n== ${s} ==`);
function loadState() {
return existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : {};
}
function saveState(s) {
writeFileSync(STATE, JSON.stringify(s, null, '\t') + '\n');
}
async function login() {
const res = await fetch(`${API}/api/v3/authentication/local`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: TEACHER, password: PASSWORD }),
});
if (!res.ok) throw new Error(`login failed: ${res.status} ${await res.text()}`);
jwt = (await res.json()).accessToken;
me = await v3('GET', '/me');
log(`signed in as ${me.user.firstName} ${me.user.lastName} (${me.roles.map((r) => r.name).join(', ')})`);
}
/** The demo student, so created content is visible to the account under test. */
async function studentId() {
const found = await v1('GET', `/users?email=${encodeURIComponent(STUDENT_EMAIL)}`);
const user = found.data?.[0] ?? found[0];
if (!user) throw new Error(`could not find ${STUDENT_EMAIL}`);
return user._id;
}
// ---------------------------------------------------------------- create ---
async function create() {
const s = loadState();
// Saved after every step, not at the end: a failure halfway through used to
// leave created objects with no record of their ids, and so no way to delete
// them again.
const keep = (k, v) => { s[k] = v; saveState(s); return v; };
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
const student = await studentId();
step('course');
const course = await v3('POST', '/courses', { name: `MCP-Test Kurs (${stamp})` });
keep('courseId', course.courseId ?? course.id ?? course._id);
// v3 creates the course but takes no members; the legacy service does.
await v1('PATCH', `/courses/${s.courseId}`, { userIds: [student], teacherIds: [me.user.id] });
log(`course ${s.courseId} with the demo student enrolled`);
step('room');
const room = await v3('POST', '/rooms', {
name: `MCP-Test Raum (${stamp})`,
color: 'blue',
features: [],
});
keep('roomId', room.id);
log(`room ${s.roomId}`);
step('team');
// Teams cannot be created through the API: the legacy service registers
// ['find','get','update','patch','remove'] and no 'create'
// (schulcloud-server src/services/teams/index.js), and v3 has no team route
// beyond news and create-room. POST /teams answers 405. So the simulation
// adopts a seeded team and edits that instead — which is all the MCP server
// needs, since it reads teams only through api_get.
const teams = await v1('GET', '/teams');
const existing = (teams.data ?? [])[0];
if (existing) {
keep('teamId', existing._id);
log(`adopted seeded team ${existing._id} (${existing.name}) — creation is not exposed`);
} else {
log('no seeded team to adopt; skipping');
}
step('topic (legacy lesson) in Biologie 9b');
const lesson = await v1('POST', '/lessons', {
name: `MCP-Test Thema (${stamp})`,
courseId: COURSE,
hidden: false,
contents: [
{
title: 'Einführung',
hidden: false,
component: 'text',
content: { text: '<p>Dieses Thema prüft, was der MCP-Server aus einem Thema liest.</p>' },
},
],
});
keep('lessonId', lesson._id);
log(`lesson ${s.lessonId}`);
step('task (homework) in Biologie 9b');
const task = await v1('POST', '/homework', {
name: `MCP-Test Aufgabe (${stamp})`,
description: '<p>Beschreibe in drei Sätzen, was ein Neuron tut.</p>',
courseId: COURSE,
availableDate: new Date(Date.now() - 86400_000).toISOString(),
dueDate: new Date(Date.now() + 7 * 86400_000).toISOString(),
private: false,
teacherId: me.user.id,
schoolId: me.school.id,
});
keep('taskId', task._id);
log(`task ${s.taskId}`);
step('column board in Biologie 9b');
const board = await v3('POST', '/boards', {
title: `MCP-Test Board (${stamp})`,
parentId: COURSE,
parentType: 'course',
layout: 'columns',
});
keep('boardId', board.id);
const column = await v3('POST', `/boards/${s.boardId}/columns`);
keep('columnId', column.id);
await v3('PATCH', `/columns/${s.columnId}/title`, { title: 'Material' });
const card = await v3('POST', `/columns/${s.columnId}/cards`);
keep('cardId', card.id);
await v3('PATCH', `/cards/${s.cardId}/title`, { title: 'Das Nervensystem' });
log(`board ${s.boardId} / column ${s.columnId} / card ${s.cardId}`);
step('card elements');
const rich = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'richText' });
keep('richTextId', rich.id);
await v3('PATCH', `/elements/${s.richTextId}/content`, {
data: {
content: {
text: '<p>Ein <strong>Neuron</strong> leitet Reize weiter. Suchbegriff: Synapsenspalt.</p>',
inputFormat: 'richTextCk5',
},
type: 'richText',
},
});
log(`richText ${s.richTextId}`);
const link = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'link' });
keep('linkId', link.id);
await v3('PATCH', `/elements/${s.linkId}/content`, {
data: {
content: { url: 'https://www.dbildungscloud.de/', title: 'dBildungscloud', description: '', imageUrl: '', originalImageUrl: '' },
type: 'link',
},
});
log(`link ${s.linkId}`);
const pad = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'collaborativeTextEditor' });
keep('padElementId', pad.id);
log(`collaborativeTextEditor (Etherpad) ${s.padElementId}`);
const folder = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'fileFolder' });
keep('folderId', folder.id);
await v3('PATCH', `/elements/${s.folderId}/content`, {
data: { content: { title: 'Arbeitsblätter' }, type: 'fileFolder' },
});
log(`fileFolder (directory) ${s.folderId}`);
step('files');
const fileEl = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'file' });
keep('fileElementId', fileEl.id);
keep('fileId', await upload(s.fileElementId, 'nervensystem-notiz.txt', 'text/plain',
'Das Nervensystem\n\nReizleitung erfolgt ueber Synapsen. Stichwort: Synapsenspalt.\n'));
log(`file element ${s.fileElementId} holding file ${s.fileId}`);
keep('folderFileId', await upload(s.folderId, 'arbeitsblatt-1.txt', 'text/plain',
'Arbeitsblatt 1\n\nAufgabe: Beschrifte die Teile eines Neurons.\n'));
log(`file ${s.folderFileId} inside the directory`);
step('publishing');
// A board is created as a draft. Students get 403 on it while the course page
// still lists its title, so the fixture needs both states to be useful.
await v3('PATCH', `/boards/${s.boardId}/visibility`, { isVisible: true });
log('main board published');
const draft = await v3('POST', '/boards', {
title: `MCP-Test Entwurf, unveröffentlicht (${stamp})`,
parentId: COURSE,
parentType: 'course',
layout: 'columns',
});
keep('draftBoardId', draft.id);
log(`second board left unpublished on purpose: ${draft.id}`);
saveState(s);
console.log(`\nstate written to ${STATE}`);
summary(s);
}
/** files-storage attaches bytes to a board node (element) id, not to the card. */
async function upload(parentId, name, type, body) {
const form = new FormData();
form.append('file', new Blob([body], { type }), name);
const record = await files('POST', `/upload/school/${me.school.id}/boardnodes/${parentId}`, form);
return record.id;
}
// ---------------------------------------------------------------- update ---
async function update() {
const s = loadState();
if (!s.boardId) throw new Error('nothing to update — run `create` first');
const stamp = new Date().toISOString().slice(11, 16);
step('renames');
await v1('PATCH', `/courses/${s.courseId}`, { name: `MCP-Test Kurs [umbenannt ${stamp}]` });
log('course renamed');
await v3('PATCH', `/boards/${s.boardId}/title`, { title: `MCP-Test Board [umbenannt ${stamp}]` });
log('board renamed');
await v3('PATCH', `/columns/${s.columnId}/title`, { title: 'Material (überarbeitet)' });
await v3('PATCH', `/cards/${s.cardId}/title`, { title: 'Das Nervensystem — überarbeitet' });
log('column and card renamed');
await v1('PATCH', `/homework/${s.taskId}`, { name: `MCP-Test Aufgabe [umbenannt ${stamp}]` });
log('task renamed');
await v1('PATCH', `/lessons/${s.lessonId}`, { name: `MCP-Test Thema [umbenannt ${stamp}]` });
log('topic renamed');
if (s.teamId) {
const team = await v1('GET', `/teams/${s.teamId}`);
await v1('PATCH', `/teams/${s.teamId}`, { name: `${team.name.replace(/ \[MCP .*$/, '')} [MCP ${stamp}]` });
log('team renamed (the one write teams do allow)');
}
await v3('PUT', `/rooms/${s.roomId}`, { name: `MCP-Test Raum [umbenannt ${stamp}]`, color: 'green', features: [] });
log('room renamed');
step('content edits');
await v3('PATCH', `/elements/${s.richTextId}/content`, {
data: {
content: {
text: '<p>Ein <strong>Neuron</strong> leitet Reize weiter. Neuer Suchbegriff: Ranvierscher Schnürring.</p>',
inputFormat: 'richTextCk5',
},
type: 'richText',
},
});
log('rich text rewritten (new search term: Ranvierscher Schnürring)');
await files('PATCH', `/rename/${s.fileId}`, { fileName: 'nervensystem-notiz-v2.txt' });
log('file renamed');
saveState({ ...s, updated: true });
}
// ---------------------------------------------------------------- delete ---
async function remove() {
const s = loadState();
if (!s.boardId) throw new Error('nothing to delete — run `create` first');
step('deleting what was created');
const tries = [
['file', () => files('DELETE', `/delete/${s.folderFileId}`)],
['file element', () => v3('DELETE', `/elements/${s.fileElementId}`)],
['fileFolder element', () => v3('DELETE', `/elements/${s.folderId}`)],
['etherpad element', () => v3('DELETE', `/elements/${s.padElementId}`)],
['link element', () => v3('DELETE', `/elements/${s.linkId}`)],
['card', () => v3('DELETE', `/cards/${s.cardId}`)],
['column', () => v3('DELETE', `/columns/${s.columnId}`)],
['board', () => v3('DELETE', `/boards/${s.boardId}`)],
['draft board', () => v3('DELETE', `/boards/${s.draftBoardId}`)],
['task', () => v3('DELETE', `/tasks/${s.taskId}`)],
['topic', () => v3('DELETE', `/lessons/${s.lessonId}`)],
['room', () => v3('DELETE', `/rooms/${s.roomId}`)],
['course', () => v1('DELETE', `/courses/${s.courseId}`)],
];
for (const [what, fn] of tries) {
try {
await fn();
log(`deleted ${what}`);
} catch (err) {
log(`could NOT delete ${what}: ${String(err.message).slice(0, 160)}`);
}
}
unlinkSync(STATE);
console.log(`\nstate file removed`);
}
function summary(s) {
console.log('\nids for the MCP side:');
for (const [k, v] of Object.entries(s)) console.log(` ${k.padEnd(16)} ${v}`);
}
const phase = process.argv[2] ?? 'create';
await login();
if (phase === 'create') await create();
else if (phase === 'update') await update();
else if (phase === 'delete') await remove();
else if (phase === 'show') summary(loadState());
else {
console.error(`unknown phase ${phase}; expected create | update | delete | show`);
process.exit(2);
}

View File

@@ -106,30 +106,76 @@ const taskId = tasks.text.match(/\(`([0-9a-f]{24})`\)/)?.[1];
check('list_tasks finished', !(await call('list_tasks', { scope: 'finished' })).isError);
check('list_news', !(await call('list_news')).isError);
// Walk courses until we find one with a board, to exercise the whole chain.
// Walk courses collecting boards and lessons, to exercise the whole chain.
// Every board id is collected rather than the first one taken: an unpublished
// board is listed on the course page with its title but 403s when opened, so
// "the first board in the course" is not reliably one that can be read.
let boardId, fileId, lessonId, courseWithBoard;
const boardIds = [];
const topicsWithTasks = [];
for (const id of courseIds) {
const course = await call('get_course', { courseId: id });
if (course.isError) continue;
courseWithBoard ??= id;
const b = course.text.match(/### Boards[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
const l = course.text.match(/### Topics[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
lessonId ??= l;
if (b && !boardId) boardId = b;
if (boardId && lessonId) break;
const boardsSection = course.text.match(/### Boards[\s\S]*?(?=\n### |$)/)?.[0] ?? '';
for (const m of boardsSection.matchAll(/\(`([0-9a-f]{24})`\)/g)) boardIds.push(m[1]);
lessonId ??= course.text.match(/### Topics[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
// A topic that reports tasks is the interesting one: those tasks are not task
// elements on the course page and carry no id in the API.
const topics = course.text.match(/### Topics[\s\S]*?(?=\n### |$)/)?.[0] ?? '';
for (const m of topics.matchAll(/\(`([0-9a-f]{24})`\) — (\d+) task/g)) topicsWithTasks.push(m[1]);
}
check('get_course', Boolean(courseWithBoard), `first usable course ${courseWithBoard}`);
check('found a column board', Boolean(boardId), boardId);
check('found a column board', boardIds.length > 0, `${boardIds.length} board(s)`);
if (boardId) {
const board = await call('get_board', { boardId });
check('get_board', !board.isError && /Board id:/.test(board.text));
let board, drafts = 0;
for (const id of boardIds) {
const attempt = await call('get_board', { boardId: id });
if (!attempt.isError) {
board = attempt;
boardId = id;
break;
}
if (/draft/i.test(attempt.text)) drafts++;
}
if (boardIds.length > 0) {
check(
'get_board',
Boolean(board) && /Board id:/.test(board.text),
boardId ? `opened ${boardId}${drafts ? `, skipped ${drafts} unpublished` : ''}` : 'no board could be opened',
);
}
if (board) {
// Pads carry real content and the board API returns them empty, so the text
// comes from Etherpad itself. Either it was read, or the tool says plainly
// that it was not — it must never claim the contents cannot be had.
const padLine = board.text.match(/- Collaborative text document `[0-9a-f]{24}`[^\n]*/)?.[0];
check(
'collaborative text documents report contents or say they are empty',
padLine === undefined || /:$|\(empty, or its contents could not be read\)/.test(padLine),
padLine ?? 'no pad on this board',
);
fileId = board.text.match(/File: \*\*[^*]+\*\* \(`([0-9a-f]{24})`/)?.[1];
check('get_board resolved attachments', Boolean(fileId), fileId ?? 'no files on this board');
check('get_board includeFiles=false', !(await call('get_board', { boardId, includeFiles: false })).isError);
}
if (lessonId) check('get_lesson', !(await call('get_lesson', { lessonId })).isError, lessonId);
// A task attached to a topic is reachable only if its id was recovered from the
// topic page: the API's topic-task projection has no id field, and such a task
// is on no course page and drops out of both task lists once it is past due.
if (topicsWithTasks.length > 0) {
const lesson = await call('get_lesson', { lessonId: topicsWithTasks[0] });
const topicTaskId = lesson.text.match(/### Tasks in this lesson[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
check('get_lesson lists a topic\'s tasks with ids', Boolean(topicTaskId), topicTaskId ?? lesson.text.slice(0, 90));
if (topicTaskId) {
const viaTopic = await call('get_task', { taskId: topicTaskId });
check('get_task opens a task found only through a topic', !viaTopic.isError && /Task id:/.test(viaTopic.text));
}
} else {
check('get_lesson lists a topic\'s tasks with ids', true, 'no topic on this account reports tasks — nothing to check');
}
if (taskId) {
const task = await call('get_task', { taskId });
check('get_task', !task.isError && /Task id:/.test(task.text), taskId);
@@ -180,13 +226,25 @@ check(
hasIndex ? !status.isError : status.isError && /not configured/.test(status.text),
status.text.split('\n')[0],
);
const changed = await call('what_changed', { since: '2026-01-01' });
check('what_changed responds', hasIndex ? !changed.isError : changed.isError);
if (hasIndex) {
// Populate before asking what changed: a brand-new index holds no generations
// to diff, and what_changed rightly refuses rather than inventing a baseline.
const refreshed = await call('refresh_index', { courseId: courseIds[0], force: true });
check('refresh_index re-crawls one course', !refreshed.isError, refreshed.text.split('\n')[0]);
}
const changed = await call('what_changed', { since: '2026-01-01' });
check('what_changed responds', hasIndex ? !changed.isError : changed.isError, changed.text.split('\n')[0]);
if (hasIndex) {
// Both the hit and the no-hit answer say when the index was last refreshed;
// a live crawl (fresh=true) says nothing of the sort. That is what separates
// "answered from the index" from "answered by crawling", whatever the term
// happens to match in this account's data.
const indexed = await call('search', { query: searchTerm });
check('search uses the index and states freshness', !indexed.isError && /Index /.test(indexed.text));
check(
'search uses the index and states freshness',
!indexed.isError && /refreshed/i.test(indexed.text),
indexed.text.split('\n')[0],
);
}
console.log('\n== api_get guard rails ==');

View File

@@ -1,4 +1,6 @@
import type { Config } from '../config.ts';
import type { SchulcloudClient } from './client.ts';
import { fetchPadText } from './etherpad.ts';
import { SchulcloudApiError } from './client.ts';
import type { BoardSkeleton, CardResponse, ContentElement, FileRecord } from './types.ts';
@@ -22,6 +24,8 @@ export interface AssembledElement {
files: FileRecord[];
/** Set when this element's files could not be resolved. */
fileError?: string;
/** What a class actually wrote in a collaborativeTextEditor (Etherpad) pad. */
padText?: string;
raw: Record<string, unknown>;
}
@@ -52,7 +56,7 @@ export async function assembleBoard(
client: SchulcloudClient,
boardId: string,
schoolId: string,
options: { resolveFiles?: boolean } = {},
options: { resolveFiles?: boolean; resolvePads?: Config } = {},
): Promise<AssembledBoard> {
const resolveFiles = options.resolveFiles ?? true;
@@ -71,6 +75,13 @@ export async function assembleBoard(
await attachFiles(client, assembled, schoolId);
}
// A pad's text is real course content, and nothing else surfaces it: the
// board API returns collaborativeTextEditor elements with empty content.
// Costs two requests per pad and only when a board has one.
if (options.resolvePads) {
await attachPadText(options.resolvePads, assembled);
}
const fileCount = assembled
.flatMap((column) => column.cards)
.flatMap((card) => card.elements)
@@ -121,6 +132,29 @@ function buildElement(element: ContentElement): AssembledElement {
return assembled;
}
/**
* Fills in the text of each collaborative text editor element.
*
* Failures are left silent rather than recorded: unlike a missing attachment,
* an unreadable pad is usually an empty one, and the element itself is still
* reported.
*/
async function attachPadText(config: Config, columns: AssembledColumn[]): Promise<void> {
const pads = columns
.flatMap((column) => column.cards)
.flatMap((card) => card.elements)
.filter((element) => element.type === 'collaborativeTextEditor');
await Promise.all(
pads.map(async (element) => {
const text = await fetchPadText(config, element.id);
if (!text) return;
// The element's own title, when it has one, stays as the heading.
element.padText = text;
}),
);
}
/**
* Resolves file-bearing elements to file records.
*

View File

@@ -13,6 +13,7 @@ import type {
NewsResponse,
Paginated,
SubmissionStatus,
LessonLinkedTask,
TaskContent,
} from './types.ts';
@@ -304,8 +305,22 @@ export class SchulcloudClient {
return this.getJson<LessonResponse>(`/api/v3/lessons/${encodeURIComponent(lessonId)}`);
}
getLessonTasks(lessonId: string): Promise<Paginated<TaskContent>> {
return this.getJson<Paginated<TaskContent>>(`/api/v3/lessons/${encodeURIComponent(lessonId)}/tasks`);
/**
* A lesson's tasks.
*
* Returns a bare array, not the `{data, total}` envelope every other list
* endpoint uses — checked against both the live instance and a local 33.40.
* Typing it as `Paginated` made `.data` undefined, which silently dropped
* every task attached to a topic: they vanished from get_lesson, get_task
* reported them as non-existent, and their submissions — grades included —
* could not be reached at all. The envelope branch is kept in case the
* endpoint is ever normalised to match its siblings.
*/
async getLessonTasks(lessonId: string): Promise<LessonLinkedTask[]> {
const body = await this.getJson<LessonLinkedTask[] | Paginated<LessonLinkedTask>>(
`/api/v3/lessons/${encodeURIComponent(lessonId)}/tasks`,
);
return Array.isArray(body) ? body : (body.data ?? []);
}
// --- column boards ---------------------------------------------------

View File

@@ -1,3 +1,4 @@
import type { Config } from '../config.ts';
import { assembleBoard, type AssembledBoard } from './board.ts';
import type { SchulcloudClient } from './client.ts';
import { htmlToText, normalizeObjectId } from './text.ts';
@@ -90,6 +91,11 @@ export interface CrawlOptions {
includeLessonContents?: boolean;
/** Resolve board file elements to file records. */
includeFiles?: boolean;
/**
* Read the text of collaborative text editor (Etherpad) pads, which needs a
* second credentialled hop outside the API. Omit to leave pads unread.
*/
config?: Config;
courseConcurrency?: number;
boardConcurrency?: number;
onProgress?: (done: number, total: number, label: string) => void;
@@ -203,7 +209,10 @@ async function crawlCourse(
await forEachLimited(boardIds, options.boardConcurrency ?? 4, async (boardId) => {
let assembled: AssembledBoard;
try {
assembled = await assembleBoard(client, boardId, options.schoolId, { resolveFiles: includeFiles });
assembled = await assembleBoard(client, boardId, options.schoolId, {
resolveFiles: includeFiles,
resolvePads: options.config,
});
} catch (error) {
// Record rather than swallow: a dropped board used to disappear from the
// index while the crawl still reported success, which is how a 20-card
@@ -222,6 +231,9 @@ async function crawlCourse(
parts.push(card.title);
for (const element of card.elements) {
if (element.text) parts.push(htmlToText(element.text));
// Pad contents are course material like any other; without this they
// are unsearchable, and a pad is often where the actual group work is.
if (element.padText) parts.push(element.padText);
if (element.url) parts.push(element.url);
for (const record of element.files) {
files.push({

100
src/core/etherpad.ts Normal file
View File

@@ -0,0 +1,100 @@
import type { Config } from '../config.ts';
/**
* Reads the text of a collaborative text editor (Etherpad) element.
*
* The board API is no help on its own: a `collaborativeTextEditor` element
* comes back with `content: {}` — no pad id, no url, nothing. What a class
* actually wrote in it is invisible to every other tool here.
*
* Two calls recover it, and neither needs Etherpad's own API key (which is a
* server-side secret this process has no business holding):
*
* 1. `GET /api/v3/collaborative-text-editor/content-element/{id}` answers with
* the pad url *and*, in a `Set-Cookie`, an Etherpad `sessionID` — the same
* exchange the web client performs before it embeds the pad.
* 2. Etherpad's own `/p/{padId}/export/txt` returns the pad as plain text to
* whoever holds that session.
*
* The session cookie is only ever sent back to the instance's own host: step 1
* returns a url built from the server's `ETHERPAD__PAD_URI`, and a value
* pointing anywhere else is refused rather than followed.
*
* Everything degrades to `undefined`. A pad that cannot be read costs its text,
* never the board.
*/
export async function fetchPadText(config: Config, elementId: string): Promise<string | undefined> {
try {
const handle = await fetchPadHandle(config, elementId);
if (!handle) return undefined;
const response = await fetch(`${handle.origin}/etherpad/p/${handle.padId}/export/txt`, {
headers: { Cookie: handle.sessionCookie, Accept: 'text/plain' },
signal: AbortSignal.timeout(config.requestTimeoutMs),
});
if (!response.ok) return undefined;
const text = (await response.text()).trim();
// A pad nobody has typed in still exports the placeholder the instance
// seeds new pads with; reporting that as content would be a lie.
return text.length > 0 && text !== DEFAULT_PAD_TEXT ? text : undefined;
} catch {
return undefined;
}
}
/** The instance's `DEFAULT_PAD_TEXT`; an untouched pad exports exactly this. */
const DEFAULT_PAD_TEXT = 'Schreib etwas!';
interface PadHandle {
origin: string;
padId: string;
sessionCookie: string;
}
async function fetchPadHandle(config: Config, elementId: string): Promise<PadHandle | undefined> {
const response = await fetch(
`${config.baseUrl}/api/v3/collaborative-text-editor/content-element/${encodeURIComponent(elementId)}`,
{
headers: { Authorization: `Bearer ${config.jwt}`, Accept: 'application/json' },
signal: AbortSignal.timeout(config.requestTimeoutMs),
},
);
if (!response.ok) return undefined;
const body = (await response.json()) as { url?: unknown };
if (typeof body.url !== 'string') return undefined;
const padId = padIdFromUrl(body.url, config.baseUrl);
if (!padId) return undefined;
// `getSetCookie` keeps the header split correctly; a plain `get` would join
// several cookies on the commas that appear inside the session list itself.
const sessionCookie = response.headers
.getSetCookie()
.map((cookie) => /^(sessionID=[^;]*)/.exec(cookie)?.[1])
.find((value): value is string => Boolean(value));
if (!sessionCookie) return undefined;
return { origin: new URL(config.baseUrl).origin, padId, sessionCookie };
}
/**
* The pad id out of the url the server hands back.
*
* Refuses a url on another host: that url is server-configured, and following
* it blindly would send an Etherpad session cookie wherever it pointed.
* Group pad ids contain `$`, so the segment is kept exactly as encoded.
*/
export function padIdFromUrl(url: string, baseUrl: string): string | undefined {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return undefined;
}
if (parsed.host !== new URL(baseUrl).host) return undefined;
const segment = /\/etherpad\/p\/([^/?#]+)/.exec(parsed.pathname)?.[1];
return segment && segment.length > 0 ? segment : undefined;
}

View File

@@ -73,9 +73,17 @@ export function parseHomeworkPage(html: string): SubmissionDetail | undefined {
// The student's own text: a textarea while the submission is still editable,
// a plain div once it is not.
//
// The read-only div is a *sibling after* `</section id="submission">`, not a
// child of it — that section then holds only the file list. Scoping this
// search to the section therefore found nothing for every submission past
// its due date, silently dropping the submitted text while still reporting
// the grade. `class="comment"` (with the quote right after the word) is
// specific enough to search the whole page: the teacher's feedback is
// `class="comment ckcontent"` and so cannot match.
const typed =
/data-testid="submission-text"[^>]*>([\s\S]*?)<\/textarea>/.exec(html)?.[1] ??
(submission ? /<div class="comment"[^>]*>([\s\S]*?)<\/div>/.exec(submission)?.[1] : undefined);
/<div class="comment"[^>]*>([\s\S]*?)<\/div>/.exec(html)?.[1];
const typedText = clean(typed);
if (typedText) detail.submittedText = typedText;

84
src/core/lesson-page.ts Normal file
View File

@@ -0,0 +1,84 @@
import type { Config } from '../config.ts';
import { decodeEntities } from './text.ts';
import type { LessonLinkedTask } from './types.ts';
/**
* Recovers the ids of tasks that hang off a topic ("Thema").
*
* `GET /api/v3/lessons/{id}/tasks` returns the topic's tasks — with their name,
* description and dates, but **no id**: `LessonLinkedTaskResponse` has no id
* field at all, by design rather than by omission. The course page is no help
* either, because a topic-attached task is not a task element there; the topic
* reports only `numberOfPublishedTasks`.
*
* The consequence is that those tasks were unreachable: absent from both task
* lists once past due (open excludes them, finished only holds what the student
* ticked off), absent from the course page, and unidentifiable from the topic.
* Their submissions, and so their grades, could not be read at all. On the
* account this was written for that is 18 of 60 tasks.
*
* The legacy topic page links each task as `/homework/{id}`, so it carries the
* mapping the API withholds. Like the homework-page scrape this authenticates
* by `jwt` **cookie**, hangs off an accessibility attribute rather than
* presentation markup, and degrades to an empty list — a markup change costs
* the ids again, never an error.
*/
export interface LessonTaskLink {
id: string;
name: string;
}
export async function fetchLessonTaskLinks(
config: Config,
courseId: string,
lessonId: string,
): Promise<LessonTaskLink[]> {
const url = `${config.baseUrl}/courses/${encodeURIComponent(courseId)}/topics/${encodeURIComponent(lessonId)}`;
try {
const response = await fetch(url, {
headers: { Cookie: `jwt=${config.jwt}`, Accept: 'text/html' },
signal: AbortSignal.timeout(config.requestTimeoutMs),
redirect: 'follow',
});
// Redirected away means the cookie was not accepted; there is nothing to
// parse and nothing worth raising.
if (!response.ok || !new URL(response.url).hostname.endsWith(new URL(config.baseUrl).hostname)) {
return [];
}
return parseLessonTaskLinks(await response.text());
} catch {
return [];
}
}
/** Exported for testing: the parsing is pure and deserves fixtures, not a network. */
export function parseLessonTaskLinks(html: string): LessonTaskLink[] {
// `aria-label="Details der Aufgabe: 'name'"` exists for screen readers, which
// makes it far steadier than the surrounding layout.
const pattern = /<a href="\/homework\/([0-9a-f]{24})"[^>]*aria-label="[^"']*'([^']*)'/g;
const found: LessonTaskLink[] = [];
const seen = new Set<string>();
for (const match of html.matchAll(pattern)) {
const id = match[1] ?? '';
if (seen.has(id)) continue;
seen.add(id);
found.push({ id, name: decodeEntities(match[2] ?? '').trim() });
}
return found;
}
/**
* Pairs the API's id-less task bodies with the ids scraped from the topic page.
*
* Matching is by name, which is what both sides agree on. A name the page does
* not account for yields a task without an id: still worth reporting (it is
* visible to the user), just not something the id-taking tools can open.
*/
export function withScrapedIds(tasks: LessonLinkedTask[], links: LessonTaskLink[]): LessonLinkedTask[] {
const byName = new Map(links.map((link) => [link.name, link.id]));
return tasks.map((task) => {
const id = task.id ?? byName.get(task.name?.trim() ?? '');
return id ? { ...task, id } : task;
});
}

View File

@@ -71,6 +71,39 @@ export interface TaskContent {
status: TaskStatus;
}
/**
* A topic's task as `GET /lessons/{id}/tasks` returns it.
*
* Deliberately not a `TaskContent`: the response carries no `id` and no
* `status` — `LessonLinkedTaskResponse` simply has no id property. Treating it
* as a TaskContent made `task.id` undefined at runtime while the type claimed
* otherwise, which is how topic-attached tasks went missing in silence.
* `core/lesson-page.ts` recovers the ids.
*/
export interface LessonLinkedTask {
/** Absent from the API; filled in from the topic page when it can be. */
id?: string;
name: string;
description?: string;
availableDate?: string;
dueDate?: string | null;
courseId?: string;
courseName?: string;
lessonName?: string;
private?: boolean;
submissionIds?: string[];
finishedIds?: string[];
}
/**
* A task as the tools render it, from whichever route found it.
*
* The task lists and course pages carry a `status`; the topic projection does
* not, and carries no id until one is scraped. Every `TaskContent` satisfies
* this, so list-derived tasks keep their full detail.
*/
export type ResolvedTask = LessonLinkedTask & { status?: TaskStatus };
export interface LessonMetaContent {
id: string;
name: string;

View File

@@ -113,6 +113,7 @@ export class Indexer {
courseIds: scope === 'full' ? undefined : [scope],
includeLessonContents: true,
includeFiles: true,
config: this.config,
});
const crawlId = await this.store.saveSnapshot(snapshot, scope);

View File

@@ -1,11 +1,19 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import { SchulcloudApiError } from '../../core/client.ts';
import { formatBytes } from '../../core/extract.ts';
import { dueLabel, formatDate, heading, htmlToText, joinSections, normalizeObjectId } from '../../core/text.ts';
import { assembleBoard, type AssembledBoard, type AssembledElement } from '../../core/board.ts';
import { forEachLimited } from '../../core/crawl.ts';
import type { CourseBoardResponse, FileRecord, LessonResponse, TaskContent } from '../../core/types.ts';
import { fetchLessonTaskLinks, withScrapedIds } from '../../core/lesson-page.ts';
import type {
CourseBoardResponse,
FileRecord,
LessonLinkedTask,
LessonResponse,
ResolvedTask,
} from '../../core/types.ts';
import { failure, text, toToolError } from './result.ts';
import { describeSubmission } from './submissions.ts';
@@ -55,9 +63,24 @@ export function registerContentTools(server: McpServer, context: ServerContext):
async ({ boardId, includeFiles }) => {
try {
const schoolId = await context.schoolId();
const board = await assembleBoard(context.client, boardId, schoolId, { resolveFiles: includeFiles });
const board = await assembleBoard(context.client, boardId, schoolId, {
resolveFiles: includeFiles,
resolvePads: context.config,
});
return text(formatBoard(board, includeFiles));
} catch (error) {
// An unpublished board 403s, while the course page lists its title
// regardless — the course-board projection does not filter drafts.
// Reporting that as "no permission" sends the reader looking for an
// access problem that does not exist; a draft is the common cause.
if (error instanceof SchulcloudApiError && error.status === 403) {
return failure(
`Board ${boardId} could not be opened (HTTP 403).\n\n` +
`The usual reason is that it is still a draft: an unpublished board is listed on ` +
`the course page with its title, but stays closed until the teacher publishes it. ` +
`Otherwise this account genuinely has no access to it.`,
);
}
return toToolError(error, `read board ${boardId}`);
}
},
@@ -80,12 +103,18 @@ export function registerContentTools(server: McpServer, context: ServerContext):
const schoolId = await context.schoolId();
const [lesson, tasks, files] = await Promise.all([
context.client.getLesson(lessonId),
context.client.getLessonTasks(lessonId).catch(() => undefined),
context.client.getLessonTasks(lessonId).catch(() => []),
context.client
.listFiles({ storageLocationId: schoolId, parentType: 'lessons', parentId: lessonId })
.catch(() => undefined),
]);
return text(formatLesson(lesson, tasks?.data ?? [], files?.data ?? []));
// The task bodies carry no id, so get_task cannot be pointed at them
// without the topic page. Only paid for when the topic has tasks.
const withIds =
tasks.length > 0
? withScrapedIds(tasks, await fetchLessonTaskLinks(context.config, lesson.courseId, lessonId))
: tasks;
return text(formatLesson(lesson, withIds, files?.data ?? []));
} catch (error) {
return toToolError(error, `read lesson ${lessonId}`);
}
@@ -142,7 +171,7 @@ export function registerContentTools(server: McpServer, context: ServerContext):
* to learn which course the task belongs to (unless told), then read the
* description off that course's page.
*/
async function findTask(context: ServerContext, taskId: string, courseId?: string): Promise<TaskContent | undefined> {
async function findTask(context: ServerContext, taskId: string, courseId?: string): Promise<ResolvedTask | undefined> {
if (courseId) {
const fromCourse = await taskFromCourse(context, courseId, taskId);
if (fromCourse) return fromCourse;
@@ -168,7 +197,7 @@ async function findTask(context: ServerContext, taskId: string, courseId?: strin
// back to scanning course pages costs ~26 requests and a few seconds, which
// is a fair price for the tool working instead of claiming the id is wrong.
const courses = await context.client.listAllCourses().catch(() => []);
let found: TaskContent | undefined;
let found: ResolvedTask | undefined;
await forEachLimited(courses, 6, async (course) => {
if (found) return;
const fromCourse = await taskFromCourse(context, course.id, taskId);
@@ -181,7 +210,7 @@ async function taskFromCourse(
context: ServerContext,
courseId: string,
taskId: string,
): Promise<TaskContent | undefined> {
): Promise<ResolvedTask | undefined> {
const board = await context.client.getCourseBoard(courseId).catch(() => undefined);
if (!board) return undefined;
for (const element of board.elements) {
@@ -189,6 +218,21 @@ async function taskFromCourse(
return { ...element.content, courseId, courseName: element.content.courseName ?? board.title };
}
}
// A task can hang off a topic rather than the course page, and those are not
// listed as task elements — only as a count on the topic. Without this the
// task is unreachable: not in the lists (a submitted, past-due task is in
// neither open nor finished) and not on the course page either.
for (const element of board.elements) {
if (element.type !== 'lesson' || !element.content.numberOfPublishedTasks) continue;
const links = await fetchLessonTaskLinks(context.config, courseId, element.content.id);
if (!links.some((link) => link.id === taskId)) continue;
const tasks = await context.client.getLessonTasks(element.content.id).catch(() => []);
const match = withScrapedIds(tasks, links).find((task) => task.id === taskId);
if (match) {
return { ...match, courseId, courseName: match.courseName ?? board.title, lessonName: element.content.name };
}
}
return undefined;
}
@@ -252,6 +296,14 @@ function formatBoard(board: AssembledBoard, includeFiles: boolean): string {
]);
}
/** Indents a pad's body so it reads as quoted content, not as board structure. */
function indent(body: string): string {
return body
.split('\n')
.map((line) => ` > ${line}`.trimEnd())
.join('\n');
}
function formatElement(element: AssembledElement, includeFiles: boolean): string {
switch (element.type) {
case 'richText': {
@@ -271,8 +323,16 @@ function formatElement(element: AssembledElement, includeFiles: boolean): string
if (element.files.length === 0) return `- ${element.type} element \`${element.id}\` — no files${caption}`;
return element.files.map((file) => `- ${formatFileLine(file)}${caption}`).join('\n');
}
case 'collaborativeTextEditor':
return `- Collaborative text document \`${element.id}\`${element.text ? `${element.text}` : ''} (contents not available through the API)`;
case 'collaborativeTextEditor': {
const title = element.text ? `${element.text}` : '';
// The board API returns these with empty content; the text comes from
// the pad itself (core/etherpad.ts). Absent means empty or unreadable,
// which for a pad is usually "nobody has written in it yet".
if (!element.padText) {
return `- Collaborative text document \`${element.id}\`${title} (empty, or its contents could not be read)`;
}
return [`- Collaborative text document \`${element.id}\`${title}:`, indent(element.padText)].join('\n');
}
case 'externalTool':
return `- External tool${element.text ? `: ${element.text}` : ''} \`${element.id}\``;
case 'videoConference':
@@ -292,7 +352,7 @@ export function formatFileLine(file: FileRecord): string {
return `File: **${file.name}** (\`${file.id}\`, ${file.mimeType}, ${formatBytes(file.size)})${blocked}${pending}`;
}
function formatLesson(lesson: LessonResponse, tasks: TaskContent[], files: FileRecord[]): string {
function formatLesson(lesson: LessonResponse, tasks: LessonLinkedTask[], files: FileRecord[]): string {
const sections = (lesson.contents ?? []).map((entry) => {
const title = entry.title?.trim();
const component = entry.component ?? 'unknown';
@@ -316,7 +376,12 @@ function formatLesson(lesson: LessonResponse, tasks: TaskContent[], files: FileR
tasks.length > 0 &&
joinSections([
heading(3, `Tasks in this lesson (${tasks.length})`),
tasks.map((task) => `- **${task.name}** (\`${task.id}\`) — ${dueLabel(task.dueDate)}`).join('\n'),
tasks
.map((task) => {
const id = task.id ? ` (\`${task.id}\`)` : '';
return `- **${task.name}**${id}${dueLabel(task.dueDate)}`;
})
.join('\n'),
]),
]);
}
@@ -336,7 +401,7 @@ function formatLessonComponent(component: string, content: Record<string, unknow
return '';
}
function formatTask(task: TaskContent, files: FileRecord[], submission?: string): string {
function formatTask(task: ResolvedTask, files: FileRecord[], submission?: string): string {
const description = htmlToText(task.description);
return joinSections([
heading(2, task.name),
@@ -346,7 +411,11 @@ function formatTask(task: TaskContent, files: FileRecord[], submission?: string)
task.lessonName ? `- Topic: ${task.lessonName}` : undefined,
`- Available from: ${formatDate(task.availableDate)}`,
`- Due: ${dueLabel(task.dueDate)}`,
`- Submitted: ${task.status.submitted}/${task.status.maxSubmissions}${task.status.graded > 0 ? ', graded' : ''}`,
// Absent for a task found through a topic: that projection reports no
// counts. The submission section below carries the authoritative state.
task.status
? `- Submitted: ${task.status.submitted}/${task.status.maxSubmissions}${task.status.graded > 0 ? ', graded' : ''}`
: undefined,
]
.filter(Boolean)
.join('\n'),

View File

@@ -110,6 +110,9 @@ async function liveSearch(
courseIds: courseId ? [courseId] : undefined,
includeLessonContents: true,
includeFiles: scoped,
// Same trade as files: worth two extra requests per pad when the caller
// named a course, too slow to do across every course they can see.
config: scoped ? context.config : undefined,
});
const hits = searchSnapshot(snapshot, query, limit);

View File

@@ -1,10 +1,11 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import { fetchLessonTaskLinks, withScrapedIds } from '../../core/lesson-page.ts';
import { forEachLimited } from '../../core/crawl.ts';
import { fetchSubmissionDetail } from '../../core/homework-page.ts';
import { dueLabel, heading, joinSections } from '../../core/text.ts';
import type { FileRecord, SubmissionStatus, TaskContent } from '../../core/types.ts';
import type { FileRecord, ResolvedTask, SubmissionStatus } from '../../core/types.ts';
import { formatFileLine } from './content.ts';
import { text, toToolError } from './result.ts';
@@ -52,7 +53,7 @@ export function registerSubmissionTools(server: McpServer, context: ServerContex
const [me, tasks] = await Promise.all([context.me(), collectTasks(context, scope, courseId, limit)]);
if (tasks.length === 0) return text('No tasks found to check for submissions.');
const rows: { task: TaskContent; status: SubmissionStatus }[] = [];
const rows: { task: ResolvedTask; status: SubmissionStatus }[] = [];
const unavailable: string[] = [];
await forEachLimited(tasks, 5, async (task) => {
@@ -125,7 +126,7 @@ export function formatGradeState(
return 'marked graded, but neither a percentage nor feedback was found';
}
function formatRow({ task, status }: { task: TaskContent; status: SubmissionStatus }): string {
function formatRow({ task, status }: { task: ResolvedTask; status: SubmissionStatus }): string {
const state = status.isSubmitted ? 'submitted' : 'not submitted';
// The list does not fetch pages, so it cannot know whether feedback exists;
// it says only what the API told it.
@@ -144,8 +145,8 @@ async function collectTasks(
scope: 'open' | 'finished' | 'all',
courseId: string | undefined,
limit: number,
): Promise<TaskContent[]> {
const wanted: TaskContent[] = [];
): Promise<(ResolvedTask & { id: string })[]> {
const wanted: ResolvedTask[] = [];
if (scope === 'open' || scope === 'all') {
wanted.push(...(await context.client.listTasks({ limit }).catch(() => ({ data: [] }))).data);
}
@@ -160,10 +161,24 @@ async function collectTasks(
for (const element of board?.elements ?? []) {
if (element.type === 'task') wanted.push({ ...element.content, courseId });
}
// Tasks attached to a topic are not task elements on the course page, so
// they have to be asked for per topic. They are the ones most likely to
// carry a grade: a task old enough to have been marked is usually old
// enough to have dropped out of both task lists.
for (const element of board?.elements ?? []) {
if (element.type !== 'lesson' || !element.content.numberOfPublishedTasks) continue;
const [tasks, links] = await Promise.all([
context.client.getLessonTasks(element.content.id).catch(() => []),
fetchLessonTaskLinks(context.config, courseId, element.content.id),
]);
// Only the ones whose id could be recovered: a submission lookup needs it.
for (const task of withScrapedIds(tasks, links)) if (task.id) wanted.push({ ...task, courseId });
}
}
const seen = new Set<string>();
return wanted
.filter((task): task is ResolvedTask & { id: string } => Boolean(task.id))
.filter((task) => (courseId ? task.courseId === courseId : true))
.filter((task) => (seen.has(task.id) ? false : (seen.add(task.id), true)))
.slice(0, limit);

View File

@@ -544,9 +544,12 @@ function fileNode(file: CrawledFile): StoredNode {
securityCheckStatus: file.record.securityCheckStatus,
at: file.at,
},
// File records are immutable, so identity alone decides change; the size
// is included only to catch an upstream record being rewritten in place.
digest: digestOf([file.record.id, file.record.size]),
// File records are less immutable than they look: `PATCH /file/rename/{id}`
// changes the name in place, keeping the id and the size, and teachers do
// rename files. Leaving the name out made that invisible to what_changed —
// the file simply reappeared under its new name with nothing reported.
// Size still catches a record rewritten in place under the same name.
digest: digestOf([file.record.id, file.record.name, file.record.size]),
};
}

39
test/etherpad.test.ts Normal file
View File

@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { padIdFromUrl } from '../src/core/etherpad.ts';
const BASE = 'https://schulcloud.example.org';
describe('padIdFromUrl', () => {
it('takes the pad id out of the url the server hands back', () => {
assert.equal(padIdFromUrl(`${BASE}/etherpad/p/g.abc123$65f0e1d2c3b4a5968778695a`, BASE), 'g.abc123$65f0e1d2c3b4a5968778695a');
});
it('keeps the id exactly as encoded', () => {
// Group pad ids contain `$`. Decoding or re-encoding the segment produces
// an id Etherpad does not recognise.
assert.equal(padIdFromUrl(`${BASE}/etherpad/p/g.x%24y`, BASE), 'g.x%24y');
});
it('refuses a url pointing at another host', () => {
// The caller sends an Etherpad session cookie to whatever this returns, and
// the url comes from server configuration — so a mismatch must not be
// followed rather than trusted.
assert.equal(padIdFromUrl('https://evil.test/etherpad/p/g.abc$123', BASE), undefined);
});
it('accepts a differing port only when it matches', () => {
assert.equal(padIdFromUrl('http://localhost:4400/etherpad/p/pad1', 'http://localhost:4400'), 'pad1');
assert.equal(padIdFromUrl('http://localhost:9001/etherpad/p/pad1', 'http://localhost:4400'), undefined);
});
it('gives up on a url that is not a pad url', () => {
assert.equal(padIdFromUrl(`${BASE}/dashboard`, BASE), undefined);
assert.equal(padIdFromUrl(`${BASE}/etherpad/p/`, BASE), undefined);
});
it('gives up rather than throwing on something that is not a url', () => {
assert.equal(padIdFromUrl('not a url', BASE), undefined);
assert.equal(padIdFromUrl('', BASE), undefined);
});
});

View File

@@ -7,11 +7,12 @@ import { parseHomeworkPage } from '../src/core/homework-page.ts';
* as actually served — including the parts that are absent for a student, which
* is what makes every field optional.
*/
const page = (parts: { submission?: string; feedback?: string }) => `
const page = (parts: { submission?: string; feedback?: string; afterSubmission?: string }) => `
<html><body>
<nav class="nav tab-links"><a href="#activetabid=extended">Details</a></nav>
<section id="extended" class="tab-content">keine Beschreibung vorhanden</section>
<section id="submission" class="tab-content">${parts.submission ?? ''}</section>
${parts.afterSubmission ?? ''}
${parts.feedback === undefined ? '' : `<section id="feedback" class="tab-content">${parts.feedback}</section>`}
</body></html>`;
@@ -39,10 +40,30 @@ describe('parseHomeworkPage', () => {
});
it('reads the typed answer from the read-only form once submission closed', () => {
const html = page({ submission: `<div class="comment"><p>Abgegebener Text</p></div>` });
// The read-only `<div class="comment">` is a sibling *after* the closed
// submission section, not a child of it — verified against a real 33.40
// page for a past-due submission. A fixture that nested it inside the
// section (as an earlier one did) hid a bug where every past-due
// submission lost its text. See src/core/homework-page.ts.
const html = page({
submission: `<section class="files" data-testid="submissions-section-files"></section>`,
afterSubmission: `<div class="comment"><p>Abgegebener Text</p></div>`,
});
assert.equal(parseHomeworkPage(html)?.submittedText, 'Abgegebener Text');
});
it('does not confuse the submitted text with the teacher feedback comment', () => {
// Both are comment divs, distinguished only by the exact class: the
// student's is `class="comment"`, the teacher's `class="comment ckcontent"`.
const html = page({
afterSubmission: `<div class="comment"><p>Meine Abgabe</p></div>`,
feedback: `<div class="comment ckcontent" data-testid="feedback-comment"><p>Gut gemacht</p></div>`,
});
const detail = parseHomeworkPage(html);
assert.equal(detail?.submittedText, 'Meine Abgabe');
assert.equal(detail?.gradeComment, 'Gut gemacht');
});
it('separates submitted files from files the teacher returned', () => {
const html = page({
submission: fileCard('a'.repeat(24), 'meine-abgabe.pdf'),

82
test/lesson-page.test.ts Normal file
View File

@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { parseLessonTaskLinks, withScrapedIds } from '../src/core/lesson-page.ts';
/**
* Fixtures mirror the legacy topic page as actually served — the task cards it
* renders under `<div id="homeworks">`, which is the only place a topic-attached
* task's id is exposed at all.
*/
const taskCard = (id: string, name: string) =>
`<li class="card assignment"><a href="/homework/${id}" aria-label="Details der Aufgabe: '${name}'">` +
`<div class="dates">Fällig: 30.07.2026 15:23</div></a></li>`;
const page = (cards: string) =>
`<html><body><h2>Aufgaben <small>(2)</small></h2>` +
`<div id="homeworks" class="container-fluid collapse"><ol class="homework">${cards}</ol></div>` +
`</body></html>`;
const id = (c: string) => c.repeat(24);
describe('parseLessonTaskLinks', () => {
it('recovers the ids the API withholds, with their names', () => {
const html = page(taskCard(id('a'), 'Bestandteile einer Kamera') + taskCard(id('b'), 'Eigenschaften einer Sammellinse'));
assert.deepEqual(parseLessonTaskLinks(html), [
{ id: id('a'), name: 'Bestandteile einer Kamera' },
{ id: id('b'), name: 'Eigenschaften einer Sammellinse' },
]);
});
it('decodes entities in task names', () => {
const html = page(taskCard(id('c'), 'Gr&ouml;&szlig;e &amp; Form'));
assert.equal(parseLessonTaskLinks(html)[0]?.name, 'Größe & Form');
});
it('reports each task once even when the page links it twice', () => {
// The card title and its action button both point at the same task.
const html = page(taskCard(id('d'), 'Würfelspiel') + taskCard(id('d'), 'Würfelspiel'));
assert.equal(parseLessonTaskLinks(html).length, 1);
});
it('returns nothing rather than guessing when the markup is not a topic page', () => {
assert.deepEqual(parseLessonTaskLinks('<html><body>Anmelden</body></html>'), []);
});
it('ignores homework links that carry no task name', () => {
// A bare link with no aria-label cannot be paired with an API task, so it
// is no use; taking it would produce a task named after nothing.
assert.deepEqual(parseLessonTaskLinks(page(`<a href="/homework/${id('e')}">Aufgabe</a>`)), []);
});
});
describe('withScrapedIds', () => {
it('gives the API tasks the ids from the page, matching on name', () => {
const tasks = [{ name: 'Würfelspiel' }, { name: 'Sammellinse' }];
const links = [
{ id: id('a'), name: 'Sammellinse' },
{ id: id('b'), name: 'Würfelspiel' },
];
assert.deepEqual(
withScrapedIds(tasks, links).map((t) => t.id),
[id('b'), id('a')],
);
});
it('leaves a task the page does not account for without an id', () => {
// Still worth reporting — the user can see it — but the id-taking tools
// cannot open it, so it must not be handed a fabricated id.
const merged = withScrapedIds([{ name: 'Nur in der API' }], [{ id: id('a'), name: 'Etwas anderes' }]);
assert.equal(merged[0]?.id, undefined);
assert.equal(merged[0]?.name, 'Nur in der API');
});
it('does not overwrite an id the task already has', () => {
const merged = withScrapedIds([{ id: id('f'), name: 'Würfelspiel' }], [{ id: id('a'), name: 'Würfelspiel' }]);
assert.equal(merged[0]?.id, id('f'));
});
it('tolerates surrounding whitespace in the API name', () => {
const merged = withScrapedIds([{ name: ' Würfelspiel ' }], [{ id: id('a'), name: 'Würfelspiel' }]);
assert.equal(merged[0]?.id, id('a'));
});
});

View File

@@ -106,6 +106,22 @@ describe('Store', { skip: DB_URL ? false : 'set TEST_DATABASE_URL to run' }, ()
assert.ok(diff.changed.some((n) => n.nodeId === 'c1-b'), 'board body change detected via digest');
});
it('reports a renamed file, which keeps its id and size', async () => {
// `PATCH /file/rename/{id}` renames a record in place. The digest once
// covered only id and size on the assumption that file records never
// change, so a rename went unreported — the file just quietly appeared
// under a new name.
const before = await store.latestCrawlId();
const after = await store.saveSnapshot(
snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung', files: [{ id: 'f2', name: 'b-v2.pdf', size: 20 }] }]),
'full',
);
const diff = await store.diff(before!, after);
assert.ok(diff.changed.some((n) => n.nodeId === 'f2'), 'rename detected');
assert.ok(!diff.added.some((n) => n.nodeId === 'f2'), 'a rename is not a new file');
assert.ok(!diff.removed.some((n) => n.nodeId === 'f2'), 'and not a deleted one');
});
it('carries other courses forward on a per-course crawl', async () => {
await store.saveSnapshot(
snapshot([