Write the notes in an app, a school day at a time

The notes existed but there was nowhere to write them: a CLI command on a
laptop, a tool call through Claude, or a file in a Docker volume. None of
those is reachable from a phone in a lesson, which is where notes are
actually taken.

So: `/app`, served only when WEB_PASSWORD is set. A login, the day's
notes, and a settings page for the Schulcloud token — the one surface
here meant for a person rather than a program.

The shape follows how the notes are written: one note per school day,
one `##` heading per lesson, prose and lists and tables beneath. That
turns out to be the design decision that matters, twice over.

First, it is what lets WebUntis earn its keep. Opening a day with no
note fills in that day's lessons — numbered, with times, teacher and
room, cancellations dropped and substitutions marked. Retyping the
timetable is exactly the work the second upstream exists to avoid, and
"Stunden ergänzen" tops up a note started before the day ended without
touching what is already written.

Second, it changes how notes are indexed. A day note is indexed per
lesson, not whole: search answers "my own note, Deutsch, 18.09.2026"
rather than "my own note, Friday", and `list_notes subject=Deutsch`
finds a day whose frontmatter names no subject at all. Indexed whole,
every hit would read as a weekday and "what did we do in Deutsch" would
match notes whose other five lessons were something else. `lessonHeading`
and `subjectFromHeading` are a loop — the app writes the heading, the
indexer reads the subject back out — and a test holds them to it.

Notes taken in a lesson cannot be retaken, so the editor is built
around not losing them: autosave, every keystroke mirrored to local
storage, a save when the phone locks, and a fallback to the local copy
when the request never arrives. A save that would overwrite a version
the editor never saw is refused and the choice handed back — the notes
folder is synced and open in more than one place, and a phone must not
silently win over a laptop. `replaceNote` is separate from `writeNote`
for that reason: never-overwrite is right for `add_note` and exactly
wrong for an editor.

WEB_PASSWORD is the first credential here a human types, so it is the
first that can be guessed: scrypt at startup, never stored or compared
in the clear, per-address rate limiting — which is not decoration, since
the scrypt cost is itself a denial-of-service vector without it. The
session is a signed HttpOnly SameSite=Strict cookie whose key is derived
from the password, so changing it logs everyone out and there is no
second secret to keep. It opens /api, because a session is the user, and
never /mcp, because nothing in a browser speaks MCP.

Also here, because the app made them matter: frontmatter now reads the
indented `- item` list form editors write, so an Obsidian vault
round-trips its tags; and a four-digit folder is a filing scheme, not a
subject, so `2026/` does not file a school year under one.

357 tests; 106/107 smoke against the local instance, the one failure
being the H5P service that instance does not run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-19 17:21:17 +02:00
parent af4464decb
commit dc50b4bcd5
29 changed files with 2501 additions and 144 deletions

View File

