Read calendar event creators from the admin module, and archive the old ones #14

Merged
Paddy merged 4 commits from feature/calendar-auth-cutover into master 2026-09-06 21:12:17 +00:00
6 changed files with 129 additions and 40 deletions
Showing only changes of commit d960ac8e24 - Show all commits
+42 -7
View File
@@ -1,7 +1,12 @@
# Migrating the Calendar domain onto the admin identity module # Migrating the Calendar domain onto the admin identity module
Status: **steps 1-4 done** (2026-09-06), step 2 dropped by decision, part of step 5 brought Status: **steps 1-4 implemented 2026-09-06, not yet merged or deployed.** Step 2 dropped by
forward. Only step 5, the removal of the legacy path, is left. decision, part of step 5 brought forward. Only step 5, the removal of the legacy path, is
left to write.
> Read the deploy checklist under step 4 before applying anything. "Done" below means the
> code exists on a branch, **not** that production has it - and in particular production has
> none of the three migrations.
Written 2026-09-05 alongside the admin module (step 2 of `docs/plan-admin-auth.md` in the 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 nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan
@@ -116,11 +121,41 @@ Each step is meant to leave production working on its own.
`test/calendar/credentials.service.test.ts`, the routes themselves in `test/calendar/credentials.service.test.ts`, the routes themselves in
`test/calendar/events.router.test.ts` - so this cannot regress quietly. `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 ### Deploy checklist
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 Production has **none** of the three migrations: 001 and 002 were only ever applied to the
`NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS`, which is a **build-time** value: a rebuild, not a dev database. The API build below selects `created_by_user_id` and `created_by_name` on
restart. every read, so deploying it against a database missing them fails every calendar request
including the anonymous public feed the website uses. In order:
1. **Apply `sql/calendar/001`, `002`, `003`, in that order**, against `CALENDAR_DB`. All
three are re-runnable, so applying one that is already applied is a no-op. Verify
before continuing:
`SHOW COLUMNS FROM events LIKE '%by_user%'; SHOW COLUMNS FROM events LIKE '%by_name%';`
- four rows across the two tables, and `created_by_id` nullable.
2. **Check `APP_ORIGINS` on the API vhost.** `calendar.nachklang.art` is in the code's
default list, but the environment variable *replaces* that list rather than adding to
it - so if it is set at all (the tickets/feedback cutover may have set it), append
`https://calendar.nachklang.art` or the calendar's sign-out will 403 while everything
else works. That is the failure mode the comment in `admin.config.ts` warns about.
3. **Deploy the API.**
4. **Deploy the calendar frontend immediately after.** Do not leave a gap - see below.
5. **Re-run 002's two `UPDATE` statements.** Between step 1 and step 3 the old API was
still writing `created_by_id` with no snapshot; those few rows would otherwise lose
their author at step 5.
6. **Rebuild the admin app** if `NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS` does not already
contain `https://calendar.nachklang.art`. It is a **build-time** value, so a restart
does nothing.
**The window between steps 3 and 4 does not look broken, which is the danger.** The old
Angular bundle starts by calling `POST /calendar/users/checkSessionValid`, and those
legacy routes are untouched - so it still succeeds and the page renders as signed in. What
the user then sees is an empty event table and saves that silently do nothing. It looks
like the calendar lost its data, not like a deploy in progress. Keep the gap to minutes,
or take the frontend offline for it.
**One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather
than `?password=` stops working permanently. The shared-password URLs are unaffected.
5. **Drop the legacy path.** Remove `users.service.ts`'s session handling, the `sessions` 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 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 `legacy_*` aliases - the snapshot fallback stays, it is what makes dropping the table
+4 -4
View File
@@ -26,13 +26,13 @@
-- "Illegal mix of collations" instead of at review time. -- "Illegal mix of collations" instead of at review time.
ALTER TABLE `events` ALTER TABLE `events`
ADD COLUMN `created_by_user_id` VARCHAR(36) ADD COLUMN IF NOT EXISTS `created_by_user_id` VARCHAR(36)
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
NULL DEFAULT NULL AFTER `created_by_id`, NULL DEFAULT NULL AFTER `created_by_id`,
ADD KEY `events_created_by_user_idx` (`created_by_user_id`); ADD KEY IF NOT EXISTS `events_created_by_user_idx` (`created_by_user_id`);
ALTER TABLE `event_versions` ALTER TABLE `event_versions`
ADD COLUMN `version_created_by_user_id` VARCHAR(36) ADD COLUMN IF NOT EXISTS `version_created_by_user_id` VARCHAR(36)
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
NULL DEFAULT NULL AFTER `version_created_by_id`, NULL DEFAULT NULL AFTER `version_created_by_id`,
ADD KEY `event_versions_created_by_user_idx` (`version_created_by_user_id`); ADD KEY IF NOT EXISTS `event_versions_created_by_user_idx` (`version_created_by_user_id`);
@@ -17,19 +17,19 @@
-- everywhere. The read path prefers the live admin name, falls back to this -- everywhere. The read path prefers the live admin name, falls back to this
-- snapshot, and falls back again to the join until step 5 removes it. -- snapshot, and falls back again to the join until step 5 removes it.
-- --
-- The backfill is written to be idempotent (`WHERE ... IS NULL`) so it can be -- The whole file is re-runnable: IF NOT EXISTS on the columns, and the backfill
-- re-run. Step 4's migration does exactly that, to catch anything created -- only touches rows with no snapshot yet. Step 4's migration re-runs the
-- between this migration and the cutover. -- backfill, to catch anything created between this migration and the cutover.
-- --
-- No charset clause: unlike 001's id columns these hold display text that is -- No charset clause: unlike 001's id columns these hold display text that is
-- only ever compared against other calendar data, so they inherit the tables' -- only ever compared against other calendar data, so they inherit the tables'
-- utf8mb4_general_ci like the columns they are copied from. -- utf8mb4_general_ci like the columns they are copied from.
ALTER TABLE `events` ALTER TABLE `events`
ADD COLUMN `created_by_name` VARCHAR(255) NULL DEFAULT NULL AFTER `created_by_user_id`; ADD COLUMN IF NOT EXISTS `created_by_name` VARCHAR(255) NULL DEFAULT NULL AFTER `created_by_user_id`;
ALTER TABLE `event_versions` ALTER TABLE `event_versions`
ADD COLUMN `version_created_by_name` VARCHAR(255) NULL DEFAULT NULL AFTER `version_created_by_user_id`; ADD COLUMN IF NOT EXISTS `version_created_by_name` VARCHAR(255) NULL DEFAULT NULL AFTER `version_created_by_user_id`;
UPDATE `events` e UPDATE `events` e
JOIN `users` u ON u.user_id = e.created_by_id JOIN `users` u ON u.user_id = e.created_by_id
+8 -7
View File
@@ -63,13 +63,14 @@ export const createApp = (): express.Application => {
// the dev machine's LAN IP, never "localhost"). Dev-only, same as above. // the dev machine's LAN IP, never "localhost"). Dev-only, same as above.
const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/; const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/;
app.use(cors({ app.use(cors({
// X-Session-* are no longer read by anything on this side: the step 4 // X-Session-* are no longer read by anything on this side, and no longer
// cutover took the last two readers (feedback.auth.ts, tickets.auth.ts) // sent by anything either: the tickets and feedback cutover took the last
// off them, and the calendar module passes its session in query // two readers off them, and the calendar cutover removed the last legacy
// parameters (DEFERRED_SECURITY.md item 1). They stay allowed only so a // credential path in the API (its session used to travel in query
// browser still running the pre-cutover tickets or feedback bundle gets // parameters - DEFERRED_SECURITY.md item 1, now closed). They stay allowed
// a clean 401 rather than a CORS preflight failure. Drop them once both // only so a browser still running a pre-cutover tickets or feedback bundle
// frontends are deployed - see docs/calendar-auth-migration.md step 5. // gets a clean 401 rather than a CORS preflight failure. Drop them once
// those have aged out - see docs/calendar-auth-migration.md step 5.
allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'], allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'],
// The admin session lives in a cookie, so browsers must be allowed to send // The admin session lives in a cookie, so browsers must be allowed to send
// it cross-origin - this is what makes credentials: 'include' work. // it cross-origin - this is what makes credentials: 'include' work.
+33 -17
View File
@@ -89,7 +89,7 @@ const signedInEditor = async (req: Request): Promise<AdminAccess | null> => {
* required: true * required: true
* schema: * schema:
* type: string * type: string
* enum: [public, members, choir, management] * enum: [public, members, choir, management, birthdays]
* description: The name of the calendar to get events from * description: The name of the calendar to get events from
* - in: query * - in: query
* name: password * name: password
@@ -182,7 +182,10 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
* /calendar/events/{calendar}/json/next: * /calendar/events/{calendar}/json/next:
* get: * get:
* summary: Get the next upcoming event from a calendar * summary: Get the next upcoming event from a calendar
* description: Returns the next upcoming event from the specified calendar. Authentication required. * description: >
* The next upcoming event. The public calendar is open to everyone; the
* others need either a signed-in account with the calendar permission or the
* calendar's shared password.
* tags: * tags:
* - calendar * - calendar
* parameters: * parameters:
@@ -191,7 +194,7 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
* required: true * required: true
* schema: * schema:
* type: string * type: string
* enum: [public, members, choir, management] * enum: [public, members, choir, management, birthdays]
* description: The name of the calendar to get the next event from * description: The name of the calendar to get the next event from
* - in: query * - in: query
* name: password * name: password
@@ -270,10 +273,19 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
let calendarId: number = calendarNames.get(calendarName)!.id; let calendarId: number = calendarNames.get(calendarName)!.id;
// Signed in, or holding the calendar's shared password. The password path // Holding the calendar's shared password, or signed in. The password path
// is what keeps iCal subscriptions working - a calendar client cannot // is what keeps iCal subscriptions working - a calendar client cannot
// send a cookie. // send a cookie.
if (!await signedInEditor(req) && ! await CredentialService.hasAccess(calendarName, password)) { //
// The password is checked FIRST so that `public`, which needs no
// credential at all, short-circuits before signedInEditor runs. Otherwise
// every request from a browser that happens to hold a .nachklang.art
// cookie - which is any signed-in user on any of the four apps - would put
// an admin-database query in front of the anonymous public feed, with no
// timeout. Both operands are side-effect free, so the order is free to
// choose; this order is the one that keeps the public calendar
// independent of the admin database.
if (! await CredentialService.hasAccess(calendarName, password) && !await signedInEditor(req)) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'}); res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return; return;
} }
@@ -304,7 +316,10 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
* /calendar/events/{calendar}/ical: * /calendar/events/{calendar}/ical:
* get: * get:
* summary: Get all events from a specific calendar in iCal format * summary: Get all events from a specific calendar in iCal format
* description: Returns all events from the specified calendar in iCal format for calendar applications. Authentication required. * description: >
* The calendar in iCal format. The public calendar is open to everyone; the
* others take the calendar's shared password in the query string, which is
* why that mechanism survives - an iCal client cannot send a cookie.
* tags: * tags:
* - calendar * - calendar
* parameters: * parameters:
@@ -313,7 +328,7 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
* required: true * required: true
* schema: * schema:
* type: string * type: string
* enum: [public, members, choir, management] * enum: [public, members, choir, management, birthdays]
* description: The name of the calendar to get events from * description: The name of the calendar to get events from
* - in: query * - in: query
* name: password * name: password
@@ -383,10 +398,19 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
let calendarId: number = calendarNames.get(calendarName)!.id; let calendarId: number = calendarNames.get(calendarName)!.id;
// Signed in, or holding the calendar's shared password. The password path // Holding the calendar's shared password, or signed in. The password path
// is what keeps iCal subscriptions working - a calendar client cannot // is what keeps iCal subscriptions working - a calendar client cannot
// send a cookie. // send a cookie.
if (!await signedInEditor(req) && ! await CredentialService.hasAccess(calendarName, password)) { //
// The password is checked FIRST so that `public`, which needs no
// credential at all, short-circuits before signedInEditor runs. Otherwise
// every request from a browser that happens to hold a .nachklang.art
// cookie - which is any signed-in user on any of the four apps - would put
// an admin-database query in front of the anonymous public feed, with no
// timeout. Both operands are side-effect free, so the order is free to
// choose; this order is the one that keeps the public calendar
// independent of the admin database.
if (! await CredentialService.hasAccess(calendarName, password) && !await signedInEditor(req)) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'}); res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return; return;
} }
@@ -630,9 +654,6 @@ eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response
* location: * location:
* type: string * type: string
* example: "Musikhochschule, Karlsruhe" * example: "Musikhochschule, Karlsruhe"
* createdBy:
* type: string
* example: "John Doe"
* url: * url:
* type: string * type: string
* example: "https://www.nachklang.art/events/concert" * example: "https://www.nachklang.art/events/concert"
@@ -732,7 +753,6 @@ eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: R
endDateTime: new Date(req.body.endDateTime), endDateTime: new Date(req.body.endDateTime),
createdDate: new Date(), createdDate: new Date(),
location: req.body.location ?? '', location: req.body.location ?? '',
createdBy: req.body.createdBy ?? '',
// LEGACY createdById is deliberately not set: there is no calendar // LEGACY createdById is deliberately not set: there is no calendar
// user id any more, and migration 003 made the column nullable. // user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id, createdByUserId: admin.id,
@@ -809,9 +829,6 @@ eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: R
* location: * location:
* type: string * type: string
* example: "Musikhochschule, Karlsruhe" * example: "Musikhochschule, Karlsruhe"
* createdBy:
* type: string
* example: "John Doe"
* url: * url:
* type: string * type: string
* example: "https://www.nachklang.art/events/concert" * example: "https://www.nachklang.art/events/concert"
@@ -908,7 +925,6 @@ eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, r
endDateTime: new Date(req.body.endDateTime), endDateTime: new Date(req.body.endDateTime),
createdDate: new Date(), createdDate: new Date(),
location: req.body.location ?? '', location: req.body.location ?? '',
createdBy: req.body.createdBy ?? '',
// LEGACY createdById is deliberately not set: there is no calendar // LEGACY createdById is deliberately not set: there is no calendar
// user id any more, and migration 003 made the column nullable. // user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id, createdByUserId: admin.id,
+37
View File
@@ -70,6 +70,7 @@ beforeEach(() => {
process.env.MEMBER_CREDENTIAL = 'member-secret'; process.env.MEMBER_CREDENTIAL = 'member-secret';
(EventService.getAllEvents as any).mockResolvedValue([]); (EventService.getAllEvents as any).mockResolvedValue([]);
(EventService.getAllEventsAdmin as any).mockResolvedValue([]); (EventService.getAllEventsAdmin as any).mockResolvedValue([]);
(EventService.getNextUpcomingEvent as any).mockResolvedValue({eventId: 1, name: 'Konzert'});
(EventService.createEvent as any).mockResolvedValue(1); (EventService.createEvent as any).mockResolvedValue(1);
(EventService.updateEvent as any).mockResolvedValue(1); (EventService.updateEvent as any).mockResolvedValue(1);
(EventService.moveEvent as any).mockResolvedValue(true); (EventService.moveEvent as any).mockResolvedValue(true);
@@ -127,6 +128,42 @@ describe('reading', () => {
await request(app).get('/calendar/events/public/json').expect(200); await request(app).get('/calendar/events/public/json').expect(200);
}); });
// The endpoint www.nachklang.art actually calls for its next-event teaser.
// Tested separately from /json because it takes a different code path - it
// has no admin view and no editor branch - so covering /json proves nothing
// about it, and its failure is invisible until someone notices the website
// has gone quiet.
it('serves the next upcoming event anonymously on the public calendar', async () => {
await request(app).get('/calendar/events/public/json/next').expect(200);
// And without asking the admin database who the caller is: the public
// feed must not acquire a dependency it has never had.
expect(auth.api.getSession).not.toHaveBeenCalled();
});
it('refuses the next upcoming event on a restricted calendar without a credential', async () => {
await request(app).get('/calendar/events/members/json/next').expect(403);
});
it('serves the next upcoming event to a shared password', async () => {
await request(app)
.get('/calendar/events/members/json/next')
.query({password: 'member-secret'})
.expect(200);
});
it('serves the next upcoming event to a signed-in editor', async () => {
signedInAs(['calendar']);
await request(app).get('/calendar/events/members/json/next').expect(200);
});
it('does not consult the admin database for the anonymous public iCal export', async () => {
await request(app).get('/calendar/events/public/ical').expect(200);
expect(auth.api.getSession).not.toHaveBeenCalled();
});
it('keeps the shared password working on the iCal export', async () => { it('keeps the shared password working on the iCal export', async () => {
(EventService.getAllEvents as any).mockResolvedValue([]); (EventService.getAllEvents as any).mockResolvedValue([]);