- Immediately after access check, return 403 if patch.isShared !== undefined
and !access.isOwner — blocks sharees from mutating list_shares
- Guard message: 'Only the list owner can change sharing settings'
- Sharees may still PATCH { name } (rename test stays green)
- Update stale comment: 'Reconcile list_shares on visibility change (owner only)'
- Closes T-04-08 (elevation of privilege) and T-04-05 (shared root cause)
- Wire publishListEvent fan-out in lists.ts after every write mutation (item:added/updated/deleted, list:updated/deleted)
- Add GET /api/sse/lists scoped endpoint in sse.ts: resolveUserId → 401 on null; getAccessibleListIds → subscribe only to accessible channels; 30s heartbeat; cleanup on disconnect (D-04/T-04-01/T-04-02)
- Create useListSSE.ts: bounded-backoff EventSource wrapper (250ms→500ms→1s→2s→4s→cap 8s); MAX_ATTEMPTS=6; withCredentials:true; close-before-retry prevents reconnect storm (Pitfall 3); invalidates ['list', listId] on open (D-10) and on each event; onStateChange('disconnected') after exhaustion (D-11)
- Create LiveSyncIndicator.tsx: connected=green dot; reconnecting=pulsing muted dot + label; disconnected=red dot + 'Updates paused' (role=alert); correct ARIA per UI-SPEC
- Wire useListSSE + LiveSyncIndicator into ListDetail header; retain refetchInterval:30000 polling fallback (D-12)
- All 8 useListSSE tests pass; all 54 API tests pass; both typechecks pass
- playwright-cli: live update confirmed (eggs item added via API appeared in browser without manual refresh)
- GET /: scoped access (owner + list_shares); activeCount/doneCount per list
- POST /: auto-populates list_shares for all other members when isShared=true (D-01/D-02)
- PATCH /🆔 rename + isShared toggle; reconciles list_shares on visibility change
- DELETE /🆔 owner-only; cascade handles items/shares via FK onDelete cascade
- resolveUserId helper copied verbatim from events.ts per project convention
- zod createListSchema (name 1..255, isShared default true) + patchListSchema
- T-04-02 / T-04-05 / T-04-07 / T-04-08 mitigations applied
- listsRouter mounted at /api/lists in index.ts (after sseRouter)
- Plan 06 SSE seam comments left at every mutation handler
- [Rule 1 - Fix] zValidator returns 400 (not 422); tests corrected to match convention
- All 23 tests green; full API suite 140 passed no regressions
The two Wave-0 RED stubs (lists.test.ts, listEmitter.test.ts) were co-located in
src/ but all existing API tests live in apps/api/tests/. Move them to tests/routes/
and tests/lib/, add explicit vitest imports to match the tests/ convention, and
update path references in downstream plans 04-02..04-06. PWA tests keep co-location
(that IS the PWA convention).
- Add react-router@7, @dnd-kit/core, @dnd-kit/sortable, fractional-indexing to PWA
- Add fractional-indexing to API (rank generation server-side)
- ioredis NOT added (in-memory EventEmitter per RESEARCH Plan 02 justification)
- Create apps/api/test/setup.ts with afterEach DB cleanup for list tables
- Wire test.setupFiles in apps/api/vitest.config.ts
- Add 4 Wave-0 RED stub test files (LIST-01/02/03/04, D-04, D-11, D-07)
- All stubs run as todo, not import-error
The legend showed 'Member 972be1a3' because Authelia does not emit
name/preferred_username/email in the ID TOKEN (only at the userinfo endpoint),
and @hono/oidc-auth reads ID-token claims only. The real fix is an Authelia
claims_policy adding those claims to id_token for the familysync client.
App-side robustness so it self-heals once Authelia is fixed (no DB surgery):
- deriveDisplayName now returns null (not a synthetic 'Member <sub>') when no
real claim is present, so we never persist an ugly sub string; the UI degrades
to a generic 'Member'.
- upsertUser now tracks the IdP name authoritatively: a non-null displayName that
differs from the stored value updates the row (blank/stale 'Member …'/email →
real name on next login). A null value never overwrites a good stored name.
The displayName claim-preference logic (name → preferred_username → email →
sub fallback) was duplicated verbatim in me.ts and events.ts resolveUserId.
Extract it to auth/user.ts as deriveDisplayName and use it in both call sites,
so the rule has one definition. Update the events.test.ts user.js mock to keep
the real helper (spread importActual) while stubbing only upsertUser.
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).
BUG 2: Both me.ts and events.ts resolveUserId were passing email (often
absent) as displayName to upsertUser, resulting in blank legend names.
Also, upsertUser returned existing rows unchanged even when displayName
was null and a better value was now available.
- me.ts: derive displayName via name → preferred_username → email →
"Member <sub-prefix>" fallback, checked defensively. Updated JSDoc.
- events.ts resolveUserId: same derivation so write-path upserts don't
re-blank a correctly-set displayName.
- user.ts: when existing row has null displayName and caller supplies one,
issue an UPDATE so already-existing blank rows are corrected on next login.
Authelia-side emission of name/preferred_username is an operator concern
(claim mappings / userinfo scope in authelia config) — out of scope here.
The code now reads whatever claims are present and falls back sensibly.
BUG 1: PATCH /:uid/edit and DELETE /:uid selected calendars.url/userId
from .from(calendarEvents) with no join, causing Drizzle to throw at
toSQL() time → 503. Added .innerJoin(calendars, ...) to both lookups,
mirroring the working GET / join idiom.
- Updated PATCH + DELETE beforeEach mocks to route through innerJoin→where
- Updated CR-01 PATCH test mock similarly
- Added regression: edit/delete lookups join calendars describe block with
toSQL() assertions using vi.importActual (real drizzle, no DB needed)
- All 21 tests pass; typecheck clean
- outboxWorker: remove empty-credential fallback; let loadClientForUser throw on error (CR-03)
- outboxWorker: fix backoff index from nextAttemptCount to row.attemptCount so first retry waits 15s not 60s (WR-01)
- events.ts: replace bare crypto.randomUUID() with import { randomUUID } from 'node:crypto' on all three handlers (WR-08)
- Import upsertUser from auth/user.js
- resolveUserId now async: dev-bypass path unchanged; OIDC path calls getAuth
then upsertUser(iss, sub, email) to resolve DB user id
- All 5 handlers (create, edit, delete, sync-status, writable-calendars) updated
to await resolveUserId and 401 only when it returns null
- Remove all inline 'For now return 401' stubs and redundant getAuth calls
- grep confirms 0 'For now return 401' stubs remain; upsertUser imported+called
- Replace summary→title, dtstart→start, dtend→end in eventFieldsSchema
- Server now accepts exact CreateEventPayload shape the PWA sends
- Update existing write tests to use new canonical field names
- No internal rename map; one canonical name set end-to-end
- grep confirms no summary/dtstart/dtend in eventFieldsSchema
- POST /create: validates with zod, checks calendar ownership (D-03/T-03-06), enqueues pending outbox row, returns 202 with uid
- PATCH /:uid/edit: looks up event, checks ownership, enqueues update row; uses db.transaction for edit-as-move calendar pair (D-04)
- DELETE /:uid: looks up event, checks ownership, enqueues delete row with server-side etag (T-03-10)
- GET /sync-status: returns outbox status scoped to currentUser only (T-03-07/D-09)
- GET /writable-calendars: returns own personal + shared calendars, never other member's personal (D-03/T-03-11)
- Auth via dev-bypass (c.get('user')) + getAuth(c) fallback; 401 if neither
- No tsdav import — broker boundary enforced (D-12)
- All 69 events tests GREEN; tsc --noEmit clean
Backend:
- expand.ts: add ownerName: string | null to CalendarOccurrence
interface and expandOccurrences() signature; thread it onto every
emitted occurrence.
- events.ts: SELECT users.displayName as ownerName in the join; pass
it to expandOccurrences().
Frontend:
- client.ts: add ownerName: string | null to CalendarOccurrence.
- EventDetailPopover.tsx: render isShared ? 'Family' :
(ownerName ?? calendarName) in the footer instead of calendarName.
Tests:
- expand.test.ts: pass ownerName to all expandOccurrences() calls;
assert ownerName is carried onto occurrences in the DST test.
- events.test.ts: add ownerName to mock rows; assert ownerName present
on occurrences; add ownerName assertion to timed-recurring test.
- EventDetailPopover.test.tsx: add ownerName to fixtures; split
"calendar name in footer" into three targeted tests covering
personal-with-owner, shared→Family, and null-owner fallback.
- Old filter: hasRrule=1 AND dtstartUtc < windowEnd
All-day recurring masters have dtstartUtc=NULL so the comparison evaluates
to NULL/false — 11 such rows in live cache were never returned
- New filter: hasRrule=1 AND (dtstartUtc < windowEnd OR dtstartDate < end)
The OR covers all-day masters whose only date column is dtstartDate (DATE)
- expandOccurrences already does precise per-occurrence window checks, so
over-selecting a master on the DATE path is safe
- Extend events.test.ts: assert timed recurring master (dtstart 2024) returns
occurrences in 2026 window; assert all-day recurring master (dtstartDate 2024,
dtstartUtc NULL) returns its 2026-06-15 occurrence
- index.ts: compute devBypassActive at startup; skip app.use(oidcAuthMiddleware)
entirely when active so the OIDC guard never runs in local dev
- routes/me.ts: read c.get('user') first; return dev identity directly when
devAuthBypass injected it, bypassing getAuth() and the DB upsert
- auth/devBypass.ts: add ContextVariableMap augmentation for 'user' key;
correct stale comment that claimed getAuth/401 path was still active
- zValidator enforces YYYY-MM-DD regex on start/end (T-02b-01)
- 90-day window cap prevents DoS (T-02b-02)
- innerJoin calendarEvents→calendars→users for color + isShared + ownerUserId
- SQL pre-filter includes hasRrule=true rows regardless of dtstartUtc range
- expandOccurrences() called per row; shared calendar uses #F25C7A rose color
- events.test.ts: added @hono/oidc-auth mock; 4/4 assertions green
- Mount /api/events, /api/sse in index.ts behind oidcAuthMiddleware; /callback + /health before guard
- Call startBrokerPoller() on boot (5-min ctag-poll background schedule)
- Add sseRouter with GET /heartbeat (streamSSE, 10s interval) for Pangolin SSE smoke test (D-08, T-04-01)
- Add CAL-08 spike script (broker/spike.ts): createFastmailClient → fetchCalendars → print calendar URLs
- Add fetchEvents() to pwa/api/client.ts with typed CalendarEvent/EventsResponse shapes
- Add EventProof.tsx: React Query ['events'], renders first event title+date or empty-state (CAL-01 broker proof)
- Update App.tsx to render MemberBadge + EventProof on landing page
- Add ical.js@2.2.1 to PWA dependencies for VEVENT summary parsing in EventProof
- All 24 API unit tests green; tsc --noEmit clean in both apps/api and apps/pwa