Document WebUntis

docs/API.md gains the findings: the endpoint and its required version
parameter, the one-time code and the two error codes worth naming, the Z that
means local time, a substitution being two periods, the unused exam module
that puts announced tests in the period notes, and a day without lessons that
is not a holiday. Those cost an afternoon of probing to learn and nothing
upstream states them.

docs/AUTH.md sets the key against the Schulcloud token it sits beside: no
password, nothing to keep alive, revocable on its own, and not read-only in
itself — which is why the allowlist exists. PI.md and DEPLOYMENT.md add the
four values, the container recreate a changed key needs, and the clock
requirement; the Pi's troubleshooting table gains both failure messages.

README and CLAUDE.md say what the server now is: Schulcloud for the material,
WebUntis for the day. Smoke is 89 checks with the index and a key, 87
live-only, 9 fewer without one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-17 20:43:24 +02:00
parent eaf9c7aa38
commit ccbf3ad3e9
8 changed files with 245 additions and 25 deletions

View File

@@ -288,6 +288,75 @@ Two more, seen while building the local fixture:
me" query reads `refPermModel`, so a share made through it never appears
under Geteilte Dateien. The share-link flow patches `/files/{id}` directly.
## WebUntis is a fourth store, and holds the timetable
Schulcloud's `times` on `/api/v1/courses` are empty for this school, so nothing
in Schulcloud says when a lesson happens, let alone that it was cancelled. That
lives in **WebUntis**, a separate product with its own login. What this server
uses is the API the Untis Mobile app uses, verified against
`ags-erfurt.webuntis.com` on 2026-09-17.
- **One endpoint, JSON-RPC:** `POST /WebUntis/jsonrpc_intern.do?m=<method>&school=<school>&v=i3.2`,
with `{"jsonrpc":"2.0","method":<method>,"params":[{…, "auth":{…}}]}`.
- **`v` is not optional.** Omit it and the call fails with `-8998` carrying a
Java `NullPointerException` from `getParameter`, which reads like a bug in
the request body and is not.
- Errors arrive with **HTTP 200** and an `error` member, so the body has to be
checked before the status.
- A plain `user-agent: schulcloud-mcp` is accepted; there is no need to
impersonate the app.
- **Authentication is a TOTP over a static base32 key**, no password and no
session: `auth: { user, otp, clientTime }` on every call, where `otp` is the
6-digit RFC 6238 code for the key from Profil → Freigaben → Untis Mobile.
- **Send the code as a string.** One in ten starts with a zero, which a JSON
number silently drops. Both shapes are accepted, so only the string is
always right.
- `-8504 bad credentials` = wrong key or user. `-8524 invalid client time` =
the host's clock is off, which is its own failure and worth naming.
- The response sets a `JSESSIONID`, but nothing needs it: each request
authenticates itself, which is why there is no keepalive on this side.
- **Methods that exist** (and are all this server may call — see the allowlist
in `core/untis.ts`): `getUserData2017`, `getTimetable2017`,
`getLessonTopic2017`, `getHomeWork2017`, `getMessagesOfDay2017`.
`getClassregEvents2017` and `getSchoolyears2017` answer "Method not found";
`getPeriodData2017` answers with empty objects for a student.
- **`startDateTime` claims to be UTC and is not.** Lessons come back as
`2026-09-21T08:00Z` and the school's time grid starts at 08:00 local, so the
`Z` is decoration. `new Date(...)` would move every lesson by an hour or two,
twice a year by a different amount; `splitLocal` takes the string apart
instead.
- **A substitution is two periods, not one changed period.** The original turns
up with `is: ["CANCELLED"]` and the replacement beside it with
`is: ["IRREGULAR"]`, same slot, different teacher. `orgId` on an element also
exists (the room moves that way), so both have to be read. Statuses seen on a
real account: `REGULAR`, `CANCELLED`, `IRREGULAR`.
- **`getTimetable2017` carries the whole master data with every answer** —
subjects, teachers with full names, rooms, classes, every holiday of six
school years. There is a `masterDataTimestamp` delta protocol; its removal
semantics are unverified, so this server asks for the full set and caches it
for a few hours.
- **Homework hangs off the periods too.** `getHomeWork2017` filters by the
homework's own dates, so a window ending today shows nothing due tomorrow and
nothing set last month — and the same items appear inline on the timetable's
periods, where they need no second call.
- **`getLessonTopic2017` takes `periodId`, singular.** It answers with
`previousTopics`: what the earlier lessons of that series actually covered,
from the class register. A `periodIds` array is rejected as "period 0 not
found".
- **The exam module is unused at this school**, so `getExams2017` is empty and
`period.exam` is null. Announced tests are typed into the period's **info
text** instead ("LF10: Leistungskontrolle agile Softwareentwicklung …"), which
makes `text.info` the most valuable field in the payload rather than a
footnote.
- **A day with no lessons is not necessarily a holiday.** At a vocational school
the weeks spent in the company simply have no periods, and `holidays` says
nothing about them. Reporting "Ferien" there would be wrong; so would an
empty answer.
- **The key can write.** This account's `rights` are `CLASSREGISTER`,
`R_MY_ABSENCES`, `W_OWN_ABSENCE`, `R_OFFICEHOURS` — the mobile API can report
an absence for the user. Nothing here does, and the allowlist is what
guarantees it.
## Re-verifying after an upstream release
`npm run probe` re-checks every assumption above against the live instance and