135 Commits
Author SHA1 Message Date
Lucas Berger f700182674 fix(auth): assign first UNUSED palette color (AUTH-03 distinct colors)
Gate 2 A3 fail: a second member (amelia) got the same color as the first (luc),
both #E8734A. Color was assigned by COUNT(*) % palette; a deleted spike user
shifted the count so two live members landed on the same slot. Replace with
'first palette color not already in use by another user' (fall back to count
round-robin only once the palette is exhausted) — guarantees distinct, stable
colors for up to palette length members. +1 regression test (deletion frees a
slot → next member fills it, no collision).
2026-06-07 18:14:20 -04:00
Lucas Berger 98753d8e34 fix(broker): reconcile deletes into cache + resync before marking outbox done
Two write-path cache bugs surfaced during Gate 2 live testing:

P1 (delete didn't work / ghost event): syncCalendar only UPSERTED events
present on Fastmail and never removed cache rows for events that disappeared.
A successful CalDAV delete left the row in calendar_events forever, so
GET /api/events kept returning it and the UI showed a ghost that 'wouldn't
delete' (even after refresh). Add a prune step: delete calendar_events rows for
this calendar whose uid is absent from the server response (scoped to cal.id so
it never touches another calendar or the other member's rows — BUG B). Empty
server result prunes the whole calendar's cache.

P2 (edit needed a manual refresh): the outbox worker marked a row 'done' BEFORE
triggerTargetedResync refreshed the cache. The PWA's SyncStateToast invalidates
['events'] the instant sync-status flips to 'done', so it refetched stale cache.
Re-sync first, then mark done — 'done' now guarantees the cache reflects the write.

Tests: +2 prune regressions (present-subset prune, empty-server prune-all).
2026-06-07 16:12:43 -04:00
Lucas Berger a99ef1daae refactor(260607-l6l): extract shared deriveDisplayName helper (BUG 2 DRY)
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.
2026-06-07 15:37:18 -04:00
Lucas Berger 509f4b26e0 test(260607-l6l): make BUG 1 join regression test couple to the handler
The original toSQL() regression test hand-built the joined query inside the
test body and asserted the SQL contained a join — tautological: it never
exercised the handler, so removing .innerJoin from events.ts left it green.

Replace it with two tests that issue real PATCH/DELETE requests against the
mocked select-chain (from → innerJoin → where) and assert the handler returns
202 (not 503) AND invokes the innerJoin spy. Verified RED: removing the
edit+delete joins fails both tests; GREEN with the joins present.
2026-06-07 15:34:39 -04:00
Lucas Berger 28704132d0 fix(260607-l6l): add missing innerJoin to PATCH+DELETE event lookups
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
2026-06-07 15:25:31 -04:00
Lucas Berger a9d3de658e fix(03): correct event-write timezone + per-user calendar identity (Gate 2 Part D)
BUG A — timed events written 4h off: EventForm sent a naive local wall-clock
string with no offset; the UTC API container parsed it via new Date() as UTC, so
09:00 America/Toronto serialized to DTSTART:...090000Z. Fix: new
apps/pwa/src/lib/eventDateTime.ts serializes timed events to an unambiguous UTC
instant in the browser (where the operator's zone is known); all-day stays a DATE
string. No backend change.

BUG B — created events attached to the wrong user's calendar + duplicate calendar
rows per poll: calendars had no unique key on url, and poller/sync matched
calendars by url alone — so under the shared single Fastmail account (D-16) one
member's collection resolved to the other member's row. Fix: composite
unique(user_id, url); scope poller lookup + sync select to (userId, url); hand
migration 0001 (dedup + add key), applied to the live DB.

Regression tests fail against the buggy url-only predicate. API 98/98, PWA 140/140,
tsc clean both packages.
2026-06-06 22:32:10 -04:00
Lucas Berger 237ec493aa feat(260606-tv8-01): add guarded GET /api/login route + tests
- 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
2026-06-06 21:35:52 -04:00
Lucas Berger 5eb26c0e6b test(03-11): RED — fresh etag re-read before PUT to avoid spurious 412 (WR-02)
- 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
2026-06-05 21:05:40 -04:00
Lucas Berger b409c09e25 feat(03-11): GREEN — durable create-before-delete gating + drain concurrency guard (CR-04, CR-05)
- CR-04: delete rows with groupId query DB for sibling create status before dispatch
  - sibling 'pending': defer delete to later cycle (leave row pending)
  - sibling 'failed'/'dead': mark delete failed permanently (original event preserved, D-04)
  - sibling 'done': dispatch delete normally
- CR-05: module-level isDraining guard; overlapping 15s cycles are no-ops
  - SINGLE-PROCESS ONLY — documented limitation for multi-replica deployments
- Fix mockFromFn to use Symbol.for('drizzle:Name') instead of JSON.stringify (circular)
- Update D-04 ordering test to queue sibling-status mock response
2026-06-05 21:01:57 -04:00
Lucas Berger 6b2cdf3683 test(03-11): RED — durable create-before-delete gating + concurrency guard (CR-04, CR-05)
- CR-04 cross-batch drain 1: sibling create 'pending' must block delete dispatch
- CR-04 cross-batch drain 2: sibling create 'done' must allow delete dispatch
- CR-04 paired-create-failed: sibling create 'failed'/'dead' marks delete failed, preserves original
- CR-05: two overlapping drain calls must invoke createCalendarEvent exactly once
2026-06-05 20:56:07 -04:00
Lucas Berger c178dcee0c test(03-10): add RED tests for CR-03 fail-closed creds + WR-01 backoff index
- 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
2026-06-05 20:50:11 -04:00
Lucas Berger 813a7ba697 test(03-10): add RED tests for ICS builder wiring + WR-04 + CR-02
- 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
2026-06-05 20:46:50 -04:00
Lucas Berger 6d1d338a45 test(03-09): add RED OIDC path tests — resolveUserId must call upsertUser (CR-06)
- 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
2026-06-05 20:40:09 -04:00
Lucas Berger 99cb1698a8 feat(03-09): rename eventFieldsSchema to canonical title/start/end contract (CR-01)
- 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
2026-06-05 20:38:27 -04:00
Lucas Berger 944693fed0 test(03-09): add RED contract tests for canonical title/start/end client payload
- 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)
2026-06-05 20:37:20 -04:00
Lucas Berger cd4a8931e5 feat(03-04): implement outbox drain state machine (GREEN)
- runOutboxDrain: drains pending outbox rows, dispatches CalDAV writes
  via broker/write.ts, classifies HTTP responses per D-07/D-08
- CONFLICT_STATUS=412 routes to conflict flow: mark failed, re-sync (D-08)
- TRANSIENT_STATUSES: exponential backoff with MAX_ATTEMPTS=5 dead-letter (D-07)
- HARD_FAIL_STATUSES 400/401/403: fail immediately, no retry (D-07)
- Edit-as-move D-04: create row sorted before delete for same groupId;
  create-fail aborts the paired delete (T-03-14)
- triggerTargetedResync: fetches fresh DAVCalendars, calls syncCalendar (D-06)
- startOutboxWorker: node-cron */15 * * * * * schedule (15s interval)
- Fix test scaffold: vi.hoisted() for mock variables to resolve vitest
  hoisting TDZ issue; simplified mock chain to match and() single .where()
2026-06-05 18:20:18 -04:00
Lucas Berger e14c5dab69 test(03-03): extend events tests RED — write/sync-status/writable-calendars endpoints
- 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)
2026-06-05 17:54:08 -04:00
Lucas Berger bbfccda756 test(03-01): add Wave 0 RED test scaffold for all Phase 3 behaviors
- vevent.test.ts: DTSTART UTC 'Z' for timed, DATE for all-day (D-13), RRULE (CAL-04/07)
- write.test.ts: createCalendarEvent uid.ics filename, updateCalendarEvent/deleteCalendarEvent
  etag/If-Match shapes (CAL-04/05/06, D-08)
- outboxWorker.test.ts: pending→done on 204, pending→failed on 412 (no retry), pending→backoff
  on 500, pending→dead at MAX_ATTEMPTS, edit-as-move create-before-delete ordering (D-04/D-07/D-08)
- events.test.ts (extended): POST /create 202+outbox row, PATCH /edit 202+etag, DELETE /:uid 202,
  GET /sync-status, GET /writable-calendars D-03 access control, 403 unauthorized calendar (V4)
- InstallPrompt.test.tsx: isIOSSafariNonStandalone UA detection, useAndroidInstallPrompt
  canInstall lifecycle (PWA-01/PWA-02)
All tests fail RED — implementation modules do not exist yet
2026-06-05 17:26:02 -04:00
Lucas Berger 194f6a82a8 fix(02): show owner name / Family in event popover footer
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.
2026-06-05 15:14:43 -04:00
Lucas Berger 1f0b9546a8 fix(02): include all-day recurring masters in events route pre-filter
- 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
2026-06-05 14:50:27 -04:00
Lucas Berger f70496871a fix(02): populate hasRrule on every sync upsert so recurring masters are flagged
- 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
2026-06-05 14:50:18 -04:00
Lucas Berger ee2281fe81 fix(02): correct DURATION-only event end in expand.ts (BUG 1)
- 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
2026-06-05 14:33:00 -04:00
Lucas Berger 93c368402c fix(02): update expand tests to assert IANA-annotated format + add cross-contract test
- 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
2026-06-05 14:04:00 -04:00
Lucas Berger df5d36308a fix(02): add regression tests for /api/me under dev-auth bypass
- 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
2026-06-05 13:48:05 -04:00
Lucas Berger 9ee26c07a7 feat(02-02): evolve /api/events to windowed endpoint with color/owner join
- 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
2026-06-05 10:30:36 -04:00
Lucas Berger 8bd44b33c7 feat(02-01): dev-auth bypass middleware with production hard guard
- 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
2026-06-05 09:32:00 -04:00
Lucas Berger 75252eb08c feat(02-01): schema columns, PWA vitest harness, ICS fixtures, RED test stubs
- 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
2026-06-05 09:29:45 -04:00
Lucas Berger 1b3ea4e99b test(01-03): add failing tests for broker poller ctag change detection
- 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)
2026-06-04 10:34:12 -04:00
Lucas Berger 90b99296e8 test(01-03): refine sync RED tests with richer db mock (select + insert)
- 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)
2026-06-04 10:30:53 -04:00
Lucas Berger ae21541e97 test(01-03): add failing tests for syncCalendar (D-13 all-day DATE handling)
- 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)
2026-06-04 10:29:29 -04:00
Lucas Berger 04d7c23e94 test(01-03): add failing tests for AES-256-GCM crypto helpers
- 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)
2026-06-04 10:26:59 -04:00
Lucas Berger 61c258c021 test(01-02): add failing tests for upsertUser color assignment + identity
- 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
2026-06-04 10:20:27 -04:00
Lucas Berger 96cda58509 feat(01-01): Drizzle schema + DB client + /health slice (GREEN)
- src/db/schema.ts: users, memberCredentials, calendars, calendarEvents tables
  - users: composite unique on oidc_iss+oidc_sub (D-10 identity)
  - calendar_events: separate dtstart_utc (TIMESTAMP) and dtstart_date (DATE) + allDay boolean (D-13)
