Move the calendar onto the shared session cookie

Step 4 of docs/calendar-auth-migration.md, and the close of
DEFERRED_SECURITY.md item 1: no calendar route reads sessionId/sessionKey from
the query string any more, so a live credential no longer travels through
access logs, browser history and Referer headers.

The four write routes sit behind requireAppAccess('calendar'), which also
narrows who may edit from "any activated @nachklang.art account" to an
explicit per-user permission. They answer 401 signed out and 403 without the
permission, where they previously answered 403 for both.

The three read routes cannot use the middleware: one URL serves an anonymous
visitor, an iCal subscription holding a shared password, and a signed-in
editor who should see drafts. They resolve the session optionally instead, and
a signed-in user without the calendar permission is treated as anonymous
rather than refused - so they keep the public calendar access anyone has.

That public calendar staying anonymous is load-bearing: nachklang.art reads it
to show the next upcoming event. It is now pinned at both the password-table
and the route level, and so is the rule that a shared password can never be
used to write.

credentials.service.ts loses its session half and becomes the password table
it always wanted to be. The shared passwords survive only for iCal clients,
which cannot send a cookie.

Writes record the author as an admin user id and no longer have a legacy int
to write, which is what migration 003 makes room for.

/calendar/users/* is left in place: nothing calls it and a session it mints
opens nothing, but they are still live password-accepting endpoints, so
removing them belongs with the rest of the legacy path in step 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 22:23:36 +02:00
parent 61d3883479
commit b848d6eab9
12 changed files with 540 additions and 309 deletions
+42 -16
View File
@@ -1,7 +1,7 @@
# Migrating the Calendar domain onto the admin identity module
Status: **steps 1 and 3 done** (2026-09-06), step 2 dropped by decision, part of step 5
brought forward, step 4 next.
Status: **steps 1-4 done** (2026-09-06), step 2 dropped by decision, part of step 5 brought
forward. Only step 5, the removal of the legacy path, is left.
Written 2026-09-05 alongside the admin module (step 2 of `docs/plan-admin-auth.md` in the
nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan
@@ -77,24 +77,50 @@ Each step is meant to leave production working on its own.
**Writes are not dual-written**, contrary to the original plan: before the cutover the
request only ever carries a legacy session, so there is no admin id available to write.
Writes start filling `created_by_user_id` (and stop filling `created_by_id`) in step 4.
4. **Switch the routes.** Replace the query-parameter session checks in
`events.router.ts` and `users.router.ts` with `requireAppAccess('calendar')`, and change
the Angular frontend to `withCredentials: true` against the same origin list. Deploy the
API first; the calendar frontend is broken between the two deploys, so pick a quiet
time. This closes `DEFERRED_SECURITY.md` item 1.
4. **Switch the routes.** ~~Replace the query-parameter session checks in `events.router.ts`
and `users.router.ts` with `requireAppAccess('calendar')`, and change the Angular frontend
to `withCredentials: true`.~~ **Done 2026-09-06.** `DEFERRED_SECURITY.md` item 1 is closed:
no route reads `sessionId`/`sessionKey` any more.
Two things this step has to carry that the original sequence put in step 5:
How it came out, route by route:
- **`sql/calendar/003_*.sql` must make `events.created_by_id` nullable** (`MODIFY
created_by_id INT NULL`). It is `NOT NULL` today, so the first event created after the
- The four write routes sit behind `requireAppAccess('calendar')` as middleware. They
answer 401 when signed out and 403 without the permission, where they used to answer 403
for both.
- The three read routes cannot use middleware - the same URL serves an anonymous visitor,
an iCal subscription holding a shared password, and a signed-in editor who should see
drafts. They call `resolveAccess` optionally instead (`signedInEditor` in the router),
and a signed-in user *without* the calendar permission is treated as anonymous rather
than refused, so they keep their access to the public calendar.
- `credentials.service.ts` lost its session half entirely and is now just the password
table. `hasAccess(calendar, password)`.
- `/calendar/users/*` was left alone. Nothing calls it and a session it mints opens
nothing, but they are live password-accepting endpoints - step 5 removes them.
Also: `calendar.nachklang.art` joined `DEFAULT_APP_ORIGINS` (better-auth `trustedOrigins`,
without which sign-out from the calendar fails while everything else works), and
`localhost:4200` joined the dev origins for the same reason.
Two things this step had to carry that the original sequence put in step 5:
- **`sql/calendar/003_allow_null_legacy_creator.sql` makes `events.created_by_id` nullable**
(`MODIFY created_by_id INT NULL`). It is `NOT NULL` today, so the first event created after the
cutover would otherwise fail to insert - there is no legacy int id to write any more.
`event_versions.version_created_by_id` is already nullable. The foreign key can stay
until step 5; it permits NULL. The same migration should re-run 002's idempotent
name backfill, to catch anything created between the two deploys.
- **The public calendar must stay anonymous.** `hasAccess('public')` returns true before
any credential check, and nachklang.art reads `/calendar/events/public/json` and
`/public/json/next` with no session at all. Pinned by
`test/calendar/credentials.service.test.ts` so this cannot regress quietly.
until step 5; it permits NULL. It also re-runs 002's idempotent name backfill, to catch
anything created between the two migrations. Applying it early is safe - widening a
column to accept NULL cannot break the running pre-cutover build.
- **The public calendar stays anonymous.** `hasAccess('public')` returns true before any
credential check, and nachklang.art reads `/calendar/events/public/json` and
`/public/json/next` with no session at all. Pinned at both levels - the password table in
`test/calendar/credentials.service.test.ts`, the routes themselves in
`test/calendar/events.router.test.ts` - so this cannot regress quietly.
**Deploy order:** migration 003, then the API, then the calendar frontend. The frontend is
broken between the last two (its old bundle sends query credentials the new API ignores),
so pick a quiet moment. Production also needs `calendar.nachklang.art` in the admin app's
`NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS`, which is a **build-time** value: a rebuild, not a
restart.
5. **Drop the legacy path.** Remove `users.service.ts`'s session handling, the `sessions`
table, `created_by_id`, and the legacy half of the step 3 read (the `users` join and its
`legacy_*` aliases - the snapshot fallback stays, it is what makes dropping the table