Files
Schulcloud-MCP/docs/API.md
MechaCat02 af4464decb Read the user's own lesson notes, and the class register behind them
Schulcloud says what was uploaded and WebUntis says what was scheduled.
Neither says what was *taught* — which point the teacher laboured, which
example landed, what "will definitely come up". That lives in two places
this server could not reach: the notes the user takes in the lesson, and
WebUntis' class register.

Notes are a directory of Markdown files (NOTES_DIR), not a table. They
have to be writable from a phone in a classroom, readable when Postgres
is down, and outlive this project, and files are the only shape that is
all three — so the files are the truth and the index is a view of them,
the same split as file_texts and the mirror. list_notes and get_note read
disk, so they answer before the first crawl; search, what_changed and all
three German prompts read them alongside the Schulcloud material.

add_note writes one, and is the only thing in this server that writes
anything. That is not a hole in the read-only invariant but a different
store: it is bounded to NOTES_DIR by the same safeComponent/resolveWithin
pair that stops a hostile Schulcloud filename escaping the mirror, so a
note titled ../../.ssh/authorized_keys becomes a filename. Schulcloud and
WebUntis stay GET-only and allowlisted respectively. NOTES_READONLY
refuses writes outright.

Appending targets the *lesson*, not the title: "halt das auch noch fest"
mid-lesson carries a new title, and deriving the path from it would start
a second note every time, which is the one thing append exists to prevent.

Notes.app has no export — its bodies are compressed protobuf and the
iCloud copy is encrypted — so scripting the app is not the clumsy route
to the notes but the only one. scripts/export-apple-notes.js reads them
through AppleScript into one JSON object per line, and `schulcloud note
import` converts the HTML to Markdown, takes the Notes folder as the
subject and the *creation* date as the lesson's date. Attachments cannot
come across; a note that was a photo of the board imports as a line
saying so, because importing it empty would hide the loss.

The class register needed one API property to become cheap:
getLessonTopic2017 answers per *series*, not per period, so a term is
reconstructed by asking about the latest period of each lesson series and
merging back by id — a few dozen calls for a school year rather than one
per lesson. untis_lesson_topics now takes a subject as well as a period
id, and UNTIS_HISTORY_DAYS of register goes into the index under a kind
of its own, so "what did we actually do before the test" is searchable.

Sharing the snapshot rather than duplicating it caught one thing on the
way: the search tool's live path had to learn notes too, or fresh=true
would have quietly disagreed with the index.

305 tests; 88/89 smoke against the local instance, the one failure being
the H5P service that instance does not run. The live smoke could not be
retaken: that session has lapsed and needs a fresh jwt cookie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:46:26 +02:00

421 lines
24 KiB
Markdown

