Root-level PWA files (manifest.webmanifest, sw.js, registerSW.js, workbox-*.js,
icon-*.png, apple-touch-icon.png) were falling through to the index.html
catch-all and returning HTML — breaking the manifest (syntax error) and
preventing the service worker from ever registering. serveStatic('/*') serves
any existing file and calls next() for SPA routes, so index.html stays the
fallback. Registered after /health, /api/*, /callback so those still win.
- git rm apps/api/scripts/seed-credential.mjs (operator-only, run out-of-band;
the credential is already seeded in the running DB)
- gitignore .playwright-cli/, gate2-*.png, and the seed script path
- Register app.get('/api/login', redirect to '/') in protected-routes block
- Route placed after OIDC guard so unauthenticated nav triggers auth flow
- Add login.test.ts covering bypass and OIDC-passthrough redirect paths
- Dockerfile: build apps/pwa into the production image's ./public so the API
serves the PWA on a single port (:3000) for the Pangolin/newt tunnel
- docker-compose.yml: set NODE_ENV=production (mount OIDC unconditionally) and
constrain OIDC_SCOPES=openid profile email offline_access (Authelia rejected
the empty-default's full scopes_supported with invalid_scope)
- apps/api/scripts/seed-credential.mjs: operator tool to seed member_credentials
(encrypted Fastmail app password) out-of-band — fills the documented gap
- In update dispatch, SELECT etag FROM calendar_events WHERE uid = row.uid before PUT
- Use fresh etag as If-Match instead of stale enqueue-time row.etag when available
- Fall back to row.etag when calendarEvents has no matching row
- D-08 conflict detection intact: genuine external changes update calendarEvents.etag
differently from any pending row, so they still 412 correctly
- WR-02 fresh: update PUT must use calendarEvents.etag not stale enqueue-time etag
(fails RED: capturedEtag === 'old-etag', not 'new-etag')
- WR-02 fallback: when calendarEvents has no row, fall back to row.etag (passes in RED)
- Add mockWhereCalEvents to mock infrastructure to isolate calendarEvents selects
- Switch all beforeEach to vi.resetAllMocks() to prevent mockImplementationOnce bleed
- 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)
- Add mockDecryptPassword to vi.hoisted() so tests can control loadClientForUser behavior
- Add vi.mock for broker/crypto.js to enable CR-03 scenario
- Introduce wireMockChain() helper that differentiates credential vs outbox db selects
- CR-03 RED: credential-load failure must leave row pending, not call createFastmailClient('')
- WR-01 RED: first transient retry must use BACKOFF_SECONDS[0]=15s not BACKOFF_SECONDS[1]=60s
- Update FAKE_CRED_ROW so loadClientForUser can return a real credential-shaped row
- vevent.test.ts: D-13 form-parsed contract block — timed and all-day cases
(all-day DTEND+1 fails: emits 20260610 not 20260611)
- outboxWorker.test.ts: worker integration — create/update must pass BEGIN:VCALENDAR
to CalDAV write functions (fails: raw JSON passes through today)
- worker: unparseable payload must mark row failed (fails: marks done today)
- Update makeRow default payload to form JSON shape the worker should parse
- 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
- POST /create with valid OIDC session (devBypassInjectUser.active=false, getAuth
returns valid iss/sub) must return 202 not 401
- POST /create with no session (getAuth=null) must return 401
- Refactor getAuth/devBypass mocks to use vi.hoisted configurable flags for
per-test OIDC path isolation
- Mock upsertUser from auth/user.js so OIDC resolution can be verified
- 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 with {title,start,end,allDay,recurrence} asserts 202 (fails: server requires summary/dtstart/dtend)
- PATCH /:uid/edit with same shape asserts 202 (fails: same schema mismatch CR-01)
- 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
- Add write endpoint tests: POST /create, PATCH /:uid/edit, DELETE /:uid
- Add GET /sync-status tests (D-09 outbox polling)
- Add GET /writable-calendars tests (D-03 writable set, access control)
- Wire db.insert and db.transaction into the vi.mock for db/client.js
- Mock devAuthBypass to inject dev user in write-endpoint tests
- All 9 new tests are RED (routes not yet registered)
ICAL.Time.fromJSDate(window, false) interpreted the UTC-midnight window bounds in the
server's local TZ (America/New_York in dev), shifting the window by the server offset and
dropping evening occurrences near a day window's end (e.g. June 11 17:45-04:00 = 21:45Z was
excluded from the June-11 day view). Use UTC so the window is deterministic and correct.
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
- Use ICAL.Event.isRecurring() (parity with expand.ts) to detect RRULE/RDATE
- Add hasRrule to .values() INSERT and .onDuplicateKeyUpdate() SET so the flag
is set on first sync and self-heals on every subsequent re-sync
- Without this fix every event had has_rrule=0 (column default), causing the
events route recurring-master pre-filter to return zero recurring occurrences
- Add sync.test.ts cases: hasRrule=true for timed+all-day recurring VEVENTs,
hasRrule=false for non-recurring, and hasRrule in onDuplicateKeyUpdate.set
- Replace dtend ?? dtstart with event.endDate which handles DURATION-only VEVENTs
- Add positive-duration guard (PT30M / P1D) to both non-recurring and recurring branches
- Add single-duration.ics fixture and regression test asserting end > start for DURATION-only events
Schedule-X rejects ids containing ':' '[' ']' (the old ${uid}::${iso} form) — mint ev-<uid>-<epochMs> instead. Add an ErrorBoundary so a render throw shows the error instead of a blank page.
- Assert timed start/end strings include '[America/New_York]' bracket (not offset-only)
- Assert DST boundary offsets: -05:00[America/New_York] pre-transition, -04:00[America/New_York] post
- Add cross-contract regression test: feeds expandOccurrences output directly into
Temporal.ZonedDateTime.from() to prove the expand→hydrate contract holds end-to-end
- Import 'temporal-polyfill/global' at top of test file for the Temporal global
- Rename describe block from 'RED stubs (Wave 0)' to reflect GREEN state
Temporal.ZonedDateTime.from() rejects offset-only ISO strings such as
'2026-06-18T08:00:00-04:00'; it requires an IANA bracket, e.g.
'2026-06-18T08:00:00-04:00[America/New_York]'. serializeTime() was
emitting offset-only for named zones and bare 'Z' for UTC — both
unparseable by the frontend, blanking the calendar view.
Changes:
- Named IANA zone: emit '...±HH:MM[tzid]' using t.zone.tzid
- UTC zone: strip trailing 'Z' from toString(), emit '+00:00[UTC]'
- Floating zone (no registered VTIMEZONE): fall back to '+00:00[UTC]'
- Update CalendarOccurrence docstrings to reflect the IANA-annotated contract
- Add temporal-polyfill@0.3.2 as dev dep in api for cross-contract test
- Asserts GET /api/me returns 200 with DEV_USER (id=1, color=#4A90D9)
when DEV_AUTH_BYPASS=true and NODE_ENV!=production
- Asserts oidcAuthMiddleware is NOT wired when bypass is active
- Asserts oidcAuthMiddleware IS wired when bypass is absent
- Asserts 401 from getAuth(null) fallback path with no OIDC session
- 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
- Create apps/api/src/auth/devBypass.ts: devAuthBypass() middleware with
NODE_ENV=production hard guard as first conditional (T-02-01 mitigation)
- Exports DEV_USER const (id:1, color:COLOR_PALETTE[0]) for test reference
- Mount devAuthBypass() before oidcAuthMiddleware on /api/* in index.ts
- Add devBypass.test.ts: all three behavioral cases pass (production guard,
unset-flag passthrough, active-injection)
- Add DEV_AUTH_BYPASS to .env.example with production warning comment
- Extend docs/deployment.md with dev-auth bypass section and production prohibition
- Add calendarEvents.hasRrule boolean + idx_calendar_events_has_rrule index (Phase 2 pre-filter)
- Add calendars.isShared boolean for shared-family calendar identification
- Create apps/pwa/vitest.config.ts with jsdom environment
- Add vitest, @testing-library/react, jsdom, @testing-library/jest-dom to PWA devDependencies
- Add "test": "vitest run" script to apps/pwa/package.json
- Create three ICS fixtures: weekly-dst.ics (DST spanning), allday-birthday.ics, exdate-series.ics
- Create RED test stub expand.test.ts with concrete DST wall-clock assertions (10:00 local both sides of March 2026 boundary)
- Create RED test stub events.test.ts with 400 validation and color/isShared field contracts
- Create RED test stub hydrateEvents.test.ts with Temporal type and calendarId routing contracts (shared→"shared", personal→String(ownerUserId))
- Create RED test stub calendarConfig.test.ts with firstDayOfWeek 0→7 translation contract
- 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
- 5 tests: skip on unchanged ctag, sync on changed ctag, first-sync (null ctag), decrypt failure handled, all credentials processed
- Mocks db.select, createFastmailClient, syncCalendar, decryptPassword at module level
- RED gate: all fail (src/broker/poller.ts does not exist yet)
- Use separate mock functions for insert/select chain to enable per-test assertions
- Reset mock implementations in beforeEach after vi.clearAllMocks
- Mock returns {id:42} from select (calendar ID for event upsert)
- 6 tests: all-day DATE vs timed TIMESTAMP, allDay flag, upsert on dup UID, rawVevent blob, ctag update
- Mocks db singleton at module level with vi.mock hoisting pattern
- Uses SAMPLE_VEVENT_TIMED/ALLDAY fixtures from tests/helpers/db.ts
- RED gate: all fail (src/broker/sync.ts does not exist yet)
- encryptPassword: randomBytes(12) IV, aes-256-gcm, returns JSON {iv,authTag,ciphertext}
- decryptPassword: verifies GCM auth tag; throws on tamper
- Key from APP_PASSWORD_ENCRYPTION_KEY env (64-char hex); validated on each call
- No logging of plaintext or key
- 5 tests covering roundtrip, IV uniqueness, tamper detection, payload shape
- Dynamic import with env key set in beforeAll for module-level KEY eval
- RED gate: all fail (src/broker/crypto.ts does not exist yet)
- Replace it.todo stubs with real failing tests (RED gate)
- Tests cover palette[0] first user, palette[1] second user, idempotent
re-upsert, iss+sub identity key, full row shape
- Tests fail: src/auth/user.ts does not exist yet