fix(03): CR-01 scope event edit/delete lookup to acting member's writable set
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user