fix(260607-l6l): scope GET /api/events to current user + shared calendars

BUG 3: GET / had no ownership predicate — it returned all users' events.
Second household member would see other member's private events.

- Resolve currentUserId at top of GET handler (same resolveUserId helper
  as write endpoints); return 401 if unauthenticated.
- Add ownership predicate to WHERE: AND (calendars.userId = currentUserId
  OR calendars.isShared = true). Combined with and() around the existing
  date-window or() block. Mirrors the /writable-calendars idiom (D-03).
This commit is contained in:
Lucas Berger
2026-06-07 15:28:54 -04:00
parent 23c8bb3402
commit 00a0454514
+33 -22
View File
@@ -126,6 +126,10 @@ const syncStatusQuerySchema = z.object({
// Response shape: { occurrences: CalendarOccurrence[] }
// ---------------------------------------------------------------------------
eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
// Resolve the current user first — only return events for owned + shared calendars (T-03-06).
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
const { start, end } = c.req.valid('query')
// --- Window span guard (T-02b-02) ---
@@ -163,32 +167,39 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
.innerJoin(users, eq(calendars.userId, users.id))
.where(
or(
// Recurring masters: may have occurrences inside the window even if dtstartUtc is old.
// Two sub-cases:
// (a) Timed recurring masters: dtstartUtc < windowEnd
// (b) All-day recurring masters: dtstartUtc is NULL (DATE-only), use dtstartDate < end
// A NULL dtstartUtc causes the timed comparison to be NULL/false, so (b) carries it.
and(
sql`${calendarEvents.hasRrule} = 1`,
or(
and(
// Ownership predicate (BUG 3 fix): restrict to calendars owned by the current user
// OR shared-family calendars (isShared=true). Mirrors the /writable-calendars idiom
// (~line 509) so both endpoints agree on the authoritative writable set (D-03).
or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)),
// Date-window pre-filter (RESEARCH.md §Open Questions 3 / Pitfall 5):
or(
// Recurring masters: may have occurrences inside the window even if dtstartUtc is old.
// Two sub-cases:
// (a) Timed recurring masters: dtstartUtc < windowEnd
// (b) All-day recurring masters: dtstartUtc is NULL (DATE-only), use dtstartDate < end
// A NULL dtstartUtc causes the timed comparison to be NULL/false, so (b) carries it.
and(
sql`${calendarEvents.hasRrule} = 1`,
or(
sql`${calendarEvents.dtstartUtc} < ${windowEndDate}`,
sql`${calendarEvents.dtstartDate} < ${end}`,
),
),
// Non-recurring timed events: dtstartUtc falls in [windowStart, windowEnd)
and(
sql`${calendarEvents.hasRrule} = 0`,
sql`${calendarEvents.dtstartUtc} IS NOT NULL`,
sql`${calendarEvents.dtstartUtc} >= ${windowStartDate}`,
sql`${calendarEvents.dtstartUtc} < ${windowEndDate}`,
),
// All-day events: dtstartDate falls in [start, end) — DATE comparison, no time component
and(
sql`${calendarEvents.dtstartDate} IS NOT NULL`,
sql`${calendarEvents.dtstartDate} >= ${start}`,
sql`${calendarEvents.dtstartDate} < ${end}`,
),
),
// Non-recurring timed events: dtstartUtc falls in [windowStart, windowEnd)
and(
sql`${calendarEvents.hasRrule} = 0`,
sql`${calendarEvents.dtstartUtc} IS NOT NULL`,
sql`${calendarEvents.dtstartUtc} >= ${windowStartDate}`,
sql`${calendarEvents.dtstartUtc} < ${windowEndDate}`,
),
// All-day events: dtstartDate falls in [start, end) — DATE comparison, no time component
and(
sql`${calendarEvents.dtstartDate} IS NOT NULL`,
sql`${calendarEvents.dtstartDate} >= ${start}`,
sql`${calendarEvents.dtstartDate} < ${end}`,
),
),
)