Commit Graph
230 Commits
Author SHA1 Message Date
Lucas Berger 8b5ec797cf chore: merge executor worktree (worktree-agent-a62cd2d02a9defd0a) 2026-06-07 15:31:31 -04:00
Lucas Berger 00a0454514 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).
2026-06-07 15:28:54 -04:00
Lucas Berger 23c8bb3402 fix(260607-l6l): derive displayName from OIDC claims in me.ts + resolveUserId
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.
2026-06-07 15:27:40 -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 4e174e5b44 docs(260607-l6l): pre-dispatch plan for write-path correctness bugs 2026-06-07 15:18:38 -04:00
Lucas Berger 3ae7724df1 wip: phase 03 Gate 2 paused — live bring-up working; edit/delete join fix is the next blocker 2026-06-06 22:42:06 -04:00
Lucas Berger bdbb9b8324 docs(debug): resolve write-path-event-bugs (timezone + calendar identity) 2026-06-06 22:32:10 -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 505f64ed93 docs: add backlog item 999.2 — slick unauthenticated-entry (no login flash) 2026-06-06 22:11:33 -04:00
Lucas Berger 431ab31fb8 fix(api): serve the full ./public tree, not just /assets/*
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.
2026-06-06 21:57:42 -04:00
Lucas Berger 1adb460412 fix(pwa): fetchMe uses redirect:manual so unauthenticated /api/me can't hang
With the default redirect:follow, the browser follows the OIDC guard's 302 to
Authelia (cross-origin, credentialed) and the fetch HANGS — meQuery stays
'loading' so the SPA spins forever and the isError-driven login redirect never
fires. redirect:manual surfaces the 302 as an opaqueredirect (status 0) that we
detect as auth-required and throw, letting CalendarShell navigate to /api/login.
+4 fetchMe tests.
2026-06-06 21:57:42 -04:00
Lucas Berger 874f23de2f docs(quick-260606-tv8): sign-in redirect fix — summary + state 2026-06-06 21:42:58 -04:00
Lucas Berger 76db63458e chore: remove operator seed script + gitignore local verification artifacts
- 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
2026-06-06 21:42:29 -04:00
Lucas Berger 7c6531f4d0 chore: merge executor worktree (worktree-agent-abd829a63c8f5dade) 2026-06-06 21:40:46 -04:00
Lucas Berger c2e0ab1b1f feat(260606-tv8-01): wire login redirect into CalendarShell meQuery handling
- Import maybeRedirectToLogin + clearLoginRedirect from loginRedirect.ts
- useEffect on meQuery.isError calls maybeRedirectToLogin() (one-shot, loop-guarded)
- useEffect on meQuery.isSuccess calls clearLoginRedirect() for future re-auth
- Existing 'Sign-in required' branch retained as fall-through for already-attempted case
2026-06-06 21:38:38 -04:00
Lucas Berger 6dc9ccd2e9 feat(260606-tv8-01): add one-shot login-redirect helper + tests; fix client.ts comment
- Add loginRedirect.ts: maybeRedirectToLogin (sessionStorage one-shot guard) and
  clearLoginRedirect; guards window/sessionStorage for SSR/test safety
- Add loginRedirect.test.ts: covers first-call redirect, one-shot no-op, clear+retry
- Update client.ts: remove false claim that fetch follows Authelia 302 automatically;
  note that XHR/fetch CORS-blocks cross-origin redirects, top-level nav required
2026-06-06 21:37:14 -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 b788ffc93e docs(260606-tv8): pre-dispatch plan for sign-in redirect fix 2026-06-06 21:33:35 -04:00
Lucas Berger b46b25b26b chore(03): Gate 2 stack bring-up — serve PWA from API image, prod env, credential seed
- 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
2026-06-06 21:30:58 -04:00
Lucas Berger ca87c023ef wip: phase 03 paused — gap-closure done, docker bring-up awaiting auth+PWA decisions 2026-06-05 21:48:17 -04:00
Lucas Berger b3eff3c677 docs(03): record gap-closure completion (03-09..03-12) in STATE + ROADMAP 2026-06-05 21:10:26 -04:00
Lucas Berger 0e4a263ce6 merge(03-11): outbox durability + concurrency + etag re-read (CR-04/05, WR-02) 2026-06-05 21:08:27 -04:00
Lucas Berger ad181e7b2a docs(03-11): complete outbox durability + etag fix plan summary (CR-04, CR-05, WR-02) 2026-06-05 21:08:07 -04:00
Lucas Berger 09fd1f2e92 feat(03-11): GREEN — re-read freshest calendarEvents etag before update PUT (WR-02)
- 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
2026-06-05 21:06:32 -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 416e813e40 merge(03-10): outbox real VEVENT dispatch + fail-closed creds (CR-02/03, WR-01/04/08) 2026-06-05 20:53:25 -04:00
Lucas Berger aefdde13bd docs(03-10): complete outbox ICS builder wiring plan summary
CR-02, CR-03, WR-01, WR-04, WR-08, IN-01 closed.
2026-06-05 20:53:04 -04:00
Lucas Berger c21b040b36 feat(03-10): fail closed on bad credentials + fix backoff index + explicit randomUUID (CR-03, WR-01, WR-08)
- 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)
2026-06-05 20:51:48 -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 c03b47938e feat(03-10): wire buildVeventString into dispatch path + fix all-day DTEND+1 (CR-02, WR-04)
- outboxWorker: parse stored form JSON, build VCALENDAR via buildVeventString for create/update
- outboxWorker: return hardFail on payload parse error (corrupt payload never self-resolves)
- outboxWorker: import buildVeventString and RRULE_PRESETS from vevent.js
- vevent.ts: advance all-day DTEND by +1 calendar day (RFC-5545 exclusive end, WR-04 owning boundary)
2026-06-05 20:47:50 -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 1fc56f42d0 merge(03-12): EventForm edit/a11y gap closure (WR-03/05/07, IN-03) 2026-06-05 20:43:45 -04:00
Lucas Berger 578315c8dd merge(03-09): route-layer reachability gap closure (CR-01, CR-06) 2026-06-05 20:43:45 -04:00
Lucas Berger a30a16b18a docs(03-12): complete EventForm gap-closure plan — WR-03/05/07, IN-03/04, PWA assets 2026-06-05 20:43:12 -04:00
Lucas Berger 0d3883ee02 docs(03-09): complete plan 09 summary — schema rename + OIDC resolution
CR-01: eventFieldsSchema aligned to canonical title/start/end (PWA contract)
CR-06: resolveUserId async with upsertUser on all 5 write handlers
19 tests pass; tsc clean
2026-06-05 20:42:13 -04:00
Lucas Berger e971e16cc6 feat(03-12): GREEN — WR-07 real focus trap on EventForm dialog
Add Tab/Shift+Tab focus trap to the dialog element:
- onKeyDown handler queries all focusable elements inside dialogRef
- Tab from last element wraps to first (preventDefault)
- Shift+Tab from first element wraps to last (preventDefault)
- No new dependency — implemented inline with dialogRef
- Existing focus-on-open (titleRef) and Escape-to-close unchanged
- Update docblock: focus trap claim is now accurate (WR-07)
2026-06-05 20:41:28 -04:00
Lucas Berger fac3a21332 feat(03-09): convert resolveUserId to async — real OIDC iss/sub→users.id via upsertUser (CR-06)
- 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
2026-06-05 20:41:20 -04:00
Lucas Berger 4244e8cd29 test(03-12): RED — WR-07 focus trap Tab/Shift+Tab cycle tests
Add two failing tests for the focus trap:
- Tab from last focusable element must wrap to first inside dialog
- Shift+Tab from first focusable element must wrap to last inside dialog

Both fail today because EventForm only calls .focus() once on open;
Tab escapes the modal to background content.
2026-06-05 20:40:40 -04:00
Lucas Berger f0f1361fba feat(03-12): GREEN — WR-03 blank edit, WR-03 recurrence, WR-05 zone-consistent, IN-03
WR-03 blank: add occurrence?.uid to reset effect deps so form re-populates
when occurrence resolves in TanStack cache after form opens.

WR-03 recurrence: derive initial recurrence from occurrence?.recurrence
instead of hard-coding 'none'; defaults to 'none' when absent (v1 comment).

WR-05: rewrite parseDateTime to use getFullYear/getMonth/getDate/getHours/
getMinutes (all local accessors) — never mix toISOString() UTC date with
getHours() local time.

IN-03: export todayIso from calendarStore (was private); import into EventForm
and collapse getDefaultStartDate/getDefaultEndDate to todayIso() calls.
2026-06-05 20:40:11 -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 02e312acdc test(03-12): RED — WR-03 blank edit, WR-03 recurrence, WR-05 zone, IN-03 export
- WR-03 blank: assert title re-populates when occurrence arrives in TanStack cache after form opens (fails: reset effect ignores occurrence in deps)
- WR-03 recurrence: assert weekly recurring event preselects 'weekly' not 'none' (fails: reset effect hard-codes 'none')
- IN-03: assert todayIso is exported from calendarStore (fails: currently private)
- WR-05: zone-consistent parseDateTime test with TZ=UTC pinned in vitest.config.ts env block
- Pin TZ=UTC in vitest.config.ts for deterministic date-extraction assertions
2026-06-05 20:38:18 -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 941c4d621f docs(03): revise gap plans 03-10/11/12 per checker feedback 2026-06-05 19:27:19 -04:00
Lucas Berger d1658bd1db docs(03): gap-closure plans 03-09..03-12 for write-path review findings 2026-06-05 19:20:25 -04:00
Lucas Berger 628894c8c2 docs(03): add code review report 2026-06-05 19:11:12 -04:00
Lucas Berger 1f8775e7b6 docs(03-08): scaffold Gate 2 results + record production build status
- PWA build: CLEAN — 1818 modules, sw.js + workbox generated (build SHA 40dfbb4)
- API build: CLEAN — tsc passed, no errors
- 03-GATE2-RESULTS.md created with deploy header (URL TBD), full Gate 2 checklist
  (all rows marked PENDING — operator/device), and operator-setup section covering:
  Authelia OIDC client registration, OIDC_AUTH_EXTERNAL_URL, Pangolin/Newt Mode A rig,
  DB schema push, and /health tunnel verification
2026-06-05 18:51:37 -04:00
Lucas Berger 40dfbb48d5 docs(03-06): complete edit/delete + sync toast plan 2026-06-05 18:48:17 -04:00