# The Schulcloud API, as verified against this instance
Everything here was confirmed against `https://schulcloud-thueringen.de` with a
real student account on 2026-09-11, not inferred from source. Where upstream
source and live behaviour disagreed, live behaviour won.
## Two services, one origin
| Service | Source repo | Base path | Self-documenting at |
|---|---|---|---|
| Main server (NestJS) | [`schulcloud-server`](https://github.com/hpi-schul-cloud/schulcloud-server) | `/api/v3/` | `/api/v3/docs`, `/api/v3/docs-json` |
| Files storage | [`file-storage`](https://github.com/hpi-schul-cloud/file-storage) | `/api/v3/file/` | `/api/v3/file/docs`, `/api/v3/file/docs-json` |
Both accept the same bearer token. The files service was split out of
`schulcloud-server` into its own repository, which is why no `file` paths
appear in the main `docs-json` — a detail that will send you in circles if you
only read the main spec. Fetch both:
```bash
curl -s "$TSC_URL/api/v3/docs-json" -o docs-v3.json # 212 paths
curl -s "$TSC_URL/api/v3/file/docs-json" -o docs-file.json # 26 paths
```
These are the authoritative reference for *this* instance's deployed version.
Prefer them over the GitHub sources, which track `main` and may be ahead.
## Which repositories matter
The `hpi-schul-cloud` org has ~100 repos, most archived or superseded. The live
ones relevant here:
- **`schulcloud-server`** — the API. Read `apps/server/src/modules/<module>/api/`
for controllers and DTOs.
- **`file-storage`** — the files service, extracted from the above.
`src/modules/files-storage/api/controller/files-storage.controller.ts` is the
whole surface.
- **`nuxt-client`** — the current web front end. Useful for seeing which API
calls the real UI makes in which order.
- **`schulcloud-client`** — the *legacy* Handlebars front end. Still receives
commits, but it is not where new features land.
Superseded/archived and worth ignoring: `authorization-service`,
`schulcloud-editor`, `nexboard-api-js`, `end-to-end-tests`, `docker-compose`,
`H5P-Nodejs-library`, `shd-client`.
Note the naming: `/api/v1` is the old Feathers surface. On this instance
`/api/v1/docs` 404s, and the v3 NestJS API covers everything this server needs.
## Content model
```
Course ─┬─ column board ─── column ─── card ─── element ─┬─ richText
│ ├─ file ──── fileRecord(s)
│ ├─ link
│ └─ …
├─ lesson (Thema) ─── contents[] + materials[]
└─ task (Aufgabe) ─── description + fileRecord(s)
```
On the account this was built against: 26 courses holding 30 column boards, 18
lessons, 42 tasks and 175 files. **Column boards hold the great majority of
current material**; lessons are the older format.
## Endpoints this server uses
| Purpose | Call |
|---|---|
| Identity, school id, permissions | `GET /api/v3/me` |
| Courses | `GET /api/v3/courses?skip&limit` |
| One course's contents | `GET /api/v3/course-rooms/{courseId}/board` |
| Dashboard tiles | `GET /api/v3/dashboard` |
| Tasks | `GET /api/v3/tasks`, `GET /api/v3/tasks/finished` |
| Lesson body | `GET /api/v3/lessons/{lessonId}` |
| Lesson's tasks | `GET /api/v3/lessons/{lessonId}/tasks` |
| Board structure | `GET /api/v3/boards/{boardId}` |
| What a board belongs to | `GET /api/v3/boards/{boardId}/context` |
| Card bodies | `GET /api/v3/cards?ids=<id>&ids=<id>` |
| Files of an entity | `GET /api/v3/file/list/{storageLocation}/{storageLocationId}/{parentType}/{parentId}` |
| One file's metadata | `GET /api/v3/file/{fileRecordId}` |
| File bytes | `GET /api/v3/file/download/{fileRecordId}/{fileName}` |
| News | `GET /api/v3/news` |
| Instance settings (no auth) | `GET /api/v3/config/public` |
| Remaining idle budget | `POST /api/v3/authentication/refresh-session``{expiresInSeconds}` |
### Gotchas that cost real time
**`course-rooms`, not `courses`, for course contents.** `GET /api/v3/courses/{id}`
does not exist. The route that returns a course's lessons/tasks/boards is
`GET /api/v3/course-rooms/{roomId}/board`, and its `:roomId` is the *course* id.
Nothing in the naming suggests this.
**`/api/v3/rooms` is a different feature, and the UI's naming hides it.** Rooms
("Räume") are the newer standalone collaboration spaces. The sidebar's *Kurse*
entry links to `/rooms/courses-overview` and lists **courses** (served by
`/api/v3/dashboard` + `/api/v3/courses`), while *Räume* links to `/rooms` and
lists **rooms** — so a url containing `/rooms` identifies neither.
A room holds boards and nothing else: no lessons, no tasks. `GET /rooms`
answers `{"data":[]}` with no `total`, derived from real memberships, so an
empty result means the account is in no rooms — which is also what it looks
like after a teacher deletes a room or revokes access. `GET /rooms/{id}/boards`
does report `isVisible`, unlike the course-page projection, so a room's draft
boards can be identified without trying to open one.
**`limit` maxima are enforced and mis-documented.** The OpenAPI schema says
`maximum: 99`; the runtime validator rejects anything `> 100`. Page at 99 to
satisfy both. Asking for 200 returns a `400 API_VALIDATION_ERROR`, not a
truncated list.
**There is no `GET /tasks/{id}`.** Single-task detail has to be assembled: the
list endpoints give metadata but *omit `description`*, which appears only on the
course page's task element. `get_task` does this join.
**Board files need three calls.** A `file` element's `content` carries only
`{caption, alternativeText}` — no file id. The bytes are found by listing
files-storage with `parentType: 'boardnodes'` and the **element** id as
`parentId`. This is the single least discoverable part of the API, and applies
equally to `fileFolder` and `drawing` elements.
**`GET /cards?ids=` accepts at most 20 ids.** Above that the request fails with
`400 "each value in ids must be a mongodb id"` — blaming the ids when the real
problem is how many there are. Express/NestJS parse the query string with `qs`,
whose default `arrayLimit` is 20; past it, repeated params become an object
keyed `"0"`, `"1"`, … and `@IsMongoId({ each: true })` then rejects every value.
Nothing in the controller or its DTO says so: the limit lives in the query
parser underneath them. Verified live — 20 ids return 200, 21 return 400 with
identical ids. A board with more than 20 cards is therefore unreadable in one
request; `getCards` chunks at 20.
**Submissions are nearly invisible.** The only route is
`GET /api/v3/submissions/status/task/{taskId}` — there is no `GET /submissions`
and no fetch-by-id, so a task id is the only way to reach a submission. The
response carries `{id, submitters, isSubmitted, isGraded, grade,
submittingCourseGroupName}` and **nothing else**: no submitted text, no grade
comment, no graded-at. Those lived on the legacy Feathers API, and `/api/v1` is
not served on this instance (404 across the board), so they are simply
unavailable. Submitted *files* are reachable through files-storage with
`parentType: 'submissions'`.
**Submitted text and written feedback exist only in the rendered web page.**
The legacy front end calls the Feathers API server-side for
`submission.comment`, `submission.grade` and `submission.gradeComment` and
renders them into `GET /homework/{taskId}`. That Feathers API is not exposed
publicly (`/api/v1/*` 404s), so the page is the only way to reach these from
outside. `core/homework-page.ts` parses it, hooked on the `data-testid`
attributes the project's own e2e tests use. Note the page authenticates with the
`jwt` **cookie** — an `Authorization` header is ignored and redirects to the
identity provider. Measured: 4 of 7 graded submissions in one course carried
feedback no API call can return.
**A grade is a percentage or nothing.** The submission schema is
`grade: { type: Number, min: 0, max: 100 }` and `gradeComment: { type: String }`
— there is no textual grade field. In practice teachers frequently grade with
`gradeComment` alone and leave `grade` unset, so a written "OK" is the whole
verdict for that submission. Treat a missing `grade` as "no percentage given",
never as "ungraded" (`graded` is its own boolean) and never as an error.
**files-storage ignores `parentType` when listing.** Asking for
`.../gradings/{submissionId}` returns the files parented to that id whatever
their type — the records come back saying `parentType: "submissions"`. The path
segment appears to serve authorisation, not filtering, so filter on each
record's own `parentType` or a student's own upload will be reported back as
teacher feedback.
**`storageLocationId` is the school id** (from `/me`), with
`storageLocation: 'school'`, for every parent type in normal use.
**Lesson ids come back as buffers.** `GET /api/v3/lessons/{id}` returns nested
ids as `{buffer:{type:'Buffer',data:[...]}}` rather than hex strings — a leak
from the legacy Mongo serialisation. `normalizeObjectId` in `src/render.ts`
converts them.
**The JWT's `exp` is not the session lifetime, and the source misleads here.**
Both the current and legacy whitelist implementations re-set a Valkey TTL on
every authenticated request, which reads as a sliding window. The live instance
does not behave that way: a session ends ~2 h after **login**, and successful
reads in between do not extend it (measured — see `docs/AUTH.md`). This is the
clearest case in this API of live behaviour diverging from upstream source.
**`GET /api/v3/config/public` is unauthenticated and useful.** 78 keys of
instance configuration, including the session timeouts and feature flags. Handy
for checking deployed settings without a token.
**`GET /lessons/{id}/tasks` returns a bare array, and its items have no id.**
Every other list endpoint returns `{data, total}`; this one returns the array
directly, so reading `.data` silently yields `undefined`. Worse, the items are
`LessonLinkedTaskResponse`, which has no id property at all — name, description
and dates only. A task attached to a topic is therefore unidentifiable from the
API: it is not a task element on the course page (the topic reports only
`numberOfPublishedTasks`), and once past due it is in neither `/tasks` nor
`/tasks/finished`. On the account this server was built for that hid 18 of 60
tasks, submissions and grades included. The ids are recoverable only from the
legacy topic page, which links each task as `/homework/{id}`
`core/lesson-page.ts`.
**A student's task lists exclude past-due tasks.** `/tasks` drops a task once
its due date passes; `/tasks/finished` holds only what the student ticked off.
A submitted, graded, past-due task is in neither. Reach it through the course
page, or through its topic.
**An unpublished board is listed but cannot be opened.** The course-board
projection reports a draft board with its title, while `GET /boards/{id}`
answers 403 for anyone who cannot edit it. Treat a 403 there as "probably not
published yet", not as an access problem.
**`PATCH /file/rename/{fileRecordId}` mutates a file record in place.** The id
and size stay the same, so any change detection keyed on those alone misses it.
**`Content-Disposition` on downloads is malformed.** It comes back as
`attachment;; filename="…"` — note the doubled semicolon — and the filename is
percent-encoded inside the quotes. Parse defensively.
### Content element types
From `ContentElementType` in `schulcloud-server`, all seen live except where
noted: `richText`, `file`, `fileFolder`, `link`, `drawing`,
`collaborativeTextEditor`, `externalTool`, `videoConference`, `h5p`, `deleted`.
Collaborative text editor elements come back with `content: {}` — no pad id, no
url, nothing. `GET /api/v3/collaborative-text-editor/content-element/{elementId}`
returns the pad url, and **also sets an Etherpad `sessionID` cookie** in its
response. With that cookie, Etherpad's own `/etherpad/p/{padId}/export/txt`
returns the document as plain text. So the contents *are* reachable, in two
hops and without Etherpad's API key; `core/etherpad.ts` does this. The url is
built from the server's `ETHERPAD__PAD_URI`, so it must be checked against the
instance host before the session cookie is sent to it.
### H5P elements are the quizzes, and one request holds a whole one
An `h5p` element carries nothing but `content: { contentId }`. The content
itself comes from the H5P service, whose API lives under `/api/v3/h5p-editor/`
— not in `docs-json`, and with no document of its own
(`/api/v3/h5p-editor/docs-json` is a 404). The deployment's ingress table routes
`/h5p/player` and `/h5p/editor`, which are the front-end apps, not this API.
- **`GET /api/v3/h5p-editor/params/{contentId}`** returns the JSON the player is
fed: `{ h5p: <metadata>, library, params: { metadata, params } }`. The inner
`params` is the exercise — **every question, every option and which are
correct** — so the player showing one question at a time is a display detail,
not a limit on what can be read. Bearer auth, same as everything else.
- `GET /api/v3/h5p-editor/play/{contentId}` is the same content wrapped in the
player's integration object: 74 KB against 51 KB for the live quiz below,
because it carries script and style lists. `params` is both smaller and
complete, so nothing needs `play`.
- `GET /api/v3/elements/{elementId}` returns a single board element with its
content, which is how an element's `contentId` can be re-read without its
board. (`/api/v3/board/element/{id}` does not exist.)
The **shape inside `params` belongs to the H5P library** the teacher used, which
is where the work is. Verified against the live quiz "Quiz zur formalen
Gestaltung einer Projektdoku" (`H5P.QuestionSet`, 20 questions):
- A `QuestionSet` holds `questions: [{ library, params, subContentId }]`, each
sub-content naming its own library — `H5P.MultiChoice 1.16` here. Any other
main library *is* a single question, with the same `params` shape.
- `H5P.MultiChoice`: `question` and `answers[].text` are HTML,
`answers[].correct` is the solution, `answers[].tipsAndFeedback.tip` a hint,
and `behaviour.singleAnswer` is what makes the player draw radio buttons —
the only honest source for "tick exactly one".
- `H5P.TrueFalse` stores `correct` as the **string** `"true"`/`"false"`, with
the button labels in `l10n`.
- Cloze libraries (`H5P.Blanks`, `H5P.DragText`, `H5P.MarkTheWords`) mark the
solutions inside the text as `*answer:tip*`, alternatives separated by `/`.
- `H5P.SingleChoiceSet` and `H5P.Summary` put the **correct option first** and
let the player shuffle; nothing else marks it.
- Every payload also carries `UI`, `l10n`, `behaviour` and `overallFeedback`
subtrees of button labels and display settings. Anything that harvests text
generically has to skip them, or the exercise reads as "Überprüfen,
Wiederholen, Absenden".
`core/h5p.ts` models the libraries above and harvests the text of anything else
under a label saying so — a teacher's exercise reported as "0 questions" would
be worse than a clumsy rendering of it.
## The file manager ("Dateien") is a third store
Persönliche Dateien, Kurs-Dateien, Team-Dateien and Geteilte Dateien are the
**legacy file system**: a `files` collection with real folders (`isDirectory`,
`parent`, `owner`, `refOwnerModel`), served by the legacy Feathers
`fileStorage` service. It shares nothing with files-storage. Asking
`/api/v3/file/list/school/{school}/courses/{courseId}` answers **0** for a
course whose file manager holds dozens of worksheets — measured: 21 of 26
courses on the live account keep files here, some nothing else.
**Its service is not in the public ingress**, so the only way in is the legacy
client. Verified live:
| Route (legacy client, `jwt` cookie) | Returns | Notes |
|---|---|---|
| `GET /files/my/` , `/files/my/{folder}` | HTML listing | personal root / one folder |
| `GET /files/courses/` | HTML listing | the courses, *as folders* (ids = course ids) |
| `GET /files/courses/{course}` , `/files/courses/{course}/{folder}` | HTML listing | **one** folder segment at any depth |
| `GET /files/teams/…` | HTML listing | same shape as courses |
| `GET /files/shared/` | HTML listing | flat; shared *folders* cannot be opened (no route; the UI's link 404s) |
| `GET /files/signedurl?file={id}&name={name}` | `{"url": …}` | pre-signed S3 url, **another host** (live: `s3.hidrive.strato.com`) |
| `GET /files/permittedDirectories/` | JSON tree | **lists every course with no folders in any** — see below |
| `GET /files/search/?q=` | HTML | **504** on live: an unindexed regex over every file record |
Listings are parsed on the attributes the page's own scripts use:
`data-folder-id` with the name inside `.card-title-directory` (emitted
**unescaped**, `{{{stripOnlyScript name}}}`), and `data-file-id`,
`data-file-name`, `data-file-size`, `data-file-viewer-type` on each
`.card.file`. A blocked file carries `btn-file-danger` and no viewer type. No
dates are rendered.
**`permittedDirectories` is broken for courses.** The directory service's
query matches course folders on `refOwnerModel: 'courses'`; the records say
`'course'`. Live result: 26 courses, 0 folders; personal folders come through.
Listings are the only complete view — which is also what the UI shows.
**Some GET routes write.** `GET /files/share/?file=` mints a share token when
the file has none (`PATCH /fileStorage/shared/{id}`), and
`GET /files/file?…&share=…` grants the caller a permission on the file.
`GET /files/fileModel/{id}/proxy` forwards to the latter. A GET-only client is
therefore *not* read-only against this surface by itself: `core/client.ts`
allows only the listing routes and `/files/signedurl`, by pattern.
**The signed-url service returns its error instead of throwing it**
(`.catch((err) => new Forbidden(err))`), so a refused file is a 200 whose body
has no `url`.
**Course names contain `/`** in real data ("LF07 - FIA24A/B - Sb/Ha",
"FIA24/FIP24 IT LF12"), and a real file is literally called `..docx`. Paths
built from names cannot be split naively; `core/legacy-files.ts` resolves by
trying joined segments, and accepts ids as segments.
Two more, seen while building the local fixture:
- **`getRefOwnerModel(owner)` answers "a course, or else `teams`".** Any owner
id that is not a course — a *user* included — is recorded as a team's. The
upload page never sends an owner for personal files (`data-owner=""`), so the
server defaults to the creator and records `user`; send the user id and every
later permission check dereferences a team that does not exist.
- **The file permission service writes `refOwnerModel`** where the "shared with
me" query reads `refPermModel`, so a share made through it never appears
under Geteilte Dateien. The share-link flow patches `/files/{id}` directly.
## 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".
- **It answers per *series*, so one call covers a term.** The entries come
back with their own `periodId` and date, which is what lets a range of
lessons be reconstructed from a handful of calls rather than one per period:
take the distinct `lessonId`s in the range, ask about the **latest**
`periodId` of each, and merge the answers back onto the periods by id.
Asking about the earliest period of a series instead reaches none of its
history, because "previous" is relative to the period given.
`core/untis-history.ts` is that walk, and it is what puts the class register
into the search index.
- **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
prints what it finds — both token clocks included: days until hard expiry, and
seconds of idle budget remaining.