@@ -251,15 +251,50 @@ access log entry is written. Rotating it means a new value, a recreated
container, and re-adding the connector. Prefer the connector token wherever a
header can be sent: a URL is copied into more places than a header is.
### The app password, for a person
`WEB_PASSWORD` is unlike every other credential here: it is typed by a human, on
a phone, in a lesson. That single fact drives its whole design.
- **It is a passphrase, not a token.** `config.ts` insists on 12 characters and
nothing else; demanding punctuation would buy little next to length, and the
failure mode of a fussy rule is a shorter password, not a better one.
- **It is never stored in the clear.** scrypt (N=16384) at startup; a login
hashes the attempt and compares in constant time. The error states the rule
and never echoes the value.
- **Logins are rate-limited per address**, eight failures in fifteen minutes.
Not optional: a password is guessable in a way a 32-byte token is not, and the
scrypt cost that makes guessing expensive is itself a denial-of-service vector
without a limiter in front of it.
- **The session is a signed cookie**, `HttpOnly` and `SameSite=Strict` — the
latter standing in for CSRF tokens, since nothing links into the app from
anywhere else. 30 days, because the alternative is a login screen at the start
of a lesson.
- **The signing key is derived from the password**, so changing it invalidates
every session that exists. No second secret, nothing to store, and the
behaviour anyone changing a password already expects.
- **The cookie opens `/api` and not `/mcp`.** A session *is* the user, and the
app is built on `/api` — but nothing in a browser speaks MCP, and a surface
that is not needed is not offered.
Unset, the app is not served at all. A login screen that no password can open is
worse than no page, because it looks like a way in.
## Blast radius
Every path in this server is a `GET`, including the `api_get` escape hatch,
which rejects anything not starting with `/api/` and anything carrying a scheme
or host. Someone who obtained both the endpoint URL and `MCP_AUTH_TOKEN` — or
the connector token, or the secret MCP path — could read this account's
Schulcloud data; they could not
the connector token, the secret MCP path, or the app password — could read this
account's Schulcloud data; they could not
post, submit, delete, or otherwise act as the user. With `MCP_AUTH_TOKEN` they
could also call `PUT /api/token`, but it accepts only a live token for the same
account, so the most it can do is hand the server a session the owner already
has. Keep it that way — adding a single write tool would change that property
entirely.
The app password and `MCP_AUTH_TOKEN` additionally reach the notes: they can
read, write and overwrite files under `NOTES_DIR`, and nothing outside it —
`safeComponent` and `resolveWithin` are what make that a property rather than a
hope. That is the only write anywhere in this server, and it touches the user's
own files, never Schulcloud. `NOTES_READONLY=1` removes even that.

View File

