fix(03): CR-01 scope event edit/delete lookup to acting member's writable set

This commit is contained in:
Lucas Berger
2026-06-09 10:35:51 -04:00
parent 8b519460d9
commit 54addb1515
+32 -3
View File
@@ -308,6 +308,15 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
// Join calendarEvents → calendars so we can read calendars.url and calendars.userId // Join calendarEvents → calendars so we can read calendars.url and calendars.userId
// in the same query. Without the join, referencing calendars.* produces invalid SQL // in the same query. Without the join, referencing calendars.* produces invalid SQL
// (Drizzle throws at toSQL() time) → 503. Mirrors the GET / join idiom at line 153. // (Drizzle throws at toSQL() time) → 503. Mirrors the GET / join idiom at line 153.
//
// CR-01: calendar_events is keyed (calendarId, uid), NOT uid alone (schema.ts:121).
// With a shared Fastmail account (D-16) the SAME uid is cached once per member's
// calendar, so a uid-only lookup returns 2+ rows and an arbitrary [0] (typically the
// OTHER member's row). Scope the lookup to the acting member's writable set
// (own calendars OR shared) so the etag/objectUrl/ownership we act on belong to the
// right calendar. Order so the current user's OWN row ranks before a shared/other row
// — when both a personal and a shared copy of the uid exist, the acting member's copy
// is authoritative for the write target. limit(1) makes the pick deterministic.
const [eventRow] = await db const [eventRow] = await db
.select({ .select({
uid: calendarEvents.uid, uid: calendarEvents.uid,
@@ -319,13 +328,22 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
}) })
.from(calendarEvents) .from(calendarEvents)
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
.where(eq(calendarEvents.uid, uid)) .where(
and(
eq(calendarEvents.uid, uid),
or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)),
),
)
.orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`)
.limit(1)
if (!eventRow) { if (!eventRow) {
return c.json({ error: 'Event not found' }, 404) return c.json({ error: 'Event not found' }, 404)
} }
// Ownership check: must be the calendar owner or shared (T-03-06) // Ownership check: must be the calendar owner or shared (T-03-06).
// The WHERE above already restricts to the writable set, so any returned row is
// either the user's own calendar or a shared one — re-verify defensively.
if (eventRow.userId !== currentUserId) { if (eventRow.userId !== currentUserId) {
// Check if the calendar is shared (shared calendars are writable by all household members) // Check if the calendar is shared (shared calendars are writable by all household members)
const [calRow] = await db const [calRow] = await db
@@ -408,6 +426,10 @@ eventsRouter.delete('/:uid', async (c) => {
// Look up the event — join calendars so calendars.url / calendars.userId are accessible. // Look up the event — join calendars so calendars.url / calendars.userId are accessible.
// Same innerJoin idiom as the GET / handler (line 153). Without this join, Drizzle // Same innerJoin idiom as the GET / handler (line 153). Without this join, Drizzle
// throws at toSQL() time → 503. // throws at toSQL() time → 503.
//
// CR-01: scope to the acting member's writable set and pick deterministically — a
// shared Fastmail account (D-16) caches the same uid once per member's calendar, so a
// uid-only lookup would otherwise act on an arbitrary member's etag/objectUrl.
const [eventRow] = await db const [eventRow] = await db
.select({ .select({
uid: calendarEvents.uid, uid: calendarEvents.uid,
@@ -419,7 +441,14 @@ eventsRouter.delete('/:uid', async (c) => {
}) })
.from(calendarEvents) .from(calendarEvents)
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
.where(eq(calendarEvents.uid, uid)) .where(
and(
eq(calendarEvents.uid, uid),
or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)),
),
)
.orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`)
.limit(1)
if (!eventRow) { if (!eventRow) {
return c.json({ error: 'Event not found' }, 404) return c.json({ error: 'Event not found' }, 404)