Fold the pre-deploy review findings into the calendar cutover

A fresh-context review before deploying found two things that would have
broken production, both in the runbook rather than the code.

The deploy order named only migration 003. Production has none of the three -
001 and 002 were only ever applied to the dev database - and the new API reads
the columns they add on every request, so following it literally would have
500'd every calendar call including the anonymous feed the public website
uses. Step 4 now carries a numbered checklist with a verification query.

APP_ORIGINS replaces the code's default list rather than adding to it, so
naming calendar.nachklang.art in DEFAULT_APP_ORIGINS is not enough if that
variable is set on the vhost - and its failure mode is the quiet one the
config already warns about, where everything works except sign-out. Added to
the same checklist.

Also from the review:

The two operands of the read guard on /json/next and /ical were swapped so the
password check short-circuits first. They are side-effect free, so the order
was free - but the old one put an admin-database query in front of the public
feed for any caller holding a .nachklang.art cookie, which is a dependency
that feed has never had. Two tests now assert the admin database is not
consulted at all.

/:calendar/json/next had no route-level test, despite being the endpoint the
public website actually calls and the property named as load-bearing. Covered
now, along with the rest of its credential matrix.

Migrations 001 and 002 gained IF NOT EXISTS. They are applied by hand with no
tracking table, so a partial re-run should be a no-op rather than an error
that aborts the rest of the paste. Verified by applying all three twice to a
throwaway container and diffing against the dev schema.

Swagger: two descriptions still claimed authentication was required where the
public calendar needs none, the calendar enum omitted `birthdays`, and a
`createdBy` request-body field was documented and read but never persisted -
misleading in a way that suggests a client can set authorship. Removed. The
CORS comment describing the calendar's query-parameter sessions is no longer
true and was rewritten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 22:41:49 +02:00
parent b848d6eab9
commit d960ac8e24
6 changed files with 129 additions and 40 deletions
+33 -17
View File
@@ -89,7 +89,7 @@ const signedInEditor = async (req: Request): Promise<AdminAccess | null> => {
* required: true
* schema:
* type: string
* enum: [public, members, choir, management]
* enum: [public, members, choir, management, birthdays]
* description: The name of the calendar to get events from
* - in: query
* name: password
@@ -182,7 +182,10 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
* /calendar/events/{calendar}/json/next:
* get:
* 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:
* - calendar
* parameters:
@@ -191,7 +194,7 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
* required: true
* schema:
* 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
* - in: query
* name: password
@@ -270,10 +273,19 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
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
// 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.'});
return;
}
@@ -304,7 +316,10 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
* /calendar/events/{calendar}/ical:
* get:
* 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:
* - calendar
* parameters:
@@ -313,7 +328,7 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
* required: true
* schema:
* type: string
* enum: [public, members, choir, management]
* enum: [public, members, choir, management, birthdays]
* description: The name of the calendar to get events from
* - in: query
* name: password
@@ -383,10 +398,19 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
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
// 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.'});
return;
}
@@ -630,9 +654,6 @@ eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response
* location:
* type: string
* example: "Musikhochschule, Karlsruhe"
* createdBy:
* type: string
* example: "John Doe"
* url:
* type: string
* 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),
createdDate: new Date(),
location: req.body.location ?? '',
createdBy: req.body.createdBy ?? '',
// LEGACY createdById is deliberately not set: there is no calendar
// user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id,
@@ -809,9 +829,6 @@ eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: R
* location:
* type: string
* example: "Musikhochschule, Karlsruhe"
* createdBy:
* type: string
* example: "John Doe"
* url:
* type: string
* 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),
createdDate: new Date(),
location: req.body.location ?? '',
createdBy: req.body.createdBy ?? '',
// LEGACY createdById is deliberately not set: there is no calendar
// user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id,