# Rolling out the notes app A runbook for putting the notes feature and the `/app` web app onto a deployment that is already running. [PI.md](PI.md) is the first-time setup; this is the upgrade, and it assumes the server is live and healthy. Read [NOTES.md](NOTES.md) first if you have not: **step 2 is a decision you should not make twice**, because changing it later means moving files by hand. ## What arrives | | | |---|---| | **Your own lesson notes** | A directory of Markdown files the server reads, indexes and searches beside Schulcloud and WebUntis. Three tools: `list_notes`, `get_note`, `add_note`. | | **The app at `/app`** | A login, a day-at-a-time notes editor with a formatting toolbar, and a settings page that replaces the Schulcloud token. Only served when `WEB_PASSWORD` is set. | | **The WebUntis class register** | `untis_lesson_topics` now takes a subject as well as a period id, and `UNTIS_HISTORY_DAYS` of "what was actually taught" goes into the search index. | Nothing here changes Schulcloud or WebUntis: both stay read-only. The notes directory is the only thing this server writes to, and it is yours. ## Before you start - **Ten minutes**, plus however long a full crawl takes (the first one with a class register adds a few dozen WebUntis requests; it runs in the background). - **No Caddy change.** `/app` is served by the same container on the same port, and the snippet already proxies everything. One line was added to `deploy/Caddyfile.snippet` as a comment about `X-Forwarded-Proto`; if your site block predates it, check that nothing strips that header — the session cookie's `Secure` flag depends on it. - **No database migration.** Notes and class-register entries are new node kinds in a column that is plain `TEXT`. - **No compose change on the Pi.** `deploy/docker-compose.pi.yml` overrides only the image, the database URL and the network, so the `notes` volume and `NOTES_DIR=/data/notes` come from `docker-compose.yml` unchanged. ## 1. Publish the image On a development machine, from a clean checkout of `main`: ```bash npm run publish-image ``` It builds arm64 and amd64 and pushes `latest` plus the commit id. If you pin `SCHULCLOUD_MCP_TAG`, note the short commit it prints — you need it in step 3. ## 2. Decide where the notes live **Do this before anything writes a note.** Both options work; moving between them afterwards means moving files. **A — the volume (default, nothing to do).** `docker-compose.yml` already declares a `notes` volume at `/data/notes`. The app and the CLI write to it, `add_note` writes to it, and that is the whole story. Choose this if you will write notes in the app and nowhere else. **B — a directory you sync.** Choose this to *also* write notes from a phone or a laptop in an editor — Obsidian, iA Writer, a git repo. Edit `docker-compose.yml` on the Pi: ```yaml # under schulcloud-mcp: volumes: - mirror:/data/mirror - state:/data/state - /home/pi/Notizen:/data/notes # was: notes:/data/notes ``` ```bash mkdir -p /home/pi/Notizen ``` The container runs unprivileged and `read_only`, so that directory must be writable by the container's user — if the logs show `EACCES` for `/data/notes`, see Troubleshooting. Then point Syncthing, Nextcloud or `git` at it. The server does not care which; new files are picked up by the next full crawl. ## 3. Configure On the Pi, in `/opt/schulcloud-mcp`: ```bash cd /opt/schulcloud-mcp git pull ``` Add the app password to `.env`. It is the only credential here a person types, so it is a passphrase rather than a token — **at least 12 characters, and three or four words is the right shape**: ```bash cat >> .env <<'EOF' # --- the notes app --- WEB_PASSWORD=change-this-to-three-or-four-words EOF ``` Optional, on the same pass: | Setting | | |---|---| | `UNTIS_HISTORY_DAYS` | How far back to index the class register. Default 180; `0` turns it off. Only does anything with `UNTIS_*` configured. | | `NOTES_READONLY=1` | Refuse every write. `list_notes` and `get_note` still work, `add_note` and the app's save do not. Right when the notes are synced in and should have exactly one writer. | | `SCHULCLOUD_MCP_TAG` | If you pin images, set it to the commit from step 1. | **Put `WEB_PASSWORD` in your password manager now.** The server never prints it, and changing it logs out every session. ## 4. Start it ```bash docker compose pull docker compose up -d docker compose logs --tail 20 schulcloud-mcp ``` The startup line names what is on. With everything configured it ends `… keepalive every 30min, index every 6h`; the app and the notes are not named there, so verify them in the next step rather than reading the log for them. ## 5. Verify From anywhere: ```bash curl -s https://mcp.example.org/healthz # {"status":"ok","sessions":0,"index":"on"} curl -s -o /dev/null -w '%{http_code}\n' https://mcp.example.org/app/ # 200 ← the app is served; 404 means WEB_PASSWORD is not set ``` Then open `https://mcp.example.org/app/` in a browser and log in. - **Notizen** should show today, and — if WebUntis is configured — today's lessons as headings, with times, teacher and room. "Kein Unterricht an diesem Tag" on a company-phase week or a weekend is correct, not a fault. - Type a line and wait two seconds. The status line should read **Gespeichert HH:MM**. - **Einstellungen** should show the Schulcloud token's remaining days and the index's state. On a phone, add it to the home screen — it has a manifest and opens standalone. Check the file landed where you meant it to: ```bash docker compose exec schulcloud-mcp ls -R /data/notes # 2026/2026-09-19.md ``` ## 6. Bring the old notes in If you have notes in Apple Notes, migrate them now — see [NOTES.md](NOTES.md#migrating-out-of-apple-notes). Briefly, on the Mac: ```bash osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson schulcloud note import notes.ndjson --dry-run # look first schulcloud note import notes.ndjson ``` Import **once**. Re-running creates second copies, because the importer cannot tell an edited note from a new one with the same title. ## 7. Index them Notes and the class register are only read by a **full** crawl. One runs on the timer (`CRAWL_INTERVAL_MS`, six hours by default), or force one now: ```bash schulcloud refresh --force ``` Expect it to take minutes; the CLI polls and prints progress. When it finishes: ```bash schulcloud status ``` Then ask Claude something only the new sources can answer — *"what did I write down in Deutsch last week?"* or *"what did we actually cover in LF07 this term?"* — and check the answer names your note or the class register as its source. ## Backups, which now matter more **The notes are the only irreplaceable thing this server holds.** Everything else it stores is a copy of something upstream; a note you took in a lesson is not, and nothing can rebuild it. | What | Needed? | |---|---| | `.env` | **Yes** — every secret, `WEB_PASSWORD` included. Encrypted only. | | **`schulcloud-mcp_notes`** (option A) | **Yes. Nothing can regenerate these.** | | **Your synced directory** (option B) | **Yes**, unless the sync tool already keeps versioned copies elsewhere — and check that it does, rather than assuming. | | Postgres | Optional: a crawl rebuilds it. | | `schulcloud-mcp_mirror` | No — re-downloaded by the next crawl. | | `schulcloud-mcp_state` | No — a replaced token, expiring within 30 days anyway. | With the volume (option A): ```bash docker run --rm -v schulcloud-mcp_notes:/notes:ro -v "$PWD":/out alpine \ tar czf /out/notizen-$(date +%F).tar.gz -C /notes . ``` They are small — a school year of notes is a few megabytes — so back them up often and keep the old copies. ## Rolling back The feature adds no migration and no incompatible state, so going back is the ordinary downgrade: ```bash # in .env SCHULCLOUD_MCP_TAG= ``` ```bash docker compose pull && docker compose up -d ``` The old image ignores `WEB_PASSWORD` and `NOTES_DIR` and serves no `/app`. **The notes volume is untouched** — the files stay, and the newer image picks them up again unchanged. The only thing lost while rolled back is the ability to read or write them. ## Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| | `/app/` answers 404 | `WEB_PASSWORD` not set, or the container not recreated since it was | `grep ^WEB_PASSWORD= .env`, then `docker compose up -d --force-recreate schulcloud-mcp` | | The server refuses to start, log names `WEB_PASSWORD` | Shorter than 12 characters | Use a longer passphrase | | Login says the password is wrong, and it is not | `.env` is read at container creation | `docker compose up -d --force-recreate schulcloud-mcp` | | Logged out constantly, or the login "does nothing" | The session cookie is marked `Secure` and the connection is not HTTPS, or `X-Forwarded-Proto` is stripped | Reach it over HTTPS; check the Caddy site does not strip that header | | `Zu viele Fehlversuche` | The per-address rate limiter, eight failures in fifteen minutes | Wait it out; it is doing its job | | The editor says the server keeps no notes | `NOTES_DIR` unset on the server | It is set by `docker-compose.yml`; check `COMPOSE_FILE` in `.env` still lists it first | | `Der Server nimmt keine Änderungen an` | `NOTES_READONLY` is on | Remove it and recreate the container | | Saves refused as a conflict, repeatedly | The note is being changed elsewhere — a sync tool, another device | Choose a version in the banner; if a sync tool keeps rewriting the file, it is fighting the app | | `EACCES` for `/data/notes` in the logs | A bind-mounted directory the container's user cannot write | `sudo chown -R 1000:1000 /home/pi/Notizen` (match the image's user), then recreate | | The toolbar is there, the text stays plain | The browser blocked `editor.js` or `markdown.js` | Check the console; both must be served from `/app/`, and `app.js` must load as `type="module"` | | A note opens in the Markdown view by itself, with a hint | It holds formatting the formatted view cannot keep unchanged | Nothing is wrong and nothing was lost; edit it there, or simplify the note | | Notes exist but `search` cannot find them | Only a full crawl reads them | `schulcloud refresh --force` | | `search` finds a day note but names no subject | The lesson headings were rewritten past recognition | Keep `## 1. Deutsch …`; the leading number and the subject are what the index reads | | `untis_lesson_topics` with a subject finds nothing | The subject code differs from what you typed | Check it against `untis_timetable`; the register uses the school's own codes | ## Security notes for this rollout - `WEB_PASSWORD` is the first credential here that a human types, so the first that can be guessed. It is hashed with scrypt at startup and never stored, compared or logged in the clear, and failed logins are rate-limited per address — but **length is what actually protects it**. - The session cookie opens `/api`, which can read your coursework and replace the Schulcloud token. It does not open `/mcp`. Treat a login on a shared device as you would treat the token. - Changing `WEB_PASSWORD` invalidates every session, because the signing key is derived from it. That is the revocation mechanism: change it, recreate the container, log in again. - The write surface is bounded to `NOTES_DIR` by `safeComponent` and `resolveWithin` — the same two functions that stop a hostile Schulcloud filename escaping the file mirror. Schulcloud and WebUntis remain read-only.