@@ -216,6 +216,32 @@ for a deployment:
time-based code; a drifting clock is refused with "invalid client time", which
the tools report in those words.
## The notes app
Set `WEB_PASSWORD` in `.env` and the server offers `/app`: the notes editor and
a settings page. Unset, it is not served at all, and nothing else changes.
```
https://mcp.example.org/app/
```
Three things worth knowing for a deployment:
- **Recreate the container after changing the password** (`docker compose up -d
--force-recreate schulcloud-mcp`) — `env_file` is read at creation. Changing
it also logs out every session, by design: the session signing key is derived
from it.
- **Notes need somewhere to live.** `docker-compose.yml` sets
`NOTES_DIR=/data/notes` with a volume of its own. To write the notes from a
phone through a sync tool as well as through the app, bind-mount a real
directory there instead — see [NOTES.md](NOTES.md).
- **The app is a way to replace the Schulcloud token**, which is the next
section, and the more comfortable one when the expiry catches you away from a
terminal.
`/token` still exists and still works. It is the fallback for a deployment with
no `WEB_PASSWORD`, and it is unchanged.
## Replacing the Schulcloud token
The token lasts 30 days at most and can only come from a browser login (see
@@ -224,8 +250,9 @@ or an `.env` edit:
1. Log in to Schulcloud in a **private window** and copy the `jwt` cookie's
value (DevTools → Application → Cookies).
2. Either run `schulcloud token set` and paste it, or open
`https://mcp.example.org/token` and paste it together with `MCP_AUTH_TOKEN`.
2. Either run `schulcloud token set` and paste it, open the app's
**Einstellungen** tab, or open `https://mcp.example.org/token` and paste it
together with `MCP_AUTH_TOKEN`.
3. **Close the private window.**
The server checks the token with Schulcloud first — right account, not

View File

@@ -7,113 +7,142 @@ whatever you write down during the lesson, and until now it lived somewhere no
agent could read.
This is the third source: a directory of Markdown files the server reads,
indexes and searches alongside everything else.
indexes and searches alongside everything else, and a small web app to write
them in.
## One note per school day
```
NOTES_DIR/
2026/
2026-09-18.md ← Freitag, one heading per lesson
2026-09-21.md
Deutsch/
2026-09-15 Erörterung.md
2026-09-22 Sprachanalyse.md
LF07/
2026-09-16 Subnetting.md
Allgemein/
2026-09-18 Elternabend.md
2026-09-15 Erörterung.md ← a single-subject note, e.g. from the import
```
## Why files
Three properties, in this order:
- **They have to be writable from a classroom.** Whatever you take notes in on a
phone or a laptop, it can produce text files; nothing can produce rows in the
Pi's Postgres.
- **They have to be readable when the index is down.** `list_notes` and
`get_note` read the disk, so they answer before the first crawl and while
Postgres is unreachable — the index is a view of the notes, never the notes
themselves. That is the same split as extracted file text and the mirror.
- **They have to survive this project.** A directory of dated Markdown is
greppable, diffable, syncable and still yours if the server is thrown away.
## What a note looks like
Frontmatter, then the note. Every field is optional, and a plain `.md` file with
no frontmatter at all is a perfectly good note.
A day note looks like this, and the shape is load-bearing:
```markdown
---
title: Erörterung — Aufbau
date: 2026-09-15
subject: Deutsch
tags: [klausur, aufsatz]
title: Freitag, 18.09.2026
date: 2026-09-18
source: notes-page
---
## 1. Deutsch — 08:0008:45 · MEI · R 204
Dreischritt: These, Argument mit Beleg, Fazit.
Frau Meier betont: das **Gegenargument** darf nicht fehlen, sonst gibt es
Abzug — kam letztes Jahr in der Arbeit dran.
### Aufbau
- Gegenargument nicht vergessen — kam letztes Jahr in der Arbeit dran
## 2. LF07 — 08:5009:35 · Sb · R 108
| Präfix | Nutzbare Adressen |
|---|---|
| /24 | 254 |
```
What the server reads out of it:
**Each `##` is indexed as its own lesson.** A search for *Gegenargument* answers
"my own note, Deutsch, 18.09.2026", not "my own note, Friday"; `list_notes
subject=Deutsch` finds this day even though the note's frontmatter names no
subject; and `what_changed` reports the lesson that changed rather than the
whole day. Prose, lists, tables and `###` subheadings all live inside their
lesson.
| Field | Falls back to |
|---|---|
| `title` | the first `# heading`, else the filename without its date |
| `date` | a `YYYY-MM-DD` the **filename** starts with, else nothing |
| `subject` | the first folder name (`Deutsch/…`) |
| `tags` | none |
| `courseId` | none — set it to tie a note to a Schulcloud course in `search` |
Nothing forces the shape. A note with no `##` headings — an imported Apple Note,
a page of revision — is indexed whole, as one piece of prose.
`date` also accepts `15.09.2026`, and `fach:` works as a German spelling of
`subject:`. Anything else in the block is kept but not interpreted.
## The app
**The file's modification time is never used as the date.** An import writes
every note today; dating a year of lessons "today" would make the whole store
useless for revision, which is most of what it is for.
`/app` is a small web app: a login, the day's notes, and a settings page. It is
served only when `WEB_PASSWORD` is set.
## Reading them
```
https://mcp.example.org/app/
```
| Tool | Answers |
|---|---|
| `list_notes` | "what did I write down in Deutsch before the test" — filter by subject or date |
| `get_note` | one note in full, by the path everything else prints |
| `search` | notes by their contents, next to board text and the inside of PDFs |
| `what_changed` | notes that appeared or were edited since a date |
On a phone it is worth adding to the home screen — it has a manifest and opens
standalone, which is the difference between "a page I have to find" and "the
thing I open in a free period".
The `pruefungsvorbereitung`, `zusammenfassung` and `tagesvorbereitung` prompts
consult them on their own, and are told to say when a note disagrees with the
uploaded material rather than quietly preferring one.
**Notizen** is one screen: the day, ` ` to move between days, and the editor.
## Writing them
- Opening a day with no note yet **fills in that day's lessons from WebUntis**
numbered, with times, teacher and room, cancellations left out and
substitutions marked. That is the whole reason the app is day-shaped: the one
thing WebUntis knows and you should not have to retype.
- **Stunden ergänzen** appears when the timetable has a lesson your note does
not — a day you started writing before it ended, or a timetable that changed
after you started. It appends what is missing and never touches what you wrote.
- It **saves as you type** (a couple of seconds after you stop), when the app
goes to the background, and when the phone locks.
- Every keystroke also goes to the browser's local storage. If the connection
drops mid-lesson you keep writing, and the next time the app loads that day it
offers the version this device has. **A note taken in a lesson cannot be
retaken**, which is the reasoning behind all of this.
- If the note changed elsewhere since you opened it — the laptop, a sync tool,
`add_note` — the save is refused and you are asked which version wins. It
never silently overwrites.
Four ways in, all landing in the same files:
**Einstellungen** holds the Schulcloud token: how long it has left, and the box
to paste a fresh `jwt` cookie into when it expires (the same thing `schulcloud
token set` and the older `/token` page do). It also shows the index's state and
the notes directory, and has the logout button.
### The login
`WEB_PASSWORD` is the app's password — at least 12 characters, and a passphrase
of three or four words is the right shape. It is hashed with scrypt at startup;
the plain value is never stored, compared or logged.
Logging in sets an `HttpOnly`, `SameSite=Strict` session cookie that lasts 30
days, so a lesson never starts with a login screen. The cookie opens `/api`
it *is* the user — but not `/mcp`, which no browser needs. Changing
`WEB_PASSWORD` invalidates every session, because the signing key is derived
from it. Failed logins are rate-limited per address; a password is guessable in
a way a 32-character token is not, and this endpoint is on the internet.
If `WEB_PASSWORD` is unset the app is not served at all — the same rule the
`untis_*` and note tools follow. A login screen that no password can open is
worse than no page, because it looks like a way in.
## The other four ways to write a note
The app is not privileged; it writes the same files as everything else.
```bash
# from the command line, text piped in
# the command line, text piped in
pbpaste | schulcloud note add --title "Subnetting" --subject LF07
# from an editor, or anything else that writes files
$EDITOR "$NOTES_DIR/LF07/2026-09-16 Subnetting.md"
# an editor, or anything else that writes files
$EDITOR "$NOTES_DIR/2026/2026-09-18.md"
# by asking Claude, during or after the lesson
# Claude, during or after the lesson
# "halt fest: Gegenargument nicht vergessen, kam letztes Jahr dran"
# → add_note, subject Deutsch, today's date
# by syncing a folder you already write in — see below
# a folder you already sync — see below
```
`add_note` and `schulcloud note add` take `append`, which adds to the note
already written for that subject and day instead of starting a second one. That
is the right choice while a lesson is running: notes accumulate in one file the
way they do on paper.
already written **for that subject and that day** rather than starting a second
one — so "note this down too" mid-lesson lands in the note that is already open,
whatever its title.
**This is the only thing in this server that writes anything.** It writes into
the notes directory and nowhere else: every path component goes through
`safeComponent` and the result through `resolveWithin`, the same two functions
that stop a hostile filename escaping the file mirror, so a note titled
`../../.ssh/authorized_keys` becomes a filename. Schulcloud and WebUntis stay
strictly read-only — see the invariants in `CLAUDE.md`. Set `NOTES_READONLY=1`
to refuse writes entirely, which is right when the notes are synced in from
somewhere else and should have exactly one writer.
## What may write, and where
**The notes directory is the only thing this server writes to.** Not Schulcloud,
not WebUntis — those stay read-only, strictly, and that has not changed.
Every path component goes through `safeComponent` and the result through
`resolveWithin`, the same two functions that stop a hostile Schulcloud filename
escaping the file mirror, so a note titled `../../.ssh/authorized_keys` becomes
a filename. `NOTES_READONLY=1` refuses writes entirely — right when the notes
are synced in from somewhere else and should have exactly one writer. The app
then still reads them, and says so rather than failing on save.
## Migrating out of Apple Notes
@@ -131,31 +160,20 @@ The first run raises a macOS permission dialog ("Terminal wants access to
Notes"); without it every note comes back empty. `--folder Deutsch` exports one
Notes folder.
Then convert and import. Look at it first:
Then look at what it would do, and do it:
```bash
schulcloud note import notes.ndjson --dry-run
schulcloud note import notes.ndjson # into the server
schulcloud note import notes.ndjson --out ~/Notizen # or to a local folder first
```
```
would import: 2026-09-15 · Deutsch · Erörterung
would import: 2026-09-16 · LF07 · Subnetting
Would import 84 of 91 note(s), skipped 5 with no text, 2 unreadable in Notes.
```
and then for real, either straight into the server:
```bash
schulcloud note import notes.ndjson
```
or into a local directory, to read through before anything is sent anywhere:
```bash
schulcloud note import notes.ndjson --out ~/Notizen
```
What the import does with each note:
What it does with each note:
- **The Notes folder becomes the subject** — its last segment, so `Schule/Deutsch`
is `Deutsch`. Notes' own default folders (`Notizen`, `Recently Deleted`) are
@@ -171,11 +189,27 @@ What the import does with each note:
- **Locked notes cannot be read at all** and are listed by name at the end.
Unlock them in Notes and export again.
Imported notes arrive as single-subject notes, not day notes, which is the
shape they were written in. Both kinds coexist.
Re-running the import creates second copies rather than overwriting, since the
importer cannot tell an edited note from a new one with the same title. Import
once, then keep writing in the new place.
## Writing them from a phone, afterwards
## Reading them
| Tool | Answers |
|---|---|
| `list_notes` | "what did I write down in Deutsch before the test" — by subject or date, lesson headings included |
| `get_note` | one note in full, by the path everything else prints |
| `search` | notes by their contents, per lesson, next to board text and the inside of PDFs |
| `what_changed` | lessons and notes that appeared or were edited since a date |
The `pruefungsvorbereitung`, `zusammenfassung` and `tagesvorbereitung` prompts
consult them on their own, and are told to say when a note disagrees with the
uploaded material rather than quietly preferring one.
## Keeping them somewhere you already sync
The notes are files, so any sync tool will do and the server does not need to
know which. Bind-mount the folder instead of using the volume:
@@ -187,19 +221,37 @@ know which. Bind-mount the folder instead of using the volume:
```
Then point Syncthing, Nextcloud, an Obsidian vault or `git` at
`/home/pi/Notizen` on one side and your phone on the other. New files are picked
up by the next crawl; nothing has to be told about them.
`/home/pi/Notizen`. New files are picked up by the next crawl; nothing has to be
told about them, and the app edits the same files.
Without any of that, `schulcloud note add` and `add_note` still work — they go
over the same authenticated `/api` as everything else the CLI does.
Frontmatter is a small YAML subset — scalars and lists, in either the inline
`[a, b]` or the indented `- item` form editors write — so an Obsidian vault
round-trips. Unknown keys are kept and ignored.
What the server reads out of a note:
| Field | Falls back to |
|---|---|
| `title` | the first `# heading`, else the filename without its date |
| `date` | a `YYYY-MM-DD` the **filename** starts with, else nothing |
| `subject` | the first folder name — except a year, which is a filing scheme |
| `tags` | none |
| `courseId` | none — set it to tie a note to a Schulcloud course in `search` |
`date` also accepts `15.09.2026`, and `fach:` works as a German spelling of
`subject:`.
**The file's modification time is never used as the date.** An import writes
every note today; dating a year of lessons "today" would make the whole store
useless for revision, which is most of what it is for.
## What the crawl does with them
A **full** crawl reads the directory and indexes every note: title, subject,
tags and body, under the kind `note`, with its path as its id. A **per-course**
refresh leaves them alone — notes belong to the account, not to a course — and
the store carries the previous generation's rows forward, so a per-course crawl
never looks like the notes were deleted.
A **full** crawl reads the directory and indexes every note — one entry per
lesson for a day note, one for the whole note otherwise, under the kind `note`.
A **per-course** refresh leaves them alone, and the store carries the previous
generation's rows forward, so a per-course crawl never looks like the notes were
deleted.
Notes are diffed by content, not by modification time, so a sync tool that
rewrites a file byte-for-byte does not show up in `what_changed` as an edit.