- src/db/client.ts: drizzle(mysql2 pool) singleton export `db`
- drizzle.config.ts: dialect mysql, schema → migrations, dbCredentials from env
- src/routes/health.ts: GET / with real SELECT 1 DB round-trip, 200 or 503
- src/index.ts: Hono app with /health mounted before auth, serveStatic for PWA
- tests/health.test.ts: 2 tests pass (mocked DB); TDD GREEN gate
- apps/pwa/src/App.tsx: React shell fetching /health via TanStack Query
2026-06-04 09:52:36 -04:00
Lucas Berger f31711af27 test(01-01): add failing health test (RED gate)
- Tests GET /health returns 200 { ok, db } on success and 503 on DB error
- Fails because src/index.ts and src/routes/health.ts don't exist yet
2026-06-04 09:50:43 -04:00
Lucas Berger 3f591566d1 chore(01-01): scaffold monorepo, Docker Compose stack, and Vitest harness
- pnpm workspace with apps/api (Hono/Drizzle) and apps/pwa (Vite/React 19)
- Pinned versions per RESEARCH: hono@4.12.23, drizzle-orm@0.45.2, mysql2@3.22.4, tsdav@2.2.2, ical.js@2.2.1, zod@^3.25.0, node-cron@^4.2.1
- docker-compose.yml with mariadb:11 healthcheck, api depends_on service_healthy, redis stub
- docker-compose.dev.yml overrides for local dev (bind mounts, exposed ports)
- .env.example lists all env vars (DB_*, OIDC_*, APP_PASSWORD_ENCRYPTION_KEY)
- .gitignore excludes .env (never commit secrets)
- apps/api/vitest.config.ts with environment: node
- Wave 0 test stubs: health, auth/user, broker/crypto, broker/sync, broker/poller
2026-06-04 09:50:16 -04:00