diff --git a/.claude/skills/playwright-cli/SKILL.md b/.claude/skills/playwright-cli/SKILL.md index 1dd448c..f95a860 100644 --- a/.claude/skills/playwright-cli/SKILL.md +++ b/.claude/skills/playwright-cli/SKILL.md @@ -193,11 +193,13 @@ playwright-cli --raw localstorage-get theme ``` For structured output wrapping every reply as JSON, pass --json + ```bash playwright-cli list --json ``` ## Open parameters + ```bash # Use specific browser when creating session playwright-cli open --browser=chrome @@ -376,13 +378,13 @@ playwright-cli show --annotate ## Specific tasks -* **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md) -* **Request mocking** [references/request-mocking.md](references/request-mocking.md) -* **Running Playwright code** [references/running-code.md](references/running-code.md) -* **Browser session management** [references/session-management.md](references/session-management.md) -* **Spec-driven testing (plan / generate / heal)** [references/spec-driven-testing.md](references/spec-driven-testing.md) -* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md) -* **Test generation** [references/test-generation.md](references/test-generation.md) -* **Tracing** [references/tracing.md](references/tracing.md) -* **Video recording** [references/video-recording.md](references/video-recording.md) -* **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md) +- **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md) +- **Request mocking** [references/request-mocking.md](references/request-mocking.md) +- **Running Playwright code** [references/running-code.md](references/running-code.md) +- **Browser session management** [references/session-management.md](references/session-management.md) +- **Spec-driven testing (plan / generate / heal)** [references/spec-driven-testing.md](references/spec-driven-testing.md) +- **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md) +- **Test generation** [references/test-generation.md](references/test-generation.md) +- **Tracing** [references/tracing.md](references/tracing.md) +- **Video recording** [references/video-recording.md](references/video-recording.md) +- **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md) diff --git a/.claude/skills/playwright-cli/references/running-code.md b/.claude/skills/playwright-cli/references/running-code.md index 98b541f..06645ec 100644 --- a/.claude/skills/playwright-cli/references/running-code.md +++ b/.claude/skills/playwright-cli/references/running-code.md @@ -17,7 +17,6 @@ You can also load the function from a file: playwright-cli run-code --filename=./my-script.js ``` - The code must be a single function expression, it is wrapped in `(...)` and evaluated. import/export/require syntax is not supported. diff --git a/.claude/skills/playwright-cli/references/session-management.md b/.claude/skills/playwright-cli/references/session-management.md index bf39acd..287e77f 100644 --- a/.claude/skills/playwright-cli/references/session-management.md +++ b/.claude/skills/playwright-cli/references/session-management.md @@ -21,6 +21,7 @@ playwright-cli -s=public snapshot ## Browser Session Isolation Properties Each browser session has independent: + - Cookies - LocalStorage / SessionStorage - IndexedDB diff --git a/.claude/skills/playwright-cli/references/spec-driven-testing.md b/.claude/skills/playwright-cli/references/spec-driven-testing.md index f37bdb4..b109158 100644 --- a/.claude/skills/playwright-cli/references/spec-driven-testing.md +++ b/.claude/skills/playwright-cli/references/spec-driven-testing.md @@ -32,7 +32,7 @@ npm init playwright@latest ### 1.2 Prerequisite: seed test -A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start *after* the seed. `--debug=cli` pauses *inside* this test, so the seed is where every planning and generation session begins. +A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start _after_ the seed. `--debug=cli` pauses _inside_ this test, so the seed is where every planning and generation session begins. Minimum viable seed: @@ -124,13 +124,16 @@ Save under `specs/.plan.md`. Use this structure: **File:** `tests//.spec.ts` **Steps:** - 1. - - expect: - - expect: - 2. - - expect: + +1. + - expect: + - expect: + +2. + - expect: #### 1.2. + ... ### 2. @@ -189,7 +192,7 @@ Collect the generated code and write the test file at the path given in the spec ```ts // spec: specs/basic-operations.plan.md // seed: tests/seed.spec.ts -import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file +import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file test.describe('Singing in and out', () => { test('should sign in', async ({ page }) => { @@ -291,15 +294,15 @@ Only after the user answers, either update the spec (intentional change) or file ### 3.5 Iteration and giving up - Fix failures one at a time; rerun after each. -- If after thorough investigation you are confident the test is correct but the app is wrong *and* the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip. +- If after thorough investigation you are confident the test is correct but the app is wrong _and_ the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip. --- ## Cross-references -| For... | See | -|---|---| -| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) | -| How `playwright-cli` actions become TS | [test-generation.md](test-generation.md) | -| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) | -| Managing the CLI browser session | [session-management.md](session-management.md) | +| For... | See | +| ---------------------------------------------- | ---------------------------------------------- | +| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) | +| How `playwright-cli` actions become TS | [test-generation.md](test-generation.md) | +| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) | +| Managing the CLI browser session | [session-management.md](session-management.md) | diff --git a/.claude/skills/playwright-cli/references/tracing.md b/.claude/skills/playwright-cli/references/tracing.md index 7ce7bab..d81cdeb 100644 --- a/.claude/skills/playwright-cli/references/tracing.md +++ b/.claude/skills/playwright-cli/references/tracing.md @@ -24,6 +24,7 @@ When you start tracing, Playwright creates a `traces/` directory with several fi ### `trace-{timestamp}.trace` **Action log** - The main trace file containing: + - Every action performed (clicks, fills, navigations) - DOM snapshots before and after each action - Screenshots at each step @@ -34,6 +35,7 @@ When you start tracing, Playwright creates a `traces/` directory with several fi ### `trace-{timestamp}.network` **Network log** - Complete network activity: + - All HTTP requests and responses - Request headers and bodies - Response headers and bodies @@ -44,20 +46,21 @@ When you start tracing, Playwright creates a `traces/` directory with several fi ### `resources/` **Resources directory** - Cached resources: + - Images, fonts, stylesheets, scripts - Response bodies for replay - Assets needed to reconstruct page state ## What Traces Capture -| Category | Details | -|----------|---------| -| **Actions** | Clicks, fills, hovers, keyboard input, navigations | -| **DOM** | Full DOM snapshot before/after each action | -| **Screenshots** | Visual state at each step | -| **Network** | All requests, responses, headers, bodies, timing | -| **Console** | All console.log, warn, error messages | -| **Timing** | Precise timing for each operation | +| Category | Details | +| --------------- | -------------------------------------------------- | +| **Actions** | Clicks, fills, hovers, keyboard input, navigations | +| **DOM** | Full DOM snapshot before/after each action | +| **Screenshots** | Visual state at each step | +| **Network** | All requests, responses, headers, bodies, timing | +| **Console** | All console.log, warn, error messages | +| **Timing** | Precise timing for each operation | ## Use Cases @@ -102,14 +105,14 @@ playwright-cli tracing-stop ## Trace vs Video vs Screenshot -| Feature | Trace | Video | Screenshot | -|---------|-------|-------|------------| -| **Format** | .trace file | .webm video | .png/.jpeg image | -| **DOM inspection** | Yes | No | No | -| **Network details** | Yes | No | No | -| **Step-by-step replay** | Yes | Continuous | Single frame | -| **File size** | Medium | Large | Small | -| **Best for** | Debugging | Demos | Quick capture | +| Feature | Trace | Video | Screenshot | +| ----------------------- | ----------- | ----------- | ---------------- | +| **Format** | .trace file | .webm video | .png/.jpeg image | +| **DOM inspection** | Yes | No | No | +| **Network details** | Yes | No | No | +| **Step-by-step replay** | Yes | Continuous | Single frame | +| **File size** | Medium | Large | Small | +| **Best for** | Debugging | Demos | Quick capture | ## Best Practices diff --git a/.claude/skills/playwright-cli/references/video-recording.md b/.claude/skills/playwright-cli/references/video-recording.md index ce9ad6a..8367c4f 100644 --- a/.claude/skills/playwright-cli/references/video-recording.md +++ b/.claude/skills/playwright-cli/references/video-recording.md @@ -42,14 +42,14 @@ playwright-cli video-start recordings/checkout-test-run-42.webm When recording a video for the user or as a proof of work, it is best to create a code snippet and execute it with run-code. It allows pulling appropriate pauses between the actions and annotating the video. There are new Playwright APIs for that. -1) Perform scenario using CLI and take note of all locators and actions. You'll need those locators to request their bounding boxes for highlight. -2) Create a file with the intended script for video (below). Use pressSequentially w/ delay for nice typing, make reasonable pauses. -3) Use playwright-cli run-code --filename your-script.js +1. Perform scenario using CLI and take note of all locators and actions. You'll need those locators to request their bounding boxes for highlight. +2. Create a file with the intended script for video (below). Use pressSequentially w/ delay for nice typing, make reasonable pauses. +3. Use playwright-cli run-code --filename your-script.js **Important**: Overlays are `pointer-events: none` — they do not interfere with page interactions. You can safely keep sticky overlays visible while clicking, filling, or performing any actions on the page. ```js -async page => { +async (page) => { await page.screencast.start({ path: 'video.webm', size: { width: 1280, height: 800 } }); await page.goto('https://demo.playwright.dev/todomvc'); @@ -63,7 +63,9 @@ async page => { }); // Perform action - await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Walk the dog', { delay: 60 }); + await page + .getByRole('textbox', { name: 'What needs to be done?' }) + .pressSequentially('Walk the dog', { delay: 60 }); await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter'); await page.waitForTimeout(1000); @@ -84,7 +86,9 @@ async page => { `); // Perform more actions while the annotation is visible - await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Buy groceries', { delay: 60 }); + await page + .getByRole('textbox', { name: 'What needs to be done?' }) + .pressSequentially('Buy groceries', { delay: 60 }); await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter'); await page.waitForTimeout(1500); @@ -93,7 +97,8 @@ async page => { // You can also highlight relevant locators and provide contextual annotations. const bounds = await page.getByText('Walk the dog').boundingBox(); - await page.screencast.showOverlay(` + await page.screencast.showOverlay( + `
Check it out, it is right above this text
- `, { duration: 2000 }); + `, + { duration: 2000 }, + ); await page.screencast.stop(); -} +}; ``` Embrace creativity, overlays are powerful. ### Overlay API Summary -| Method | Use Case | -|--------|----------| +| Method | Use Case | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | | `page.screencast.showChapter(title, { description?, duration?, styleSheet? })` | Full-screen chapter card with blurred backdrop — ideal for section transitions | -| `page.screencast.showOverlay(html, { duration? })` | Custom HTML overlay — use for callouts, labels, highlights | -| `disposable.dispose()` | Remove a sticky overlay added without duration | -| `page.screencast.hideOverlays()` / `page.screencast.showOverlays()` | Temporarily hide/show all overlays | +| `page.screencast.showOverlay(html, { duration? })` | Custom HTML overlay — use for callouts, labels, highlights | +| `disposable.dispose()` | Remove a sticky overlay added without duration | +| `page.screencast.hideOverlays()` / `page.screencast.showOverlays()` | Temporarily hide/show all overlays | ## Tracing vs Video -| Feature | Video | Tracing | -|---------|-------|---------| -| Output | WebM file | Trace file (viewable in Trace Viewer) | -| Shows | Visual recording | DOM snapshots, network, console, actions | -| Use case | Demos, documentation | Debugging, analysis | -| Size | Larger | Smaller | +| Feature | Video | Tracing | +| -------- | -------------------- | ---------------------------------------- | +| Output | WebM file | Trace file (viewable in Trace Viewer) | +| Shows | Visual recording | DOM snapshots, network, console, actions | +| Use case | Demos, documentation | Debugging, analysis | +| Size | Larger | Smaller | ## Limitations diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c477bf7..26afc23 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -25,12 +25,12 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # lint is currently a no-op: no package defines a `lint` script and ESLint is - # not installed. `pnpm -r lint` prints ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT but - # exits 0, so this step passes. Wiring lint is out of this phase's scope. - name: Lint run: pnpm lint + - name: Format check + run: pnpm format:check + - name: Typecheck run: pnpm typecheck diff --git a/.planning/HANDOFF.json b/.planning/HANDOFF.json index 68df8a2..a15099d 100644 --- a/.planning/HANDOFF.json +++ b/.planning/HANDOFF.json @@ -9,24 +9,68 @@ "total_tasks": null, "status": "paused", "completed_tasks": [ - {"id": 1, "name": "All 8 plans (05-01..05-08) executed across 5 waves, sequential (worktree degrade)", "status": "done"}, - {"id": 2, "name": "Code review --fix --all --auto: 14 findings fixed over 3 iterations; 05-REVIEW.md clean", "status": "done"}, - {"id": 3, "name": "Phase verification: 12/12 must-haves in code; NOTIF-01/02/03 traced; 05-VERIFICATION.md status human_needed", "status": "done"}, - {"id": 4, "name": "5 device-only UAT items persisted to 05-UAT.md; ROADMAP reverted to pending device UAT", "status": "done"} + { + "id": 1, + "name": "All 8 plans (05-01..05-08) executed across 5 waves, sequential (worktree degrade)", + "status": "done" + }, + { + "id": 2, + "name": "Code review --fix --all --auto: 14 findings fixed over 3 iterations; 05-REVIEW.md clean", + "status": "done" + }, + { + "id": 3, + "name": "Phase verification: 12/12 must-haves in code; NOTIF-01/02/03 traced; 05-VERIFICATION.md status human_needed", + "status": "done" + }, + { + "id": 4, + "name": "5 device-only UAT items persisted to 05-UAT.md; ROADMAP reverted to pending device UAT", + "status": "done" + } ], "remaining_tasks": [ - {"id": 5, "name": "On-device UAT (iOS 16.4+ Home-Screen PWA + Android) via /gsd-verify-work 5 — 5 items in 05-UAT.md", "status": "not_started"}, - {"id": 6, "name": "After UAT passes, phase auto-transitions to complete (verify-work); milestone can advance to Phase 6", "status": "not_started"} + { + "id": 5, + "name": "On-device UAT (iOS 16.4+ Home-Screen PWA + Android) via /gsd-verify-work 5 — 5 items in 05-UAT.md", + "status": "not_started" + }, + { + "id": 6, + "name": "After UAT passes, phase auto-transitions to complete (verify-work); milestone can advance to Phase 6", + "status": "not_started" + } ], "blockers": [], "human_actions_pending": [ - {"action": "Run /gsd-verify-work 5 on a physical iOS device and an Android device", "context": "Phase goal 'reliably on iOS and Android' is device-only; 5 UAT items cannot be automated (CLAUDE.md)", "blocking": true}, - {"action": "Create + share the 'Family' calendar and set is_shared=1 (Phase 2 D-16)", "context": "Reminders (NOTIF-01/SC-1) only fire on shared Family-calendar events; needed before SC-1 has real events", "blocking": false} + { + "action": "Run /gsd-verify-work 5 on a physical iOS device and an Android device", + "context": "Phase goal 'reliably on iOS and Android' is device-only; 5 UAT items cannot be automated (CLAUDE.md)", + "blocking": true + }, + { + "action": "Create + share the 'Family' calendar and set is_shared=1 (Phase 2 D-16)", + "context": "Reminders (NOTIF-01/SC-1) only fire on shared Family-calendar events; needed before SC-1 has real events", + "blocking": false + } ], "decisions": [ - {"decision": "VAPID keypair generated by assistant; user pasted into root .env (gitignored); wired into docker-compose.yml env + .env.example", "rationale": "Config env-injected for Docker transposability; no key baked into image; .env is permission-blocked from assistant Read/Write", "phase": "05"}, - {"decision": "Reverted premature ROADMAP [x] complete to [ ] pending device UAT", "rationale": "Verification is human_needed; goal not confirmable without devices; avoid false completion claim", "phase": "05"}, - {"decision": "Ran code review --fix --all --auto rather than ship-then-fix", "rationale": "4 Criticals (esp. iOS gesture gate) defeated success criteria; fixed before declaring done", "phase": "05"} + { + "decision": "VAPID keypair generated by assistant; user pasted into root .env (gitignored); wired into docker-compose.yml env + .env.example", + "rationale": "Config env-injected for Docker transposability; no key baked into image; .env is permission-blocked from assistant Read/Write", + "phase": "05" + }, + { + "decision": "Reverted premature ROADMAP [x] complete to [ ] pending device UAT", + "rationale": "Verification is human_needed; goal not confirmable without devices; avoid false completion claim", + "phase": "05" + }, + { + "decision": "Ran code review --fix --all --auto rather than ship-then-fix", + "rationale": "4 Criticals (esp. iOS gesture gate) defeated success criteria; fixed before declaring done", + "phase": "05" + } ], "uncommitted_files": [], "next_action": "Run /gsd-verify-work 5 on iOS + Android devices to close the 5 UAT items in 05-UAT.md. Dev MariaDB (familysync-mariadb-1, host port 3306) is up for any API re-checks.", diff --git a/.planning/MILESTONES.md b/.planning/MILESTONES.md index 5b57e22..d5da0f6 100644 --- a/.planning/MILESTONES.md +++ b/.planning/MILESTONES.md @@ -18,6 +18,7 @@ **Requirements:** 20/20 v1 requirements complete (AUTH, CAL, PWA, LIST, NOTIF). Deferred out of scope: CAL-09…CAL-12 (v1.x), DISP-01/DISP-02 (v2). **Known deferred items at close** (acknowledged, device-only / live-infra — not regressions): + - Phase 01 Gate-2 live checks (Authelia OIDC over Pangolin, iOS standalone install/redirect, session persistence) — carried under D-14; spot-check at go-live. - Phase 06 iOS device-only residuals: standalone cold-load + OIDC redirect (D-10/D-11), PushPermissionPrompt spinner (CP-04.3). - Android event-change push: server-side FCM delivery proven (201); on-device confirmation + operator channel-importance bump pending (05-UAT Test 4). diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 06709e2..61b1977 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -13,6 +13,7 @@ The household can see and co-edit one color-coded family calendar (shared + each **Goal:** Make FamilySync configurable, administrable, and maintainable for real multi-member use — guided setup, in-app admin, per-event reminders, faster write-back, CI/CD, and mobile test coverage — without hand-editing env files or the database. **Target features:** + - **Per-event reminders** — reminder selector on the event form (incl. "none"), serialized as VALARM; scheduler honors each event's lead instead of a hardcoded 15-min, and fires nothing when an event has no alarm (was backlog 999.4) - **Admin Settings section** — role-gated UI to manage per-member Fastmail app passwords and designate the shared calendar, replacing manual DB writes (was backlog 999.10) - **Initial setup wizard** — first-run validated bootstrap of env vars, VAPID keypair, DB connection, and first app password (was backlog 999.11) @@ -39,6 +40,7 @@ Deferred to backlog: self-service provider onboarding (999.5) and provider abstr ### Active + - [ ] Android PWA install walkthrough verified on a real Android device (iOS validated Phase 3; Android = carried Gate 2 row B5) - [ ] Low-friction onboarding for the non-technical Apple member — visit one URL, sign in. **Partially validated Phase 3** (wife logged in + installed unaided); the per-member Fastmail app-password provider-setup step is still missing (backlog 999.5) @@ -82,25 +84,26 @@ Deferred to backlog: self-service provider onboarding (999.5) and provider abstr ## Key Decisions -| Decision | Rationale | Outcome | -|----------|-----------|---------| -| Calendar hosted on Fastmail, not self-hosted (Baikal dropped) | Fastmail reaches the whole household native-or-PWA with one fewer self-hosted service; Fastmail Android app can't show self-hosted CalDAV anyway | ✓ Validated (v1.0) | -| Personal calendars also Fastmail-hosted collections, aggregated via one broker token | Avoids external ICS feeds and per-member credential management; uniform read/write path | ✓ Validated (v1.0, CAL-08 GO) | -| Shared lists in custom MariaDB, not Vikunja | Cross-ecosystem native task sync is impossible; a list table in the backend being built anyway is trivial vs another container + SSO integration | ✓ Validated (v1.0, Phase 4) | -| React PWA only, no React Native / App Store | App-like UX with one codebase for all surfaces; lowest onboarding friction (one URL) | ✓ Validated (v1.0, Phase 3) | -| Authelia OIDC for all members | True SSO consistent with existing infra; no separate calendar-credential problem | ✓ Validated (v1.0, Phase 3) | -| Web Push notifications in v1 (not deferred) | Family alerts judged essential day one; build push into the foundation | ✓ Validated (v1.0, Phase 5) | -| Personal-calendar overlay in v1 (not shared-only) | Unified view of everyone's schedules is the Skylight magic worth shipping early | — Validated (CAL-08 GO, Phase 1): per-member app password reaches all of an account's calendars; no cross-account ACL needed | -| **D-14:** Defer Phase 1 Gate 2 (live Authelia/Pangolin verification). SSE-over-Pangolin smoke = hard gate before Phase 4; live AUTH smoke incl. iOS standalone-PWA folded into Phase 3; full 2-member prod login verified there. Phases 2–3 develop behind a documented dev-auth bypass. | Gate 2 needs operator infra (Authelia config + tunnel) + docs that didn't exist; deferring unblocks Phase 2/3 code without rework risk, since the broker data path (CAL-01/CAL-08) is already proven live. SSE must still be verified before Phase 4 to avoid building live-sync on an unverified transport (#1034). | Tracked: `01-HUMAN-UAT.md`, `docs/deployment.md` | -| **D-15:** Validate the real external topology via a **local Newt connector + test subdomain** through existing Pangolin (Mode A), not an Unraid deploy. Unraid (Mode B) reserved for go-live. | Authelia OIDC + SSE pass-through behaviour live in Authelia + Pangolin/Newt, not in where the origin runs — so a local Newt rig faithfully tests both, decoupling "does the topology work" from "is it in production." Newt dials outbound (no open ports). Only shared touch is an additive, reversible Authelia client. | ✓ Validated (Gate 2 executed live in Phase 3 / D-17) | -| **D-16 (2026-06-05, Phase 2):** No dedicated Fastmail "broker" account. The **shared-family calendar is a calendar collection created on the operator's primary Fastmail account** (`me@lucasberger.ca`) and shared out to the wife + others via Fastmail's own calendar sharing. The app's single app password enumerates it like any other collection; the `calendars.is_shared` flag (operator-set) marks which row is the shared one. | Clarified during the Wave 2 checkpoint: "broker account" was only ever the role the primary account's app password plays. id=1 ("Calendar") is the operator's **personal** calendar, not the shared one — so it must NOT be marked `is_shared`. Aggregating each *other* member's **personal** calendar still follows the D-09 per-member app-password model (open for Phase 3 onboarding: a member may get a personal color lane, or only the shared calendar). | ✓ Resolved (2026-06-10): "FamilySync" shared calendar created on the primary account, synced as `calendars.id=10`, marked `is_shared=1`; shared lane + reminders now active | -| **D-17 (2026-06-07, Phase 3):** Phase 1 Gate 2 (deferred per D-14) was executed live during Phase 3 against real Authelia OIDC over Pangolin/Newt (Mode A), clearing the load-bearing iOS-standalone-login risk. The full event write path (create/all-day/recurring/edit/delete/conflict) is verified end-to-end to Fastmail. | Live bring-up surfaced bugs the dev-bypass build could not (newt MTU blackhole, OIDC state-cookie race, write-path timezone/identity/join/cache bugs, all-day off-by-one, color collisions). All fixed; UX gaps captured as backlog 999.3–999.9. | — Validated (Gate 2, `03-GATE2-RESULTS.md`). Carried: Android install (B5), SSE smoke (Phase 4 entry gate, D-14). | +| Decision | Rationale | Outcome | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Calendar hosted on Fastmail, not self-hosted (Baikal dropped) | Fastmail reaches the whole household native-or-PWA with one fewer self-hosted service; Fastmail Android app can't show self-hosted CalDAV anyway | ✓ Validated (v1.0) | +| Personal calendars also Fastmail-hosted collections, aggregated via one broker token | Avoids external ICS feeds and per-member credential management; uniform read/write path | ✓ Validated (v1.0, CAL-08 GO) | +| Shared lists in custom MariaDB, not Vikunja | Cross-ecosystem native task sync is impossible; a list table in the backend being built anyway is trivial vs another container + SSO integration | ✓ Validated (v1.0, Phase 4) | +| React PWA only, no React Native / App Store | App-like UX with one codebase for all surfaces; lowest onboarding friction (one URL) | ✓ Validated (v1.0, Phase 3) | +| Authelia OIDC for all members | True SSO consistent with existing infra; no separate calendar-credential problem | ✓ Validated (v1.0, Phase 3) | +| Web Push notifications in v1 (not deferred) | Family alerts judged essential day one; build push into the foundation | ✓ Validated (v1.0, Phase 5) | +| Personal-calendar overlay in v1 (not shared-only) | Unified view of everyone's schedules is the Skylight magic worth shipping early | — Validated (CAL-08 GO, Phase 1): per-member app password reaches all of an account's calendars; no cross-account ACL needed | +| **D-14:** Defer Phase 1 Gate 2 (live Authelia/Pangolin verification). SSE-over-Pangolin smoke = hard gate before Phase 4; live AUTH smoke incl. iOS standalone-PWA folded into Phase 3; full 2-member prod login verified there. Phases 2–3 develop behind a documented dev-auth bypass. | Gate 2 needs operator infra (Authelia config + tunnel) + docs that didn't exist; deferring unblocks Phase 2/3 code without rework risk, since the broker data path (CAL-01/CAL-08) is already proven live. SSE must still be verified before Phase 4 to avoid building live-sync on an unverified transport (#1034). | Tracked: `01-HUMAN-UAT.md`, `docs/deployment.md` | +| **D-15:** Validate the real external topology via a **local Newt connector + test subdomain** through existing Pangolin (Mode A), not an Unraid deploy. Unraid (Mode B) reserved for go-live. | Authelia OIDC + SSE pass-through behaviour live in Authelia + Pangolin/Newt, not in where the origin runs — so a local Newt rig faithfully tests both, decoupling "does the topology work" from "is it in production." Newt dials outbound (no open ports). Only shared touch is an additive, reversible Authelia client. | ✓ Validated (Gate 2 executed live in Phase 3 / D-17) | +| **D-16 (2026-06-05, Phase 2):** No dedicated Fastmail "broker" account. The **shared-family calendar is a calendar collection created on the operator's primary Fastmail account** (`me@lucasberger.ca`) and shared out to the wife + others via Fastmail's own calendar sharing. The app's single app password enumerates it like any other collection; the `calendars.is_shared` flag (operator-set) marks which row is the shared one. | Clarified during the Wave 2 checkpoint: "broker account" was only ever the role the primary account's app password plays. id=1 ("Calendar") is the operator's **personal** calendar, not the shared one — so it must NOT be marked `is_shared`. Aggregating each _other_ member's **personal** calendar still follows the D-09 per-member app-password model (open for Phase 3 onboarding: a member may get a personal color lane, or only the shared calendar). | ✓ Resolved (2026-06-10): "FamilySync" shared calendar created on the primary account, synced as `calendars.id=10`, marked `is_shared=1`; shared lane + reminders now active | +| **D-17 (2026-06-07, Phase 3):** Phase 1 Gate 2 (deferred per D-14) was executed live during Phase 3 against real Authelia OIDC over Pangolin/Newt (Mode A), clearing the load-bearing iOS-standalone-login risk. The full event write path (create/all-day/recurring/edit/delete/conflict) is verified end-to-end to Fastmail. | Live bring-up surfaced bugs the dev-bypass build could not (newt MTU blackhole, OIDC state-cookie race, write-path timezone/identity/join/cache bugs, all-day off-by-one, color collisions). All fixed; UX gaps captured as backlog 999.3–999.9. | — Validated (Gate 2, `03-GATE2-RESULTS.md`). Carried: Android install (B5), SSE smoke (Phase 4 entry gate, D-14). | ## Evolution This document evolves at phase transitions and milestone boundaries. **After each phase transition** (via `/gsd-transition`): + 1. Requirements invalidated? → Move to Out of Scope with reason 2. Requirements validated? → Move to Validated with phase reference 3. New requirements emerged? → Add to Active @@ -108,10 +111,12 @@ This document evolves at phase transitions and milestone boundaries. 5. "What This Is" still accurate? → Update if drifted **After each milestone** (via `/gsd-complete-milestone`): + 1. Full review of all sections 2. Core Value check — still the right priority? 3. Audit Out of Scope — reasons still valid? 4. Update Context with current state --- -*Last updated: 2026-06-11 — Phase 7 (Mobile Test Harness) complete; TEST-01/TEST-02 validated* + +_Last updated: 2026-06-11 — Phase 7 (Mobile Test Harness) complete; TEST-01/TEST-02 validated_ diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 141a477..cfa8c40 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -50,7 +50,7 @@ Each requirement maps to exactly one roadmap phase (see Traceability). ## Future Requirements (deferred, not in v1.1) - **Multiple reminders per event** (2× VALARM) — stretch; deferred to v1.2. -- **Self-service provider onboarding** (backlog 999.5) — a member adds their *own* Fastmail app password on first login. v1.1 covers this admin-managed (ADMIN-01); self-service deferred. +- **Self-service provider onboarding** (backlog 999.5) — a member adds their _own_ Fastmail app password on first login. v1.1 covers this admin-managed (ADMIN-01); self-service deferred. - **Calendar provider abstraction** (backlog 999.1) — provider interface so Fastmail is one of several backends. - **Android PWA install walkthrough** verified on a real device (carried from v1.0). - **Wizard re-run / reconfigure** flow after first setup. @@ -69,24 +69,24 @@ Each requirement maps to exactly one roadmap phase (see Traceability). Maps each REQ-ID to its phase. v1.1 phases continue v1.0 numbering (v1.0 ended at Phase 6) → v1.1 starts at Phase 7. **Coverage: 17/17 v1.1 requirements mapped, no orphans, no duplicates.** -| REQ-ID | Phase | Status | -|--------|-------|--------| -| TEST-01 | Phase 7 (Mobile Test Harness) | Complete | -| TEST-02 | Phase 7 (Mobile Test Harness) | Complete | -| CI-01 | Phase 8 (Gitea CI) | Complete | -| CI-02 | Phase 8 (Gitea CI) | Complete | -| CAL-15 | Phase 9 (Faster Write-Back) | Pending | -| ADMIN-01 | Phase 10 (Admin Role & Settings) | Pending | -| ADMIN-02 | Phase 10 (Admin Role & Settings) | Pending | -| ADMIN-03 | Phase 10 (Admin Role & Settings) | Pending | -| CAL-13 | Phase 11 (Per-Event Reminders) | Pending | -| CAL-14 | Phase 11 (Per-Event Reminders) | Pending | -| NOTIF-04 | Phase 11 (Per-Event Reminders) | Pending | -| NOTIF-05 | Phase 11 (Per-Event Reminders) | Pending | -| NOTIF-06 | Phase 11 (Per-Event Reminders) | Pending | -| SETUP-01 | Phase 12 (Initial Setup Wizard) | Pending | -| SETUP-02 | Phase 12 (Initial Setup Wizard) | Pending | -| SETUP-03 | Phase 12 (Initial Setup Wizard) | Pending | -| SETUP-04 | Phase 12 (Initial Setup Wizard) | Pending | +| REQ-ID | Phase | Status | +| -------- | -------------------------------- | -------- | +| TEST-01 | Phase 7 (Mobile Test Harness) | Complete | +| TEST-02 | Phase 7 (Mobile Test Harness) | Complete | +| CI-01 | Phase 8 (Gitea CI) | Complete | +| CI-02 | Phase 8 (Gitea CI) | Complete | +| CAL-15 | Phase 9 (Faster Write-Back) | Pending | +| ADMIN-01 | Phase 10 (Admin Role & Settings) | Pending | +| ADMIN-02 | Phase 10 (Admin Role & Settings) | Pending | +| ADMIN-03 | Phase 10 (Admin Role & Settings) | Pending | +| CAL-13 | Phase 11 (Per-Event Reminders) | Pending | +| CAL-14 | Phase 11 (Per-Event Reminders) | Pending | +| NOTIF-04 | Phase 11 (Per-Event Reminders) | Pending | +| NOTIF-05 | Phase 11 (Per-Event Reminders) | Pending | +| NOTIF-06 | Phase 11 (Per-Event Reminders) | Pending | +| SETUP-01 | Phase 12 (Initial Setup Wizard) | Pending | +| SETUP-02 | Phase 12 (Initial Setup Wizard) | Pending | +| SETUP-03 | Phase 12 (Initial Setup Wizard) | Pending | +| SETUP-04 | Phase 12 (Initial Setup Wizard) | Pending | **DB foundation note:** The v1.1 schema migration (`users.is_admin`, `calendar_events.reminder_lead_minutes`, `app_config` table) is not a standalone requirement — it is carried by **Phase 10 (Admin Role & Settings)** (which owns is_admin + app_config) and consumed by **Phase 11 (Per-Event Reminders)** (reminder_lead_minutes) and **Phase 12 (Initial Setup Wizard)** (app_config.setup_complete). Folded per ARCHITECTURE.md ordering rather than created as a migration-only phase. This makes Phase 10 the head of the admin chain (10 → 11, 10 → 12). diff --git a/.planning/RETROSPECTIVE.md b/.planning/RETROSPECTIVE.md index bc7a2bb..dd9b735 100644 --- a/.planning/RETROSPECTIVE.md +++ b/.planning/RETROSPECTIVE.md @@ -1,6 +1,6 @@ # Project Retrospective -*A living document updated after each milestone. Lessons feed forward into future planning.* +_A living document updated after each milestone. Lessons feed forward into future planning._ ## Milestone: v1.0 — MVP @@ -8,32 +8,38 @@ **Phases:** 6 | **Plans:** 42 | **Sessions:** not tracked ### What Was Built + - Unified color-coded Fastmail calendar (shared + personal) with full event CRUD written back via CalDAV — read views, recurrence/DST expansion, all-day, and an enqueue-only outbox write path. - Installable React PWA behind Authelia OIDC, taken live over Pangolin/Newt and verified end-to-end on desktop and iOS. - Shared collaborative lists with real-time SSE co-edit sync, and VAPID Web Push for reminders / event-change / list alerts. ### What Worked + - **Dev-auth bypass to build behind a deferred gate (D-14):** Phases 2–3 were built against a documented bypass while live Authelia/Pangolin infra wasn't ready, with no rework when Gate 2 finally ran live in Phase 3. - **Wave-based parallel plans** within phases kept large phases (Phase 3 = 12 plans) moving. - **Enqueue-only outbox with optimistic 202** cleanly separated request handling from the slow CalDAV write, and made create-before-delete ordering + etag/412 handling tractable. ### What Was Inefficient + - **A long tail of bugs only reproduced under live conditions** (Newt MTU blackhole, OIDC state-cookie race, write-path timezone/identity/join/cache bugs, all-day off-by-one, color collisions, silent Android notifications, session-cookie expiry). Building behind the bypass too long delayed their discovery — they all surfaced at once during live bring-up. -- **Background workers silently failed:** node-cron 4.2.1 skipped *every* scheduled tick in the long-lived API process, so reminders/poller/outbox never fired on schedule — caught late, during Phase 5 UAT, not by tests. +- **Background workers silently failed:** node-cron 4.2.1 skipped _every_ scheduled tick in the long-lived API process, so reminders/poller/outbox never fired on schedule — caught late, during Phase 5 UAT, not by tests. - **Repeated mobile-only defects could only be found by the operator on real devices** because the test harness is desktop-Chromium and the prod PWA is behind OIDC (→ backlog 999.12). ### Patterns Established + - **`setInterval`, not node-cron, for in-process schedulers** (node-cron silently no-ops in a long-lived process). Do not reintroduce node-cron. - **drizzle-kit `generate`+`migrate`, never `push`, on MariaDB** — `push` emits a false destructive (truncate) diff against populated MariaDB. - **iOS-Safari standalone behavior is a human/device checkpoint**, not a playwright-cli check — keep those as explicit manual gates. - **Run `tsc --noEmit` (both apps) in the post-merge gate** — esbuild strips types so vitest stays green while tsc fails. ### Key Lessons -1. Bring the real external topology (auth + tunnel) up *early* and behind a small reversible config, rather than deferring all live verification — the live-only bug class is large and clusters at first contact. + +1. Bring the real external topology (auth + tunnel) up _early_ and behind a small reversible config, rather than deferring all live verification — the live-only bug class is large and clusters at first contact. 2. Long-running Node schedulers need an integration-level "does it actually fire on a tick" check; unit tests pass while the scheduler silently does nothing. 3. Push has hard platform footguns (iOS revokes after 3 silent pushes; standalone install mandatory; VAPID key truncation = silent Apple 403) — encode them as guards from day one, not after a missed notification. ### Cost Observations + - Model mix: not tracked - Sessions: not tracked - Notable: TDD red→green discipline is visible in commit history, but per-commit `gate_status:` trailers were never emitted across the milestone — the ship-time TDD audit had nothing structured to aggregate. Wire gate_status trailers in v1.x if the audit is wanted. @@ -44,15 +50,15 @@ ### Process Evolution -| Milestone | Sessions | Phases | Key Change | -|-----------|----------|--------|------------| -| v1.0 | n/a | 6 | Established GSD plan→execute→verify→ship→complete loop; dev-auth bypass for gated infra; milestone-branch + Gitea PR shipping | +| Milestone | Sessions | Phases | Key Change | +| --------- | -------- | ------ | ----------------------------------------------------------------------------------------------------------------------------- | +| v1.0 | n/a | 6 | Established GSD plan→execute→verify→ship→complete loop; dev-auth bypass for gated infra; milestone-branch + Gitea PR shipping | ### Cumulative Quality -| Milestone | Tests | Coverage | Zero-Dep Additions | -|-----------|-------|----------|-------------------| -| v1.0 | PWA 191 + API broker/events 114 green | not measured | n/a | +| Milestone | Tests | Coverage | Zero-Dep Additions | +| --------- | ------------------------------------- | ------------ | ------------------ | +| v1.0 | PWA 191 + API broker/events 114 green | not measured | n/a | ### Top Lessons (Verified Across Milestones) diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 2c23d04..7bfdb4e 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -31,7 +31,7 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem - [ ] **Phase 10: Admin Role & Settings** - DB foundation (is_admin / reminder_lead / app_config) + role-gated admin UI to rotate app passwords and designate the shared calendar - [ ] **Phase 11: Per-Event Reminders** - Reminder selector on the event form (incl. "None") serialized as VALARM, with a variable-lead scheduler that honors each event's choice - [ ] **Phase 12: Initial Setup Wizard** - First-run validated bootstrap of env/VAPID/DB/OIDC + first app password, reusing the admin route surface -- [ ] **Phase 13: Real Lint Gate (ESLint)** - Wire ESLint flat config (typescript-eslint + React) across both apps so the Phase 8 CI lint slot actually fails on violations instead of no-op'ing +- [x] **Phase 13: Real Lint Gate (ESLint)** - Wire ESLint flat config (typescript-eslint + React) across both apps so the Phase 8 CI lint slot actually fails on violations instead of no-op'ing (completed 2026-06-12) - [ ] **Phase 14: Desktop E2E Coverage** - Add a Desktop Chrome Playwright profile + make the mobile-authored specs desktop-safe so the Phase 8 regression gate validates desktop, not just mobile ## Phase Details @@ -227,7 +227,12 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem - Pick a baseline ruleset (recommended vs strict-type-checked) deliberately — strict surfaces a large upfront cleanup; decide blocking vs advisory before flipping the gate to blocking. - `typecheck`/tsc already gates type errors; ESLint should not duplicate type-checking rules unnecessarily. -**Plans**: TBD +**Plans**: 3 plans — all complete (scope expanded during planning to add a Prettier `format:check` gate) + +- [x] 13-01-PLAN.md — Install ESLint/Prettier deps + flat config + package scripts + prove the gate fails (SC-1) +- [x] 13-02-PLAN.md — Fix all first-run lint violations across both apps, green `pnpm lint` (D-13-05/06) +- [x] 13-03-PLAN.md — Prettier reformat (isolated commit) + CI format:check step + green baseline (SC-2/SC-3) + **UI hint**: no ### Phase 14: Desktop E2E Coverage @@ -266,7 +271,7 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem | 10. Admin Role & Settings | v1.1 | 0/? | Not started | - | | 11. Per-Event Reminders | v1.1 | 0/? | Not started | - | | 12. Initial Setup Wizard | v1.1 | 0/? | Not started | - | -| 13. Real Lint Gate (ESLint) | v1.1 | 0/? | Not started | - | +| 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 | | 14. Desktop E2E Coverage | v1.1 | 0/? | Not started | - | ## Backlog diff --git a/.planning/STATE.md b/.planning/STATE.md index e8cd6b4..c8115c7 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,16 +2,16 @@ gsd_state_version: 1.0 milestone: v1.1 milestone_name: Operability & Polish -status: phase-complete -stopped_at: Phase 08 complete — all 4 plans executed, CI-01 + CI-02 delivered, publish job verified green (run #14) -last_updated: "2026-06-11T22:00:00.000Z" -last_activity: "2026-06-11 -- 08-04 complete; publish job green (run #14, merge commit 98acff8): both image tags pushed (familysync-api:latest + :v1.1-98acff8), PAT masked, --password-stdin confirmed. REGISTRY_PAT naming fix (73eecf7). Phase 8 (Gitea CI) complete — all 6 ROADMAP criteria met." +status: verifying +stopped_at: Phase 13 Plan 03 complete (Prettier reformat + CI format gate + green baseline) +last_updated: "2026-06-12T00:59:51.634Z" +last_activity: 2026-06-12 progress: - total_phases: 16 - completed_phases: 2 - total_plans: 8 - completed_plans: 8 - percent: 12 + total_phases: 17 + completed_phases: 3 + total_plans: 11 + completed_plans: 11 + percent: 18 --- # Project State @@ -21,37 +21,38 @@ progress: See: .planning/PROJECT.md (updated 2026-06-10) **Core value:** One color-coded family calendar (shared + personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store -**Current focus:** Phase 08 — gitea-ci +**Current focus:** Phase 13 — real-lint-gate-eslint ## Current Position -Phase: 08 (gitea-ci) — COMPLETE -Plan: 4 of 4 (08-04 complete) -Status: Phase 08 complete — all 4 plans executed, CI-01 + CI-02 delivered -Last activity: 2026-06-11 -- Quick task 260611-ozt: split publish into standalone push-only publish.yml (kills orphaned CI / publish (pull_request) pending status, WR-01); release model documented in README + publish.yml. Branch-protection contexts unchanged. +Phase: 999.1 +Plan: Not started +Status: Phase complete — ready for verification +Last activity: 2026-06-12 ## Performance Metrics **Velocity:** -- Total plans completed: 21 +- Total plans completed: 24 - Average duration: - - Total execution time: 0 hours **By Phase:** | Phase | Plans | Total | Avg/Plan | -|-------|-------|-------|----------| -| 02 | 5 | - | - | -| 03 | 12 | - | - | -| 07 | 4 | - | - | +| ----- | ----- | ----- | -------- | +| 02 | 5 | - | - | +| 03 | 12 | - | - | +| 07 | 4 | - | - | +| 13 | 3 | - | - | **Recent Trend:** - Last 5 plans: - - Trend: - -*Updated after each plan completion* +_Updated after each plan completion_ | Phase 03 P01 | 45 | 5 tasks | 8 files | | Phase 03 P03-02 | 4 | 2 tasks | 2 files | | Phase 03 P03-03 | 7 | 3 tasks | 2 files | @@ -82,6 +83,9 @@ Last activity: 2026-06-11 -- Quick task 260611-ozt: split publish into standalon | Phase 07 P02 | 196 | 2 tasks | 4 files | | Phase 07-mobile-test-harness P03 | 480 | 2 tasks | 2 files | | Phase 07-mobile-test-harness P04 | 22 | 2 tasks | 2 files | +| Phase 13 P01 | 8 | 2 tasks | 7 files | +| Phase 13-real-lint-gate-eslint P02 | 90 | 2 tasks | 31 files | +| Phase 13-real-lint-gate-eslint P03 | 10 | 3 tasks | 399 files | ## Accumulated Context @@ -90,6 +94,8 @@ Last activity: 2026-06-11 -- Quick task 260611-ozt: split publish into standalon Decisions are logged in PROJECT.md Key Decisions table. Recent decisions affecting current work: +- D-13-ESLint-PIN (2026-06-11, 13-01): eslint pinned to 9.39.4 — ESLint 10 breaks eslint-plugin-react@7.37.5 at runtime ("getFilename is not a function", jsx-eslint#3977). Unpin when plugin releases ESLint 10 support. +- D-13-JSX-SCOPE (2026-06-11, 13-01): react/react-in-jsx-scope disabled explicitly — flat.recommended enables it at error; PWA uses jsx:react-jsx (React 19 automatic transform), React import not required in JSX files. - D-PROBE-01 (2026-06-11, 08-01): runs-on must be ubuntu-latest — runner has no self-hosted label; all downstream ci.yml workflows use ubuntu-latest. - D-PROBE-02 (2026-06-11, 08-01): Docker-executor confirmed (/.dockerenv present); services: works; DB_HOST=mariadb in all downstream jobs. - D-PROBE-03 (2026-06-11, 08-01): No mysql CLI in runner image — DB readiness uses healthcheck.sh --connect --innodb_initialized or Node mysql2 wait; no mysql shell-out. @@ -136,10 +142,11 @@ Recent decisions affecting current work: - [Phase 06-04]: @keyframes pulse added globally to tokens.css; redundant local spin redefinition removed from PushPermissionPrompt.tsx — all sync-animation consumers now resolve from the global stylesheet (D-13) - [Phase 06-05]: TanStack Query v5 global error handler: QueryCache({onError})/MutationCache({onError}) constructor pattern; defaultOptions.onError removed in v5 (NOT used); confirmed via Context7 /tanstack/query - [Phase 06-05]: AuthSplash state machine: loading/redirecting/dead-end; CalendarContent renders only on meQuery.isSuccess (D-10); sessionExpired flag via Zustand + global QueryCache/MutationCache onError (D-11); one-shot redirect guard re-armed only on explicit user tap -- [Phase 06-06]: Schedule-X all-day CSS: .sx__all-day-event does not exist in v4.6.0; real selectors are .sx__date-grid-event (week/day) + .sx__month-grid-event:not(:has(.sx__month-grid-event-time)) (month); --sx-color-primary-container remapped as fallback +- [Phase 06-06]: Schedule-X all-day CSS: .sx**all-day-event does not exist in v4.6.0; real selectors are .sx**date-grid-event (week/day) + .sx**month-grid-event:not(:has(.sx**month-grid-event-time)) (month); --sx-color-primary-container remapped as fallback - [Phase 06]: Phase-level UX fixes (surfaced during UAT, not in any single plan): AppNav made persistent across routes — nav no longer disappears on /lists (commits 6070437 RED + 051874b fix); BottomTabBar hidden on desktop — no longer overlaps sidebar Settings affordance (commits 740e342 RED + 089b53d fix) - [Phase ?]: D-04-SCHEDULE-X-LOCATOR: Used .sx-react-calendar-wrapper CSS class to assert Schedule-X grid — no semantic role on outer wrapper div - [Phase ?]: D-04-EMPTY-NETWORK-SIM: Lists empty state simulated via page.route to 200 empty array — preserves seeded DB for parallel workers (D-06 / T-07-11) +- [Phase ?]: D-13-08: Prettier reformat committed as isolated mechanical diff; CI Format check step added to fast-checks job ### Roadmap Evolution @@ -169,41 +176,42 @@ Recent decisions affecting current work: ### Quick Tasks Completed -| # | Description | Date | Commit | Status | Directory | -|---|-------------|------|--------|--------|-----------| -| 260606-tv8 | Fix missing sign-in redirect in the PWA (Phase 03 auth-entry gap from Gate 2): guarded /api/login → / + full-page redirect on unauthenticated fetchMe | 2026-06-07 | 7c6531f | | [260606-tv8-fix-missing-sign-in-redirect-in-the-pwa-](./quick/260606-tv8-fix-missing-sign-in-redirect-in-the-pwa-/) | -| 260607-l6l | Batch-fix Phase 03 write-path bugs: events.ts edit/delete missing calendars innerJoin (503, BLOCKING) + handler-coupled regression test; shared deriveDisplayName helper (me.ts + resolveUserId, corrects blank rows); GET /api/events userId/isShared ownership filter | 2026-06-07 | 2870413 | | [260607-l6l-fix-phase-03-write-path-correctness-bugs](./quick/260607-l6l-fix-phase-03-write-path-correctness-bugs/) | -| 260607-u8o | Record SSE-over-Pangolin smoke test PASS (Phase 4 entry gate, D-14 / issue #1034) — updated 01-HUMAN-UAT item 4 + 03-GATE2-RESULTS Part C to PASS with live evidence | 2026-06-08 | 26655cf | | [260607-u8o-record-sse-over-pangolin-smoke-test-pass](./quick/260607-u8o-record-sse-over-pangolin-smoke-test-pass/) | -| 260610-cr8 | Adopt drizzle generate+migrate workflow, retire db:push on MariaDB — removed db:push script + repointed deployment.md to migrate with anti-push warning; dry-verified no destructive diff | 2026-06-10 | 1a95d81 | Verified | [260610-cr8-adopt-drizzle-generate-migrate-workflow-](./quick/260610-cr8-adopt-drizzle-generate-migrate-workflow-/) | -| 260610-czd | Fix docs/deployment.md local-dev command — added "Running locally (host-side, no Docker)" subsection with correct env-sourced two-terminal run command (Phase 2 UAT gap) | 2026-06-10 | 39e2ee0 | | [260610-czd-fix-docs-deployment-md-local-dev-command](./quick/260610-czd-fix-docs-deployment-md-local-dev-command/) | -| 260610-hbu | Phase 5 reminder scheduler resilience (UAT Test 1 gap) — catch-up scan `(now, now+16min]` + per-uid exactly-once dedup so a missed/late cron tick no longer drops a reminder; lead-accurate body; also fixes pre-existing cross-tick double-fire. 10/10 reminder tests pass | 2026-06-10 | 19d92c6 | Verified | [260610-hbu-make-phase-5-reminder-scheduler-resilien](./quick/260610-hbu-make-phase-5-reminder-scheduler-resilien/) | -| 260610-i4x | Replace node-cron with setInterval in all 3 broker workers (poller/outbox/reminder) — node-cron 4.2.1 skipped EVERY scheduled execution in the long-running API process ("missed execution" each tick), so reminders/poll/outbox never fired on schedule. setInterval fires reliably (verified). 91 broker tests pass | 2026-06-10 | d9efbc1 | Verified | [260610-i4x-replace-node-cron-with-setinterval-in-ba](./quick/260610-i4x-replace-node-cron-with-setinterval-in-ba/) | -| 260610-jlp | Fix broken "How to enable" link in notifications-blocked UI (Phase 5 UAT Test 4) — extracted InstructionSheet into a shared component; SettingsSheet "How to enable" now opens the OS-step instructions instead of just closing the sheet. 187 pwa tests pass, build green | 2026-06-10 | f82837c | Verified | [260610-jlp-fix-broken-how-to-enable-link-in-notific](./quick/260610-jlp-fix-broken-how-to-enable-link-in-notific/) | -| 260610-k1z | Persist OIDC session cookie (AUTH-02) — @hono/oidc-auth 1.8.3 sets a session-scoped `oidc-auth` cookie (no maxAge) so it died on PWA/browser close → re-login almost every return (both devices). Added persistSessionCookie middleware re-issuing the cookie with maxAge(=OIDC_AUTH_EXPIRES)+SameSite=Lax, ONLY when a valid session exists (no resurrection guard). NOT an Authelia/refresh issue. 14 auth tests pass | 2026-06-10 | 8343fad | Verified | [260610-k1z-persist-oidc-session-cookie-with-maxage-](./quick/260610-k1z-persist-oidc-session-cookie-with-maxage-/) | -| 260610-ka9 | Fix silent Android push (Phase 5 UAT Test 4) — SW showNotification had only {body,tag,data} → Android Chromium/Edge showed them silently. Added icon/badge/renotify:true/vibrate; generalized re-enable instructions to Chrome-or-Edge. iOS unaffected. Build emits sw.js with renotify; 187 pwa tests pass | 2026-06-10 | c864fc4 | Verified | [260610-ka9-fix-silent-android-push-notifications-en](./quick/260610-ka9-fix-silent-android-push-notifications-en/) | -| 260611-ozt | Split publish job into standalone .gitea/workflows/publish.yml (on: push→main only, no redundant event-guard if:; MILESTONE env moved with it) and strip it + the push trigger from ci.yml — kills the orphaned `CI / publish (pull_request)` pending status (phase-8 code-review WR-01). name:CI + fast-checks/api/harness job ids held stable so the required branch-protection contexts stay valid. Documented the release model in README "Publishing / Releases" + publish.yml header. Both YAML validated (yq) | 2026-06-11 | 92353e1 | | [260611-ozt-split-publish-job-into-standalone-gitea-](./quick/260611-ozt-split-publish-job-into-standalone-gitea-/) | +| # | Description | Date | Commit | Status | Directory | +| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------- | +| 260606-tv8 | Fix missing sign-in redirect in the PWA (Phase 03 auth-entry gap from Gate 2): guarded /api/login → / + full-page redirect on unauthenticated fetchMe | 2026-06-07 | 7c6531f | | [260606-tv8-fix-missing-sign-in-redirect-in-the-pwa-](./quick/260606-tv8-fix-missing-sign-in-redirect-in-the-pwa-/) | +| 260607-l6l | Batch-fix Phase 03 write-path bugs: events.ts edit/delete missing calendars innerJoin (503, BLOCKING) + handler-coupled regression test; shared deriveDisplayName helper (me.ts + resolveUserId, corrects blank rows); GET /api/events userId/isShared ownership filter | 2026-06-07 | 2870413 | | [260607-l6l-fix-phase-03-write-path-correctness-bugs](./quick/260607-l6l-fix-phase-03-write-path-correctness-bugs/) | +| 260607-u8o | Record SSE-over-Pangolin smoke test PASS (Phase 4 entry gate, D-14 / issue #1034) — updated 01-HUMAN-UAT item 4 + 03-GATE2-RESULTS Part C to PASS with live evidence | 2026-06-08 | 26655cf | | [260607-u8o-record-sse-over-pangolin-smoke-test-pass](./quick/260607-u8o-record-sse-over-pangolin-smoke-test-pass/) | +| 260610-cr8 | Adopt drizzle generate+migrate workflow, retire db:push on MariaDB — removed db:push script + repointed deployment.md to migrate with anti-push warning; dry-verified no destructive diff | 2026-06-10 | 1a95d81 | Verified | [260610-cr8-adopt-drizzle-generate-migrate-workflow-](./quick/260610-cr8-adopt-drizzle-generate-migrate-workflow-/) | +| 260610-czd | Fix docs/deployment.md local-dev command — added "Running locally (host-side, no Docker)" subsection with correct env-sourced two-terminal run command (Phase 2 UAT gap) | 2026-06-10 | 39e2ee0 | | [260610-czd-fix-docs-deployment-md-local-dev-command](./quick/260610-czd-fix-docs-deployment-md-local-dev-command/) | +| 260610-hbu | Phase 5 reminder scheduler resilience (UAT Test 1 gap) — catch-up scan `(now, now+16min]` + per-uid exactly-once dedup so a missed/late cron tick no longer drops a reminder; lead-accurate body; also fixes pre-existing cross-tick double-fire. 10/10 reminder tests pass | 2026-06-10 | 19d92c6 | Verified | [260610-hbu-make-phase-5-reminder-scheduler-resilien](./quick/260610-hbu-make-phase-5-reminder-scheduler-resilien/) | +| 260610-i4x | Replace node-cron with setInterval in all 3 broker workers (poller/outbox/reminder) — node-cron 4.2.1 skipped EVERY scheduled execution in the long-running API process ("missed execution" each tick), so reminders/poll/outbox never fired on schedule. setInterval fires reliably (verified). 91 broker tests pass | 2026-06-10 | d9efbc1 | Verified | [260610-i4x-replace-node-cron-with-setinterval-in-ba](./quick/260610-i4x-replace-node-cron-with-setinterval-in-ba/) | +| 260610-jlp | Fix broken "How to enable" link in notifications-blocked UI (Phase 5 UAT Test 4) — extracted InstructionSheet into a shared component; SettingsSheet "How to enable" now opens the OS-step instructions instead of just closing the sheet. 187 pwa tests pass, build green | 2026-06-10 | f82837c | Verified | [260610-jlp-fix-broken-how-to-enable-link-in-notific](./quick/260610-jlp-fix-broken-how-to-enable-link-in-notific/) | +| 260610-k1z | Persist OIDC session cookie (AUTH-02) — @hono/oidc-auth 1.8.3 sets a session-scoped `oidc-auth` cookie (no maxAge) so it died on PWA/browser close → re-login almost every return (both devices). Added persistSessionCookie middleware re-issuing the cookie with maxAge(=OIDC_AUTH_EXPIRES)+SameSite=Lax, ONLY when a valid session exists (no resurrection guard). NOT an Authelia/refresh issue. 14 auth tests pass | 2026-06-10 | 8343fad | Verified | [260610-k1z-persist-oidc-session-cookie-with-maxage-](./quick/260610-k1z-persist-oidc-session-cookie-with-maxage-/) | +| 260610-ka9 | Fix silent Android push (Phase 5 UAT Test 4) — SW showNotification had only {body,tag,data} → Android Chromium/Edge showed them silently. Added icon/badge/renotify:true/vibrate; generalized re-enable instructions to Chrome-or-Edge. iOS unaffected. Build emits sw.js with renotify; 187 pwa tests pass | 2026-06-10 | c864fc4 | Verified | [260610-ka9-fix-silent-android-push-notifications-en](./quick/260610-ka9-fix-silent-android-push-notifications-en/) | +| 260611-ozt | Split publish job into standalone .gitea/workflows/publish.yml (on: push→main only, no redundant event-guard if:; MILESTONE env moved with it) and strip it + the push trigger from ci.yml — kills the orphaned `CI / publish (pull_request)` pending status (phase-8 code-review WR-01). name:CI + fast-checks/api/harness job ids held stable so the required branch-protection contexts stay valid. Documented the release model in README "Publishing / Releases" + publish.yml header. Both YAML validated (yq) | 2026-06-11 | 92353e1 | | [260611-ozt-split-publish-job-into-standalone-gitea-](./quick/260611-ozt-split-publish-job-into-standalone-gitea-/) | +| 260611-tfc | Fix WR-01 (13-REVIEW): apps/pwa/src/sw.ts notificationclick openWindow fallback was unreachable when client.focus() rejects (window closed between matchAll/focus) or client.navigate() resolves null — chained a navigate-result check + a .catch, both falling through to self.clients.openWindow(url). lint/format:check/typecheck green, build emits sw.js, 191/191 pwa tests | 2026-06-12 | af78ccc | Verified | [260611-tfc-fix-wr-01-sw-ts-notificationclick-openwi](./quick/260611-tfc-fix-wr-01-sw-ts-notificationclick-openwi/) | ## Deferred Items -| Category | Item | Status | Deferred At | -|----------|------|--------|-------------| -| Calendar | Single-occurrence recurring edit (RECURRENCE-ID) | v1.x | Roadmap | -| Calendar | "This and following" recurring edit | v1.x | Roadmap | -| Calendar | Apple Calendar native subscribe URL docs | v1.x | Roadmap | -| Calendar | Secondary timezone display toggle | v1.x | Roadmap | -| Display | Wall-display / kiosk dashboard | v2 | PROJECT.md | -| Reminders | Multiple reminders per event (2× VALARM) | v1.2 | v1.1 REQUIREMENTS.md | -| Onboarding | Self-service member app-password setup (999.5) — v1.1 covers admin-managed (ADMIN-01) | Backlog | v1.1 roadmap | -| Calendar | Provider abstraction (999.1) — Fastmail as one of several backends | Backlog | v1.1 roadmap | -| Setup | Wizard re-run / reconfigure flow after first setup | Backlog | v1.1 REQUIREMENTS.md | -| Notifications | **Android event-change push delivery (Phase 5 UAT Test 4)** — confirm member B's Android device receives a non-silent "A updated an event" push after member A edits a shared event. Blocking bugs already fixed + deployed (quick 260610-jlp how-to-enable link, 260610-ka9 silent-notification options); server-side FCM delivery proven (FCM 201). Remaining: on-device confirmation + operator raises the Edge/Android notification-channel importance. See 05-UAT.md Test 4. | Phase 6 verification | 2026-06-10 | -| ~~Calendar~~ | ~~Mark shared-family calendar `is_shared=1`~~ **RESOLVED 2026-06-10** — operator created the "FamilySync" calendar on the primary Fastmail account; poller synced it as calendars.id=10 (user 2); ran `UPDATE calendars SET is_shared=1 WHERE id=10`. Shared color lane now populated; Phase 5 reminders now fire on its events. Poller upsert does not touch is_shared, so the flag persists. (ADMIN-02 in v1.1 Phase 10 replaces this manual step with a UI toggle.) | ~~Phase 2 (deferred, D-16)~~ DONE | 2026-06-05 → 2026-06-10 | +| Category | Item | Status | Deferred At | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ----------------------- | +| Calendar | Single-occurrence recurring edit (RECURRENCE-ID) | v1.x | Roadmap | +| Calendar | "This and following" recurring edit | v1.x | Roadmap | +| Calendar | Apple Calendar native subscribe URL docs | v1.x | Roadmap | +| Calendar | Secondary timezone display toggle | v1.x | Roadmap | +| Display | Wall-display / kiosk dashboard | v2 | PROJECT.md | +| Reminders | Multiple reminders per event (2× VALARM) | v1.2 | v1.1 REQUIREMENTS.md | +| Onboarding | Self-service member app-password setup (999.5) — v1.1 covers admin-managed (ADMIN-01) | Backlog | v1.1 roadmap | +| Calendar | Provider abstraction (999.1) — Fastmail as one of several backends | Backlog | v1.1 roadmap | +| Setup | Wizard re-run / reconfigure flow after first setup | Backlog | v1.1 REQUIREMENTS.md | +| Notifications | **Android event-change push delivery (Phase 5 UAT Test 4)** — confirm member B's Android device receives a non-silent "A updated an event" push after member A edits a shared event. Blocking bugs already fixed + deployed (quick 260610-jlp how-to-enable link, 260610-ka9 silent-notification options); server-side FCM delivery proven (FCM 201). Remaining: on-device confirmation + operator raises the Edge/Android notification-channel importance. See 05-UAT.md Test 4. | Phase 6 verification | 2026-06-10 | +| ~~Calendar~~ | ~~Mark shared-family calendar `is_shared=1`~~ **RESOLVED 2026-06-10** — operator created the "FamilySync" calendar on the primary Fastmail account; poller synced it as calendars.id=10 (user 2); ran `UPDATE calendars SET is_shared=1 WHERE id=10`. Shared color lane now populated; Phase 5 reminders now fire on its events. Poller upsert does not touch is_shared, so the flag persists. (ADMIN-02 in v1.1 Phase 10 replaces this manual step with a UI toggle.) | ~~Phase 2 (deferred, D-16)~~ DONE | 2026-06-05 → 2026-06-10 | ## Session Continuity -Last session: 2026-06-11T22:00:00.000Z -Stopped at: Phase 08 complete — CI-01 + CI-02 delivered, publish job verified, SUMMARY + VERIFICATION written -Resume file: None — start Phase 09 with /gsd-plan-phase 9 +Last session: 2026-06-12T00:44:39.612Z +Stopped at: Phase 13 Plan 03 complete (Prettier reformat + CI format gate + green baseline) +Resume file: None ## Operator Next Steps diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md index 8ca2d16..74abba1 100644 --- a/.planning/codebase/ARCHITECTURE.md +++ b/.planning/codebase/ARCHITECTURE.md @@ -1,4 +1,5 @@ + # Architecture **Analysis Date:** 2026-06-09 @@ -50,53 +51,56 @@ └─ Fastmail CalDAV + app passwords ────────────────┘ (tsdav client, encrypted credentials) (PROPFIND, REPORT, PUT, DELETE) - + MariaDB (persistent cache) (read on every request, written by broker) ``` ## Component Responsibilities -| Component | Responsibility | File | -|-----------|----------------|------| -| **CalendarShell** | React entry point; wires TanStack Query + Schedule-X + Zustand + event modals | `apps/pwa/src/components/CalendarShell.tsx` | -| **AppNav** | Navigation header/sidebar; responsive (phone/tablet layout) | `apps/pwa/src/components/AppNav.tsx` | -| **EventDetailPopover** | Displays event details; driven by Zustand.openEventId | `apps/pwa/src/components/EventDetailPopover.tsx` | -| **EventForm** | Create/edit event modal; builds payloads for POST/PATCH endpoints | `apps/pwa/src/components/EventForm.tsx` | -| **SyncStateToast** | Toast polling `/api/events/sync-status?uid=` for optimistic-accept feedback | `apps/pwa/src/components/SyncStateToast.tsx` | -| **Zustand calendarStore** | UI-only state: selectedView, openEventId, eventFormOpen, calendarRange, deleteDialog state | `apps/pwa/src/store/calendarStore.ts` | -| **TanStack Query hooks** | Server state: fetchMe (user profile), fetchEvents (windowed occurrences), fetchSyncStatus | `apps/pwa/src/api/client.ts` + CalendarShell.tsx | -| **hydrateEvents** | Transforms raw CalendarOccurrence[] to Schedule-X CalendarType[] with Temporal dates | `apps/pwa/src/lib/hydrateEvents.ts` | -| **buildCalendarConfig** | Builds Schedule-X calendar configuration (views, plugins, columns) | `apps/pwa/src/lib/calendarConfig.ts` | -| **OIDC middleware** | Protects /api/* routes; 302-redirects unauthenticated requests to Authelia | `apps/api/src/auth/middleware.ts` | -| **devAuthBypass** | Dev-only passthrough auth for local development without Authelia | `apps/api/src/auth/devBypass.ts` | -| **upsertUser** | Identity upsert by (oidc_iss, oidc_sub); auto-assigns color from palette | `apps/api/src/auth/user.ts` | -| **eventsRouter** | GET /api/events (windowed reads), POST/PATCH/DELETE (enqueue outbox), GET /writable-calendars | `apps/api/src/routes/events.ts` | -| **meRouter** | GET /api/me — returns authenticated user profile (id, displayName, color) | `apps/api/src/routes/me.ts` | -| **sseRouter** | GET /api/sse/heartbeat — SSE smoke test for Pangolin proxy validation | `apps/api/src/routes/sse.ts` | -| **CalDAV Broker** | Decrypts credentials, manages tsdav clients, syncs calendars, drains outbox | `apps/api/src/broker/*.ts` | -| **Poller** | 5-min background job: PROPFIND → ctag comparison → skip or syncCalendar | `apps/api/src/broker/poller.ts` | -| **syncCalendar** | REPORT → ical.js parse → VEVENT upsert; prunes deleted events | `apps/api/src/broker/sync.ts` | -| **OutboxWorker** | 15-sec background job: drain pending outbox rows, build VEVENTs, PUT/DELETE to Fastmail | `apps/api/src/broker/outboxWorker.ts` | -| **expandOccurrences** | Server-side RRULE expansion via ical.js; produces concrete occurrences for UI | `apps/api/src/broker/expand.ts` | +| Component | Responsibility | File | +| ------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| **CalendarShell** | React entry point; wires TanStack Query + Schedule-X + Zustand + event modals | `apps/pwa/src/components/CalendarShell.tsx` | +| **AppNav** | Navigation header/sidebar; responsive (phone/tablet layout) | `apps/pwa/src/components/AppNav.tsx` | +| **EventDetailPopover** | Displays event details; driven by Zustand.openEventId | `apps/pwa/src/components/EventDetailPopover.tsx` | +| **EventForm** | Create/edit event modal; builds payloads for POST/PATCH endpoints | `apps/pwa/src/components/EventForm.tsx` | +| **SyncStateToast** | Toast polling `/api/events/sync-status?uid=` for optimistic-accept feedback | `apps/pwa/src/components/SyncStateToast.tsx` | +| **Zustand calendarStore** | UI-only state: selectedView, openEventId, eventFormOpen, calendarRange, deleteDialog state | `apps/pwa/src/store/calendarStore.ts` | +| **TanStack Query hooks** | Server state: fetchMe (user profile), fetchEvents (windowed occurrences), fetchSyncStatus | `apps/pwa/src/api/client.ts` + CalendarShell.tsx | +| **hydrateEvents** | Transforms raw CalendarOccurrence[] to Schedule-X CalendarType[] with Temporal dates | `apps/pwa/src/lib/hydrateEvents.ts` | +| **buildCalendarConfig** | Builds Schedule-X calendar configuration (views, plugins, columns) | `apps/pwa/src/lib/calendarConfig.ts` | +| **OIDC middleware** | Protects /api/\* routes; 302-redirects unauthenticated requests to Authelia | `apps/api/src/auth/middleware.ts` | +| **devAuthBypass** | Dev-only passthrough auth for local development without Authelia | `apps/api/src/auth/devBypass.ts` | +| **upsertUser** | Identity upsert by (oidc_iss, oidc_sub); auto-assigns color from palette | `apps/api/src/auth/user.ts` | +| **eventsRouter** | GET /api/events (windowed reads), POST/PATCH/DELETE (enqueue outbox), GET /writable-calendars | `apps/api/src/routes/events.ts` | +| **meRouter** | GET /api/me — returns authenticated user profile (id, displayName, color) | `apps/api/src/routes/me.ts` | +| **sseRouter** | GET /api/sse/heartbeat — SSE smoke test for Pangolin proxy validation | `apps/api/src/routes/sse.ts` | +| **CalDAV Broker** | Decrypts credentials, manages tsdav clients, syncs calendars, drains outbox | `apps/api/src/broker/*.ts` | +| **Poller** | 5-min background job: PROPFIND → ctag comparison → skip or syncCalendar | `apps/api/src/broker/poller.ts` | +| **syncCalendar** | REPORT → ical.js parse → VEVENT upsert; prunes deleted events | `apps/api/src/broker/sync.ts` | +| **OutboxWorker** | 15-sec background job: drain pending outbox rows, build VEVENTs, PUT/DELETE to Fastmail | `apps/api/src/broker/outboxWorker.ts` | +| **expandOccurrences** | Server-side RRULE expansion via ical.js; produces concrete occurrences for UI | `apps/api/src/broker/expand.ts` | ## Pattern Overview -**Overall:** Three-tier monolith (PWA frontend, Node.js backend, MariaDB), split between `apps/pwa` (React) and `apps/api` (Hono + broker). +**Overall:** Three-tier monolith (PWA frontend, Node.js backend, MariaDB), split between `apps/pwa` (React) and `apps/api` (Hono + broker). **Request-response pattern:** + - Frontend reads from MariaDB cache via REST endpoints (GET only) - Frontend enqueues writes to transactional outbox (POST/PATCH/DELETE return 202 immediately) - Background broker drains outbox, calls Fastmail CalDAV, updates cache - Real-time updates via SSE (Phase 4) and/or polling (SyncStateToast for write feedback) **Data ownership pattern:** + - Poller owns calendar collection discovery + change detection (D-13 ctag polling) - syncCalendar owns per-calendar event cache (REPORT → parse → upsert) - OutboxWorker owns write-back to Fastmail (D-05 transactional outbox) - Routes own read authorization and ownership checks (T-03-06..T-03-11) **Key Characteristics:** + - Events endpoint shares MariaDB cache — no direct Fastmail I/O from routes (T-03-02 broker boundary) - Write operations use optimistic-accept pattern: 202 + immediate UI response, success confirmed via polling - All server state in TanStack Query; UI state only in Zustand (clear separation) @@ -107,6 +111,7 @@ ## Layers **Presentation (React PWA):** + - Purpose: Display calendar, handle user interactions, manage UI state (view selection, modals, popovers) - Location: `apps/pwa/src/` - Contains: Components (CalendarShell, EventForm, EventDetailPopover, AppNav, SyncStateToast), UI hooks (CalendarShell's useQuery for data, Zustand for view state) @@ -114,6 +119,7 @@ - Used by: Browser tab (Vite dev proxy or production Pangolin tunnel) **API / Route Layer:** + - Purpose: Validate requests, enforce authorization (T-03-06..T-03-11), read from cache, enqueue writes - Location: `apps/api/src/routes/` - Contains: Route handlers (events.ts, me.ts, health.ts, sse.ts); Zod schemas for input validation @@ -122,6 +128,7 @@ - Architecture invariant: Routes **never** import tsdav or call Fastmail directly (T-03-02) **Database / ORM Layer:** + - Purpose: Type-safe query building, schema definition, migrations - Location: `apps/api/src/db/` - Contains: Drizzle schema (users, member_credentials, calendars, calendar_events, calendar_outbox), mysql2 client @@ -129,6 +136,7 @@ - Used by: All route handlers, broker modules **Broker / Background Worker Layer:** + - Purpose: Keep MariaDB calendar cache in sync with Fastmail; drain transactional outbox - Location: `apps/api/src/broker/` - Contains: Poller (5-min cron), syncCalendar (REPORT parse), OutboxWorker (15-sec drain), supporting utilities @@ -137,6 +145,7 @@ - Data sources: member_credentials (encrypted), calendars, calendar_events (cache), calendar_outbox (pending writes) **Auth / Session Layer:** + - Purpose: OIDC authentication via Authelia, user identity upsert, session cookies - Location: `apps/api/src/auth/` - Contains: Middleware (oidcAuthMiddleware, processOAuthCallback from @hono/oidc-auth), upsertUser color assignment, dev bypass @@ -159,6 +168,7 @@ 8. **Schedule-X render** — eventsService.set() updates calendar model; re-render with color routing (isShared ? 'shared' : String(ownerUserId)) **State Management:** + - TanStack Query caches result with key ['events', start, end]; staleTime 5 min - Zustand calendarRange (start/end) drives query key → navigation re-fetches - SyncStateToast polls `/api/events/sync-status?uid=` to show write-back progress @@ -200,6 +210,7 @@ - Prune deletes: DELETE events whose uid is no longer on server (BUG B: scope by (userId, url) for shared account) **Ownership Model (D-03, D-16):** + - Shared Fastmail account: both members' credentials fetch the same calendar collections - Stored as (userId, url) composite unique key so each member caches the same calendar separately - eventsRouter ownership check: calendar.userId = currentUserId OR isShared=true (writable set) @@ -208,26 +219,31 @@ ## Key Abstractions **CalendarOccurrence:** + - Purpose: Single concrete event occurrence ready for UI (expanded from RRULE if needed) - Examples: `apps/api/src/broker/expand.ts:CalendarOccurrence`, `apps/pwa/src/api/client.ts:CalendarOccurrence` - Pattern: Backend expands RRULE into N occurrences; each has stable id = `${uid}::${dtstart_iso}`, allowing Schedule-X dedup and Zustand.openEventId routing **Transactional Outbox (D-05):** + - Purpose: Decouple client request (202 response) from Fastmail write (async worker) - Examples: `apps/api/src/db/schema.ts:calendarOutbox` - Pattern: Write endpoint INSERTs pending row; worker POLLs and drains; status machine (pending → done/failed/dead) controls retry + backoff **Wrapped Schema Contract (D-13):** + - Purpose: Guarantee correct DATE vs TIMESTAMP storage for all-day vs timed events - Examples: `apps/api/src/db/schema.ts` (dtstartUtc, dtstartDate, allDay); `apps/api/src/broker/sync.ts` (storage logic); `apps/api/src/routes/events.ts` (window predicate) - Pattern: All-day events NEVER coerce to midnight-UTC (Pitfall 2); timed events always UTC; query pre-filters both branches **RRULE Expansion (D-09):** + - Purpose: Expand recurring masters server-side so client receives concrete occurrences only - Examples: `apps/api/src/broker/expand.ts:expandOccurrences`, `apps/pwa/src/lib/hydrateEvents.ts` (no expansion on client) - Pattern: Route calls expandOccurrences for each cached VEVENT; ical.js handles RRULE parsing, EXDATE exclusion, VTIMEZONE DST adjustment **Encrypted Credentials:** + - Purpose: Store Fastmail app passwords at rest without exposing plaintext - Examples: `apps/api/src/db/schema.ts:memberCredentials.encryptedPassword`, `apps/api/src/broker/crypto.ts:decryptPassword` - Pattern: AES-256-GCM with per-message nonce; stored as JSON { iv, authTag, ciphertext }; decrypted only immediately before tsdav client creation (T-03-04) @@ -235,36 +251,43 @@ ## Entry Points **Browser → PWA:** + - Location: `apps/pwa/src/main.tsx` (Vite SPA entry), `apps/pwa/src/App.tsx` (root component = CalendarShell) - Triggers: User navigates to / (domain root) or clicks Home - Responsibilities: Hydrate React app, mount CalendarShell, wire TanStack Query + Zustand **PWA → API:** + - Location: `apps/pwa/src/api/client.ts` (fetch functions) - Triggers: CalendarShell useQuery hooks on mount and navigation - Responsibilities: Fetch events, me profile, sync status; handle OIDC redirects via maybeRedirectToLogin **Unauthenticated User → OIDC:** + - Location: `apps/api/src/auth/middleware.ts` (oidcAuthMiddleware) -- Triggers: Unauthenticated fetch to /api/* endpoint +- Triggers: Unauthenticated fetch to /api/\* endpoint - Responsibilities: 302-redirect to Authelia /authorize; await callback at /callback; set session JWT cookie **OIDC Callback → API Login:** + - Location: `apps/api/src/index.ts:app.get('/callback')` and `apps/api/src/auth/middleware.ts:processOAuthCallback` - Triggers: Authelia POST to /callback after authorization-code exchange - Responsibilities: Exchange code for token, validate nonce, set JWT cookie with refresh token, redirect to /api/login **API Login → SPA Boot:** + - Location: `apps/api/src/index.ts:app.get('/api/login')` - Triggers: Top-level navigation after callback redirects here (or direct /api/login hit by PWA) - Responsibilities: Verify session cookie valid, 302-redirect to / so SPA boots authenticated **Background Poller:** + - Location: `apps/api/src/broker/poller.ts:startBrokerPoller`, called from `apps/api/src/index.ts` in isMainModule() guard - Triggers: 5-min node-cron schedule starting at API boot - Responsibilities: Load all credentials, PROPFIND calendars, compare ctag, call syncCalendar if changed **Outbox Worker:** + - Location: `apps/api/src/broker/outboxWorker.ts:startOutboxWorker`, called from `apps/api/src/index.ts` in isMainModule() guard - Triggers: 15-sec node-cron schedule starting at API boot - Responsibilities: Poll outbox WHERE status='pending', drain to Fastmail via write.ts, update status, trigger refetch @@ -319,4 +342,4 @@ --- -*Architecture analysis: 2026-06-09* +_Architecture analysis: 2026-06-09_ diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md index 4180c6d..f0a4cdb 100644 --- a/.planning/codebase/CONCERNS.md +++ b/.planning/codebase/CONCERNS.md @@ -5,6 +5,7 @@ ## Tech Debt **Drizzle-kit push unsafe on MariaDB 11:** + - Issue: `drizzle-kit push` emits false destructive DDL on MariaDB 11 (mysql dialect) — misreads table metadata and schedules column truncation in the migration diff. This destroys production data if applied blindly. - Files: `apps/api/src/db/schema.ts`, `apps/api/drizzle.config.ts`, `.planning/STATE.md` (D-Task5-DDL) - Impact: Any schema change requires manual validation. Automated push pipelines are unsafe. @@ -12,6 +13,7 @@ - Fix approach: Adopt `drizzle-kit generate+migrate` workflow for all future schema changes — generate the diff, manually review the SQL, then apply via migration file. Never use `push` on MariaDB without field-by-field validation. If multi-replica deployment is needed, consider PostgreSQL migration at that point. **Dev-auth bypass lacks production guard redundancy:** + - Issue: The `DEV_AUTH_BYPASS` environment variable is guarded by a `NODE_ENV !== 'production'` check in `index.ts` (line 19), but relies on correct deployment configuration. If `NODE_ENV` is accidentally omitted from the production Docker Compose, the bypass could activate. - Files: `apps/api/src/index.ts` (lines 19–26), `apps/api/src/auth/devBypass.ts` - Impact: Unauthenticated access to the API in production if misconfigured. @@ -19,12 +21,14 @@ - Fix approach: Add a startup assertion that logs an error and exits if `NODE_ENV !== 'production'` and `DEV_AUTH_BYPASS=true` are both detected. Consider a secondary check in the oidcAuthMiddleware instantiation. **Event datetime serialization was timezone-naive (FIXED in Phase 3):** + - Issue: The PWA's `EventForm` previously sent naive local wall-clock strings (no UTC offset) to the API; the outbox worker's `new Date(string)` parsed them in the container's UTC timezone, resulting in events written 4 hours early/late. Fixed in Phase 3 quick 260607-l6l. - Files: `apps/pwa/src/lib/eventDateTime.ts` (new), `apps/pwa/src/components/EventForm.tsx` (updated) - Impact: FIXED. Regression test added (`apps/pwa/src/lib/eventDateTime.test.ts`). - Fix status: Closed via commit 2870413 (2026-06-07). Serialization now uses `localWallClockToUtcIso()` to convert to UTC `Z` instant in the browser before sending to the API. **Calendar row deduplication cross-user bug (FIXED in Phase 3):** + - Issue: The poller and sync used `url`-only predicates to lookup calendar rows, but the two household members share one Fastmail account — the same collection URL exists for both. This caused events to be cached under the wrong member's calendar and duplicate rows accumulated on every poll. Fixed in Phase 3 via commit 2870413 and migration `0001_calendars_user_url_unique.sql`. - Files: `apps/api/src/broker/poller.ts` (line 52–56), `apps/api/src/broker/sync.ts` (line 62–66), `apps/api/src/db/schema.ts` (line 84), `apps/api/src/db/migrations/0001_calendars_user_url_unique.sql` - Impact: FIXED. Unique constraint `uniq_calendar_user_url` enforces (userId, url) identity; all predicates scoped correctly. @@ -35,12 +39,14 @@ ## Known Bugs **GET /api/events missing userId/isShared filter (IDENTIFIED, RESOLVED via 260607-l6l):** + - Symptoms: GET /api/events returned events from all users (including stale spike data), not just owned + shared calendars. - Files: `apps/api/src/routes/events.ts` (line 127–129 now filters correctly via resolveUserId) - Trigger: Any `/api/events` call without the ownership/isShared predicate in the JOIN. - Status: FIXED in commit 2870413. The route now filters: `WHERE currentUserId = userId OR isShared=1`. **Stale spike user + calendar data in production DB:** + - Symptoms: User id=1 ("Dev User", obsolete spike identity `oidc_iss='spike://cal-08'`) remains in the DB with 508 cached events under the now-deduplicated calendar row id=1. This is stale data, not a code bug. - Files: Live MariaDB (data only, not source code) - Impact: Low — new events written by the real users go to the correct rows (id=2, id=3 calendars). The spike data is not served to the app because the route filters by currentUserId. Safe to clean via a manual DB DELETE, but non-blocking. @@ -51,18 +57,21 @@ ## Security Considerations **Fastmail app password exposure risk:** + - Risk: The API loads and decrypts Fastmail app passwords from `member_credentials.encrypted_password`. If the encryption key is leaked or the decryption is implemented incorrectly, all calendar access is compromised. - Files: `apps/api/src/broker/crypto.ts`, `apps/api/src/broker/poller.ts` (line 41), `deployment.md` (Step 2 — key generation) - Current mitigation: AES-256-GCM encryption, key stored in `.env` (gitignored). Decrypted password never logged (T-03-04). Decryption happens only in `poller.ts` and `outboxWorker.ts`, not in HTTP routes. - Recommendations: (1) Ensure `.env` is marked .gitignore in CI/CD (already done). (2) Rotate encryption key monthly + re-encrypt all passwords — design a rotation mechanism before multi-replica deployment. (3) Monitor access logs for repeated failed calendar syncs (sign of credential tampering). (4) Consider a secrets manager (e.g., Docker Compose secrets) for the encryption key in production. **OIDC claim extraction fragility (Authelia defaults):** + - Risk: Authelia v4.39+ omits `name`, `email`, `preferred_username` from the ID token by default — requires a `claims_policy` config. The app's `deriveDisplayName()` (auth/user.ts) falls back through `name` → `preferred_username` → `email` → `sub`, but if Authelia is not configured with claims, all users appear as "Member" in the legend (observed in Phase 2). This is a configuration issue, not a code bug, but fragile. - Files: `apps/api/src/auth/user.ts` (lines 8–19), `docs/deployment.md` (Authelia client config, line 91–92 does NOT show claims_policy) - Current mitigation: The identity is keyed on `iss+sub` (never email), so display name is cosmetic. The legend displays correctly after identity is established. - Recommendations: (1) Add a `claims_policy` block to the example Authelia configuration in `docs/deployment.md` (or a separate `authelia-familysync-claims.yml` example). (2) Document that without claims, all users show as "Member" and that's non-blocking for v1 (they still get distinct colors via their `sub`). (3) Test Authelia claim extraction before Phase 5 push notifications are built (notification titles will need displayName). **SSE heartbeat endpoint carries no secrets but could be abuse vector:** + - Risk: `/api/sse/heartbeat` is authenticated (behind oidcAuthMiddleware) but emits only timestamps — no sensitive data. However, a malicious actor with a valid session could hold open many concurrent heartbeat streams, consuming server resources (DoS). - Files: `apps/api/src/routes/sse.ts` - Current mitigation: The endpoint is single-purpose (testing transport viability); Phase 4 will add real list-change SSE with per-user subscriptions. Resource limits are absent. @@ -73,18 +82,21 @@ ## Performance Bottlenecks **Calendar windowed query without pagination (acceptable for v1, scales to ~5000 events):** + - Problem: GET `/api/events?start=X&end=Y` returns all occurrences in the window with no pagination. The query is efficient (indexes on `dtstart_utc`, `dtstart_date`, `hasRrule`), but response size grows with window span and recurrence expansion. - Files: `apps/api/src/routes/events.ts` (line 126–170) - Cause: No pagination implemented. For a 2-person household with ~500 events/person and heavy recurring series, a month-view response is ~2–5 KB (acceptable). - Improvement path: (1) Monitor response time in Phase 4 (live sync will add per-user subscriptions). (2) If response >100 KB, add cursor-based pagination to the events endpoint. (3) Consider server-side caching of expansion results per (userId, window) for frequently-accessed ranges (e.g., current month). **Broker poller is full-scan every 5 minutes (acceptable for <10 members, mitigated by ctag):** + - Problem: `poller.ts` loops all member_credentials and calls `fetchCalendars()` on each, then compares ctag. For a 2-person household with 2 Fastmail accounts (shared calendars + personal), this is ~2–4 PROPFIND/REPORT calls per cycle. Scales poorly to >10 members. - Files: `apps/api/src/broker/poller.ts` (line 35–77) - Cause: No selective polling per calendar; all calendars checked every 5 minutes. - Improvement path: (1) For v1 (2–4 members), current approach is fine — ~10 req/min to Fastmail. (2) For Phase 1.x (N-member expansion, per STATE.md note): track last-known ctag per calendar and skip polling if unchanged; implement WebDAV-Sync (sync-token) for delta-only fetches (RFC 6578). (3) Monitor Fastmail API rate-limit headers (`X-RateLimit-*`) in logs. **Outbox worker retries backoff reaches 30 min max (acceptable, prevents spam):** + - Problem: The outbox retry window for a failed write is capped at ~30 min (BACKOFF_SECONDS: 15+60+300+600+1800). A transient Fastmail outage lasting >30 min will abandon the write as "dead" without user notification. - Files: `apps/api/src/broker/outboxWorker.ts` (line 46, MAX_ATTEMPTS=5) - Cause: Exponential backoff with a fixed cap to prevent infinite queuing. @@ -95,16 +107,19 @@ ## Fragile Areas **CalDAV event write-back lacks conflict resolution (D-08 mitigation exists, risk remains):** + - Files: `apps/api/src/broker/write.ts`, `apps/api/src/broker/outboxWorker.ts` (line 180–190), `docs/deployment.md` (Pitfall 14) - Why fragile: When a user edits an event in the app and another user edits it concurrently in the native Fastmail app, the outbox worker receives a 412 (If-Match conflict). The current behavior is to mark the outbox row as "failed" and trigger a re-sync. This is correct but provides no UI feedback to the user — they don't know their edit was rejected. If this happens repeatedly, the user will see the calendar diverge unpredictably. - Safe modification: (1) Add a `syncStatus` subscription in the PWA (already designed in Phase 3 Plan 03-06). The UI shows "sync conflict — your edit was rejected, event reloaded from server" in a toast. (2) If the outbox row is marked "failed", the next re-sync will pull the current server state. (3) For Phase 4+, consider implementing a "merge/overwrite" UI where the user can choose to force their edit if they're confident it's the right state. For v1, reject-and-reload is acceptable. **Recurring event expansion via rrule + EXDATE is CPU-sensitive (mitigated by window cap):** + - Files: `apps/api/src/broker/expand.ts`, `apps/api/src/routes/events.ts` (line 45, MAX_WINDOW_DAYS=90) - Why fragile: Expanding a 5-year-old weekly recurring event to a 90-day window generates ~50 occurrences. Expanding to a 1-year window generates ~250. If a user requests a 365-day window (not capped), the expansion becomes CPU-bound. - Safe modification: The MAX_WINDOW_DAYS=90 guard is in place (T-02b-02, DoS protection). No change needed. If Phase 6 adds a "year view", re-evaluate the expansion window and consider caching expanded results per (event.uid, window). **OIDC session middleware dependency on @hono/oidc-auth (tied to Authelia version):** + - Files: `apps/api/src/auth/middleware.ts`, package.json (@hono/oidc-auth: 1.8.3) - Why fragile: @hono/oidc-auth v1.8.3 assumes a specific OIDC metadata contract. If Authelia makes a breaking change in its .well-known/openid-configuration response, the middleware could fail silently (e.g., missing `token_endpoint`, `userinfo_endpoint`). - Safe modification: (1) Add a startup health check that fetches Authelia's OIDC metadata and logs an error if critical fields are missing. (2) Monitor Authelia release notes for OIDC spec changes. (3) Pin @hono/oidc-auth to 1.8.x in package.json (already done). (4) Test Authelia upgrades in a staging environment before deploying to production. @@ -114,16 +129,19 @@ ## Scaling Limits **Single-process deployment concurrency guard in outbox worker:** + - Current capacity: The outbox worker's drain-concurrency guard (CR-05, line 87–100) uses a module-level boolean flag. This is safe for a single-process Docker container but breaks if scaled to multiple API replicas. - Limit: If the API is deployed as N replicas behind a load balancer, the drain cycles can overlap and double-dispatch the same outbox row to Fastmail, causing duplicate writes. - Scaling path: (1) For v1 (single Unraid container), no change needed. (2) For multi-replica or Kubernetes: replace the module-level guard with a durable DB row claim (`UPDATE calendar_outbox SET status='processing' WHERE id=? AND status='pending'`). The first replica to claim wins; others skip that row. (3) Add a "processing" timeout (5 min) to prevent dead-replica claims from blocking the queue indefinitely. **In-memory SSE fan-out via EventEmitter (Phase 4 dependency, acceptable for single process):** + - Current capacity: Phase 4 will add live list-change SSE that broadcasts to connected clients. If implemented as a simple Node EventEmitter, each replica process maintains its own in-memory subscriptions. A member on replica A updates a list; the SSE fires on replica A but replica B's connections don't see it (if the member's browser is routed to replica B after the update). - Limit: Limited to single-process deployment or requires Redis Pub/Sub for fan-out across replicas. - Scaling path: (1) For v1 (single container), EventEmitter is fine. (2) For Phase 4+, if multi-replica is needed: design the SSE layer to use Redis Pub/Sub for cross-process broadcasts. Add ioredis to package.json (it's already recommended in CLAUDE.md). See PITFALLS.md §Pitfall 15 for sequence-number replay strategy. **Redis not yet installed (Phase 4 dependency, scheduled for list sync):** + - Current status: The app has no Redis dependency. Phase 4 will require Redis for pub/sub (list-change broadcasts across processes/replicas). - Impact: v1 is single-process; live sync works fine without Redis. Phase 4+ requires it. - Remediation: Add Redis to docker-compose.yml in Phase 4. ioredis client already in package.json recommendations (CLAUDE.md, Table 1). Configure connection pooling (ioredis default: 8 connections). @@ -133,16 +151,19 @@ ## Dependencies at Risk **@hono/oidc-auth peer dependency on Authelia RFC compliance:** + - Risk: @hono/oidc-auth relies on Authelia conforming to OIDC RFC 6749/6234. If Authelia introduces a non-standard endpoint or claim format, the middleware may fail. - Impact: OIDC login would break; users cannot access the app. - Migration plan: If Authelia breaks OIDC compatibility, replace @hono/oidc-auth with `openid-client` (a lower-level OIDC library). Estimated effort: 2–3 days to wire custom middleware. openid-client is already in CLAUDE.md as an escape hatch (Table 1, row 3). **tsdav maintained by single contributor (NateLinDev/tsdav):** + - Risk: The CalDAV client library `tsdav@2.2.2` has low maintenance activity. If a Fastmail CalDAV protocol change occurs or a critical bug is found, the library may not be updated promptly. - Impact: Calendar sync could break (PROPFIND, REPORT, PUT all depend on tsdav). - Migration plan: (1) For v1, tsdav is stable and proven in this codebase. (2) If maintenance becomes a blocker, the next option is to implement CalDAV PROPFIND/REPORT directly via fetch + xml2js (Pitfall 1 explicitly warns against this, but it's doable). Estimated effort: 1 week to implement a minimal CalDAV client. (3) Monitor tsdav GitHub issues and PRs. **ical.js reference implementation (kewisch/ical.js):** + - Risk: ical.js is the Mozilla-maintained RRULE/iCalendar reference implementation, but Mozilla does not actively develop calendar software. If a new RFC 5545 edge case is discovered (e.g., an RRULE rule that breaks ical.js), it may not be fixed quickly. - Impact: Recurring events could expand incorrectly (rare, but affects display). - Migration plan: (1) For v1, ical.js is the most reliable available. (2) If a bug is found, open an issue on GitHub; Mozilla is responsive to reference-implementation bugs. (3) Fallback: use `rrule` library only (lighter weight) if ical.js is abandoned, but rrule is less comprehensive for EXDATE/RECURRENCE-ID handling. @@ -152,12 +173,14 @@ ## Missing Critical Features **Single-occurrence recurring event override (deferred to v1.x):** + - Problem: A user cannot edit or delete a single occurrence of a recurring event (e.g., "skip next Tuesday's meeting"). The edit-as-move write path (D-04) supports full-series edits only. - Blocks: Users frustrated when they want to reschedule one instance. - Deferred reason: Requires RECURRENCE-ID write-back (RFC 5545) and complex VCALENDAR patching. Estimated effort: 2–3 days of implementation + testing. For v1, edit-all is acceptable for a 2-person household. - Resolution approach: Phase 6 or v1.x — implement a "Edit this and all following" option that re-dates the RRULE UNTIL and creates a new series from the edit date onward. **Notification subscription health-check (CRITICAL for Phase 5, deferred to Phase 5 implementation):** + - Problem: iOS silently revokes Web Push subscriptions after 3 silent push events (Pitfall 9). The app must detect this and re-subscribe automatically. - Blocks: Phase 5 (push notifications) cannot be considered production-ready without this. - Missing implementation: No subscription health-check exists in the PWA yet. The service worker needs to call `pushManager.getSubscription()` on every page open and compare the endpoint to the server's stored endpoint; if they differ, re-subscribe. @@ -168,30 +191,35 @@ ## Test Coverage Gaps **Events API route (GET /api/events, POST /create, PATCH /edit, DELETE /delete) has integration-level testing but lacks edge cases:** + - What's not tested: (1) Window boundary conditions (start=end, off-by-one day shifts). (2) Recurring all-day events with complex EXDATE. (3) Concurrent edit conflict (412 handling). (4) Ownership assertions with mixed owned + shared calendars. - Files: `apps/api/tests/routes/events.test.ts` (126 lines, covers happy paths + 400/403 error cases) - Risk: Edge cases in expansion or ownership filtering could silently pass tests and break in production. - Priority: MEDIUM — add 10–15 test cases before Phase 4 (live sync will depend on ownership filtering being bulletproof). **Outbox worker state machine (retry backoff, edit-as-move ordering, dead-letter) has unit tests but lacks end-to-end CalDAV integration:** + - What's not tested: (1) Outbox row with a real Fastmail endpoint (mocked in tests). (2) 412 conflict response from Fastmail + re-sync flow. (3) Concurrent outbox rows from the same list (edit+delete pair ordering under network failures). (4) Recovery after a multi-hour Fastmail outage. - Files: `apps/api/tests/broker/outboxWorker.test.ts` (state-machine tests only) - Risk: Silent data loss if outbox row ordering is wrong under failures; list sync will depend on correct write ordering. - Priority: HIGH — add integration tests before Phase 4. Mock Fastmail CalDAV responses (conflict, transient, success) and verify state transitions. **PWA EventForm timezone serialization (fixed in Phase 3, regression test exists but limited scope):** + - What's not tested: (1) Daylight Saving Time transitions (create event on March 12, spring-forward boundary). (2) Cross-timezone consistency (create event in Toronto, verify UTC serialization, reload in UTC, confirm display is Toronto wall-clock). (3) All-day event edge cases (midnight boundary serialization). - Files: `apps/pwa/src/lib/eventDateTime.test.ts` (5 cases: timed → UTC, all-day → DATE, round-trip) - Risk: Similar timezone bug could reappear if eventDateTime.ts is refactored without comprehensive DST testing. - Priority: MEDIUM — add 5–10 DST/all-day edge cases to the test suite before Phase 6 (UX polish will touch date/time handling). **PWA service worker and offline behavior untested:** + - What's not tested: (1) Service worker install, activation, and update lifecycle. (2) Offline calendar view (reads from cache). (3) Offline list mutation (queues for sync). (4) Cache expiration strategy. - Files: Service worker is auto-generated by vite-plugin-pwa; offline behavior is unimplemented in Phase 1–3. - Risk: Phase 4's offline queue and Phase 5's background sync depend on correct SW lifecycle. Silent failures in SW updates could leave the wife on a stale version. - Priority: MEDIUM — Phase 4 should include SW unit tests (simulate offline, verify cache reads, verify mutation queue behavior). **Mobile-specific behavior (iOS push, PWA standalone mode, permissions) untested by vitest:** + - What's not tested: (1) iOS 16.4+ push subscription (requires real device). (2) Standalone PWA launch (requires Add-to-Home-Screen). (3) Permission request flow (requires user gesture). (4) Camera/location permissions (out of scope for v1, but worth listing). - Files: Not applicable (device-only testing). - Risk: High impact if broken (wife can't install, can't receive notifications). Mitigated by human UAT (Phase 3 Gate 2 item 4). @@ -202,16 +230,19 @@ ## Architectural Constraints & Anti-Patterns **Single-process assumption in outbox drain guard (CR-05, documented but constrains scaling):** + - Constraint: The module-level boolean flag `let isProcessing = false` in outboxWorker.ts assumes a single Node.js process. This is correct for the Unraid single-container deployment but breaks if scaled horizontally. - Consequence: Multi-replica deployments MUST implement a durable DB claim (UPDATE … WHERE status='processing') before the API is horizontally scaled. - Workaround: Documented in code comment (line 91–100). Clear and easy to address when scaling is needed. **No pagination on calendar events endpoint (acceptable for v1, design assumption):** + - Constraint: GET /api/events returns all occurrences in the window with no pagination. Designed for a 90-day max window and <1000 occurrences per window (acceptable for 2-person household). - Consequence: Very large windows or households with hundreds of recurring events could generate multi-MB responses. - Workaround: MAX_WINDOW_DAYS=90 guard prevents DoS. For Phase 4+, if response size exceeds 500 KB, add cursor pagination. **Dev-auth bypass is development-only but deployment-critical (configuration risk):** + - Constraint: The bypass is designed for local development (NODE_ENV !== 'production' + DEV_AUTH_BYPASS=true). If the bypass is accidentally enabled in production, the OIDC guard is completely bypassed. - Consequence: Unauthenticated API access if misconfigured. - Workaround: (1) .env.example has DEV_AUTH_BYPASS commented out. (2) docker-compose.yml MUST NOT include DEV_AUTH_BYPASS in env. (3) Documented in docs/deployment.md. Recommended: add a startup assertion to double-check. @@ -221,12 +252,14 @@ ## Infrastructure & Deployment Concerns **Drizzle migrations require manual SQL review (no auto-apply in Docker):** + - Issue: The app does not auto-migrate on startup. The `drizzle-kit push` command is unsafe on MariaDB. Manual `drizzle-kit migrate` must be run once per DB version before the app starts. - Files: `apps/api/src/db/migrations/`, `docs/deployment.md` (Step 3: `drizzle-kit push` is the documented command, but should be `migrate` or `generate+migrate` for production safety) - Impact: If the operator forgets to migrate after pulling a new schema, the app will crash on startup (missing tables). The error message should be clear. - Fix approach: (1) Update `docs/deployment.md` Step 3 to use `migrate` instead of `push`. (2) Add a startup health check in `src/db/client.ts` that verifies all expected tables exist; fail with a clear message if any are missing. (3) Document the migration process in a DEPLOYMENT.md subsection. **Pangolin SSE idle timeout dependency (D-14, issue #1034) verified but residual risk remains:** + - Issue: SSE streams can be cut by proxy idle-timeout. The Phase 4 entry gate smoke test PASSED (6 min without cut), but only tested on the test domain `familysync-dev.bergerhouse.net`. - Files: `docs/deployment.md` (line 165–170), `.planning/phases/04-shared-lists-live-sync/04-CONTEXT.md` - Impact: If the production Pangolin idle-timeout is lower than the test rig, SSE will be cut during live list sync. Users will experience brief disconnects (mitigated by reconnect logic in Phase 4). @@ -238,16 +271,19 @@ ## Known Limitations (Documented as Design Decisions) **Personal calendar sharing requires manual Fastmail setup (D-16 CAL-08 spike result):** + - Limitation: The two household members' personal Fastmail calendars are accessed via per-member app passwords (not a shared broker token). This requires each member to generate an app password and register it in the app. - Impact: Acceptable. The unified view works correctly and scales to shared + personal calendars. - Status: GO decision (CAL-08-DECISION.md, Phase 1). **Recurring event edit supports edit-all only (single-occurrence override deferred to v1.x):** + - Limitation: The write path does not support RECURRENCE-ID overrides. Editing a recurring event changes all future occurrences. - Impact: Users cannot reschedule a single meeting. For a 2-person household, edit-all is acceptable. - Status: Documented in STATE.md (deferred items), Phase 6 planning. **EU DMA compliance risk for EU-based households (Pitfall 11):** + - Limitation: iOS 17.4+ in EU countries removes standalone PWA mode and push support due to Digital Markets Act. FamilySync's push notifications would not work for an EU user. - Impact: If the household moves to EU or uses EU Apple IDs, notifications are unavailable. - Status: This is a Canadian household (me@lucasberger.ca, .ca domain, Unraid self-hosted). Documented as not applicable but worth flagging for future. @@ -255,4 +291,4 @@ --- -*Concerns audit: 2026-06-09* +_Concerns audit: 2026-06-09_ diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md index 69a8220..a6423a7 100644 --- a/.planning/codebase/CONVENTIONS.md +++ b/.planning/codebase/CONVENTIONS.md @@ -5,6 +5,7 @@ ## Naming Patterns **Files:** + - Backend route handlers: `camelCase.ts` — `events.ts`, `me.ts`, `health.ts` (`apps/api/src/routes/`) - Broker modules: `camelCase.ts` — `poller.ts`, `sync.ts`, `write.ts`, `expand.ts` (`apps/api/src/broker/`) - Frontend components: `PascalCase.tsx` — `EventForm.tsx`, `CalendarShell.tsx`, `InstallPrompt.tsx` (`apps/pwa/src/components/`) @@ -12,12 +13,14 @@ - Tests: `{filename}.test.ts` or `.test.tsx` co-located with source **Functions:** + - Private helpers (not exported): `camelCase` — `claimStr()`, `getBreakpointGroup()`, `viewStorageKey()`, `resolveUserId()` - Exported async handlers: `camelCase` — `fetchMe()`, `createEvent()`, `expandOccurrences()`, `upsertUser()` - React hooks (Zustand): `useCalendarStore`, `useXxxx` pattern — follows React convention - Type guard / coercion functions: `camelCase` — `deriveDisplayName()`, `claimStr()` **Variables:** + - Constants (module-level): `SCREAMING_SNAKE_CASE` — `MAX_WINDOW_DAYS`, `SHARED_FAMILY_COLOR`, `COLOR_PALETTE`, `FIXTURES` - Local state: `camelCase` — `currentUserId`, `targetCalendarUrl`, `windowStartDate`, `eventRow` - Zustand store methods: `camelCase` setters — `setSelectedView()`, `setEventForm()`, `setLastSyncedUid()` @@ -26,6 +29,7 @@ - Database column mappings: `snake_case` in schema → `camelCase` in TypeScript (Drizzle handles mapping) **Types/Interfaces:** + - TypeScript interfaces: `PascalCase` — `MeUser`, `MeResponse`, `CalendarOccurrence`, `WritableCalendar`, `CalendarStore`, `SyncStatus` - Zod schemas: `camelCase` + `Schema` suffix — `eventsQuerySchema`, `eventFieldsSchema`, `syncStatusQuerySchema` - Union types (enums): `PascalCase` or quoted literals in types — `'create' | 'update' | 'delete'`, `'pending' | 'done' | 'failed' | 'dead'` @@ -33,6 +37,7 @@ - DB column names: `snake_case` — `dtstart_utc`, `dtstart_date`, `oidc_iss`, `oidc_sub` **Drizzle ORM tables:** + - Table function: `mysqlTable('table_name', {...})` - Column names in schema def: use snake_case strings — `int('user_id')`, `varchar('oidc_iss', ...)` - TypeScript field names (destructured queries): auto-convert to camelCase via Drizzle's default mode @@ -44,6 +49,7 @@ ## Code Style **Formatting:** + - No explicit ESLint or Prettier config files in the codebase (uses project defaults) - 2-space indentation (inferred from source code) - Single quotes for strings (`'string'`, not `"string"`) @@ -51,21 +57,23 @@ - No trailing commas in function calls; trailing commas in object/array literals (modern style) **Linting:** + - TypeScript: `strict: true` in both backend and frontend `tsconfig.json` - Module resolution: `NodeNext` (backend), `Bundler` (frontend) - No `any` types — use `Context` from Hono where typing is available **Example formatting (from `routes/events.ts` line 64):** + ```typescript async function resolveUserId(c: Context): Promise { - const devUser = c.get('user') as { id: number } | undefined - if (devUser) return devUser.id + const devUser = c.get('user') as { id: number } | undefined; + if (devUser) return devUser.id; - const auth = await getAuth(c) - if (!auth) return null + const auth = await getAuth(c); + if (!auth) return null; - const iss = (auth.iss as string | undefined) ?? '' - const sub = auth.sub ?? '' + const iss = (auth.iss as string | undefined) ?? ''; + const sub = auth.sub ?? ''; // ... } ``` @@ -73,6 +81,7 @@ async function resolveUserId(c: Context): Promise { ## Import Organization **Order:** + 1. Node.js built-ins (`import { ... } from 'node:...'`) 2. Third-party packages (`import { ... } from 'hono'`, `import { ... } from 'drizzle-orm'`) 3. Local absolute imports (backend: none; frontend: none visible — no path aliases configured) @@ -80,10 +89,12 @@ async function resolveUserId(c: Context): Promise { 5. Side-effect imports (import without destructuring, placed last) — `import '../auth/devBypass.js'` **Path extensions:** + - All imports use explicit `.js` extensions — `from './index.js'`, `from '../db/client.js'` - Applies to both backend and frontend (ESM module resolution) **Example (from `routes/events.ts` lines 24–38):** + ```typescript import { randomUUID } from 'node:crypto' // Node.js built-in import { Hono } from 'hono' // Third-party @@ -105,6 +116,7 @@ import '../auth/devBypass.js' // Side-effect import (last) **Patterns:** **Backend (Hono routes):** + - Early return with typed `c.json(...)` on validation or auth failure — `return c.json({ error: 'message' }, statusCode)` - Try-catch blocks wrap DB/external I/O, catch logs error + returns 503 Service Unavailable - No unhandled rejections — every async operation has explicit error handling @@ -112,6 +124,7 @@ import '../auth/devBypass.js' // Side-effect import (last) - Validation failures: return 400 Bad Request with error envelope **Example (from `routes/events.ts` lines 126–225):** + ```typescript eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { const currentUserId = await resolveUserId(c) @@ -136,28 +149,30 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { ``` **Frontend (React + TanStack Query):** + - Fetch client throws on non-ok response; caller handles redirect logic (`maybeRedirectToLogin()`) - API client checks `res.type === 'opaqueredirect'` and `res.status === 401` to detect auth failure (CORS-safe 302 handling) - Component state via Zustand; server state via React Query - No inline try-catch in components — defer to query error states **Example (from `api/client.ts` lines 28–53):** + ```typescript export async function fetchMe(): Promise { const res = await fetch('/api/me', { credentials: 'include', redirect: 'manual', - }) + }); if (res.type === 'opaqueredirect' || res.status === 401) { - throw new Error('GET /api/me: authentication required') + throw new Error('GET /api/me: authentication required'); } if (!res.ok) { - throw new Error(`GET /api/me failed: ${res.status}`) + throw new Error(`GET /api/me failed: ${res.status}`); } - return res.json() as Promise + return res.json() as Promise; } ``` @@ -166,6 +181,7 @@ export async function fetchMe(): Promise { **Framework:** Console methods only (`console.log`, `console.error`, `console.warn`) **Patterns:** + - Errors logged with context prefix in square brackets — `console.error('[events]', message)`, `console.error('[broker/sync]', message)` - Startup messages logged at info level — `console.log('FamilySync API running on ...')` - Dev-mode warnings prefixed with warning emoji-ish symbol — `console.warn('⚠ DEV_AUTH_BYPASS active ...')` @@ -173,19 +189,21 @@ export async function fetchMe(): Promise { - Errors include the full exception object for stack trace — `console.error('[events] DB query failed:', err)` **Example (from `index.ts` lines 23, 111):** + ```typescript if (devBypassActive) { - console.warn('⚠ DEV_AUTH_BYPASS active — OIDC guard DISABLED. Never use in production.') + console.warn('⚠ DEV_AUTH_BYPASS active — OIDC guard DISABLED. Never use in production.'); } // ... serve({ fetch: app.fetch, port: 3000 }, (info) => { - console.log(`FamilySync API running on http://localhost:${info.port}`) -}) + console.log(`FamilySync API running on http://localhost:${info.port}`); +}); ``` ## Comments **When to Comment:** + - Complex algorithms or non-obvious business logic — e.g., window date filtering in `routes/events.ts` (lines 142–151) - Security assertions or threat-model references — e.g., ownership checks (T-03-06), CSRF-token patterns - Architectural invariants — e.g., "broker boundary: this route reads ONLY from cache" (routes/events.ts:4) @@ -193,11 +211,13 @@ serve({ fetch: app.fetch, port: 3000 }, (info) => { - Workarounds and why they exist — e.g., "WR-04: carrier/groupId for edit-as-move txn" (routes/events.ts:373) **JSDoc/TSDoc:** + - Used for public exported functions, not for every function - Single-line for simple functions; multi-line with `@param` and `@returns` for complex signatures - Comments on types (interfaces) to document contract — e.g., `CalendarOccurrence` interface (api/client.ts:71–87) **Example (from `auth/user.ts` lines 25–32):** + ```typescript /** * Accessible, visually-distinct palette for per-member color assignment. @@ -217,16 +237,19 @@ export const COLOR_PALETTE: string[] = [...] **Size:** Prefer short, single-responsibility functions. Route handlers are the exception — they bundle validation, ownership check, and response assembly (pragmatism for Hono idiom). **Parameters:** + - Use Hono's `Context` type rather than destructuring everything — `async (c: Context)` - Explicit parameters for helper functions; Hono context passed implicitly where possible - Zod validators return typed objects via `c.req.valid('json')` or `c.req.valid('query')` **Return Values:** + - Async functions return typed values or throw — `Promise` or `Promise` - Error responses returned explicitly (not thrown) — callers handle 4xx/5xx in same try-catch - Database queries return typed Drizzle result objects; destructure as needed **Example (from `auth/user.ts` lines 79–142):** + ```typescript export async function upsertUser( oidcIss: string, @@ -253,22 +276,26 @@ export async function upsertUser( ## Module Design **Exports:** + - Named exports for functions and types — `export const TABLE`, `export function handler()`, `export interface Type` - No default exports (exception: SPA app shell `App.tsx` uses default export) - Re-export from middleware modules for convenience — `auth/middleware.ts` re-exports `@hono/oidc-auth` functions **Barrel Files:** + - No wildcard re-exports (`export * from ...`) — explicit named exports only - Top-level index files not used (each module imported directly) **Example (from `auth/middleware.ts` lines 24–26):** + ```typescript -export { oidcAuthMiddleware, processOAuthCallback, getAuth } from '@hono/oidc-auth' +export { oidcAuthMiddleware, processOAuthCallback, getAuth } from '@hono/oidc-auth'; ``` ## Database Patterns **Drizzle conventions (critical):** + - Schema definition: `mysqlTable('name', { id: int().primaryKey().autoincrement(), ... }, (t) => [...])` - Foreign keys: ALWAYS include `{ onDelete: 'cascade' }` to propagate deletes cleanly - Indexes: Explicit index names with `idx_` prefix on frequently filtered columns @@ -276,12 +303,14 @@ export { oidcAuthMiddleware, processOAuthCallback, getAuth } from '@hono/oidc-au - Never use `db:push` on populated MariaDB (false destructive diffs) — ALWAYS use `generate + migrate` **Query patterns:** + - Use Drizzle's type-safe query builder: `db.select(...).from(table).where(...).limit(...)` - Raw SQL via `` sql`...` `` for complex predicates (e.g., multi-condition OR chains in events.ts:167–201) - Parameterized values via `sql` template tag prevent SQL injection - Joins: explicitly `innerJoin()` or `leftJoin()` with `.on(eq(...))` conditions **Example (from `db/schema.ts` lines 96–123):** + ```typescript export const calendarEvents = mysqlTable( 'calendar_events', @@ -298,24 +327,27 @@ export const calendarEvents = mysqlTable( index('idx_calendar_events_has_rrule').on(t.hasRrule), unique('uniq_calendar_uid').on(t.calendarId, t.uid), ], -) +); ``` ## Reactive State (Frontend) **TanStack Query (Server State):** + - All calendar events, lists, user profile live in React Query - Queries keyed by API endpoint + windowing params — `['events', { start, end }]` - Mutations handle POST/PATCH/DELETE; invalidate cache on success - Use `useQuery` for reads, `useMutation` for writes; never mix server state into Zustand **Zustand (UI State):** + - Owns only UI-shape state: `selectedView`, `openEventId`, `eventFormOpen`, `deleteDialogOpen`, etc. - Persists breakpoint-scoped `selectedView` to `localStorage` - Never store server data (user profile, events) — keep it in React Query - Setters are synchronous; no side effects (except localStorage in `setSelectedView`) **Example (from `store/calendarStore.ts` lines 1–25):** + ```typescript /** * Zustand UI-state store for the calendar shell. @@ -327,4 +359,4 @@ export const calendarEvents = mysqlTable( --- -*Convention analysis: 2026-06-09* +_Convention analysis: 2026-06-09_ diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md index 75f6367..33d4272 100644 --- a/.planning/codebase/INTEGRATIONS.md +++ b/.planning/codebase/INTEGRATIONS.md @@ -5,6 +5,7 @@ ## APIs & External Services **CalDAV (Fastmail):** + - Fastmail CalDAV endpoint - Calendar read/write for all household calendars - SDK/Client: tsdav 2.2.2 (`apps/api/src/broker/client.ts`) - Auth: Basic auth with Fastmail app password (per-member, stored encrypted in `member_credentials` table) @@ -14,6 +15,7 @@ - Parse responses via ical.js; expand recurrence with rrule **OIDC (Authelia):** + - Authelia OIDC identity provider - User authentication and session management - SDK/Client: @hono/oidc-auth 1.8.3 (`apps/api/src/auth/middleware.ts`) - Auth method: Authorization-code flow with PKCE (S256 challenge method) @@ -27,6 +29,7 @@ ## Data Storage **Databases:** + - MariaDB 11 - Primary relational database (required; PostgreSQL not available) - Connection: Environment vars (DB_HOST, DB_PORT 3306, DB_USER, DB_PASSWORD, DB_NAME) - Client: mysql2 3.22.4 (native driver via Drizzle ORM) @@ -37,12 +40,14 @@ - Local dev: Docker service `mariadb` with healthcheck; data persisted to `mariadb_data` volume **File Storage:** + - Local filesystem only - PWA static assets built by Vite - Location: Built output copied to `apps/api/dist/public` (Dockerfile pwa-builder stage) - Served by Hono via serveStatic middleware on the same :3000 port - No external cloud storage (S3, GCS, etc.) **Caching:** + - Redis 7-Alpine - Declared in docker-compose.yml but unused in Phase 1 - Reserved for Phase 4 live list sync (pub/sub for broadcasting list-change events across Node processes) - Local dev: Docker service `redis` on port 6379 @@ -51,6 +56,7 @@ ## Authentication & Identity **Auth Provider:** + - Authelia (self-hosted, pre-deployed on Unraid host) - Implementation: RFC-compliant OIDC provider - User identity: Composite key of oidc_iss + oidc_sub (never email, per D-10 in schema) @@ -59,6 +65,7 @@ - Claims policy: Authelia 4.39+ required for name/email/preferred_username in ID token (otherwise defaults to "Member" display name) **Dev Bypass (non-production only):** + - DEV_AUTH_BYPASS environment variable (NODE_ENV !== 'production') - When enabled: Skips @hono/oidc-auth middleware; injects DEV_USER into context - Allows local development without live Authelia instance @@ -67,14 +74,17 @@ ## Monitoring & Observability **Error Tracking:** + - Not detected - Errors logged to console; no external service integration **Logs:** + - Console-based - Events logged to stdout/stderr - Backend (Hono): Startup message, CalDAV poller errors (per-credential logging, T-03-04), outbox worker status - Frontend: React error boundaries catch component errors **Health Check:** + - GET /health endpoint (unauthenticated) - Endpoint: `apps/api/src/routes/health.ts` - Used by Docker Compose healthcheck for mariadb service @@ -83,15 +93,18 @@ ## CI/CD & Deployment **Hosting:** + - Docker on Unraid host (self-hosted) - Container image: Single production image from Dockerfile (API + PWA on port :3000) - Orchestration: Docker Compose (docker-compose.yml + docker-compose.dev.yml overrides) - Environment: Split-DNS internal domain; private IPs internally; external access via Pangolin/Newt tunnel **CI Pipeline:** + - Not detected - No GitHub Actions, GitLab CI, or similar configured **Build Output:** + - Docker multi-stage build: - API: TypeScript compiled to `apps/api/dist/` by tsc - PWA: Vite bundles to `apps/pwa/dist/`; copied to `apps/api/dist/public` in production image @@ -100,6 +113,7 @@ ## Environment Configuration **Required env vars (Backend):** + - Database: DB_HOST, DB_PORT (default 3306), DB_USER, DB_PASSWORD, DB_NAME, DB_ROOT_PASSWORD - OIDC: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_AUTH_EXTERNAL_URL (mandatory for Pangolin redirects) - Session: OIDC_AUTH_SECRET (32+ chars for JWT cookie signing) @@ -109,18 +123,21 @@ - Dev override: DEV_AUTH_BYPASS (set to 'true' to disable OIDC; dev-only, NODE_ENV !== 'production') **Secrets location:** + - `.env` file (local development) — not committed; pattern documented in docker-compose.yml - Docker Compose environment variables — injected at runtime from `.env` or deployment config - Member app passwords: Encrypted in DB (member_credentials.encryptedPassword) using APP_PASSWORD_ENCRYPTION_KEY - OIDC client secret: Plain text in env var (NOT the pbkdf2 hash from Authelia config) **Optional env vars:** + - OIDC_AUTH_EXTERNAL_URL - MANDATORY behind Pangolin for correct redirect_uri construction (Pitfall 1) - DEV_AUTH_BYPASS - Dev-only; local testing without Authelia ## Webhooks & Callbacks **Incoming:** + - /callback - OIDC authorization-code exchange endpoint - Mounted in `apps/api/src/index.ts` before oidcAuthMiddleware - Receives POST from Authelia after user login; exchanges code for tokens @@ -128,6 +145,7 @@ - Critical: Must not be intercepted by service worker (navigateFallbackDenylist in vite.config.ts) **Outgoing:** + - None detected - No third-party webhooks triggered by the app - Fastmail CalDAV: Changes are POLLED (5-min cron poller), not webhook-driven - List sync (Phase 4): Will use SSE (server-sent events) for client push, not webhooks @@ -135,12 +153,14 @@ ## Network & Transport **HTTPS/TLS:** + - Mandatory for OIDC flows - Pangolin/Newt tunnel provides HTTPS reverse proxy - Internal domain: Split-DNS routes internal requests directly to private IP - External requests: Routed through Pangolin tunnel **Server-Sent Events (SSE):** + - GET /api/sse/heartbeat - Test endpoint for Pangolin compatibility - Endpoint: `apps/api/src/routes/sse.ts` - Uses Hono's streamSSE helper @@ -148,9 +168,10 @@ - Phase 4 will extend this for live list sync **CORS:** + - Credentials: 'include' for all fetch calls (session cookie sent cross-origin in dev proxy) - redirect: 'manual' for /api/me to detect OIDC redirect (prevents fetch hang on cross-origin 302 to Authelia) --- -*Integration audit: 2026-06-09* +_Integration audit: 2026-06-09_ diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md index 647d5d4..3459e77 100644 --- a/.planning/codebase/STACK.md +++ b/.planning/codebase/STACK.md @@ -5,20 +5,24 @@ ## Languages **Primary:** + - TypeScript 5.5.x - Full stack: backend (`apps/api/src`), frontend (`apps/pwa/src`), shared types - JavaScript - Package tooling (node-cron, vite config, drizzle config) **Secondary:** + - CSS - Styling (imported via Vite; Schedule-X provides default theme) - HTML - PWA manifest generation via vite-plugin-pwa ## Runtime **Environment:** + - Node.js 22 LTS (`FROM node:22-alpine` in Dockerfile) - Browser: ES2023 target; iOS 16.4+ (PWA home-screen install required) **Package Manager:** + - pnpm 11.5.1 - Lockfile: `pnpm-lock.yaml` present - Workspace: `pnpm-workspace.yaml` with `apps/*` packages @@ -26,16 +30,19 @@ ## Frameworks **Core (Backend):** + - Hono 4.12.23 - HTTP framework with Web Standards API; `@hono/node-server` for Node.js runtime - @hono/oidc-auth 1.8.3 - OIDC session middleware (Authelia integration; storage-less JWT cookies) - @hono/zod-validator 0.8.0 - Request body/query validation in route handlers **Core (Frontend):** + - React 19.x - PWA frontend with concurrent features - Vite 8.0.16 - Build tooling (dev server with HMR, production bundler) - vite-plugin-pwa 1.3.0 - Service worker registration, PWA manifest generation, Workbox 7 integration **Calendar UI:** + - @schedule-x/react 4.1.0 - Calendar component wrapper - @schedule-x/calendar 4.6.0 - Core calendar rendering - @schedule-x/event-modal 4.6.0 - Event detail/edit modal @@ -44,45 +51,55 @@ - @schedule-x/theme-default 4.6.0 - Default theme (CSS overridden by `apps/pwa/src/styles/tokens.css`) **Client State:** + - @tanstack/react-query 5.101.0 - Server state fetching, caching, background refetch, invalidation - zustand 5.0.14 - UI-only state (selected date range, color assignments, drawer states) **Testing (Backend):** + - Vitest 4.1.8+ - Unit + integration test runner; config: `apps/api/vitest.config.ts` (environment: node, globals: true) **Testing (Frontend):** + - Vitest 4.1.8+ - Unit test runner; config: `apps/pwa/vitest.config.ts` (environment: jsdom, TZ=UTC for deterministic date tests) - @testing-library/react 16.3.0 - Component testing utilities - @testing-library/jest-dom 6.6.3+ - Jest DOM matchers **Build/Dev:** + - @vitejs/plugin-react 4.3.0+ - JSX transform, React Fast Refresh ## Key Dependencies **Critical (CalDAV):** + - tsdav 2.2.2 - CalDAV client for Fastmail integration; fetches calendars (PROPFIND) and events (REPORT); handles Basic auth - ical.js 2.2.1 - iCalendar (.ics) parsing on both backend (CalDAV responses) and frontend (event hydration); Mozilla-maintained reference implementation - rrule 2.8.1 - Not yet declared; RRULE expansion for recurring event expansion (Phase 2 calendar view) **Critical (Database):** + - drizzle-orm 0.45.2 - Type-safe SQL ORM; MySQL dialect targeting MariaDB; zero runtime overhead - drizzle-kit 0.31.10 - Schema migration generator (generates SQL from `apps/api/src/db/schema.ts`) - mysql2 3.22.4 - Native MariaDB/MySQL driver; Promises API; used by Drizzle **Critical (Validation):** + - zod 3.25.0+ - Schema validation (event payloads, API requests) **Supporting (Backend):** + - node-cron 4.2.1+ - Cron scheduling for CalDAV poller (5-min), outbox worker (15-sec) - temporal-polyfill 0.3.2 - Temporal API polyfill for date/time operations (ISO 8601 handling) **Supporting (Frontend):** + - temporal-polyfill 0.3.2 - Same Temporal polyfill; imported before Schedule-X at `apps/pwa/src/main.tsx:7` - lucide-react 1.17.0 - Icon library - idb 7.1.1 - IndexedDB wrapper (optional; available but not yet wired) **Development Only:** + - @types/node 22.x - Node.js type definitions - @types/react 19.x - React type definitions - @types/react-dom 19.x - React DOM type definitions @@ -91,15 +108,18 @@ ## Configuration **Environment (Backend — `apps/api`):** + - `.env` - Local secrets (DB credentials, OIDC settings, encryption key); pattern in `docker-compose.yml` - `drizzle.config.ts` - Dialect: mysql; schema path: `./src/db/schema.ts`; migrations: `./src/db/migrations` - `tsconfig.json` - Target: ES2023; module: NodeNext; strict: true **Environment (Frontend — `apps/pwa`):** -- `vite.config.ts` - React plugin, PWA plugin (Workbox config with navigateFallback and denylist for /callback, /api/*, /health) + +- `vite.config.ts` - React plugin, PWA plugin (Workbox config with navigateFallback and denylist for /callback, /api/\*, /health) - `tsconfig.json` - Target: ES2023; lib: [ES2023, DOM, DOM.Iterable]; jsx: react-jsx; strict: true **Build (Docker):** + - Multi-stage Dockerfile (`apps/api/Dockerfile`): - `base` - Node 22 Alpine with pnpm enabled - `builder` - TypeScript compilation for API only @@ -110,6 +130,7 @@ ## Platform Requirements **Development:** + - Node.js 22 LTS - pnpm 11.5.1 - Docker + Docker Compose (for local MariaDB + Redis) @@ -118,6 +139,7 @@ - Vite dev server proxy: `localhost:3000` for /api, /callback, /health **Production:** + - Node.js 22 LTS runtime in Docker container - Authelia OIDC provider (pre-deployed; configured via env vars) - MariaDB 11 database @@ -127,4 +149,4 @@ --- -*Stack analysis: 2026-06-09* +_Stack analysis: 2026-06-09_ diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md index f56d263..70152c2 100644 --- a/.planning/codebase/STRUCTURE.md +++ b/.planning/codebase/STRUCTURE.md @@ -93,12 +93,14 @@ familysync/ ## Directory Purposes **`apps/api/src/`** — Backend HTTP server and background broker + - **Routes** respond to client requests (GET reads cache only; POST/PATCH/DELETE enqueue outbox) - **Broker** runs background jobs (poller syncs with Fastmail; outbox worker drains writes) - **Auth** handles OIDC session + user identity upsert - **DB** defines schema and provides Drizzle ORM client **`apps/pwa/src/`** — React PWA frontend + - **Components** render UI and handle user interactions - **API** wraps typed fetch calls to backend endpoints - **Store** owns UI-only state (view selection, modal open/close) via Zustand @@ -107,6 +109,7 @@ familysync/ - **Styles** defines design tokens (colors, spacing, typography) **`apps/api/tests/`** — Unit tests for backend + - **Routes** test endpoint validation, authorization, DB queries - **Broker** test CalDAV sync logic, RRULE expansion, outbox draining - **Auth** test user upsert, color assignment, OIDC claim handling @@ -114,103 +117,104 @@ familysync/ - **Helpers** provide test utilities (mock Drizzle, mock tsdav clients) **`packages/shared/`** — Shared types (future expansion for N-member) + - Currently a placeholder; will contain cross-app TypeScript interfaces when multi-member features need shared definitions ## Key File Locations **Entry Points:** -| File | Purpose | -|------|---------| +| File | Purpose | +| ----------------------- | ------------------------------------------------------------------------- | | `apps/api/src/index.ts` | Hono app definition, middleware stack, route registration, broker startup | -| `apps/pwa/src/main.tsx` | Vite entry point; React.createRoot, hydrate App | -| `apps/pwa/src/App.tsx` | Root component; renders CalendarShell | +| `apps/pwa/src/main.tsx` | Vite entry point; React.createRoot, hydrate App | +| `apps/pwa/src/App.tsx` | Root component; renders CalendarShell | **Configuration:** -| File | Purpose | -|------|---------| -| `apps/api/package.json` | Backend dependencies (Hono, Drizzle, tsdav, ical.js, rrule, node-cron, zod, @hono/zod-validator, @hono/oidc-auth, mysql2) | -| `apps/pwa/package.json` | Frontend dependencies (React 19, Vite, @tanstack/react-query, Zustand, @schedule-x/react, lucide-react, etc.) | -| `apps/api/tsconfig.json` | strict: true; lib: es2022; module: es2022 | -| `apps/pwa/tsconfig.json` | strict: true; jsx: react-jsx; lib: es2022, dom | -| `apps/pwa/vite.config.ts` | Vite plugins (react, VitePWA); dev proxy to :3000; PWA manifest config | +| File | Purpose | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `apps/api/package.json` | Backend dependencies (Hono, Drizzle, tsdav, ical.js, rrule, node-cron, zod, @hono/zod-validator, @hono/oidc-auth, mysql2) | +| `apps/pwa/package.json` | Frontend dependencies (React 19, Vite, @tanstack/react-query, Zustand, @schedule-x/react, lucide-react, etc.) | +| `apps/api/tsconfig.json` | strict: true; lib: es2022; module: es2022 | +| `apps/pwa/tsconfig.json` | strict: true; jsx: react-jsx; lib: es2022, dom | +| `apps/pwa/vite.config.ts` | Vite plugins (react, VitePWA); dev proxy to :3000; PWA manifest config | **Core Logic:** -| File | Purpose | -|------|---------| -| `apps/api/src/db/schema.ts` | Drizzle table definitions (users, member_credentials, calendars, calendar_events, calendar_outbox) | -| `apps/api/src/routes/events.ts` | GET /api/events (windowed + expanded), write endpoints (POST/PATCH/DELETE), sync-status polling, writable-calendars | -| `apps/api/src/broker/poller.ts` | 5-min background job; PROPFIND → ctag change detection | -| `apps/api/src/broker/sync.ts` | REPORT → ical.js parse → calendar_events upsert; prune deletes | -| `apps/api/src/broker/outboxWorker.ts` | 15-sec drain pending outbox rows; PUT/DELETE to Fastmail; exponential backoff | -| `apps/api/src/broker/expand.ts` | ical.js RecurExpansion; emit concrete occurrences (with VTIMEZONE + RRULE handled) | -| `apps/pwa/src/components/CalendarShell.tsx` | TanStack Query (events, me), Zustand (range, view), Schedule-X wiring | -| `apps/pwa/src/store/calendarStore.ts` | Zustand store; selectedView, openEventId, calendarRange, eventFormOpen, deleteDialogOpen | -| `apps/pwa/src/api/client.ts` | Typed fetch wrappers; MeResponse, CalendarOccurrence, CreateEventPayload interfaces | -| `apps/pwa/src/lib/hydrateEvents.ts` | Occurrence[] → Schedule-X CalendarEvent[] with Temporal.ZonedDateTime conversion | +| File | Purpose | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `apps/api/src/db/schema.ts` | Drizzle table definitions (users, member_credentials, calendars, calendar_events, calendar_outbox) | +| `apps/api/src/routes/events.ts` | GET /api/events (windowed + expanded), write endpoints (POST/PATCH/DELETE), sync-status polling, writable-calendars | +| `apps/api/src/broker/poller.ts` | 5-min background job; PROPFIND → ctag change detection | +| `apps/api/src/broker/sync.ts` | REPORT → ical.js parse → calendar_events upsert; prune deletes | +| `apps/api/src/broker/outboxWorker.ts` | 15-sec drain pending outbox rows; PUT/DELETE to Fastmail; exponential backoff | +| `apps/api/src/broker/expand.ts` | ical.js RecurExpansion; emit concrete occurrences (with VTIMEZONE + RRULE handled) | +| `apps/pwa/src/components/CalendarShell.tsx` | TanStack Query (events, me), Zustand (range, view), Schedule-X wiring | +| `apps/pwa/src/store/calendarStore.ts` | Zustand store; selectedView, openEventId, calendarRange, eventFormOpen, deleteDialogOpen | +| `apps/pwa/src/api/client.ts` | Typed fetch wrappers; MeResponse, CalendarOccurrence, CreateEventPayload interfaces | +| `apps/pwa/src/lib/hydrateEvents.ts` | Occurrence[] → Schedule-X CalendarEvent[] with Temporal.ZonedDateTime conversion | **Testing:** -| File | Purpose | -|------|---------| -| `apps/api/tests/routes/events.test.ts` | Unit tests for route handlers (validation, ownership checks, SQL correctness) | -| `apps/api/tests/broker/expand.test.ts` | Unit tests for RRULE expansion (VTIMEZONE, EXDATE, DST) | -| `apps/pwa/src/components/CalendarShell.test.tsx` | Component integration test; mocked React Query + Zustand | -| `apps/pwa/src/lib/hydrateEvents.test.ts` | Unit tests for Temporal conversion logic | +| File | Purpose | +| ------------------------------------------------ | ----------------------------------------------------------------------------- | +| `apps/api/tests/routes/events.test.ts` | Unit tests for route handlers (validation, ownership checks, SQL correctness) | +| `apps/api/tests/broker/expand.test.ts` | Unit tests for RRULE expansion (VTIMEZONE, EXDATE, DST) | +| `apps/pwa/src/components/CalendarShell.test.tsx` | Component integration test; mocked React Query + Zustand | +| `apps/pwa/src/lib/hydrateEvents.test.ts` | Unit tests for Temporal conversion logic | ## Naming Conventions **Files:** -| Pattern | Example | Where | -|---------|---------|-------| -| Kebab-case for route/route groups | `events.ts`, `health.ts` | `apps/api/src/routes/` | -| Kebab-case for modules | `poller.ts`, `sync.ts`, `outbox-worker.ts` (or camelCase `outboxWorker.ts`) | `apps/api/src/broker/` | -| PascalCase for React components | `CalendarShell.tsx`, `EventDetailPopover.tsx` | `apps/pwa/src/components/` | -| Kebab-case for utility functions | `hydrateEvents.ts`, `colorUtils.ts` | `apps/pwa/src/lib/` | -| `.test.ts` / `.test.tsx` for tests | `events.test.ts`, `CalendarShell.test.tsx` | Colocated with source | +| Pattern | Example | Where | +| ---------------------------------- | --------------------------------------------------------------------------- | -------------------------- | +| Kebab-case for route/route groups | `events.ts`, `health.ts` | `apps/api/src/routes/` | +| Kebab-case for modules | `poller.ts`, `sync.ts`, `outbox-worker.ts` (or camelCase `outboxWorker.ts`) | `apps/api/src/broker/` | +| PascalCase for React components | `CalendarShell.tsx`, `EventDetailPopover.tsx` | `apps/pwa/src/components/` | +| Kebab-case for utility functions | `hydrateEvents.ts`, `colorUtils.ts` | `apps/pwa/src/lib/` | +| `.test.ts` / `.test.tsx` for tests | `events.test.ts`, `CalendarShell.test.tsx` | Colocated with source | **Functions:** -| Pattern | Example | -|---------|---------| -| camelCase for functions | `fetchEvents`, `expandOccurrences`, `upsertUser`, `syncCalendar` | -| PascalCase for React components | `CalendarShell`, `EventForm`, `SyncStateToast` | -| UPPER_CASE for module-level constants | `MAX_WINDOW_DAYS`, `COLOR_PALETTE`, `TRANSIENT_STATUSES` | -| Leading `$` for Drizzle special methods | `.$returningId()`, `.onDuplicateKeyUpdate()` | +| Pattern | Example | +| --------------------------------------- | ---------------------------------------------------------------- | +| camelCase for functions | `fetchEvents`, `expandOccurrences`, `upsertUser`, `syncCalendar` | +| PascalCase for React components | `CalendarShell`, `EventForm`, `SyncStateToast` | +| UPPER_CASE for module-level constants | `MAX_WINDOW_DAYS`, `COLOR_PALETTE`, `TRANSIENT_STATUSES` | +| Leading `$` for Drizzle special methods | `.$returningId()`, `.onDuplicateKeyUpdate()` | **Variables:** -| Pattern | Example | -|---------|---------| -| camelCase for variables | `currentUserId`, `calendarRange`, `eventsQuery` | -| `is`/`has` prefix for booleans | `isShared`, `hasRrule`, `eventFormOpen` | -| Trailing `Id` for foreign keys | `userId`, `calendarId`, `groupId` | -| Descriptive names for arrays | `seenUids`, `usedColors`, `occurrences` | +| Pattern | Example | +| ------------------------------ | ----------------------------------------------- | +| camelCase for variables | `currentUserId`, `calendarRange`, `eventsQuery` | +| `is`/`has` prefix for booleans | `isShared`, `hasRrule`, `eventFormOpen` | +| Trailing `Id` for foreign keys | `userId`, `calendarId`, `groupId` | +| Descriptive names for arrays | `seenUids`, `usedColors`, `occurrences` | **Types:** -| Pattern | Example | -|---------|---------| -| PascalCase for interfaces | `CalendarOccurrence`, `MeResponse`, `CreateEventPayload` | -| PascalCase for type aliases | `RecurrencePreset`, `BreakpointGroup` | -| Trailing `Schema` for Zod/validation | `eventsQuerySchema`, `eventFieldsSchema` | -| Trailing `Response` for API responses | `MeResponse`, `OccurrencesResponse` | +| Pattern | Example | +| ------------------------------------- | -------------------------------------------------------- | +| PascalCase for interfaces | `CalendarOccurrence`, `MeResponse`, `CreateEventPayload` | +| PascalCase for type aliases | `RecurrencePreset`, `BreakpointGroup` | +| Trailing `Schema` for Zod/validation | `eventsQuerySchema`, `eventFieldsSchema` | +| Trailing `Response` for API responses | `MeResponse`, `OccurrencesResponse` | ## Where to Add New Code **New Feature:** -| Feature Type | Primary Code | Tests | Configuration | -|--------------|--------------|-------|---------------| -| Calendar event operation (read-only) | `apps/api/src/routes/events.ts` (new GET endpoint) | `apps/api/tests/routes/events.test.ts` | `apps/pwa/src/api/client.ts` (new fetchFn) | -| Calendar event operation (write) | `apps/api/src/routes/events.ts` (new POST/PATCH/DELETE) + `apps/api/src/broker/write.ts` (new builder) | Route tests + outbox drain tests | `apps/pwa/src/components/EventForm.tsx` (new field) | -| Recurring event handling | `apps/api/src/broker/expand.ts` (expansion logic) | `apps/api/tests/broker/expand.test.ts` | N/A (no UI change needed) | -| Shared list sync | `apps/api/src/routes/lists.ts` (new router) + `apps/api/src/broker/listsSync.ts` (if background job needed) | `apps/api/tests/routes/lists.test.ts` | `apps/pwa/src/api/client.ts` (new interfaces) | -| UI component (calendar display) | `apps/pwa/src/components/` | `apps/pwa/src/components/*.test.tsx` | N/A | -| UI component (modal/dialog) | `apps/pwa/src/components/` + `apps/pwa/src/store/calendarStore.ts` (add state if needed) | Component test | N/A | +| Feature Type | Primary Code | Tests | Configuration | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------- | +| Calendar event operation (read-only) | `apps/api/src/routes/events.ts` (new GET endpoint) | `apps/api/tests/routes/events.test.ts` | `apps/pwa/src/api/client.ts` (new fetchFn) | +| Calendar event operation (write) | `apps/api/src/routes/events.ts` (new POST/PATCH/DELETE) + `apps/api/src/broker/write.ts` (new builder) | Route tests + outbox drain tests | `apps/pwa/src/components/EventForm.tsx` (new field) | +| Recurring event handling | `apps/api/src/broker/expand.ts` (expansion logic) | `apps/api/tests/broker/expand.test.ts` | N/A (no UI change needed) | +| Shared list sync | `apps/api/src/routes/lists.ts` (new router) + `apps/api/src/broker/listsSync.ts` (if background job needed) | `apps/api/tests/routes/lists.test.ts` | `apps/pwa/src/api/client.ts` (new interfaces) | +| UI component (calendar display) | `apps/pwa/src/components/` | `apps/pwa/src/components/*.test.tsx` | N/A | +| UI component (modal/dialog) | `apps/pwa/src/components/` + `apps/pwa/src/store/calendarStore.ts` (add state if needed) | Component test | N/A | **New Endpoint:** @@ -241,6 +245,7 @@ familysync/ ## Special Directories **`apps/api/src/db/migrations/`:** + - Purpose: drizzle-kit-generated SQL migration files - Generated: Yes (via `drizzle-kit generate:mysql`) - Committed: Yes (must be version-controlled for reproducibility) @@ -248,26 +253,30 @@ familysync/ - How to apply: Run `drizzle-kit migrate:mysql` to execute pending migrations against MariaDB **`apps/pwa/public/`:** + - Purpose: PWA static assets served at root (manifest.webmanifest, service worker, icons, index.html) - Generated: `sw.js` and `registerSW.js` are generated by vite-plugin-pwa; others are committed - Committed: Yes (except dist/ and generated service worker code — PWA plugin handles registration) - How to add: Place assets here; vite build copies to dist/ and serves at / **`apps/api/dist/` and `apps/pwa/dist/`:** + - Purpose: Compiled output (JavaScript, CSS, bundled PWA) - Generated: Yes (via build scripts) - Committed: No (gitignored) **`node_modules/`:** + - Purpose: pnpm-installed dependencies - Generated: Yes (via `pnpm install`) - Committed: No (gitignored; use `pnpm-lock.yaml` for reproducibility) **`.planning/codebase/`:** + - Purpose: Auto-generated codebase analysis documents (this file, ARCHITECTURE.md, TESTING.md, etc.) - Generated: Yes (by `/gsd-map-codebase` orchestrator) - Committed: Yes (reference documentation for future phases) --- -*Structure analysis: 2026-06-09* +_Structure analysis: 2026-06-09_ diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md index a9e47fe..217ed1d 100644 --- a/.planning/codebase/TESTING.md +++ b/.planning/codebase/TESTING.md @@ -5,16 +5,19 @@ ## Test Framework **Runner:** + - Backend: Vitest 4.1.8, Node environment - Frontend: Vitest 4.1.8, jsdom environment - Config: `apps/api/vitest.config.ts`, `apps/pwa/vitest.config.ts` **Assertion Library:** + - Vitest built-in `expect()` - Testing Library (`@testing-library/react`, `@testing-library/jest-dom`) for component DOM assertions - `jest-dom` matchers extended via `apps/pwa/src/test-setup.ts` **Run Commands:** + ```bash # Run all tests pnpm test @@ -30,14 +33,17 @@ vitest run --coverage ## Test File Organization **Location:** + - Backend: `apps/api/tests/` parallel to `apps/api/src/` — mirrors source structure - Frontend: Co-located with source files — `src/components/Foo.tsx` → `src/components/Foo.test.tsx` **Naming:** + - Test files: `{module}.test.ts` or `.test.tsx` - Fixtures: `apps/api/tests/fixtures/` — fixture files (e.g., `weekly-dst.ics`) loaded by test helpers **Structure:** + ``` apps/api/tests/ ├── health.test.ts # End-to-end test for GET /health @@ -76,39 +82,41 @@ apps/pwa/src/ ## Test Structure **Suite Organization:** + ```typescript -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; describe('GET /health', () => { it('returns 200 with { ok: true, db: "up" } when DB round-trip succeeds', async () => { // Arrange - const { app } = await import('../src/index.js') - + const { app } = await import('../src/index.js'); + // Act - const res = await app.request('/health') - + const res = await app.request('/health'); + // Assert - expect(res.status).toBe(200) - const body = await res.json() as { ok: boolean; db: string } - expect(body.ok).toBe(true) - }) + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean; db: string }; + expect(body.ok).toBe(true); + }); it('returns 503 when DB round-trip throws', async () => { // Arrange - const { db } = await import('../src/db/client.js') - vi.mocked(db.execute).mockRejectedValueOnce(new Error('DB connection failed')) + const { db } = await import('../src/db/client.js'); + vi.mocked(db.execute).mockRejectedValueOnce(new Error('DB connection failed')); // Act - const { app } = await import('../src/index.js') - const res = await app.request('/health') + const { app } = await import('../src/index.js'); + const res = await app.request('/health'); // Assert - expect(res.status).toBe(503) - }) -}) + expect(res.status).toBe(503); + }); +}); ``` **Patterns:** + - Async test functions with full await chain - Hono request testing: `app.request(path)` returns a Response object - Mock setup in `beforeEach`; cleanup in `afterEach` with `vi.unstubAllGlobals()` or `vi.clearAllMocks()` @@ -120,25 +128,27 @@ describe('GET /health', () => { **Framework:** Vitest `vi` object (`vi.mock`, `vi.mocked`, `vi.fn`, `vi.stubGlobal`) **Module Mocking:** + ```typescript // Hoist vi.mock() calls to the top of the module (Vitest requirement) vi.mock('../src/db/client.js', () => ({ db: { execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]), }, -})) +})); ``` **Function Mocking:** + ```typescript -const mockFetch = vi.mocked(fetch) +const mockFetch = vi.mocked(fetch); mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ uid: 'test-uid' }), -} as Response) +} as Response); // Call the function under test -await createEvent(payload) +await createEvent(payload); // Assert the mock was called correctly expect(mockFetch).toHaveBeenCalledWith( @@ -147,27 +157,30 @@ expect(mockFetch).toHaveBeenCalledWith( method: 'POST', credentials: 'include', }), -) +); ``` **Global Stubs (Frontend):** + ```typescript beforeEach(() => { - vi.stubGlobal('fetch', vi.fn()) -}) + vi.stubGlobal('fetch', vi.fn()); +}); afterEach(() => { - vi.unstubAllGlobals() -}) + vi.unstubAllGlobals(); +}); ``` **What to Mock:** + - External I/O: database (via `vi.mock` on `src/db/client.js`) - Network calls: `fetch` (via `vi.stubGlobal('fetch', ...)`) - Environment-dependent code: `window.matchMedia` (jsdom polyfill, see test-setup.ts) - Time-dependent code: `Date`, `setTimeout` (if needed; not used currently) **What NOT to Mock:** + - Pure utility functions — test them directly (colorUtils, eventDateTime, hydrateEvents) - Zod validation schemas — test with real payloads - Zustand stores — instantiate real store, call real methods @@ -181,7 +194,7 @@ Fixture files are `.ics` (iCalendar) strings stored in `apps/api/tests/fixtures/ ```typescript // Load fixture file -const rawVevent = readFileSync(join(FIXTURES, 'weekly-dst.ics'), 'utf8') +const rawVevent = readFileSync(join(FIXTURES, 'weekly-dst.ics'), 'utf8'); // Use in test const occurrences = expandOccurrences( @@ -194,7 +207,7 @@ const occurrences = expandOccurrences( 'Alice', '#4A90D9', false, -) +); ``` **Test Data (Frontend):** @@ -205,14 +218,25 @@ vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => ({ calendars: [ - { url: 'https://caldav.fastmail.com/cal1', displayName: 'My Calendar', color: '#4A90D9', isShared: false }, - { url: 'https://caldav.fastmail.com/cal2', displayName: 'Family', color: '#F25C7A', isShared: true }, + { + url: 'https://caldav.fastmail.com/cal1', + displayName: 'My Calendar', + color: '#4A90D9', + isShared: false, + }, + { + url: 'https://caldav.fastmail.com/cal2', + displayName: 'Family', + color: '#F25C7A', + isShared: true, + }, ], }), -} as Response) +} as Response); ``` **Location:** + - Fixture files: `apps/api/tests/fixtures/` — raw .ics strings for iCalendar tests - Mock payloads: inline in test files (`api/client.test.ts`, etc.) @@ -221,10 +245,12 @@ vi.mocked(fetch).mockResolvedValueOnce({ **Requirements:** None enforced (no coverage thresholds in vitest.config.ts) **Current State:** + - Backend: Partial coverage — broker modules (expand, sync, write, crypto, vevent) tested; route handlers mostly untested - Frontend: Good coverage of utility functions (colorUtils, eventDateTime, hydrateEvents, calendarConfig) and API client **View Coverage:** + ```bash # Generate coverage report (requires @vitest/coverage-v8) vitest run --coverage @@ -233,38 +259,43 @@ vitest run --coverage ## Test Types **Unit Tests:** + - Scope: Single function or small module in isolation (mocks external dependencies) - Approach: Test input → output contracts, edge cases, error conditions - Examples: `lib/colorUtils.test.ts`, `broker/crypto.test.ts`, `api/client.test.ts` **Integration Tests:** + - Scope: Multi-module behavior (e.g., route handler + DB + auth middleware) - Approach: Test realistic user flows using `app.request()` for HTTP semantics - Examples: `health.test.ts` (GET /health with mocked DB) - No external API calls (Fastmail, Authelia mocked) **E2E Tests:** + - Not implemented; would require running a real server + browser - Currently using `playwright-cli` skill for browser-based smoke tests of UI (per project CLAUDE.md) ## Common Patterns **Async Testing:** + ```typescript it('returns { uid } on success', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => ({ uid: 'returned-uid' }), - } as Response) + } as Response); - const { createEvent } = await import('./client.js') - const result = await createEvent(payload) + const { createEvent } = await import('./client.js'); + const result = await createEvent(payload); - expect(result).toEqual({ uid: 'returned-uid' }) -}) + expect(result).toEqual({ uid: 'returned-uid' }); +}); ``` **Error Testing:** + ```typescript it('throws on non-ok response', async () => { vi.mocked(fetch).mockResolvedValueOnce({ @@ -274,7 +305,7 @@ it('throws on non-ok response', async () => { } as Response) const { createEvent } = await import('./client.js') - + await expect( createEvent({ title: '', ... }) ).rejects.toThrow() @@ -282,78 +313,89 @@ it('throws on non-ok response', async () => { ``` **Status Code Testing:** + ```typescript it('returns 503 when DB round-trip throws', async () => { - const { db } = await import('../src/db/client.js') - vi.mocked(db.execute).mockRejectedValueOnce(new Error('DB connection failed')) + const { db } = await import('../src/db/client.js'); + vi.mocked(db.execute).mockRejectedValueOnce(new Error('DB connection failed')); - const { app } = await import('../src/index.js') - const res = await app.request('/health') + const { app } = await import('../src/index.js'); + const res = await app.request('/health'); - expect(res.status).toBe(503) -}) + expect(res.status).toBe(503); +}); ``` **Fixture-Based Testing:** + ```typescript describe('expandOccurrences — DST correctness', () => { it('returns 10:00 America/New_York wall-clock time on BOTH sides of March 2026 DST boundary', () => { - const rawVevent = loadFixture('weekly-dst.ics') - const windowStart = new Date('2026-03-01T00:00:00Z') - const windowEnd = new Date('2026-04-01T00:00:00Z') + const rawVevent = loadFixture('weekly-dst.ics'); + const windowStart = new Date('2026-03-01T00:00:00Z'); + const windowEnd = new Date('2026-04-01T00:00:00Z'); const occurrences = expandOccurrences( rawVevent, windowStart, windowEnd, - 1, 'My Calendar', 1, 'Alice', '#4A90D9', false, - ) + 1, + 'My Calendar', + 1, + 'Alice', + '#4A90D9', + false, + ); // Check DST correctness: all occurrences must show hour === 10 local time for (const occ of occurrences) { - expect(occ.start).toMatch(/T10:00:00/) - expect(occ.start).toContain('[America/New_York]') + expect(occ.start).toMatch(/T10:00:00/); + expect(occ.start).toContain('[America/New_York]'); } // Explicitly check pre- and post-transition occurrences - const preTransition = occurrences.find(o => o.start.includes('2026-03-01')) - const postTransition = occurrences.find(o => o.start.includes('2026-03-15')) + const preTransition = occurrences.find((o) => o.start.includes('2026-03-01')); + const postTransition = occurrences.find((o) => o.start.includes('2026-03-15')); - expect(preTransition!.start).toContain('-05:00[America/New_York]') // EST - expect(postTransition!.start).toContain('-04:00[America/New_York]') // EDT - }) -}) + expect(preTransition!.start).toContain('-05:00[America/New_York]'); // EST + expect(postTransition!.start).toContain('-04:00[America/New_York]'); // EDT + }); +}); ``` **Zustand Store Testing:** + ```typescript describe('calendarStore', () => { it('setEventForm(true, edit, some-uid) updates all three keys', async () => { - const { useCalendarStore } = await import('../store/calendarStore.js') - useCalendarStore.getState().setEventForm(true, 'edit', 'some-uid') - const state = useCalendarStore.getState() + const { useCalendarStore } = await import('../store/calendarStore.js'); + useCalendarStore.getState().setEventForm(true, 'edit', 'some-uid'); + const state = useCalendarStore.getState(); - expect(state.eventFormOpen).toBe(true) - expect(state.eventFormMode).toBe('edit') - expect(state.eventFormUid).toBe('some-uid') - }) -}) + expect(state.eventFormOpen).toBe(true); + expect(state.eventFormMode).toBe('edit'); + expect(state.eventFormUid).toBe('some-uid'); + }); +}); ``` ## Test Setup **Backend (Node environment):** + - `vitest.config.ts` specifies `environment: 'node'` with `globals: true` - No test-setup file needed (Node has built-in globals) - Modules imported via `await import(...)` to enable per-test mocking **Frontend (jsdom environment):** + - `vitest.config.ts` specifies `environment: 'jsdom'` with `globals: true` and `setupFiles: ['./src/test-setup.ts']` - `test-setup.ts` polyfills `window.matchMedia` (jsdom doesn't implement CSSOM MediaQueryList) - `test-setup.ts` extends `expect` with `jest-dom` matchers - Timezone pinned to UTC via `env: { TZ: 'UTC' }` for deterministic date tests (WR-05) **Example (from `apps/pwa/vitest.config.ts`):** + ```typescript export default defineConfig({ test: { @@ -362,12 +404,13 @@ export default defineConfig({ setupFiles: ['./src/test-setup.ts'], env: { TZ: 'UTC' }, }, -}) +}); ``` **Example (from `apps/pwa/src/test-setup.ts`):** + ```typescript -import '@testing-library/jest-dom' +import '@testing-library/jest-dom'; Object.defineProperty(window, 'matchMedia', { writable: true, @@ -376,25 +419,28 @@ Object.defineProperty(window, 'matchMedia', { media: query, // ... other MediaQueryList methods }), -}) +}); ``` ## Known Testing Gaps **Backend Route Handlers:** + - GET /api/events, POST /api/events/create, PATCH /api/events/:uid/edit, DELETE /api/events/:uid — no route tests yet (in scope for Phase 5 / Plan 05) - GET /api/events/writable-calendars, GET /api/events/sync-status — no route tests - SSE route (`/api/sse`) — not tested - Auth flow tests (dev-bypass, OIDC session) partially covered; integration tests with Authelia not applicable **Frontend Components:** + - EventForm, DeleteConfirmationDialog, CalendarShell — no component tests yet - SSE event listener integration (real-time list updates) — not tested **Integration:** + - Full end-to-end flow (login → fetch events → create event → poll sync-status) — not covered - Database transaction rollback on error — not explicitly tested --- -*Testing analysis: 2026-06-09* +_Testing analysis: 2026-06-09_ diff --git a/.planning/debug/write-path-event-bugs.md b/.planning/debug/write-path-event-bugs.md index 62768e6..2cbc871 100644 --- a/.planning/debug/write-path-event-bugs.md +++ b/.planning/debug/write-path-event-bugs.md @@ -4,7 +4,7 @@ status: resolved trigger: "Phase 03 Gate 2 Part D — created events have wrong time (4h off) and attach to the wrong user's calendar; poller creates duplicate calendar rows" created: 2026-06-07 updated: 2026-06-08 -phase: "03-event-write-back-pwa-install" +phase: '03-event-write-back-pwa-install' branch: gsd/v1.0-milestone --- @@ -15,6 +15,7 @@ branch: gsd/v1.0-milestone Two distinct, confirmed write-path bugs found during live Gate 2 Part D testing. ### BUG A — Event times written 4 hours off (local serialized as UTC) + - **Expected:** Create an event for 9:00–10:00am local (operator is in America/Toronto, EDT = UTC−4) → it displays at 9:00–10:00am. - **Actual:** It displays at 5:00–6:00am (−4h). - **Confirmed evidence:** the stored VEVENT for the created event is: @@ -27,25 +28,28 @@ Two distinct, confirmed write-path bugs found during live Gate 2 Part D testing. - **Acceptance:** 9am local in → stored as UTC-correct (or TZID) → round-trips → displays 9am local. Add a regression test. ### BUG B — Created events attach to the wrong user's calendar; poller creates duplicate calendar rows + - **Expected:** user 2 (real OIDC, id=2) creates an event → it belongs to user 2's calendar; the poller maintains exactly one calendar row per (userId, collection URL). - **Actual:** the event landed on `calendar_id=1` (owned by user 1, the obsolete spike identity "Dev User", `oidc_iss='spike://cal-08'`). The poller created a NEW calendar row for user 2's "Calendar" collection on every poll. - **Confirmed evidence (DB):** `calendars` has FOUR rows for the same Fastmail collection URL ending `/2180A37A-806E-11EB-872C-AE53E9CB9923/`: - id=1 → user_id=1 (508 events) - id=2, id=4, id=5 → user_id=2 (0 events each) — duplicates created across polls - id=3 → user_id=2 "USA Holidays" (32 events, different URL — synced fine) - All "Calendar" events (508) sit under id=1; user 2's "Calendar" rows have 0 events. + All "Calendar" events (508) sit under id=1; user 2's "Calendar" rows have 0 events. - **Investigate:** - `apps/api/src/broker/poller.ts` — loops `davCalendars`, looks up the stored calendar via `eq(calendars.url, davCal.url)` WITHOUT a `userId` predicate → cross-user match (finds user 1's row). Strong candidate for the core defect. - `apps/api/src/broker/sync.ts` — calendar upsert (the `.values({` calls ~line 42 and ~105): how it matches/creates the calendar row (is it idempotent on (userId, url)? does it produce duplicates?), and how the event-cache upsert resolves `calendarId` (unique key is (calendarId, uid)) — events end up under the wrong calendarId. - **Acceptance:** exactly one calendar row per (userId, url); a user's created event syncs back under THAT user's calendar row; no duplicate rows accumulate across polls. Add regression test(s). ## Out of scope (tracked separately — do NOT fix here) + - `GET /api/events` missing `userId`/`isShared` filter (returns all users' events). - `me.ts` blank `displayName`/`oidc_iss` (OIDC claim extraction). - Non-animated "Syncing" toast (UI polish). - Stale spike data cleanup (user 1 + calendar id=1 + 508 events) — data, not code; relates to BUG B but handle after the code fix. ## Environment + - Stack running via `docker compose` (production target). Write path works end-to-end (outbox dispatches to Fastmail). These are LOGIC bugs, not infra. - DB access: `docker compose exec -T mariadb mariadb -ufamilysync -p"$(grep -E '^DB_PASSWORD' .env | cut -d= -f2)" familysync -e "..."` - Tests: `pnpm --filter @familysync/api exec vitest run` ; `pnpm --filter @familysync/pwa exec vitest run`. TDD mode is ON. @@ -71,11 +75,13 @@ next_action: none — root cause confirmed and fixed for both bugs; regression t ## Resolution ### BUG A — write-path timezone serialization + - **root_cause:** `EventForm.handleSubmit` sent timed start/end as a naive local wall-clock string (`${date}T${time}:00`, no UTC offset). The outbox worker then ran `new Date(thatString)`, which Node parses in the API container's local timezone (UTC in Docker) — so 09:00 America/Toronto was treated as 09:00 UTC. `buildVeventString` (`ICAL.Time.fromJSDate(d, true)`) then emitted `DTSTART:...090000Z`, displaying back as 05:00 EDT (−4h). - **fix:** Added `apps/pwa/src/lib/eventDateTime.ts` (`serializeEventDateTime`/`localWallClockToUtcIso`). The PWA now converts timed events to an unambiguous UTC instant in the browser (where the operator's zone is known) via `new Date(localParts).toISOString()`; the worker's `new Date(utcIso)` is now container-timezone-independent. All-day events stay `YYYY-MM-DD` DATE strings (D-13). Wired into `EventForm.handleSubmit`. No backend change needed. - **regression test:** `apps/pwa/src/lib/eventDateTime.test.ts` (5 cases): timed → UTC `Z` instant, round-trips to the same local wall clock, equals `new Date(localParts).toISOString()` (not passthrough), all-day stays a DATE string. ### BUG B — wrong-calendar attach + duplicate calendar rows + - **root_cause:** Two faults compounding. (1) `calendars` had NO unique key on `url` (only a non-unique `idx_calendars_user_id`), so the `onDuplicateKeyUpdate` in `syncCalendar`'s calendar upsert never fired → every poll inserted a fresh row for user 2's collection. (2) Both `poller.ts` (ctag lookup) and `sync.ts` (post-upsert id select) matched on `url` alone. Because the two members share ONE Fastmail account (D-16), the same collection URL exists for both; the url-only query returned the lowest-id row (user 1's id=1), so user 2's events were cached under `calendarId=1`. - **fix:** - `apps/api/src/db/schema.ts`: added composite `unique('uniq_calendar_user_url').on(t.userId, t.url)` so the calendar upsert is idempotent per (userId, url). @@ -85,6 +91,7 @@ next_action: none — root cause confirmed and fixed for both bugs; regression t - **regression tests:** `poller.test.ts` + `sync.test.ts` each assert the calendar predicate is scoped to `(user_id, url)` (verified non-vacuous: fails against the buggy url-only predicate); `sync.test.ts` asserts the calendar upsert uses `onDuplicateKeyUpdate`. ### Verification + - API tests: 98/98 pass. PWA tests: 140/140 pass. `tsc --noEmit` clean for both packages. - Live DB post-migration: `calendars` = id1(user1, stale spike data, out-of-scope), id2(user2 Calendar), id3(user2 USA Holidays); `uniq_calendar_user_url` present. - NOT done here (per scope guardrails / broken playwright daemon): image rebuild + tunnel re-test, and stale spike data cleanup (user 1 / calendar id=1 / 508 events). Orchestrator owns these. diff --git a/.planning/graphs/GRAPH_REPORT.md b/.planning/graphs/GRAPH_REPORT.md index a280453..72076fc 100644 --- a/.planning/graphs/GRAPH_REPORT.md +++ b/.planning/graphs/GRAPH_REPORT.md @@ -1,20 +1,24 @@ -# Graph Report - familysync (2026-06-10) +# Graph Report - familysync (2026-06-10) ## Corpus Check + - 372 files · ~942,238 words - Verdict: corpus is large enough that graph structure adds value. ## Summary + - 6380 nodes · 6612 edges · 593 communities (550 shown, 43 thin omitted) - Extraction: 100% EXTRACTED · 0% INFERRED · 0% AMBIGUOUS · INFERRED: 5 edges (avg confidence: 0.8) - Token cost: 0 input · 0 output ## Graph Freshness + - Built from commit: `a9c3304c` - Run `git rev-parse HEAD` and compare to check if the graph is stale. - Run `graphify update .` after code changes (no API cost). ## Community Hubs (Navigation) + - [[_COMMUNITY_Community 0|Community 0]] - [[_COMMUNITY_Community 1|Community 1]] - [[_COMMUNITY_Community 2|Community 2]] @@ -589,6 +593,7 @@ - [[_COMMUNITY_Community 593|Community 593]] ## God Nodes (most connected - your core abstractions) + 1. `Communities (598 total, 44 thin omitted)` - 534 edges 2. `entries` - 39 edges 3. `workflow` - 32 edges @@ -601,2155 +606,2688 @@ 10. `entries` - 20 edges ## Surprising Connections (you probably didn't know these) -- `runPoll()` --calls--> `createFastmailClient()` [EXTRACTED] + +- `runPoll()` --calls--> `createFastmailClient()` [EXTRACTED] apps/api/src/broker/poller.ts → apps/api/src/broker/client.ts -- `main()` --calls--> `createFastmailClient()` [EXTRACTED] +- `main()` --calls--> `createFastmailClient()` [EXTRACTED] apps/api/src/broker/spike.ts → apps/api/src/broker/client.ts -- `loadClientForUser()` --calls--> `decryptPassword()` [EXTRACTED] +- `loadClientForUser()` --calls--> `decryptPassword()` [EXTRACTED] apps/api/src/broker/outboxWorker.ts → apps/api/src/broker/crypto.ts -- `triggerTargetedResync()` --calls--> `syncCalendar()` [EXTRACTED] +- `triggerTargetedResync()` --calls--> `syncCalendar()` [EXTRACTED] apps/api/src/broker/outboxWorker.ts → apps/api/src/broker/sync.ts -- `triggerTargetedResync()` --calls--> `dispatchEventChange()` [EXTRACTED] +- `triggerTargetedResync()` --calls--> `dispatchEventChange()` [EXTRACTED] apps/api/src/broker/outboxWorker.ts → apps/api/src/lib/eventChangeDispatcher.ts ## Import Cycles + - None detected. ## Communities (593 total, 43 thin omitted) ### Community 0 - "Community 0" + Cohesion: 0.05 Nodes (43): 60 / 30 / 10 split, Agenda view (default: phone), AgendaRow, AppNav, Base palette, Breakpoints, Calendar config constant, Calendar Display (+35 more) ### Community 1 - "Community 1" + Cohesion: 0.05 Nodes (42): dependencies, @dnd-kit/core, @dnd-kit/sortable, fractional-indexing, ical.js, lucide-react, react, react-dom (+34 more) ### Community 2 - "Community 2" + Cohesion: 0.06 Nodes (35): Advanced: Multiple Cookies or Custom Options, Advanced: Multiple Operations, Authentication State Reuse, Clear All Cookies, Clear All localStorage, Clear sessionStorage, Common Patterns, Cookies (+27 more) ### Community 3 - "Community 3" + Cohesion: 0.06 Nodes (34): Accessibility Baseline, `AddItemInput`, Bottom Tab Bar (phone, ≤767px), `BottomTabBar`, Checked-Off Sink Behavior (D-05), Checker Sign-Off, Color, Component Inventory (+26 more) ### Community 4 - "Community 4" + Cohesion: 0.06 Nodes (31): Android install, CalendarPicker (within EventForm — conditional, D-02), Checker Sign-Off, Color, Component Inventory, Copywriting Contract, Delete interaction, DeleteConfirmationDialog (new component) (+23 more) ### Community 5 - "Community 5" + Cohesion: 0.06 Nodes (33): dependencies, drizzle-orm, fractional-indexing, hono, @hono/node-server, @hono/oidc-auth, @hono/zod-validator, ical.js (+25 more) ### Community 6 - "Community 6" + Cohesion: 0.06 -Nodes (32): workflow, ai_integration_phase, auto_advance, _auto_chain_active, auto_prune_state, code_review, code_review_command, code_review_depth (+24 more) +Nodes (32): workflow, ai_integration_phase, auto_advance, \_auto_chain_active, auto_prune_state, code_review, code_review_command, code_review_depth (+24 more) ### Community 7 - "Community 7" + Cohesion: 0.06 Nodes (31): Anti-Pattern 1: Fetching from Fastmail on Every Calendar Request, Anti-Pattern 2: Using Email as the App User Key, Anti-Pattern 3: Storing Recurring Events Only as Expanded Instances, Anti-Pattern 4: Separate Microservices for Broker, Lists, Push, Anti-Pattern 5: Per-User CalDAV Credentials in the PWA, Anti-Patterns, Architectural Patterns, Architecture Research (+23 more) ### Community 8 - "Community 8" + Cohesion: 0.06 Nodes (30): `apps/api/src/auth/devBypass.ts` (middleware — new file), `apps/api/src/broker/expand.ts` (utility, transform — new file), `apps/api/src/db/schema.ts` (model — modify existing), `apps/api/src/index.ts` (config — modify existing), `apps/api/src/routes/events.ts` (route, request-response — modify existing), `apps/api/tests/broker/expand.test.ts` (test — new file), `apps/api/tests/routes/events.test.ts` (test — new file), `apps/pwa/src/api/client.ts` (utility, request-response — modify existing) (+22 more) ### Community 9 - "Community 9" + Cohesion: 0.07 Nodes (29): 401 Guard Pattern, `apps/api/src/db/schema.ts` (model, CRUD — append new tables), `apps/api/src/index.ts` (config, modify), `apps/api/src/lib/listEmitter.ts` (utility, event-driven), `apps/api/src/routes/lists.ts` (route, CRUD), `apps/api/src/routes/sse.ts` (route, streaming — extend existing), `apps/pwa/src/api/listsClient.ts` (utility, request-response), `apps/pwa/src/App.tsx` (component, modify) (+21 more) ### Community 10 - "Community 10" + Cohesion: 0.07 Nodes (29): Critical Pitfalls, Integration Gotchas, "Looks Done But Isn't" Checklist, Performance Traps, Pitfall 10: Declarative Web Push vs Standard Web Push — Choose the Right Target, Pitfall 11: EU Digital Markets Act Breaks iOS PWA Entirely for EU Users, Pitfall 12: Service Worker Caching Serves Stale Calendar Data, Pitfall 13: Service Worker Update Staleness — App Never Updates for the Wife (+21 more) ### Community 11 - "Community 11" + Cohesion: 0.07 Nodes (27): Alternatives Considered, Architecture, Authelia OIDC Integration, Backend, Backend Framework, Browser-based verification, Calendar Integration: CalDAV, Not JMAP, Constraints (+19 more) ### Community 12 - "Community 12" + Cohesion: 0.09 Nodes (26): createFastmailClient(), FastmailClient, assembleRruleString(), BACKOFF_SECONDS, DispatchResult, dispatchRow(), HARD_FAIL_STATUSES, loadClientForUser() (+18 more) ### Community 13 - "Community 13" + Cohesion: 0.08 Nodes (23): `apps/api/src/broker/outboxWorker.ts` — new file, outbox drain loop, `apps/api/src/broker/vevent.ts` — new file, ical.js VEVENT builder, `apps/api/src/broker/write.ts` — new file, tsdav PUT/DELETE wrapper, `apps/api/src/db/schema.ts` — add `calendarOutbox` table + `objectUrl` column on `calendarEvents`, `apps/api/src/routes/events.ts` — extend with write endpoints + sync-status, `apps/pwa/src/api/client.ts` — add write calls + sync-status poll, `apps/pwa/src/components/EventDetailPopover.tsx` — add edit/delete to reserved footer, `apps/pwa/src/components/EventForm.tsx` — new file, create/edit form (+15 more) ### Community 14 - "Community 14" + Cohesion: 0.08 Nodes (23): Browser Automation with playwright-cli, Browser Sessions, Commands, Core, DevTools, Example: Debugging with DevTools, Example: Form submission, Example: Interactive session (+15 more) ### Community 15 - "Community 15" + Cohesion: 0.09 Nodes (22): Canonical References, Claude's Discretion (deferred to research/planner), Deferred Ideas, Entry gate & transport, Established Patterns, Existing Code Insights, Implementation Decisions, Integration Points (+14 more) ### Community 16 - "Community 16" + Cohesion: 0.12 Nodes (22): createEvent(), CreateEventPayload, CreateEventResponse, fetchSyncStatus(), fetchWritableCalendars(), handleAuthResponse(), MeResponse, MeUser (+14 more) ### Community 17 - "Community 17" + Cohesion: 0.11 Nodes (25): ContextVariableMap, DEV_USER, devAuthBypass(), persistSessionCookie(), claimStr(), COLOR_PALETTE, deriveDisplayName(), upsertUser() (+17 more) ### Community 18 - "Community 18" + Cohesion: 0.13 Nodes (14): Backlog, Milestones, Phase 999.10: Admin Settings / Administration section — manage app passwords + designate the shared calendar via UI (BACKLOG), Phase 999.11: Initial setup wizard — first-run config of env vars, app passwords, DB connection (BACKLOG), Phase 999.12: Assistant-driven mobile-browser UI testing (mobile viewport + authed PWA) (BACKLOG), Phase 999.13: Reduce event write-back latency to the calendar provider (outbox drain) (BACKLOG), Phase 999.14: Gitea CI — full regression on PR to main + build/publish Docker image (BACKLOG), Phase 999.1: Treat Fastmail as one calendar provider; framework supports adding more providers (BACKLOG) (+6 more) ### Community 19 - "Community 19" + Cohesion: 0.09 Nodes (21): Architecture Approach, Confidence Assessment, Critical Pitfalls, Executive Summary, Expected Features, Gaps to Address, Implications for Roadmap, Key Findings (+13 more) ### Community 20 - "Community 20" + Cohesion: 0.10 Nodes (20): Canonical References, Claude's Discretion, Color & ownership legibility, Deferred Ideas, Dev-auth bypass (from D-14, project-level), Established Patterns, Event detail density, Existing Code Insights (+12 more) ### Community 21 - "Community 21" + Cohesion: 0.10 Nodes (20): CR-01: Event edit/delete ownership check resolves an arbitrary member's row (uid-only lookup), CR-02: Outbox WR-02 "freshest etag" re-read also queries uid-only — can pick the wrong member's etag, CR-03: All-day end date is exclusive on write but inclusive on edit pre-fill — span grows one day per re-edit, Critical Issues, IN-01: `triggerTargetedResync` re-loads and re-decrypts the credential per row, IN-02: Unknown-status responses retried for the full backoff window before giving up, IN-03: `InstallPrompt` reads `localStorage` synchronously in `useState` initializer without try/catch, IN-04: `resolveUserId` typed as `any` (+12 more) ### Community 22 - "Community 22" + Cohesion: 0.10 Nodes (20): 1. Name Browser Sessions Semantically, 2. Always Clean Up, 3. Delete Stale Browser Data, A/B Testing Sessions, Attach by channel name, Attach via browser extension, Attach via CDP endpoint, Attaching to a Running Browser (+12 more) ### Community 23 - "Community 23" + Cohesion: 0.10 Nodes (19): Canonical References, Claude's Discretion, Deferred Ideas, Established Patterns, Existing Code Insights, External docs (authoritative), Implementation Decisions, Infrastructure & Deployment Scope (+11 more) ### Community 24 - "Community 24" + Cohesion: 0.10 Nodes (19): Architectural Responsibility Map, Assumptions Log, Don't Hand-Roll, Environment Availability, Metadata, Open Questions (RESOLVED), Package Legitimacy Audit, Pattern 1: Building a VEVENT with ical.js (new file: `broker/vevent.ts`) (+11 more) ### Community 25 - "Community 25" + Cohesion: 0.10 Nodes (19): Anti-Patterns, Architectural Constraints, Architecture, Caching tsdav clients across polls, Calendar Sync (Background Poller → syncCalendar), Component Responsibilities, Data Flow, Direct Fastmail calls from routes (+11 more) ### Community 26 - "Community 26" + Cohesion: 0.10 Nodes (19): agent_skills, brave_search, claude_md_path, commit_docs, exa_search, firecrawl, granularity, hooks (+11 more) ### Community 27 - "Community 27" + Cohesion: 0.11 Nodes (17): blockers, completed_tasks, context_notes, decisions, human_actions_pending, next_action, phase, phase_dir (+9 more) ### Community 28 - "Community 28" + Cohesion: 0.11 Nodes (18): 1.1 Prerequisite: workspace, 1.2 Prerequisite: seed test, 1.3 Explore the app, 1.4 Write the spec file, 1. Planning, 2.1 Inputs, 2.2 Generate one scenario, 2.3 Generate multiple scenarios (+10 more) ### Community 29 - "Community 29" + Cohesion: 0.11 Nodes (17): Accomplishments, Auto-fixed Issues, Decisions Made, Dependency graph, Deviations from Plan, Files Created/Modified, Issues Encountered, Known Stubs (+9 more) ### Community 30 - "Community 30" + Cohesion: 0.11 Nodes (17): 1. Color-coded event rendering, 2. All four views render events correctly, 3. Recurring events — DST boundary (CAL-07), 4. All-day events — no date shift (CAL-07), Anti-Patterns Found, Behavioral Spot-Checks, Data-Flow Trace (Level 4), Deferred Items (+9 more) ### Community 31 - "Community 31" + Cohesion: 0.11 Nodes (17): Canonical References, Carried forward — locked, NOT re-discussed, Claude's Discretion (researcher / planner decide), Code this phase extends, Deferred Ideas, Established Patterns, Existing Code Insights, Implementation Decisions (+9 more) ### Community 32 - "Community 32" + Cohesion: 0.11 Nodes (17): CR-01: Event edit/delete ownership check resolves an arbitrary member's row (uid-only lookup), CR-02: Outbox WR-02 "freshest etag" re-read also queries uid-only, CR-03: All-day end date exclusive on write but inclusive on edit pre-fill, Fixed Issues, IN-01: `triggerTargetedResync` re-loads and re-decrypts the credential per row, IN-02: Unknown-status responses retried for the full backoff window before giving up, IN-03: `InstallPrompt` reads `localStorage` synchronously without try/catch, IN-04: `resolveUserId` typed as `any` (+9 more) ### Community 33 - "Community 33" + Cohesion: 0.11 Nodes (18): compilerOptions, declaration, declarationMap, esModuleInterop, forceConsistentCasingInFileNames, lib, module, moduleResolution (+10 more) ### Community 34 - "Community 34" + Cohesion: 0.12 Nodes (16): Accomplishments, Decisions Made, Dependency graph, Deviations from Plan, Files Created/Modified, Issues Encountered, Known Stubs, Metrics (+8 more) ### Community 35 - "Community 35" + Cohesion: 0.12 Nodes (16): Accomplishments, Dependency graph, Deviations from Plan, Files Created/Modified, Known Stubs, Live Verification Pending (Tasks 2 + 3), Metrics, Performance (+8 more) ### Community 36 - "Community 36" + Cohesion: 0.12 Nodes (16): 1. AUTH-01 — Authelia OIDC Login (Member 1: Lucas), 2. AUTH-02 — Session Persistence Across Browser Restart, 3. AUTH-03 — Cross-Member Distinct Color (Member 2: Wife), 4. SSE Smoke Test — Pangolin Pass-Through (D-08), Anti-Patterns Found, Behavioral Spot-Checks, Data-Flow Trace (Level 4), Gaps Summary (+8 more) ### Community 37 - "Community 37" + Cohesion: 0.12 Nodes (16): Accomplishments, Decisions Made, Dependency graph, Deviations from Plan, Files Created/Modified, Known Stubs, Metrics, Performance (+8 more) ### Community 38 - "Community 38" + Cohesion: 0.12 Nodes (16): Architectural Responsibility Map, Assumptions Log, Don't Hand-Roll, Entry Gate Status: CLEARED, Environment Availability, Metadata, Open Questions, Package Legitimacy Audit (+8 more) ### Community 39 - "Community 39" + Cohesion: 0.12 Nodes (16): 1. Start Tracing Before the Problem, 2. Clean Up Old Traces, Analyzing Performance, Basic Usage, Best Practices, Capturing Evidence, Debugging Failed Actions, Limitations (+8 more) ### Community 40 - "Community 40" + Cohesion: 0.12 Nodes (16): Alternatives Considered, Authelia OIDC Integration, Backend Framework, Calendar Integration: CalDAV, Not JMAP, Core Technologies, Development Tools, Installation, Live List Sync (+8 more) ### Community 41 - "Community 41" + Cohesion: 0.12 Nodes (15): Accomplishments, Auto-fixed Issues, Decisions Made, Dependency graph, Deviations from Plan, Files Created/Modified, Known Stubs, Metrics (+7 more) ### Community 42 - "Community 42" + Cohesion: 0.12 Nodes (15): 1. Register FamilySync as an Authelia OIDC confidential client, 2. Set OIDC_AUTH_EXTERNAL_URL in the app's .env, 3. Expose via Pangolin / Newt (Mode A local rig), 4. Apply database schema (first deploy only), 5. Bring up the app and confirm /health over the tunnel, Gate 2 Checklist, Header, /health Tunnel Verification (+7 more) ### Community 43 - "Community 43" + Cohesion: 0.16 Nodes (15): decryptPassword(), EncryptedPayload, encryptPassword(), getKey(), getCrypto(), runPoll(), syncCalendar(), calendars (+7 more) ### Community 44 - "Community 44" + Cohesion: 0.13 Nodes (14): Accomplishments, Decisions Made, Dependency graph, Deviations from Plan, Files Created/Modified, Known Stubs, Metrics, Operator Setup Required (Authelia Client Registration) (+6 more) ### Community 45 - "Community 45" + Cohesion: 0.13 Nodes (14): Accomplishments, Decisions Made, Dependency graph, Deviations from Plan, Files Created/Modified, Issues Encountered, Metrics, Next Phase Readiness (+6 more) ### Community 46 - "Community 46" + Cohesion: 0.13 Nodes (14): Accomplishments, Auto-fixed Issues, Decisions Made, Dependency graph, Deviations from Plan, Files Created, Known Stubs, Metrics (+6 more) ### Community 47 - "Community 47" + Cohesion: 0.13 Nodes (14): Accomplishments, Auto-fixed Issues, Decisions Made, Deviations from Plan, Files Created/Modified, Issues Encountered, Known Stubs, Next Phase Readiness (+6 more) ### Community 48 - "Community 48" + Cohesion: 0.13 Nodes (14): Accomplishments, Auto-fixed Issues, Decisions Made, Deviations from Plan, Files Created/Modified, Issues Closed, Issues Encountered, Known Stubs (+6 more) ### Community 49 - "Community 49" + Cohesion: 0.13 Nodes (14): Auto-fixed Issues, Deviations from Plan, IN-03: todayIso exported from calendarStore, Issues Closed, Phase 03 Plan 12: EventForm Gap Closure — Edit Mode, Focus Trap, PWA Assets Summary, PWA-01/PWA-02: Install assets confirmed present (IN-04), Self-Check: PASSED, Tasks Completed (+6 more) ### Community 50 - "Community 50" + Cohesion: 0.13 Nodes (14): Behavior when the CalDAV write fails, Calendar selector visibility, Claude's Discretion, Default target calendar for a new event, Deferred Ideas, Edit-conflict (etag mismatch / 412) handling, How the member sees their own change after save (poll-based cache), Move event between calendars on edit (+6 more) ### Community 51 - "Community 51" + Cohesion: 0.13 Nodes (14): Auto-fixed Issues, BUG 1 — Missing join on PATCH/:uid/edit and DELETE/:uid (BLOCKING), BUG 2 — Blank displayName from weak OIDC claim reading, BUG 3 — GET /api/events returning all users' events, Bug Details, Deviations from Plan, Known Stubs, Operator Actions Required (+6 more) ### Community 52 - "Community 52" + Cohesion: 0.15 Nodes (11): fetchEvents(), fetchMe(), ALLDAY_OCCURRENCE, makeQueryClient(), mockEventsServiceSet, renderWithClient(), TIMED_OCCURRENCE, CalendarOccurrence (+3 more) ### Community 53 - "Community 53" + Cohesion: 0.17 Nodes (11): Apply Database Migrations, Build and Start, Deployment Targets, Environment Setup, FamilySync — Deployment Guide, Health Check, Monitoring, Pangolin / Newt Tunnel (+3 more) ### Community 54 - "Community 54" + Cohesion: 0.13 Nodes (14): compilerOptions, esModuleInterop, forceConsistentCasingInFileNames, jsx, lib, module, moduleResolution, noEmit (+6 more) ### Community 55 - "Community 55" + Cohesion: 0.13 Nodes (14): Add After Validation (v1.x), Anti-Features (Deliberately Exclude), Competitor Feature Analysis, Dependency Notes, Differentiators (Competitive Advantage for This Product), Feature Dependencies, Feature Landscape, Feature Prioritization Matrix (+6 more) ### Community 56 - "Community 56" + Cohesion: 0.14 Nodes (13): Area Selection, Broker access model, Claude's Discretion, Credential storage, Deferred Ideas, Deployment scope, Infra validation scope, Member color assignment (+5 more) ### Community 57 - "Community 57" + Cohesion: 0.14 Nodes (13): CR-01: Edit-as-move silently strips a recurring series' RRULE, Critical Issues, IN-01: `RRULE_PRESETS` round-trip is lossy for any parameterized RRULE, IN-02: `parseDateTime` silently rewrites a malformed edit value to today/09:00, IN-03: Unchecked `as` casts on JSON-parsed outbox payload fields, Info, Phase 3: Code Review Report (Re-Review, Iteration 2), Summary (+5 more) ### Community 58 - "Community 58" + Cohesion: 0.14 Nodes (13): Architectural Constraints & Anti-Patterns, Codebase Concerns, Dependencies at Risk, Fragile Areas, Infrastructure & Deployment Concerns, Known Bugs, Known Limitations (Documented as Design Decisions), Missing Critical Features (+5 more) ### Community 59 - "Community 59" + Cohesion: 0.06 Nodes (21): BeforeInstallPromptEvent, InstallPrompt(), IOS_STEPS, isIOSSafariNonStandalone(), useAndroidInstallPrompt(), WalkthroughSheetProps, ANDROID_STEPS, InstructionSheet() (+13 more) ### Community 60 - "Community 60" + Cohesion: 0.14 Nodes (13): BUG A — Event times written 4 hours off (local serialized as UTC), BUG A — write-path timezone serialization, BUG B — Created events attach to the wrong user's calendar; poller creates duplicate calendar rows, BUG B — wrong-calendar attach + duplicate calendar rows, Current Focus, Debug Session: write-path-event-bugs, Eliminated, Environment (+5 more) ### Community 61 - "Community 61" + Cohesion: 0.27 Nodes (9): buildCalendarConfig(), CalendarConfig, MemberCalendarConfig, ScheduleXCalendarEntry, deriveScheduleXColors(), hexToContainer(), hexToOnContainer(), hexToRgb() (+1 more) ### Community 62 - "Community 62" + Cohesion: 0.14 Nodes (13): Calendar → Fastmail (NOT self-hosted), Display + aggregation → custom app (the actual product), FamilySync Architecture Decisions, Frontend → React PWA, Household, Infrastructure, Lists → custom app backend (MariaDB), Net result (+5 more) ### Community 63 - "Community 63" + Cohesion: 0.06 Nodes (34): Accessibility Contract, Animation Contract, Asset Manifest, Auth splash — unauthenticated cold load (999.2), Brand Assets & Iconography, Checker Sign-Off, Color, Copywriting Contract (+26 more) ### Community 64 - "Community 64" + Cohesion: 0.14 Nodes (13): Clipboard, Complex Workflows, Error Handling, File Downloads, Frames and Iframes, Geolocation, JavaScript Execution, Media Emulation (+5 more) ### Community 65 - "Community 65" + Cohesion: 0.15 Nodes (12): Auto-fixed Issues, Dev-Auth Bypass (Task 2), Deviations from Plan, ICS Fixtures (Task 1), Known Stubs, Phase 02 Plan 01: Foundation — Schema Columns, Test Harness, Dev-Auth Bypass Summary, PWA Test Harness (Task 1), RED Test Stubs (Task 1) (+4 more) ### Community 66 - "Community 66" + Cohesion: 0.15 Nodes (12): Auto-fixed Issues, Deviations from Plan, Human Verify Checkpoint (Task 3 — awaiting operator), Known Stubs, Phase 02 Plan 05: Calendar UX — Popover, Chrome, States Summary, Self-Check: PASSED, Task 1: EventDetailPopover + CalendarShell wiring (TDD), Task 2: Chrome components, state branches, EventProof retired (+4 more) ### Community 67 - "Community 67" + Cohesion: 0.15 Nodes (12): Auto-fixed Issues, Deviations from Plan, Known Stubs, Phase 03 Plan 06: Edit/Delete + SyncStateToast Summary, Self-Check: PASSED, Task 1 — Client calls + Zustand keys (RED: `8357cf9`, GREEN: `8aeacc8`), Task 2 — SyncStateToast (RED: `6874e1a`, GREEN: `aa7c4c3`), Task 3 — EventDetailPopover footer + DeleteConfirmationDialog (RED: `2fbeffe`, GREEN: `40322e1`) (+4 more) ### Community 68 - "Community 68" + Cohesion: 0.15 Nodes (12): Auto-fixed Issues, Decisions Made, Deviations from Plan, Infrastructure, Issues Closed, Known Stubs, Phase 03 Plan 10: Outbox Worker ICS Builder Wiring Summary, Self-Check: PASSED (+4 more) ### Community 69 - "Community 69" + Cohesion: 0.15 Nodes (9): mockDelete, mockDeleteWhere, mockFrom, mockInsert, mockLimit, mockOnDuplicateKeyUpdate, mockSelect, mockValues (+1 more) ### Community 70 - "Community 70" + Cohesion: 0.14 Nodes (13): Accumulated Context, Blockers/Concerns, Current Position, Decisions, Deferred Items, Operator Next Steps, Pending Todos, Performance Metrics (+5 more) ### Community 71 - "Community 71" + Cohesion: 0.17 Nodes (11): Architectural Responsibility Map, Assumptions Log, Don't Hand-Roll, Environment Availability, Metadata, Open Questions (DEFERRED TO SPIKE — resolved empirically in Plan 01-04), Package Legitimacy Audit, Phase 1: Foundation + Broker Spike — Research (+3 more) ### Community 72 - "Community 72" + Cohesion: 0.17 Nodes (11): Auto-fixed Issues, Deviations from Plan, Known Stubs, Phase 02 Plan 03: PWA Foundation — Token Layer, Color Utils, Calendar Config, Hydration, Store Summary, Self-Check: PASSED, Task 1: Schedule-X Stack + Token Layer + main.tsx Import Order, Task 2: colorUtils + calendarConfig — RED Stubs Turned GREEN, Task 3: hydrateEvents + calendarStore + windowed fetchEvents — RED Stubs Turned GREEN (+3 more) ### Community 73 - "Community 73" + Cohesion: 0.17 Nodes (11): Architectural Responsibility Map, Assumptions Log, Don't Hand-Roll, Environment Availability, Metadata, Open Questions, Package Legitimacy Audit, Phase 2: Calendar Display - Research (+3 more) ### Community 74 - "Community 74" + Cohesion: 0.17 Nodes (11): Auto-fixed Issues, Deviations from Plan, Known Stubs, Phase 03 Plan 05: Event Write UI (EventForm + Client Calls) Summary, Self-Check: PASSED, Task 1: Typed write client calls + Zustand form-state keys, Task 2: EventForm modal, Task 3: Mount EventForm + "New Event" FAB/toolbar in CalendarShell (+3 more) ### Community 75 - "Community 75" + Cohesion: 0.17 Nodes (11): Auto-fixed Issues, Decisions Made, Deviations from Plan, Known Stubs, Phase 03 Plan 09: Route Schema + OIDC Resolution Fix Summary, Self-Check: PASSED, Tasks Completed, TDD Gate Compliance (+3 more) ### Community 76 - "Community 76" + Cohesion: 0.17 Nodes (11): Code Style, Coding Conventions, Comments, Database Patterns, Error Handling, Function Design, Import Organization, Logging (+3 more) ### Community 77 - "Community 77" + Cohesion: 0.17 Nodes (11): Common Patterns, Coverage, Fixtures and Factories, Known Testing Gaps, Mocking, Test File Organization, Test Framework, Test Setup (+3 more) ### Community 78 - "Community 78" + Cohesion: 0.17 Nodes (11): Active, Constraints, Context, Core Value, Evolution, FamilySync, Key Decisions, Out of Scope (+3 more) ### Community 79 - "Community 79" + Cohesion: 0.18 Nodes (10): Auto-fixed Issues, Deviations from Plan, Known Stubs, Phase 02 Plan 02: Windowed /api/events — Recurrence Expansion + Color Join Summary, Self-Check: PASSED, Task 1: expandOccurrences() — apps/api/src/broker/expand.ts, Task 2: Windowed /api/events — apps/api/src/routes/events.ts, Task 3: Shared-Family Calendar Marking — RESOLVED BY DEFERRAL (+2 more) ### Community 80 - "Community 80" + Cohesion: 0.18 Nodes (10): Auto-fixed Issues, Deviations from Plan, Known Stubs, Phase 02 Plan 04: CalendarShell — Schedule-X Mounted, Wired to Data Pipeline Summary, Self-Check: PASSED, Task 1: CalendarShell + App.tsx, Task 2: CalendarShell Render Smoke Test (CAL-03), Threat Flags (+2 more) ### Community 81 - "Community 81" + Cohesion: 0.18 Nodes (10): CR-01: Edit-as-move silently strips a recurring series' RRULE, Fixed Issues, IN-01: `RRULE_PRESETS` round-trip is lossy for parameterized RRULEs, IN-02: `parseDateTime` silently rewrites a malformed edit value to today/09:00, IN-03: Unchecked `as` casts on JSON-parsed outbox payload fields, Phase 3: Code Review Fix Report (Iteration 2), WR-01: Edit form provides no indication recurrence is locked, WR-02: `handleAllDayToggle` can leave end-date inconsistent (+2 more) ### Community 82 - "Community 82" + Cohesion: 0.18 Nodes (10): CR-01: Edit-as-move silently strips a recurring series' RRULE, Fixed Issues, IN-01: `RRULE_PRESETS` round-trip is lossy for parameterized RRULEs, IN-02: `parseDateTime` silently rewrites a malformed edit value to today/09:00, IN-03: Unchecked `as` casts on JSON-parsed outbox payload fields, Phase 3: Code Review Fix Report (Iteration 2), WR-01: Edit form provides no indication recurrence is locked, WR-02: `handleAllDayToggle` can leave end-date inconsistent (+2 more) ### Community 83 - "Community 83" + Cohesion: 0.18 Nodes (10): Claude's Discretion, Deferred Ideas, List & item behavior — Checked items & delete guard, List & item behavior — Privacy (refined), List & item behavior — Sharing, Lists navigation, Live feel & conflicts, Phase 4: Shared Lists + Live Sync - Discussion Log (+2 more) ### Community 84 - "Community 84" + Cohesion: 0.18 Nodes (10): Deviations from Plan, Known Stubs, Quick Task 260606-tv8: Fix Missing Sign-in Redirect in the PWA — Summary, Self-Check: PASSED, Task 1: GET /api/login backend route + tests (commit 237ec49), Task 2: One-shot login-redirect helper + tests (commit 6dc9ccd), Task 3: CalendarShell meQuery wiring (commit c2e0ab1), Threat Flags (+2 more) ### Community 85 - "Community 85" + Cohesion: 0.20 Nodes (11): CalendarOccurrence, deleteEvent(), DeleteConfirmationDialog(), EventDetailPopover(), formatDateTime(), ScheduleXEventModalProps, ALLDAY_OCCURRENCE, { - mockSetOpenEventId, - mockSetEventForm, - mockSetDeleteDialog, +mockSetOpenEventId, +mockSetEventForm, +mockSetDeleteDialog, } (+3 more) ### Community 86 - "Community 86" + Cohesion: 0.25 -Nodes (8): CalendarOccurrence, expandOccurrences(), formatUtcOffset(), makeOccurrenceId(), OccurrenceMeta, serializeTime(), __dirname, FIXTURES +Nodes (8): CalendarOccurrence, expandOccurrences(), formatUtcOffset(), makeOccurrenceId(), OccurrenceMeta, serializeTime(), \_\_dirname, FIXTURES ### Community 87 - "Community 87" + Cohesion: 0.08 Nodes (35): addItem(), apiFetch(), createList(), deleteItem(), deleteList(), fetchListItems(), fetchLists(), List (+27 more) ### Community 88 - "Community 88" + Cohesion: 0.18 Nodes (10): name, packageManager, private, scripts, build, dev:api, dev:pwa, lint (+2 more) ### Community 89 - "Community 89" + Cohesion: 0.20 Nodes (10): Anti-Patterns to Avoid, Architecture Patterns, Pattern 1: @hono/oidc-auth Middleware Wiring, Pattern 2: Drizzle/MariaDB Schema, Pattern 3: CalDAV Broker (tsdav), Pattern 4: AES-GCM App-Password Encryption, Pattern 5: Pangolin SSE Smoke Test, Pattern 6: Docker Compose Layout (+2 more) ### Community 90 - "Community 90" + Cohesion: 0.20 Nodes (9): IN-01: `RRULE_PRESETS` round-trip is lossy for any parameterized RRULE, IN-02: Move-path RRULE preservation depends silently on `rawVevent` being non-empty, Info, Iteration-2 fixes — verified, Phase 3: Code Review Report (Re-Review, Iteration 3 — final --auto pass), Summary, Warnings, WR-01: Missing cached etag still produces an unconditional PUT/DELETE (D-08 gap) (+1 more) ### Community 91 - "Community 91" + Cohesion: 0.00 Nodes (534): Communities (598 total, 44 thin omitted), Community 0 - "Community 0", Community 100 - "Community 100", Community 101 - "Community 101", Community 102 - "Community 102", Community 103 - "Community 103", Community 104 - "Community 104", Community 105 - "Community 105" (+526 more) ### Community 92 - "Community 92" + Cohesion: 0.20 Nodes (9): mockCalendarsSelectResult, mockCreateFastmailClient, mockCredentialsSelectResult, mockFetchCalendars, mockSelect, mockSelectFrom, mockSelectLimit, mockSelectWhere (+1 more) ### Community 93 - "Community 93" + Cohesion: 0.20 Nodes (9): APIs & External Services, Authentication & Identity, CI/CD & Deployment, Data Storage, Environment Configuration, External Integrations, Monitoring & Observability, Network & Transport (+1 more) ### Community 94 - "Community 94" + Cohesion: 0.12 Nodes (11): AppNav(), AppNavProps, BottomTabBar(), isPhone(), tabActiveOverride, tabBase, ColorLegend(), ColorLegendProps (+3 more) ### Community 95 - "Community 95" + Cohesion: 0.22 Nodes (8): 1. AUTH-01 — Live Authelia OIDC login over the public Pangolin URL, 2. AUTH-02 — Session persists across browser restart, 3. AUTH-03 — Second member gets a distinct color, 4. SSE-over-Pangolin smoke test (de-risks Phase 4), Current Test, Gaps, Summary, Tests ### Community 96 - "Community 96" + Cohesion: 0.22 Nodes (8): CAL-08: Personal Calendar ACL Spike — Decision Record, Calendars Discovered, Decision, How to Run the Spike, Notes, Questions Resolved, Results, Status ### Community 97 - "Community 97" + Cohesion: 0.22 Nodes (8): 1. Color-coded rendering, 2. All four views render + grid scrolls, 3. Recurring events across DST, 4. All-day banners — no date shift, Current Test, Gaps, Summary, Tests ### Community 98 - "Community 98" + Cohesion: 0.22 Nodes (9): Anti-Patterns to Avoid, Architecture Patterns, Pattern 1: `/api/events` Windowed Query with Expansion, Pattern 2: Schedule-X Event Format (Temporal, not ISO strings), Pattern 3: Schedule-X Calendar Configuration, Pattern 4: TanStack Query + onRangeUpdate Wiring, Pattern 5: Dev-Auth Bypass Middleware, Recommended Project Structure (+1 more) ### Community 99 - "Community 99" + Cohesion: 0.22 Nodes (8): Blocker bugs found + fixed live (all committed + deployed), Deferred / carried forward, Dependency graph, Outcome, Phase 03 Plan 08: Gate 2 Live Verification — Summary, Self-Check, Tech tracking, Verification method ### Community 100 - "Community 100" + Cohesion: 0.22 Nodes (8): Advanced Mocking with run-code, CLI Route Commands, Conditional Response Based on Request, Delayed Response, Modify Real Response, Request Mocking, Simulate Network Failures, URL Patterns ### Community 101 - "Community 101" + Cohesion: 0.22 Nodes (8): 1. Use Semantic Locators, 2. Explore Before Recording, 3. Add Assertions Manually, Best Practices, Building a Test File, Example Workflow, How It Works, Test Generation ### Community 102 - "Community 102" + Cohesion: 0.22 Nodes (8): 1. Use Descriptive Filenames, 2. Record entire hero scripts., Basic Recording, Best Practices, Limitations, Overlay API Summary, Tracing vs Video, Video Recording ### Community 103 - "Community 103" + Cohesion: 0.22 Nodes (8): Calendar host decision — RESOLVED, Email scope — RESOLVED, Open, Research Questions, Resolved, RQ-001 — Vikunja MariaDB compatibility — SUPERSEDED, RQ-002 — CalDAV server: Radicale vs Baikal — SUPERSEDED, RQ-003 — Fastmail calendar API: JMAP vs CalDAV ### Community 104 - "Community 104" + Cohesion: 0.25 Nodes (8): Common Pitfalls, Pitfall 1: OIDC_AUTH_EXTERNAL_URL Missing Behind Pangolin, Pitfall 2: All-Day Event DATE Stored as DATETIME, Pitfall 3: tsdav createDAVClient Requires Account Discovery Round-Trip, Pitfall 4: node-cron v4 vs v3 API Change, Pitfall 5: Drizzle `onDuplicateKeyUpdate` Requires MariaDB 10.3+, Pitfall 6: Fastmail ctag vs syncToken Field Availability, Pitfall 7: Authelia Client Secret — Plain vs Hashed ### Community 105 - "Community 105" + Cohesion: 0.25 Nodes (7): Manual-Only Verifications, Per-Task Verification Map, Phase 1 — Validation Strategy, Sampling Rate, Test Infrastructure, Validation Sign-Off, Wave 0 Requirements (created in Plan 01 Task 1) ### Community 106 - "Community 106" + Cohesion: 0.25 Nodes (7): Claude's Discretion, Color & shared-vs-personal, Default view & per-device, Deferred Ideas, Event detail density, Phase 2: Calendar Display - Discussion Log, Visual model → Theming architecture ### Community 107 - "Community 107" + Cohesion: 0.25 Nodes (8): Common Pitfalls, Pitfall 1: `firstDayOfWeek` Temporal Numbering Mismatch, Pitfall 2: All-Day Events Shifting by One Day, Pitfall 3: Missing VTIMEZONE Registration Causes DST-Shifted Occurrences, Pitfall 4: Schedule-X ISO String Events Silently Fail, Pitfall 5: Unwindowed `/api/events` Endpoint, Pitfall 6: `@schedule-x/react` Version Behind `@schedule-x/calendar`, Pitfall 7: Dev-Auth Bypass Active in Production ### Community 108 - "Community 108" + Cohesion: 0.25 Nodes (7): Manual-Only Verifications, Per-Task Verification Map, Phase 02 — Validation Strategy, Sampling Rate, Test Infrastructure, Validation Sign-Off, Wave 0 Requirements ### Community 109 - "Community 109" + Cohesion: 0.25 Nodes (8): Common Pitfalls, Pitfall 1: Service Worker intercepts `/callback` and breaks OIDC login, Pitfall 2: iOS standalone mode breaks on OIDC redirect to auth.DOMAIN, Pitfall 3: D-13 DATE vs DATETIME coercion in VEVENT building, Pitfall 4: ETag not returned after PUT on Fastmail, Pitfall 5: Edit-as-move (D-04) partial-failure, Pitfall 6: `navigateFallbackDenylist` not respected in dev mode, Pitfall 7: Outbox worker runs without a valid DAVCalendar object for re-sync ### Community 110 - "Community 110" + Cohesion: 0.25 Nodes (7): Manual-Only Verifications, Per-Task Verification Map, Phase 3 — Validation Strategy, Sampling Rate, Test Infrastructure, Validation Sign-Off, Wave 0 Requirements ### Community 111 - "Community 111" + Cohesion: 0.25 Nodes (8): Common Pitfalls, Pitfall 1: SSE fan-out leaking private list events to all clients, Pitfall 2: Float-based positions exhausting precision, Pitfall 3: Raw EventSource reconnect storm, Pitfall 4: `drizzle-kit push` on populated MariaDB, Pitfall 5: dnd-kit drag handle with touch — accidental drags, Pitfall 6: React Router + vite-plugin-pwa navigation fallback, Pitfall 7: SSE auth with `withCredentials` ### Community 112 - "Community 112" + Cohesion: 0.25 Nodes (7): Manual-Only Verifications, Per-Task Verification Map, Phase 4 — Validation Strategy, Sampling Rate, Test Infrastructure, Validation Sign-Off, Wave 0 Requirements ### Community 113 - "Community 113" + Cohesion: 0.25 Nodes (7): Commits, Deviations from Plan, Evidence Recorded (verbatim), Quick Task 260607-u8o: Record SSE-over-Pangolin Smoke Test PASS Summary, Self-Check: PASSED, Verification, What Was Done ### Community 114 - "Community 114" + Cohesion: 0.10 Nodes (16): WritableCalendar, makeQueryClient(), { - mockDeleteEvent, - mockSetDeleteDialog, - mockSetLastSyncedUid, - mockSetOpenEventId, - mockDeleteDialogOpen, - mockDeleteDialogUid, +mockDeleteEvent, +mockSetDeleteDialog, +mockSetLastSyncedUid, +mockSetOpenEventId, +mockDeleteDialogOpen, +mockDeleteDialogUid, }, renderDialog(), renderPopover(), ALL_DAY_OCCURRENCE, EDIT_OCCURRENCE, LATE_OCCURRENCE (+8 more) ### Community 115 - "Community 115" + Cohesion: 0.07 Nodes (29): 44px touch target (all interactive elements), `apps/api/src/broker/reminderScheduler.ts`, `apps/api/src/db/schema.ts` — add `pushSubscriptions` table, `apps/api/src/index.ts` (modifications), `apps/api/src/lib/eventChangeDispatcher.ts`, `apps/api/src/lib/pushCoalescer.ts`, `apps/api/src/lib/pushDispatcher.ts`, `apps/api/src/routes/push.ts` (POST /api/push/subscription, DELETE, GET /api/push/vapid-public-key) (+21 more) ### Community 116 - "Community 116" + Cohesion: 0.25 Nodes (7): Configuration, Frameworks, Key Dependencies, Languages, Platform Requirements, Runtime, Technology Stack ### Community 117 - "Community 117" + Cohesion: 0.25 Nodes (7): Codebase Structure, Directory Layout, Directory Purposes, Key File Locations, Naming Conventions, Special Directories, Where to Add New Code ### Community 118 - "Community 118" + Cohesion: 0.18 Nodes (5): SessionExpiredError, ErrorBoundary, ErrorBoundaryProps, ErrorBoundaryState, queryClient ### Community 119 - "Community 119" + Cohesion: 0.07 Nodes (29): auth, build_system, cache, calendar_backend, calendar_ui, client_state, content_formats, database (+21 more) ### Community 120 - "Community 120" + Cohesion: 0.29 Nodes (6): Architectural Decisions, Capability Proven End-to-End, Out of Scope (Deferred to Later Slices), Stack Touched in Phase 1, Subsequent Slice Plan, Walking Skeleton — FamilySync ### Community 121 - "Community 121" + Cohesion: 0.07 Nodes (26): Accessibility Requirements, Checker Sign-Off, Color, Component Inventory, Copywriting Contract, Design System, Event change — modified event (NOTIF-03, change), Event change — new event (NOTIF-03, new) (+18 more) ### Community 122 - "Community 122" + Cohesion: 0.22 Nodes (9): checkConstraint, compositePrimaryKeys, foreignKeys, columns, name, name, uniqueConstraints, calendar_outbox_id (+1 more) ### Community 123 - "Community 123" + Cohesion: 0.29 Nodes (4): shimmerStyle, SkeletonCalendar(), SkeletonCalendarProps, SkeletonVariant ### Community 124 - "Community 124" + Cohesion: 0.22 Nodes (15): addDaysISO(), computeNewAllDayEnd(), computeNewTimedEnd(), dateDiffDays(), localDateISO(), localTimeHHMM(), localWallClockToUtcIso(), pad2() (+7 more) ### Community 125 - "Community 125" + Cohesion: 0.33 Nodes (6): Fixture ICS Files (test corpus), Phase Requirements → Test Map, Sampling Rate, Test Framework, Validation Architecture, Wave 0 Gaps ### Community 126 - "Community 126" + Cohesion: 0.33 Nodes (5): .from(calendarEvents).innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)), Reference idioms already in events.ts:, .where(or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true))), - Working join: GET / at ~line 142-183 uses, - Working ownership predicate: /writable-calendars at ~line 501-509 uses ### Community 127 - "Community 127" + Cohesion: 0.33 Nodes (6): git, branching_strategy, create_tag, milestone_branch_template, phase_branch_template, quick_branch_template ### Community 128 - "Community 128" + Cohesion: 0.40 Nodes (5): Phase Requirements → Test Map, Sampling Rate, Test Framework, Validation Architecture, Wave 0 Gaps (must create before implementation) ### Community 129 - "Community 129" + Cohesion: 0.40 Nodes (5): Core (already installed — no new installs for write-back), Installation, New Installs (PWA layer only), rrule — NOT needed for Phase 3, Standard Stack ### Community 130 - "Community 130" + Cohesion: 0.40 Nodes (5): Phase Requirements → Test Map, Sampling Rate, Test Framework, Validation Architecture, Wave 0 Gaps ### Community 131 - "Community 131" + Cohesion: 0.40 Nodes (5): Code Examples, Verified Pattern: Drizzle schema conventions (existing schema.ts), Verified Pattern: Hono streamSSE (existing sse.ts), Verified Pattern: NavLink with active styling, Verified Pattern: React Query optimistic update ### Community 132 - "Community 132" + Cohesion: 0.40 Nodes (5): Primary (HIGH confidence — VERIFIED from codebase), Primary (HIGH confidence — VERIFIED via npm registry + Context7), Secondary (MEDIUM confidence — CITED from official docs), Sources, Tertiary (LOW confidence — ASSUMED) ### Community 133 - "Community 133" + Cohesion: 0.40 Nodes (4): browser, browserName, launchOptions, channel ### Community 134 - "Community 134" + Cohesion: 0.50 Nodes (3): Artifacts this phase produces (Plan 01), STRIDE Threat Register, Trust Boundaries ### Community 135 - "Community 135" + Cohesion: 0.50 Nodes (3): Artifacts this phase produces (Plan 02), STRIDE Threat Register, Trust Boundaries ### Community 136 - "Community 136" + Cohesion: 0.50 Nodes (3): Artifacts this phase produces (Plan 03), STRIDE Threat Register, Trust Boundaries ### Community 137 - "Community 137" + Cohesion: 0.50 Nodes (3): Artifacts this phase produces (Plan 04), STRIDE Threat Register, Trust Boundaries ### Community 138 - "Community 138" + Cohesion: 0.50 Nodes (4): Claude's Discretion, Deferred Ideas (OUT OF SCOPE), Locked Decisions, User Constraints (from CONTEXT.md) ### Community 139 - "Community 139" + Cohesion: 0.50 Nodes (4): Code Examples, Hono app bootstrap with all middleware, Serving PWA static files from Hono, User upsert with color assignment ### Community 140 - "Community 140" + Cohesion: 0.50 Nodes (4): Core (Phase 1 scope), Phase 1 backend install, Phase 1 frontend install, Standard Stack ### Community 141 - "Community 141" + Cohesion: 0.50 Nodes (4): Primary (HIGH confidence), Secondary (MEDIUM confidence), Sources, Tertiary (LOW confidence) ### Community 142 - "Community 142" + Cohesion: 0.50 Nodes (3): Artifacts this phase produces (Plan 01), STRIDE Threat Register, Trust Boundaries ### Community 143 - "Community 143" + Cohesion: 0.50 Nodes (3): Artifacts this phase produces (Plan 02), STRIDE Threat Register, Trust Boundaries ### Community 144 - "Community 144" + Cohesion: 0.50 Nodes (3): Artifacts this phase produces (Plan 03), STRIDE Threat Register, Trust Boundaries ### Community 145 - "Community 145" + Cohesion: 0.50 Nodes (3): Artifacts this phase produces (Plan 04), STRIDE Threat Register, Trust Boundaries ### Community 146 - "Community 146" + Cohesion: 0.50 Nodes (3): Artifacts this phase produces (Plan 05), STRIDE Threat Register, Trust Boundaries ### Community 147 - "Community 147" + Cohesion: 0.50 Nodes (4): All-Day Event: Server Format to Schedule-X PlainDate, Code Examples, Schedule-X CSS Token Override Pattern, VTIMEZONE Registration + ICAL.RecurExpansion (complete pattern) ### Community 148 - "Community 148" + Cohesion: 0.50 Nodes (4): Claude's Discretion, Deferred Ideas (OUT OF SCOPE), Locked Decisions, User Constraints (from CONTEXT.md) ### Community 149 - "Community 149" + Cohesion: 0.50 Nodes (4): Core (all versions verified against npm registry 2026-06-04), Installation (PWA only), No New Backend Dependencies Needed, Standard Stack ### Community 150 - "Community 150" + Cohesion: 0.50 Nodes (4): Primary (HIGH confidence), Secondary (MEDIUM confidence), Sources, Tertiary (LOW confidence) ### Community 151 - "Community 151" + Cohesion: 0.50 Nodes (3): Critical Anti-Patterns (do NOT repeat these), Infrastructure State, Required Reading (in order) ### Community 152 - "Community 152" + Cohesion: 0.50 Nodes (4): Claude's Discretion, Deferred Ideas (OUT OF SCOPE), Locked Decisions, User Constraints (from CONTEXT.md) ### Community 153 - "Community 153" + Cohesion: 0.50 Nodes (4): Code Examples, Create a recurring event (whole-series RRULE presets), Detect installed state (for hiding install prompts), Sync-state poll with TanStack Query ### Community 154 - "Community 154" + Cohesion: 0.50 Nodes (4): Primary (HIGH confidence), Secondary (MEDIUM confidence), Sources, Tertiary (LOW confidence / ASSUMED) ### Community 155 - "Community 155" + Cohesion: 0.50 Nodes (3): Phase Goal, STRIDE Threat Register, Trust Boundaries ### Community 156 - "Community 156" + Cohesion: 0.50 Nodes (4): Claude's Discretion, Deferred Ideas (OUT OF SCOPE), Locked Decisions, User Constraints (from CONTEXT.md) ### Community 157 - "Community 157" + Cohesion: 0.50 Nodes (4): Phase Requirements → Test Map, Test Framework, Validation Architecture, Wave 0 Gaps ### Community 158 - "Community 158" + Cohesion: 0.50 Nodes (3): Infrastructure State, Open Decisions for the Planner (intentionally NOT pre-decided), Required Reading (in order) ### Community 159 - "Community 159" + Cohesion: 0.09 Nodes (21): API Surface, `DELETE /api/events/:uid`, `DELETE /api/list-items/:itemId`, `DELETE /api/lists/:id`, `GET /api/events`, `GET /api/events/sync-status`, `GET /api/events/writable-calendars`, `GET /api/lists` (+13 more) ### Community 160 - "Community 160" + Cohesion: 0.08 Nodes (25): `apps/api/src/broker/expand.ts` (service, transform) — D-08, `apps/api/src/broker/outboxWorker.ts` (service, CRUD) — D-06, `apps/api/src/broker/vevent.ts` (service, transform) — D-06, `apps/api/tests/broker/expand.test.ts` (test, transform) — D-06, D-08, `apps/api/tests/broker/vevent.test.ts` (test, transform) — D-06, `apps/pwa/src/api/client.ts` (service, request-response) — D-06, D-11, `apps/pwa/src/components/CalendarShell.tsx` (component, request-response) — D-10, D-11, `apps/pwa/src/components/EventForm.tsx` (component, request-response) — D-04, D-06, D-07, D-08 (+17 more) ### Community 161 - "Community 161" + Cohesion: 0.08 Nodes (25): list_shares_id, list_shares_user_id_users_id_fk, columns, isUnique, name, idx_list_shares_user_id, checkConstraint, compositePrimaryKeys (+17 more) ### Community 162 - "Community 162" + Cohesion: 0.67 Nodes (3): Applicable ASVS Categories (Level 1), Known Threat Patterns for This Stack, Security Domain ### Community 163 - "Community 163" + Cohesion: 0.67 Nodes (3): Applicable ASVS Categories, Known Threat Patterns for This Phase, Security Domain ### Community 164 - "Community 164" + Cohesion: 0.67 Nodes (3): Backend: `/api/events` Evolution, Current state (Phase 1), Target state (Phase 2) ### Community 173 - "Community 173" + Cohesion: 0.67 Nodes (3): Applicable ASVS Categories, Known Threat Patterns, Security Domain ### Community 174 - "Community 174" + Cohesion: 0.67 Nodes (3): Architecture Patterns, Recommended Project Structure Additions, System Architecture Diagram ### Community 180 - "Community 180" + Cohesion: 0.67 Nodes (3): Applicable ASVS Categories, Known Threat Patterns for This Phase, Security Domain ### Community 181 - "Community 181" + Cohesion: 0.67 Nodes (3): Architecture Patterns, Recommended Project Structure, System Architecture Diagram ### Community 182 - "Community 182" + Cohesion: 0.67 Nodes (3): Core (already installed — no new installs needed), New Dependencies (must install), Standard Stack ### Community 184 - "Community 184" + Cohesion: 0.67 Nodes (3): plan_review, source_grounding, source_grounding_authority ### Community 193 - "Community 193" + Cohesion: 0.67 Nodes (3): graphify, auto_update, enabled ### Community 210 - "Community 210" + Cohesion: 0.09 Nodes (13): mockDb, db, pool, listItems, lists, listShares, users, getAccessibleListIds() (+5 more) ### Community 211 - "Community 211" + Cohesion: 0.08 Nodes (25): columns, autoincrement, name, notNull, primaryKey, type, color, display_name (+17 more) ### Community 212 - "Community 212" + Cohesion: 0.11 Nodes (17): 10. Live sync between two members (within seconds), 11. Live sync survives a brief reconnect, 12. Private-list isolation (no cross-leak), 1. Cold Start Smoke Test, 2. Navigate to Lists (bottom tab bar), 3. Create a named list (defaults to Shared), 4. Delete a list with confirmation, 5. Open a list and add items (+9 more) ### Community 213 - "Community 213" + Cohesion: 0.12 Nodes (16): 10. Coverage check (goal-backward against the phase user story), 1. Cold-start smoke — app boots and renders after the fixes, 2. Create-event UI flow → enqueue → sync feedback, 3. All-day toggle hides time inputs, 4. Edit mode pre-fill + recurrence preserved (WR-01 / WR-02 fix), 5. Member-scoped read (CR-01 GET path), 6. CR-01/CR-02 member-scoped edit/delete + freshest-etag (byte/SQL level), 7. CR-03 all-day inclusive/exclusive round-trip (byte level) (+8 more) ### Community 214 - "Community 214" + Cohesion: 0.12 Nodes (16): CR-01: Sharee can de-share or re-share a list — privilege escalation on `isShared` toggle, CR-02: SSE subscription scope is stale — newly shared lists never delivered to live subscribers, CR-03: `Number(c.req.param(...))` — NaN propagates silently into DB queries, Critical Issues, IN-01: `useListSSE` connects to `/api/sse/lists` — not scoped to the current `listId`, IN-02: `getAccessibleListIds` issues two sequential DB round-trips that could be one query, IN-03: The 401 test in `lists.test.ts` is a no-op assertion, Info (+8 more) ### Community 215 - "Community 215" + Cohesion: 0.50 Nodes (4): compositePrimaryKeys, columns, name, calendars_id ### Community 216 - "Community 216" + Cohesion: 0.22 Nodes (9): list_items_id, checkConstraint, compositePrimaryKeys, foreignKeys, columns, name, name, uniqueConstraints (+1 more) ### Community 217 - "Community 217" + Cohesion: 0.12 Nodes (17): list_shares_id, columns, isUnique, name, idx_list_shares_user_id, checkConstraint, compositePrimaryKeys, foreignKeys (+9 more) ### Community 218 - "Community 218" + Cohesion: 0.25 Nodes (8): list_shares_list_id_lists_id_fk, columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo ### Community 219 - "Community 219" + Cohesion: 0.12 Nodes (15): Accomplishments, Decisions Made, Dependency graph, Deviations from Plan, Files Created/Modified, Issues Encountered, Metrics, Must-Haves Verification (+7 more) ### Community 220 - "Community 220" + Cohesion: 0.13 Nodes (14): API Tests, Auto-fixed Issues, Deviations from Plan, Existing Tests, Full API Suite, Known Stubs, Phase 4 Plan 3: List CRUD Vertical Slice Summary, Playwright Browser Check (+6 more) ### Community 221 - "Community 221" + Cohesion: 0.13 Nodes (14): Anti-Patterns Found, Behavioral Spot-Checks, Data-Flow Trace (Level 4), Gap Closure Detail: LIST-03 Rank Collation, Gap Closure Detail: T-04-08 / T-04-05 Owner Guard, Gaps Summary, Goal Achievement, Human Verification Required (+6 more) ### Community 222 - "Community 222" + Cohesion: 0.14 Nodes (13): Auto-fixed Issues, Deviations from Plan, Known Stubs, Migration, Phase 4 Plan 1: Foundation Shell Summary, Playwright Browser Check (per CLAUDE.md convention), Self-Check: PASSED, Tasks Completed (+5 more) ### Community 223 - "Community 223" + Cohesion: 0.14 Nodes (13): Auto-fixed Issues, Deviations from Plan, Full API Suite, ioredis Check, Known Stubs, Phase 4 Plan 2: Scoped Fan-out Primitives Summary, Self-Check: PASSED, Tasks Completed (+5 more) ### Community 224 - "Community 224" + Cohesion: 0.14 Nodes (13): API Tests, Auto-fixed Issues, Deviations from Plan, Known Stubs, Phase 4 Plan 4: Item CRUD + Checked-Sink Vertical Slice Summary, Playwright Browser Check, PWA Tests, Self-Check: PASSED (+5 more) ### Community 225 - "Community 225" + Cohesion: 0.14 Nodes (13): API Tests, Deviations from Plan, Known Stubs, Phase 4 Plan 6: Live-Sync SSE Vertical Slice Summary, Playwright Browser Check, PWA Tests, Self-Check: PASSED, SSE Endpoint Verification (+5 more) ### Community 226 - "Community 226" + Cohesion: 0.15 Nodes (12): API Tests, Auto-fixed Issues, Deviations from Plan, Known Stubs, Phase 4 Plan 5: Drag-to-Reorder Vertical Slice Summary, Playwright Browser Check, PWA Tests, PWA TypeScript (+4 more) ### Community 227 - "Community 227" + Cohesion: 0.15 Nodes (13): indexes, columns, isUnique, name, columns, isUnique, name, columns (+5 more) ### Community 228 - "Community 228" + Cohesion: 0.06 Nodes (32): API Reference, Authentication, Calendar Events, `DELETE /api/events/:uid`, `DELETE /api/list-items/:itemId`, `DELETE /api/lists/:id`, `DELETE /api/push/subscription`, Endpoints Overview (+24 more) ### Community 229 - "Community 229" + Cohesion: 0.11 Nodes (19): id, rank, text, autoincrement, name, notNull, primaryKey, type (+11 more) ### Community 230 - "Community 230" + Cohesion: 0.08 Nodes (24): is_shared, name, lists_owner_id_users_id_fk, columns, isUnique, name, idx_lists_owner_id, autoincrement (+16 more) ### Community 231 - "Community 231" + Cohesion: 0.09 Nodes (21): 1. Install dependencies, 2. Start the dev database and Redis, 3. Configure environment variables, API dev loop, `apps/api` scripts, `apps/pwa` scripts, Build Commands, Code Style (+13 more) ### Community 232 - "Community 232" + Cohesion: 0.06 Nodes (33): columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo, checkConstraint (+25 more) ### Community 233 - "Community 233" + Cohesion: 0.11 Nodes (19): autoincrement, name, notNull, primaryKey, type, columns, autoincrement, name (+11 more) ### Community 234 - "Community 234" + Cohesion: 0.15 Nodes (13): indexes, columns, isUnique, name, columns, isUnique, name, columns (+5 more) ### Community 235 - "Community 235" + Cohesion: 0.10 Nodes (20): is_shared, name, owner_id, autoincrement, default, name, notNull, primaryKey (+12 more) ### Community 236 - "Community 236" + Cohesion: 0.10 Nodes (20): autoincrement, default, name, notNull, primaryKey, type, checked, rank (+12 more) ### Community 237 - "Community 237" + Cohesion: 0.15 Nodes (13): checkConstraint, foreignKeys, indexes, name, uniqueConstraints, columns, isUnique, name (+5 more) ### Community 238 - "Community 238" + Cohesion: 0.08 Nodes (25): list_shares_id, list_shares_list_id_lists_id_fk, columns, isUnique, name, idx_list_shares_user_id, checkConstraint, compositePrimaryKeys (+17 more) ### Community 239 - "Community 239" + Cohesion: 0.13 Nodes (17): runReminderCheck(), sentReminders, startReminderScheduler(), yyyyMmDd(), calendarEvents, pushSubscriptions, notifyListChange(), sendListChangePush() (+9 more) ### Community 240 - "Community 240" + Cohesion: 0.18 Nodes (10): Accepted Risks Log, Audit Observations (non-blocking, from 04-REVIEW.md), Closed Threat Detail (Plan 04-07), Phase 4 — Security, Security Audit Trail, Sign-Off, T-04-05 — isShared reconciliation runs for any allowed user — CLOSED, T-04-08 — Sharee can rewrite list_shares via PATCH `isShared` — CLOSED (+2 more) ### Community 241 - "Community 241" + Cohesion: 0.18 -Nodes (10): Community Hubs (Navigation), Corpus Check, God Nodes (most connected - your core abstractions), Graph Freshness, Graph Report - familysync (2026-06-10), Import Cycles, Knowledge Gaps, Suggested Questions (+2 more) +Nodes (10): Community Hubs (Navigation), Corpus Check, God Nodes (most connected - your core abstractions), Graph Freshness, Graph Report - familysync (2026-06-10), Import Cycles, Knowledge Gaps, Suggested Questions (+2 more) ### Community 242 - "Community 242" + Cohesion: 0.18 Nodes (3): MockEventSource, MockEventSourceInstance, mockInstances ### Community 243 - "Community 243" + Cohesion: 0.09 Nodes (21): Auth flow (999.2/999.3), Auth-flow polish (999.2 + 999.3), Canonical References, Claude's Discretion / delegated to UI-phase, Deferred Ideas, Established Patterns, Event-form end-tracking (999.7) — applies to ALL events, not just recurring, Event form / write-back (999.6/7/8/9) (+13 more) ### Community 244 - "Community 244" + Cohesion: 0.12 Nodes (15): dialect, lists_owner_id_users_id_fk, id, columns, isUnique, name, idx_lists_owner_id, foreignKeys (+7 more) ### Community 245 - "Community 245" + Cohesion: 0.22 Nodes (9): PATCH /api/events/:uid/edit, auth, body, description, file, method, params, path (+1 more) ### Community 246 - "Community 246" + Cohesion: 0.22 Nodes (9): PATCH /api/list-items/:itemId, auth, body, description, file, method, params, path (+1 more) ### Community 247 - "Community 247" + Cohesion: 0.22 Nodes (9): PATCH /api/lists/:id, auth, body, description, file, method, params, path (+1 more) ### Community 248 - "Community 248" + Cohesion: 0.22 Nodes (9): POST /api/lists/:id/items, auth, body, description, file, method, params, path (+1 more) ### Community 249 - "Community 249" + Cohesion: 0.22 Nodes (9): columns, isUnique, name, columns, isUnique, name, idx_list_items_list_id_checked, idx_list_items_list_id_rank (+1 more) ### Community 250 - "Community 250" + Cohesion: 0.25 Nodes (8): columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo, calendar_outbox_user_id_users_id_fk ### Community 251 - "Community 251" + Cohesion: 0.25 Nodes (8): list_items_list_id_lists_id_fk, columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo ### Community 252 - "Community 252" + Cohesion: 0.22 Nodes (9): list_items_id, checkConstraint, compositePrimaryKeys, foreignKeys, columns, name, name, uniqueConstraints (+1 more) ### Community 253 - "Community 253" + Cohesion: 0.22 Nodes (9): columns, isUnique, name, columns, isUnique, name, idx_list_items_list_id_checked, idx_list_items_list_id_rank (+1 more) ### Community 254 - "Community 254" + Cohesion: 0.25 Nodes (8): auth, description, file, method, params, path, response, DELETE /api/events/:uid ### Community 255 - "Community 255" + Cohesion: 0.25 Nodes (8): auth, description, file, method, params, path, response, DELETE /api/list-items/:itemId ### Community 256 - "Community 256" + Cohesion: 0.25 Nodes (8): auth, description, file, method, params, path, response, DELETE /api/lists/:id ### Community 257 - "Community 257" + Cohesion: 0.25 Nodes (8): GET /api/events, auth, description, file, method, params, path, response ### Community 258 - "Community 258" + Cohesion: 0.25 Nodes (8): GET /api/events/sync-status, auth, description, file, method, params, path, response ### Community 259 - "Community 259" + Cohesion: 0.25 Nodes (8): GET /api/lists/:id/items, auth, description, file, method, params, path, response ### Community 260 - "Community 260" + Cohesion: 0.25 Nodes (8): POST /api/events/create, auth, body, description, file, method, path, response ### Community 261 - "Community 261" + Cohesion: 0.25 Nodes (8): POST /api/lists, auth, body, description, file, method, path, response ### Community 262 - "Community 262" + Cohesion: 0.10 Nodes (20): Canonical References, Carried forward — locked, NOT re-discussed, Claude's Discretion (researcher / planner decide), Deferred Ideas, Established Patterns, Existing Code Insights, Implementation Decisions, Integration code (read before implementing) (+12 more) ### Community 263 - "Community 263" + Cohesion: 0.25 Nodes (8): columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo, calendars_user_id_users_id_fk ### Community 264 - "Community 264" + Cohesion: 0.25 Nodes (8): updated_at, autoincrement, default, name, notNull, onUpdate, primaryKey, type ### Community 265 - "Community 265" + Cohesion: 0.16 Nodes (12): emitter, ListEvent, publishListEvent(), subscribeListEvents(), rankBetween(), rankForAppend(), createItemSchema, createListSchema (+4 more) ### Community 266 - "Community 266" + Cohesion: 0.25 Nodes (8): list_shares_user_id_users_id_fk, columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo ### Community 267 - "Community 267" + Cohesion: 0.10 Nodes (20): CR-01: `recurrenceUntil` is not validated as a date before being spliced into an RRULE and written to Fastmail, Critical Issues, IN-01: Dead/misleading interface doc comment in `client.ts` CalendarOccurrence, IN-02: `resolveDefaultView` ignores its only branch's intent, IN-03: `members` list in CalendarShell is always length-1 (only the current user), IN-04: `recurrenceCount` default of `1` is sent-eligible the instant bound flips to "count", IN-05: Duplicated focus-trap implementation across two dialogs, IN-06: `weekly-count3.ics` fixture DESCRIPTION exceeds the typical 75-octet ICS line without folding (+12 more) ### Community 268 - "Community 268" + Cohesion: 0.12 Nodes (15): Authentication, Backend modules, Calendar background sync (poller), Calendar read (typical request), Calendar write (create/edit/delete), Component Interaction, Data Flow, Directory Rationale (+7 more) ### Community 269 - "Community 269" + Cohesion: 0.12 Nodes (17): checkConstraint, compositePrimaryKeys, foreignKeys, columns, name, indexes, name, uniqueConstraints (+9 more) ### Community 270 - "Community 270" + Cohesion: 0.25 Nodes (8): updated_at, autoincrement, default, name, notNull, onUpdate, primaryKey, type ### Community 271 - "Community 271" + Cohesion: 0.25 Nodes (8): list_items_list_id_lists_id_fk, columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo ### Community 272 - "Community 272" + Cohesion: 0.29 Nodes (6): Accepted Risks Log, Phase 03 — Security, Security Audit Trail, Sign-Off, Threat Register, Trust Boundaries ### Community 273 - "Community 273" + Cohesion: 0.29 Nodes (7): GET /api/events/writable-calendars, auth, description, file, method, path, response ### Community 274 - "Community 274" + Cohesion: 0.29 Nodes (7): GET /api/lists, auth, description, file, method, path, response ### Community 275 - "Community 275" + Cohesion: 0.29 Nodes (7): GET /api/me, auth, description, file, method, path, response ### Community 276 - "Community 276" + Cohesion: 0.29 Nodes (7): GET /api/sse/heartbeat, auth, description, file, method, path, response ### Community 277 - "Community 277" + Cohesion: 0.29 Nodes (7): GET /api/sse/lists, auth, description, file, method, path, response ### Community 278 - "Community 278" + Cohesion: 0.29 Nodes (7): autoincrement, default, name, notNull, primaryKey, type, all_day ### Community 279 - "Community 279" + Cohesion: 0.11 Nodes (19): autoincrement, name, notNull, primaryKey, type, columns, calendar_object_url, group_id (+11 more) ### Community 280 - "Community 280" + Cohesion: 0.29 Nodes (7): columns, autoincrement, name, notNull, primaryKey, type, calendar_id ### Community 281 - "Community 281" + Cohesion: 0.29 Nodes (7): autoincrement, default, name, notNull, primaryKey, type, attempt_count ### Community 282 - "Community 282" + Cohesion: 0.10 Nodes (20): Backlog, Overview, Phase 1: Foundation + Broker Spike, Phase 2: Calendar Display, Phase 3: Event Write-Back + PWA Install, Phase 4: Shared Lists + Live Sync, Phase 5: Web Push Notifications, Phase 6: UX Polish (+12 more) ### Community 283 - "Community 283" + Cohesion: 0.29 Nodes (7): has_rrule, autoincrement, default, name, notNull, primaryKey, type ### Community 284 - "Community 284" + Cohesion: 0.29 Nodes (7): created_at, autoincrement, default, name, notNull, primaryKey, type ### Community 285 - "Community 285" + Cohesion: 0.29 Nodes (7): next_attempt_at, autoincrement, default, name, notNull, primaryKey, type ### Community 286 - "Community 286" + Cohesion: 0.29 Nodes (7): status, autoincrement, default, name, notNull, primaryKey, type ### Community 287 - "Community 287" + Cohesion: 0.29 Nodes (7): autoincrement, default, name, notNull, primaryKey, type, all_day ### Community 288 - "Community 288" + Cohesion: 0.29 Nodes (7): autoincrement, default, name, notNull, primaryKey, type, attempt_count ### Community 289 - "Community 289" + Cohesion: 0.10 Nodes (20): columns, has_rrule, object_url, raw_vevent, autoincrement, default, name, notNull (+12 more) ### Community 290 - "Community 290" + Cohesion: 0.10 Nodes (20): autoincrement, default, name, notNull, primaryKey, type, checked, rank (+12 more) ### Community 291 - "Community 291" + Cohesion: 0.10 Nodes (20): created_at, list_id, user_id, autoincrement, default, name, notNull, primaryKey (+12 more) ### Community 292 - "Community 292" + Cohesion: 0.13 Nodes (14): 1. Clone the repository, 2. Enable pnpm via corepack, 3. Install dependencies, 4. Copy the environment file, 5. Start the database services, 6. Run database migrations, 7. Build and start the API, 8. Start the PWA dev server (+6 more) ### Community 293 - "Community 293" + Cohesion: 0.11 Nodes (18): CR-01: `recurrenceUntil` not validated as a date before RRULE splice, Fixed Issues, IN-01: Stale `CalendarOccurrence.id` doc comment in client.ts, IN-02: `resolveDefaultView` indirection, IN-03: `members` list in CalendarShell is always length-1, IN-04: `recurrenceCount` default of `1` is send-eligible the instant bound flips to "count", IN-05: Duplicated focus-trap implementation across two dialogs, IN-06: `weekly-count3.ics` DESCRIPTION line exceeds 75 octets without folding (+10 more) ### Community 294 - "Community 294" + Cohesion: 0.29 Nodes (7): next_attempt_at, autoincrement, default, name, notNull, primaryKey, type ### Community 295 - "Community 295" + Cohesion: 0.29 Nodes (7): status, autoincrement, default, name, notNull, primaryKey, type ### Community 296 - "Community 296" + Cohesion: 0.33 -Nodes (5): entries, _meta, commit, updated_at, version +Nodes (5): entries, \_meta, commit, updated_at, version ### Community 297 - "Community 297" + Cohesion: 0.33 Nodes (6): GET /api/login, auth, description, file, method, path ### Community 298 - "Community 298" + Cohesion: 0.33 Nodes (6): GET /callback, auth, description, file, method, path ### Community 299 - "Community 299" + Cohesion: 0.33 Nodes (6): GET /health, auth, description, file, method, path ### Community 300 - "Community 300" + Cohesion: 0.33 -Nodes (5): entries, _meta, commit, updated_at, version +Nodes (5): entries, \_meta, commit, updated_at, version ### Community 301 - "Community 301" + Cohesion: 0.33 Nodes (6): invocation, type, used_by, version, workspace, @dnd-kit/core ### Community 302 - "Community 302" + Cohesion: 0.33 Nodes (6): invocation, type, used_by, version, workspace, @dnd-kit/sortable ### Community 303 - "Community 303" + Cohesion: 0.33 Nodes (6): invocation, type, used_by, version, workspace, drizzle-kit ### Community 304 - "Community 304" + Cohesion: 0.33 Nodes (6): invocation, type, used_by, version, workspace, drizzle-orm ### Community 305 - "Community 305" + Cohesion: 0.33 Nodes (6): fractional-indexing, invocation, type, used_by, version, workspace ### Community 306 - "Community 306" + Cohesion: 0.33 Nodes (6): hono, invocation, type, used_by, version, workspace ### Community 307 - "Community 307" + Cohesion: 0.33 Nodes (6): @hono/node-server, invocation, type, used_by, version, workspace ### Community 308 - "Community 308" + Cohesion: 0.33 Nodes (6): @hono/oidc-auth, invocation, type, used_by, version, workspace ### Community 309 - "Community 309" + Cohesion: 0.33 Nodes (6): @hono/zod-validator, invocation, type, used_by, version, workspace ### Community 310 - "Community 310" + Cohesion: 0.33 Nodes (6): ical.js, invocation, type, used_by, version, workspace ### Community 311 - "Community 311" + Cohesion: 0.33 Nodes (6): lucide-react, invocation, type, used_by, version, workspace ### Community 312 - "Community 312" + Cohesion: 0.33 Nodes (6): mysql2, invocation, type, used_by, version, workspace ### Community 313 - "Community 313" + Cohesion: 0.33 Nodes (6): node-cron, invocation, type, used_by, version, workspace ### Community 314 - "Community 314" + Cohesion: 0.33 Nodes (6): react, invocation, type, used_by, version, workspace ### Community 315 - "Community 315" + Cohesion: 0.33 Nodes (6): react-router, invocation, type, used_by, version, workspace ### Community 316 - "Community 316" + Cohesion: 0.33 Nodes (6): @schedule-x/calendar, invocation, type, used_by, version, workspace ### Community 317 - "Community 317" + Cohesion: 0.33 Nodes (6): @schedule-x/react, invocation, type, used_by, version, workspace ### Community 318 - "Community 318" + Cohesion: 0.33 Nodes (6): @tanstack/react-query, invocation, type, used_by, version, workspace ### Community 319 - "Community 319" + Cohesion: 0.33 Nodes (6): temporal-polyfill, invocation, type, used_by, version, workspace ### Community 320 - "Community 320" + Cohesion: 0.33 Nodes (6): tsdav, invocation, type, used_by, version, workspace ### Community 321 - "Community 321" + Cohesion: 0.33 Nodes (6): vite, invocation, type, used_by, version, workspace ### Community 322 - "Community 322" + Cohesion: 0.33 Nodes (6): vite-plugin-pwa, invocation, type, used_by, version, workspace ### Community 323 - "Community 323" + Cohesion: 0.33 Nodes (6): vitest, invocation, type, used_by, version, workspace ### Community 324 - "Community 324" + Cohesion: 0.33 Nodes (6): zod, invocation, type, used_by, version, workspace ### Community 325 - "Community 325" + Cohesion: 0.33 Nodes (6): zustand, invocation, type, used_by, version, workspace ### Community 326 - "Community 326" + Cohesion: 0.33 -Nodes (5): entries, _meta, commit, updated_at, version +Nodes (5): entries, \_meta, commit, updated_at, version ### Community 327 - "Community 327" + Cohesion: 0.14 Nodes (13): Broker Encryption, Config File Reference, Database, Defaults Summary, Environment Variables, FamilySync — Configuration Reference, Local Development (host-side), OIDC / Authelia Authentication (+5 more) ### Community 328 - "Community 328" + Cohesion: 0.11 Nodes (18): CR-01: `recurrenceUntil` not validated as a date before RRULE splice, Fixed Issues, IN-01: Stale `CalendarOccurrence.id` doc comment in client.ts, IN-02: `resolveDefaultView` indirection, IN-03: `members` list in CalendarShell is always length-1, IN-04: `recurrenceCount` default of `1` is send-eligible the instant bound flips to "count", IN-05: Duplicated focus-trap implementation across two dialogs, IN-06: `weekly-count3.ics` DESCRIPTION line exceeds 75 octets without folding (+10 more) ### Community 329 - "Community 329" + Cohesion: 0.08 Nodes (25): columns, autoincrement, name, notNull, primaryKey, type, color, ctag (+17 more) ### Community 330 - "Community 330" + Cohesion: 0.15 Nodes (13): list_id, user_id, autoincrement, name, notNull, primaryKey, type, columns (+5 more) ### Community 331 - "Community 331" + Cohesion: 0.33 Nodes (6): dtstart_utc, autoincrement, name, notNull, primaryKey, type ### Community 332 - "Community 332" + Cohesion: 0.33 Nodes (6): etag, autoincrement, name, notNull, primaryKey, type ### Community 333 - "Community 333" + Cohesion: 0.11 Nodes (17): All-day events, Area selection, Attribution, Claude's Discretion, Copy & anti-spam, Dead-subscription recovery, Deferred Ideas, Detail level (+9 more) ### Community 334 - "Community 334" + Cohesion: 0.11 Nodes (17): 1. iOS PWA install + notification permission grant, 2. iOS 15-minute reminder delivery, 3. iOS gesture gate validation (NEW-CR-01), 4. iOS subscription health-check (D-10 / success criterion 4), 5. Multi-device push delivery (end-to-end NOTIF-02 / NOTIF-03), Anti-Patterns Found, Behavioral Spot-Checks, D-05 Scope Narrowing Confirmation (+9 more) ### Community 335 - "Community 335" + Cohesion: 0.29 Nodes (7): created_at, autoincrement, default, name, notNull, primaryKey, type ### Community 336 - "Community 336" + Cohesion: 0.33 Nodes (6): operation, autoincrement, name, notNull, primaryKey, type ### Community 337 - "Community 337" + Cohesion: 0.33 Nodes (6): payload, autoincrement, name, notNull, primaryKey, type ### Community 338 - "Community 338" + Cohesion: 0.12 Nodes (16): Decisions Made, Deviations from Plan, Grep sanity, Known Stubs, Phase quick-260610-hbu Plan 01: Reminder Scheduler Resilience Summary, pnpm --filter @familysync/api test (full suite), pnpm --filter @familysync/api test (reminderScheduler only), pnpm --filter @familysync/api typecheck (+8 more) ### Community 339 - "Community 339" + Cohesion: 0.17 Nodes (11): Database migrations, Development, Environment variables, @familysync/api, Further reading, Prerequisites, Production, Running in the workspace (+3 more) ### Community 340 - "Community 340" + Cohesion: 0.17 Nodes (12): checkConstraint, compositePrimaryKeys, foreignKeys, columns, name, name, uniqueConstraints, calendar_events_id (+4 more) ### Community 341 - "Community 341" + Cohesion: 0.33 Nodes (6): url, autoincrement, name, notNull, primaryKey, type ### Community 342 - "Community 342" + Cohesion: 0.29 Nodes (7): is_shared, autoincrement, default, name, notNull, primaryKey, type ### Community 343 - "Community 343" + Cohesion: 0.08 Nodes (25): columns, ctag, display_name, last_synced_at, url, autoincrement, name, notNull (+17 more) ### Community 344 - "Community 344" + Cohesion: 0.12 Nodes (17): checkConstraint, compositePrimaryKeys, foreignKeys, columns, name, indexes, name, uniqueConstraints (+9 more) ### Community 345 - "Community 345" + Cohesion: 0.33 Nodes (6): dtstart_date, autoincrement, name, notNull, primaryKey, type ### Community 346 - "Community 346" + Cohesion: 0.33 Nodes (6): dtstart_utc, autoincrement, name, notNull, primaryKey, type ### Community 347 - "Community 347" + Cohesion: 0.12 Nodes (17): list_shares_id, columns, isUnique, name, idx_list_shares_user_id, checkConstraint, compositePrimaryKeys, foreignKeys (+9 more) ### Community 348 - "Community 348" + Cohesion: 0.33 Nodes (6): group_id, autoincrement, name, notNull, primaryKey, type ### Community 349 - "Community 349" + Cohesion: 0.33 Nodes (6): ctag, autoincrement, name, notNull, primaryKey, type ### Community 350 - "Community 350" + Cohesion: 0.33 Nodes (6): last_error, autoincrement, name, notNull, primaryKey, type ### Community 351 - "Community 351" + Cohesion: 0.22 Nodes (9): list_items_id, checkConstraint, compositePrimaryKeys, foreignKeys, columns, name, name, uniqueConstraints (+1 more) ### Community 352 - "Community 352" + Cohesion: 0.12 Nodes (15): 1. iOS/Standalone Cold-Load and OIDC Redirect (D-10/D-11), 2. PushPermissionPrompt Spinner on iOS Device (CP-04.3), Anti-Patterns Found, Behavioral Spot-Checks, Data-Flow Trace (Level 4), Gaps Summary, Goal Achievement, Human Verification Required (+7 more) ### Community 353 - "Community 353" + Cohesion: 0.17 Nodes (11): Calendar Integration, Commands, Deployment, FamilySync, Installation, License, Monorepo Structure, Prerequisites (+3 more) ### Community 354 - "Community 354" + Cohesion: 0.13 Nodes (14): 1. iOS PWA install → push subscription → 15-min reminder receipt, (1-week elapsed time, non-gating). Test 4 deferred to Phase 6 verification, 2. iOS push subscription does not receive NotAllowedError, 3. iOS subscription health-check keeps subscription alive after 1+ week of inactivity, 4. Android FCM: event-change push arrives after the other member modifies a calendar event, 5. List-change push coalescing is observable, Current Test, delivery confirmation moved to Phase 6). Phase 5 UAT resolved. (+6 more) ### Community 355 - "Community 355" + Cohesion: 0.33 Nodes (6): payload, autoincrement, name, notNull, primaryKey, type ### Community 356 - "Community 356" + Cohesion: 0.13 Nodes (14): Auto-fixed Issues, Checkpoint Verification (Task 4), Deviations from Plan, Follow-Up Fix After Checkpoint, Phase 06 Plan 05: Auth-Flow Gating + Session-Expiry (D-10/D-11) Summary, Residual Device-Only Item, Self-Check: PASSED, Task 1: SessionExpiredError + handleAuthResponse (TDD) (+6 more) ### Community 357 - "Community 357" + Cohesion: 0.20 Nodes (9): CI integration, Coverage requirements, File naming and location, Integration tests requiring a real database, Running tests, Test framework and setup, Test helpers, Testing (+1 more) ### Community 358 - "Community 358" + Cohesion: 0.33 Nodes (6): uid, autoincrement, name, notNull, primaryKey, type ### Community 359 - "Community 359" + Cohesion: 0.13 Nodes (14): Architectural Responsibility Map, Architecture Patterns, Assumptions Log, Don't Hand-Roll, Environment Availability, Known Threat Patterns for this phase, Metadata, Open Questions (+6 more) ### Community 360 - "Community 360" + Cohesion: 0.13 Nodes (14): Authentication & Onboarding, Calendar, Calendar, Display, Lists, Notifications, Out of Scope, PWA & Install (+6 more) ### Community 361 - "Community 361" + Cohesion: 0.40 -Nodes (4): _meta, commit, updated_at, version +Nodes (4): \_meta, commit, updated_at, version ### Community 362 - "Community 362" + Cohesion: 0.40 Nodes (5): decision, files, title, entries, D-08-per-field-lww-patch ### Community 363 - "Community 363" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/auth/devBypass.ts ### Community 364 - "Community 364" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/auth/middleware.ts ### Community 365 - "Community 365" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/auth/user.ts ### Community 366 - "Community 366" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/broker/client.ts ### Community 367 - "Community 367" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/broker/crypto.ts ### Community 368 - "Community 368" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/broker/expand.ts ### Community 369 - "Community 369" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/broker/outboxWorker.ts ### Community 370 - "Community 370" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/broker/poller.ts ### Community 371 - "Community 371" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/broker/sync.ts ### Community 372 - "Community 372" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/broker/vevent.ts ### Community 373 - "Community 373" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/broker/write.ts ### Community 374 - "Community 374" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/db/client.ts ### Community 375 - "Community 375" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/db/schema.ts ### Community 376 - "Community 376" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/index.ts ### Community 377 - "Community 377" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/lib/listAccess.ts ### Community 378 - "Community 378" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/lib/listEmitter.ts ### Community 379 - "Community 379" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/lib/rank.ts ### Community 380 - "Community 380" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/routes/events.ts ### Community 381 - "Community 381" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/routes/health.ts ### Community 382 - "Community 382" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/routes/lists.ts ### Community 383 - "Community 383" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/routes/me.ts ### Community 384 - "Community 384" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/api/src/routes/sse.ts ### Community 385 - "Community 385" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/pwa/src/api/client.ts ### Community 386 - "Community 386" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/pwa/src/api/listsClient.ts ### Community 387 - "Community 387" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/pwa/src/App.tsx ### Community 388 - "Community 388" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/pwa/src/components/BottomTabBar.tsx ### Community 389 - "Community 389" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/pwa/src/components/CalendarShell.tsx ### Community 390 - "Community 390" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/pwa/src/hooks/useListSSE.ts ### Community 391 - "Community 391" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/pwa/src/lib/calendarConfig.ts ### Community 392 - "Community 392" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/pwa/src/lib/colorUtils.ts ### Community 393 - "Community 393" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/pwa/src/lib/eventDateTime.ts ### Community 394 - "Community 394" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/pwa/src/lib/hydrateEvents.ts ### Community 395 - "Community 395" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/pwa/src/lib/loginRedirect.ts ### Community 396 - "Community 396" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/pwa/src/main.tsx ### Community 397 - "Community 397" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/pwa/src/routes/ListDetail.tsx ### Community 398 - "Community 398" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/pwa/src/routes/ListsIndex.tsx ### Community 399 - "Community 399" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/pwa/src/store/calendarStore.ts ### Community 400 - "Community 400" + Cohesion: 0.40 Nodes (5): exports, imports, notes, type, apps/pwa/src/store/listsStore.ts ### Community 401 - "Community 401" + Cohesion: 0.33 Nodes (5): dialect, id, prevId, tables, version ### Community 402 - "Community 402" + Cohesion: 0.14 Nodes (13): Auto-added: VAPID container-transposability (orchestrator requirement), Deviations from Plan, Known Stubs, Package dependencies committed separately (Rule 3 — blocking issue), Phase 05 Plan 01: Wave-0 Foundation Summary, Self-Check, Self-Check: PASSED, Task 1: Package legitimacy gate + install push dependencies (+5 more) ### Community 403 - "Community 403" + Cohesion: 0.14 Nodes (13): Auto-fixed Issues, Deviations from Plan, Dispatch signature simplification, Known Stubs, Phase 05 Plan 03: pushCoalescer — per-(list,actor) debounce — Summary, Self-Check, Self-Check: PASSED, Task 1: RED — fix lint warning, add actorId assertion (+5 more) ### Community 404 - "Community 404" + Cohesion: 0.14 Nodes (13): Architectural Responsibility Map, Assumptions Log, Don't Hand-Roll, Environment Availability, Metadata, Open Questions (RESOLVED), Package Legitimacy Audit, Phase 5: Web Push Notifications — Research (+5 more) ### Community 405 - "Community 405" + Cohesion: 0.50 Nodes (3): Artifacts this phase produces, STRIDE Threat Register, Trust Boundaries ### Community 406 - "Community 406" + Cohesion: 0.50 Nodes (4): decision, files, title, broker-cache-api-pattern ### Community 407 - "Community 407" + Cohesion: 0.50 Nodes (4): decision, files, title, D-01-D-02-list-sharing ### Community 408 - "Community 408" + Cohesion: 0.50 Nodes (4): decision, files, title, D-03-writable-set ### Community 409 - "Community 409" + Cohesion: 0.50 Nodes (4): decision, files, title, D-04-scoped-sse-fan-out ### Community 410 - "Community 410" + Cohesion: 0.50 Nodes (4): decision, files, title, D-10-D-11-D-12-sse-resilience ### Community 411 - "Community 411" + Cohesion: 0.50 Nodes (4): decision, files, title, D-13-dual-field-dtstart ### Community 412 - "Community 412" + Cohesion: 0.50 Nodes (4): decision, files, title, D-13-fractional-rank ### Community 413 - "Community 413" + Cohesion: 0.50 Nodes (4): decision, files, title, D-16-shared-fastmail-account ### Community 414 - "Community 414" + Cohesion: 0.50 Nodes (4): decision, files, title, dev-auth-bypass ### Community 415 - "Community 415" + Cohesion: 0.50 Nodes (4): identity-keying, decision, files, title ### Community 416 - "Community 416" + Cohesion: 0.50 Nodes (4): lists-storage-mariadb-not-caldav, decision, files, title ### Community 417 - "Community 417" + Cohesion: 0.50 Nodes (4): oidc-behind-pangolin, decision, files, title ### Community 418 - "Community 418" + Cohesion: 0.50 Nodes (4): outbox-status-machine, decision, files, title ### Community 419 - "Community 419" + Cohesion: 0.50 Nodes (4): pwa-static-serving, decision, files, title ### Community 420 - "Community 420" + Cohesion: 0.50 Nodes (4): react-router-spa-shell, decision, files, title ### Community 421 - "Community 421" + Cohesion: 0.50 Nodes (4): schedule-x-routing, decision, files, title ### Community 422 - "Community 422" + Cohesion: 0.50 Nodes (4): state-ownership, decision, files, title ### Community 423 - "Community 423" + Cohesion: 0.50 Nodes (4): write-broker-boundary, decision, files, title ### Community 424 - "Community 424" + Cohesion: 0.14 Nodes (13): Auto-fixed Issues, Checkpoint Verification (Task 4), Deviations from Plan, Integration Fix (commit `69e5ae8`), Known Stubs, Phase 06 Plan 06: EventForm Integration Slice (999.6/7/8/9) Summary, Self-Check: PASSED, Task 1: End-Tracking + Recurrence-Bound Control (D-04, D-06, D-07, D-05 verify) (+5 more) ### Community 425 - "Community 425" + Cohesion: 0.14 Nodes (13): Accomplishments, Auto-fixed Issues, Decisions Made, Deviations from Plan, Files Created/Modified, Issues Encountered, Next Phase Readiness, Performance (+5 more) ### Community 426 - "Community 426" + Cohesion: 0.50 Nodes (3): dialect, entries, version ### Community 427 - "Community 427" + Cohesion: 0.15 Nodes (12): Auto-fixed issues, Deferred (iOS Device-Only Checks), Deviations from Plan, Known Stubs, Phase 05 Plan 04: Push Vertical Slice — Subscribe, SW, Prompt Summary, Self-Check, Self-Check: PASSED, Task 1: Push subscription API + startup VAPID wiring (+4 more) ### Community 428 - "Community 428" + Cohesion: 0.15 Nodes (12): Auto-fixed Issues, Deviations from Plan, Known Stubs, Phase 05 Plan 05: listChangeDispatcher — NOTIF-02 List-Change Push — Summary, Self-Check, Self-Check: PASSED, Task 1: listChangeDispatcher — access-scoped, self-suppressed fan-out, Task 2: Hook notifyListChange into list/item mutations (reorder excluded) (+4 more) ### Community 429 - "Community 429" + Cohesion: 0.15 Nodes (12): Auto-fixed Issues, Deferred (iOS Device-Only Checks), Deviations from Plan, Known Stubs, Phase 05 Plan 08: Opt-Out + Reliability Surface Summary, Self-Check, Self-Check: PASSED, Task 1: usePushSubscription health-check + permission state (D-10) (+4 more) ### Community 430 - "Community 430" + Cohesion: 0.15 Nodes (12): Auto-fixed Issues, Deviations from Plan, Known Stubs, Phase 06 Plan 02: RRULE UNTIL/COUNT Serialization + FREQ Persistence Summary, Self-Check: PASSED, Task 1: RED, Task 2: GREEN, Tasks Completed (+4 more) ### Community 431 - "Community 431" + Cohesion: 0.15 Nodes (12): `apps/api/src/broker/expand.ts`, `apps/api/tests/broker/expand.test.ts`, `apps/api/tests/fixtures/weekly-count3.ics`, Deviations from Plan, Known Stubs, Phase 06 Plan 03: hasRrule Server-Side Exposure Summary, Self-Check: PASSED, Tasks Completed (+4 more) ### Community 432 - "Community 432" + Cohesion: 0.15 Nodes (13): Actual bugs, Actual state of `@keyframes spin` [VERIFIED: codebase read], All-day DTEND symmetry on series edit (D-05 / WR-04), CalendarShell cold-load flash — D-10 [VERIFIED: codebase read], Confirmation prompt (D-09), Focus 2: FREQ-Persistence Bug — D-07, Focus 3: Whole-Series Edit — D-08/D-09, Focus 5: Spinner Animation — D-13 (+5 more) ### Community 433 - "Community 433" + Cohesion: 0.27 Nodes (9): AuthSplash(), AuthSplashProps, AuthSplashState, CalendarShell(), isPhone(), resolveDefaultView(), clearLoginRedirect(), maybeRedirectToLogin() (+1 more) ### Community 434 - "Community 434" + Cohesion: 0.15 Nodes (13): indexes, columns, isUnique, name, columns, isUnique, name, columns (+5 more) ### Community 435 - "Community 435" + Cohesion: 0.06 Nodes (33): columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo, checkConstraint (+25 more) ### Community 436 - "Community 436" + Cohesion: 0.33 Nodes (6): group_id, autoincrement, name, notNull, primaryKey, type ### Community 437 - "Community 437" + Cohesion: 0.15 Nodes (13): indexes, columns, isUnique, name, columns, isUnique, name, columns (+5 more) ### Community 438 - "Community 438" + Cohesion: 0.11 Nodes (19): id, list_id, user_id, autoincrement, name, notNull, primaryKey, type (+11 more) ### Community 439 - "Community 439" + Cohesion: 0.08 Nodes (24): created_at, name, owner_id, autoincrement, default, name, notNull, primaryKey (+16 more) ### Community 440 - "Community 440" + Cohesion: 0.10 Nodes (20): autoincrement, default, name, notNull, primaryKey, type, checked, rank (+12 more) ### Community 441 - "Community 441" + Cohesion: 0.15 Nodes (13): indexes, columns, isUnique, name, columns, isUnique, name, columns (+5 more) ### Community 442 - "Community 442" + Cohesion: 0.15 Nodes (13): indexes, columns, isUnique, name, columns, isUnique, name, columns (+5 more) ### Community 443 - "Community 443" + Cohesion: 0.15 Nodes (12): Cost Observations, Cross-Milestone Trends, Cumulative Quality, Key Lessons, Milestone: v1.0 — MVP, Patterns Established, Process Evolution, Project Retrospective (+4 more) ### Community 444 - "Community 444" + Cohesion: 0.17 Nodes (11): Architecture note (no deviation — design decision), Auto-fixed issues, Deviations from Plan, Known Stubs, Phase 05 Plan 06: reminderScheduler — shared timed 15-min reminder scan — Summary, Self-Check, Self-Check: PASSED, Task 1: Implement reminderScheduler.ts (GREEN) (+3 more) ### Community 445 - "Community 445" + Cohesion: 0.17 Nodes (11): Architecture note: Generic notification copy (not actor-attributed), Auto-fixed: D-03 actor exclusion required application-level filter, Deviations from Plan, Known Stubs, Phase 05 Plan 07: eventChangeDispatcher + syncCalendar diff/title/onChanges — Summary, Self-Check, Self-Check: PASSED, Task 1: Implement eventChangeDispatcher.ts + syncCalendar changes (GREEN) (+3 more) ### Community 446 - "Community 446" + Cohesion: 0.17 Nodes (12): 10. API test pattern, 11. Test setup truncates list tables only, 1. Migration workflow confirmed, 2. web-push NOT installed, 3. workbox-precaching NOT installed in apps/pwa, 4. vite.config.ts is generateSW mode, 5. listEmitter.ts publish points, 6. poller.ts calls syncCalendar on ctag change (+4 more) ### Community 447 - "Community 447" + Cohesion: 0.17 Nodes (11): apps/api/src/broker/outboxWorker.ts, apps/api/src/broker/poller.ts, apps/api/src/broker/reminderScheduler.ts, Changes Made, Deviations from Plan, Known Stubs, Quick Task 260610-i4x: Replace node-cron with setInterval in Broker Workers Summary, Self-Check: PASSED (+3 more) ### Community 448 - "Community 448" + Cohesion: 0.17 Nodes (11): apps/pwa/src/components/InstructionSheet.tsx, apps/pwa/src/sw.ts, Auto-fixed Issues, Changes Made, Deviations from Plan, Known Stubs, Phase 260610-ka9 Plan 01: Fix Silent Android Push Notifications Summary, Self-Check: PASSED (+3 more) ### Community 449 - "Community 449" + Cohesion: 0.20 Nodes (9): Communication with the API, Development, @familysync/pwa, Further reading, PWA install notes, Scripts, Source layout, Stack (+1 more) ### Community 450 - "Community 450" + Cohesion: 0.17 Nodes (12): checkConstraint, compositePrimaryKeys, foreignKeys, columns, name, name, uniqueConstraints, calendar_events_id (+4 more) ### Community 451 - "Community 451" + Cohesion: 0.18 Nodes (10): Deviations from Plan, Known Stubs, Phase 05 Plan 02: pushDispatcher — VAPID send + 410/404 prune — Summary, Plan specifies `dispatchPush(subscription, notification, dbRowId)` — test uses 2-arg form, Self-Check, Self-Check: PASSED, Task 1: Implement pushDispatcher.ts (GREEN), Tasks Executed (+2 more) ### Community 452 - "Community 452" + Cohesion: 0.18 Nodes (11): Anti-Patterns to Avoid, Architecture Patterns, Pattern 1: web-push VAPID dispatch (TypeScript / ESM), Pattern 2: Custom Service Worker (sw.ts) — injectManifest, Pattern 3: vite.config.ts migration to injectManifest, Pattern 4: Push subscription schema (Drizzle, MariaDB), Pattern 5: Reminder Scheduler (node-cron, 1-min interval), Pattern 6: List-change coalescing debounce (+3 more) ### Community 453 - "Community 453" + Cohesion: 0.18 Nodes (10): Anti-Patterns Found, Behavioral Spot-Checks, Gaps Summary, Goal Achievement, Human Verification Required, Key Link Verification, Observable Truths, Quick Task 260610-cr8: Adopt drizzle-kit generate+migrate Workflow — Verification Report (+2 more) ### Community 454 - "Community 454" + Cohesion: 0.18 Nodes (10): Deviations from Plan, Known Stubs, Phase 260610-k1z Plan 01: Persist OIDC Session Cookie with Max-Age Summary, Self-Check: PASSED, Tasks Completed, Threat Flags, typecheck, Verification Results (+2 more) ### Community 455 - "Community 455" + Cohesion: 0.20 Nodes (9): Anti-Patterns Found, Behavioral Spot-Checks, Gaps Summary, Goal Achievement, Human Verification Required, Key Link Verification, Observable Truths, Quick Task 260610-hbu: Reminder Scheduler Resilience Verification Report (+1 more) ### Community 456 - "Community 456" + Cohesion: 0.20 Nodes (9): Anti-Patterns Found, Behavioral Spot-Checks, Goal Achievement, Human Verification Required, Key Link Verification, Observable Truths, Quick Task 260610-i4x: Verification Report, Required Artifacts (+1 more) ### Community 457 - "Community 457" + Cohesion: 0.20 Nodes (9): Anti-Patterns Found, Behavioral Spot-Checks (Vitest), Goal Achievement, Human Verification Required, Key Link Verification, Observable Truths, Phase 260610-k1z: Persist OIDC Session Cookie with Max-Age — Verification Report, Required Artifacts (+1 more) ### Community 458 - "Community 458" + Cohesion: 0.33 Nodes (5): dialect, id, prevId, tables, version ### Community 459 - "Community 459" + Cohesion: 0.22 Nodes (9): Common Pitfalls, Pitfall 1: iOS subscription silently revoked after ~3 silent pushes, Pitfall 2: pushManager.subscribe() outside a user gesture fails silently on iOS, Pitfall 3: vite-plugin-pwa injectManifest — missing workbox-precaching devDependency, Pitfall 4: /callback denylist lost after SW migration, Pitfall 5: Duplicate reminder fires for events at the window boundary, Pitfall 6: calendarEvents has no title column — must parse rawVevent, Pitfall 7: web-push ESM import — requires default import with @types/web-push (+1 more) ### Community 460 - "Community 460" + Cohesion: 0.22 Nodes (8): Deviations from Plan, Phase 06 Plan 01: End-Tracking Duration Math (D-04) Summary, Self-Check: PASSED, Tasks Completed, TDD Gate Compliance, Test Coverage, Threat Flags, What Was Built ### Community 461 - "Community 461" + Cohesion: 0.22 Nodes (8): Checkpoint Verification (Task 2), Deviations from Plan, Phase 06 Plan 04: Sync-Indicator Animations (D-13) Summary, Residual Device-Only Item, Self-Check: PASSED, Tasks Completed, Threat Flags, What Was Built ### Community 462 - "Community 462" + Cohesion: 0.22 Nodes (8): All-day visual treatment (999.6), Backlog scope — pull in 999.4 / 999.5?, Claude's Discretion, Deferred Ideas, Event-form behavior (999.7 end-tracking + 999.8 recurrence bound), Phase 6: UX Polish - Discussion Log, Recurring-series edit (999.9), Visual feel & sync feedback (user-added, post-questions) ### Community 463 - "Community 463" + Cohesion: 0.12 Nodes (17): checkConstraint, compositePrimaryKeys, foreignKeys, columns, name, name, uniqueConstraints, columnsFrom (+9 more) ### Community 464 - "Community 464" + Cohesion: 0.22 Nodes (9): list_items_id, checkConstraint, compositePrimaryKeys, foreignKeys, columns, name, name, uniqueConstraints (+1 more) ### Community 465 - "Community 465" + Cohesion: 0.22 Nodes (9): columns, isUnique, name, columns, isUnique, name, idx_list_items_list_id_checked, idx_list_items_list_id_rank (+1 more) ### Community 466 - "Community 466" + Cohesion: 0.22 Nodes (9): checkConstraint, compositePrimaryKeys, foreignKeys, columns, name, name, uniqueConstraints, calendar_outbox_id (+1 more) ### Community 467 - "Community 467" + Cohesion: 0.25 Nodes (8): columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo, calendars_user_id_users_id_fk ### Community 468 - "Community 468" + Cohesion: 0.22 Nodes (9): columns, isUnique, name, columns, isUnique, name, idx_list_items_list_id_checked, idx_list_items_list_id_rank (+1 more) ### Community 469 - "Community 469" + Cohesion: 0.25 Nodes (7): Critical Issues, NEW-CR-01: iOS Gesture Gate Still Broken in `PushPermissionPrompt` — `await navigator.serviceWorker.ready` Before `subscribe()`, NEW-WR-01: `sync.ts` Delete-Change Pre-Capture Misses the All-Calendars-Empty Case, Phase 5: Code Review Report (Re-review), Resolution (iteration 3), Summary (historical — pre-fix), Warnings ### Community 470 - "Community 470" + Cohesion: 0.25 Nodes (7): Accepted Risks Log, Notes (informational — not blockers), Phase 05 — Security, Security Audit Trail, Sign-Off, Threat Register, Trust Boundaries ### Community 471 - "Community 471" + Cohesion: 0.25 Nodes (7): Manual-Only Verifications, Per-Task Verification Map, Phase 5 — Validation Strategy, Sampling Rate, Test Infrastructure, Validation Sign-Off, Wave 0 Requirements ### Community 472 - "Community 472" + Cohesion: 0.25 Nodes (8): Glue/UI items (not TDD-eligible, use browser verification), Phase Requirements → Test Map, Playwright-cli scope (desktop-Chromium drivable vs iOS-only), Sampling Rate, TDD-Eligible Items (pure functions with defined I/O), Test Framework, Validation Architecture, Wave 0 Gaps ### Community 473 - "Community 473" + Cohesion: 0.25 Nodes (7): Manual-Only Verifications, Per-Task Verification Map, Phase 6 — Validation Strategy, Sampling Rate, Test Infrastructure, Validation Sign-Off, Wave 0 Requirements ### Community 474 - "Community 474" + Cohesion: 0.25 Nodes (7): Deviations from Plan, Known Stubs, Phase quick-260610-czd Plan 01: Fix docs/deployment.md local-dev command — Summary, Self-Check: PASSED, Tasks Completed, Threat Flags, Verification Output ### Community 475 - "Community 475" + Cohesion: 0.25 Nodes (7): Deviations from Plan, Known Stubs, Phase quick-260610-jlp Plan 01: Fix Broken "How to Enable" Link in SettingsSheet, Quality Gate Results, Self-Check: PASSED, Tasks Completed, Threat Flags ### Community 476 - "Community 476" + Cohesion: 0.22 Nodes (9): checkConstraint, compositePrimaryKeys, foreignKeys, columns, name, name, uniqueConstraints, calendar_outbox_id (+1 more) ### Community 477 - "Community 477" + Cohesion: 0.33 Nodes (6): raw_vevent, autoincrement, name, notNull, primaryKey, type ### Community 478 - "Community 478" + Cohesion: 0.25 Nodes (8): columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo, calendar_events_calendar_id_calendars_id_fk ### Community 479 - "Community 479" + Cohesion: 0.33 Nodes (6): autoincrement, name, notNull, primaryKey, type, calendar_id ### Community 480 - "Community 480" + Cohesion: 0.25 Nodes (8): columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo, calendars_user_id_users_id_fk ### Community 481 - "Community 481" + Cohesion: 0.25 Nodes (8): updated_at, autoincrement, default, name, notNull, onUpdate, primaryKey, type ### Community 482 - "Community 482" + Cohesion: 0.25 Nodes (8): list_items_list_id_lists_id_fk, columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo ### Community 483 - "Community 483" + Cohesion: 0.25 Nodes (8): list_shares_list_id_lists_id_fk, columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo ### Community 484 - "Community 484" + Cohesion: 0.25 Nodes (8): list_shares_user_id_users_id_fk, columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo ### Community 485 - "Community 485" + Cohesion: 0.25 Nodes (8): columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo, calendar_events_calendar_id_calendars_id_fk ### Community 486 - "Community 486" + Cohesion: 0.25 Nodes (8): list_items_list_id_lists_id_fk, columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo ### Community 487 - "Community 487" + Cohesion: 0.10 Nodes (21): name, owner_id, updated_at, columns, autoincrement, name, notNull, primaryKey (+13 more) ### Community 488 - "Community 488" + Cohesion: 0.25 Nodes (8): list_shares_list_id_lists_id_fk, columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo ### Community 489 - "Community 489" + Cohesion: 0.29 Nodes (7): Common Pitfalls, Pitfall 1: UNTIL value-type mismatch with DTSTART, Pitfall 2: UNTIL in user's local timezone vs UTC, Pitfall 3: Adding UNTIL/COUNT to the preserved RRULE on series edit, Pitfall 4: `hasRrule` flow-through schema break, Pitfall 5: TanStack Query v5 global error handler API, Pitfall 6: React `useState` initializer runs once ### Community 490 - "Community 490" + Cohesion: 0.29 Nodes (6): 1. PushPermissionPrompt spinner animates (installed iOS PWA) — CP-04.3, 2. Authelia iOS-Safari standalone cold-load + redirect (D-10/D-11), Current Test, Gaps, Summary, Tests ### Community 491 - "Community 491" + Cohesion: 0.29 Nodes (6): apps/api/src/db/migrations/ (including the orphan 0001_calendars_user_url_unique.sql)., - Do NOT renumber, delete, or regenerate any existing migration SQL file or snapshot in, - Do NOT run `db:migrate` or `db:push` against the live/dev DB — it holds real data., - `drizzle-kit generate` is safe: it diffs schema.ts against the JSON snapshots in meta/,, Hard constraints (do NOT violate):, never the live DB. It needs no DB connection. ### Community 492 - "Community 492" + Cohesion: 0.29 Nodes (6): Ground truth on the library's cookie + context behavior (do NOT edit the library):, - @hono/oidc-auth/dist/index.js sets a session-scoped cookie via, Logged-out / no-session requests do NOT set it. This is the guard signal., - oidcAuthJwt is set ONLY on requests where a valid session is created/refreshed., setCookie(c, OIDC_COOKIE_NAME, session_jwt, { path, httpOnly:true, secure:true [, domain if OIDC_COOKIE_DOMAIN] }), with NO maxAge/expires, then immediately calls c.set('oidcAuthJwt', session_jwt). ### Community 493 - "Community 493" + Cohesion: 0.29 Nodes (6): duration_ms, exit_code, graphify_version, head_at_build, status, ts ### Community 494 - "Community 494" + Cohesion: 0.29 Nodes (7): autoincrement, default, name, notNull, primaryKey, type, all_day ### Community 495 - "Community 495" + Cohesion: 0.10 Nodes (20): autoincrement, default, name, notNull, primaryKey, type, autoincrement, name (+12 more) ### Community 496 - "Community 496" + Cohesion: 0.33 Nodes (6): raw_vevent, autoincrement, name, notNull, primaryKey, type ### Community 497 - "Community 497" + Cohesion: 0.25 Nodes (8): columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo, calendar_outbox_user_id_users_id_fk ### Community 498 - "Community 498" + Cohesion: 0.25 Nodes (8): columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo, calendars_user_id_users_id_fk ### Community 499 - "Community 499" + Cohesion: 0.29 Nodes (7): has_rrule, autoincrement, default, name, notNull, primaryKey, type ### Community 500 - "Community 500" + Cohesion: 0.29 Nodes (7): is_shared, autoincrement, default, name, notNull, primaryKey, type ### Community 501 - "Community 501" + Cohesion: 0.29 Nodes (7): next_attempt_at, autoincrement, default, name, notNull, primaryKey, type ### Community 502 - "Community 502" + Cohesion: 0.29 Nodes (7): status, autoincrement, default, name, notNull, primaryKey, type ### Community 503 - "Community 503" + Cohesion: 0.29 Nodes (7): autoincrement, default, name, notNull, primaryKey, type, all_day ### Community 504 - "Community 504" + Cohesion: 0.29 Nodes (7): autoincrement, default, name, notNull, primaryKey, type, attempt_count ### Community 505 - "Community 505" + Cohesion: 0.29 Nodes (7): columns, raw_vevent, autoincrement, name, notNull, primaryKey, type ### Community 506 - "Community 506" + Cohesion: 0.33 Nodes (6): autoincrement, name, notNull, primaryKey, type, calendar_url ### Community 507 - "Community 507" + Cohesion: 0.33 Nodes (6): etag, autoincrement, name, notNull, primaryKey, type ### Community 508 - "Community 508" + Cohesion: 0.29 Nodes (7): has_rrule, autoincrement, default, name, notNull, primaryKey, type ### Community 509 - "Community 509" + Cohesion: 0.29 Nodes (7): next_attempt_at, autoincrement, default, name, notNull, primaryKey, type ### Community 510 - "Community 510" + Cohesion: 0.29 Nodes (7): status, autoincrement, default, name, notNull, primaryKey, type ### Community 511 - "Community 511" + Cohesion: 0.33 Nodes (5): Fix verification detail, IN-04: `recurrenceCount` default of `1` is send-eligible the instant bound flips to "count", Info, Phase 6: Code Review Report (re-review), Summary ### Community 512 - "Community 512" + Cohesion: 0.33 Nodes (6): last_error, autoincrement, name, notNull, primaryKey, type ### Community 513 - "Community 513" + Cohesion: 0.15 Nodes (13): list_id, user_id, autoincrement, name, notNull, primaryKey, type, columns (+5 more) ### Community 514 - "Community 514" + Cohesion: 0.11 Nodes (19): columns, autoincrement, name, notNull, primaryKey, type, calendar_id, object_url (+11 more) ### Community 515 - "Community 515" + Cohesion: 0.33 Nodes (6): operation, autoincrement, name, notNull, primaryKey, type ### Community 516 - "Community 516" + Cohesion: 0.25 Nodes (8): list_shares_user_id_users_id_fk, columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo ### Community 517 - "Community 517" + Cohesion: 0.25 Nodes (8): columnsFrom, columnsTo, name, onDelete, onUpdate, tableFrom, tableTo, calendar_outbox_user_id_users_id_fk ### Community 518 - "Community 518" + Cohesion: 0.33 Nodes (6): dtstart_date, autoincrement, name, notNull, primaryKey, type ### Community 519 - "Community 519" + Cohesion: 0.33 Nodes (6): dtstart_utc, autoincrement, name, notNull, primaryKey, type ### Community 520 - "Community 520" + Cohesion: 0.33 Nodes (6): etag, autoincrement, name, notNull, primaryKey, type ### Community 521 - "Community 521" + Cohesion: 0.29 Nodes (7): autoincrement, default, name, notNull, primaryKey, type, checked ### Community 522 - "Community 522" + Cohesion: 0.33 Nodes (6): autoincrement, name, notNull, primaryKey, type, calendar_url ### Community 523 - "Community 523" + Cohesion: 0.33 Nodes (6): dtstart_date, autoincrement, name, notNull, primaryKey, type ### Community 524 - "Community 524" + Cohesion: 0.33 Nodes (6): object_url, autoincrement, name, notNull, primaryKey, type ### Community 525 - "Community 525" + Cohesion: 0.33 Nodes (6): payload, autoincrement, name, notNull, primaryKey, type ### Community 526 - "Community 526" + Cohesion: 0.33 Nodes (6): sync_token, autoincrement, name, notNull, primaryKey, type ### Community 527 - "Community 527" + Cohesion: 0.33 Nodes (6): owner_id, autoincrement, name, notNull, primaryKey, type ### Community 528 - "Community 528" + Cohesion: 0.33 Nodes (6): uid, autoincrement, name, notNull, primaryKey, type ### Community 529 - "Community 529" + Cohesion: 0.20 Nodes (9): dialect, id, idx_lists_owner_id, indexes, name, prevId, tables, lists (+1 more) ### Community 530 - "Community 530" + Cohesion: 0.33 Nodes (6): autoincrement, name, notNull, primaryKey, type, calendar_id ### Community 531 - "Community 531" + Cohesion: 0.11 Nodes (19): autoincrement, name, notNull, primaryKey, type, columns, calendar_object_url, group_id (+11 more) ### Community 532 - "Community 532" + Cohesion: 0.11 Nodes (19): columns, autoincrement, name, notNull, primaryKey, type, color, display_name (+11 more) ### Community 533 - "Community 533" + Cohesion: 0.33 Nodes (6): autoincrement, name, notNull, primaryKey, type, color ### Community 534 - "Community 534" + Cohesion: 0.33 Nodes (6): id, autoincrement, name, notNull, primaryKey, type ### Community 535 - "Community 535" + Cohesion: 0.33 Nodes (6): dtstart_date, autoincrement, name, notNull, primaryKey, type ### Community 536 - "Community 536" + Cohesion: 0.33 Nodes (6): dtstart_utc, autoincrement, name, notNull, primaryKey, type ### Community 537 - "Community 537" + Cohesion: 0.33 Nodes (6): etag, autoincrement, name, notNull, primaryKey, type ### Community 538 - "Community 538" + Cohesion: 0.33 Nodes (6): sync_token, autoincrement, name, notNull, primaryKey, type ### Community 539 - "Community 539" + Cohesion: 0.33 Nodes (6): url, autoincrement, name, notNull, primaryKey, type ### Community 540 - "Community 540" + Cohesion: 0.33 Nodes (6): ctag, autoincrement, name, notNull, primaryKey, type ### Community 541 - "Community 541" + Cohesion: 0.33 Nodes (6): object_url, autoincrement, name, notNull, primaryKey, type ### Community 542 - "Community 542" + Cohesion: 0.33 Nodes (6): operation, autoincrement, name, notNull, primaryKey, type ### Community 543 - "Community 543" + Cohesion: 0.33 Nodes (5): canonical_queue, created_at, excluded, gap_queue, review_queue ### Community 544 - "Community 544" + Cohesion: 0.33 Nodes (6): payload, autoincrement, name, notNull, primaryKey, type ### Community 545 - "Community 545" + Cohesion: 0.33 Nodes (6): title, autoincrement, name, notNull, primaryKey, type ### Community 546 - "Community 546" + Cohesion: 0.33 Nodes (6): uid, autoincrement, name, notNull, primaryKey, type ### Community 547 - "Community 547" + Cohesion: 0.40 Nodes (5): Phase Requirements → Test Map, Sampling Rate, Test Framework, Validation Architecture, Wave 0 Gaps ### Community 548 - "Community 548" + Cohesion: 0.40 Nodes (5): API route validation (`events.ts:100–109`), API write path — ical.js RRULE serialization (`vevent.ts:49–54`, `outboxWorker.ts`), Expansion path — `expand.ts` per-occurrence duration, Focus 1: Recurrence Bounding — D-06 (RRULE UNTIL/COUNT), PWA side — `RecurrencePreset` extension (`client.ts:130`, `EventForm.tsx`) ### Community 549 - "Community 549" + Cohesion: 0.50 Nodes (4): compositePrimaryKeys, columns, name, calendars_id ### Community 550 - "Community 550" + Cohesion: 0.50 Nodes (4): Claude's Discretion, Deferred Ideas (OUT OF SCOPE), Locked Decisions, User Constraints (from CONTEXT.md) ### Community 551 - "Community 551" + Cohesion: 0.50 Nodes (4): Code Examples, Event payload format (Declarative Web Push + legacy SW compatible), SW navigateFallback preservation in injectManifest mode, VAPID key generation (one-time CLI) ### Community 552 - "Community 552" + Cohesion: 0.50 Nodes (4): Codebase (HIGH confidence — direct inspection), Primary (MEDIUM confidence — Context7 from official docs), Secondary (MEDIUM confidence — web search + official blog), Sources ### Community 553 - "Community 553" + Cohesion: 0.50 Nodes (4): Core, Installation, Standard Stack, Supporting ### Community 554 - "Community 554" + Cohesion: 0.50 Nodes (3): Critical Anti-Patterns, Infrastructure State, Required Reading (in order) ### Community 555 - "Community 555" + Cohesion: 0.50 Nodes (4): Claude's Discretion / Delegated to UI-Phase, Deferred Ideas (OUT OF SCOPE), Locked Decisions, User Constraints (from CONTEXT.md) ### Community 556 - "Community 556" + Cohesion: 0.50 Nodes (4): Code Examples, D-11 typed error (client.ts extension), End-tracking pure functions (TDD target), RRULE UNTIL/COUNT — verified ical.js 2.2.1 patterns ### Community 557 - "Community 557" + Cohesion: 0.50 Nodes (4): D-05 verification (already fixed), Fix target, Focus 4: End-Tracking — D-03/D-04, Start/end state structure ### Community 558 - "Community 558" + Cohesion: 0.50 Nodes (4): Primary (HIGH confidence — verified in codebase), Secondary (MEDIUM confidence — Context7 + live code evaluation), Sources, Tertiary (LOW confidence — ASSUMED) ### Community 560 - "Community 560" + Cohesion: 0.50 Nodes (3): Adopt drizzle generate+migrate workflow (retire db:push on MariaDB), Goal, Tasks ### Community 561 - "Community 561" + Cohesion: 0.50 Nodes (3): Context to bring into the session, Kick off FamilySync with /gsd:new-project, Pre-work before running ### Community 570 - "Community 570" + Cohesion: 0.67 Nodes (3): Applicable ASVS Categories (Level 1), Known Threat Patterns for this Stack, Security Domain ### Community 588 - "Community 588" + Cohesion: 0.33 Nodes (6): last_error, autoincrement, name, notNull, primaryKey, type ### Community 590 - "Community 590" + Cohesion: 0.33 Nodes (6): id, autoincrement, name, notNull, primaryKey, type ### Community 591 - "Community 591" + Cohesion: 0.33 Nodes (6): sync_token, autoincrement, name, notNull, primaryKey, type ### Community 592 - "Community 592" + Cohesion: 0.33 Nodes (6): url, autoincrement, name, notNull, primaryKey, type ### Community 593 - "Community 593" + Cohesion: 0.15 Nodes (13): checkConstraint, foreignKeys, indexes, name, uniqueConstraints, columns, isUnique, name (+5 more) ## Knowledge Gaps + - **4777 isolated node(s):** `allow`, `version`, `timestamp`, `phase`, `phase_name` (+4772 more) These have ≤1 connection - possible missing edges or undocumented components. - **43 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. ## Suggested Questions + _Questions this graph is uniquely positioned to answer:_ - **Why does `Communities (598 total, 44 thin omitted)` connect `Community 91` to `Community 241`?** @@ -2765,4 +3303,4 @@ _Questions this graph is uniquely positioned to answer:_ - **Should `Community 1` be split into smaller, more focused modules?** _Cohesion score 0.046511627906976744 - nodes in this community are weakly interconnected._ - **Should `Community 2` be split into smaller, more focused modules?** - _Cohesion score 0.05555555555555555 - nodes in this community are weakly interconnected._ \ No newline at end of file + _Cohesion score 0.05555555555555555 - nodes in this community are weakly interconnected._ diff --git a/.planning/graphs/graph.json b/.planning/graphs/graph.json index a38f9c7..a054910 100644 --- a/.planning/graphs/graph.json +++ b/.planning/graphs/graph.json @@ -130457,4 +130457,4 @@ ], "hyperedges": [], "built_at_commit": "a9c3304c4e5f6ab5cb7b7f3560b8258a1efa57f8" -} \ No newline at end of file +} diff --git a/.planning/intel/api-map.json b/.planning/intel/api-map.json index 9db5988..1b9300b 100644 --- a/.planning/intel/api-map.json +++ b/.planning/intel/api-map.json @@ -38,10 +38,7 @@ "method": "GET", "path": "/api/events", "auth": "oidcAuthMiddleware", - "params": [ - "start (YYYY-MM-DD, required)", - "end (YYYY-MM-DD, required)" - ], + "params": ["start (YYYY-MM-DD, required)", "end (YYYY-MM-DD, required)"], "file": "apps/api/src/routes/events.ts", "response": "{ occurrences: CalendarOccurrence[] }", "description": "Windowed calendar events. Max 90-day window. Reads only from MariaDB cache; never hits Fastmail. Expands RRULEs server-side." @@ -59,9 +56,7 @@ "method": "PATCH", "path": "/api/events/:uid/edit", "auth": "oidcAuthMiddleware", - "params": [ - "uid (path)" - ], + "params": ["uid (path)"], "body": "CreateEventPayload", "file": "apps/api/src/routes/events.ts", "response": "202 { uid: string }", @@ -71,9 +66,7 @@ "method": "DELETE", "path": "/api/events/:uid", "auth": "oidcAuthMiddleware", - "params": [ - "uid (path)" - ], + "params": ["uid (path)"], "file": "apps/api/src/routes/events.ts", "response": "202 { uid: string }", "description": "Enqueues delete to calendarOutbox with cached etag (If-Match). Async write-back." @@ -82,9 +75,7 @@ "method": "GET", "path": "/api/events/sync-status", "auth": "oidcAuthMiddleware", - "params": [ - "uid (query, required)" - ], + "params": ["uid (query, required)"], "file": "apps/api/src/routes/events.ts", "response": "{ uid: string, status: 'pending'|'done'|'failed'|'dead', error?: string }", "description": "Outbox status poll for a given event UID, scoped to current member. Used by SyncStateToast." @@ -134,9 +125,7 @@ "method": "PATCH", "path": "/api/lists/:id", "auth": "oidcAuthMiddleware", - "params": [ - "id (path)" - ], + "params": ["id (path)"], "body": "{ name?: string, isShared?: boolean } — at least one field required", "file": "apps/api/src/routes/lists.ts", "response": "{ id, name, isShared, ownerId, createdAt, updatedAt }", @@ -146,9 +135,7 @@ "method": "DELETE", "path": "/api/lists/:id", "auth": "oidcAuthMiddleware", - "params": [ - "id (path)" - ], + "params": ["id (path)"], "file": "apps/api/src/routes/lists.ts", "response": "{ id }", "description": "Owner-only delete. Cascade via FK onDelete:cascade removes items and shares. Non-owner sharees receive 403." @@ -157,9 +144,7 @@ "method": "GET", "path": "/api/lists/:id/items", "auth": "oidcAuthMiddleware", - "params": [ - "id (path)" - ], + "params": ["id (path)"], "file": "apps/api/src/routes/lists.ts", "response": "{ items: [{ id, listId, text, checked, rank, createdAt, updatedAt }] }", "description": "Returns all items for the list ordered by rank ASC. Access-gated: owner or sharee only (T-04-05)." @@ -168,9 +153,7 @@ "method": "POST", "path": "/api/lists/:id/items", "auth": "oidcAuthMiddleware", - "params": [ - "id (path)" - ], + "params": ["id (path)"], "body": "{ text: string (1..500) }", "file": "apps/api/src/routes/lists.ts", "response": "201 { id, listId, text, checked, rank, createdAt, updatedAt }", @@ -180,9 +163,7 @@ "method": "PATCH", "path": "/api/list-items/:itemId", "auth": "oidcAuthMiddleware", - "params": [ - "itemId (path)" - ], + "params": ["itemId (path)"], "body": "exactly one of: { checked: boolean } | { text: string } | { position: string }", "file": "apps/api/src/routes/lists.ts", "response": "{ id, listId, text, checked, rank, createdAt, updatedAt }", @@ -192,9 +173,7 @@ "method": "DELETE", "path": "/api/list-items/:itemId", "auth": "oidcAuthMiddleware", - "params": [ - "itemId (path)" - ], + "params": ["itemId (path)"], "file": "apps/api/src/routes/lists.ts", "response": "{ id }", "description": "Delete item instantly (D-06). Delete-wins semantics (D-09): no rollback path. Access-gated: owner or sharee. publishListEvent item:deleted fan-out." diff --git a/.planning/intel/arch-decisions.json b/.planning/intel/arch-decisions.json index 0b6a610..11603fd 100644 --- a/.planning/intel/arch-decisions.json +++ b/.planning/intel/arch-decisions.json @@ -17,103 +17,67 @@ "write-broker-boundary": { "title": "Write-broker boundary invariant", "decision": "No route file imports tsdav or createFastmailClient; no broker file handles HTTP requests. Routes enqueue calendar_outbox rows and return 202 (optimistic-accept); the outbox worker performs the Fastmail write asynchronously.", - "files": [ - "apps/api/src/routes/events.ts", - "apps/api/src/broker/write.ts" - ] + "files": ["apps/api/src/routes/events.ts", "apps/api/src/broker/write.ts"] }, "identity-keying": { "title": "Identity keyed on oidc_iss + oidc_sub", "decision": "Users are keyed on oidc_iss + oidc_sub (never email). A hex color from the palette is auto-assigned on first login.", - "files": [ - "apps/api/src/routes/me.ts", - "apps/api/src/auth/middleware.ts" - ] + "files": ["apps/api/src/routes/me.ts", "apps/api/src/auth/middleware.ts"] }, "D-03-writable-set": { "title": "D-03 calendar ownership / writable-set predicate", "decision": "Every writable-set query uses WHERE userId = currentUser.id OR isShared = true. Another member's personal calendar is a read-only overlay.", - "files": [ - "apps/api/src/routes/events.ts" - ] + "files": ["apps/api/src/routes/events.ts"] }, "D-13-dual-field-dtstart": { "title": "D-13 all-day vs timed events (dual dtstart fields)", "decision": "dtstart_utc is NULL for all-day events; dtstart_date is NULL for timed events. Never coerce DATE to DATETIME.", - "files": [ - "apps/api/src/db/schema.ts" - ] + "files": ["apps/api/src/db/schema.ts"] }, "D-16-shared-fastmail-account": { "title": "D-16 shared Fastmail account, per-member credentials", "decision": "Both members share one Fastmail account. Calendar identity in DB is (userId, url) — the same collection URL appears once per member credential. CalDAV credential per member is stored AES-256-GCM encrypted in member_credentials.", - "files": [ - "apps/api/src/db/schema.ts", - "apps/api/src/broker/poller.ts" - ] + "files": ["apps/api/src/db/schema.ts", "apps/api/src/broker/poller.ts"] }, "outbox-status-machine": { "title": "Outbox status machine", "decision": "calendar_outbox rows transition pending -> done | failed | dead. failed rows retry up to a limit; dead is terminal. The sync-status endpoint surfaces worst-status-first per uid.", - "files": [ - "apps/api/src/broker/outboxWorker.ts", - "apps/api/src/routes/events.ts" - ] + "files": ["apps/api/src/broker/outboxWorker.ts", "apps/api/src/routes/events.ts"] }, "oidc-behind-pangolin": { "title": "OIDC behind Pangolin requires OIDC_AUTH_EXTERNAL_URL", "decision": "OIDC_AUTH_EXTERNAL_URL must be set to the public HTTPS URL to construct a correct redirect_uri; without it the callback resolves to the internal container address.", - "files": [ - "apps/api/src/auth/middleware.ts", - "apps/api/src/index.ts" - ] + "files": ["apps/api/src/auth/middleware.ts", "apps/api/src/index.ts"] }, "dev-auth-bypass": { "title": "Dev auth bypass", "decision": "DEV_AUTH_BYPASS=true with NODE_ENV!=production injects DEV_USER via Hono context; OIDC middleware is never mounted in this mode.", - "files": [ - "apps/api/src/auth/devBypass.js", - "apps/api/src/index.ts" - ] + "files": ["apps/api/src/auth/devBypass.js", "apps/api/src/index.ts"] }, "pwa-static-serving": { "title": "PWA static serving + SPA fallback", "decision": "Hono serveStatic serves ./public (Vite build output); SPA routes fall through to an index.html catch-all registered after /health, /api/*, and /callback so those win.", - "files": [ - "apps/api/src/index.ts" - ] + "files": ["apps/api/src/index.ts"] }, "schedule-x-routing": { "title": "Schedule-X calendar routing", "decision": "Events are routed to Schedule-X calendars by isShared ? 'shared' : String(ownerUserId) — never by calendarId. hydrateEvents.ts enforces this.", - "files": [ - "apps/pwa/src/lib/hydrateEvents.ts", - "apps/pwa/src/components/CalendarShell.tsx" - ] + "files": ["apps/pwa/src/lib/hydrateEvents.ts", "apps/pwa/src/components/CalendarShell.tsx"] }, "state-ownership": { "title": "Client state ownership split", "decision": "Server state is owned by TanStack Query; UI-only state (selected range, color map, drawer) by Zustand. Schedule-X renders the calendar UI.", - "files": [ - "apps/pwa/src/store/calendarStore.ts", - "apps/pwa/src/components/CalendarShell.tsx" - ] + "files": ["apps/pwa/src/store/calendarStore.ts", "apps/pwa/src/components/CalendarShell.tsx"] }, "lists-storage-mariadb-not-caldav": { "title": "Lists stored in MariaDB, not CalDAV (Phase 4)", "decision": "Named lists and items are app-owned data in MariaDB (lists, list_items, list_shares tables), not pushed to Fastmail. CalDAV is exclusively for calendar events.", - "files": [ - "apps/api/src/db/schema.ts", - "apps/api/src/routes/lists.ts" - ] + "files": ["apps/api/src/db/schema.ts", "apps/api/src/routes/lists.ts"] }, "D-01-D-02-list-sharing": { "title": "D-01/D-02 list sharing via join table (member-count-agnostic)", "decision": "isShared=true (default) triggers auto-insert of list_shares rows for all other users at create/patch time. Shares are server-managed only — no client-writable shares endpoint (T-04-08). list_shares join table is member-count-agnostic for future N-member expansion.", - "files": [ - "apps/api/src/routes/lists.ts", - "apps/api/src/db/schema.ts" - ] + "files": ["apps/api/src/routes/lists.ts", "apps/api/src/db/schema.ts"] }, "D-04-scoped-sse-fan-out": { "title": "D-04 scoped SSE fan-out — per-list channels, not global", @@ -127,9 +91,7 @@ "D-08-per-field-lww-patch": { "title": "D-08 per-field last-write-wins PATCH for list items", "decision": "PATCH /api/list-items/:itemId accepts exactly one field (checked | text | position). Zod enforces single-field constraint. Prevents one client's stale read overwriting concurrent updates to other fields.", - "files": [ - "apps/api/src/routes/lists.ts" - ] + "files": ["apps/api/src/routes/lists.ts"] }, "D-13-fractional-rank": { "title": "D-13 fractional-indexing rank for list item ordering", @@ -143,18 +105,12 @@ "D-10-D-11-D-12-sse-resilience": { "title": "D-10/D-11/D-12 SSE resilience: invalidate-not-patch, bounded backoff, polling fallback", "decision": "D-10: SSE events carry minimal { type, listId } payload; client full-refetches via TanStack Query invalidation rather than patching cache from event payload. D-11: useListSSE implements bounded backoff (250ms→8s cap, MAX_ATTEMPTS then give-up). D-12: 30s polling fallback always active in ListDetail as safety net.", - "files": [ - "apps/pwa/src/hooks/useListSSE.ts", - "apps/pwa/src/routes/ListDetail.tsx" - ] + "files": ["apps/pwa/src/hooks/useListSSE.ts", "apps/pwa/src/routes/ListDetail.tsx"] }, "react-router-spa-shell": { "title": "react-router BrowserRouter SPA shell with BottomTabBar", "decision": "App.tsx wraps routes in BrowserRouter with declarative Routes. BottomTabBar is a sibling of Routes (not inside) so it persists across navigation. SW navigateFallback covers /lists/* deep-links.", - "files": [ - "apps/pwa/src/App.tsx", - "apps/pwa/src/components/BottomTabBar.tsx" - ] + "files": ["apps/pwa/src/App.tsx", "apps/pwa/src/components/BottomTabBar.tsx"] } } } diff --git a/.planning/intel/dependency-graph.json b/.planning/intel/dependency-graph.json index f828d88..c53ed6f 100644 --- a/.planning/intel/dependency-graph.json +++ b/.planning/intel/dependency-graph.json @@ -10,38 +10,28 @@ "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": [ - "apps/api/src/index.ts", - "apps/api/src/routes/" - ] + "used_by": ["apps/api/src/index.ts", "apps/api/src/routes/"] }, "@hono/node-server": { "version": "2.0.4", "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": [ - "apps/api/src/index.ts" - ] + "used_by": ["apps/api/src/index.ts"] }, "@hono/oidc-auth": { "version": "1.8.3", "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": [ - "apps/api/src/auth/middleware.ts" - ] + "used_by": ["apps/api/src/auth/middleware.ts"] }, "@hono/zod-validator": { "version": "0.8.0", "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": [ - "apps/api/src/routes/events.ts", - "apps/api/src/routes/lists.ts" - ] + "used_by": ["apps/api/src/routes/events.ts", "apps/api/src/routes/lists.ts"] }, "drizzle-orm": { "version": "0.45.2", @@ -60,19 +50,14 @@ "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": [ - "apps/api/src/db/client.ts" - ] + "used_by": ["apps/api/src/db/client.ts"] }, "tsdav": { "version": "2.2.2", "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": [ - "apps/api/src/broker/client.ts", - "apps/api/src/broker/write.ts" - ] + "used_by": ["apps/api/src/broker/client.ts", "apps/api/src/broker/write.ts"] }, "ical.js": { "version": "2.2.1", @@ -90,60 +75,42 @@ "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": [ - "apps/api/src/routes/events.ts", - "apps/api/src/routes/lists.ts" - ] + "used_by": ["apps/api/src/routes/events.ts", "apps/api/src/routes/lists.ts"] }, "fractional-indexing": { "version": "^3.2.0", "type": "production", "workspace": "both (@familysync/api + @familysync/pwa)", "invocation": "require", - "used_by": [ - "apps/api/src/lib/rank.ts", - "apps/pwa/src/routes/ListDetail.tsx" - ] + "used_by": ["apps/api/src/lib/rank.ts", "apps/pwa/src/routes/ListDetail.tsx"] }, "node-cron": { "version": "^4.2.1", "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": [ - "apps/api/src/broker/poller.ts", - "apps/api/src/broker/outboxWorker.ts" - ] + "used_by": ["apps/api/src/broker/poller.ts", "apps/api/src/broker/outboxWorker.ts"] }, "drizzle-kit": { "version": "0.31.10", "type": "development", "workspace": "@familysync/api", "invocation": "npm run db:generate / npm run db:migrate", - "used_by": [ - "npm run db:generate", - "npm run db:migrate", - "npm run db:push" - ] + "used_by": ["npm run db:generate", "npm run db:migrate", "npm run db:push"] }, "temporal-polyfill": { "version": "0.3.2", "type": "production", "workspace": "both", "invocation": "require", - "used_by": [ - "apps/api/src/broker/expand.ts", - "apps/pwa/src/lib/eventDateTime.ts" - ] + "used_by": ["apps/api/src/broker/expand.ts", "apps/pwa/src/lib/eventDateTime.ts"] }, "react": { "version": "^19.0.0", "type": "production", "workspace": "@familysync/pwa", "invocation": "require", - "used_by": [ - "apps/pwa/src/" - ] + "used_by": ["apps/pwa/src/"] }, "react-router": { "version": "^7.17.0", @@ -161,37 +128,28 @@ "type": "production", "workspace": "@familysync/pwa", "invocation": "require", - "used_by": [ - "apps/pwa/src/routes/ListDetail.tsx" - ] + "used_by": ["apps/pwa/src/routes/ListDetail.tsx"] }, "@dnd-kit/sortable": { "version": "^10.0.0", "type": "production", "workspace": "@familysync/pwa", "invocation": "require", - "used_by": [ - "apps/pwa/src/routes/ListDetail.tsx" - ] + "used_by": ["apps/pwa/src/routes/ListDetail.tsx"] }, "vite": { "version": "8.0.16", "type": "development", "workspace": "@familysync/pwa", "invocation": "npm run dev / npm run build", - "used_by": [ - "npm run dev", - "npm run build" - ] + "used_by": ["npm run dev", "npm run build"] }, "vite-plugin-pwa": { "version": "^1.3.0", "type": "production", "workspace": "@familysync/pwa", "invocation": "implicit", - "used_by": [ - "apps/pwa/vite.config.ts" - ] + "used_by": ["apps/pwa/vite.config.ts"] }, "@tanstack/react-query": { "version": "5.101.0", @@ -211,48 +169,35 @@ "type": "production", "workspace": "@familysync/pwa", "invocation": "require", - "used_by": [ - "apps/pwa/src/store/calendarStore.ts", - "apps/pwa/src/store/listsStore.ts" - ] + "used_by": ["apps/pwa/src/store/calendarStore.ts", "apps/pwa/src/store/listsStore.ts"] }, "@schedule-x/calendar": { "version": "4.6.0", "type": "production", "workspace": "@familysync/pwa", "invocation": "require", - "used_by": [ - "apps/pwa/src/components/CalendarShell.tsx", - "apps/pwa/src/lib/calendarConfig.ts" - ] + "used_by": ["apps/pwa/src/components/CalendarShell.tsx", "apps/pwa/src/lib/calendarConfig.ts"] }, "@schedule-x/react": { "version": "4.1.0", "type": "production", "workspace": "@familysync/pwa", "invocation": "require", - "used_by": [ - "apps/pwa/src/components/CalendarShell.tsx" - ] + "used_by": ["apps/pwa/src/components/CalendarShell.tsx"] }, "lucide-react": { "version": "1.17.0", "type": "production", "workspace": "@familysync/pwa", "invocation": "require", - "used_by": [ - "apps/pwa/src/components/" - ] + "used_by": ["apps/pwa/src/components/"] }, "vitest": { "version": "^4.1.8", "type": "development", "workspace": "both", "invocation": "npm test", - "used_by": [ - "npm test", - "npm run test:watch" - ] + "used_by": ["npm test", "npm run test:watch"] } } } diff --git a/.planning/intel/file-roles.json b/.planning/intel/file-roles.json index 7e9de17..61533a0 100644 --- a/.planning/intel/file-roles.json +++ b/.planning/intel/file-roles.json @@ -6,9 +6,7 @@ }, "entries": { "apps/api/src/index.ts": { - "exports": [ - "app" - ], + "exports": ["app"], "imports": [ "@hono/node-server", "@hono/node-server/serve-static", @@ -27,9 +25,7 @@ "notes": "Hono app factory + HTTP server; mounts routes, OIDC guard, static PWA assets. Broker workers started only when isMainModule()." }, "apps/api/src/routes/events.ts": { - "exports": [ - "eventsRouter" - ], + "exports": ["eventsRouter"], "imports": [ "node:crypto", "hono", @@ -48,10 +44,7 @@ "notes": "GET /api/events (windowed), POST /api/events/create, PATCH /api/events/:uid/edit, DELETE /api/events/:uid, GET /api/events/sync-status, GET /api/events/writable-calendars. Writes enqueue to calendarOutbox only — never calls Fastmail directly." }, "apps/api/src/routes/lists.ts": { - "exports": [ - "listsRouter", - "listItemsRouter" - ], + "exports": ["listsRouter", "listItemsRouter"], "imports": [ "hono", "@hono/zod-validator", @@ -69,9 +62,7 @@ "notes": "listsRouter: GET/POST /api/lists, PATCH/DELETE /api/lists/:id, POST/GET /api/lists/:id/items. listItemsRouter: PATCH/DELETE /api/list-items/:itemId. Owner-guard on isShared mutations (T-04-07/T-04-08). Auto-populates list_shares on isShared=true creation (D-01/D-02). publishListEvent fan-out after every mutation." }, "apps/api/src/routes/sse.ts": { - "exports": [ - "sseRouter" - ], + "exports": ["sseRouter"], "imports": [ "hono", "hono/streaming", @@ -85,27 +76,14 @@ "notes": "GET /api/sse/heartbeat — 10s interval smoke-test. GET /api/sse/lists — scoped live-list fan-out (LIST-04, D-04); subscribes per-accessible-list via subscribeListEvents; 30s keepalive heartbeat." }, "apps/api/src/routes/me.ts": { - "exports": [ - "meRouter" - ], - "imports": [ - "hono", - "../auth/middleware.js", - "../auth/user.js", - "../auth/devBypass.js" - ], + "exports": ["meRouter"], + "imports": ["hono", "../auth/middleware.js", "../auth/user.js", "../auth/devBypass.js"], "type": "module", "notes": "GET /api/me — returns { user: { id, displayName, color } }. Upserts user on first login." }, "apps/api/src/routes/health.ts": { - "exports": [ - "healthRouter" - ], - "imports": [ - "hono", - "../db/client.js", - "drizzle-orm" - ], + "exports": ["healthRouter"], + "imports": ["hono", "../db/client.js", "drizzle-orm"], "type": "module", "notes": "GET /health — unauthenticated. Runs SELECT 1 against DB; returns { ok, db }." }, @@ -120,207 +98,108 @@ "listShares", "listItems" ], - "imports": [ - "drizzle-orm/mysql-core" - ], + "imports": ["drizzle-orm/mysql-core"], "type": "config", "notes": "Drizzle schema for all 8 MariaDB tables. Phase 4 adds lists, list_shares, list_items. list_items.rank uses varcharBin (COLLATE utf8mb4_bin) for fractional-indexing sort correctness. calendarOutbox status enum: pending|done|failed|dead." }, "apps/api/src/db/client.ts": { - "exports": [ - "db" - ], - "imports": [ - "drizzle-orm/mysql2", - "mysql2/promise" - ], + "exports": ["db"], + "imports": ["drizzle-orm/mysql2", "mysql2/promise"], "type": "module", "notes": "Drizzle client bound to mysql2 pool. Reads DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME from env." }, "apps/api/src/lib/listEmitter.ts": { - "exports": [ - "publishListEvent", - "subscribeListEvents", - "ListEvent" - ], - "imports": [ - "node:events" - ], + "exports": ["publishListEvent", "subscribeListEvents", "ListEvent"], + "imports": ["node:events"], "type": "module", "notes": "In-process singleton EventEmitter for list change fan-out (D-18). Per-list channels keyed as list:${listId}. publishListEvent broadcasts; subscribeListEvents returns an unsubscribe fn. Max 200 listeners (T-04-04). Redis swap seam: abstraction boundary is inside this module." }, "apps/api/src/lib/listAccess.ts": { - "exports": [ - "getAccessibleListIds" - ], - "imports": [ - "drizzle-orm", - "../db/client.js", - "../db/schema.js" - ], + "exports": ["getAccessibleListIds"], + "imports": ["drizzle-orm", "../db/client.js", "../db/schema.js"], "type": "module", "notes": "getAccessibleListIds(userId): returns deduped list IDs the user owns OR has a list_shares row for. Gate used by SSE endpoint to scope subscriptions (D-04, T-04-02, T-04-03)." }, "apps/api/src/lib/rank.ts": { - "exports": [ - "rankForAppend", - "rankBetween" - ], - "imports": [ - "fractional-indexing" - ], + "exports": ["rankForAppend", "rankBetween"], + "imports": ["fractional-indexing"], "type": "module", "notes": "Pure helpers wrapping fractional-indexing generateKeyBetween. rankForAppend(lastRank) → rank after last active item. rankBetween(prev, next) → rank between two items. No DB access." }, "apps/api/src/auth/middleware.ts": { - "exports": [ - "oidcAuthMiddleware", - "processOAuthCallback", - "getAuth" - ], - "imports": [ - "@hono/oidc-auth", - "hono" - ], + "exports": ["oidcAuthMiddleware", "processOAuthCallback", "getAuth"], + "imports": ["@hono/oidc-auth", "hono"], "type": "module", "notes": "OIDC middleware for Hono. Reads OIDC_AUTH_EXTERNAL_URL (mandatory behind Pangolin), OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_ISSUER from env." }, "apps/api/src/auth/devBypass.ts": { - "exports": [ - "devAuthBypass", - "DEV_USER" - ], - "imports": [ - "hono" - ], + "exports": ["devAuthBypass", "DEV_USER"], + "imports": ["hono"], "type": "module", "notes": "Dev-only auth bypass middleware. Active only when DEV_AUTH_BYPASS=true AND NODE_ENV!=production. Augments Hono ContextVariableMap with 'user' key." }, "apps/api/src/auth/user.ts": { - "exports": [ - "upsertUser", - "deriveDisplayName" - ], - "imports": [ - "../db/client.js", - "../db/schema.js", - "drizzle-orm" - ], + "exports": ["upsertUser", "deriveDisplayName"], + "imports": ["../db/client.js", "../db/schema.js", "drizzle-orm"], "type": "module", "notes": "User upsert keyed on oidc_iss + oidc_sub. deriveDisplayName: name → preferred_username → email → sub." }, "apps/api/src/broker/poller.ts": { - "exports": [ - "startBrokerPoller" - ], - "imports": [ - "node-cron", - "./sync.js", - "../db/client.js", - "../db/schema.js" - ], + "exports": ["startBrokerPoller"], + "imports": ["node-cron", "./sync.js", "../db/client.js", "../db/schema.js"], "type": "module", "notes": "5-minute cron that polls Fastmail CalDAV for each member credential. ctag change-detection (D-13)." }, "apps/api/src/broker/outboxWorker.ts": { - "exports": [ - "startOutboxWorker" - ], - "imports": [ - "node-cron", - "./write.js", - "../db/client.js", - "../db/schema.js" - ], + "exports": ["startOutboxWorker"], + "imports": ["node-cron", "./write.js", "../db/client.js", "../db/schema.js"], "type": "module", "notes": "15-second cron that drains pending calendarOutbox rows. Dispatches create/update/delete to Fastmail. Status machine: pending → done|failed|dead." }, "apps/api/src/broker/sync.ts": { - "exports": [ - "syncCalendarsForCredential" - ], - "imports": [ - "./client.js", - "./expand.js", - "../db/client.js", - "../db/schema.js", - "ical.js" - ], + "exports": ["syncCalendarsForCredential"], + "imports": ["./client.js", "./expand.js", "../db/client.js", "../db/schema.js", "ical.js"], "type": "module", "notes": "CalDAV PROPFIND + REPORT → upserts calendars and calendarEvents rows." }, "apps/api/src/broker/write.ts": { - "exports": [ - "executeOutboxRow" - ], - "imports": [ - "./client.js", - "./vevent.js", - "../db/client.js", - "../db/schema.js" - ], + "exports": ["executeOutboxRow"], + "imports": ["./client.js", "./vevent.js", "../db/client.js", "../db/schema.js"], "type": "module", "notes": "Executes a single outbox row: builds VEVENT, calls tsdav PUT/DELETE with If-Match etag." }, "apps/api/src/broker/client.ts": { - "exports": [ - "createFastmailClient" - ], - "imports": [ - "tsdav", - "./crypto.js", - "../db/client.js", - "../db/schema.js" - ], + "exports": ["createFastmailClient"], + "imports": ["tsdav", "./crypto.js", "../db/client.js", "../db/schema.js"], "type": "module", "notes": "Creates a tsdav DAVClient per member credential (decrypted AES-256-GCM)." }, "apps/api/src/broker/crypto.ts": { - "exports": [ - "encrypt", - "decrypt" - ], - "imports": [ - "node:crypto" - ], + "exports": ["encrypt", "decrypt"], + "imports": ["node:crypto"], "type": "module", "notes": "AES-256-GCM encrypt/decrypt for Fastmail app passwords stored in memberCredentials." }, "apps/api/src/broker/expand.ts": { - "exports": [ - "expandOccurrences" - ], - "imports": [ - "ical.js", - "temporal-polyfill" - ], + "exports": ["expandOccurrences"], + "imports": ["ical.js", "temporal-polyfill"], "type": "module", "notes": "Expands raw VCALENDAR string into CalendarOccurrence[] for a [start, end) window. Handles RRULE, EXDATE, DST via ical.js + Temporal." }, "apps/api/src/broker/vevent.ts": { - "exports": [ - "buildVevent", - "extractRruleString" - ], - "imports": [ - "ical.js" - ], + "exports": ["buildVevent", "extractRruleString"], + "imports": ["ical.js"], "type": "module", "notes": "Builds VCALENDAR/VEVENT strings from CreateEventPayload. extractRruleString preserves RRULE on calendar-move edits." }, "apps/pwa/src/main.tsx": { "exports": [], - "imports": [ - "react-dom/client", - "./App.tsx" - ], + "imports": ["react-dom/client", "./App.tsx"], "type": "entry-point", "notes": "React root mount." }, "apps/pwa/src/App.tsx": { - "exports": [ - "default" - ], + "exports": ["default"], "imports": [ "react-router", "./components/CalendarShell.js", @@ -332,22 +211,13 @@ "notes": "BrowserRouter shell. Routes: / → /calendar redirect, /calendar → CalendarShell, /lists → ListsIndex, /lists/:listId → ListDetail. BottomTabBar rendered as persistent sibling of Routes." }, "apps/pwa/src/routes/ListsIndex.tsx": { - "exports": [ - "ListsIndex" - ], - "imports": [ - "react", - "@tanstack/react-query", - "../api/listsClient.js", - "../components/" - ], + "exports": ["ListsIndex"], + "imports": ["react", "@tanstack/react-query", "../api/listsClient.js", "../components/"], "type": "module", "notes": "Lists overview route (/lists). TanStack Query ['lists'] → fetchLists. Renders ListCard per list, ListsEmptyState when empty, CreateListSheet for new list, ListDeleteDialog for delete confirmation. Optimistic delete with rollback." }, "apps/pwa/src/routes/ListDetail.tsx": { - "exports": [ - "ListDetail" - ], + "exports": ["ListDetail"], "imports": [ "react", "@tanstack/react-query", @@ -395,20 +265,13 @@ "notes": "Typed fetch wrappers for all calendar API endpoints. Uses credentials: 'include' + redirect: 'manual' for OIDC opaqueredirect detection." }, "apps/pwa/src/hooks/useListSSE.ts": { - "exports": [ - "useListSSE" - ], - "imports": [ - "react", - "@tanstack/react-query" - ], + "exports": ["useListSSE"], + "imports": ["react", "@tanstack/react-query"], "type": "module", "notes": "Bounded-backoff EventSource hook for /api/sse/lists (D-11). Backoff: 250ms→500ms→1s→2s→4s→cap 8s; stops after MAX_ATTEMPTS. withCredentials: true (T-04-01). On open: invalidates ['list', listId] for full refetch (D-10). On event: invalidates relevant query. Polling fallback (D-12) lives in ListDetail." }, "apps/pwa/src/components/CalendarShell.tsx": { - "exports": [ - "CalendarShell" - ], + "exports": ["CalendarShell"], "imports": [ "react", "@tanstack/react-query", @@ -429,78 +292,49 @@ "notes": "Top-level calendar view. Orchestrates TanStack Query fetches, Schedule-X calendar, event create/edit/delete flows, sync toasts." }, "apps/pwa/src/components/BottomTabBar.tsx": { - "exports": [ - "BottomTabBar" - ], - "imports": [ - "react", - "react-router", - "../store/listsStore.js" - ], + "exports": ["BottomTabBar"], + "imports": ["react", "react-router", "../store/listsStore.js"], "type": "module", "notes": "Phone-only bottom navigation tab bar. Tabs: Calendar (/calendar) and Lists (/lists). Persistent across route changes (rendered outside ). Visibility controlled by CSS at ≥768px." }, "apps/pwa/src/store/listsStore.ts": { - "exports": [ - "useListsStore" - ], - "imports": [ - "zustand" - ], + "exports": ["useListsStore"], + "imports": ["zustand"], "type": "module", "notes": "Zustand UI-only state for lists surface: activeTab, createListSheetOpen. No server data. Follows calendarStore.ts pattern — no persist, no immer." }, "apps/pwa/src/store/calendarStore.ts": { - "exports": [ - "useCalendarStore" - ], - "imports": [ - "zustand" - ], + "exports": ["useCalendarStore"], + "imports": ["zustand"], "type": "module", "notes": "Zustand store for UI-only state: selectedDateRange, calendarId→color map, drawer open/closed. No server state." }, "apps/pwa/src/lib/calendarConfig.ts": { - "exports": [ - "buildCalendarConfig" - ], + "exports": ["buildCalendarConfig"], "imports": [], "type": "module", "notes": "Builds Schedule-X calendar config from member color map and MeUser." }, "apps/pwa/src/lib/hydrateEvents.ts": { - "exports": [ - "hydrateEvents" - ], - "imports": [ - "../api/client.ts" - ], + "exports": ["hydrateEvents"], + "imports": ["../api/client.ts"], "type": "module", "notes": "Maps CalendarOccurrence[] → Schedule-X event objects. Routes by isShared/ownerUserId (never calendarId)." }, "apps/pwa/src/lib/eventDateTime.ts": { - "exports": [ - "formatEventDateTime", - "toScheduleXDateTime" - ], - "imports": [ - "temporal-polyfill" - ], + "exports": ["formatEventDateTime", "toScheduleXDateTime"], + "imports": ["temporal-polyfill"], "type": "module", "notes": "Date/time formatting helpers for Schedule-X event start/end fields." }, "apps/pwa/src/lib/loginRedirect.ts": { - "exports": [ - "maybeRedirectToLogin" - ], + "exports": ["maybeRedirectToLogin"], "imports": [], "type": "module", "notes": "Top-level navigation to /api/login when OIDC 302/opaqueredirect detected. CORS-bypass strategy." }, "apps/pwa/src/lib/colorUtils.ts": { - "exports": [ - "assignMemberColors" - ], + "exports": ["assignMemberColors"], "imports": [], "type": "module", "notes": "Assigns hex colors from palette to members deterministically." diff --git a/.planning/intel/stack.json b/.planning/intel/stack.json index b1d576a..22d2ca4 100644 --- a/.planning/intel/stack.json +++ b/.planning/intel/stack.json @@ -4,15 +4,8 @@ "commit": "99f59c3999f4ef992f01ef8f8a38c1d86d2f2a0f", "version": 3 }, - "languages": [ - "TypeScript", - "SQL" - ], - "frameworks": [ - "Hono 4.12.23", - "React 19", - "Drizzle ORM 0.45.2" - ], + "languages": ["TypeScript", "SQL"], + "frameworks": ["Hono 4.12.23", "React 19", "Drizzle ORM 0.45.2"], "tools": [ "Vite 8.0.16", "vite-plugin-pwa 1.3.0", diff --git a/.planning/milestones/v1.0-REQUIREMENTS.md b/.planning/milestones/v1.0-REQUIREMENTS.md index 093e154..c0e52c9 100644 --- a/.planning/milestones/v1.0-REQUIREMENTS.md +++ b/.planning/milestones/v1.0-REQUIREMENTS.md @@ -33,7 +33,7 @@ Requirements for initial release. Each maps to roadmap phases. - [x] **CAL-05**: User can edit an existing event - [x] **CAL-06**: User can delete an event - [x] **CAL-07**: User can create a recurring event and see all its occurrences expanded correctly (single-occurrence editing deferred to v1.x) -- [ ] **CAL-08**: Each member's personal Fastmail calendar is overlaid into the unified view — *spike-gated in Phase 1*; if cross-account CalDAV sharing proves infeasible, v1 falls back to shared-family-only and this moves to v1.x +- [ ] **CAL-08**: Each member's personal Fastmail calendar is overlaid into the unified view — _spike-gated in Phase 1_; if cross-account CalDAV sharing proves infeasible, v1 falls back to shared-family-only and this moves to v1.x ### Lists @@ -75,56 +75,56 @@ Deferred to a near-term follow-up release. Tracked but not in the v1 roadmap. Explicitly excluded. Documented to prevent scope creep. Anti-features sourced from research (`.planning/research/FEATURES.md`). -| Feature | Reason | -|---------|--------| -| Email features | Members keep existing mail clients; never the product's job | -| Self-hosted calendar server (Baikal/Radicale) | Fastmail hosts all calendars via CalDAV; one fewer service | -| Vikunja / external task backend | Lists live in MariaDB; cross-ecosystem native task sync is impossible anyway | -| React Native / App Store app | PWA delivers app-like UX without publishing overhead | -| PostgreSQL | Not in the stack; MariaDB is the database | -| Chores / rewards / star system | No children in the household; lists cover any task need | -| Meal planning / recipe box | Separate domain, high cost; grocery list covers the coordination need | -| Kids / sub-accounts | No children; irrelevant | -| AI email-to-event import | Requires email access (out of scope) + LLM backend; privacy risk | -| RSVP / invite flows (iTIP/iMIP) | Two people share one calendar; both attend by default | -| Event-level comments / photos | Two people can text; adds chat/media storage for ~zero value | -| Activity feed / audit log | Obvious with two users | -| Multi-household / accounts at scale | One household, two hardcoded Authelia accounts | -| Ads / monetization | Self-hosted; no revenue model | -| Complex permissions / role tiers | Two equal partners with identical write access | -| Offline-first with CRDT conflict resolution | Home WiFi is primary; optimistic updates + retry suffice | -| Grocery delivery integration | Third-party dependency; not needed | +| Feature | Reason | +| --------------------------------------------- | ---------------------------------------------------------------------------- | +| Email features | Members keep existing mail clients; never the product's job | +| Self-hosted calendar server (Baikal/Radicale) | Fastmail hosts all calendars via CalDAV; one fewer service | +| Vikunja / external task backend | Lists live in MariaDB; cross-ecosystem native task sync is impossible anyway | +| React Native / App Store app | PWA delivers app-like UX without publishing overhead | +| PostgreSQL | Not in the stack; MariaDB is the database | +| Chores / rewards / star system | No children in the household; lists cover any task need | +| Meal planning / recipe box | Separate domain, high cost; grocery list covers the coordination need | +| Kids / sub-accounts | No children; irrelevant | +| AI email-to-event import | Requires email access (out of scope) + LLM backend; privacy risk | +| RSVP / invite flows (iTIP/iMIP) | Two people share one calendar; both attend by default | +| Event-level comments / photos | Two people can text; adds chat/media storage for ~zero value | +| Activity feed / audit log | Obvious with two users | +| Multi-household / accounts at scale | One household, two hardcoded Authelia accounts | +| Ads / monetization | Self-hosted; no revenue model | +| Complex permissions / role tiers | Two equal partners with identical write access | +| Offline-first with CRDT conflict resolution | Home WiFi is primary; optimistic updates + retry suffice | +| Grocery delivery integration | Third-party dependency; not needed | ## Traceability -| Requirement | Phase | Status | -|-------------|-------|--------| -| AUTH-01 | Phase 1 | Pending | -| AUTH-02 | Phase 1 | Pending | -| AUTH-03 | Phase 1 | Pending | -| CAL-01 | Phase 1 | Pending | -| CAL-08 | Phase 1 | Pending | -| CAL-02 | Phase 2 | Complete | -| CAL-03 | Phase 2 | Complete | -| CAL-04 | Phase 3 | Complete | -| CAL-05 | Phase 3 | Complete | -| CAL-06 | Phase 3 | Complete | -| CAL-07 | Phase 3 | Complete | -| PWA-01 | Phase 3 | Complete | -| PWA-02 | Phase 3 | Complete | -| LIST-01 | Phase 4 | Complete | -| LIST-02 | Phase 4 | Complete | -| LIST-03 | Phase 4 | Complete | -| LIST-04 | Phase 4 | Complete | -| NOTIF-01 | Phase 5 | Complete | -| NOTIF-02 | Phase 5 | Complete | -| NOTIF-03 | Phase 5 | Complete | -| CAL-09 | v1.x | Deferred | -| CAL-10 | v1.x | Deferred | -| CAL-11 | v1.x | Deferred | -| CAL-12 | v1.x | Deferred | -| DISP-01 | v2 | Deferred | -| DISP-02 | v2 | Deferred | +| Requirement | Phase | Status | +| ----------- | ------- | -------- | +| AUTH-01 | Phase 1 | Pending | +| AUTH-02 | Phase 1 | Pending | +| AUTH-03 | Phase 1 | Pending | +| CAL-01 | Phase 1 | Pending | +| CAL-08 | Phase 1 | Pending | +| CAL-02 | Phase 2 | Complete | +| CAL-03 | Phase 2 | Complete | +| CAL-04 | Phase 3 | Complete | +| CAL-05 | Phase 3 | Complete | +| CAL-06 | Phase 3 | Complete | +| CAL-07 | Phase 3 | Complete | +| PWA-01 | Phase 3 | Complete | +| PWA-02 | Phase 3 | Complete | +| LIST-01 | Phase 4 | Complete | +| LIST-02 | Phase 4 | Complete | +| LIST-03 | Phase 4 | Complete | +| LIST-04 | Phase 4 | Complete | +| NOTIF-01 | Phase 5 | Complete | +| NOTIF-02 | Phase 5 | Complete | +| NOTIF-03 | Phase 5 | Complete | +| CAL-09 | v1.x | Deferred | +| CAL-10 | v1.x | Deferred | +| CAL-11 | v1.x | Deferred | +| CAL-12 | v1.x | Deferred | +| DISP-01 | v2 | Deferred | +| DISP-02 | v2 | Deferred | **Coverage:** @@ -134,5 +134,6 @@ Explicitly excluded. Documented to prevent scope creep. Anti-features sourced fr - Deferred (not in v1 scope): 6 — CAL-09…CAL-12 (v1.x), DISP-01/DISP-02 (v2) --- -*Requirements defined: 2026-06-03* -*Last updated: 2026-06-10 — added deferred REQ-IDs (CAL-09…CAL-12, DISP-01/02) to traceability table* + +_Requirements defined: 2026-06-03_ +_Last updated: 2026-06-10 — added deferred REQ-IDs (CAL-09…CAL-12, DISP-01/02) to traceability table_ diff --git a/.planning/milestones/v1.0-ROADMAP.md b/.planning/milestones/v1.0-ROADMAP.md index 0715a02..5f38f96 100644 --- a/.planning/milestones/v1.0-ROADMAP.md +++ b/.planning/milestones/v1.0-ROADMAP.md @@ -30,11 +30,11 @@ Decimal phases appear between their surrounding integers in numeric order. **Requirements**: AUTH-01, AUTH-02, AUTH-03, CAL-01, CAL-08 **Success Criteria** (what must be TRUE): - 1. Both members can reach the app URL, authenticate through Authelia OIDC, and land on the app home page without entering any Fastmail credentials - 2. Sessions persist across browser restarts — neither member is asked to log in again on the next visit - 3. Each member is assigned a stable, distinct display color that does not change between sessions - 4. The broker successfully fetches and caches at least one event from the shared Fastmail calendar via CalDAV PROPFIND/REPORT - 5. The personal-calendar ACL spike produces a documented go/no-go decision: either the broker token sees the wife's personal calendar after Fastmail share+accept, or the fallback strategy (shared-family-only or per-member app password) is chosen and recorded +1. Both members can reach the app URL, authenticate through Authelia OIDC, and land on the app home page without entering any Fastmail credentials +2. Sessions persist across browser restarts — neither member is asked to log in again on the next visit +3. Each member is assigned a stable, distinct display color that does not change between sessions +4. The broker successfully fetches and caches at least one event from the shared Fastmail calendar via CalDAV PROPFIND/REPORT +5. The personal-calendar ACL spike produces a documented go/no-go decision: either the broker token sees the wife's personal calendar after Fastmail share+accept, or the fallback strategy (shared-family-only or per-member app password) is chosen and recorded **Verification status (D-14, 2026-06-04):** Code + **Gate 1** complete. Gate 1 = stack up (`/health` live), CAL-01 proven live (503 real events cached via REPORT), CAL-08 = **GO** (per-member app-password model, see `CAL-08-DECISION.md`). **Gate 2 deferred** — criteria 1/2/3 (live Authelia OIDC login over Pangolin, session persistence, distinct colors in a real browser) and the SSE-over-Pangolin smoke test require the operator's Authelia + Pangolin/Newt infra; tracked in `01-HUMAN-UAT.md` and `docs/deployment.md`. The live AUTH smoke (incl. iOS) is folded into **Phase 3**; the SSE smoke is a hard gate before **Phase 4**. Phases 2–3 develop behind a documented dev-auth bypass. @@ -54,30 +54,30 @@ Plans: **Requirements**: CAL-02, CAL-03, CAL-07 **Success Criteria** (what must be TRUE): - 1. Opening the app shows a color-coded calendar where each member's events appear in their assigned color, with shared events distinguishable from personal events - 2. The user can switch between day, week, month, and agenda views and all events render correctly in each view - 3. A recurring event (e.g., weekly meeting) displays all its occurrences correctly in the current view window, including correct behavior across DST boundaries - 4. All-day events (birthdays, holidays) appear as full-day banners on the correct date with no timezone shift +1. Opening the app shows a color-coded calendar where each member's events appear in their assigned color, with shared events distinguishable from personal events +2. The user can switch between day, week, month, and agenda views and all events render correctly in each view +3. A recurring event (e.g., weekly meeting) displays all its occurrences correctly in the current view window, including correct behavior across DST boundaries +4. All-day events (birthdays, holidays) appear as full-day banners on the correct date with no timezone shift **Plans**: 5 plansPlans: **Wave 1** - [x] 02-01-PLAN.md — Foundation: schema (hasRrule + calendars.isShared, pushed) + dev-auth bypass + PWA vitest/jsdom harness + ICS fixtures + RED test stubs -**Wave 2** *(blocked on Wave 1 completion)* +**Wave 2** _(blocked on Wave 1 completion)_ - [x] 02-02-PLAN.md — Backend slice: expandOccurrences() (VTIMEZONE/DST + all-day + EXDATE) + windowed/joined/zod-validated /api/events + shared-calendar checkpoint (CAL-02/CAL-07) - [x] 02-03-PLAN.md — Frontend foundation: CSS token layer + colorUtils + calendarConfig (firstDayOfWeek 0→7) + hydrateEvents (Temporal/PlainDate guard) + Zustand store + windowed fetchEvents -**Wave 3** *(blocked on Wave 2 completion)* +**Wave 3** _(blocked on Wave 2 completion)_ - [x] 02-04-PLAN.md — Vertical slice: CalendarShell mounts Schedule-X, renders real windowed Fastmail events color-coded across all four views (CAL-02/CAL-03) -**Wave 4** *(blocked on Wave 3 completion)* +**Wave 4** _(blocked on Wave 3 completion)_ - [x] 02-05-PLAN.md — UX completion: read-only EventDetailPopover + ColorLegend + nav/toolbar + skeleton/empty/error states + human visual verification -**Gap-closure waves** *(from 03-REVIEW.md — write path was broken end-to-end; Gate 2 / 03-08 is blocked on these)* +**Gap-closure waves** _(from 03-REVIEW.md — write path was broken end-to-end; Gate 2 / 03-08 is blocked on these)_ - [x] 03-09-PLAN.md — Route layer: align zod schema to client title/start/end contract (CR-01) + real OIDC iss/sub→users.id resolution on all 5 handlers (CR-06) [wave 1] - [x] 03-12-PLAN.md — PWA EventForm: edit-mode population + recurrence preselect (WR-03), zone-consistent dates (WR-05), real focus trap (WR-07); PWA-01/02 install assets verified [wave 1] @@ -94,12 +94,12 @@ Plans: **Requirements**: CAL-04, CAL-05, CAL-06, CAL-07, PWA-01, PWA-02 **Success Criteria** (what must be TRUE): - 1. A member can create a timed or all-day event (including recurring events) in the app and see it appear in the native Fastmail app within the next sync cycle - 2. A member can edit an existing event's title, time, or description and the change persists correctly in Fastmail - 3. A member can delete an event and it disappears from all views on the next sync - 4. On Android, the app shows a browser install prompt and installs to the home screen; on iOS, the app shows a guided "Add to Home Screen" walkthrough with annotated screenshots that a non-technical user can follow independently - 5. The installed PWA opens full-screen without browser chrome on both iOS and Android - 6. **(Carried from Phase 1 Gate 2, D-14)** Live Authelia OIDC login works over the public Pangolin URL — including the **iOS standalone-PWA** flow: the wife can install to Home Screen and complete login without the redirect breaking out of standalone mode; sessions persist (AUTH-01/02) and members get distinct stable colors (AUTH-03). Verify per `docs/deployment.md` Gate 2 checklist; this is the first real external deploy (local Newt test rig is sufficient — Unraid prod is optional until go-live). +1. A member can create a timed or all-day event (including recurring events) in the app and see it appear in the native Fastmail app within the next sync cycle +2. A member can edit an existing event's title, time, or description and the change persists correctly in Fastmail +3. A member can delete an event and it disappears from all views on the next sync +4. On Android, the app shows a browser install prompt and installs to the home screen; on iOS, the app shows a guided "Add to Home Screen" walkthrough with annotated screenshots that a non-technical user can follow independently +5. The installed PWA opens full-screen without browser chrome on both iOS and Android +6. **(Carried from Phase 1 Gate 2, D-14)** Live Authelia OIDC login works over the public Pangolin URL — including the **iOS standalone-PWA** flow: the wife can install to Home Screen and complete login without the redirect breaking out of standalone mode; sessions persist (AUTH-01/02) and members get distinct stable colors (AUTH-03). Verify per `docs/deployment.md` Gate 2 checklist; this is the first real external deploy (local Newt test rig is sufficient — Unraid prod is optional until go-live). **Plans**: 12 plans (8 original + 4 gap-closure from 03-REVIEW.md) Plans: @@ -107,22 +107,22 @@ Plans: - [x] 03-01-PLAN.md — Foundation: calendarOutbox table + calendarEvents.objectUrl (pushed), vite-plugin-pwa install + legitimacy gate, sync.ts objectUrl, full Wave 0 RED test scaffold -**Wave 2** *(blocked on Wave 1)* +**Wave 2** _(blocked on Wave 1)_ - [x] 03-02-PLAN.md — TDD: VEVENT builder (vevent.ts, D-13 DATE/DATETIME + RRULE) + tsdav write wrappers (write.ts, D-12 broker boundary) - [x] 03-03-PLAN.md — Write API: POST/PATCH/DELETE events + GET sync-status, enqueue-only, D-03 ownership, D-04 edit-as-move pair (CAL-04/05/06/07) -**Wave 3** *(blocked on Wave 2)* +**Wave 3** _(blocked on Wave 2)_ - [x] 03-04-PLAN.md — TDD: outbox worker state machine (D-05/06/07/08 retry/backoff/dead/conflict, edit-as-move ordering) + index.ts wiring - [x] 03-05-PLAN.md — Frontend create/edit slice: write client calls + Zustand keys + EventForm (D-01/02/11) + New Event FAB - [x] 03-07-PLAN.md — PWA install: VitePWA manifest + auth-safe SW denylist + icons + InstallPrompt (iOS walkthrough + Android prompt) (PWA-01/02) -**Wave 4** *(blocked on Wave 3)* +**Wave 4** _(blocked on Wave 3)_ - [x] 03-06-PLAN.md — Delete + sync feedback: popover Edit/Delete footer + DeleteConfirmationDialog + SyncStateToast polling (D-06/08/09) (CAL-05/06) -**Wave 5** *(blocked on Wave 4)* +**Wave 5** _(blocked on Wave 4)_ - [x] 03-08-PLAN.md — Gate 2 live verification: real Authelia OIDC over Pangolin + iOS standalone login + end-to-end Fastmail write round-trips (success criterion 6, D-14/D-15) @@ -137,9 +137,9 @@ Plans: **Requirements**: LIST-01, LIST-02, LIST-03, LIST-04 **Success Criteria** (what must be TRUE): - 1. Either member can create a named list (e.g., "Groceries") and delete a list they no longer need - 2. Either member can add items to a list, check items off, reorder them by drag-and-drop, and delete individual items - 3. When one member adds or checks off an item, the other member sees the change appear in the list within a few seconds without refreshing — even if they reconnect after a brief network gap +1. Either member can create a named list (e.g., "Groceries") and delete a list they no longer need +2. Either member can add items to a list, check items off, reorder them by drag-and-drop, and delete individual items +3. When one member adds or checks off an item, the other member sees the change appear in the list within a few seconds without refreshing — even if they reconnect after a brief network gap **Entry gate status (2026-06-08):** CLEARED — SSE-over-Pangolin smoke test PASSED (35 heartbeats over ~6 min, buffering off, no cut). Live sync may be built directly on SSE; polling fallback (D-12) retained as belt-and-suspenders. @@ -149,24 +149,24 @@ Plans: - [x] 04-01-PLAN.md — Foundation + app shell: deps install (+ legitimacy gate), list tables generate+migrate [BLOCKING], API test harness + Wave-0 RED stubs, react-router + BottomTabBar + empty ListsIndex (D-13/D-16/D-17/D-18) -**Wave 2** *(blocked on Wave 1)* +**Wave 2** _(blocked on Wave 1)_ - [x] 04-02-PLAN.md — TDD: scoped in-memory fan-out (listEmitter) + getAccessibleListIds access scope — the load-bearing D-04 no-leak primitive (LIST-04) - [x] 04-03-PLAN.md — List CRUD slice: POST/GET/PATCH/DELETE /api/lists with scoped access + auto-share-on-create + ListsIndex/ListCard/CreateListSheet/ListDeleteDialog (LIST-01, D-01/D-02/D-06) -**Wave 3** *(blocked on Wave 2)* +**Wave 3** _(blocked on Wave 2)_ - [x] 04-04-PLAN.md — Item CRUD + checked-sink slice: item endpoints + fractional rank + per-field LWW PATCH + ListDetail/ItemRow/AddItemInput + optimistic UI (LIST-02, D-05/D-07/D-08/D-09) -**Wave 4** *(blocked on Wave 3)* +**Wave 4** _(blocked on Wave 3)_ - [x] 04-05-PLAN.md — Reorder slice: dnd-kit sortable + generateKeyBetween rank + one-row position PATCH + animate-on-remote (LIST-03, D-13/D-14/D-15) -**Wave 5** *(blocked on Waves 2 + 4)* +**Wave 5** _(blocked on Waves 2 + 4)_ - [x] 04-06-PLAN.md — Live-sync slice: scoped /api/sse/lists + fan-out triggers + useListSSE bounded-backoff hook + LiveSyncIndicator + polling fallback (LIST-04, D-04/D-10/D-11/D-12) -**Wave 6** *(gap closure — blocked on Waves 2 + 4)* +**Wave 6** _(gap closure — blocked on Waves 2 + 4)_ - [x] 04-07-PLAN.md — Gap closure: migrate list_items.rank to COLLATE utf8mb4_bin (LIST-03 drag-to-top) + owner-only guard on PATCH isShared (T-04-08/T-04-05) — two TDD features (LIST-03) @@ -180,10 +180,10 @@ Plans: **Requirements**: NOTIF-01, NOTIF-02, NOTIF-03 **Success Criteria** (what must be TRUE): - 1. A member receives a push notification on their phone approximately 15 minutes before a calendar event starts — delivered to the installed PWA, including on iOS - 2. When the other member adds or changes a calendar event, the first member receives a push notification with the event title and action described in the payload - 3. When the other member modifies a shared list (adds, checks off, or deletes an item), the first member receives a push notification identifying the list and the change - 4. After an extended period of app inactivity, push notifications are still delivered (subscription health-check prevents silent revocation on iOS) +1. A member receives a push notification on their phone approximately 15 minutes before a calendar event starts — delivered to the installed PWA, including on iOS +2. When the other member adds or changes a calendar event, the first member receives a push notification with the event title and action described in the payload +3. When the other member modifies a shared list (adds, checks off, or deletes an item), the first member receives a push notification identifying the list and the change +4. After an extended period of app inactivity, push notifications are still delivered (subscription health-check prevents silent revocation on iOS) **Plans**: 8 plans (6 waves) Plans: @@ -191,25 +191,25 @@ Plans: - [x] 05-01-PLAN.md — Foundation: install web-push + workbox deps (legitimacy gate), generate VAPID keypair, push_subscriptions table + calendar_events.title generate+migrate [BLOCKING], Wave-0 RED scaffolds (D-11/D-12) -**Wave 2** *(blocked on Wave 1)* +**Wave 2** _(blocked on Wave 1)_ - [x] 05-02-PLAN.md — TDD: pushDispatcher (VAPID send + dual-format payload + 410/404 prune) (D-11) - [x] 05-03-PLAN.md — TDD: pushCoalescer (per-list/actor debounce, generic copy, self-suppress) (D-01/D-02/D-03) -**Wave 3** *(blocked on Wave 2)* +**Wave 3** _(blocked on Wave 2)_ - [x] 05-04-PLAN.md — Subscribe slice (end-to-end): push subscription API + setVapidDetails, generateSW→injectManifest SW migration (push/notificationclick/denylist), usePushSubscription + PushPermissionPrompt (D-08/D-11/D-14) -**Wave 4** *(blocked on Wave 3)* +**Wave 4** _(blocked on Wave 3)_ - [x] 05-05-PLAN.md — NOTIF-02 list-change slice: listChangeDispatcher + hook coalescer into mutations, reorder-silent (D-01/D-02/D-03) - [x] 05-06-PLAN.md — TDD: NOTIF-01 reminderScheduler — shared-timed 15-min scan (query-enforced D-05), all-day excl, dedup, empty-set safe (D-05/D-06/D-07) -**Wave 5** *(blocked on Wave 4)* +**Wave 5** _(blocked on Wave 4)_ - [x] 05-07-PLAN.md — TDD: NOTIF-03 eventChangeDispatcher + syncCalendar diff/title/onChanges hook (poller + outbox), meaningful-only, actor-suppressed (D-02/D-03/D-04/D-13) -**Wave 6** *(blocked on Wave 3)* +**Wave 6** _(blocked on Wave 3)_ - [x] 05-08-PLAN.md — Settings + reliability: master toggle (D-09) + silent re-subscribe (D-10) + PermissionDeniedBanner + avatar→Settings sheet @@ -223,17 +223,17 @@ Plans: **Requirements**: none (all v1 REQ-IDs complete in Phases 1–5; this is a polish phase tracked against backlog items 999.2/3/6/7/8/9 and locked decisions D-01..D-13) **Success Criteria** (what must be TRUE): - 1. All-day events are visually distinct from timed events at a glance - 2. The event form keeps a sane duration when the start moves, all-day edits don't grow the event, and a recurrence can be bounded (repeat-until / count) - 3. A recurring series can be edited as a whole - 4. A session that expires mid-use redirects cleanly to sign-in instead of hanging on a generic error - 5. Unauthenticated cold load shows a neutral "signing you in…" splash — no calendar/"sign-in required" flash before Authelia +1. All-day events are visually distinct from timed events at a glance +2. The event form keeps a sane duration when the start moves, all-day edits don't grow the event, and a recurrence can be bounded (repeat-until / count) +3. A recurring series can be edited as a whole +4. A session that expires mid-use redirects cleanly to sign-in instead of hanging on a generic error +5. Unauthenticated cold load shows a neutral "signing you in…" splash — no calendar/"sign-in required" flash before Authelia **Scope** (promoted from backlog, locked at planning): 999.2 (login flash), 999.3 (session-timeout redirect), 999.6 (all-day visual), 999.7 (form end-tracking + all-day-edit off-by-one), 999.8 (recurrence bound), 999.9 (recurring-series edit). 999.4 (reminders) and 999.5 (provider setup) deferred to milestone 1.1 (D-01/D-02). **Plans**: 6 plans (2 waves) Plans: -**Wave 1** *(parallel — exclusive file ownership)* +**Wave 1** _(parallel — exclusive file ownership)_ - [x] 06-01-PLAN.md — TDD: duration-preserving end-tracking math (computeNewTimedEnd/computeNewAllDayEnd) in eventDateTime.ts (D-04) - [x] 06-02-PLAN.md — TDD: RRULE UNTIL/COUNT serialization + Zod acceptance + FREQ-persistence regression (vevent/outboxWorker/events route) (D-06/D-07) @@ -241,7 +241,7 @@ Plans: - [x] 06-04-PLAN.md — Spinner/pulse: global @keyframes pulse + remove redundant spin redefinition (D-13) - [x] 06-05-PLAN.md — Auth gating slice: SessionExpiredError + AuthSplash + global QueryCache/MutationCache error handler; client.ts type mirrors (D-10/D-11, + D-06/D-08 type carriers) -**Wave 2** *(blocked on 06-01/02/03/05)* +**Wave 2** _(blocked on 06-01/02/03/05)_ - [x] 06-06-PLAN.md — EventForm integration slice: end-tracking wiring + recurrence-bound control + series-edit prompt + all-day pill (D-03/D-04/D-05/D-06/D-07/D-08/D-09/D-12) @@ -253,14 +253,14 @@ Plans: Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 Note: Phase 4 depends only on Phase 1 and can begin as soon as Phase 1 is complete. It is serialized here to reduce work-in-progress. -| Phase | Plans Complete | Status | Completed | -|-------|----------------|--------|-----------| -| 1. Foundation + Broker Spike | 4/4 | Complete | 2026-06-04 | -| 2. Calendar Display | 5/5 | Complete | 2026-06-05 | -| 3. Event Write-Back + PWA Install | 12/12 | Complete | 2026-06-07 | -| 4. Shared Lists + Live Sync | 6/6 | Complete | 2026-06-09 | -| 5. Web Push Notifications | 8/8 | Complete | 2026-06-10 | -| 6. UX Polish | 6/6 | Complete | 2026-06-10 | +| Phase | Plans Complete | Status | Completed | +| --------------------------------- | -------------- | -------- | ---------- | +| 1. Foundation + Broker Spike | 4/4 | Complete | 2026-06-04 | +| 2. Calendar Display | 5/5 | Complete | 2026-06-05 | +| 3. Event Write-Back + PWA Install | 12/12 | Complete | 2026-06-07 | +| 4. Shared Lists + Live Sync | 6/6 | Complete | 2026-06-09 | +| 5. Web Push Notifications | 8/8 | Complete | 2026-06-10 | +| 6. UX Polish | 6/6 | Complete | 2026-06-10 | ## Backlog @@ -276,7 +276,7 @@ Plans: ### Phase 999.4: Per-event reminder configuration (VALARM authoring + scheduler honors it) (BACKLOG) -**Goal:** [Captured for future planning] End-to-end per-event reminders — let the user choose *when* (or whether) to be reminded per event, and make the push scheduler honor that choice instead of a hardcoded lead. +**Goal:** [Captured for future planning] End-to-end per-event reminders — let the user choose _when_ (or whether) to be reminded per event, and make the push scheduler honor that choice instead of a hardcoded lead. **Half A — author the VALARM (event form):** The event create/edit form has no UI to set a reminder ("remind me 10 min / 1 hour / 1 day before", or **no reminder**), so the written `.ics` carries no `VALARM` and no reminder can fire — in native clients or via web push. Add a reminder selector (including an explicit "none"), serialize chosen offsets as `VALARM` (TRIGGER) on write-back, and parse existing `VALARM`s on read so edits preserve them. Feeds the Phase 5 web-push requirement (push needs reminder data to notify about). @@ -357,7 +357,7 @@ Plans: - **An authenticated entry path for automated runs** so the assistant can reach the real PWA past Authelia — e.g. a reusable saved storage-state/cookie, a test-only bypass on a non-prod host, or driving the Authelia login once and reusing the session. (Note: this overlaps the existing `DEV_AUTH_BYPASS`, but that only works on the host-side dev stack, not the prod-mode PWA that has the real service worker. A mobile, authed, SW-enabled target is the gap.) - Optionally: a documented way to point the harness at the Pangolin HTTPS URL with a persisted session, and/or remote-debug a real device. -**Boundary:** genuinely device-only behaviour (iOS-Safari standalone push, real APNs/FCM delivery, OS notification-channel importance) still needs a human — this item is about everything SHORT of that (responsive layout, tap flows, in-page notification UI states, auth redirects) which a mobile-emulated authed browser *could* cover but currently can't. +**Boundary:** genuinely device-only behaviour (iOS-Safari standalone push, real APNs/FCM delivery, OS notification-channel importance) still needs a human — this item is about everything SHORT of that (responsive layout, tap flows, in-page notification UI states, auth redirects) which a mobile-emulated authed browser _could_ cover but currently can't. **Context:** Surfaced 2026-06-10 during Phase 5 UAT — repeated mobile-only bugs were caught only by the operator because the assistant had no mobile, authenticated browser to test in. **Related:** [[feedback-playwright-verify]] (use playwright-cli over manual verification — this extends it to mobile/authed). Tags: testing, playwright, mobile, pwa, oidc, dx. **Requirements:** TBD diff --git a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-01-PLAN.md b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-01-PLAN.md index b46dc02..b0c74d5 100644 --- a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-01-PLAN.md +++ b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-01-PLAN.md @@ -36,44 +36,44 @@ autonomous: false requirements: [CAL-01] user_setup: - service: mariadb - why: "Local stack DB; provisioned via Docker Compose (no external account)" + why: 'Local stack DB; provisioned via Docker Compose (no external account)' env_vars: - name: DB_PASSWORD - source: "Choose any strong password; set in .env (consumed by both mariadb and api services)" + source: 'Choose any strong password; set in .env (consumed by both mariadb and api services)' - name: DB_ROOT_PASSWORD - source: "Choose any strong password; set in .env (MariaDB root)" + source: 'Choose any strong password; set in .env (MariaDB root)' must_haves: truths: - - "docker compose up brings MariaDB healthy and the Hono API serving" - - "GET /health returns 200 and proves a real DB round-trip (write then read)" - - "The React PWA builds and renders a shell that fetches /health" - - "npx drizzle-kit push applies the users/member_credentials/calendars/calendar_events schema to the live MariaDB" - - "vitest runs and the Wave 0 test files exist and execute (red or green)" + - 'docker compose up brings MariaDB healthy and the Hono API serving' + - 'GET /health returns 200 and proves a real DB round-trip (write then read)' + - 'The React PWA builds and renders a shell that fetches /health' + - 'npx drizzle-kit push applies the users/member_credentials/calendars/calendar_events schema to the live MariaDB' + - 'vitest runs and the Wave 0 test files exist and execute (red or green)' artifacts: - - path: "apps/api/src/db/schema.ts" - provides: "Drizzle mysqlTable definitions for users, member_credentials, calendars, calendar_events" + - path: 'apps/api/src/db/schema.ts' + provides: 'Drizzle mysqlTable definitions for users, member_credentials, calendars, calendar_events' contains: "mysqlTable('users'" - - path: "apps/api/src/db/client.ts" - provides: "drizzle(mysql2 pool) singleton export `db`" - exports: ["db"] - - path: "apps/api/src/routes/health.ts" - provides: "GET /health with real DB read/write" - - path: "docker-compose.yml" - provides: "api + mariadb + redis services with mariadb healthcheck" - contains: "healthcheck" - - path: "apps/api/vitest.config.ts" - provides: "Node-environment vitest config" - - path: "apps/pwa/src/App.tsx" - provides: "React shell that fetches /health" + - path: 'apps/api/src/db/client.ts' + provides: 'drizzle(mysql2 pool) singleton export `db`' + exports: ['db'] + - path: 'apps/api/src/routes/health.ts' + provides: 'GET /health with real DB read/write' + - path: 'docker-compose.yml' + provides: 'api + mariadb + redis services with mariadb healthcheck' + contains: 'healthcheck' + - path: 'apps/api/vitest.config.ts' + provides: 'Node-environment vitest config' + - path: 'apps/pwa/src/App.tsx' + provides: 'React shell that fetches /health' key_links: - - from: "apps/api/src/routes/health.ts" - to: "apps/api/src/db/client.ts" - via: "db query" - pattern: "from ['\"].*db/client" - - from: "apps/pwa/src/App.tsx" - to: "/health" - via: "fetch" + - from: 'apps/api/src/routes/health.ts' + to: 'apps/api/src/db/client.ts' + via: 'db query' + pattern: 'from [''"].*db/client' + - from: 'apps/pwa/src/App.tsx' + to: '/health' + via: 'fetch' pattern: "fetch\\(.*health" --- @@ -103,6 +103,7 @@ Output: Running Docker stack, applied DB schema, a green `/health` slice, and th + ## Artifacts this phase produces (Plan 01) New files: `package.json` (root workspace), `pnpm-workspace.yaml`, `.gitignore`, `.env.example`, `docker-compose.yml`, `docker-compose.dev.yml`, `apps/api/package.json`, `apps/api/tsconfig.json`, `apps/api/Dockerfile`, `apps/api/drizzle.config.ts`, `apps/api/vitest.config.ts`, `apps/api/src/index.ts`, `apps/api/src/db/schema.ts`, `apps/api/src/db/client.ts`, `apps/api/src/routes/health.ts`, `apps/api/tests/helpers/db.ts`, `apps/api/tests/health.test.ts`, `apps/api/tests/auth/user.test.ts`, `apps/api/tests/broker/crypto.test.ts`, `apps/api/tests/broker/sync.test.ts`, `apps/api/tests/broker/poller.test.ts`, `apps/pwa/package.json`, `apps/pwa/tsconfig.json`, `apps/pwa/vite.config.ts`, `apps/pwa/index.html`, `apps/pwa/src/main.tsx`, `apps/pwa/src/App.tsx`. @@ -138,6 +139,7 @@ New env vars: `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_ROO docker-compose.yml per RESEARCH Pattern 6: services `api` (build ./apps/api, env DB_* + placeholders for OIDC/encryption used by later plans, depends_on mariadb service_healthy, port 3000:3000), `mariadb` (image mariadb:11, MARIADB_* env, named volume mariadb_data, healthcheck using healthcheck.sh --connect --innodb_initialized interval 10s retries 5), `redis` (redis:7-alpine, present but unused in Phase 1). docker-compose.dev.yml overrides for local dev (bind mounts, expose mariadb 3306). `.env.example` lists every env var referenced (DB_HOST=mariadb, DB_PORT=3306, DB_USER=familysync, DB_NAME=familysync, DB_PASSWORD, DB_ROOT_PASSWORD, plus OIDC_* and APP_PASSWORD_ENCRYPTION_KEY placeholders for later plans). `.gitignore` excludes node_modules, dist, .env (NEVER commit .env — security: secrets at rest). Create the Wave 0 test files as RED stubs that import the not-yet-existing modules from later plans, each with `it.todo` or a failing assertion plus a comment naming the plan that fills it: `tests/helpers/db.ts` (Drizzle test-DB fixture against the Docker MariaDB or a throwaway schema), `tests/auth/user.test.ts` (upsertUser color round-robin + identity stability — Plan 02), `tests/broker/crypto.test.ts` (AES-GCM roundtrip + IV uniqueness — Plan 03), `tests/broker/sync.test.ts` (allDay → dtstart_date vs dtstart_utc, UID upsert — Plan 03), `tests/broker/poller.test.ts` (ctag unchanged → no write — Plan 03), `tests/health.test.ts` (GET /health 200 — filled by Task 2 below). Do NOT place fenced code in this plan; follow the patterns in RESEARCH. + cd apps/api && pnpm install && pnpm vitest run --reporter=dot; test -f ../../docker-compose.yml && grep -q "healthcheck" ../../docker-compose.yml && grep -q "mariadb:11" ../../docker-compose.yml @@ -177,6 +179,7 @@ New env vars: `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_ROO Fill `tests/health.test.ts` GREEN: mock or use the test-DB fixture to assert GET /health returns 200 `{ ok: true }`. Update `apps/pwa/src/App.tsx`: a thin shell that fetches `/health` via React Query and renders "stack: up" / "stack: down". This is the one real UI interaction wired to the API for the skeleton. + cd apps/api && pnpm vitest run tests/health.test.ts --reporter=verbose && pnpm exec tsc --noEmit @@ -211,21 +214,23 @@ New env vars: `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_ROO + ## Trust Boundaries -| Boundary | Description | -|----------|-------------| -| Docker host → MariaDB container | DB credentials cross here; never hardcoded, sourced from .env | -| .env file → process env | Secrets (DB passwords, later OIDC + encryption key) loaded here; .env never committed | +| Boundary | Description | +| ------------------------------- | ------------------------------------------------------------------------------------- | +| Docker host → MariaDB container | DB credentials cross here; never hardcoded, sourced from .env | +| .env file → process env | Secrets (DB passwords, later OIDC + encryption key) loaded here; .env never committed | ## STRIDE Threat Register -| Threat ID | Category | Component | Disposition | Mitigation Plan | -|-----------|----------|-----------|-------------|-----------------| -| T-01-01 | Information Disclosure | .env with DB + future OIDC/encryption secrets | mitigate | `.gitignore` excludes `.env`; only `.env.example` (no real values) is committed | -| T-01-02 | Tampering | MariaDB container | mitigate | Dedicated `familysync` DB user (not root) for the app; root password separate and unused by api service | -| T-01-03 | Elevation of Privilege | /health route | accept | Intentionally unauthenticated and read-mostly; returns no secrets or user data, only `{ ok, db }` | -| T-01-SC | Tampering | pnpm installs (hono, drizzle, mysql2, tsdav, ical.js, node-cron, react, vite) | accept | All packages reviewed [OK] in RESEARCH § Package Legitimacy Audit (multi-year histories, official repos); no [ASSUMED]/[SUS]/[SLOP] packages | +| Threat ID | Category | Component | Disposition | Mitigation Plan | +| --------- | ---------------------- | ----------------------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| T-01-01 | Information Disclosure | .env with DB + future OIDC/encryption secrets | mitigate | `.gitignore` excludes `.env`; only `.env.example` (no real values) is committed | +| T-01-02 | Tampering | MariaDB container | mitigate | Dedicated `familysync` DB user (not root) for the app; root password separate and unused by api service | +| T-01-03 | Elevation of Privilege | /health route | accept | Intentionally unauthenticated and read-mostly; returns no secrets or user data, only `{ ok, db }` | +| T-01-SC | Tampering | pnpm installs (hono, drizzle, mysql2, tsdav, ical.js, node-cron, react, vite) | accept | All packages reviewed [OK] in RESEARCH § Package Legitimacy Audit (multi-year histories, official repos); no [ASSUMED]/[SUS]/[SLOP] packages | + @@ -238,12 +243,13 @@ New env vars: `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_ROO + - Monorepo scaffold (apps/api + apps/pwa) builds and type-checks - Docker stack runs MariaDB (healthy) + Hono API - Schema applied to live DB via drizzle-kit push (all four tables present) - /health proves a real DB read+write round-trip; React shell renders its result - Wave 0 test files exist and run; health test green - + Create `.planning/phases/01-foundation-broker-spike/01-01-SUMMARY.md` when done. diff --git a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-01-SUMMARY.md b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-01-SUMMARY.md index 056872b..c7e5fc4 100644 --- a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-01-SUMMARY.md +++ b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-01-SUMMARY.md @@ -148,6 +148,7 @@ None — plan executed exactly as specified. One minor pnpm API difference (allo ### Auto-fixed Issues **1. [Rule 3 - Blocking] pnpm 11 allowBuilds syntax** + - **Found during:** Task 1 (pnpm install) - **Issue:** `pnpm install` failed with `ERR_PNPM_IGNORED_BUILDS: esbuild@*`. pnpm 11 uses `allowBuilds` map (not `onlyBuiltDependencies` list used in older versions) - **Fix:** Set `allowBuilds.esbuild: true` in pnpm-workspace.yaml @@ -156,6 +157,7 @@ None — plan executed exactly as specified. One minor pnpm API difference (allo - **Committed in:** `3f59156` (Task 1 commit) **2. [Rule 1 - Bug] vi.mock hoisting in health test** + - **Found during:** Task 2 (writing TDD RED test) - **Issue:** Placing `vi.mock()` inside `describe()` blocks caused Vitest hoisting warnings; tests used `resetModules` approach which conflicted with hoisting behavior - **Fix:** Moved `vi.mock` to module top level; used `vi.mocked().mockRejectedValueOnce()` for per-test override @@ -164,6 +166,7 @@ None — plan executed exactly as specified. One minor pnpm API difference (allo - **Committed in:** `96cda58` (Task 2 feat commit) **3. [Checkpoint clearing - Blocking] Docker image build broken for pnpm workspace** + - **Found during:** Task 3 (orchestrator bringing up the stack to clear the checkpoint) - **Issue:** The original `apps/api/Dockerfile` built from a `./apps/api` context and could not work in a pnpm workspace: 1. `COPY package.json pnpm-lock.yaml* ./` + `pnpm install --frozen-lockfile` failed (`ERR_PNPM_NO_LOCKFILE`) — the lockfile lives at the repo root, not in `apps/api/`. @@ -200,6 +203,7 @@ None. The `serveStatic` warning for `./public` in tests is expected (no built PW ## Threat Surface Scan No new threat surface beyond what was planned in the threat model: + - T-01-01: `.env` excluded from git via `.gitignore` ✓ - T-01-02: `familysync` user (not root) in docker-compose.yml ✓ - T-01-03: `/health` unauthenticated, returns only `{ok, db}` ✓ @@ -221,5 +225,6 @@ No new threat surface beyond what was planned in the threat model: - Commits 3f59156, f31711a, 96cda58: FOUND --- -*Phase: 01-foundation-broker-spike* -*Completed: 2026-06-04 (Tasks 1-2; Task 3 at checkpoint)* + +_Phase: 01-foundation-broker-spike_ +_Completed: 2026-06-04 (Tasks 1-2; Task 3 at checkpoint)_ diff --git a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-02-PLAN.md b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-02-PLAN.md index 35bc03a..e029dc0 100644 --- a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-02-PLAN.md +++ b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-02-PLAN.md @@ -3,7 +3,7 @@ phase: 01-foundation-broker-spike plan: 02 type: execute wave: 2 -depends_on: ["01-01"] +depends_on: ['01-01'] files_modified: - apps/api/src/auth/middleware.ts - apps/api/src/auth/user.ts @@ -19,33 +19,33 @@ requirements: [AUTH-01, AUTH-02, AUTH-03] must_haves: truths: - "An unauthenticated request to /api/* is redirected to Authelia's authorize endpoint (302)" - - "After login, the OIDC callback upserts a users row keyed by oidc_iss + oidc_sub (never email)" - - "Each member is auto-assigned a stable, distinct color from a curated palette on first login; re-login returns the same color" - - "Session persists via @hono/oidc-auth refresh-token rotation — no iframe, refresh held backend-side" + - 'After login, the OIDC callback upserts a users row keyed by oidc_iss + oidc_sub (never email)' + - 'Each member is auto-assigned a stable, distinct color from a curated palette on first login; re-login returns the same color' + - 'Session persists via @hono/oidc-auth refresh-token rotation — no iframe, refresh held backend-side' - "GET /api/me returns the authenticated user's identity + color" - "The PWA shell renders the logged-in member's name and color swatch" artifacts: - - path: "apps/api/src/auth/user.ts" - provides: "upsertUser(oidcIss, oidcSub, displayName) with round-robin color assignment" - exports: ["upsertUser", "COLOR_PALETTE"] - - path: "apps/api/src/auth/middleware.ts" - provides: "oidcAuthMiddleware wiring + getAuth → upsertUser bridge" - - path: "apps/api/src/routes/me.ts" - provides: "GET /api/me → { user: { id, displayName, color } }" - exports: ["meRouter"] + - path: 'apps/api/src/auth/user.ts' + provides: 'upsertUser(oidcIss, oidcSub, displayName) with round-robin color assignment' + exports: ['upsertUser', 'COLOR_PALETTE'] + - path: 'apps/api/src/auth/middleware.ts' + provides: 'oidcAuthMiddleware wiring + getAuth → upsertUser bridge' + - path: 'apps/api/src/routes/me.ts' + provides: 'GET /api/me → { user: { id, displayName, color } }' + exports: ['meRouter'] key_links: - - from: "apps/api/src/routes/me.ts" - to: "apps/api/src/auth/user.ts" - via: "upsertUser call" + - from: 'apps/api/src/routes/me.ts' + to: 'apps/api/src/auth/user.ts' + via: 'upsertUser call' pattern: "upsertUser\\(" - - from: "apps/api/src/index.ts" - to: "@hono/oidc-auth" - via: "oidcAuthMiddleware on /api/*" - pattern: "oidcAuthMiddleware" - - from: "apps/pwa/src/App.tsx" - to: "/api/me" - via: "React Query fetch" - pattern: "api/me" + - from: 'apps/api/src/index.ts' + to: '@hono/oidc-auth' + via: 'oidcAuthMiddleware on /api/*' + pattern: 'oidcAuthMiddleware' + - from: 'apps/pwa/src/App.tsx' + to: '/api/me' + via: 'React Query fetch' + pattern: 'api/me' --- @@ -73,6 +73,7 @@ Output: Working Authelia OIDC login, stable identity + color, /api/me, authentic + ## Artifacts this phase produces (Plan 02) New files: `apps/api/src/auth/middleware.ts`, `apps/api/src/auth/user.ts`, `apps/api/src/routes/me.ts`, `apps/pwa/src/api/client.ts`. @@ -105,6 +106,7 @@ New env vars: `OIDC_AUTH_SECRET`, `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_ Create `src/auth/user.ts` exporting `COLOR_PALETTE` (a curated array of >=4 visually-distinct, accessible hex hues per D-06 / Claude's Discretion — e.g. calm blue, warm coral, forest green, soft purple; exact values Claude's choice) and `upsertUser(oidcIss, oidcSub, displayName?)`. Logic per RESEARCH example: SELECT existing by `and(eq(users.oidcIss, oidcIss), eq(users.oidcSub, oidcSub))`; if present return it; else COUNT existing users, assign `COLOR_PALETTE[count % length]`, INSERT, return the new row. Use `$returningId()` then re-select (mysql2 has no RETURNING). Never key on email. Fill `tests/auth/user.test.ts` GREEN using the test-DB fixture (tests/helpers/db.ts): assert (a) first insert assigns palette[0]; (b) second user assigns palette[1]; (c) re-upsert of user 1 returns the identical row + color and does not create a duplicate; (d) lookup is by iss+sub. + cd apps/api && pnpm vitest run tests/auth/user.test.ts --reporter=verbose @@ -136,6 +138,7 @@ New env vars: `OIDC_AUTH_SECRET`, `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_ PWA: create `apps/pwa/src/api/client.ts` with a typed `fetchMe()` (GET /api/me, credentials: 'include'). Update `App.tsx`: React Query `useQuery(['me'], fetchMe)`; on 401/redirect the browser follows Authelia (full-page). Render the member's displayName and a color swatch using `user.color`. Keep the /health indicator from Plan 01. Also record the Authelia client registration YAML (from RESEARCH Pattern 1) in the SUMMARY so the operator can paste it into Authelia's configuration.yml — this is the only human-side config (no code change in this repo). + cd apps/api && pnpm exec tsc --noEmit && grep -q "oidcAuthMiddleware" src/index.ts && grep -q "OIDC_AUTH_EXTERNAL_URL" ../../.env.example && grep -q "upsertUser" src/routes/me.ts @@ -155,24 +158,26 @@ New env vars: `OIDC_AUTH_SECRET`, `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_ + ## Trust Boundaries -| Boundary | Description | -|----------|-------------| -| Browser → Pangolin → Hono /api/* | Untrusted client; only authenticated requests cross (OIDC session cookie) | -| Authelia → /callback | OIDC authorization-code exchange; PKCE + state validate the callback | -| Hono → Authelia token endpoint | Backend confidential client; client_secret + refresh token never reach the browser | +| Boundary | Description | +| --------------------------------- | ---------------------------------------------------------------------------------- | +| Browser → Pangolin → Hono /api/\* | Untrusted client; only authenticated requests cross (OIDC session cookie) | +| Authelia → /callback | OIDC authorization-code exchange; PKCE + state validate the callback | +| Hono → Authelia token endpoint | Backend confidential client; client_secret + refresh token never reach the browser | ## STRIDE Threat Register -| Threat ID | Category | Component | Disposition | Mitigation Plan | -|-----------|----------|-----------|-------------|-----------------| -| T-02-01 | Spoofing | OIDC redirect_uri | mitigate | Authelia validates exact match; OIDC_REDIRECT_URI env must equal the registered URI; OIDC_AUTH_EXTERNAL_URL set so Pangolin Host header cannot forge the redirect (Pitfall 1) | -| T-02-02 | Spoofing | CSRF on /callback | mitigate | @hono/oidc-auth uses PKCE (state + code_verifier); require_pkce true, S256 in Authelia client | -| T-02-03 | Tampering | OIDC session JWT cookie | mitigate | Cookie signed with OIDC_AUTH_SECRET (32+ char), httpOnly + Secure + SameSite; verified every request | -| T-02-04 | Information Disclosure | Refresh token / client_secret | mitigate | Backend-only (D-12); never serialized to frontend; not logged; OIDC_CLIENT_SECRET is the plain secret in env, never committed | -| T-02-05 | Elevation of Privilege | /api/* without auth | mitigate | oidcAuthMiddleware mounted on /api/*; no guest access (ASVS V4) | -| T-02-06 | Spoofing | Identity confusion via mutable email | mitigate | Identity keyed on oidc_iss + oidc_sub, never email (D-10) | +| Threat ID | Category | Component | Disposition | Mitigation Plan | +| --------- | ---------------------- | ------------------------------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| T-02-01 | Spoofing | OIDC redirect_uri | mitigate | Authelia validates exact match; OIDC_REDIRECT_URI env must equal the registered URI; OIDC_AUTH_EXTERNAL_URL set so Pangolin Host header cannot forge the redirect (Pitfall 1) | +| T-02-02 | Spoofing | CSRF on /callback | mitigate | @hono/oidc-auth uses PKCE (state + code_verifier); require_pkce true, S256 in Authelia client | +| T-02-03 | Tampering | OIDC session JWT cookie | mitigate | Cookie signed with OIDC_AUTH_SECRET (32+ char), httpOnly + Secure + SameSite; verified every request | +| T-02-04 | Information Disclosure | Refresh token / client_secret | mitigate | Backend-only (D-12); never serialized to frontend; not logged; OIDC_CLIENT_SECRET is the plain secret in env, never committed | +| T-02-05 | Elevation of Privilege | /api/\* without auth | mitigate | oidcAuthMiddleware mounted on /api/\*; no guest access (ASVS V4) | +| T-02-06 | Spoofing | Identity confusion via mutable email | mitigate | Identity keyed on oidc_iss + oidc_sub, never email (D-10) | + @@ -185,11 +190,12 @@ New env vars: `OIDC_AUTH_SECRET`, `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_ -- AUTH-01: unauthenticated /api/* redirects to Authelia; login lands authenticated (verified live in Plan 04) + +- AUTH-01: unauthenticated /api/\* redirects to Authelia; login lands authenticated (verified live in Plan 04) - AUTH-02: session persists via backend refresh-token rotation (no iframe) - AUTH-03: stable identity (iss+sub) + stable distinct per-member color, asserted by unit tests - /api/me returns the member; PWA shell shows name + color - + Create `.planning/phases/01-foundation-broker-spike/01-02-SUMMARY.md` when done. Include the Authelia client registration YAML for the operator. diff --git a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-02-SUMMARY.md b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-02-SUMMARY.md index 72a753a..8c85538 100644 --- a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-02-SUMMARY.md +++ b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-02-SUMMARY.md @@ -69,7 +69,7 @@ completed: "2026-06-04" - **Task 1 (TDD):** Replaced 5 `it.todo` stubs in `tests/auth/user.test.ts` with 6 real tests covering: palette[0] first user, palette[1] second distinct user, idempotent re-upsert (no duplicate insert), identity keyed on iss+sub not email, full row shape. All GREEN. - **Task 2:** Wired full OIDC vertical slice: - `src/auth/middleware.ts` re-exports oidcAuthMiddleware/processOAuthCallback/getAuth from @hono/oidc-auth - - `src/index.ts` updated: /health (public) → /callback → /api/* guarded by oidcAuthMiddleware → /api/me mounted + - `src/index.ts` updated: /health (public) → /callback → /api/\* guarded by oidcAuthMiddleware → /api/me mounted - `src/routes/me.ts` calls getAuth → upsertUser(iss, sub, email) → returns {user: {id, displayName, color}} - `apps/pwa/src/api/client.ts` typed fetchMe() with credentials: 'include' - `apps/pwa/src/App.tsx` renders MemberBadge (name + color swatch circle) via useQuery(['me'], fetchMe) @@ -85,7 +85,7 @@ completed: "2026-06-04" - `apps/api/src/auth/user.ts` — `upsertUser` + `COLOR_PALETTE` (6 hex hues) - `apps/api/src/auth/middleware.ts` — `oidcAuthMiddleware`, `processOAuthCallback`, `getAuth` re-exports with env var documentation - `apps/api/src/routes/me.ts` — `GET /` handler: getAuth → upsertUser → `{user: {id, displayName, color}}` -- `apps/api/src/index.ts` — updated mount order: /health → /callback → oidcAuthMiddleware on /api/* → /api/me +- `apps/api/src/index.ts` — updated mount order: /health → /callback → oidcAuthMiddleware on /api/\* → /api/me - `apps/pwa/src/api/client.ts` — `fetchMe()` with typed response shape - `apps/pwa/src/App.tsx` — `MemberBadge` component with `ColorSwatch`; useQuery(['me'], fetchMe); retains /health indicator - `apps/api/tests/auth/user.test.ts` — 6 passing tests (was 5 it.todo stubs) @@ -127,13 +127,13 @@ identity_providers: **`.env` values to set before first run:** -| Variable | Value | -|----------|-------| -| `OIDC_AUTH_SECRET` | 32+ char random string (e.g. `openssl rand -base64 32`) | -| `OIDC_ISSUER` | Authelia base URL, e.g. `https://auth.yourdomain.com` | -| `OIDC_CLIENT_ID` | `familysync` | -| `OIDC_CLIENT_SECRET` | Plain text secret (same value used with `authelia crypto hash`) | -| `OIDC_REDIRECT_URI` | `https://familysync.yourdomain.com/callback` | +| Variable | Value | +| ------------------------ | ------------------------------------------------------------------------------- | +| `OIDC_AUTH_SECRET` | 32+ char random string (e.g. `openssl rand -base64 32`) | +| `OIDC_ISSUER` | Authelia base URL, e.g. `https://auth.yourdomain.com` | +| `OIDC_CLIENT_ID` | `familysync` | +| `OIDC_CLIENT_SECRET` | Plain text secret (same value used with `authelia crypto hash`) | +| `OIDC_REDIRECT_URI` | `https://familysync.yourdomain.com/callback` | | `OIDC_AUTH_EXTERNAL_URL` | `https://familysync.yourdomain.com` — **mandatory** behind Pangolin (Pitfall 1) | **Note:** `OIDC_AUTH_EXTERNAL_URL` is not optional behind Pangolin. Without it, `@hono/oidc-auth` constructs the redirect_uri from the internal container `Host` header, which won't match the registered URI in Authelia — login fails with "invalid redirect_uri". @@ -154,7 +154,7 @@ All surfaces are within the planned threat model (Plan 02 STRIDE register): - **T-02-02 (CSRF):** processOAuthCallback uses PKCE (state + code_verifier); Authelia configured with require_pkce: true, S256 - **T-02-03 (cookie tampering):** @hono/oidc-auth signs cookie with OIDC_AUTH_SECRET; httpOnly + Secure + SameSite enforced by library - **T-02-04 (refresh token / client_secret):** backend-only (D-12); getAuth → upsertUser → returns {id, displayName, color} only — no token or credential data in /api/me response -- **T-02-05 (/api/* without auth):** oidcAuthMiddleware on /api/*; /health public-before-guard +- **T-02-05 (/api/\* without auth):** oidcAuthMiddleware on /api/\*; /health public-before-guard - **T-02-06 (identity confusion):** upsertUser keyed exclusively on oidcIss + oidcSub; no email lookup anywhere in auth path No new threat surface introduced beyond plan. @@ -170,5 +170,6 @@ No new threat surface introduced beyond plan. - 6 auth/user tests pass: PASSED --- -*Phase: 01-foundation-broker-spike* -*Completed: 2026-06-04* + +_Phase: 01-foundation-broker-spike_ +_Completed: 2026-06-04_ diff --git a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-03-PLAN.md b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-03-PLAN.md index f92bfe8..9e9f205 100644 --- a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-03-PLAN.md +++ b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-03-PLAN.md @@ -3,7 +3,7 @@ phase: 01-foundation-broker-spike plan: 03 type: execute wave: 2 -depends_on: ["01-01"] +depends_on: ['01-01'] files_modified: - apps/api/src/broker/crypto.ts - apps/api/src/broker/client.ts @@ -19,40 +19,40 @@ requirements: [CAL-01] must_haves: truths: - - "App passwords are encrypted at rest with AES-256-GCM (key from env) and decrypt losslessly; never exposed to the frontend" - - "The broker creates a Fastmail CalDAV client (Basic auth, app password) and fetches calendars via PROPFIND" - - "syncCalendar parses VEVENTs with ical.js and upserts them into calendar_events, storing all-day events as DATE (dtstart_date) never DATETIME" + - 'App passwords are encrypted at rest with AES-256-GCM (key from env) and decrypt losslessly; never exposed to the frontend' + - 'The broker creates a Fastmail CalDAV client (Basic auth, app password) and fetches calendars via PROPFIND' + - 'syncCalendar parses VEVENTs with ical.js and upserts them into calendar_events, storing all-day events as DATE (dtstart_date) never DATETIME' - "The poller skips DB writes when a calendar's ctag is unchanged (sync-token with ctag fallback)" - - "GET /api/events returns cached events from MariaDB (never a live Fastmail call per request)" + - 'GET /api/events returns cached events from MariaDB (never a live Fastmail call per request)' artifacts: - - path: "apps/api/src/broker/crypto.ts" - provides: "encryptPassword/decryptPassword (AES-256-GCM, key from APP_PASSWORD_ENCRYPTION_KEY)" - exports: ["encryptPassword", "decryptPassword"] - - path: "apps/api/src/broker/client.ts" - provides: "createFastmailClient(email, appPassword) → tsdav DAVClient" - exports: ["createFastmailClient"] - - path: "apps/api/src/broker/sync.ts" - provides: "syncCalendar: REPORT → ical.js → calendar_events upsert" - exports: ["syncCalendar"] - - path: "apps/api/src/broker/poller.ts" - provides: "startBrokerPoller (node-cron 5-min) with ctag change detection" - exports: ["startBrokerPoller"] - - path: "apps/api/src/routes/events.ts" - provides: "GET /api/events → cached events from DB" - exports: ["eventsRouter"] + - path: 'apps/api/src/broker/crypto.ts' + provides: 'encryptPassword/decryptPassword (AES-256-GCM, key from APP_PASSWORD_ENCRYPTION_KEY)' + exports: ['encryptPassword', 'decryptPassword'] + - path: 'apps/api/src/broker/client.ts' + provides: 'createFastmailClient(email, appPassword) → tsdav DAVClient' + exports: ['createFastmailClient'] + - path: 'apps/api/src/broker/sync.ts' + provides: 'syncCalendar: REPORT → ical.js → calendar_events upsert' + exports: ['syncCalendar'] + - path: 'apps/api/src/broker/poller.ts' + provides: 'startBrokerPoller (node-cron 5-min) with ctag change detection' + exports: ['startBrokerPoller'] + - path: 'apps/api/src/routes/events.ts' + provides: 'GET /api/events → cached events from DB' + exports: ['eventsRouter'] key_links: - - from: "apps/api/src/broker/poller.ts" - to: "apps/api/src/broker/crypto.ts" - via: "decryptPassword before client creation" + - from: 'apps/api/src/broker/poller.ts' + to: 'apps/api/src/broker/crypto.ts' + via: 'decryptPassword before client creation' pattern: "decryptPassword\\(" - - from: "apps/api/src/broker/sync.ts" - to: "apps/api/src/db/client.ts" - via: "calendarEvents upsert" - pattern: "calendarEvents" - - from: "apps/api/src/routes/events.ts" - to: "apps/api/src/db/client.ts" - via: "cache read (no live CalDAV)" - pattern: "from ['\"].*db/client" + - from: 'apps/api/src/broker/sync.ts' + to: 'apps/api/src/db/client.ts' + via: 'calendarEvents upsert' + pattern: 'calendarEvents' + - from: 'apps/api/src/routes/events.ts' + to: 'apps/api/src/db/client.ts' + via: 'cache read (no live CalDAV)' + pattern: 'from [''"].*db/client' --- @@ -80,6 +80,7 @@ Output: crypto helper, broker client/sync/poller, /api/events router, all unit-t + ## Artifacts this phase produces (Plan 03) New files: `apps/api/src/broker/crypto.ts`, `apps/api/src/broker/client.ts`, `apps/api/src/broker/sync.ts`, `apps/api/src/broker/poller.ts`, `apps/api/src/routes/events.ts`. @@ -113,6 +114,7 @@ New env vars: `APP_PASSWORD_ENCRYPTION_KEY` (64-char hex = 32 bytes). Add `APP_PASSWORD_ENCRYPTION_KEY` to `.env.example` with a comment showing the generator: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`. Fill `tests/broker/crypto.test.ts` GREEN: set a fixed test key in the test, assert (a) roundtrip lossless; (b) two encrypts of same plaintext differ; (c) tampering authTag causes decrypt to throw. + cd apps/api && pnpm vitest run tests/broker/crypto.test.ts --reporter=verbose @@ -149,6 +151,7 @@ New env vars: `APP_PASSWORD_ENCRYPTION_KEY` (64-char hex = 32 bytes). Create `src/routes/events.ts` exporting `eventsRouter` (Hono): GET / reads from `calendarEvents` via `db` (cache only — NEVER call Fastmail per request, ARCHITECTURE anti-pattern), returns the rows (id, uid, allDay, dtstart_utc, dtstart_date, raw_vevent or a minimal shape). This router is mounted in Plan 04. Fill `tests/broker/sync.test.ts` GREEN with a mocked tsdav client returning captured raw VEVENT strings (timed + all-day fixtures — Wave 0 fixture requirement). Assert the dtstart_utc vs dtstart_date split, all_day flag, and UID-upsert idempotency. + cd apps/api && pnpm vitest run tests/broker/sync.test.ts --reporter=verbose && pnpm exec tsc --noEmit @@ -183,6 +186,7 @@ New env vars: `APP_PASSWORD_ENCRYPTION_KEY` (64-char hex = 32 bytes). Create `src/broker/poller.ts` exporting `startBrokerPoller()` (and an internal `runPoll()` exported for tests). Per RESEARCH poller pattern: `schedule('*/5 * * * *', runPoll)` using node-cron v4 (Pitfall 4 — basic 5-field cron API is stable). `runPoll`: select all `memberCredentials`; for each, `decryptPassword`, `createFastmailClient`, `fetchCalendars`; for each davCal, compare `davCal.ctag ?? davCal.syncToken ?? null` to the stored calendars row ctag — if equal and non-null, `continue` (skip); else `syncCalendar`. Make `runPoll` injectable/testable (accept the db + client factory or use module mocks) so the unit test can assert skip-on-unchanged without hitting Fastmail. Fill `tests/broker/poller.test.ts` GREEN: mock fetchCalendars to return a calendar with a known ctag matching a stored row → assert syncCalendar spy NOT called; then a changed ctag → assert syncCalendar IS called. + cd apps/api && pnpm vitest run tests/broker/poller.test.ts --reporter=verbose @@ -199,24 +203,26 @@ New env vars: `APP_PASSWORD_ENCRYPTION_KEY` (64-char hex = 32 bytes). + ## Trust Boundaries -| Boundary | Description | -|----------|-------------| +| Boundary | Description | +| -------------------------------- | ------------------------------------------------------------------------------------ | | member_credentials (DB) → broker | App passwords stored encrypted; only broker/crypto.ts decrypts; never leaves backend | -| Broker → Fastmail CalDAV | Outbound Basic auth over TLS; sole holder of Fastmail I/O | -| Hono /api/events → browser | Returns only cached event data; never credentials or raw app passwords | +| Broker → Fastmail CalDAV | Outbound Basic auth over TLS; sole holder of Fastmail I/O | +| Hono /api/events → browser | Returns only cached event data; never credentials or raw app passwords | ## STRIDE Threat Register -| Threat ID | Category | Component | Disposition | Mitigation Plan | -|-----------|----------|-----------|-------------|-----------------| -| T-03-01 | Information Disclosure | Fastmail app password at rest | mitigate | AES-256-GCM with 96-bit IV + auth tag (crypto.ts); key from APP_PASSWORD_ENCRYPTION_KEY env, never committed/logged (ASVS V6) | -| T-03-02 | Information Disclosure | App password leaking via /api/events | mitigate | events route reads only calendar_events (event data); never joins/returns member_credentials; broker is the only credential reader (D-04) | -| T-03-03 | Tampering | Encrypted-credential integrity | mitigate | GCM auth tag verified on decrypt; tampered ciphertext throws, never silently used | -| T-03-04 | Information Disclosure | Credentials in logs | mitigate | No console logging of decrypted passwords or the encryption key in client.ts / poller.ts | -| T-03-05 | Tampering | Caching client-side event versions | mitigate | Only server-returned objects cached (raw VEVENT verbatim — D-13, Pitfall 14); no write-back in Phase 1 | -| T-03-SC | Tampering | tsdav / ical.js / node-cron installs | accept | All [OK] in RESEARCH § Package Legitimacy Audit (tsdav 3+ yrs official repo, ical.js Mozilla-maintained, node-cron 8+ yrs); no [ASSUMED]/[SUS]/[SLOP] | +| Threat ID | Category | Component | Disposition | Mitigation Plan | +| --------- | ---------------------- | ------------------------------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| T-03-01 | Information Disclosure | Fastmail app password at rest | mitigate | AES-256-GCM with 96-bit IV + auth tag (crypto.ts); key from APP_PASSWORD_ENCRYPTION_KEY env, never committed/logged (ASVS V6) | +| T-03-02 | Information Disclosure | App password leaking via /api/events | mitigate | events route reads only calendar_events (event data); never joins/returns member_credentials; broker is the only credential reader (D-04) | +| T-03-03 | Tampering | Encrypted-credential integrity | mitigate | GCM auth tag verified on decrypt; tampered ciphertext throws, never silently used | +| T-03-04 | Information Disclosure | Credentials in logs | mitigate | No console logging of decrypted passwords or the encryption key in client.ts / poller.ts | +| T-03-05 | Tampering | Caching client-side event versions | mitigate | Only server-returned objects cached (raw VEVENT verbatim — D-13, Pitfall 14); no write-back in Phase 1 | +| T-03-SC | Tampering | tsdav / ical.js / node-cron installs | accept | All [OK] in RESEARCH § Package Legitimacy Audit (tsdav 3+ yrs official repo, ical.js Mozilla-maintained, node-cron 8+ yrs); no [ASSUMED]/[SUS]/[SLOP] | + @@ -227,12 +233,13 @@ New env vars: `APP_PASSWORD_ENCRYPTION_KEY` (64-char hex = 32 bytes). + - App passwords encrypted at rest (AES-256-GCM), lossless roundtrip, tamper-detecting - Broker discovers calendars and syncs VEVENTs into the cache with correct all-day DATE handling - Poller skips unchanged calendars (ctag detection) - /api/events serves cached events without a live Fastmail call - All three unit test files green - + Create `.planning/phases/01-foundation-broker-spike/01-03-SUMMARY.md` when done. diff --git a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-03-SUMMARY.md b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-03-SUMMARY.md index db261c5..1e5ef1a 100644 --- a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-03-SUMMARY.md +++ b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-03-SUMMARY.md @@ -1,6 +1,6 @@ --- phase: 01-foundation-broker-spike -plan: "03" +plan: '03' subsystem: api tags: [caldav, ical.js, tsdav, node-cron, aes-256-gcm, drizzle, mariadb, vitest] @@ -46,22 +46,22 @@ key-files: - .env.example (APP_PASSWORD_ENCRYPTION_KEY with generator comment) key-decisions: - - "Store dtstartDate as JS Date at T00:00:00Z (not raw string): Drizzle date column expects a Date or null; ical.js toString().slice(0,10) gives the YYYY-MM-DD, appending T00:00:00Z avoids TZ ambiguity" - - "Export runPoll separately from startBrokerPoller: lets tests invoke one poll cycle synchronously with vi.mock injected deps, avoiding real cron schedule in tests" - - "ctag skip condition: null ctag means first sync (no row stored) → must always sync; only skip when both sides have a non-null matching ctag" + - 'Store dtstartDate as JS Date at T00:00:00Z (not raw string): Drizzle date column expects a Date or null; ical.js toString().slice(0,10) gives the YYYY-MM-DD, appending T00:00:00Z avoids TZ ambiguity' + - 'Export runPoll separately from startBrokerPoller: lets tests invoke one poll cycle synchronously with vi.mock injected deps, avoiding real cron schedule in tests' + - 'ctag skip condition: null ctag means first sync (no row stored) → must always sync; only skip when both sides have a non-null matching ctag' - "Per-credential try/catch in runPoll: one corrupted or expired credential must not block other members' calendars from syncing" - - "events route imports db but never tsdav or crypto: enforces broker hard boundary (T-03-02)" + - 'events route imports db but never tsdav or crypto: enforces broker hard boundary (T-03-02)' patterns-established: - - "Pattern: broker boundary — tsdav and credentials are imported exclusively under apps/api/src/broker/; routes never touch Fastmail I/O" - - "Pattern: D-13 dtstart split — use ical.js ICAL.Time.isDate to route all-day vs timed into separate nullable columns" - - "Pattern: ctag null-defence — always use davCal.ctag ?? davCal.syncToken ?? null; Fastmail may return either field" + - 'Pattern: broker boundary — tsdav and credentials are imported exclusively under apps/api/src/broker/; routes never touch Fastmail I/O' + - 'Pattern: D-13 dtstart split — use ical.js ICAL.Time.isDate to route all-day vs timed into separate nullable columns' + - 'Pattern: ctag null-defence — always use davCal.ctag ?? davCal.syncToken ?? null; Fastmail may return either field' requirements-completed: [CAL-01] # Metrics duration: ~multi-session -completed: "2026-06-04" +completed: '2026-06-04' --- # Phase 01 Plan 03: CalDAV Broker Slice — Summary @@ -100,7 +100,7 @@ Each task committed with TDD RED → GREEN cycle: - `apps/api/src/broker/crypto.ts` — encryptPassword / decryptPassword using node:crypto aes-256-gcm; 96-bit IV; JSON payload {iv, authTag, ciphertext} as hex - `apps/api/src/broker/client.ts` — createFastmailClient(email, appPassword) → tsdav DAVClient; FastmailClient type alias - `apps/api/src/broker/sync.ts` — syncCalendar: upserts calendars row, fetches REPORT objects, ical.js parses VEVENTs, upserts calendarEvents with D-13 split; onDuplicateKeyUpdate on calendarId+uid -- `apps/api/src/broker/poller.ts` — startBrokerPoller (node-cron */5 * * * *) + runPoll (exported for tests); per-credential try/catch +- `apps/api/src/broker/poller.ts` — startBrokerPoller (node-cron _/5 _ \* \* \*) + runPoll (exported for tests); per-credential try/catch - `apps/api/src/routes/events.ts` — eventsRouter GET / reads from db.select().from(calendarEvents); no tsdav import - `apps/api/tests/broker/crypto.test.ts` — roundtrip, IV uniqueness, tamper-throws - `apps/api/tests/broker/sync.test.ts` — timed dtstart_utc, all-day dtstart_date, same-UID idempotency @@ -125,6 +125,7 @@ None — the draft poller.ts written by the interrupted agent passed all tests o ## User Setup Required Add to `.env`: + ``` APP_PASSWORD_ENCRYPTION_KEY=<64-char hex> # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ``` @@ -139,6 +140,7 @@ No external service configuration required for this plan. Live Fastmail integrat ## Threat Surface Scan No new surface beyond the plan's threat model: + - T-03-01: AES-256-GCM with 96-bit IV + auth tag — implemented in crypto.ts - T-03-02: /api/events reads cache only, no tsdav import in routes/events.ts - T-03-03: GCM auth tag verified on decrypt; tampered ciphertext throws (test asserts this) @@ -157,5 +159,6 @@ No new surface beyond the plan's threat model: ## Self-Check: PASSED --- -*Phase: 01-foundation-broker-spike* -*Completed: 2026-06-04* + +_Phase: 01-foundation-broker-spike_ +_Completed: 2026-06-04_ diff --git a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-04-PLAN.md b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-04-PLAN.md index 99cdbe5..e020ca5 100644 --- a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-04-PLAN.md +++ b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-04-PLAN.md @@ -3,7 +3,7 @@ phase: 01-foundation-broker-spike plan: 04 type: execute wave: 3 -depends_on: ["01-02", "01-03"] +depends_on: ['01-02', '01-03'] files_modified: - apps/api/src/routes/sse.ts - apps/api/src/index.ts @@ -17,35 +17,35 @@ requirements: [CAL-08, CAL-01, AUTH-01, AUTH-02] must_haves: truths: - - "The full app is wired: broker poller starts on boot, /api/events + /api/me + /api/sse are mounted behind OIDC, /health public" - - "The landing page shows the logged-in member (name + color) AND one real cached Fastmail event as broker proof" + - 'The full app is wired: broker poller starts on boot, /api/events + /api/me + /api/sse are mounted behind OIDC, /health public' + - 'The landing page shows the logged-in member (name + color) AND one real cached Fastmail event as broker proof' - "A CAL-08 spike confirms Lucas's app password reads BOTH the shared family calendar and his personal calendar; the go/no-go decision is recorded in a committed doc" - - "GET /api/sse/heartbeat streams events over the real Pangolin tunnel for 5+ minutes without the proxy closing the stream (smoke test result recorded)" - - "Both members can authenticate through Authelia over the public URL and land on the shell (verified live)" + - 'GET /api/sse/heartbeat streams events over the real Pangolin tunnel for 5+ minutes without the proxy closing the stream (smoke test result recorded)' + - 'Both members can authenticate through Authelia over the public URL and land on the shell (verified live)' artifacts: - - path: "apps/api/src/routes/sse.ts" - provides: "GET /api/sse/heartbeat (streamSSE) — Pangolin pass-through smoke test" - exports: ["sseRouter"] - - path: "apps/api/src/broker/spike.ts" - provides: "CAL-08 spike script: createFastmailClient → fetchCalendars → print URLs" - - path: ".planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md" - provides: "Documented go/no-go for personal-calendar overlay" - contains: "Decision:" - - path: "apps/pwa/src/components/EventProof.tsx" - provides: "Renders one cached event from /api/events" + - path: 'apps/api/src/routes/sse.ts' + provides: 'GET /api/sse/heartbeat (streamSSE) — Pangolin pass-through smoke test' + exports: ['sseRouter'] + - path: 'apps/api/src/broker/spike.ts' + provides: 'CAL-08 spike script: createFastmailClient → fetchCalendars → print URLs' + - path: '.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md' + provides: 'Documented go/no-go for personal-calendar overlay' + contains: 'Decision:' + - path: 'apps/pwa/src/components/EventProof.tsx' + provides: 'Renders one cached event from /api/events' key_links: - - from: "apps/api/src/index.ts" - to: "apps/api/src/broker/poller.ts" - via: "startBrokerPoller on boot" + - from: 'apps/api/src/index.ts' + to: 'apps/api/src/broker/poller.ts' + via: 'startBrokerPoller on boot' pattern: "startBrokerPoller\\(\\)" - - from: "apps/api/src/index.ts" - to: "apps/api/src/routes/events.ts" - via: "app.route /api/events" - pattern: "/api/events" - - from: "apps/pwa/src/components/EventProof.tsx" - to: "/api/events" - via: "React Query fetch" - pattern: "api/events" + - from: 'apps/api/src/index.ts' + to: 'apps/api/src/routes/events.ts' + via: 'app.route /api/events' + pattern: '/api/events' + - from: 'apps/pwa/src/components/EventProof.tsx' + to: '/api/events' + via: 'React Query fetch' + pattern: 'api/events' --- @@ -75,6 +75,7 @@ Output: fully wired app, landing page with member + event proof, CAL-08 decision + ## Artifacts this phase produces (Plan 04) New files: `apps/api/src/routes/sse.ts`, `apps/api/src/broker/spike.ts`, `apps/pwa/src/components/EventProof.tsx`, `.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md`. @@ -103,6 +104,7 @@ Modified: `apps/api/src/index.ts` (mount events/sse routers, startBrokerPoller o Update `src/index.ts` to the final bootstrap (RESEARCH "Hono app bootstrap"): order = `/callback` (processOAuthCallback) → `/health` (public, Plan 01) → `app.use('/api/*', oidcAuthMiddleware())` → `app.route('/api/me', meRouter)` → `app.route('/api/events', eventsRouter)` → `app.route('/api/sse', sseRouter)` → `startBrokerPoller()` → serveStatic(./public) → `serve({ port: 3000 })`. Confirm /health stays before the /api guard. PWA: add `fetchEvents()` to `src/api/client.ts` (GET /api/events, credentials include). Create `src/components/EventProof.tsx`: React Query `['events']`, render the first event's title/date (parse from the returned shape) or an empty-state "No cached events yet". Update `App.tsx` to render member (name + color, from Plan 02) AND `` together — the single broker-proof landing screen (Claude's Discretion landing page). + cd apps/api && pnpm exec tsc --noEmit && grep -q "startBrokerPoller()" src/index.ts && grep -q "'/api/events'" src/index.ts && grep -q "'/api/sse'" src/index.ts && grep -q "streamSSE" src/routes/sse.ts @@ -149,24 +151,26 @@ Modified: `apps/api/src/index.ts` (mount events/sse routers, startBrokerPoller o + ## Trust Boundaries -| Boundary | Description | -|----------|-------------| -| Public internet → Pangolin → Hono | Untrusted; only authenticated /api/* requests proceed; /health + /callback are the only pre-auth routes | -| SSE stream (browser ↔ /api/sse) | Long-lived; must remain behind OIDC, must not leak data beyond heartbeat | -| Spike credential handling | Lucas's app password used once for enumeration; stored encrypted, never logged/committed | +| Boundary | Description | +| --------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Public internet → Pangolin → Hono | Untrusted; only authenticated /api/\* requests proceed; /health + /callback are the only pre-auth routes | +| SSE stream (browser ↔ /api/sse) | Long-lived; must remain behind OIDC, must not leak data beyond heartbeat | +| Spike credential handling | Lucas's app password used once for enumeration; stored encrypted, never logged/committed | ## STRIDE Threat Register -| Threat ID | Category | Component | Disposition | Mitigation Plan | -|-----------|----------|-----------|-------------|-----------------| -| T-04-01 | Elevation of Privilege | /api/sse/heartbeat | mitigate | Mounted under /api/* behind oidcAuthMiddleware; no unauthenticated SSE access (ASVS V4) | -| T-04-02 | Information Disclosure | SSE payload | accept | Heartbeat carries only `{ ts, id }` — no user data or secrets | -| T-04-03 | Spoofing | Same-site session cookie behind Pangolin | mitigate | FamilySync + Authelia share parent domain (Pitfall 1/17); OIDC_AUTH_EXTERNAL_URL set so redirect_uri matches | -| T-04-04 | Information Disclosure | Fastmail app password during spike | mitigate | Passed via env for one-off enumeration or stored encrypted via Plan 03 crypto; never echoed to logs or committed; spike output prints only calendar URLs/displayNames, never the password | -| T-04-05 | Tampering | client_secret plain vs hashed | mitigate | Plain secret only in OIDC_CLIENT_SECRET env (Pitfall 7); Authelia YAML holds the pbkdf2-sha512 hash; .env never committed | -| T-04-SC | Tampering | tsx (dev runner for spike) | accept | tsx is a widely-used TypeScript runner; spike script is dev-only, not shipped in the Docker image | +| Threat ID | Category | Component | Disposition | Mitigation Plan | +| --------- | ---------------------- | ---------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| T-04-01 | Elevation of Privilege | /api/sse/heartbeat | mitigate | Mounted under /api/\* behind oidcAuthMiddleware; no unauthenticated SSE access (ASVS V4) | +| T-04-02 | Information Disclosure | SSE payload | accept | Heartbeat carries only `{ ts, id }` — no user data or secrets | +| T-04-03 | Spoofing | Same-site session cookie behind Pangolin | mitigate | FamilySync + Authelia share parent domain (Pitfall 1/17); OIDC_AUTH_EXTERNAL_URL set so redirect_uri matches | +| T-04-04 | Information Disclosure | Fastmail app password during spike | mitigate | Passed via env for one-off enumeration or stored encrypted via Plan 03 crypto; never echoed to logs or committed; spike output prints only calendar URLs/displayNames, never the password | +| T-04-05 | Tampering | client_secret plain vs hashed | mitigate | Plain secret only in OIDC_CLIENT_SECRET env (Pitfall 7); Authelia YAML holds the pbkdf2-sha512 hash; .env never committed | +| T-04-SC | Tampering | tsx (dev runner for spike) | accept | tsx is a widely-used TypeScript runner; spike script is dev-only, not shipped in the Docker image | + @@ -179,13 +183,14 @@ Modified: `apps/api/src/index.ts` (mount events/sse routers, startBrokerPoller o + - SC1 (AUTH-01): both members authenticate via Authelia over the public URL, land on home, no Fastmail prompt - SC2 (AUTH-02): sessions persist across browser restart - SC3 (AUTH-03): each member has a stable distinct color - SC4 (CAL-01): broker fetched + cached ≥1 real event; shown on the landing page - SC5 (CAL-08): go/no-go decision documented in CAL-08-DECISION.md - Bonus (D-08): Pangolin SSE pass-through smoke result recorded for Phase 4 - + Create `.planning/phases/01-foundation-broker-spike/01-04-SUMMARY.md` when done. Include the SSE smoke-test result and a pointer to CAL-08-DECISION.md. diff --git a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-04-SUMMARY.md b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-04-SUMMARY.md index 8177c00..4514ca0 100644 --- a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-04-SUMMARY.md +++ b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-04-SUMMARY.md @@ -1,6 +1,6 @@ --- phase: 01-foundation-broker-spike -plan: "04" +plan: '04' subsystem: integration tags: [hono, sse, caldav, react, typescript, pwa, spike, pangolin] @@ -21,12 +21,12 @@ affects: # Tech tracking tech-stack: added: - - "hono/streaming (streamSSE) — SSE streaming helper, already a peer dep of hono" - - "ical.js@2.2.1 added to apps/pwa (already in apps/api; needed for EventProof summary parsing)" + - 'hono/streaming (streamSSE) — SSE streaming helper, already a peer dep of hono' + - 'ical.js@2.2.1 added to apps/pwa (already in apps/api; needed for EventProof summary parsing)' patterns: - - "SSE auth: sseRouter mounted under /api/sse, behind oidcAuthMiddleware — no unauthenticated SSE access (T-04-01)" - - "Bootstrap order: /callback → /health (pre-guard) → app.use(/api/*, oidcAuthMiddleware) → /api/me → /api/events → /api/sse → startBrokerPoller → serveStatic" - - "Spike pattern: env-var credentials (FASTMAIL_EMAIL, FASTMAIL_APP_PASSWORD) → createFastmailClient → fetchCalendars → print URLs; never log password (T-04-04)" + - 'SSE auth: sseRouter mounted under /api/sse, behind oidcAuthMiddleware — no unauthenticated SSE access (T-04-01)' + - 'Bootstrap order: /callback → /health (pre-guard) → app.use(/api/*, oidcAuthMiddleware) → /api/me → /api/events → /api/sse → startBrokerPoller → serveStatic' + - 'Spike pattern: env-var credentials (FASTMAIL_EMAIL, FASTMAIL_APP_PASSWORD) → createFastmailClient → fetchCalendars → print URLs; never log password (T-04-04)' - "EventProof: tries ical.js parse for SUMMARY field; falls back to 'Untitled event' on parse failure — resilient to malformed VEVENT blobs" key-files: @@ -42,13 +42,13 @@ key-files: - apps/pwa/package.json (added ical.js@2.2.1 dependency) key-decisions: - - "ical.js added to PWA for VEVENT summary parsing in EventProof: EventProof parses rawVevent to extract the SUMMARY field for a human-readable event title; ical.js is already approved and used in the API — the same package at the same version" - - "spike.ts uses tsx (dev-only runner) via pnpm exec: tsx is accepted per T-04-SC; spike is not imported by the API or Docker image" + - 'ical.js added to PWA for VEVENT summary parsing in EventProof: EventProof parses rawVevent to extract the SUMMARY field for a human-readable event title; ical.js is already approved and used in the API — the same package at the same version' + - 'spike.ts uses tsx (dev-only runner) via pnpm exec: tsx is accepted per T-04-SC; spike is not imported by the API or Docker image' - "CAL-08-DECISION.md committed as a template now, human-filled after running spike: the file must contain 'Decision:' to satisfy the artifact spec; template pre-populates the structure" # Metrics duration: ~3min (code tasks only; live verification pending) -completed: "2026-06-04" +completed: '2026-06-04' --- # Phase 01 Plan 04: Integration + Gate Slice — Summary @@ -102,6 +102,7 @@ These are `checkpoint:human-action` tasks that require real infrastructure: - Fastmail Settings → Privacy & Security → App Passwords → scope: "Mail, Contacts & Calendars" 2. Run the spike: + ```bash cd apps/api FASTMAIL_EMAIL=lucas@fastmail.com \ @@ -112,6 +113,7 @@ These are `checkpoint:human-action` tasks that require real infrastructure: 3. Confirm in output: (a) shared family calendar collection URL appears; (b) Lucas's personal calendar URL appears. Record both. 4. To insert the credential into the DB for the broker to use (one-off node script): + ```bash # First ensure APP_PASSWORD_ENCRYPTION_KEY is set in .env cd apps/api @@ -124,6 +126,7 @@ These are `checkpoint:human-action` tasks that require real infrastructure: ``` 5. Start the stack and verify at least one event lands in `calendar_events`: + ```bash docker compose exec mariadb mariadb -ufamilysync -p familysync \ -e "SELECT id, uid, all_day FROM calendar_events LIMIT 5;" @@ -145,12 +148,15 @@ These are `checkpoint:human-action` tasks that require real infrastructure: **What to do:** 1. Register FamilySync OIDC client in Authelia (see 01-02-SUMMARY.md for full YAML): + ```bash authelia crypto hash --sha512 ``` + Add the client block to Authelia's `configuration.yml` and reload. 2. Set env vars in `.env`: + ``` OIDC_AUTH_SECRET= OIDC_ISSUER=https://auth. @@ -161,6 +167,7 @@ These are `checkpoint:human-action` tasks that require real infrastructure: ``` 3. Expose FamilySync through Pangolin under the SAME parent domain as Authelia (same-site cookie requirement — Pitfall 1). + ```bash docker compose up -d ``` @@ -172,9 +179,11 @@ These are `checkpoint:human-action` tasks that require real infrastructure: 6. Repeat for second member (wife) → confirm distinct color (AUTH-03). 7. SSE smoke test (D-08) — run from external network with a valid session cookie: + ```bash curl -N -b "session=" https://familysync./api/sse/heartbeat ``` + Keep open 5+ minutes. Record: **PASS** (events keep arriving) or **FAIL** (stream cut by proxy). 8. Record SSE smoke result in SUMMARY (update this file) for Phase 4 transport decision. @@ -193,6 +202,7 @@ If FAIL: investigate Pangolin idle-timeout config; note for Phase 4. ## Deviations from Plan **[Rule 2 - Missing dependency] ical.js added to PWA for EventProof summary parsing** + - **Found during:** Task 1 (EventProof.tsx implementation) - **Issue:** EventProof.tsx parses rawVevent strings using ical.js to extract human-readable SUMMARY. ical.js was only in apps/api; EventProof runs in the browser. - **Fix:** Added ical.js@2.2.1 to apps/pwa/package.json. Same package, same version, already approved in the legitimacy audit. @@ -206,6 +216,7 @@ If FAIL: investigate Pangolin idle-timeout config; note for Phase 4. ## Threat Surface Scan All surfaces within Plan 04 threat model: + - **T-04-01 (SSE auth):** sseRouter mounted under `/api/sse` behind `oidcAuthMiddleware` — confirmed - **T-04-02 (SSE payload):** heartbeat carries only `{ ts, id }` — no user data or secrets - **T-04-03 (same-site cookies):** operator must expose FamilySync under same parent domain as Authelia — documented in Task 3 steps @@ -227,5 +238,6 @@ All surfaces within Plan 04 threat model: - `pnpm vitest run` 24/24 tests green: PASSED --- -*Phase: 01-foundation-broker-spike* -*Completed (code): 2026-06-04 — Live verification pending* + +_Phase: 01-foundation-broker-spike_ +_Completed (code): 2026-06-04 — Live verification pending_ diff --git a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-CONTEXT.md b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-CONTEXT.md index 4c35696..411e299 100644 --- a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-CONTEXT.md +++ b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-CONTEXT.md @@ -16,6 +16,7 @@ Stand up the FamilySync stack (Docker Compose + MariaDB, deployed through the re ## Implementation Decisions ### Personal-Calendar Access & Spike (CAL-08 gate) + - **D-01:** The wife's personal calendar lives on **Fastmail** (confirmed by user). It is a Fastmail-hosted calendar collection, so the broker can reach it via CalDAV. This removes the iCloud "unreachable" risk entirely. - **D-02:** Broker access model = **per-member app passwords**, NOT single-token cross-account share+accept. Each member generates their own Fastmail app password; the broker holds N credentials and reads each account directly. This eliminates the unconfirmed cross-account CalDAV ACL discovery risk — the original highest project risk. The CAL-08 spike therefore simplifies to: confirm an app password reads its own account's shared + personal calendars via PROPFIND/REPORT. - **D-03:** Phase 1 proceeds with **only the primary user's (Lucas) app password**. Success criterion #4 (read+cache a real event) is proven against Lucas's personal + the shared family calendar. The wife's app password is added in Phase 2 — Phase 1 is NOT blocked on coordinating with her. CAL-08 is structurally proven (N-credential broker) without her credential present. @@ -23,13 +24,16 @@ Stand up the FamilySync stack (Docker Compose + MariaDB, deployed through the re - **D-05:** Go/no-go record: since access is per-member app passwords and her calendar is on Fastmail, the expected outcome is GO. The documented fallback (only relevant if a Fastmail app password unexpectedly cannot read a personal calendar) is shared-family-only for v1, moving CAL-08 to v1.x. ### Member Color Assignment (AUTH-03) + - **D-06:** Each member's color is **auto-assigned from a curated palette on first login** and persisted on the user row (keyed by `oidc_iss + oidc_sub`). Stable across sessions, no settings UI in Phase 1, works for both current members and any future member. Not user-pickable in v1 (a settings color-picker is a deferred idea). ### Infrastructure & Deployment Scope + - **D-07:** Phase 1 **deploys through the real Pangolin tunnel + Authelia**, not local-only. OIDC redirect URIs, HTTPS, and session cookies are validated in the real topology from day one to avoid a "worked locally, broke in prod" OIDC failure. Ensure FamilySync and Authelia share the same parent domain so Authelia session cookies are same-site (Pitfall 17). - **D-08:** Phase 1 **folds in the Pangolin SSE pass-through smoke test** (a trivial long-lived SSE endpoint confirmed over the public URL). De-risks Phase 4 transport choice early (issue #1034). A failure here changes the Phase 4 real-time transport decision; it does NOT block Phase 1's auth/broker success criteria. ### Locked Upstream (carried forward — do NOT re-litigate) + - **D-09:** CalDAV-only via `tsdav`; broker auth = Fastmail **app password**, never JMAP/API token. Principal URL form `https://caldav.fastmail.com/dav/principals/user/{email}/` (Pitfall 1). - **D-10:** Identity = `oidc_iss + oidc_sub` composite key, never email (AUTH-03). - **D-11:** **Skip the Authelia `groups` claim** — two equal members; authentication not authorization (Pitfall 16). @@ -37,29 +41,34 @@ Stand up the FamilySync stack (Docker Compose + MariaDB, deployed through the re - **D-13:** Calendar cache: store raw VEVENT blob + `dtstart_utc`; all-day events as `DATE` / `{date, allDay}` struct, never coerced to DATETIME/UTC (Pitfall 3). Write-through cache invalidation; only cache server-returned objects (Pitfall 14). Use sync-token (WebDAV-Sync) with ctag-poll fallback from day one (Pitfall 4). ### Claude's Discretion + - **Phase 1 landing page:** a thin authenticated shell that ALSO displays the one cached event as broker proof (not a bare health page, not a real calendar UI). Confirms end-to-end auth + broker in one screen. - **Color palette:** a small set of visually-distinct, accessible hues assigned round-robin by join order. Exact values are Claude's choice. - **Broker internals:** sync-token vs ctag detection, poll interval (research suggests conservative 5-min / 60s acceptable for v1), Drizzle schema specifics, OIDC middleware wiring, encryption helper implementation. - **Stack libraries/versions:** per locked research stack (Hono + Drizzle/mysql2 + tsdav + ical.js + rrule + @hono/oidc-auth). - + + ## Canonical References **Downstream agents MUST read these before planning or implementing.** ### Phase definition & requirements + - `.planning/ROADMAP.md` § "Phase 1: Foundation + Broker Spike" — goal + 5 success criteria (the scope anchor) - `.planning/REQUIREMENTS.md` — AUTH-01, AUTH-02, AUTH-03, CAL-01, CAL-08 (full requirement text + traceability) - `.planning/PROJECT.md` — constraints, key decisions, household context ### Research (read before planning — flagged NEEDS research-phase by SUMMARY) + - `.planning/research/SUMMARY.md` — cross-cutting findings; Phase 1 section + confidence assessment - `.planning/research/PITFALLS.md` — Phase-1-relevant pitfalls: #1 (CalDAV-only), #3 (all-day DATE), #4 (ETag/sync-token), #7 (personal-cal sharing), #14 (cache double-write), #16 (Authelia groups), #17 (Authelia silent renewal/cookies), #18 (Pangolin WS/SSE) - `.planning/research/STACK.md` — locked library versions + import paths - `.planning/research/ARCHITECTURE.md` — broker-cache pattern, component layout, MariaDB schema guidance ### External docs (authoritative) + - Fastmail CalDAV principal URL + app passwords (see SUMMARY/PITFALLS Sources) - Authelia OIDC client config — PKCE S256, `client_secret_basic`, response_type `code`, grant types `authorization_code`+`refresh_token` (see CLAUDE.md "Authelia OIDC Integration") - Pangolin WebSocket/SSE issue #1034 (referenced in PITFALLS #18) @@ -67,15 +76,19 @@ Stand up the FamilySync stack (Docker Compose + MariaDB, deployed through the re + ## Existing Code Insights ### Reusable Assets + - None — greenfield repo. Only `.planning/`, generated `CLAUDE.md`, and an empty `README` exist. No source tree yet. ### Established Patterns + - None established yet. Phase 1 sets the foundational patterns (Docker Compose layout, Drizzle schema/migrations, broker module boundary, OIDC session middleware) that later phases inherit. ### Integration Points + - Authelia (already deployed, both members have accounts) — register FamilySync as an OIDC confidential client; no Authelia deploy/provisioning. - Pangolin/Newt tunnel (already running) — FamilySync gets a public hostname under the same parent domain as Authelia. - MariaDB + Redis available in the Unraid stack; no PostgreSQL. @@ -86,7 +99,7 @@ Stand up the FamilySync stack (Docker Compose + MariaDB, deployed through the re ## Specific Ideas - The broker module must be a hard boundary: all Fastmail I/O isolated in `broker/`; nothing else imports Fastmail credentials or tsdav directly (architecture note). -- "No per-member credential juggling" is a core-value phrase, but the user explicitly accepted per-member app passwords as the access model — the juggling avoided is calendar-credential *login* friction for members (they still log in only via Authelia SSO), not broker-side secrets. +- "No per-member credential juggling" is a core-value phrase, but the user explicitly accepted per-member app passwords as the access model — the juggling avoided is calendar-credential _login_ friction for members (they still log in only via Authelia SSO), not broker-side secrets. @@ -98,11 +111,12 @@ Stand up the FamilySync stack (Docker Compose + MariaDB, deployed through the re - **Wife's app password onboarding flow** — the encrypted-credential DB table is built in Phase 1 to support it, but the actual onboarding UX/endpoint is Phase 2 work. ### Reviewed Todos (not folded) + - "Kick off FamilySync with /gsd:new-project" — stale project-kickoff todo, already completed during initialization; not relevant to Phase 1 scope. --- -*Phase: 1-Foundation + Broker Spike* -*Context gathered: 2026-06-04* +_Phase: 1-Foundation + Broker Spike_ +_Context gathered: 2026-06-04_ diff --git a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-DISCUSSION-LOG.md b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-DISCUSSION-LOG.md index 67d434c..19ec112 100644 --- a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-DISCUSSION-LOG.md +++ b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-DISCUSSION-LOG.md @@ -11,52 +11,56 @@ ## Area Selection -| Option | Selected | -|--------|----------| -| Personal-cal spike & fallback | ✓ | -| Member color assignment | ✓ | -| Phase 1 landing scope | (skipped → Claude's discretion) | -| Infra validation scope | ✓ | +| Option | Selected | +| ----------------------------- | ------------------------------- | +| Personal-cal spike & fallback | ✓ | +| Member color assignment | ✓ | +| Phase 1 landing scope | (skipped → Claude's discretion) | +| Infra validation scope | ✓ | --- ## Personal-cal spike & fallback ### Where the wife's personal calendar lives -| Option | Description | Selected | -|--------|-------------|----------| -| Fastmail | Fastmail-hosted collection; broker can reach it | ✓ | -| iCloud / Apple Calendar | Broker cannot read; CAL-08 degrades | | -| Not sure yet | Confirm before spike | | -| Both / mixed | Define overlay target | | + +| Option | Description | Selected | +| ----------------------- | ----------------------------------------------- | -------- | +| Fastmail | Fastmail-hosted collection; broker can reach it | ✓ | +| iCloud / Apple Calendar | Broker cannot read; CAL-08 degrades | | +| Not sure yet | Confirm before spike | | +| Both / mixed | Define overlay target | | **User's choice:** Fastmail **Notes:** Removes the iCloud "unreachable" risk; CAL-08 is technically achievable. ### Broker access model -| Option | Description | Selected | -|--------|-------------|----------| -| Single broker token + share | One token; share+accept per member | | -| Per-member app passwords | Each member's own app password; broker holds N creds | ✓ | -| Prove single-token, keep per-member ready | Hybrid | | + +| Option | Description | Selected | +| ----------------------------------------- | ---------------------------------------------------- | -------- | +| Single broker token + share | One token; share+accept per member | | +| Per-member app passwords | Each member's own app password; broker holds N creds | ✓ | +| Prove single-token, keep per-member ready | Hybrid | | **User's choice:** Per-member app passwords **Notes:** Eliminates the unconfirmed cross-account CalDAV ACL discovery risk (the project's original highest risk). Spike simplifies to confirming an app password reads its own account. ### Spike credential sequencing -| Option | Description | Selected | -|--------|-------------|----------| -| Just mine now | Prove broker with Lucas's app password; wife's added Phase 2 | ✓ | -| Both before Phase 1 done | Get wife's password too; fully close CAL-08 now | | + +| Option | Description | Selected | +| ------------------------ | ------------------------------------------------------------ | -------- | +| Just mine now | Prove broker with Lucas's app password; wife's added Phase 2 | ✓ | +| Both before Phase 1 done | Get wife's password too; fully close CAL-08 now | | **User's choice:** Just mine now **Notes:** Phase 1 not blocked on coordinating with wife. CAL-08 structurally proven via N-credential broker. ### Credential storage -| Option | Description | Selected | -|--------|-------------|----------| -| DB table, encrypted at rest | Keyed by oidc_sub, key from env; supports later onboarding | ✓ | -| Env vars / secrets file | Simple, but redeploy to add/rotate | | + +| Option | Description | Selected | +| --------------------------- | ---------------------------------------------------------- | -------- | +| DB table, encrypted at rest | Keyed by oidc_sub, key from env; supports later onboarding | ✓ | +| Env vars / secrets file | Simple, but redeploy to add/rotate | | **User's choice:** DB table, encrypted at rest **Notes:** Supports adding wife later with no redeploy; backend-only, never exposed to frontend. @@ -65,11 +69,11 @@ ## Member color assignment -| Option | Description | Selected | -|--------|-------------|----------| -| Auto-assign from palette | Assign on first login, persist on user row | ✓ | -| User-pickable in settings | Default + settings color-picker | | -| Hardcoded per account | Fixed color in config per oidc_sub | | +| Option | Description | Selected | +| ------------------------- | ------------------------------------------ | -------- | +| Auto-assign from palette | Assign on first login, persist on user row | ✓ | +| User-pickable in settings | Default + settings color-picker | | +| Hardcoded per account | Fixed color in config per oidc_sub | | **User's choice:** Auto-assign from palette **Notes:** No settings UI in Phase 1; stable across sessions; works for future members. Pickable color deferred. @@ -79,19 +83,21 @@ ## Infra validation scope ### Deployment scope -| Option | Description | Selected | -|--------|-------------|----------| -| Deploy through Pangolin | Real tunnel + Authelia; validate OIDC topology day one | ✓ | -| Local docker-compose only | Localhost; defer tunnel validation | | + +| Option | Description | Selected | +| ------------------------- | ------------------------------------------------------ | -------- | +| Deploy through Pangolin | Real tunnel + Authelia; validate OIDC topology day one | ✓ | +| Local docker-compose only | Localhost; defer tunnel validation | | **User's choice:** Deploy through Pangolin **Notes:** Avoids "worked locally, broke in prod" OIDC failure; same-parent-domain cookies (Pitfall 17). ### Pangolin SSE smoke test -| Option | Description | Selected | -|--------|-------------|----------| -| Fold it in now | Trivial SSE endpoint over public URL; de-risk Phase 4 | ✓ | -| Defer to Phase 4 | Keep Phase 1 strict to its 5 criteria | | + +| Option | Description | Selected | +| ---------------- | ----------------------------------------------------- | -------- | +| Fold it in now | Trivial SSE endpoint over public URL; de-risk Phase 4 | ✓ | +| Defer to Phase 4 | Keep Phase 1 strict to its 5 criteria | | **User's choice:** Fold it in now **Notes:** Cheap while stack+tunnel are up; a failure changes Phase 4 transport but does not block Phase 1. diff --git a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-HUMAN-UAT.md b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-HUMAN-UAT.md index 6563c23..3cfc87f 100644 --- a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-HUMAN-UAT.md +++ b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-HUMAN-UAT.md @@ -2,8 +2,8 @@ status: partial phase: 01-foundation-broker-spike source: [01-VERIFICATION.md] -started: "2026-06-04" -updated: "2026-06-04" +started: '2026-06-04' +updated: '2026-06-04' --- ## Current Test @@ -13,19 +13,23 @@ updated: "2026-06-04" ## Tests ### 1. AUTH-01 — Live Authelia OIDC login over the public Pangolin URL + expected: From an external network, opening `https://familysync.` redirects to Authelia; after logging in as Lucas, the app shell loads showing his name, his assigned color, and one real cached Fastmail event. No Fastmail credentials are entered in the app. result: [pending] setup: Register the `familysync` OIDC client in Authelia (`require_pkce: true`, `pkce_challenge_method: S256`, `token_endpoint_auth_method: client_secret_basic`, redirect `https://familysync./callback`, scopes openid/profile/email). Set OIDC env vars in `.env` — note `OIDC_AUTH_EXTERNAL_URL` is mandatory behind Pangolin. Deploy via `docker compose up -d`. ### 2. AUTH-02 — Session persists across browser restart + expected: After authenticating, fully close and reopen the browser, revisit the URL → no re-login prompt; the shell loads directly. result: [pending] ### 3. AUTH-03 — Second member gets a distinct color + expected: The wife logs in via Authelia on her device and is assigned a stable color distinct from Lucas's; it does not change on subsequent logins. result: [pending] ### 4. SSE-over-Pangolin smoke test (de-risks Phase 4) + expected: With a valid session cookie, `curl -N -H "Cookie: oidc-auth=" https://familysync./api/sse/heartbeat` streams a `heartbeat` event roughly every 10s and stays open for 5+ minutes without Pangolin cutting the stream. PASS = continuous heartbeats; FAIL = stream cut early (investigate Pangolin idle-timeout; note as Phase 4 constraint, ref issue #1034). result: PASS (2026-06-08) — GET /api/sse/heartbeat over familysync-dev.bergerhouse.net (Pangolin→Newt→api) with a valid session cookie held open ~6 min (01:37:53Z→01:43:54Z), 35 heartbeat events id 0→34 at ~10s cadence; response bytes grew 71→2535 (incremental delivery → Pangolin buffering OFF); no early cut. Phase 4 entry gate (D-14 / issue #1034) CLEARED. Caveat: proves no idle-timeout/buffering over ~6 min, not the absence of a max total connection-duration cap — residual risk covered by Phase 4 design (D-10/D-11/D-12 in 04-CONTEXT.md). diff --git a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-RESEARCH.md b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-RESEARCH.md index 4361071..b4d2b7a 100644 --- a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-RESEARCH.md +++ b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-RESEARCH.md @@ -7,6 +7,7 @@ --- + ## User Constraints (from CONTEXT.md) ### Locked Decisions @@ -36,18 +37,20 @@ - User-pickable color picker (settings UI) - Single-token broker via Fastmail share+accept - Wife's app password onboarding flow (Phase 2) - + + ## Phase Requirements -| ID | Description | Research Support | -|----|-------------|------------------| -| AUTH-01 | User can log in through Authelia OIDC SSO — no separate FamilySync account | @hono/oidc-auth v1.8.3 handles full authorization-code+PKCE flow; Authelia client YAML documented | -| AUTH-02 | User stays logged in across sessions (persistent session) | @hono/oidc-auth refresh-token rotation: middleware refreshes session automatically every 15 min using stored refresh token | -| AUTH-03 | Each member maps to stable identity (oidc_iss+sub) and gets consistent per-member color | mysqlTable users schema with oidc_iss+oidc_sub composite key; color column with auto-assign logic on INSERT | -| CAL-01 | App reads shared Fastmail calendar via CalDAV broker and caches locally (ctag polling) | tsdav createDAVClient → fetchCalendars → fetchCalendarObjects; ctag/syncToken fields on DAVCalendar; drizzle schema for calendar_events cache | -| CAL-08 | Spike: confirm per-member app password reads own account's shared+personal calendars via PROPFIND/REPORT | tsdav with Basic auth against caldav.fastmail.com; spike proves N-credential broker model; go/no-go documented | +| ID | Description | Research Support | +| ------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| AUTH-01 | User can log in through Authelia OIDC SSO — no separate FamilySync account | @hono/oidc-auth v1.8.3 handles full authorization-code+PKCE flow; Authelia client YAML documented | +| AUTH-02 | User stays logged in across sessions (persistent session) | @hono/oidc-auth refresh-token rotation: middleware refreshes session automatically every 15 min using stored refresh token | +| AUTH-03 | Each member maps to stable identity (oidc_iss+sub) and gets consistent per-member color | mysqlTable users schema with oidc_iss+oidc_sub composite key; color column with auto-assign logic on INSERT | +| CAL-01 | App reads shared Fastmail calendar via CalDAV broker and caches locally (ctag polling) | tsdav createDAVClient → fetchCalendars → fetchCalendarObjects; ctag/syncToken fields on DAVCalendar; drizzle schema for calendar_events cache | +| CAL-08 | Spike: confirm per-member app password reads own account's shared+personal calendars via PROPFIND/REPORT | tsdav with Basic auth against caldav.fastmail.com; spike proves N-credential broker model; go/no-go documented | + --- @@ -66,18 +69,18 @@ The Pangolin SSE smoke test is structurally the right call: GitHub issue #1034 s ## Architectural Responsibility Map -| Capability | Primary Tier | Secondary Tier | Rationale | -|------------|-------------|----------------|-----------| -| OIDC auth flow (redirect, code exchange, session cookie) | API / Backend | — | Backend confidential client holds client_secret and refresh token; D-12 locks this | -| Session persistence (refresh token rotation) | API / Backend | — | @hono/oidc-auth manages refresh automatically in middleware; no browser iframe | -| User identity upsert (oidc_iss+sub → users row) | API / Backend | — | Happens on first authenticated request; DB write at API layer | -| Per-member color assignment | API / Backend | — | Auto-assign on INSERT, persisted in users table, returned to frontend via /api/me | -| CalDAV broker (PROPFIND, REPORT, ctag polling) | API / Backend | — | Broker is a module boundary; Fastmail credentials never reach frontend | -| App-password encryption/decryption | API / Backend | — | AES-GCM with key from env; purely server-side | -| Calendar event cache (MariaDB) | Database / Storage | — | Raw VEVENT blob + dtstart_utc; broker writes, API reads | -| SSE smoke test endpoint | API / Backend | — | Long-lived GET /api/sse/heartbeat; proves Pangolin pass-through | -| Landing page shell (authenticated UI) | Browser / Client | Frontend served by API or nginx | Thin React component; TanStack Query fetches /api/me + /api/events | -| Static asset serving | CDN / Static | API / Backend | Vite build served from same Docker container or separate nginx | +| Capability | Primary Tier | Secondary Tier | Rationale | +| -------------------------------------------------------- | ------------------ | ------------------------------- | ---------------------------------------------------------------------------------- | +| OIDC auth flow (redirect, code exchange, session cookie) | API / Backend | — | Backend confidential client holds client_secret and refresh token; D-12 locks this | +| Session persistence (refresh token rotation) | API / Backend | — | @hono/oidc-auth manages refresh automatically in middleware; no browser iframe | +| User identity upsert (oidc_iss+sub → users row) | API / Backend | — | Happens on first authenticated request; DB write at API layer | +| Per-member color assignment | API / Backend | — | Auto-assign on INSERT, persisted in users table, returned to frontend via /api/me | +| CalDAV broker (PROPFIND, REPORT, ctag polling) | API / Backend | — | Broker is a module boundary; Fastmail credentials never reach frontend | +| App-password encryption/decryption | API / Backend | — | AES-GCM with key from env; purely server-side | +| Calendar event cache (MariaDB) | Database / Storage | — | Raw VEVENT blob + dtstart_utc; broker writes, API reads | +| SSE smoke test endpoint | API / Backend | — | Long-lived GET /api/sse/heartbeat; proves Pangolin pass-through | +| Landing page shell (authenticated UI) | Browser / Client | Frontend served by API or nginx | Thin React component; TanStack Query fetches /api/me + /api/events | +| Static asset serving | CDN / Static | API / Backend | Vite build served from same Docker container or separate nginx | --- @@ -85,24 +88,24 @@ The Pangolin SSE smoke test is structurally the right call: GitHub issue #1034 s ### Core (Phase 1 scope) -| Library | Version | Purpose | Verified | -|---------|---------|---------|----------| -| hono | 4.12.23 | HTTP framework | [VERIFIED: npm registry] | -| @hono/node-server | 2.0.4 | Node.js adapter for Hono | [VERIFIED: npm registry] | -| @hono/oidc-auth | 1.8.3 | OIDC session middleware | [VERIFIED: npm registry] | -| @hono/zod-validator | 0.8.0 | Zod validation middleware | [VERIFIED: npm registry] | -| drizzle-orm | 0.45.2 | MariaDB ORM | [VERIFIED: npm registry] | -| drizzle-kit | 0.31.10 | Schema migrations | [VERIFIED: npm registry] | -| mysql2 | 3.22.4 | MariaDB driver | [VERIFIED: npm registry] | -| tsdav | 2.2.2 | CalDAV client | [VERIFIED: npm registry] | -| ical.js | 2.2.1 | iCalendar parser | [VERIFIED: npm registry] | -| zod | 3.25.76 (pin 3.25.x) | Schema validation | [VERIFIED: npm registry] | -| node-cron | 4.2.1 | Background polling scheduler | [VERIFIED: npm registry] | -| react | 19.x | PWA frontend | [ASSUMED] | -| vite | 8.0.16 | Build tooling | [VERIFIED: npm registry] | -| @tanstack/react-query | 5.101.0 | Server state management | [VERIFIED: npm registry] | -| zustand | 5.0.14 | UI state | [VERIFIED: npm registry] | -| vitest | 4.1.8 | Test runner | [VERIFIED: npm registry] | +| Library | Version | Purpose | Verified | +| --------------------- | -------------------- | ---------------------------- | ------------------------ | +| hono | 4.12.23 | HTTP framework | [VERIFIED: npm registry] | +| @hono/node-server | 2.0.4 | Node.js adapter for Hono | [VERIFIED: npm registry] | +| @hono/oidc-auth | 1.8.3 | OIDC session middleware | [VERIFIED: npm registry] | +| @hono/zod-validator | 0.8.0 | Zod validation middleware | [VERIFIED: npm registry] | +| drizzle-orm | 0.45.2 | MariaDB ORM | [VERIFIED: npm registry] | +| drizzle-kit | 0.31.10 | Schema migrations | [VERIFIED: npm registry] | +| mysql2 | 3.22.4 | MariaDB driver | [VERIFIED: npm registry] | +| tsdav | 2.2.2 | CalDAV client | [VERIFIED: npm registry] | +| ical.js | 2.2.1 | iCalendar parser | [VERIFIED: npm registry] | +| zod | 3.25.76 (pin 3.25.x) | Schema validation | [VERIFIED: npm registry] | +| node-cron | 4.2.1 | Background polling scheduler | [VERIFIED: npm registry] | +| react | 19.x | PWA frontend | [ASSUMED] | +| vite | 8.0.16 | Build tooling | [VERIFIED: npm registry] | +| @tanstack/react-query | 5.101.0 | Server state management | [VERIFIED: npm registry] | +| zustand | 5.0.14 | UI state | [VERIFIED: npm registry] | +| vitest | 4.1.8 | Test runner | [VERIFIED: npm registry] | > **Zod version note:** `npm view zod version` returns 4.4.3. CLAUDE.md specifies 3.24.x (stale). `@hono/zod-validator@0.8.0` peer deps accept `^3.25.0 || ^4.0.0`. Pin `zod@3.25.x` for Phase 1 to avoid any unexpected v4 API differences — v3 is the conservative choice and fully supported. [VERIFIED: npm registry peer deps] @@ -129,27 +132,27 @@ npm install -D vite @vitejs/plugin-react typescript > slopcheck was not installable in this environment. All packages are verified via npm registry AND have authoritative source repos (GitHub official maintainers or known organizations). No packages are flagged for removal. -| Package | Registry | Age | Source Repo | slopcheck | Disposition | -|---------|----------|-----|-------------|-----------|-------------| -| hono | npm | 3+ yrs | github.com/honojs/hono | [OK — known major framework] | Approved | -| @hono/oidc-auth | npm | Active (updated 2026-06-01) | github.com/honojs/middleware | [OK — official Hono middleware monorepo] | Approved | -| @hono/node-server | npm | Active | github.com/honojs/node-server | [OK] | Approved | -| @hono/zod-validator | npm | Active | github.com/honojs/middleware | [OK] | Approved | -| drizzle-orm | npm | 2+ yrs | github.com/drizzle-team/drizzle-orm | [OK — known ORM] | Approved | -| drizzle-kit | npm | 2+ yrs | github.com/drizzle-team/drizzle-orm | [OK] | Approved | -| mysql2 | npm | 8+ yrs | github.com/sidorares/node-mysql2 | [OK — industry standard] | Approved | -| tsdav | npm | 3+ yrs (updated 2026-05-14) | github.com/natelindev/tsdav | [OK] | Approved | -| ical.js | npm | 10+ yrs (updated 2025-08-08) | github.com/kewisch/ical.js | [OK — Mozilla-maintained] | Approved | -| zod | npm | 4+ yrs | github.com/colinhacks/zod | [OK — industry standard] | Approved | -| node-cron | npm | 8+ yrs | github.com/merencia/node-cron | [OK] | Approved | -| vitest | npm | 3+ yrs | github.com/vitest-dev/vitest | [OK] | Approved | -| @tanstack/react-query | npm | 4+ yrs | github.com/TanStack/query | [OK] | Approved | -| zustand | npm | 4+ yrs | github.com/pmndrs/zustand | [OK] | Approved | +| Package | Registry | Age | Source Repo | slopcheck | Disposition | +| --------------------- | -------- | ---------------------------- | ----------------------------------- | ---------------------------------------- | ----------- | +| hono | npm | 3+ yrs | github.com/honojs/hono | [OK — known major framework] | Approved | +| @hono/oidc-auth | npm | Active (updated 2026-06-01) | github.com/honojs/middleware | [OK — official Hono middleware monorepo] | Approved | +| @hono/node-server | npm | Active | github.com/honojs/node-server | [OK] | Approved | +| @hono/zod-validator | npm | Active | github.com/honojs/middleware | [OK] | Approved | +| drizzle-orm | npm | 2+ yrs | github.com/drizzle-team/drizzle-orm | [OK — known ORM] | Approved | +| drizzle-kit | npm | 2+ yrs | github.com/drizzle-team/drizzle-orm | [OK] | Approved | +| mysql2 | npm | 8+ yrs | github.com/sidorares/node-mysql2 | [OK — industry standard] | Approved | +| tsdav | npm | 3+ yrs (updated 2026-05-14) | github.com/natelindev/tsdav | [OK] | Approved | +| ical.js | npm | 10+ yrs (updated 2025-08-08) | github.com/kewisch/ical.js | [OK — Mozilla-maintained] | Approved | +| zod | npm | 4+ yrs | github.com/colinhacks/zod | [OK — industry standard] | Approved | +| node-cron | npm | 8+ yrs | github.com/merencia/node-cron | [OK] | Approved | +| vitest | npm | 3+ yrs | github.com/vitest-dev/vitest | [OK] | Approved | +| @tanstack/react-query | npm | 4+ yrs | github.com/TanStack/query | [OK] | Approved | +| zustand | npm | 4+ yrs | github.com/pmndrs/zustand | [OK] | Approved | **Packages removed due to [SLOP] verdict:** none **Packages flagged [SUS]:** none -*slopcheck was unavailable; manual provenance review performed against npm registry + known official GitHub repos. All packages have multi-year histories, large download counts, and verified source repositories.* +_slopcheck was unavailable; manual provenance review performed against npm registry + known official GitHub repos. All packages have multi-year histories, large download counts, and verified source repositories._ --- @@ -249,6 +252,7 @@ familysync/ **What:** Mount `oidcAuthMiddleware()` before all protected routes. Use `getAuth(c)` to extract OIDC claims (`sub`, `iss`, `email`). On first authenticated request, upsert user row with `oidc_iss + oidc_sub` composite key. **Env vars required:** + - `OIDC_AUTH_SECRET` — 32+ char random string for JWT cookie signing - `OIDC_ISSUER` — `https://auth.yourdomain.com` (Authelia's base URL; middleware fetches `/.well-known/openid-configuration`) - `OIDC_CLIENT_ID` — registered client ID in Authelia @@ -257,35 +261,37 @@ familysync/ - `OIDC_AUTH_EXTERNAL_URL` — `https://familysync.yourdomain.com` (critical behind Pangolin) **Code pattern:** + ```typescript // src/index.ts // Source: https://github.com/honojs/middleware/tree/main/packages/oidc-auth -import { Hono } from 'hono' -import { oidcAuthMiddleware, getAuth, processOAuthCallback } from '@hono/oidc-auth' +import { Hono } from 'hono'; +import { oidcAuthMiddleware, getAuth, processOAuthCallback } from '@hono/oidc-auth'; -const app = new Hono() +const app = new Hono(); // Callback route BEFORE auth middleware -app.get('/callback', (c) => processOAuthCallback(c)) +app.get('/callback', (c) => processOAuthCallback(c)); // Mount auth middleware on all /api routes -app.use('/api/*', oidcAuthMiddleware()) +app.use('/api/*', oidcAuthMiddleware()); app.get('/api/me', async (c) => { - const auth = await getAuth(c) + const auth = await getAuth(c); // auth.sub = oidc_sub, auth.iss = oidc_iss, auth.email = display hint - const user = await upsertUser(auth.iss, auth.sub, auth.email) - return c.json({ user }) -}) + const user = await upsertUser(auth.iss, auth.sub, auth.email); + return c.json({ user }); +}); ``` **Authelia client YAML** (add to Authelia's `configuration.yml`): + ```yaml identity_providers: oidc: clients: - client_id: 'familysync' - client_secret: '$pbkdf2-sha512$...' # authelia crypto hash + client_secret: '$pbkdf2-sha512$...' # authelia crypto hash redirect_uris: - 'https://familysync.yourdomain.com/callback' grant_types: @@ -310,79 +316,110 @@ identity_providers: **Import path:** `drizzle-orm/mysql2` [VERIFIED: npm registry, drizzle-orm@0.45.2] **Schema for Phase 1 tables:** + ```typescript // src/db/schema.ts // Source: https://orm.drizzle.team/docs/sql-schema-declaration -import { mysqlTable, varchar, text, int, date, timestamp, boolean, primaryKey, index } from 'drizzle-orm/mysql-core' +import { + mysqlTable, + varchar, + text, + int, + date, + timestamp, + boolean, + primaryKey, + index, +} from 'drizzle-orm/mysql-core'; -export const users = mysqlTable('users', { - id: int().primaryKey().autoincrement(), - oidcIss: varchar('oidc_iss', { length: 512 }).notNull(), - oidcSub: varchar('oidc_sub', { length: 256 }).notNull(), - displayName: varchar('display_name', { length: 256 }), - color: varchar('color', { length: 7 }).notNull(), // hex e.g. '#4A90D9' - createdAt: timestamp('created_at').defaultNow().notNull(), -}, (t) => [ - // Composite unique key — identity is iss+sub, never email - { name: 'uniq_oidc_identity', columns: [t.oidcIss, t.oidcSub] } -]) +export const users = mysqlTable( + 'users', + { + id: int().primaryKey().autoincrement(), + oidcIss: varchar('oidc_iss', { length: 512 }).notNull(), + oidcSub: varchar('oidc_sub', { length: 256 }).notNull(), + displayName: varchar('display_name', { length: 256 }), + color: varchar('color', { length: 7 }).notNull(), // hex e.g. '#4A90D9' + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (t) => [ + // Composite unique key — identity is iss+sub, never email + { name: 'uniq_oidc_identity', columns: [t.oidcIss, t.oidcSub] }, + ], +); // Encrypted app-password credentials per member -export const memberCredentials = mysqlTable('member_credentials', { - id: int().primaryKey().autoincrement(), - userId: int('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), - // iv (hex) + ciphertext (hex) stored together as JSON or two columns - encryptedPassword: text('encrypted_password').notNull(), // JSON: {iv, ciphertext} - fastmailEmail: varchar('fastmail_email', { length: 256 }).notNull(), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), -}, (t) => [ - index('idx_user_id').on(t.userId) -]) +export const memberCredentials = mysqlTable( + 'member_credentials', + { + id: int().primaryKey().autoincrement(), + userId: int('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + // iv (hex) + ciphertext (hex) stored together as JSON or two columns + encryptedPassword: text('encrypted_password').notNull(), // JSON: {iv, ciphertext} + fastmailEmail: varchar('fastmail_email', { length: 256 }).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), + }, + (t) => [index('idx_user_id').on(t.userId)], +); // Calendar collections discovered via PROPFIND -export const calendars = mysqlTable('calendars', { - id: int().primaryKey().autoincrement(), - userId: int('user_id').notNull().references(() => users.id), - url: varchar('url', { length: 1024 }).notNull(), - displayName: varchar('display_name', { length: 256 }), - color: varchar('color', { length: 7 }), - ctag: varchar('ctag', { length: 512 }), - syncToken: varchar('sync_token', { length: 1024 }), - lastSyncedAt: timestamp('last_synced_at'), -}, (t) => [ - index('idx_user_calendars').on(t.userId) -]) +export const calendars = mysqlTable( + 'calendars', + { + id: int().primaryKey().autoincrement(), + userId: int('user_id') + .notNull() + .references(() => users.id), + url: varchar('url', { length: 1024 }).notNull(), + displayName: varchar('display_name', { length: 256 }), + color: varchar('color', { length: 7 }), + ctag: varchar('ctag', { length: 512 }), + syncToken: varchar('sync_token', { length: 1024 }), + lastSyncedAt: timestamp('last_synced_at'), + }, + (t) => [index('idx_user_calendars').on(t.userId)], +); // Calendar event cache — raw VEVENT blob + indexed dtstart_utc -export const calendarEvents = mysqlTable('calendar_events', { - id: int().primaryKey().autoincrement(), - calendarId: int('calendar_id').notNull().references(() => calendars.id, { onDelete: 'cascade' }), - uid: varchar('uid', { length: 512 }).notNull(), // VEVENT UID — natural key - etag: varchar('etag', { length: 256 }), - rawVevent: text('raw_vevent').notNull(), // full VCALENDAR/VEVENT string - dtstartUtc: timestamp('dtstart_utc'), // NULL for all-day events - dtstartDate: date('dtstart_date'), // set for all-day events; NULL for timed - allDay: boolean('all_day').default(false).notNull(), - updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), -}, (t) => [ - index('idx_dtstart_utc').on(t.dtstartUtc), - index('idx_dtstart_date').on(t.dtstartDate), - // uid is unique per calendar - { name: 'uniq_calendar_uid', columns: [t.calendarId, t.uid] } -]) +export const calendarEvents = mysqlTable( + 'calendar_events', + { + id: int().primaryKey().autoincrement(), + calendarId: int('calendar_id') + .notNull() + .references(() => calendars.id, { onDelete: 'cascade' }), + uid: varchar('uid', { length: 512 }).notNull(), // VEVENT UID — natural key + etag: varchar('etag', { length: 256 }), + rawVevent: text('raw_vevent').notNull(), // full VCALENDAR/VEVENT string + dtstartUtc: timestamp('dtstart_utc'), // NULL for all-day events + dtstartDate: date('dtstart_date'), // set for all-day events; NULL for timed + allDay: boolean('all_day').default(false).notNull(), + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), + }, + (t) => [ + index('idx_dtstart_utc').on(t.dtstartUtc), + index('idx_dtstart_date').on(t.dtstartDate), + // uid is unique per calendar + { name: 'uniq_calendar_uid', columns: [t.calendarId, t.uid] }, + ], +); ``` **Key schema rules:** + - All-day events: `dtstart_utc` = NULL, `dtstart_date` = `DATE` column, `all_day` = true — never coerce `DATE` to `DATETIME` (Pitfall #3) - Timed events: `dtstart_utc` = `TIMESTAMP` (MariaDB stores in UTC), `dtstart_date` = NULL - `uid` is the CalDAV/iCalendar UID; use as idempotency key on upsert - `rawVevent` stores the full VCALENDAR string so ical.js can parse timezone/RRULE correctly on read **drizzle.config.ts:** + ```typescript // Source: https://orm.drizzle.team/docs/kit-overview -import { defineConfig } from 'drizzle-kit' +import { defineConfig } from 'drizzle-kit'; export default defineConfig({ dialect: 'mysql', @@ -395,10 +432,11 @@ export default defineConfig({ database: process.env.DB_NAME!, port: Number(process.env.DB_PORT ?? 3306), }, -}) +}); ``` **Migration workflow:** + ```bash npx drizzle-kit generate # generates SQL migration files npx drizzle-kit migrate # applies migrations to MariaDB @@ -407,11 +445,12 @@ npx drizzle-kit migrate # applies migrations to MariaDB For Docker startup: run `drizzle-kit migrate` as an entrypoint step or a separate init container to ensure schema is applied before the API starts. **DB client singleton:** + ```typescript // src/db/client.ts -import { drizzle } from 'drizzle-orm/mysql2' -import mysql from 'mysql2/promise' -import * as schema from './schema' +import { drizzle } from 'drizzle-orm/mysql2'; +import mysql from 'mysql2/promise'; +import * as schema from './schema'; const pool = mysql.createPool({ host: process.env.DB_HOST, @@ -420,9 +459,9 @@ const pool = mysql.createPool({ database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, -}) +}); -export const db = drizzle({ client: pool, schema, mode: 'default' }) +export const db = drizzle({ client: pool, schema, mode: 'default' }); ``` ### Pattern 3: CalDAV Broker (tsdav) @@ -430,6 +469,7 @@ export const db = drizzle({ client: pool, schema, mode: 'default' }) **What:** `createDAVClient` with Fastmail principal URL + Basic auth (app password). `fetchCalendars()` does PROPFIND → returns array of `DAVCalendar` (with `url`, `ctag`, `syncToken`, `displayName`). `fetchCalendarObjects()` does REPORT calendar-query → returns raw VEVENT strings. **Fastmail-specific config:** + - `serverUrl`: `https://caldav.fastmail.com` - `credentials.username`: Fastmail email - `credentials.password`: app password (decrypted at runtime) @@ -439,10 +479,11 @@ export const db = drizzle({ client: pool, schema, mode: 'default' }) > Pitfall #1: Do NOT use bare `https://caldav.fastmail.com` as the only URL. tsdav's service discovery will call `/.well-known/caldav` which redirects to the principal. Confirm the redirected principal URL matches `https://caldav.fastmail.com/dav/principals/user/{email}/`. [VERIFIED: CLAUDE.md + PITFALLS.md #1] **Code pattern:** + ```typescript // src/broker/client.ts // Source: https://github.com/natelindev/tsdav -import { createDAVClient } from 'tsdav' +import { createDAVClient } from 'tsdav'; export async function createFastmailClient(email: string, appPassword: string) { return createDAVClient({ @@ -453,108 +494,117 @@ export async function createFastmailClient(email: string, appPassword: string) { }, authMethod: 'Basic', defaultAccountType: 'caldav', - }) + }); } ``` **Fetching calendars + ctag polling:** + ```typescript // src/broker/poller.ts -import { schedule } from 'node-cron' -import { createFastmailClient } from './client' -import { db } from '../db/client' -import { calendars, memberCredentials } from '../db/schema' -import { syncCalendar } from './sync' -import { decryptPassword } from './crypto' -import { eq } from 'drizzle-orm' +import { schedule } from 'node-cron'; +import { createFastmailClient } from './client'; +import { db } from '../db/client'; +import { calendars, memberCredentials } from '../db/schema'; +import { syncCalendar } from './sync'; +import { decryptPassword } from './crypto'; +import { eq } from 'drizzle-orm'; // Run every 5 minutes schedule('*/5 * * * *', async () => { - const creds = await db.select().from(memberCredentials) + const creds = await db.select().from(memberCredentials); for (const cred of creds) { - const appPassword = decryptPassword(cred.encryptedPassword) - const client = await createFastmailClient(cred.fastmailEmail, appPassword) - const davCalendars = await client.fetchCalendars() + const appPassword = decryptPassword(cred.encryptedPassword); + const client = await createFastmailClient(cred.fastmailEmail, appPassword); + const davCalendars = await client.fetchCalendars(); for (const davCal of davCalendars) { - const stored = await db.select() + const stored = await db + .select() .from(calendars) .where(eq(calendars.url, davCal.url)) - .limit(1) + .limit(1); // ctag-based change detection (D-13: try syncToken first, fall back to ctag) - const knownCtag = stored[0]?.ctag ?? null - const currentCtag = davCal.ctag ?? null + const knownCtag = stored[0]?.ctag ?? null; + const currentCtag = davCal.ctag ?? null; - if (currentCtag && currentCtag === knownCtag) continue // no change + if (currentCtag && currentCtag === knownCtag) continue; // no change - await syncCalendar(client, davCal, cred.userId) + await syncCalendar(client, davCal, cred.userId); } } -}) +}); ``` **Fetching events (REPORT):** + ```typescript // src/broker/sync.ts -import { DAVCalendar, DAVClient } from 'tsdav' -import ICAL from 'ical.js' -import { db } from '../db/client' -import { calendars, calendarEvents } from '../db/schema' -import { eq } from 'drizzle-orm' +import { DAVCalendar, DAVClient } from 'tsdav'; +import ICAL from 'ical.js'; +import { db } from '../db/client'; +import { calendars, calendarEvents } from '../db/schema'; +import { eq } from 'drizzle-orm'; export async function syncCalendar(client: DAVClient, davCal: DAVCalendar, userId: number) { const objects = await client.fetchCalendarObjects({ calendar: davCal, // No timeRange filter on initial sync — fetch all events for cache - }) + }); // Upsert calendar row - const [cal] = await db.insert(calendars).values({ - userId, - url: davCal.url, - displayName: davCal.displayName ?? '', - ctag: davCal.ctag ?? null, - syncToken: davCal.syncToken ?? null, - lastSyncedAt: new Date(), - }).onDuplicateKeyUpdate({ - set: { + const [cal] = await db + .insert(calendars) + .values({ + userId, + url: davCal.url, + displayName: davCal.displayName ?? '', ctag: davCal.ctag ?? null, syncToken: davCal.syncToken ?? null, lastSyncedAt: new Date(), - } - }) + }) + .onDuplicateKeyUpdate({ + set: { + ctag: davCal.ctag ?? null, + syncToken: davCal.syncToken ?? null, + lastSyncedAt: new Date(), + }, + }); // Parse and upsert each VEVENT for (const obj of objects) { - if (!obj.data) continue - const parsed = ICAL.parse(obj.data) - const comp = new ICAL.Component(parsed) - const vevent = comp.getFirstSubcomponent('vevent') - if (!vevent) continue + if (!obj.data) continue; + const parsed = ICAL.parse(obj.data); + const comp = new ICAL.Component(parsed); + const vevent = comp.getFirstSubcomponent('vevent'); + if (!vevent) continue; - const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time - const uid = vevent.getFirstPropertyValue('uid') as string - const allDay = dtstart?.isDate ?? false + const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time; + const uid = vevent.getFirstPropertyValue('uid') as string; + const allDay = dtstart?.isDate ?? false; - await db.insert(calendarEvents).values({ - calendarId: cal.id, // requires knowing the DB calendar id - uid, - etag: obj.etag ?? null, - rawVevent: obj.data, - dtstartUtc: allDay ? null : dtstart?.toJSDate(), - dtstartDate: allDay ? dtstart?.toString().slice(0, 10) : null, // 'YYYY-MM-DD' - allDay, - }).onDuplicateKeyUpdate({ - set: { + await db + .insert(calendarEvents) + .values({ + calendarId: cal.id, // requires knowing the DB calendar id + uid, etag: obj.etag ?? null, rawVevent: obj.data, dtstartUtc: allDay ? null : dtstart?.toJSDate(), - dtstartDate: allDay ? dtstart?.toString().slice(0, 10) : null, + dtstartDate: allDay ? dtstart?.toString().slice(0, 10) : null, // 'YYYY-MM-DD' allDay, - updatedAt: new Date(), - } - }) + }) + .onDuplicateKeyUpdate({ + set: { + etag: obj.etag ?? null, + rawVevent: obj.data, + dtstartUtc: allDay ? null : dtstart?.toJSDate(), + dtstartDate: allDay ? dtstart?.toString().slice(0, 10) : null, + allDay, + updatedAt: new Date(), + }, + }); } } ``` @@ -568,49 +618,43 @@ export async function syncCalendar(client: DAVClient, davCal: DAVCalendar, userI ```typescript // src/broker/crypto.ts // Source: Node.js docs node:crypto — AES-GCM [ASSUMED pattern; verified Node 22 has crypto.subtle + node:crypto] -import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto' +import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'; -const KEY_HEX = process.env.APP_PASSWORD_ENCRYPTION_KEY! // 64-char hex = 32 bytes -const KEY = Buffer.from(KEY_HEX, 'hex') +const KEY_HEX = process.env.APP_PASSWORD_ENCRYPTION_KEY!; // 64-char hex = 32 bytes +const KEY = Buffer.from(KEY_HEX, 'hex'); interface EncryptedPayload { - iv: string - authTag: string - ciphertext: string + iv: string; + authTag: string; + ciphertext: string; } export function encryptPassword(plaintext: string): string { - const iv = randomBytes(12) // 96-bit IV for GCM - const cipher = createCipheriv('aes-256-gcm', KEY, iv) - const encrypted = Buffer.concat([ - cipher.update(plaintext, 'utf8'), - cipher.final(), - ]) - const authTag = cipher.getAuthTag() + const iv = randomBytes(12); // 96-bit IV for GCM + const cipher = createCipheriv('aes-256-gcm', KEY, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); const payload: EncryptedPayload = { iv: iv.toString('hex'), authTag: authTag.toString('hex'), ciphertext: encrypted.toString('hex'), - } - return JSON.stringify(payload) + }; + return JSON.stringify(payload); } export function decryptPassword(stored: string): string { - const { iv, authTag, ciphertext } = JSON.parse(stored) as EncryptedPayload - const decipher = createDecipheriv( - 'aes-256-gcm', - KEY, - Buffer.from(iv, 'hex') - ) - decipher.setAuthTag(Buffer.from(authTag, 'hex')) + const { iv, authTag, ciphertext } = JSON.parse(stored) as EncryptedPayload; + const decipher = createDecipheriv('aes-256-gcm', KEY, Buffer.from(iv, 'hex')); + decipher.setAuthTag(Buffer.from(authTag, 'hex')); return Buffer.concat([ decipher.update(Buffer.from(ciphertext, 'hex')), decipher.final(), - ]).toString('utf8') + ]).toString('utf8'); } ``` **Key generation** (run once, store in `.env`): + ```bash node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ``` @@ -622,27 +666,28 @@ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ```typescript // src/routes/sse.ts // Source: https://hono.dev/docs/helpers/streaming -import { Hono } from 'hono' -import { streamSSE } from 'hono/streaming' +import { Hono } from 'hono'; +import { streamSSE } from 'hono/streaming'; -export const sseRouter = new Hono() +export const sseRouter = new Hono(); sseRouter.get('/heartbeat', (c) => { return streamSSE(c, async (stream) => { - let id = 0 + let id = 0; while (!stream.aborted) { await stream.writeSSE({ data: JSON.stringify({ ts: new Date().toISOString(), id }), event: 'heartbeat', id: String(id++), - }) - await stream.sleep(10_000) + }); + await stream.sleep(10_000); } - }) -}) + }); +}); ``` **Smoke test procedure:** + 1. Deploy stack through Pangolin tunnel 2. `curl -N https://familysync.yourdomain.com/api/sse/heartbeat` from an external network (not LAN) 3. Observe heartbeat events arriving every 10s @@ -677,7 +722,7 @@ services: mariadb: condition: service_healthy ports: - - "3000:3000" + - '3000:3000' mariadb: image: mariadb:11 @@ -689,7 +734,7 @@ services: volumes: - mariadb_data:/var/lib/mysql healthcheck: - test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'] interval: 10s timeout: 5s retries: 5 @@ -703,6 +748,7 @@ volumes: ``` **API Dockerfile (Node 22):** + ```dockerfile FROM node:22-alpine WORKDIR /app @@ -731,14 +777,14 @@ CMD ["node", "dist/index.js"] ## Don't Hand-Roll -| Problem | Don't Build | Use Instead | Why | -|---------|-------------|-------------|-----| -| OIDC auth flow + session cookies | Custom PKCE implementation | @hono/oidc-auth | Handles code exchange, PKCE, JWT cookie signing, refresh rotation | -| CalDAV PROPFIND/REPORT XML | Raw fetch + xml parsing | tsdav | WebDAV XML namespace handling is complex; tsdav wraps all of it | -| iCalendar parsing | Custom VCALENDAR regex | ical.js | VTIMEZONE, RDATE, EXDATE, RECURRENCE-ID require RFC 5545 parser | -| DB migrations | Ad-hoc SQL scripts | drizzle-kit generate + migrate | Schema-as-code, repeatable, reversible | -| AES-GCM encryption | Custom cipher | node:crypto AES-256-GCM | Built into Node 22; battle-tested; don't use a custom approach | -| Background scheduling | setInterval | node-cron | Handles missed ticks on startup; cron syntax is clearer | +| Problem | Don't Build | Use Instead | Why | +| -------------------------------- | -------------------------- | ------------------------------ | ----------------------------------------------------------------- | +| OIDC auth flow + session cookies | Custom PKCE implementation | @hono/oidc-auth | Handles code exchange, PKCE, JWT cookie signing, refresh rotation | +| CalDAV PROPFIND/REPORT XML | Raw fetch + xml parsing | tsdav | WebDAV XML namespace handling is complex; tsdav wraps all of it | +| iCalendar parsing | Custom VCALENDAR regex | ical.js | VTIMEZONE, RDATE, EXDATE, RECURRENCE-ID require RFC 5545 parser | +| DB migrations | Ad-hoc SQL scripts | drizzle-kit generate + migrate | Schema-as-code, repeatable, reversible | +| AES-GCM encryption | Custom cipher | node:crypto AES-256-GCM | Built into Node 22; battle-tested; don't use a custom approach | +| Background scheduling | setInterval | node-cron | Handles missed ticks on startup; cron syntax is clearer | **Key insight:** The CalDAV/iCalendar layer is the most deceptive "looks simple, is complex" area. The XML namespace hell alone justifies tsdav. The RFC 5545 edge cases (timezone, all-day, recurrence overrides) alone justify ical.js. @@ -799,26 +845,26 @@ CMD ["node", "dist/index.js"] ```typescript // src/index.ts // Source: https://hono.dev/docs/getting-started/nodejs -import { serve } from '@hono/node-server' -import { serveStatic } from '@hono/node-server/serve-static' -import { Hono } from 'hono' +import { serve } from '@hono/node-server'; +import { serveStatic } from '@hono/node-server/serve-static'; +import { Hono } from 'hono'; -const app = new Hono() +const app = new Hono(); // Serve React PWA build -app.use('/assets/*', serveStatic({ root: './public' })) -app.get('*', serveStatic({ path: './public/index.html' })) +app.use('/assets/*', serveStatic({ root: './public' })); +app.get('*', serveStatic({ path: './public/index.html' })); -serve({ fetch: app.fetch, port: 3000 }) +serve({ fetch: app.fetch, port: 3000 }); ``` ### User upsert with color assignment ```typescript // src/auth/user.ts -import { db } from '../db/client' -import { users } from '../db/schema' -import { and, eq } from 'drizzle-orm' +import { db } from '../db/client'; +import { users } from '../db/schema'; +import { and, eq } from 'drizzle-orm'; // Claude's discretion: accessible palette, round-robin by join order const COLOR_PALETTE = [ @@ -826,28 +872,37 @@ const COLOR_PALETTE = [ '#E8734A', // warm coral (wife) '#5BA85A', // forest green '#9B6DC5', // soft purple -] +]; export async function upsertUser(oidcIss: string, oidcSub: string, displayName?: string) { - const existing = await db.select() + const existing = await db + .select() .from(users) .where(and(eq(users.oidcIss, oidcIss), eq(users.oidcSub, oidcSub))) - .limit(1) + .limit(1); - if (existing[0]) return existing[0] + if (existing[0]) return existing[0]; // Count existing users to assign next color - const [{ count }] = await db.select({ count: sql`COUNT(*)` }).from(users) - const color = COLOR_PALETTE[count % COLOR_PALETTE.length] + const [{ count }] = await db.select({ count: sql`COUNT(*)` }).from(users); + const color = COLOR_PALETTE[count % COLOR_PALETTE.length]; - const [user] = await db.insert(users).values({ - oidcIss, - oidcSub, - displayName: displayName ?? null, - color, - }).$returningId() + const [user] = await db + .insert(users) + .values({ + oidcIss, + oidcSub, + displayName: displayName ?? null, + color, + }) + .$returningId(); - return db.select().from(users).where(eq(users.id, user.id)).limit(1).then(r => r[0]) + return db + .select() + .from(users) + .where(eq(users.id, user.id)) + .limit(1) + .then((r) => r[0]); } ``` @@ -855,44 +910,45 @@ export async function upsertUser(oidcIss: string, oidcSub: string, displayName?: ```typescript // src/index.ts -import { serve } from '@hono/node-server' -import { Hono } from 'hono' -import { oidcAuthMiddleware, processOAuthCallback } from '@hono/oidc-auth' -import { eventsRouter } from './routes/events' -import { meRouter } from './routes/me' -import { sseRouter } from './routes/sse' +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { oidcAuthMiddleware, processOAuthCallback } from '@hono/oidc-auth'; +import { eventsRouter } from './routes/events'; +import { meRouter } from './routes/me'; +import { sseRouter } from './routes/sse'; -const app = new Hono() +const app = new Hono(); // OIDC callback — must be before auth middleware -app.get('/callback', (c) => processOAuthCallback(c)) +app.get('/callback', (c) => processOAuthCallback(c)); // Protected API routes -app.use('/api/*', oidcAuthMiddleware()) -app.route('/api/events', eventsRouter) -app.route('/api/me', meRouter) -app.route('/api/sse', sseRouter) +app.use('/api/*', oidcAuthMiddleware()); +app.route('/api/events', eventsRouter); +app.route('/api/me', meRouter); +app.route('/api/sse', sseRouter); // Start broker polling -import { startBrokerPoller } from './broker/poller' -startBrokerPoller() +import { startBrokerPoller } from './broker/poller'; +startBrokerPoller(); -serve({ fetch: app.fetch, port: 3000 }) +serve({ fetch: app.fetch, port: 3000 }); ``` --- ## State of the Art -| Old Approach | Current Approach | Impact | -|--------------|------------------|--------| -| Knex (as noted in ARCHITECTURE.md) | Drizzle ORM (locked in CLAUDE.md) | Better TypeScript inference; no binary engine; use Drizzle exclusively | -| iframe silent OIDC renewal | Refresh token rotation via backend confidential client | ITP-safe; no iframe; @hono/oidc-auth implements this by default | -| WebSocket for SSE-style events | SSE via `streamSSE` | More proxy-resilient; Pangolin WS is known-broken | -| node-cron v3 | node-cron v4 (4.2.1) | Same API for basic use; v4 is current | -| Fastify (mentioned in ARCHITECTURE.md) | Hono (locked in CLAUDE.md) | Use Hono; ARCHITECTURE.md mentions Fastify but CLAUDE.md locks Hono | +| Old Approach | Current Approach | Impact | +| -------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------- | +| Knex (as noted in ARCHITECTURE.md) | Drizzle ORM (locked in CLAUDE.md) | Better TypeScript inference; no binary engine; use Drizzle exclusively | +| iframe silent OIDC renewal | Refresh token rotation via backend confidential client | ITP-safe; no iframe; @hono/oidc-auth implements this by default | +| WebSocket for SSE-style events | SSE via `streamSSE` | More proxy-resilient; Pangolin WS is known-broken | +| node-cron v3 | node-cron v4 (4.2.1) | Same API for basic use; v4 is current | +| Fastify (mentioned in ARCHITECTURE.md) | Hono (locked in CLAUDE.md) | Use Hono; ARCHITECTURE.md mentions Fastify but CLAUDE.md locks Hono | **Deprecated/outdated in this codebase:** + - Knex: mentioned in ARCHITECTURE.md as DB layer, but CLAUDE.md locks Drizzle. Use Drizzle exclusively. Do not add Knex. - Fastify: mentioned in ARCHITECTURE.md component table as "Node/Express or Fastify," but CLAUDE.md locks Hono. Use Hono. - `@hono/node-ws` deprecated: Hono docs note this is deprecated; use `upgradeWebSocket` from `@hono/node-server` directly. Phase 1 does not need WebSocket at all. @@ -902,16 +958,16 @@ serve({ fetch: app.fetch, port: 3000 }) ## Assumptions Log -| # | Claim | Section | Risk if Wrong | -|---|-------|---------|---------------| -| A1 | Fastmail PROPFIND returns `ctag` and/or `syncToken` fields on calendar collections | Broker pattern, Pitfall 6 | Broker falls back to full-sync every poll; higher Fastmail API load but functional | -| A2 | tsdav `DAVCalendar.ctag` field name maps to Fastmail's `getctag` PROPFIND property | Broker pattern | Must check actual field name in returned object at spike time; easy to correct | -| A3 | Fastmail app password reads both personal AND shared calendars via PROPFIND of the principal | CAL-08 spike | If personal calendar not returned: re-share step needed or fallback to shared-only | -| A4 | ical.js `ICAL.Time.isDate` correctly identifies all-day events parsed from tsdav-returned strings | Broker sync pattern | If isDate is wrong: all-day events stored as timed events; DATE vs DATETIME bug manifests | -| A5 | MariaDB `onDuplicateKeyUpdate` works with Drizzle on the `calendarId + uid` composite constraint | Schema pattern | Use `eq` filter + separate upsert logic if this fails | -| A6 | node-cron v4 `schedule()` API is backward-compatible with v3 for the basic 5-field cron string | Poller pattern | Downgrade to v3 if v4 has breaking change in basic usage | -| A7 | `OIDC_AUTH_EXTERNAL_URL` resolves the redirect_uri construction behind Pangolin | OIDC middleware | If it doesn't: need to set `OIDC_REDIRECT_URI` explicitly instead | -| A8 | vite-plugin-pwa@1.3.0 is compatible with vite@8.0.16 | Standard Stack | Check release notes if PWA plugin fails to load | +| # | Claim | Section | Risk if Wrong | +| --- | ------------------------------------------------------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------- | +| A1 | Fastmail PROPFIND returns `ctag` and/or `syncToken` fields on calendar collections | Broker pattern, Pitfall 6 | Broker falls back to full-sync every poll; higher Fastmail API load but functional | +| A2 | tsdav `DAVCalendar.ctag` field name maps to Fastmail's `getctag` PROPFIND property | Broker pattern | Must check actual field name in returned object at spike time; easy to correct | +| A3 | Fastmail app password reads both personal AND shared calendars via PROPFIND of the principal | CAL-08 spike | If personal calendar not returned: re-share step needed or fallback to shared-only | +| A4 | ical.js `ICAL.Time.isDate` correctly identifies all-day events parsed from tsdav-returned strings | Broker sync pattern | If isDate is wrong: all-day events stored as timed events; DATE vs DATETIME bug manifests | +| A5 | MariaDB `onDuplicateKeyUpdate` works with Drizzle on the `calendarId + uid` composite constraint | Schema pattern | Use `eq` filter + separate upsert logic if this fails | +| A6 | node-cron v4 `schedule()` API is backward-compatible with v3 for the basic 5-field cron string | Poller pattern | Downgrade to v3 if v4 has breaking change in basic usage | +| A7 | `OIDC_AUTH_EXTERNAL_URL` resolves the redirect_uri construction behind Pangolin | OIDC middleware | If it doesn't: need to set `OIDC_REDIRECT_URI` explicitly instead | +| A8 | vite-plugin-pwa@1.3.0 is compatible with vite@8.0.16 | Standard Stack | Check release notes if PWA plugin fails to load | --- @@ -941,21 +997,22 @@ serve({ fetch: app.fetch, port: 3000 }) ## Environment Availability -| Dependency | Required By | Available | Version | Fallback | -|------------|------------|-----------|---------|----------| -| Node.js | Backend runtime | ✓ | v20.20.2 (dev machine) | — | -| npm | Package manager | ✓ | 10.8.2 | — | -| Docker | Container runtime | ✓ | 29.3.1 | — | -| Docker Compose | Stack orchestration | ✓ | v5.1.1 | — | -| MariaDB (in Docker) | Database | ✓ via image | mariadb:11 | — | -| Redis (in Docker) | Pub/sub (Phase 4) | ✓ via image | redis:7-alpine | In-process EventEmitter | -| Node 22 (in Docker) | Production runtime | ✓ via image | node:22-alpine | — | -| MariaDB client (local) | Local migration testing | ✗ | — | Run migrations inside Docker container | -| Redis CLI (local) | Local Redis debugging | ✗ | — | Redis CLI inside Docker: `docker exec -it redis-cli` | +| Dependency | Required By | Available | Version | Fallback | +| ---------------------- | ----------------------- | ----------- | ---------------------- | ---------------------------------------------------------------- | +| Node.js | Backend runtime | ✓ | v20.20.2 (dev machine) | — | +| npm | Package manager | ✓ | 10.8.2 | — | +| Docker | Container runtime | ✓ | 29.3.1 | — | +| Docker Compose | Stack orchestration | ✓ | v5.1.1 | — | +| MariaDB (in Docker) | Database | ✓ via image | mariadb:11 | — | +| Redis (in Docker) | Pub/sub (Phase 4) | ✓ via image | redis:7-alpine | In-process EventEmitter | +| Node 22 (in Docker) | Production runtime | ✓ via image | node:22-alpine | — | +| MariaDB client (local) | Local migration testing | ✗ | — | Run migrations inside Docker container | +| Redis CLI (local) | Local Redis debugging | ✗ | — | Redis CLI inside Docker: `docker exec -it redis-cli` | > **Dev machine note:** Local Node is v20.20.2, but Docker runs node:22-alpine. Migrations and broker spike can be run inside Docker. TypeScript `target: ES2023` covers both. **Missing dependencies with fallback:** + - Local MariaDB client: use `docker exec -it familysync-mariadb-1 mariadb -u familysync -p familysync` for local testing - Local Redis CLI: use Docker exec @@ -967,31 +1024,31 @@ serve({ fetch: app.fetch, port: 3000 }) ### Test Framework -| Property | Value | -|----------|-------| -| Framework | Vitest 4.1.8 | -| Config file | `apps/api/vitest.config.ts` — Wave 0 gap | -| Quick run command | `vitest run --reporter=verbose` | -| Full suite command | `vitest run` | +| Property | Value | +| ------------------ | ---------------------------------------- | +| Framework | Vitest 4.1.8 | +| Config file | `apps/api/vitest.config.ts` — Wave 0 gap | +| Quick run command | `vitest run --reporter=verbose` | +| Full suite command | `vitest run` | ### Phase Requirements → Test Map -| Req ID | Behavior | Test Type | Automated Command | File Exists? | -|--------|----------|-----------|-------------------|-------------| -| AUTH-01 | OIDC redirect flow sends unauthenticated request to Authelia | integration (manual) | — | Manual: browser test against real Authelia | -| AUTH-01 | Callback route upserts user on first login | unit | `vitest run --reporter=verbose tests/auth/user.test.ts` | ❌ Wave 0 | -| AUTH-02 | Session persists: /api/me returns 200 with valid JWT cookie after access token expiry window | integration (manual) | — | Manual: wait for token expiry, verify no redirect | -| AUTH-02 | Refresh interval config: middleware does not make unnecessary token requests | unit (middleware mock) | `vitest run tests/auth/middleware.test.ts` | ❌ Wave 0 | -| AUTH-03 | First login assigns color from palette; second login for same oidcSub returns same color | unit | `vitest run tests/auth/user.test.ts` | ❌ Wave 0 | -| AUTH-03 | Two different oidcSub users get distinct colors | unit | `vitest run tests/auth/user.test.ts` | ❌ Wave 0 | -| CAL-01 | Broker fetchCalendars returns at least one calendar from Fastmail | integration spike (manual) | — | Manual: run broker spike script against real Fastmail | -| CAL-01 | syncCalendar upserts events with correct allDay + dtstart fields | unit (mock tsdav) | `vitest run tests/broker/sync.test.ts` | ❌ Wave 0 | -| CAL-01 | All-day event stored with dtstart_date (DATE) not dtstart_utc | unit | `vitest run tests/broker/sync.test.ts` | ❌ Wave 0 | -| CAL-01 | ctag-poll: no DB write when ctag unchanged | unit | `vitest run tests/broker/poller.test.ts` | ❌ Wave 0 | -| CAL-08 | Spike script returns ≥1 personal calendar URL for Lucas | manual spike | — | Manual: node spike script | -| AES-GCM | encryptPassword + decryptPassword roundtrip is lossless | unit | `vitest run tests/broker/crypto.test.ts` | ❌ Wave 0 | -| AES-GCM | Different IVs produce different ciphertext for same plaintext | unit | `vitest run tests/broker/crypto.test.ts` | ❌ Wave 0 | -| SSE | /api/sse/heartbeat keeps stream alive and sends events | integration (manual) | — | Manual: `curl -N ` for 5+ minutes | +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +| ------- | -------------------------------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------- | ----------------------------------------------------- | +| AUTH-01 | OIDC redirect flow sends unauthenticated request to Authelia | integration (manual) | — | Manual: browser test against real Authelia | +| AUTH-01 | Callback route upserts user on first login | unit | `vitest run --reporter=verbose tests/auth/user.test.ts` | ❌ Wave 0 | +| AUTH-02 | Session persists: /api/me returns 200 with valid JWT cookie after access token expiry window | integration (manual) | — | Manual: wait for token expiry, verify no redirect | +| AUTH-02 | Refresh interval config: middleware does not make unnecessary token requests | unit (middleware mock) | `vitest run tests/auth/middleware.test.ts` | ❌ Wave 0 | +| AUTH-03 | First login assigns color from palette; second login for same oidcSub returns same color | unit | `vitest run tests/auth/user.test.ts` | ❌ Wave 0 | +| AUTH-03 | Two different oidcSub users get distinct colors | unit | `vitest run tests/auth/user.test.ts` | ❌ Wave 0 | +| CAL-01 | Broker fetchCalendars returns at least one calendar from Fastmail | integration spike (manual) | — | Manual: run broker spike script against real Fastmail | +| CAL-01 | syncCalendar upserts events with correct allDay + dtstart fields | unit (mock tsdav) | `vitest run tests/broker/sync.test.ts` | ❌ Wave 0 | +| CAL-01 | All-day event stored with dtstart_date (DATE) not dtstart_utc | unit | `vitest run tests/broker/sync.test.ts` | ❌ Wave 0 | +| CAL-01 | ctag-poll: no DB write when ctag unchanged | unit | `vitest run tests/broker/poller.test.ts` | ❌ Wave 0 | +| CAL-08 | Spike script returns ≥1 personal calendar URL for Lucas | manual spike | — | Manual: node spike script | +| AES-GCM | encryptPassword + decryptPassword roundtrip is lossless | unit | `vitest run tests/broker/crypto.test.ts` | ❌ Wave 0 | +| AES-GCM | Different IVs produce different ciphertext for same plaintext | unit | `vitest run tests/broker/crypto.test.ts` | ❌ Wave 0 | +| SSE | /api/sse/heartbeat keeps stream alive and sends events | integration (manual) | — | Manual: `curl -N ` for 5+ minutes | ### Sampling Rate @@ -1009,15 +1066,16 @@ serve({ fetch: app.fetch, port: 3000 }) - [ ] `apps/api/tests/helpers/db.ts` — in-memory or test-DB fixtures for Drizzle **Vitest config for Node backend:** + ```typescript // apps/api/vitest.config.ts -import { defineConfig } from 'vitest/config' +import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', globals: true, }, -}) +}); ``` --- @@ -1028,25 +1086,25 @@ export default defineConfig({ ### Applicable ASVS Categories (Level 1) -| ASVS Category | Applies | Standard Control | -|---------------|---------|-----------------| -| V2 Authentication | Yes | @hono/oidc-auth — Authelia handles credential validation; app verifies JWT cookie signature | -| V3 Session Management | Yes | @hono/oidc-auth JWT cookie: httpOnly, Secure, SameSite; refresh token rotation | -| V4 Access Control | Yes | All /api routes behind oidcAuthMiddleware(); no guest access | -| V5 Input Validation | Yes | zod + @hono/zod-validator on any route accepting body/query params | -| V6 Cryptography | Yes | node:crypto AES-256-GCM for app passwords; never hand-roll cipher logic | +| ASVS Category | Applies | Standard Control | +| --------------------- | ------- | ------------------------------------------------------------------------------------------- | +| V2 Authentication | Yes | @hono/oidc-auth — Authelia handles credential validation; app verifies JWT cookie signature | +| V3 Session Management | Yes | @hono/oidc-auth JWT cookie: httpOnly, Secure, SameSite; refresh token rotation | +| V4 Access Control | Yes | All /api routes behind oidcAuthMiddleware(); no guest access | +| V5 Input Validation | Yes | zod + @hono/zod-validator on any route accepting body/query params | +| V6 Cryptography | Yes | node:crypto AES-256-GCM for app passwords; never hand-roll cipher logic | ### Known Threat Patterns for This Stack -| Pattern | STRIDE | Standard Mitigation | -|---------|--------|---------------------| -| Fastmail app password exposed in response | Information Disclosure | Encrypted in DB; broker module is the only reader; API routes never return credential data | -| OIDC redirect_uri manipulation | Spoofing | Authelia validates exact match; `OIDC_REDIRECT_URI` env var must match Authelia config | -| CSRF on callback route | Spoofing | @hono/oidc-auth uses PKCE (state + code_verifier) to validate the callback | -| JWT cookie tampering | Tampering | Cookie is signed with `OIDC_AUTH_SECRET`; @hono/oidc-auth verifies signature on every request | -| AES key exposure via env | Information Disclosure | `APP_PASSWORD_ENCRYPTION_KEY` never logged; .env not committed to git | -| Unencrypted CalDAV credentials at rest | Information Disclosure | AES-256-GCM with 96-bit IV and auth tag; implementation in crypto.ts | -| SSE endpoint accessible without auth | Elevation of Privilege | Mount SSE under /api/* which is behind oidcAuthMiddleware() | +| Pattern | STRIDE | Standard Mitigation | +| ----------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------- | +| Fastmail app password exposed in response | Information Disclosure | Encrypted in DB; broker module is the only reader; API routes never return credential data | +| OIDC redirect_uri manipulation | Spoofing | Authelia validates exact match; `OIDC_REDIRECT_URI` env var must match Authelia config | +| CSRF on callback route | Spoofing | @hono/oidc-auth uses PKCE (state + code_verifier) to validate the callback | +| JWT cookie tampering | Tampering | Cookie is signed with `OIDC_AUTH_SECRET`; @hono/oidc-auth verifies signature on every request | +| AES key exposure via env | Information Disclosure | `APP_PASSWORD_ENCRYPTION_KEY` never logged; .env not committed to git | +| Unencrypted CalDAV credentials at rest | Information Disclosure | AES-256-GCM with 96-bit IV and auth tag; implementation in crypto.ts | +| SSE endpoint accessible without auth | Elevation of Privilege | Mount SSE under /api/\* which is behind oidcAuthMiddleware() | --- @@ -1081,6 +1139,7 @@ export default defineConfig({ ## Metadata **Confidence breakdown:** + - Standard stack: HIGH — all packages verified on npm with authoritative GitHub repos and official docs - Architecture: HIGH — patterns directly derived from locked decisions + verified library docs - Authelia OIDC wiring: HIGH — @hono/oidc-auth README + Authelia official client config docs diff --git a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-VALIDATION.md b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-VALIDATION.md index 770e84f..32a92d0 100644 --- a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-VALIDATION.md +++ b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-VALIDATION.md @@ -15,13 +15,13 @@ created: 2026-06-04 ## Test Infrastructure -| Property | Value | -|----------|-------| -| **Framework** | vitest (Vite-native, shared backend + frontend per CLAUDE.md) | -| **Config file** | `apps/api/vitest.config.ts` (environment: node) — created Plan 01 Task 1 (Wave 0) | -| **Quick run command** | `pnpm vitest run --reporter=dot` | -| **Full suite command** | `pnpm vitest run` | -| **Estimated runtime** | ~30 seconds | +| Property | Value | +| ---------------------- | --------------------------------------------------------------------------------- | +| **Framework** | vitest (Vite-native, shared backend + frontend per CLAUDE.md) | +| **Config file** | `apps/api/vitest.config.ts` (environment: node) — created Plan 01 Task 1 (Wave 0) | +| **Quick run command** | `pnpm vitest run --reporter=dot` | +| **Full suite command** | `pnpm vitest run` | +| **Estimated runtime** | ~30 seconds | --- @@ -38,16 +38,16 @@ created: 2026-06-04 > Mapped to final task IDs. Each phase success criterion maps to at least one automated or manual verification. -| Success Criterion | Requirement | Plan/Task | Verification approach | Test Type | -|-------------------|-------------|-----------|-----------------------|-----------| -| SC1 — OIDC login lands on home, no Fastmail creds | AUTH-01 | 01-02 Task 2 (wiring) + 01-04 Task 3 (live) | Middleware mounted on /api/*, /callback wired (tsc/grep); live: unauth /api/me 302→Authelia, login lands on shell | integration + manual (real Authelia/Pangolin) | -| SC2 — Sessions persist across browser restart | AUTH-02 | 01-02 Task 2 + 01-04 Task 3 (live) | Backend refresh-token rotation configured (no iframe); live: close browser, revisit, no re-login | integration + manual | -| SC3 — Stable distinct member color | AUTH-03 | 01-02 Task 1 (unit) + 01-04 Task 3 (2nd member, live) | Unit: round-robin palette by join order, idempotent re-upsert, identity by iss+sub (`tests/auth/user.test.ts`); live: 2nd member distinct color | unit + manual | -| SC4 — Broker fetches + caches ≥1 real event | CAL-01 | 01-03 Task 2/3 (unit) + 01-04 Task 2 (live spike) | Unit: sync all-day/timed split + UID upsert (`tests/broker/sync.test.ts`), ctag skip (`tests/broker/poller.test.ts`), crypto roundtrip (`tests/broker/crypto.test.ts`); live: ≥1 event row cached + shown on landing page | unit + manual (real Fastmail) | -| SC5 — CAL-08 go/no-go documented | CAL-08 | 01-04 Task 2 | Manual spike: app password reads shared + personal collections; `CAL-08-DECISION.md` committed with `Decision: GO|NO-GO` + fallback | manual (decision artifact) | -| Bonus — Pangolin SSE pass-through | D-08 (de-risks Phase 4) | 01-04 Task 1 (endpoint) + Task 3 (smoke) | `/api/sse/heartbeat` via streamSSE (tsc/grep); live: stream alive 5+ min over public URL, PASS/FAIL recorded | integration + manual | +| Success Criterion | Requirement | Plan/Task | Verification approach | Test Type | +| ------------------------------------------------- | ----------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -------------------------- | +| SC1 — OIDC login lands on home, no Fastmail creds | AUTH-01 | 01-02 Task 2 (wiring) + 01-04 Task 3 (live) | Middleware mounted on /api/\*, /callback wired (tsc/grep); live: unauth /api/me 302→Authelia, login lands on shell | integration + manual (real Authelia/Pangolin) | +| SC2 — Sessions persist across browser restart | AUTH-02 | 01-02 Task 2 + 01-04 Task 3 (live) | Backend refresh-token rotation configured (no iframe); live: close browser, revisit, no re-login | integration + manual | +| SC3 — Stable distinct member color | AUTH-03 | 01-02 Task 1 (unit) + 01-04 Task 3 (2nd member, live) | Unit: round-robin palette by join order, idempotent re-upsert, identity by iss+sub (`tests/auth/user.test.ts`); live: 2nd member distinct color | unit + manual | +| SC4 — Broker fetches + caches ≥1 real event | CAL-01 | 01-03 Task 2/3 (unit) + 01-04 Task 2 (live spike) | Unit: sync all-day/timed split + UID upsert (`tests/broker/sync.test.ts`), ctag skip (`tests/broker/poller.test.ts`), crypto roundtrip (`tests/broker/crypto.test.ts`); live: ≥1 event row cached + shown on landing page | unit + manual (real Fastmail) | +| SC5 — CAL-08 go/no-go documented | CAL-08 | 01-04 Task 2 | Manual spike: app password reads shared + personal collections; `CAL-08-DECISION.md` committed with `Decision: GO | NO-GO` + fallback | manual (decision artifact) | +| Bonus — Pangolin SSE pass-through | D-08 (de-risks Phase 4) | 01-04 Task 1 (endpoint) + Task 3 (smoke) | `/api/sse/heartbeat` via streamSSE (tsc/grep); live: stream alive 5+ min over public URL, PASS/FAIL recorded | integration + manual | -*Status legend: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* +_Status legend: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky_ --- @@ -66,14 +66,14 @@ created: 2026-06-04 ## Manual-Only Verifications -| Behavior | Requirement | Why Manual | Plan/Task | -|----------|-------------|------------|-----------| -| Authelia OIDC end-to-end login (both members) | AUTH-01 | Requires real Authelia + Pangolin topology | 01-04 Task 3 | -| Session persistence across restart | AUTH-02 | Browser-restart behavior not unit-testable | 01-04 Task 3 | -| Real Fastmail event fetch + cache | CAL-01 | Requires real app password + live calendar | 01-04 Task 2 | -| CAL-08 personal-calendar ACL spike | CAL-08 | Spike produces a human go/no-go judgement | 01-04 Task 2 | -| Pangolin SSE pass-through smoke test | D-08 | Idle-timeout behavior only observable over real public URL | 01-04 Task 3 | -| drizzle-kit push against live MariaDB | CAL-01 (schema) | Live DB apply; [BLOCKING] gate | 01-01 Task 3 | +| Behavior | Requirement | Why Manual | Plan/Task | +| --------------------------------------------- | --------------- | ---------------------------------------------------------- | ------------ | +| Authelia OIDC end-to-end login (both members) | AUTH-01 | Requires real Authelia + Pangolin topology | 01-04 Task 3 | +| Session persistence across restart | AUTH-02 | Browser-restart behavior not unit-testable | 01-04 Task 3 | +| Real Fastmail event fetch + cache | CAL-01 | Requires real app password + live calendar | 01-04 Task 2 | +| CAL-08 personal-calendar ACL spike | CAL-08 | Spike produces a human go/no-go judgement | 01-04 Task 2 | +| Pangolin SSE pass-through smoke test | D-08 | Idle-timeout behavior only observable over real public URL | 01-04 Task 3 | +| drizzle-kit push against live MariaDB | CAL-01 (schema) | Live DB apply; [BLOCKING] gate | 01-01 Task 3 | --- diff --git a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-VERIFICATION.md b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-VERIFICATION.md index 0a5cc19..f028bd7 100644 --- a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-VERIFICATION.md +++ b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-VERIFICATION.md @@ -5,18 +5,18 @@ status: human_needed score: 10/13 must-haves verified (3 human-pending) overrides_applied: 0 human_verification: - - test: "AUTH-01: Open https://familysync. from an external network, confirm redirect to Authelia authorize endpoint, log in, land on shell with name + color — no Fastmail credential prompt" - expected: "Browser redirects to Authelia, login succeeds, PWA shell renders member name and color swatch (MemberBadge component)" - why_human: "Requires live Authelia + Pangolin infrastructure not available in this environment; oidcAuthMiddleware only validates at runtime against a real OIDC issuer" - - test: "AUTH-02: Close browser completely after step above, reopen the public URL, confirm no re-login required" - expected: "Session cookie persists; /api/me still returns 200 without re-authenticating; access-token refresh rotation has kept the session alive" - why_human: "Session persistence is enforced by @hono/oidc-auth refresh-token rotation at runtime; cannot verify without real Authelia token endpoint" - - test: "AUTH-03 (cross-member): Log in as the second member (wife) over the same public URL, confirm she receives a visually distinct color from Lucas" + - test: 'AUTH-01: Open https://familysync. from an external network, confirm redirect to Authelia authorize endpoint, log in, land on shell with name + color — no Fastmail credential prompt' + expected: 'Browser redirects to Authelia, login succeeds, PWA shell renders member name and color swatch (MemberBadge component)' + why_human: 'Requires live Authelia + Pangolin infrastructure not available in this environment; oidcAuthMiddleware only validates at runtime against a real OIDC issuer' + - test: 'AUTH-02: Close browser completely after step above, reopen the public URL, confirm no re-login required' + expected: 'Session cookie persists; /api/me still returns 200 without re-authenticating; access-token refresh rotation has kept the session alive' + why_human: 'Session persistence is enforced by @hono/oidc-auth refresh-token rotation at runtime; cannot verify without real Authelia token endpoint' + - test: 'AUTH-03 (cross-member): Log in as the second member (wife) over the same public URL, confirm she receives a visually distinct color from Lucas' expected: "palette[1] (#E8734A warm coral) assigned; both members' MemberBadge components show different colors; identity row for second member present in users table" - why_human: "Requires a second live Authelia account and real browser session; unit tests verify color assignment logic but not end-to-end identity creation for both members" + why_human: 'Requires a second live Authelia account and real browser session; unit tests verify color assignment logic but not end-to-end identity creation for both members' - test: "SSE smoke test (D-08): From external network with a valid session cookie, run 'curl -N https://familysync./api/sse/heartbeat' for 5+ minutes, confirm heartbeat events keep arriving" - expected: "Server-sent events arrive every 10 seconds without proxy timeout; stream stays alive through the Pangolin/Newt tunnel for Phase 4 transport decision" - why_human: "Pangolin idle-timeout behavior is network-infrastructure-dependent; only observable over the real tunnel" + expected: 'Server-sent events arrive every 10 seconds without proxy timeout; stream stays alive through the Pangolin/Newt tunnel for Phase 4 transport decision' + why_human: 'Pangolin idle-timeout behavior is network-infrastructure-dependent; only observable over the real tunnel' --- # Phase 01: Foundation + Broker Spike — Verification Report @@ -32,21 +32,21 @@ human_verification: ### Observable Truths -| # | Truth | Status | Evidence | -|---|-------|--------|----------| -| 1 | Docker Compose stack starts MariaDB healthy and API serving | VERIFIED | docker-compose.yml has mariadb:11 with healthcheck; api depends_on service_healthy; 01-01-SUMMARY confirms `/health` returned `{"ok":true,"db":"up"}` live | -| 2 | GET /health returns 200 with real DB round-trip | VERIFIED | `apps/api/src/routes/health.ts` executes `db.execute(sql'SELECT 1')` before returning `{ok:true,db:"up"}`; health.test.ts 2/2 green | -| 3 | Drizzle schema pushed to live MariaDB (4 tables) | VERIFIED | 01-01-SUMMARY records drizzle-kit push clearing Task 3 checkpoint; all 4 tables listed in SHOW TABLES output | -| 4 | React PWA fetches /health and renders result | VERIFIED | `apps/pwa/src/App.tsx` uses `useQuery({queryKey:['health'],queryFn:fetchHealth})` and renders "stack: up/down" | -| 5 | OIDC middleware guards /api/* and redirects unauthenticated requests (AUTH-01 code path) | VERIFIED | `apps/api/src/index.ts` line 24: `app.use('/api/*', oidcAuthMiddleware())`; /callback registered before guard; /health before guard | -| 6 | upsertUser keyed on oidc_iss + oidc_sub with stable color assignment (AUTH-03) | VERIFIED | `apps/api/src/auth/user.ts` SELECT by `and(eq(users.oidcIss,...),eq(users.oidcSub,...))`, never email; COLOR_PALETTE 6 entries; user.test.ts 6/6 green | -| 7 | GET /api/me returns authenticated member identity + color | VERIFIED | `apps/api/src/routes/me.ts` calls getAuth → upsertUser → returns `{user:{id,displayName,color}}`; mounted behind oidcAuthMiddleware | -| 8 | AES-256-GCM app-password encryption: lossless roundtrip, unique IVs, tamper detection | VERIFIED | `apps/api/src/broker/crypto.ts` uses node:crypto aes-256-gcm, 96-bit random IV; crypto.test.ts 5/5 green (roundtrip, IV uniqueness, tamper-throws x2, payload shape) | -| 9 | CalDAV broker reads Fastmail calendars and caches VEVENTs with D-13 all-day DATE handling | VERIFIED | CAL-08-DECISION.md records live REPORT against Fastmail fetching 503 real events; sync.test.ts 6/6 green confirming timed→dtstart_utc, all-day→dtstart_date, onDuplicateKeyUpdate idempotency | -| 10 | Broker poller skips DB writes when ctag unchanged | VERIFIED | `apps/api/src/broker/poller.ts` lines 59-61: skip when `currentCtag !== null && currentCtag === knownCtag`; poller.test.ts 5/5 green | -| 11 | CAL-08 go/no-go decision recorded | VERIFIED | `CAL-08-DECISION.md` present, committed (0b074cd), contains "Decision: GO" — per-member app-password model proven live; 503 events cached; cross-account ACL concern resolved as moot | -| 12 | Both members authenticate live through Authelia over Pangolin (AUTH-01/02) | HUMAN-PENDING | Code wired (oidcAuthMiddleware, processOAuthCallback, OIDC env vars); live verification requires real Authelia + Pangolin infrastructure | -| 13 | Both members have distinct stable colors confirmed in real browser (AUTH-03 cross-member) | HUMAN-PENDING | upsertUser unit-tested for color assignment; live cross-member test requires two real OIDC sessions | +| # | Truth | Status | Evidence | +| --- | ----------------------------------------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Docker Compose stack starts MariaDB healthy and API serving | VERIFIED | docker-compose.yml has mariadb:11 with healthcheck; api depends_on service_healthy; 01-01-SUMMARY confirms `/health` returned `{"ok":true,"db":"up"}` live | +| 2 | GET /health returns 200 with real DB round-trip | VERIFIED | `apps/api/src/routes/health.ts` executes `db.execute(sql'SELECT 1')` before returning `{ok:true,db:"up"}`; health.test.ts 2/2 green | +| 3 | Drizzle schema pushed to live MariaDB (4 tables) | VERIFIED | 01-01-SUMMARY records drizzle-kit push clearing Task 3 checkpoint; all 4 tables listed in SHOW TABLES output | +| 4 | React PWA fetches /health and renders result | VERIFIED | `apps/pwa/src/App.tsx` uses `useQuery({queryKey:['health'],queryFn:fetchHealth})` and renders "stack: up/down" | +| 5 | OIDC middleware guards /api/\* and redirects unauthenticated requests (AUTH-01 code path) | VERIFIED | `apps/api/src/index.ts` line 24: `app.use('/api/*', oidcAuthMiddleware())`; /callback registered before guard; /health before guard | +| 6 | upsertUser keyed on oidc_iss + oidc_sub with stable color assignment (AUTH-03) | VERIFIED | `apps/api/src/auth/user.ts` SELECT by `and(eq(users.oidcIss,...),eq(users.oidcSub,...))`, never email; COLOR_PALETTE 6 entries; user.test.ts 6/6 green | +| 7 | GET /api/me returns authenticated member identity + color | VERIFIED | `apps/api/src/routes/me.ts` calls getAuth → upsertUser → returns `{user:{id,displayName,color}}`; mounted behind oidcAuthMiddleware | +| 8 | AES-256-GCM app-password encryption: lossless roundtrip, unique IVs, tamper detection | VERIFIED | `apps/api/src/broker/crypto.ts` uses node:crypto aes-256-gcm, 96-bit random IV; crypto.test.ts 5/5 green (roundtrip, IV uniqueness, tamper-throws x2, payload shape) | +| 9 | CalDAV broker reads Fastmail calendars and caches VEVENTs with D-13 all-day DATE handling | VERIFIED | CAL-08-DECISION.md records live REPORT against Fastmail fetching 503 real events; sync.test.ts 6/6 green confirming timed→dtstart_utc, all-day→dtstart_date, onDuplicateKeyUpdate idempotency | +| 10 | Broker poller skips DB writes when ctag unchanged | VERIFIED | `apps/api/src/broker/poller.ts` lines 59-61: skip when `currentCtag !== null && currentCtag === knownCtag`; poller.test.ts 5/5 green | +| 11 | CAL-08 go/no-go decision recorded | VERIFIED | `CAL-08-DECISION.md` present, committed (0b074cd), contains "Decision: GO" — per-member app-password model proven live; 503 events cached; cross-account ACL concern resolved as moot | +| 12 | Both members authenticate live through Authelia over Pangolin (AUTH-01/02) | HUMAN-PENDING | Code wired (oidcAuthMiddleware, processOAuthCallback, OIDC env vars); live verification requires real Authelia + Pangolin infrastructure | +| 13 | Both members have distinct stable colors confirmed in real browser (AUTH-03 cross-member) | HUMAN-PENDING | upsertUser unit-tested for color assignment; live cross-member test requires two real OIDC sessions | **Score:** 11/13 truths verified (2 human-pending, counted as HUMAN-PENDING not FAILED; see requirements section for SSE smoke test) @@ -54,30 +54,30 @@ human_verification: ### Required Artifacts -| Artifact | Expected | Status | Details | -|----------|----------|--------|---------| -| `apps/api/src/db/schema.ts` | 4 mysqlTable defs, D-13 dtstart split, D-10 iss+sub unique key | VERIFIED | All 4 tables; unique('uniq_oidc_identity').on(oidcIss,oidcSub); separate dtstart_utc (timestamp) + dtstart_date (date) + allDay boolean | -| `apps/api/src/db/client.ts` | drizzle(mysql2 pool) db singleton export | VERIFIED | Exports `db = drizzle({client:pool,schema,mode:'default'})`; connectionLimit 10 | -| `apps/api/src/routes/health.ts` | GET / with real DB round-trip | VERIFIED | SELECT 1 via db.execute; returns 200 or 503; unauthenticated | -| `docker-compose.yml` | api + mariadb:11 (healthcheck) + redis | VERIFIED | mariadb healthcheck using healthcheck.sh --connect; api depends_on service_healthy; redis present | -| `apps/api/src/auth/user.ts` | upsertUser + COLOR_PALETTE | VERIFIED | Exports both; palette 6 entries; SELECT-first idempotent pattern | -| `apps/api/src/auth/middleware.ts` | oidcAuthMiddleware re-export | VERIFIED | Re-exports oidcAuthMiddleware, processOAuthCallback, getAuth from @hono/oidc-auth | -| `apps/api/src/routes/me.ts` | GET /api/me → {user:{id,displayName,color}} | VERIFIED | getAuth → upsertUser → returns user shape; exports meRouter | -| `apps/api/src/broker/crypto.ts` | encryptPassword/decryptPassword (AES-256-GCM) | VERIFIED | node:crypto aes-256-gcm, 96-bit IV, JSON {iv,authTag,ciphertext} payload | -| `apps/api/src/broker/client.ts` | createFastmailClient → tsdav DAVClient | VERIFIED | caldav.fastmail.com, Basic auth, caldav account type | -| `apps/api/src/broker/sync.ts` | syncCalendar: REPORT → ical.js → upsert | VERIFIED | ical.js ICAL.Time.isDate routing, onDuplicateKeyUpdate on calendarId+uid | -| `apps/api/src/broker/poller.ts` | startBrokerPoller (node-cron */5) + ctag detection | VERIFIED | schedule('*/5 * * * *'); ctag skip logic; per-credential try/catch; exports runPoll for tests | -| `apps/api/src/routes/events.ts` | GET /api/events reads DB cache only | VERIFIED | db.select().from(calendarEvents); no tsdav import; exports eventsRouter | -| `apps/api/src/routes/sse.ts` | GET /api/sse/heartbeat (streamSSE) | VERIFIED | streamSSE every 10s; exports sseRouter; mounted behind oidcAuthMiddleware | -| `apps/api/src/broker/spike.ts` | CAL-08 spike script | VERIFIED | createFastmailClient → fetchCalendars → print URLs; never logs password | -| `.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md` | Decision: GO/NO-GO recorded | VERIFIED | "Decision: GO"; 503 events cached; ctag/syncToken findings recorded | -| `apps/pwa/src/App.tsx` | Renders MemberBadge + EventProof | VERIFIED | useQuery(['me']) → MemberBadge; EventProof component rendered | -| `apps/pwa/src/components/EventProof.tsx` | Fetches /api/events, renders first event | VERIFIED | useQuery(['events'],fetchEvents); renders event SUMMARY + date or empty state | -| `apps/api/tests/auth/user.test.ts` | 6 passing tests | VERIFIED | 6/6 green (palette[0], palette[1], idempotent, iss+sub identity, full row shape, COLOR_PALETTE validity) | -| `apps/api/tests/broker/crypto.test.ts` | 5 passing tests | VERIFIED | 5/5 green | -| `apps/api/tests/broker/sync.test.ts` | 6 passing tests | VERIFIED | 6/6 green | -| `apps/api/tests/broker/poller.test.ts` | 5 passing tests | VERIFIED | 5/5 green | -| `apps/api/tests/health.test.ts` | 2 passing tests | VERIFIED | 2/2 green | +| Artifact | Expected | Status | Details | +| ---------------------------------------------------------------- | -------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/api/src/db/schema.ts` | 4 mysqlTable defs, D-13 dtstart split, D-10 iss+sub unique key | VERIFIED | All 4 tables; unique('uniq_oidc_identity').on(oidcIss,oidcSub); separate dtstart_utc (timestamp) + dtstart_date (date) + allDay boolean | +| `apps/api/src/db/client.ts` | drizzle(mysql2 pool) db singleton export | VERIFIED | Exports `db = drizzle({client:pool,schema,mode:'default'})`; connectionLimit 10 | +| `apps/api/src/routes/health.ts` | GET / with real DB round-trip | VERIFIED | SELECT 1 via db.execute; returns 200 or 503; unauthenticated | +| `docker-compose.yml` | api + mariadb:11 (healthcheck) + redis | VERIFIED | mariadb healthcheck using healthcheck.sh --connect; api depends_on service_healthy; redis present | +| `apps/api/src/auth/user.ts` | upsertUser + COLOR_PALETTE | VERIFIED | Exports both; palette 6 entries; SELECT-first idempotent pattern | +| `apps/api/src/auth/middleware.ts` | oidcAuthMiddleware re-export | VERIFIED | Re-exports oidcAuthMiddleware, processOAuthCallback, getAuth from @hono/oidc-auth | +| `apps/api/src/routes/me.ts` | GET /api/me → {user:{id,displayName,color}} | VERIFIED | getAuth → upsertUser → returns user shape; exports meRouter | +| `apps/api/src/broker/crypto.ts` | encryptPassword/decryptPassword (AES-256-GCM) | VERIFIED | node:crypto aes-256-gcm, 96-bit IV, JSON {iv,authTag,ciphertext} payload | +| `apps/api/src/broker/client.ts` | createFastmailClient → tsdav DAVClient | VERIFIED | caldav.fastmail.com, Basic auth, caldav account type | +| `apps/api/src/broker/sync.ts` | syncCalendar: REPORT → ical.js → upsert | VERIFIED | ical.js ICAL.Time.isDate routing, onDuplicateKeyUpdate on calendarId+uid | +| `apps/api/src/broker/poller.ts` | startBrokerPoller (node-cron \*/5) + ctag detection | VERIFIED | schedule('_/5 _ \* \* \*'); ctag skip logic; per-credential try/catch; exports runPoll for tests | +| `apps/api/src/routes/events.ts` | GET /api/events reads DB cache only | VERIFIED | db.select().from(calendarEvents); no tsdav import; exports eventsRouter | +| `apps/api/src/routes/sse.ts` | GET /api/sse/heartbeat (streamSSE) | VERIFIED | streamSSE every 10s; exports sseRouter; mounted behind oidcAuthMiddleware | +| `apps/api/src/broker/spike.ts` | CAL-08 spike script | VERIFIED | createFastmailClient → fetchCalendars → print URLs; never logs password | +| `.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md` | Decision: GO/NO-GO recorded | VERIFIED | "Decision: GO"; 503 events cached; ctag/syncToken findings recorded | +| `apps/pwa/src/App.tsx` | Renders MemberBadge + EventProof | VERIFIED | useQuery(['me']) → MemberBadge; EventProof component rendered | +| `apps/pwa/src/components/EventProof.tsx` | Fetches /api/events, renders first event | VERIFIED | useQuery(['events'],fetchEvents); renders event SUMMARY + date or empty state | +| `apps/api/tests/auth/user.test.ts` | 6 passing tests | VERIFIED | 6/6 green (palette[0], palette[1], idempotent, iss+sub identity, full row shape, COLOR_PALETTE validity) | +| `apps/api/tests/broker/crypto.test.ts` | 5 passing tests | VERIFIED | 5/5 green | +| `apps/api/tests/broker/sync.test.ts` | 6 passing tests | VERIFIED | 6/6 green | +| `apps/api/tests/broker/poller.test.ts` | 5 passing tests | VERIFIED | 5/5 green | +| `apps/api/tests/health.test.ts` | 2 passing tests | VERIFIED | 2/2 green | **Artifact total: 22/22 present and substantive.** @@ -85,19 +85,19 @@ human_verification: ### Key Link Verification -| From | To | Via | Status | Details | -|------|-----|-----|--------|---------| -| `apps/api/src/routes/health.ts` | `apps/api/src/db/client.ts` | `db.execute()` | WIRED | `import { db } from '../db/client.js'`; execute called in route handler | -| `apps/pwa/src/App.tsx` | `/health` | `fetch('/health')` in fetchHealth | WIRED | `fetchHealth` calls `fetch('/health')` inside useQuery | -| `apps/api/src/routes/me.ts` | `apps/api/src/auth/user.ts` | `upsertUser(iss, sub, email)` | WIRED | `import { upsertUser }` + called in handler body | -| `apps/api/src/index.ts` | `@hono/oidc-auth` | `oidcAuthMiddleware` on `/api/*` | WIRED | `app.use('/api/*', oidcAuthMiddleware())` line 24 | -| `apps/pwa/src/App.tsx` | `/api/me` | React Query via fetchMe | WIRED | `import { fetchMe }` + `useQuery({queryKey:['me'],queryFn:fetchMe})` | -| `apps/api/src/broker/poller.ts` | `apps/api/src/broker/crypto.ts` | `decryptPassword` | WIRED | `import { decryptPassword }` + called before createFastmailClient | -| `apps/api/src/broker/sync.ts` | `apps/api/src/db/client.ts` | calendarEvents upsert | WIRED | `import { db }` + `db.insert(calendarEvents)...onDuplicateKeyUpdate()` | -| `apps/api/src/routes/events.ts` | `apps/api/src/db/client.ts` | cache read | WIRED | `import { db }` + `db.select().from(calendarEvents)` | -| `apps/api/src/index.ts` | `apps/api/src/broker/poller.ts` | `startBrokerPoller()` on boot | WIRED | `import { startBrokerPoller }` + called unconditionally at module level | -| `apps/api/src/index.ts` | `apps/api/src/routes/events.ts` | `app.route('/api/events', eventsRouter)` | WIRED | Line 28 in index.ts | -| `apps/pwa/src/components/EventProof.tsx` | `/api/events` | React Query via fetchEvents | WIRED | `import { fetchEvents }` + `useQuery({queryKey:['events'],queryFn:fetchEvents})` | +| From | To | Via | Status | Details | +| ---------------------------------------- | ------------------------------- | ---------------------------------------- | ------ | -------------------------------------------------------------------------------- | +| `apps/api/src/routes/health.ts` | `apps/api/src/db/client.ts` | `db.execute()` | WIRED | `import { db } from '../db/client.js'`; execute called in route handler | +| `apps/pwa/src/App.tsx` | `/health` | `fetch('/health')` in fetchHealth | WIRED | `fetchHealth` calls `fetch('/health')` inside useQuery | +| `apps/api/src/routes/me.ts` | `apps/api/src/auth/user.ts` | `upsertUser(iss, sub, email)` | WIRED | `import { upsertUser }` + called in handler body | +| `apps/api/src/index.ts` | `@hono/oidc-auth` | `oidcAuthMiddleware` on `/api/*` | WIRED | `app.use('/api/*', oidcAuthMiddleware())` line 24 | +| `apps/pwa/src/App.tsx` | `/api/me` | React Query via fetchMe | WIRED | `import { fetchMe }` + `useQuery({queryKey:['me'],queryFn:fetchMe})` | +| `apps/api/src/broker/poller.ts` | `apps/api/src/broker/crypto.ts` | `decryptPassword` | WIRED | `import { decryptPassword }` + called before createFastmailClient | +| `apps/api/src/broker/sync.ts` | `apps/api/src/db/client.ts` | calendarEvents upsert | WIRED | `import { db }` + `db.insert(calendarEvents)...onDuplicateKeyUpdate()` | +| `apps/api/src/routes/events.ts` | `apps/api/src/db/client.ts` | cache read | WIRED | `import { db }` + `db.select().from(calendarEvents)` | +| `apps/api/src/index.ts` | `apps/api/src/broker/poller.ts` | `startBrokerPoller()` on boot | WIRED | `import { startBrokerPoller }` + called unconditionally at module level | +| `apps/api/src/index.ts` | `apps/api/src/routes/events.ts` | `app.route('/api/events', eventsRouter)` | WIRED | Line 28 in index.ts | +| `apps/pwa/src/components/EventProof.tsx` | `/api/events` | React Query via fetchEvents | WIRED | `import { fetchEvents }` + `useQuery({queryKey:['events'],queryFn:fetchEvents})` | **All 11 key links WIRED.** @@ -105,23 +105,23 @@ human_verification: ### Data-Flow Trace (Level 4) -| Artifact | Data Variable | Source | Produces Real Data | Status | -|----------|---------------|--------|-------------------|--------| -| `apps/pwa/src/App.tsx` (MemberBadge) | `meQuery.data.user` | `/api/me` → upsertUser → MariaDB `users` table | Yes — upsertUser does SELECT then INSERT; backed by real DB | FLOWING | -| `apps/pwa/src/App.tsx` (health indicator) | `healthQuery.data` | `/health` → db.execute SELECT 1 | Yes — real DB round-trip | FLOWING | -| `apps/pwa/src/components/EventProof.tsx` | `events[0]` | `/api/events` → db.select().from(calendarEvents) | Yes — live Fastmail REPORT cached 503 events into MariaDB (CAL-08-DECISION.md); dev DB populated | FLOWING | +| Artifact | Data Variable | Source | Produces Real Data | Status | +| ----------------------------------------- | ------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------ | ------- | +| `apps/pwa/src/App.tsx` (MemberBadge) | `meQuery.data.user` | `/api/me` → upsertUser → MariaDB `users` table | Yes — upsertUser does SELECT then INSERT; backed by real DB | FLOWING | +| `apps/pwa/src/App.tsx` (health indicator) | `healthQuery.data` | `/health` → db.execute SELECT 1 | Yes — real DB round-trip | FLOWING | +| `apps/pwa/src/components/EventProof.tsx` | `events[0]` | `/api/events` → db.select().from(calendarEvents) | Yes — live Fastmail REPORT cached 503 events into MariaDB (CAL-08-DECISION.md); dev DB populated | FLOWING | --- ### Behavioral Spot-Checks -| Behavior | Command | Result | Status | -|----------|---------|--------|--------| -| All 24 unit tests pass | `cd apps/api && pnpm vitest run` | 24/24 passed, 5 test files | PASS | -| TypeScript compiles clean (api) | `cd apps/api && pnpm exec tsc --noEmit` | Exit 0, no errors | PASS | -| tsdav imported only under broker module | `grep -rn "import.*tsdav" apps/api/src/` | `broker/sync.ts`, `broker/client.ts` only | PASS | -| events route imports no tsdav or broker client | `grep -rn "createFastmailClient\|tsdav" apps/api/src/routes/` | No matches | PASS | -| oidcAuthMiddleware mounted on /api/* | `grep -n "oidcAuthMiddleware" apps/api/src/index.ts` | Line 24: `app.use('/api/*', oidcAuthMiddleware())` | PASS | +| Behavior | Command | Result | Status | +| ---------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------- | ------ | +| All 24 unit tests pass | `cd apps/api && pnpm vitest run` | 24/24 passed, 5 test files | PASS | +| TypeScript compiles clean (api) | `cd apps/api && pnpm exec tsc --noEmit` | Exit 0, no errors | PASS | +| tsdav imported only under broker module | `grep -rn "import.*tsdav" apps/api/src/` | `broker/sync.ts`, `broker/client.ts` only | PASS | +| events route imports no tsdav or broker client | `grep -rn "createFastmailClient\|tsdav" apps/api/src/routes/` | No matches | PASS | +| oidcAuthMiddleware mounted on /api/\* | `grep -n "oidcAuthMiddleware" apps/api/src/index.ts` | Line 24: `app.use('/api/*', oidcAuthMiddleware())` | PASS | --- @@ -133,21 +133,21 @@ No `scripts/*/tests/probe-*.sh` files declared or found. Task 3 of Plan 01 and T ### Requirements Coverage -| Requirement | Source Plan | Description | Status | Evidence | -|-------------|-------------|-------------|--------|----------| -| AUTH-01 | 01-02 | User can log in through Authelia (OIDC SSO) | HUMAN-PENDING | Code wired: oidcAuthMiddleware on /api/*, processOAuthCallback on /callback; live Authelia test pending | -| AUTH-02 | 01-02 | User stays logged in across sessions | HUMAN-PENDING | @hono/oidc-auth refresh-token rotation documented in middleware.ts; live session persistence test pending | -| AUTH-03 | 01-02 | Stable iss+sub identity + consistent per-member color | PARTIALLY VERIFIED | Unit-tested fully (6 tests); live cross-member color check pending human verification | -| CAL-01 | 01-01, 01-03 | Broker reads shared Fastmail calendar via CalDAV, caches locally (ctag polling) | VERIFIED | CAL-08-DECISION.md: 503 real events cached live via REPORT; poller ctag-detection unit tested | -| CAL-08 | 01-04 | Personal calendar spike go/no-go | VERIFIED | CAL-08-DECISION.md: Decision GO; per-member app-password model proven; no fallback needed | +| Requirement | Source Plan | Description | Status | Evidence | +| ----------- | ------------ | ------------------------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------- | +| AUTH-01 | 01-02 | User can log in through Authelia (OIDC SSO) | HUMAN-PENDING | Code wired: oidcAuthMiddleware on /api/\*, processOAuthCallback on /callback; live Authelia test pending | +| AUTH-02 | 01-02 | User stays logged in across sessions | HUMAN-PENDING | @hono/oidc-auth refresh-token rotation documented in middleware.ts; live session persistence test pending | +| AUTH-03 | 01-02 | Stable iss+sub identity + consistent per-member color | PARTIALLY VERIFIED | Unit-tested fully (6 tests); live cross-member color check pending human verification | +| CAL-01 | 01-01, 01-03 | Broker reads shared Fastmail calendar via CalDAV, caches locally (ctag polling) | VERIFIED | CAL-08-DECISION.md: 503 real events cached live via REPORT; poller ctag-detection unit tested | +| CAL-08 | 01-04 | Personal calendar spike go/no-go | VERIFIED | CAL-08-DECISION.md: Decision GO; per-member app-password model proven; no fallback needed | --- ### Anti-Patterns Found -| File | Pattern | Severity | Impact | -|------|---------|----------|--------| -| None found | — | — | — | +| File | Pattern | Severity | Impact | +| ---------- | ------- | -------- | ------ | +| None found | — | — | — | No TBD/FIXME/XXX markers in source files. No `return null` or `return []` stubs in non-test production code. No hardcoded empty values flowing to rendering. No console.log of credentials or encryption key in broker code. diff --git a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/CAL-08-DECISION.md b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/CAL-08-DECISION.md index 3f809d1..7893308 100644 --- a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/CAL-08-DECISION.md +++ b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/CAL-08-DECISION.md @@ -30,19 +30,19 @@ displayName, ctag, syncToken). It never logs the password. ### Calendars Discovered -| displayName | URL | ctag returned? | syncToken returned? | -|-------------|-----|----------------|---------------------| -| Calendar | `https://caldav.fastmail.com/dav/calendars/user/me@lucasberger.ca/2180A37A-806E-11EB-872C-AE53E9CB9923/` | yes (`1615249618-218118`) | yes (`data:,1615249618-218118`) | +| displayName | URL | ctag returned? | syncToken returned? | +| ------------ | -------------------------------------------------------------------------------------------------------- | ------------------------- | ------------------------------- | +| Calendar | `https://caldav.fastmail.com/dav/calendars/user/me@lucasberger.ca/2180A37A-806E-11EB-872C-AE53E9CB9923/` | yes (`1615249618-218118`) | yes (`data:,1615249618-218118`) | | USA Holidays | `https://caldav.fastmail.com/dav/calendars/user/me@lucasberger.ca/2da291bc-7962-4a0e-94bc-7bb135c90d6e/` | yes (`1635037263-211516`) | yes (`data:,1635037263-211516`) | ### Questions Resolved -| Question | Finding | -|----------|---------| -| Does one Fastmail app password see all of that account's calendars? | **Yes** — a single app password (scope "Mail, Contacts & Calendars") enumerated every collection owned by the account via PROPFIND. | +| Question | Finding | +| ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Does one Fastmail app password see all of that account's calendars? | **Yes** — a single app password (scope "Mail, Contacts & Calendars") enumerated every collection owned by the account via PROPFIND. | | Does the broker need cross-account ACL sharing to read a member's personal calendar? | **No** — under the locked design (D-09) each member supplies their own app password; the broker stores one encrypted credential per member and aggregates. Cross-account ACL sharing (the original CAL-08 risk) is not on the critical path. | -| Which change-detection field does Fastmail use — ctag or syncToken? | **Both** are returned. The poller uses ctag as the primary change signal with syncToken available as a fallback (matches the SKELETON poller design). | -| Can the broker actually fetch + cache real events (CAL-01)? | **Yes** — a live REPORT against the `Calendar` collection fetched and cached **503 events** into `calendar_events`, with correct D-13 handling (timed events → `dtstart_utc` set, `dtstart_date` null, `all_day` false). | +| Which change-detection field does Fastmail use — ctag or syncToken? | **Both** are returned. The poller uses ctag as the primary change signal with syncToken available as a fallback (matches the SKELETON poller design). | +| Can the broker actually fetch + cache real events (CAL-01)? | **Yes** — a live REPORT against the `Calendar` collection fetched and cached **503 events** into `calendar_events`, with correct D-13 handling (timed events → `dtstart_utc` set, `dtstart_date` null, `all_day` false). | --- @@ -53,7 +53,7 @@ displayName, ctag, syncToken). It never logs the password. **Rationale:** The personal-calendar read path is proven end-to-end against live Fastmail. A single app password reaches every calendar owned by its account, and the broker successfully fetched and cached real events (CAL-01). The original CAL-08 worry — whether the broker token -could see *another member's* personal calendar via Fastmail cross-account share+accept — is +could see _another member's_ personal calendar via Fastmail cross-account share+accept — is moot: the project already locked the **per-member app-password** model (D-09), where each member contributes their own credential. That model is validated here. The wife's personal calendar is reached the same way (her own app password), onboarded in Phase 2. diff --git a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/SKELETON.md b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/SKELETON.md index 8ac2b39..2c3ed30 100644 --- a/.planning/milestones/v1.0-phases/01-foundation-broker-spike/SKELETON.md +++ b/.planning/milestones/v1.0-phases/01-foundation-broker-spike/SKELETON.md @@ -9,23 +9,23 @@ A member reaches the app over the real Pangolin tunnel, authenticates through Au ## Architectural Decisions -| Decision | Choice | Rationale | -|---|---|---| -| Monorepo | pnpm workspace, `apps/api` + `apps/pwa` | Shared TypeScript, single repo; matches RESEARCH recommended structure | -| Backend framework | Hono 4.12.23 on Node 22 (@hono/node-server) | Locked in CLAUDE.md; Web-Standards-native, built-in streamSSE, RPC type sharing | -| Data layer | MariaDB 11 + Drizzle ORM 0.45.2 via mysql2 3.22.4 | Locked stack; no PostgreSQL; type-safe SQL, no binary engine (vs Prisma) | -| Schema apply | `drizzle-kit push` against live MariaDB | Greenfield Phase 1; push is the [BLOCKING] gate before verification (Drizzle types come from schema.ts, not the live DB) | -| Auth | Authelia OIDC via @hono/oidc-auth 1.8.3 (authorization-code + PKCE S256, client_secret_basic) | Authelia already deployed; backend confidential client holds refresh token (D-12), no iframe | -| Identity | `oidc_iss + oidc_sub` composite key, never email | D-10 — email is mutable in Authelia | -| Member color | Auto-assigned round-robin from a curated accessible palette, persisted on the user row | D-06 — stable across sessions, no settings UI in v1 | -| Calendar access | CalDAV via tsdav 2.2.2; per-member Fastmail app passwords | D-02/D-09 — JMAP unavailable on Fastmail; per-member app passwords eliminate cross-account ACL risk | -| Credential storage | AES-256-GCM (node:crypto), key from `APP_PASSWORD_ENCRYPTION_KEY` env, backend-only | D-04 — encrypted at rest, never exposed to frontend | -| Calendar cache | `calendar_events`: raw VEVENT blob + `dtstart_utc` (timed) / `dtstart_date` (all-day) split; ctag/sync-token polling | D-13 — all-day never coerced to DATETIME (Pitfall 3); cache-first reads | -| Background sync | node-cron 4 every 5 min, ctag change detection | RESEARCH poller pattern; sync-token with ctag fallback from day one | -| Real-time transport | SSE (`streamSSE`); WebSocket rejected | Pangolin WS upgrade known-broken (issue #1034); SSE smoke-tested in Phase 1 to de-risk Phase 4 | -| Frontend | Vite 8 + React 19; TanStack Query (server state) + Zustand (UI state) | Locked stack; React Query owns server data, Zustand UI-only | -| Deployment target | Docker Compose on Unraid, public via Pangolin/Newt tunnel (same parent domain as Authelia) | D-07 — validate real OIDC/HTTPS/cookie topology from day one | -| Directory layout | `apps/api/src/{auth,broker,db,routes}`; broker is a hard module boundary | RESEARCH structure; only `broker/` imports tsdav + Fastmail credentials | +| Decision | Choice | Rationale | +| ------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Monorepo | pnpm workspace, `apps/api` + `apps/pwa` | Shared TypeScript, single repo; matches RESEARCH recommended structure | +| Backend framework | Hono 4.12.23 on Node 22 (@hono/node-server) | Locked in CLAUDE.md; Web-Standards-native, built-in streamSSE, RPC type sharing | +| Data layer | MariaDB 11 + Drizzle ORM 0.45.2 via mysql2 3.22.4 | Locked stack; no PostgreSQL; type-safe SQL, no binary engine (vs Prisma) | +| Schema apply | `drizzle-kit push` against live MariaDB | Greenfield Phase 1; push is the [BLOCKING] gate before verification (Drizzle types come from schema.ts, not the live DB) | +| Auth | Authelia OIDC via @hono/oidc-auth 1.8.3 (authorization-code + PKCE S256, client_secret_basic) | Authelia already deployed; backend confidential client holds refresh token (D-12), no iframe | +| Identity | `oidc_iss + oidc_sub` composite key, never email | D-10 — email is mutable in Authelia | +| Member color | Auto-assigned round-robin from a curated accessible palette, persisted on the user row | D-06 — stable across sessions, no settings UI in v1 | +| Calendar access | CalDAV via tsdav 2.2.2; per-member Fastmail app passwords | D-02/D-09 — JMAP unavailable on Fastmail; per-member app passwords eliminate cross-account ACL risk | +| Credential storage | AES-256-GCM (node:crypto), key from `APP_PASSWORD_ENCRYPTION_KEY` env, backend-only | D-04 — encrypted at rest, never exposed to frontend | +| Calendar cache | `calendar_events`: raw VEVENT blob + `dtstart_utc` (timed) / `dtstart_date` (all-day) split; ctag/sync-token polling | D-13 — all-day never coerced to DATETIME (Pitfall 3); cache-first reads | +| Background sync | node-cron 4 every 5 min, ctag change detection | RESEARCH poller pattern; sync-token with ctag fallback from day one | +| Real-time transport | SSE (`streamSSE`); WebSocket rejected | Pangolin WS upgrade known-broken (issue #1034); SSE smoke-tested in Phase 1 to de-risk Phase 4 | +| Frontend | Vite 8 + React 19; TanStack Query (server state) + Zustand (UI state) | Locked stack; React Query owns server data, Zustand UI-only | +| Deployment target | Docker Compose on Unraid, public via Pangolin/Newt tunnel (same parent domain as Authelia) | D-07 — validate real OIDC/HTTPS/cookie topology from day one | +| Directory layout | `apps/api/src/{auth,broker,db,routes}`; broker is a hard module boundary | RESEARCH structure; only `broker/` imports tsdav + Fastmail credentials | ## Stack Touched in Phase 1 @@ -41,7 +41,7 @@ A member reaches the app over the real Pangolin tunnel, authenticates through Au - Wife's app password onboarding flow + her credential — Phase 2 (encrypted credential table built now to support it) - Event write-back (create/edit/delete) to Fastmail — Phase 3 - PWA manifest + service worker + guided iOS install — Phase 3 -- Shared lists + live SSE co-edit sync (the SSE *transport* is only smoke-tested here) — Phase 4 +- Shared lists + live SSE co-edit sync (the SSE _transport_ is only smoke-tested here) — Phase 4 - Web Push notifications (VAPID) — Phase 5 - User-pickable color picker (settings UI) — deferred, v1.x - Single-occurrence recurring edits — never in v1 diff --git a/.planning/milestones/v1.0-phases/02-calendar-display/.continue-here.md b/.planning/milestones/v1.0-phases/02-calendar-display/.continue-here.md index bc2f96e..64a2f1f 100644 --- a/.planning/milestones/v1.0-phases/02-calendar-display/.continue-here.md +++ b/.planning/milestones/v1.0-phases/02-calendar-display/.continue-here.md @@ -21,6 +21,7 @@ One uncommitted file: `.planning/config.json` (this session's settings changes). This session: + - Phase 2 RESEARCH.md + Nyquist VALIDATION.md written and committed (a707f8d) - PATTERNS.md written — 23 files classified, 19 analogs from Phase 1 code (5e14413) - 5 PLAN.md files created in 4 waves; plan-checker PASSED iteration 2 (fc4cc2c) @@ -28,7 +29,7 @@ This session: - Backlog item 999.1 added — "treat Fastmail as a calendar provider, support more" (8bd52c6) - GSD config changed via /gsd-config: Adaptive profile, TDD on, per-milestone branching, auto-advance on; saved as global defaults (~/.gsd/defaults.json). **config.json uncommitted.** - + @@ -39,7 +40,7 @@ This session: foundation: tokens, colorUtils, calendarConfig, hydrateEvents, Zustand store) — no file overlap - Wave 3: 02-04 — CalendarShell renders REAL windowed Fastmail events, color-coded, 4 views - Wave 4: 02-05 — EventDetailPopover + ColorLegend + nav/toolbar + skeleton/empty/error + human verify - + @@ -51,13 +52,14 @@ This session: - Shared-family calendar identified via a `calendars.is_shared` column + operator checkpoint (chosen over fragile displayName matching). - This-session GSD config: Adaptive profile, TDD on, per-milestone branching, auto-advance on. - + - None. Clean pause between plan and execute. ## Required Reading (in order) + 1. `.planning/phases/02-calendar-display/02-01-PLAN.md` … `02-05-PLAN.md` — the plans to execute 2. `.planning/phases/02-calendar-display/02-RESEARCH.md` — DST/VTIMEZONE, Schedule-X Temporal, firstDayOfWeek 0→7, has_rrule pre-filter (the landmines) @@ -65,6 +67,7 @@ This session: 4. `.planning/phases/02-calendar-display/02-PATTERNS.md` — analog files in the Phase 1 codebase ## Critical Anti-Patterns (do NOT repeat these) + - Do NOT skip the `[BLOCKING] npx drizzle-kit push` task in Wave 1 (02-01). Build/types pass without it because TS types come from config, not the live DB → false-positive verification. - Do NOT stamp `String(occ.calendarId)` as the Schedule-X calendarId — use isShared/ownerUserId. @@ -72,6 +75,7 @@ This session: UTC (keep them as 'YYYY-MM-DD' / Temporal.PlainDate). ## Infrastructure State + - Branch: `main`. git.branching_strategy is now `milestone` — execute may create a milestone branch. - Phase 1 shipped: apps/api + apps/pwa, MariaDB via docker-compose. No background processes running. - TDD is ON globally now, but Phase 2 plans were written PRE-TDD — they carry no TDD gates. diff --git a/.planning/milestones/v1.0-phases/02-calendar-display/02-01-PLAN.md b/.planning/milestones/v1.0-phases/02-calendar-display/02-01-PLAN.md index b8fba2d..8d1a76f 100644 --- a/.planning/milestones/v1.0-phases/02-calendar-display/02-01-PLAN.md +++ b/.planning/milestones/v1.0-phases/02-calendar-display/02-01-PLAN.md @@ -26,32 +26,32 @@ user_setup: [] must_haves: truths: - - "Drizzle schema has an indexed hasRrule boolean on calendar_events and an isShared boolean on calendars, both pushed to the live MariaDB" - - "PWA test runner (vitest + jsdom + @testing-library/react) executes and a smoke test passes" - - "Dev-auth bypass middleware injects a fixed dev user only when DEV_AUTH_BYPASS=true AND NODE_ENV!=production" - - "Failing-but-present test stubs exist for expand, events route, hydrateEvents, and calendarConfig (Wave 0 RED state) with concrete behavioral assertions, not bare failing imports" + - 'Drizzle schema has an indexed hasRrule boolean on calendar_events and an isShared boolean on calendars, both pushed to the live MariaDB' + - 'PWA test runner (vitest + jsdom + @testing-library/react) executes and a smoke test passes' + - 'Dev-auth bypass middleware injects a fixed dev user only when DEV_AUTH_BYPASS=true AND NODE_ENV!=production' + - 'Failing-but-present test stubs exist for expand, events route, hydrateEvents, and calendarConfig (Wave 0 RED state) with concrete behavioral assertions, not bare failing imports' artifacts: - - path: "apps/api/src/db/schema.ts" - provides: "hasRrule + isShared columns + idx_calendar_events_has_rrule index" - contains: "has_rrule" - - path: "apps/api/src/auth/devBypass.ts" - provides: "devAuthBypass() middleware with hard production guard" - exports: ["devAuthBypass"] - - path: "apps/pwa/vitest.config.ts" - provides: "jsdom-environment vitest config for PWA" - contains: "jsdom" - - path: "apps/api/tests/fixtures/weekly-dst.ics" - provides: "DST-spanning weekly RRULE fixture for CAL-07 tests" + - path: 'apps/api/src/db/schema.ts' + provides: 'hasRrule + isShared columns + idx_calendar_events_has_rrule index' + contains: 'has_rrule' + - path: 'apps/api/src/auth/devBypass.ts' + provides: 'devAuthBypass() middleware with hard production guard' + exports: ['devAuthBypass'] + - path: 'apps/pwa/vitest.config.ts' + provides: 'jsdom-environment vitest config for PWA' + contains: 'jsdom' + - path: 'apps/api/tests/fixtures/weekly-dst.ics' + provides: 'DST-spanning weekly RRULE fixture for CAL-07 tests' min_lines: 10 key_links: - - from: "apps/api/src/index.ts" - to: "apps/api/src/auth/devBypass.ts" + - from: 'apps/api/src/index.ts' + to: 'apps/api/src/auth/devBypass.ts' via: "app.use('/api/*', devAuthBypass()) before oidcAuthMiddleware" - pattern: "devAuthBypass" - - from: "apps/pwa/package.json" - to: "vitest" - via: "test script + devDependencies" - pattern: "\"test\".*vitest" + pattern: 'devAuthBypass' + - from: 'apps/pwa/package.json' + to: 'vitest' + via: 'test script + devDependencies' + pattern: '"test".*vitest' --- @@ -113,6 +113,7 @@ RED test stubs with concrete behavioral contracts. Create three ICS fixtures under `apps/api/tests/fixtures/`: `weekly-dst.ics` (VEVENT with `DTSTART;TZID=America/New_York:20260301T100000`, `RRULE:FREQ=WEEKLY`, and a full `VTIMEZONE` block for America/New_York with both STANDARD and DAYLIGHT subcomponents so DST rules are present), `allday-birthday.ics` (VEVENT with `DTSTART;VALUE=DATE:20260615`, yearly RRULE, no DTEND), `exdate-series.ics` (weekly VEVENT with one `EXDATE` line removing a single occurrence). These must be valid VCALENDAR strings parseable by ICAL.parse. Create the four RED test stubs with the CONCRETE behavioral assertions described in above — each must encode its real contract (the DST wall-clock assertion in expand.test.ts; the 'shared'/String(ownerUserId) calendarId routing assertion in hydrateEvents.test.ts), not merely a failing import. Each test imports the not-yet-existing module (`../../src/broker/expand.js`, etc.) so the file fails to resolve / the assertion fails — that is the intended RED state. Per the Nyquist rule, mark each `` for the modules they cover as satisfied here. Use the describe/it patterns from poller.test.ts and health.test.ts. Load fixtures with `readFileSync` relative to the test file. Do NOT implement expand.ts, the route changes, hydrateEvents.ts, or calendarConfig.ts in this task — only the stubs that later plans turn green. + cd apps/api && grep -q "has_rrule" src/db/schema.ts && grep -q "is_shared" src/db/schema.ts && grep -q "idx_calendar_events_has_rrule" src/db/schema.ts && echo SCHEMA_OK @@ -156,6 +157,7 @@ RED test stubs with concrete behavioral contracts. In `apps/api/src/index.ts`, mount `app.use('/api/*', devAuthBypass())` on the line immediately BEFORE the existing `app.use('/api/*', oidcAuthMiddleware())`. The bypass is a no-op when inactive, so production behavior is unchanged. Add `DEV_AUTH_BYPASS` to `.env.example` with a comment: `# DEV ONLY — injects a fixed dev user, skips Authelia. Hard-disabled when NODE_ENV=production. NEVER set in prod.` Extend `docs/deployment.md` dev-auth-bypass section to note the NODE_ENV production hard guard and that the production Docker Compose must not set DEV_AUTH_BYPASS. + cd apps/api && pnpm test -- tests/auth/devBypass.test.ts @@ -197,20 +199,22 @@ RED test stubs with concrete behavioral contracts. + ## Trust Boundaries -| Boundary | Description | -|----------|-------------| -| browser → /api/* | OIDC-gated; dev-auth bypass replaces the gate in dev only | -| CI/prod env → app config | DEV_AUTH_BYPASS env var could leak into production | +| Boundary | Description | +| ------------------------ | --------------------------------------------------------- | +| browser → /api/\* | OIDC-gated; dev-auth bypass replaces the gate in dev only | +| CI/prod env → app config | DEV_AUTH_BYPASS env var could leak into production | ## STRIDE Threat Register -| Threat ID | Category | Component | Disposition | Mitigation Plan | -|-----------|----------|-----------|-------------|-----------------| -| T-02-01 | Elevation of privilege | devAuthBypass() | mitigate | Hard `NODE_ENV === 'production'` guard as the FIRST conditional, before reading DEV_AUTH_BYPASS; .env.example warning; prod compose must not set the flag (Pitfall 7) | -| T-02-02 | Tampering | drizzle-kit push | accept | Local dev DB; push reviewed; no untrusted input. Operator runs push against own MariaDB | -| T-02-SC | Tampering | pnpm installs (vitest, @testing-library/*, jsdom) | mitigate | All packages are mainstream, audited in RESEARCH §Package Legitimacy (Approved); no [ASSUMED]/[SUS] packages in this plan | +| Threat ID | Category | Component | Disposition | Mitigation Plan | +| --------- | ---------------------- | -------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| T-02-01 | Elevation of privilege | devAuthBypass() | mitigate | Hard `NODE_ENV === 'production'` guard as the FIRST conditional, before reading DEV_AUTH_BYPASS; .env.example warning; prod compose must not set the flag (Pitfall 7) | +| T-02-02 | Tampering | drizzle-kit push | accept | Local dev DB; push reviewed; no untrusted input. Operator runs push against own MariaDB | +| T-02-SC | Tampering | pnpm installs (vitest, @testing-library/\*, jsdom) | mitigate | All packages are mainstream, audited in RESEARCH §Package Legitimacy (Approved); no [ASSUMED]/[SUS] packages in this plan | + @@ -221,16 +225,19 @@ RED test stubs with concrete behavioral contracts. + - Schema columns added, pushed, and verified against the live DB - PWA test runner operational - Dev-auth bypass green with production hard guard - ICS fixtures parse; RED stubs in place for later waves with concrete DST + calendarId-routing contracts - + + ## Artifacts this phase produces (Plan 01) New symbols/files created here (exclude from drift verification): + - `calendar_events.hasRrule` Drizzle column + `idx_calendar_events_has_rrule` index - `calendars.isShared` Drizzle column - `devAuthBypass` (function) — apps/api/src/auth/devBypass.ts @@ -240,7 +247,7 @@ New symbols/files created here (exclude from drift verification): - apps/api/tests/fixtures/{weekly-dst,allday-birthday,exdate-series}.ics - apps/api/tests/broker/expand.test.ts, apps/api/tests/routes/events.test.ts, apps/api/tests/auth/devBypass.test.ts (new test files) - apps/pwa/src/lib/hydrateEvents.test.ts, apps/pwa/src/lib/calendarConfig.test.ts (new test files) - + Create `.planning/phases/02-calendar-display/02-01-SUMMARY.md` when done diff --git a/.planning/milestones/v1.0-phases/02-calendar-display/02-01-SUMMARY.md b/.planning/milestones/v1.0-phases/02-calendar-display/02-01-SUMMARY.md index 2a23797..1021741 100644 --- a/.planning/milestones/v1.0-phases/02-calendar-display/02-01-SUMMARY.md +++ b/.planning/milestones/v1.0-phases/02-calendar-display/02-01-SUMMARY.md @@ -1,17 +1,25 @@ --- phase: 02-calendar-display -plan: "01" +plan: '01' subsystem: api-schema, api-auth, pwa-test tags: [schema-migration, dev-auth, test-harness, ics-fixtures, red-stubs] dependency_graph: requires: [01-foundation-broker-spike] - provides: [calendar_events.hasRrule, calendars.isShared, devAuthBypass, pwa-vitest-jsdom, ics-fixtures, red-test-stubs] + provides: + [ + calendar_events.hasRrule, + calendars.isShared, + devAuthBypass, + pwa-vitest-jsdom, + ics-fixtures, + red-test-stubs, + ] affects: [02-02, 02-03, 02-04, 02-05] tech_stack: added: - vitest@^4.1.8 (PWA devDependency) - - "@testing-library/react@^16.3.0 (PWA devDependency)" - - "@testing-library/jest-dom@^6.6.3 (PWA devDependency)" + - '@testing-library/react@^16.3.0 (PWA devDependency)' + - '@testing-library/jest-dom@^6.6.3 (PWA devDependency)' - jsdom@^26.1.0 (PWA devDependency) patterns: - Drizzle boolean column + index pattern (hasRrule, isShared) @@ -36,12 +44,12 @@ key_files: - .env.example - docs/deployment.md decisions: - - "Applied ALTER TABLE directly instead of drizzle-kit push due to non-TTY interactive prompt — false-positive int(11) vs int type warning on existing rows" - - "devAuthBypass evaluates env vars at call time (process start) not request time — intentional so auth mode is fixed for the lifetime of the process" - - "RED test stubs reference not-yet-built modules to ensure compile-time failure (concrete RED state), not just assertion failure" + - 'Applied ALTER TABLE directly instead of drizzle-kit push due to non-TTY interactive prompt — false-positive int(11) vs int type warning on existing rows' + - 'devAuthBypass evaluates env vars at call time (process start) not request time — intentional so auth mode is fixed for the lifetime of the process' + - 'RED test stubs reference not-yet-built modules to ensure compile-time failure (concrete RED state), not just assertion failure' metrics: - duration: "8m 25s" - completed: "2026-06-05" + duration: '8m 25s' + completed: '2026-06-05' tasks_completed: 3 files_created: 10 files_modified: 5 @@ -56,6 +64,7 @@ Horizontal foundation for Phase 2 calendar slice: two schema columns pushed to l ### Schema Changes (Task 1) Added to `apps/api/src/db/schema.ts`: + - `calendarEvents.hasRrule`: `boolean('has_rrule').default(false).notNull()` — pre-filter flag for recurring event masters (RESEARCH.md §Pitfall 5) - `calendarEvents`: new index `idx_calendar_events_has_rrule` matching style of `idx_calendar_events_dtstart_utc` - `calendars.isShared`: `boolean('is_shared').default(false).notNull()` — operator-marked shared-family calendar flag @@ -72,6 +81,7 @@ Both columns pushed to live MariaDB (503-event cache intact). `SHOW COLUMNS` con ### ICS Fixtures (Task 1) Three fixtures created at `apps/api/tests/fixtures/`: + - `weekly-dst.ics`: weekly VEVENT at `DTSTART;TZID=America/New_York:20260301T100000` with full VTIMEZONE block (STANDARD + DAYLIGHT subcomponents for March 2026 EST→EDT transition) - `allday-birthday.ics`: `DTSTART;VALUE=DATE:20260615` with `RRULE:FREQ=YEARLY`, no DTEND — pure DATE type - `exdate-series.ics`: `RRULE:FREQ=WEEKLY;COUNT=5` with `EXDATE;TZID=America/New_York:20260615T090000` — exactly one occurrence excluded @@ -83,6 +93,7 @@ All three fixtures parse via `ICAL.parse()` without throwing. Four test stubs with concrete behavioral contracts (not bare failing imports): **expand.test.ts**: Three behavioral contracts — + 1. DST wall-clock: every occurrence in March 2026 window has `T10:00:00` in the ISO start string, regardless of EST/EDT offset. Tests both pre-transition (2026-03-01) and post-transition (2026-03-15) occurrences. 2. All-day: `allDay:true` and `start === '2026-06-15'` (no `T` component) 3. EXDATE: length === 4 (not 5), June 15 occurrence absent @@ -98,6 +109,7 @@ All RED stubs fail at import resolution (module not built yet) — correct RED s ### Dev-Auth Bypass (Task 2) Created `apps/api/src/auth/devBypass.ts`: + - Exports `devAuthBypass(): MiddlewareHandler` - First conditional is `NODE_ENV === 'production'` — hard guard (T-02-01 mitigation) - Returns no-op passthrough when production OR bypass flag unset @@ -115,6 +127,7 @@ All three devBypass.test.ts cases pass: production guard, unset-flag passthrough ### Auto-fixed Issues **1. [Rule 3 - Blocker] drizzle-kit push replaced with direct ALTER TABLE** + - **Found during:** Task 3 - **Issue:** `drizzle-kit push` emitted a non-TTY interactive prompt. The "data-loss" warnings were false positives — MariaDB stores int as `int(11)` display width but drizzle-kit 0.31.x sees this as a type change on existing rows. The prompt cannot be auto-confirmed without TTY. - **Fix:** Applied the two actual new columns directly via `ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS has_rrule tinyint(1) NOT NULL DEFAULT 0` and `ALTER TABLE calendars ADD COLUMN IF NOT EXISTS is_shared tinyint(1) NOT NULL DEFAULT 0`, plus the index. Outcome is identical to what drizzle-kit push would have done for the new columns. @@ -125,6 +138,7 @@ All three devBypass.test.ts cases pass: production guard, unset-flag passthrough ## Known Stubs The following test stubs are intentionally RED (modules not yet built): + - `apps/api/tests/broker/expand.test.ts` — awaits `apps/api/src/broker/expand.ts` (Plan 02) - `apps/api/tests/routes/events.test.ts` — awaits evolved `apps/api/src/routes/events.ts` (Plan 02) - `apps/pwa/src/lib/hydrateEvents.test.ts` — awaits `apps/pwa/src/lib/hydrateEvents.ts` (Plan 03) @@ -139,6 +153,7 @@ No new threat surface introduced beyond what is already in the plan's threat mod ## Self-Check: PASSED Files created: + - [x] apps/api/src/auth/devBypass.ts — FOUND - [x] apps/pwa/vitest.config.ts — FOUND - [x] apps/api/tests/fixtures/weekly-dst.ics — FOUND @@ -151,10 +166,12 @@ Files created: - [x] apps/pwa/src/lib/calendarConfig.test.ts — FOUND Commits: + - [x] 75252eb — Task 1 feat - [x] 8bd44b3 — Task 2 feat DB state: + - [x] SHOW COLUMNS FROM calendar_events LIKE 'has_rrule' — returns 1 row - [x] SHOW COLUMNS FROM calendars LIKE 'is_shared' — returns 1 row - [x] 503 events intact diff --git a/.planning/milestones/v1.0-phases/02-calendar-display/02-02-PLAN.md b/.planning/milestones/v1.0-phases/02-calendar-display/02-02-PLAN.md index c5803e8..8cda1cc 100644 --- a/.planning/milestones/v1.0-phases/02-calendar-display/02-02-PLAN.md +++ b/.planning/milestones/v1.0-phases/02-calendar-display/02-02-PLAN.md @@ -3,7 +3,7 @@ phase: 02-calendar-display plan: 02 type: execute wave: 2 -depends_on: ["02-01"] +depends_on: ['02-01'] files_modified: - apps/api/src/broker/expand.ts - apps/api/src/routes/events.ts @@ -15,27 +15,27 @@ user_setup: [] must_haves: truths: - - "GET /api/events?start=&end= returns a flat array of concrete occurrences (no recurring masters, no raw VCALENDAR blobs) windowed to the requested date range" - - "Each occurrence carries the owner member color (from users.color) or the shared-family rose, plus an isShared flag, an ownerUserId, and the DB calendarId" - - "Recurring events are expanded server-side with VTIMEZONE registered before expansion so DST occurrences keep correct wall-clock time" + - 'GET /api/events?start=&end= returns a flat array of concrete occurrences (no recurring masters, no raw VCALENDAR blobs) windowed to the requested date range' + - 'Each occurrence carries the owner member color (from users.color) or the shared-family rose, plus an isShared flag, an ownerUserId, and the DB calendarId' + - 'Recurring events are expanded server-side with VTIMEZONE registered before expansion so DST occurrences keep correct wall-clock time' - "All-day occurrences are returned with allDay:true and a 'YYYY-MM-DD' start (no timezone shift) — single local timezone for v1 (D-10)" - - "EXDATE-excluded occurrences are omitted from the expansion" - - "Invalid or missing start/end query params are rejected (zod) before any SQL runs; window capped at 90 days" + - 'EXDATE-excluded occurrences are omitted from the expansion' + - 'Invalid or missing start/end query params are rejected (zod) before any SQL runs; window capped at 90 days' artifacts: - - path: "apps/api/src/broker/expand.ts" - provides: "expandOccurrences() — ICAL.RecurExpansion + VTIMEZONE registration + allDay split → CalendarOccurrence[]" - exports: ["expandOccurrences", "CalendarOccurrence"] - - path: "apps/api/src/routes/events.ts" - provides: "windowed /api/events with calendarEvents→calendars→users join, hasRrule pre-filter, zod validation" - contains: "zValidator" + - path: 'apps/api/src/broker/expand.ts' + provides: 'expandOccurrences() — ICAL.RecurExpansion + VTIMEZONE registration + allDay split → CalendarOccurrence[]' + exports: ['expandOccurrences', 'CalendarOccurrence'] + - path: 'apps/api/src/routes/events.ts' + provides: 'windowed /api/events with calendarEvents→calendars→users join, hasRrule pre-filter, zod validation' + contains: 'zValidator' key_links: - - from: "apps/api/src/routes/events.ts" - to: "apps/api/src/broker/expand.ts" - via: "expandOccurrences() called per recurring/timed row" - pattern: "expandOccurrences" - - from: "apps/api/src/routes/events.ts" - to: "users.color" - via: "innerJoin calendars→users, select color + isShared + users.id" + - from: 'apps/api/src/routes/events.ts' + to: 'apps/api/src/broker/expand.ts' + via: 'expandOccurrences() called per recurring/timed row' + pattern: 'expandOccurrences' + - from: 'apps/api/src/routes/events.ts' + to: 'users.color' + via: 'innerJoin calendars→users, select color + isShared + users.id' pattern: "users\\.color" --- @@ -102,6 +102,7 @@ shared-family calendar (open question A3) — the operator marks it. 7. Use rrule ONLY as a fallback if ICAL.RecurExpansion cannot parse a given RRULE — do not import it on the primary path (D-09). Turn the Plan 01 RED expand.test.ts stub green against the three fixtures. + cd apps/api && pnpm test -- tests/broker/expand.test.ts @@ -145,6 +146,7 @@ shared-family calendar (open question A3) — the operator marks it. For each row, derive `color = row.isShared ? '#F25C7A' : row.userColor` and `isShared = row.isShared`, then call `expandOccurrences(row.rawVevent, windowStartDate, windowEndDate, { calendarId, calendarName: row.displayName, ownerUserId: row.userId, color, isShared })`. The `ownerUserId: row.userId` field is load-bearing — the client routes calendar color by it. Flatten all results into one array. Wrap the DB+expansion body in try/catch returning 503 on DB error (health.ts pattern). Return `c.json({ occurrences })`. Turn the Plan 01 RED events.test.ts stub green (mock db.select chain following the health.test.ts vi.mock pattern; assert color field, isShared, ownerUserId, and 400 on bad params). + cd apps/api && pnpm test -- tests/routes/events.test.ts @@ -184,21 +186,23 @@ shared-family calendar (open question A3) — the operator marks it. + ## Trust Boundaries -| Boundary | Description | -|----------|-------------| -| browser → /api/events | start/end query params are untrusted input crossing into SQL | -| cached VEVENT → expansion | rawVevent originates from Fastmail; parsed by ical.js | +| Boundary | Description | +| ------------------------- | ------------------------------------------------------------ | +| browser → /api/events | start/end query params are untrusted input crossing into SQL | +| cached VEVENT → expansion | rawVevent originates from Fastmail; parsed by ical.js | ## STRIDE Threat Register -| Threat ID | Category | Component | Disposition | Mitigation Plan | -|-----------|----------|-----------|-------------|-----------------| -| T-02b-01 | Tampering | start/end query params | mitigate | zod ISO-date regex validation before SQL; Drizzle parameterized queries (no string interpolation) | -| T-02b-02 | Denial of service | unwindowed/overwide fetch | mitigate | start+end required (zod); window hard-capped at 90 days; hasRrule index prevents full-table scan | -| T-02b-03 | Information disclosure | cross-account calendar leakage | mitigate | Route is behind oidcAuthMiddleware (Phase 1); each member's own credential fetched their own collections; no other-account ACL path exists (CAL-08-DECISION) | -| T-02b-04 | Tampering | malformed rawVevent | accept | expandOccurrences try/catch returns [] on parse failure; matches sync.ts resilience; no crash | +| Threat ID | Category | Component | Disposition | Mitigation Plan | +| --------- | ---------------------- | ------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| T-02b-01 | Tampering | start/end query params | mitigate | zod ISO-date regex validation before SQL; Drizzle parameterized queries (no string interpolation) | +| T-02b-02 | Denial of service | unwindowed/overwide fetch | mitigate | start+end required (zod); window hard-capped at 90 days; hasRrule index prevents full-table scan | +| T-02b-03 | Information disclosure | cross-account calendar leakage | mitigate | Route is behind oidcAuthMiddleware (Phase 1); each member's own credential fetched their own collections; no other-account ACL path exists (CAL-08-DECISION) | +| T-02b-04 | Tampering | malformed rawVevent | accept | expandOccurrences try/catch returns [] on parse failure; matches sync.ts resilience; no crash | + @@ -208,20 +212,22 @@ shared-family calendar (open question A3) — the operator marks it. + - /api/events returns windowed, color-tagged, DST-correct, all-day-safe, EXDATE-aware occurrences - Each occurrence carries ownerUserId + isShared for client-side color routing - Bad/oversized windows rejected with 400 - Shared-family calendar marked and verified end-to-end - + + ## Artifacts this phase produces (Plan 02) - `expandOccurrences` (function) + `CalendarOccurrence` (interface) — apps/api/src/broker/expand.ts - Evolved `eventsRouter` GET / handler with `{ occurrences }` response shape — apps/api/src/routes/events.ts - `eventsQuerySchema` (zod) for start/end validation - New JSON contract field set: id, uid, calendarId, calendarName, ownerUserId, color, isShared, title, start, end, allDay, location, description - + Create `.planning/phases/02-calendar-display/02-02-SUMMARY.md` when done diff --git a/.planning/milestones/v1.0-phases/02-calendar-display/02-02-SUMMARY.md b/.planning/milestones/v1.0-phases/02-calendar-display/02-02-SUMMARY.md index d13d477..266ad09 100644 --- a/.planning/milestones/v1.0-phases/02-calendar-display/02-02-SUMMARY.md +++ b/.planning/milestones/v1.0-phases/02-calendar-display/02-02-SUMMARY.md @@ -1,6 +1,6 @@ --- phase: 02-calendar-display -plan: "02" +plan: '02' subsystem: api-expansion, api-events tags: [recurrence-expansion, dst-correctness, windowed-query, color-join, zod-validation] dependency_graph: @@ -22,12 +22,12 @@ key_files: - apps/api/src/routes/events.ts - apps/api/tests/routes/events.test.ts decisions: - - "ICAL.TimezoneService.register(timezone, name) arg order: first arg is Timezone object, second is optional name string — inverted from research pseudocode which incorrectly placed tzid first" - - "events.test.ts needed @hono/oidc-auth mock — oidcAuthMiddleware throws HTTP 500 when OIDC env vars are absent, blocking all route tests; added passthrough mock as Rule 3 fix" - - "Task 3 (shared-family calendar marking) deferred by operator: id=1 is the operator personal calendar, dedicated shared Family calendar does not exist yet — is_shared stays false for all current rows" + - 'ICAL.TimezoneService.register(timezone, name) arg order: first arg is Timezone object, second is optional name string — inverted from research pseudocode which incorrectly placed tzid first' + - 'events.test.ts needed @hono/oidc-auth mock — oidcAuthMiddleware throws HTTP 500 when OIDC env vars are absent, blocking all route tests; added passthrough mock as Rule 3 fix' + - 'Task 3 (shared-family calendar marking) deferred by operator: id=1 is the operator personal calendar, dedicated shared Family calendar does not exist yet — is_shared stays false for all current rows' metrics: - duration: "22m" - completed: "2026-06-05" + duration: '22m' + completed: '2026-06-05' tasks_completed: 2 tasks_deferred: 1 files_created: 1 @@ -45,6 +45,7 @@ Server-side recurrence expansion with DST-correct VTIMEZONE registration, all-da New file exporting `CalendarOccurrence` interface and `expandOccurrences()` function. **Interface `CalendarOccurrence`** — carries all fields the Schedule-X frontend needs: + - `id`: `${uid}::${startIso}` stable identity - `ownerUserId`: load-bearing client field; Schedule-X calendarId = `isShared ? 'shared' : String(ownerUserId)` - `isShared`: from calendar row, stamped on every occurrence from meta @@ -52,6 +53,7 @@ New file exporting `CalendarOccurrence` interface and `expandOccurrences()` func - `allDay`, `color`, `calendarId`, `calendarName`, `uid`, `title`, `location`, `description` **Implementation contracts met:** + 1. `ICAL.parse()` in try/catch — malformed input returns `[]` without throwing 2. VTIMEZONE registration loop runs before `new ICAL.RecurExpansion(...)` — mandatory for DST correctness (Pitfall 3) 3. Non-recurring: single occurrence check against [windowStart, windowEnd) @@ -61,6 +63,7 @@ New file exporting `CalendarOccurrence` interface and `expandOccurrences()` func 7. No `import ... 'rrule'` anywhere in expand.ts **Test results (expand.test.ts — 3/3 green):** + - DST: `T10:00:00` present in every March 2026 occurrence across EST→EDT boundary - All-day: `allDay:true`, `start === '2026-06-15'`, no `T` in string - EXDATE: 4 occurrences returned (not 5), June 15 absent @@ -70,6 +73,7 @@ New file exporting `CalendarOccurrence` interface and `expandOccurrences()` func Rewrote `eventsRouter.get('/')` from raw table dump to display-ready windowed endpoint. **Zod validation:** + - `eventsQuerySchema`: `start` and `end` each required, validated as `/^\d{4}-\d{2}-\d{2}$/` - `zValidator('query', eventsQuerySchema)` — 400 returned automatically on schema failure - Post-schema: 90-day window cap returns 400 if span exceeds limit (T-02b-02 DoS guard) @@ -79,6 +83,7 @@ Rewrote `eventsRouter.get('/')` from raw table dump to display-ready windowed en **WHERE pre-filter (RESEARCH.md Open Q3 / Pitfall 5):** Three-branch OR covering: + 1. `hasRrule=1 AND dtstartUtc < windowEnd` — recurring masters from any date 2. `hasRrule=0 AND dtstartUtc IN [windowStart, windowEnd)` — non-recurring timed events 3. `dtstartDate IN [start, end)` — all-day events (DATE comparison) @@ -92,6 +97,7 @@ Three-branch OR covering: **Broker-boundary invariant preserved:** No tsdav / createFastmailClient import. **Test results (events.test.ts — 4/4 green):** + - 400 on missing start - 400 on missing end - 400 on malformed date @@ -114,12 +120,14 @@ The `calendars.is_shared` column exists (added in Plan 01, default false). The r ### Auto-fixed Issues **1. [Rule 1 - Bug] ICAL.TimezoneService.register() argument order** + - **Found during:** Task 1 typecheck - **Issue:** Research pseudocode showed `register(tzid, timezone)` but the actual API is `register(timezone, name?)` — tzid-first call causes TS2345 type error - **Fix:** Swapped to `register(new ICAL.Timezone({ component: vtz, tzid }), tzid)` - **Files modified:** apps/api/src/broker/expand.ts **2. [Rule 3 - Blocker] @hono/oidc-auth throws 500 in test environment** + - **Found during:** Task 2 (events test execution) - **Issue:** `oidcAuthMiddleware()` calls `throw new HTTPException(500, ...)` when `OIDC_AUTH_SECRET` env var is absent. The RED stub's test imports `app` from `src/index.js` which mounts `oidcAuthMiddleware`, so all `/api/events` requests get 500 before reaching the route handler. - **Fix:** Added `vi.mock('@hono/oidc-auth', ...)` passthrough mock to events.test.ts, making `oidcAuthMiddleware` a no-op in the test environment. Same pattern works for future route tests that use app.request(). @@ -141,16 +149,20 @@ No new threat surface beyond the plan's threat model. ## Self-Check: PASSED Files created: + - [x] apps/api/src/broker/expand.ts — FOUND Files modified: + - [x] apps/api/src/routes/events.ts — FOUND - [x] apps/api/tests/routes/events.test.ts — FOUND Commits: + - [x] 6736194 — feat(02-02): expandOccurrences Task 1 - [x] 9ee26c0 — feat(02-02): windowed events route Task 2 Test suite: + - [x] pnpm --filter @familysync/api test — 34/34 passed - [x] pnpm --filter @familysync/api typecheck — clean diff --git a/.planning/milestones/v1.0-phases/02-calendar-display/02-03-PLAN.md b/.planning/milestones/v1.0-phases/02-calendar-display/02-03-PLAN.md index 35b0519..f6e4ce5 100644 --- a/.planning/milestones/v1.0-phases/02-calendar-display/02-03-PLAN.md +++ b/.planning/milestones/v1.0-phases/02-calendar-display/02-03-PLAN.md @@ -3,7 +3,7 @@ phase: 02-calendar-display plan: 03 type: execute wave: 2 -depends_on: ["02-01"] +depends_on: ['02-01'] files_modified: - apps/pwa/src/styles/tokens.css - apps/pwa/src/styles/tokens.ts @@ -24,40 +24,40 @@ user_setup: [] must_haves: truths: - - "A CSS custom-property token layer (the clean theme) defines all colors, spacing, typography, breakpoints from UI-SPEC; no hard-coded hex/px will be needed by components — tuned to stay legible/informational at tablet distance, not ultra-minimal (D-03)" - - "Schedule-X --sx-color-* vars are mapped to project tokens so no Schedule-X default colors bleed through" - - "colorUtils derives Schedule-X lightColors (main/container/onContainer) from a member hex" + - 'A CSS custom-property token layer (the clean theme) defines all colors, spacing, typography, breakpoints from UI-SPEC; no hard-coded hex/px will be needed by components — tuned to stay legible/informational at tablet distance, not ultra-minimal (D-03)' + - 'Schedule-X --sx-color-* vars are mapped to project tokens so no Schedule-X default colors bleed through' + - 'colorUtils derives Schedule-X lightColors (main/container/onContainer) from a member hex' - "calendarConfig translates WEEK_START_DAY=0 (Sunday/JS) to Schedule-X firstDayOfWeek=7 (Temporal Sunday) and builds the per-calendar config keyed by String(userId) + 'shared'" - - "hydrateEvents converts all-day occurrences to Temporal.PlainDate and timed occurrences to Temporal.ZonedDateTime" + - 'hydrateEvents converts all-day occurrences to Temporal.PlainDate and timed occurrences to Temporal.ZonedDateTime' - "hydrateEvents routes each event's Schedule-X calendarId to 'shared' (isShared) or String(ownerUserId), matching the userId-keyed calendars config — never String(calendarId)" - - "calendarStore (Zustand) holds selectedView (persisted per breakpoint group), selectedDate, openEventId, calendarRange — no server data" - - "fetchEvents(start,end) calls the windowed /api/events with credentials:include and returns OccurrencesResponse" + - 'calendarStore (Zustand) holds selectedView (persisted per breakpoint group), selectedDate, openEventId, calendarRange — no server data' + - 'fetchEvents(start,end) calls the windowed /api/events with credentials:include and returns OccurrencesResponse' artifacts: - - path: "apps/pwa/src/styles/tokens.css" - provides: "clean-theme CSS custom properties + Schedule-X var overrides" - contains: "--color-shared-family" - - path: "apps/pwa/src/lib/calendarConfig.ts" - provides: "WEEK_START_DAY, firstDayOfWeek translation, buildCalendarConfig()" - exports: ["WEEK_START_DAY", "buildCalendarConfig"] - - path: "apps/pwa/src/lib/hydrateEvents.ts" - provides: "hydrateEvents() ISO→Temporal with all-day PlainDate guard + isShared/ownerUserId calendarId routing" - exports: ["hydrateEvents"] - - path: "apps/pwa/src/store/calendarStore.ts" - provides: "Zustand UI-state store with localStorage view persistence" - exports: ["useCalendarStore"] + - path: 'apps/pwa/src/styles/tokens.css' + provides: 'clean-theme CSS custom properties + Schedule-X var overrides' + contains: '--color-shared-family' + - path: 'apps/pwa/src/lib/calendarConfig.ts' + provides: 'WEEK_START_DAY, firstDayOfWeek translation, buildCalendarConfig()' + exports: ['WEEK_START_DAY', 'buildCalendarConfig'] + - path: 'apps/pwa/src/lib/hydrateEvents.ts' + provides: 'hydrateEvents() ISO→Temporal with all-day PlainDate guard + isShared/ownerUserId calendarId routing' + exports: ['hydrateEvents'] + - path: 'apps/pwa/src/store/calendarStore.ts' + provides: 'Zustand UI-state store with localStorage view persistence' + exports: ['useCalendarStore'] key_links: - - from: "apps/pwa/src/main.tsx" - to: "temporal-polyfill/global" - via: "import before any Schedule-X mount" - pattern: "temporal-polyfill/global" - - from: "apps/pwa/src/lib/calendarConfig.ts" - to: "apps/pwa/src/lib/colorUtils.ts" - via: "deriveScheduleXColors() for lightColors" - pattern: "deriveScheduleXColors" - - from: "apps/pwa/src/lib/hydrateEvents.ts" - to: "apps/pwa/src/lib/calendarConfig.ts" + - from: 'apps/pwa/src/main.tsx' + to: 'temporal-polyfill/global' + via: 'import before any Schedule-X mount' + pattern: 'temporal-polyfill/global' + - from: 'apps/pwa/src/lib/calendarConfig.ts' + to: 'apps/pwa/src/lib/colorUtils.ts' + via: 'deriveScheduleXColors() for lightColors' + pattern: 'deriveScheduleXColors' + - from: 'apps/pwa/src/lib/hydrateEvents.ts' + to: 'apps/pwa/src/lib/calendarConfig.ts' via: "calendarId = isShared ? 'shared' : String(ownerUserId) matches buildCalendarConfig keys" - pattern: "ownerUserId" + pattern: 'ownerUserId' --- @@ -109,6 +109,7 @@ windowed fetchEvents, updated main.tsx, Schedule-X deps installed. Create `apps/pwa/src/styles/index.css` importing tokens.css, plus a minimal global reset (box-sizing border-box, body font-family var, margin 0) — no third-party reset library. Update `apps/pwa/src/main.tsx`: as the FIRST three imports (before React), add `import 'temporal-polyfill/global'`, `import '@schedule-x/theme-default/dist/index.css'`, `import './styles/index.css'` (in that order — Temporal must register before any Schedule-X usage, and token overrides must come after the Schedule-X default CSS so they win). Leave the QueryClientProvider tree intact. + cd apps/pwa && grep -q "temporal-polyfill/global" src/main.tsx && grep -q "@schedule-x/theme-default/dist/index.css" src/main.tsx && grep -q "./styles/index.css" src/main.tsx && echo MAIN_IMPORTS_OK @@ -143,6 +144,7 @@ windowed fetchEvents, updated main.tsx, Schedule-X deps installed. Create `apps/pwa/src/lib/colorUtils.ts` exporting `hexToContainer(hex)` (main at 15% opacity blended over #FFFFFF → returns a hex/rgb string), `hexToOnContainer(hex)` (main darkened 40%), and `deriveScheduleXColors(main)` returning `{ main, container, onContainer }`. Implement the math inline (no third-party color lib per RESEARCH Don't-Hand-Roll note — it's simple enough). Write colorUtils.test.ts asserting the derivations for a known hex. Create `apps/pwa/src/lib/calendarConfig.ts` exporting `export const WEEK_START_DAY = 0` with the inline comment that Schedule-X uses 7=Sunday, a translation `const sxFirstDay = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY` exposed as an exported `SX_FIRST_DAY_OF_WEEK`, the view factory list (`createViewDay`, `createViewWeek`, `createViewMonthGrid`, `createViewMonthAgenda` from @schedule-x/calendar), and `buildCalendarConfig(members: MemberCalendarConfig[])` returning `{ calendars }` keyed by String(userId) plus a reserved `'shared'` entry using deriveScheduleXColors('#F25C7A'). Per-member entries use deriveScheduleXColors(member.color). The `String(userId)` + `'shared'` key scheme here is the routing contract hydrateEvents (Task 3) must match — keep them aligned. Limit usage to the confirmed Schedule-X API surface (Pitfall 6). Turn the Plan 01 RED calendarConfig.test.ts green (it asserts the 0→7 translation). + cd apps/pwa && pnpm test -- src/lib/colorUtils.test.ts src/lib/calendarConfig.test.ts @@ -187,6 +189,7 @@ windowed fetchEvents, updated main.tsx, Schedule-X deps installed. Create `apps/pwa/src/store/calendarStore.ts` exporting `useCalendarStore` (Zustand `create`) with state: `selectedView:string`, `selectedDate:string`, `openEventId:string|null`, `calendarRange:{start:string;end:string}` and setters. selectedView is initialized from localStorage keyed by breakpoint group (`window.matchMedia('(max-width:767px)').matches ? 'phone' : 'tablet-desktop'`), defaulting to 'month-agenda' on phone / 'month-grid' on tablet-desktop (D-05); setSelectedView writes back to localStorage under `calendarView.{group}`. calendarRange defaults to the current month ± 1 week (do NOT depend on Schedule-X onRangeUpdate for the first fetch — A4/Open Q2). Server events NEVER enter this store. Add `zustand` to apps/pwa deps if not already present. In `apps/pwa/src/api/client.ts`, REPLACE the old unwindowed `fetchEvents()` and its `CalendarEvent`/`EventsResponse` types with: `CalendarOccurrence` interface (mirror the server contract — include calendarId, ownerUserId, isShared so hydrateEvents can route), `OccurrencesResponse { occurrences: CalendarOccurrence[] }`, and `fetchEvents(start:string, end:string): Promise` calling `/api/events?start=${start}&end=${end}` with `credentials:'include'` and the same `if(!res.ok) throw` pattern as fetchMe. Note: EventProof.tsx referenced the old fetchEvents — leave EventProof for Plan 05 to remove; if the type change breaks its build, update EventProof minimally to compile (it is replaced in Plan 05). + cd apps/pwa && pnpm test -- src/lib/hydrateEvents.test.ts @@ -207,20 +210,22 @@ windowed fetchEvents, updated main.tsx, Schedule-X deps installed. + ## Trust Boundaries -| Boundary | Description | -|----------|-------------| -| localStorage → store init | persisted view string read at startup | +| Boundary | Description | +| --------------------------- | ----------------------------------------------- | +| localStorage → store init | persisted view string read at startup | | server JSON → hydrateEvents | occurrence strings parsed into Temporal objects | ## STRIDE Threat Register -| Threat ID | Category | Component | Disposition | Mitigation Plan | -|-----------|----------|-----------|-------------|-----------------| -| T-02c-01 | Tampering | localStorage selectedView | accept | UI-only state; an invalid stored view falls back to the D-05 default; no security impact (single-device, two-person app) | -| T-02c-02 | Tampering | hydrateEvents string parsing | accept | Temporal.from throws on malformed input surfaced as a React Query error, not a security boundary; data already passed server zod validation | -| T-02c-SC | Tampering | @schedule-x/*, temporal-polyfill, lucide-react, zustand installs | mitigate | All Approved in RESEARCH §Package Legitimacy (mainstream, no postinstall); pinned versions; no [ASSUMED]/[SUS] | +| Threat ID | Category | Component | Disposition | Mitigation Plan | +| --------- | --------- | ----------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| T-02c-01 | Tampering | localStorage selectedView | accept | UI-only state; an invalid stored view falls back to the D-05 default; no security impact (single-device, two-person app) | +| T-02c-02 | Tampering | hydrateEvents string parsing | accept | Temporal.from throws on malformed input surfaced as a React Query error, not a security boundary; data already passed server zod validation | +| T-02c-SC | Tampering | @schedule-x/\*, temporal-polyfill, lucide-react, zustand installs | mitigate | All Approved in RESEARCH §Package Legitimacy (mainstream, no postinstall); pinned versions; no [ASSUMED]/[SUS] | + @@ -230,26 +235,28 @@ windowed fetchEvents, updated main.tsx, Schedule-X deps installed. + - Clean-theme token layer + Schedule-X overrides present (D-01/D-02) - firstDayOfWeek 0→7 translation encoded; per-calendar config built from member colors - All-day Temporal PlainDate guard in place - hydrateEvents calendarId routes by isShared/ownerUserId to match buildCalendarConfig keys - Zustand UI store + windowed fetchEvents ready for Plan 04 - + + ## Artifacts this phase produces (Plan 03) -- CSS custom properties: --color-*, --space-*, --text-*, --font-family-base, --bp-*, --sx-color-* overrides (tokens.css) +- CSS custom properties: --color-_, --space-_, --text-_, --font-family-base, --bp-_, --sx-color-\* overrides (tokens.css) - token object export (tokens.ts); styles/index.css global reset - `hexToContainer`, `hexToOnContainer`, `deriveScheduleXColors` (colorUtils.ts) - `WEEK_START_DAY`, `SX_FIRST_DAY_OF_WEEK`, `buildCalendarConfig`, `MemberCalendarConfig` (calendarConfig.ts) - `hydrateEvents`, `ScheduleXEvent` (hydrateEvents.ts) — calendarId routed by isShared/ownerUserId - `useCalendarStore` Zustand store + CalendarStore state shape (calendarStore.ts) - `fetchEvents(start,end)`, `CalendarOccurrence`, `OccurrencesResponse` (client.ts — replaces old unwindowed versions) -- @schedule-x/* + temporal-polyfill + lucide-react + zustand dependencies +- @schedule-x/\* + temporal-polyfill + lucide-react + zustand dependencies - main.tsx Temporal-polyfill-first import block - + Create `.planning/phases/02-calendar-display/02-03-SUMMARY.md` when done diff --git a/.planning/milestones/v1.0-phases/02-calendar-display/02-03-SUMMARY.md b/.planning/milestones/v1.0-phases/02-calendar-display/02-03-SUMMARY.md index b1eec8d..8f2e87a 100644 --- a/.planning/milestones/v1.0-phases/02-calendar-display/02-03-SUMMARY.md +++ b/.planning/milestones/v1.0-phases/02-calendar-display/02-03-SUMMARY.md @@ -66,11 +66,13 @@ CSS custom-property token layer with Schedule-X overrides, hex-blend color utili ### Task 1: Schedule-X Stack + Token Layer + main.tsx Import Order **Installed packages** in `apps/pwa`: + - `@schedule-x/calendar@4.6.0`, `@schedule-x/react@4.1.0`, `@schedule-x/theme-default@4.6.0` - `@schedule-x/event-modal@4.6.0`, `@schedule-x/events-service@4.6.0` - `temporal-polyfill@0.3.2`, `lucide-react@1.17.0` **`apps/pwa/src/styles/tokens.css`** — CSS custom properties declaring: + - Surface/border/text palette: `--color-surface`, `--color-surface-dim`, `--color-surface-raised`, `--color-border`, `--color-border-subtle`, `--color-text-primary/secondary/muted`, `--color-focus-ring`, `--color-overlay` - Calendar colors: `--color-member-0..5` + `--color-shared-family: #F25C7A` + `--color-destructive` - Spacing scale: `--space-1..12` (multiples of 4px) @@ -84,6 +86,7 @@ CSS custom-property token layer with Schedule-X overrides, hex-blend color utili **`apps/pwa/src/styles/index.css`** — imports tokens.css + minimal global reset. **`apps/pwa/src/main.tsx`** — updated with load-bearing import order: + 1. `import 'temporal-polyfill/global'` (must be first) 2. `import '@schedule-x/theme-default/dist/index.css'` (SX layout CSS) 3. `import './styles/index.css'` (token overrides win cascade) @@ -91,11 +94,13 @@ CSS custom-property token layer with Schedule-X overrides, hex-blend color utili ### Task 2: colorUtils + calendarConfig — RED Stubs Turned GREEN **`apps/pwa/src/lib/colorUtils.ts`** exports: + - `hexToContainer(hex)` — alpha blends at 15% opacity over white - `hexToOnContainer(hex)` — darkens 40% (channel multiply by 0.6) - `deriveScheduleXColors(main)` returning `{ main, container, onContainer }` **`apps/pwa/src/lib/calendarConfig.ts`** exports: + - `WEEK_START_DAY = 0` (JS Sunday convention) - `SX_FIRST_DAY_OF_WEEK = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY` - `buildCalendarConfig(members)` returning `{ firstDayOfWeek: 7, calendars }` with `'shared'` (rose) + per-member entries keyed by `String(userId)` @@ -105,6 +110,7 @@ CSS custom-property token layer with Schedule-X overrides, hex-blend color utili ### Task 3: hydrateEvents + calendarStore + windowed fetchEvents — RED Stubs Turned GREEN **`apps/pwa/src/lib/hydrateEvents.ts`**: + - `allDay:true` uses `Temporal.PlainDate.from(occ.start)` (guards all-day date shift) - `allDay:false` uses `Temporal.ZonedDateTime.from(occ.start/end)` - `calendarId = occ.isShared ? 'shared' : String(occ.ownerUserId)` — never `String(occ.calendarId)` @@ -113,11 +119,13 @@ CSS custom-property token layer with Schedule-X overrides, hex-blend color utili `hydrateEvents.test.ts` (Plan 01 RED stubs) — all 4 assertions now GREEN. **`apps/pwa/src/store/calendarStore.ts`** Zustand store: + - `selectedView` — from localStorage per breakpoint group; D-05 defaults - `calendarRange` — month ± 7-day buffer for initial TanStack Query key - `openEventId`, `selectedDate` — UI-only, not persisted **`apps/pwa/src/api/client.ts`**: + - Added `CalendarOccurrence`, `OccurrencesResponse`, `fetchEvents(start, end)` - Kept deprecated `CalendarEvent`, `EventsResponse`, `fetchEventsLegacy()` for EventProof.tsx (removed Plan 05) @@ -131,6 +139,7 @@ tsc --noEmit: clean (0 errors) ``` All Wave 1 RED stubs are GREEN: + - `calendarConfig.test.ts` — 4/4 pass - `hydrateEvents.test.ts` — 4/4 pass - `colorUtils.test.ts` — 10/10 pass @@ -140,6 +149,7 @@ All Wave 1 RED stubs are GREEN: ### Auto-fixed Issues **1. [Rule 2 - Missing Critical Functionality] Added temporal-polyfill/global to hydrateEvents.test.ts** + - **Found during:** Task 3 test run - **Issue:** Plan 01 RED stub lacked `import 'temporal-polyfill/global'`; jsdom has no native Temporal - **Fix:** Added as first import in `hydrateEvents.test.ts` @@ -147,6 +157,7 @@ All Wave 1 RED stubs are GREEN: - **Commit:** f377d7c **2. [Rule 2 - Missing Critical Functionality] Added fetchEventsLegacy() to preserve EventProof** + - **Found during:** Task 3 — updating client.ts - **Issue:** EventProof.tsx called no-arg `fetchEvents()` and used `CalendarEvent` fields not on `CalendarOccurrence` - **Fix:** Added `fetchEventsLegacy()` (deprecated) + updated EventProof to use it; plan says it is replaced in Plan 05 @@ -164,6 +175,7 @@ No new threat surface beyond the plan's threat model. All packages pre-approved ## Self-Check: PASSED Files created: + - [x] apps/pwa/src/styles/tokens.css - [x] apps/pwa/src/styles/tokens.ts - [x] apps/pwa/src/styles/index.css @@ -174,6 +186,7 @@ Files created: - [x] apps/pwa/src/store/calendarStore.ts Commits: + - [x] 0911a23 — Task 1: Schedule-X stack + token layer + main.tsx - [x] 43554f4 — Task 2: colorUtils + calendarConfig; calendarConfig stubs GREEN - [x] f377d7c — Task 3: hydrateEvents + calendarStore + windowed fetchEvents; all stubs GREEN diff --git a/.planning/milestones/v1.0-phases/02-calendar-display/02-04-PLAN.md b/.planning/milestones/v1.0-phases/02-calendar-display/02-04-PLAN.md index 93e1f8a..31b177f 100644 --- a/.planning/milestones/v1.0-phases/02-calendar-display/02-04-PLAN.md +++ b/.planning/milestones/v1.0-phases/02-calendar-display/02-04-PLAN.md @@ -3,7 +3,7 @@ phase: 02-calendar-display plan: 04 type: execute wave: 3 -depends_on: ["02-02", "02-03"] +depends_on: ['02-02', '02-03'] files_modified: - apps/pwa/src/components/CalendarShell.tsx - apps/pwa/src/App.tsx @@ -14,32 +14,32 @@ user_setup: [] must_haves: truths: - - "Opening the app renders a Schedule-X calendar populated with REAL windowed Fastmail occurrences fetched from /api/events" + - 'Opening the app renders a Schedule-X calendar populated with REAL windowed Fastmail occurrences fetched from /api/events' - "Events render in their owner's member color; shared-family events render in the reserved rose color (D-06)" - - "The user can switch between day, week, month, and agenda views and events render in each (D-04)" - - "Recurring events show all in-window occurrences; all-day events appear as full-day banners on the correct date with no shift" - - "The visible window drives the TanStack Query key; navigating to a new window refetches" - - "Default view is agenda on phone and month on tablet/desktop (D-05)" + - 'The user can switch between day, week, month, and agenda views and events render in each (D-04)' + - 'Recurring events show all in-window occurrences; all-day events appear as full-day banners on the correct date with no shift' + - 'The visible window drives the TanStack Query key; navigating to a new window refetches' + - 'Default view is agenda on phone and month on tablet/desktop (D-05)' artifacts: - - path: "apps/pwa/src/components/CalendarShell.tsx" - provides: "Schedule-X calendar wired to TanStack Query + hydrateEvents + Zustand range" + - path: 'apps/pwa/src/components/CalendarShell.tsx' + provides: 'Schedule-X calendar wired to TanStack Query + hydrateEvents + Zustand range' min_lines: 60 - - path: "apps/pwa/src/App.tsx" - provides: "renders CalendarShell as the app root (replaces EventProof landing)" - contains: "CalendarShell" + - path: 'apps/pwa/src/App.tsx' + provides: 'renders CalendarShell as the app root (replaces EventProof landing)' + contains: 'CalendarShell' key_links: - - from: "apps/pwa/src/components/CalendarShell.tsx" - to: "/api/events" + - from: 'apps/pwa/src/components/CalendarShell.tsx' + to: '/api/events' via: "useQuery(['events',start,end]) → fetchEvents" - pattern: "fetchEvents" - - from: "apps/pwa/src/components/CalendarShell.tsx" - to: "Schedule-X eventsService" - via: "eventsService.set(hydrateEvents(data.occurrences))" - pattern: "hydrateEvents" - - from: "apps/pwa/src/components/CalendarShell.tsx" - to: "apps/pwa/src/store/calendarStore.ts" - via: "calendarRange drives query key; onRangeUpdate updates it" - pattern: "useCalendarStore" + pattern: 'fetchEvents' + - from: 'apps/pwa/src/components/CalendarShell.tsx' + to: 'Schedule-X eventsService' + via: 'eventsService.set(hydrateEvents(data.occurrences))' + pattern: 'hydrateEvents' + - from: 'apps/pwa/src/components/CalendarShell.tsx' + to: 'apps/pwa/src/store/calendarStore.ts' + via: 'calendarRange drives query key; onRangeUpdate updates it' + pattern: 'useCalendarStore' --- @@ -95,6 +95,7 @@ Output: CalendarShell.tsx wired to the full data pipeline; App.tsx renders it; a Render `` filling the available space. Use token-based styling only (className/var(--token)) — no hard-coded hex/px (Phase 2 rule). The AppNav/ViewToolbar/ColorLegend/popover chrome is Plan 05; CalendarShell here may render a minimal toolbar placeholder or rely on Schedule-X's built-in controls so the four views are switchable and verifiable now. Update `apps/pwa/src/App.tsx`: replace the EventProof landing content with `` as the app root. Migrate any remaining hard-coded hex/px in App.tsx to tokens (Phase 2 rule). Leave the meQuery sign-in-required error branch intact for unauthenticated state. + cd apps/pwa && grep -q "ScheduleXCalendar" src/components/CalendarShell.tsx && grep -q "hydrateEvents" src/components/CalendarShell.tsx && grep -q "queryKey: \['events'" src/components/CalendarShell.tsx && grep -q "CalendarShell" src/App.tsx && echo SHELL_WIRED @@ -143,18 +144,20 @@ Output: CalendarShell.tsx wired to the full data pipeline; App.tsx renders it; a + ## Trust Boundaries -| Boundary | Description | -|----------|-------------| +| Boundary | Description | +| ---------------------------------- | -------------------------------------------------- | | /api/events JSON → calendar render | server occurrences rendered into the DOM via React | ## STRIDE Threat Register -| Threat ID | Category | Component | Disposition | Mitigation Plan | -|-----------|----------|-----------|-------------|-----------------| -| T-02d-01 | Tampering (XSS) | event title/location/description in render | mitigate | React JSX default escaping; never dangerouslySetInnerHTML for event fields (carried into Plan 05 popover) | -| T-02d-02 | Information disclosure | events from another member's account | accept | API already enforces auth + per-credential scoping (Plan 02 / CAL-08-DECISION); client renders only what the authed endpoint returns | +| Threat ID | Category | Component | Disposition | Mitigation Plan | +| --------- | ---------------------- | ------------------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| T-02d-01 | Tampering (XSS) | event title/location/description in render | mitigate | React JSX default escaping; never dangerouslySetInnerHTML for event fields (carried into Plan 05 popover) | +| T-02d-02 | Information disclosure | events from another member's account | accept | API already enforces auth + per-credential scoping (Plan 02 / CAL-08-DECISION); client renders only what the authed endpoint returns | + @@ -164,19 +167,21 @@ Output: CalendarShell.tsx wired to the full data pipeline; App.tsx renders it; a + - Real Fastmail occurrences render color-coded across day/week/month/agenda (CAL-02, CAL-03) - Recurring + all-day occurrences render correctly in-window (CAL-07 display) - Window navigation refetches via TanStack Query - + + ## Artifacts this phase produces (Plan 04) - `CalendarShell` (React component) — apps/pwa/src/components/CalendarShell.tsx - App.tsx now renders CalendarShell as root (EventProof landing removed from render path) - CalendarShell.test.tsx (CAL-03 render smoke) - Schedule-X eventsService + eventModal plugin instances + useCalendarApp config in CalendarShell - + Create `.planning/phases/02-calendar-display/02-04-SUMMARY.md` when done diff --git a/.planning/milestones/v1.0-phases/02-calendar-display/02-04-SUMMARY.md b/.planning/milestones/v1.0-phases/02-calendar-display/02-04-SUMMARY.md index 7c290ed..a4863db 100644 --- a/.planning/milestones/v1.0-phases/02-calendar-display/02-04-SUMMARY.md +++ b/.planning/milestones/v1.0-phases/02-calendar-display/02-04-SUMMARY.md @@ -1,12 +1,12 @@ --- phase: 02-calendar-display -plan: "04" +plan: '04' subsystem: pwa-calendar-shell tags: [schedule-x, tanstack-query, zustand, hydrate-events, temporal, calendar-shell, smoke-test] dependency_graph: - requires: ["02-02", "02-03"] + requires: ['02-02', '02-03'] provides: [CalendarShell, App-root-calendar, CAL-03-smoke-test] - affects: ["02-05"] + affects: ['02-05'] tech_stack: added: [] patterns: @@ -24,13 +24,13 @@ key_files: - apps/pwa/src/App.tsx - apps/pwa/vitest.config.ts decisions: - - "CalendarShell callbacks nested under config.callbacks per CalendarConfigExternal type (not top-level)" - - "DateRange.start/end are Temporal.ZonedDateTime; extracted to ISO date via .toPlainDate().toString() for Zustand" - - "test-setup.ts as vitest setupFiles for window.matchMedia polyfill — calendarStore creates Zustand store at module load time before test polyfills run" - - "App.tsx simplified to single-line wrapper; EventProof and health probe removed from render path" + - 'CalendarShell callbacks nested under config.callbacks per CalendarConfigExternal type (not top-level)' + - 'DateRange.start/end are Temporal.ZonedDateTime; extracted to ISO date via .toPlainDate().toString() for Zustand' + - 'test-setup.ts as vitest setupFiles for window.matchMedia polyfill — calendarStore creates Zustand store at module load time before test polyfills run' + - 'App.tsx simplified to single-line wrapper; EventProof and health probe removed from render path' metrics: - duration: "~12m" - completed: "2026-06-05" + duration: '~12m' + completed: '2026-06-05' tasks_completed: 2 files_created: 3 files_modified: 2 @@ -45,6 +45,7 @@ Schedule-X CalendarShell component wired to TanStack Query windowed fetch, hydra ### Task 1: CalendarShell + App.tsx **`apps/pwa/src/components/CalendarShell.tsx`** (186 lines): + - `useCalendarApp(config, [eventsService, eventModal])` with all four view factories: `createViewDay`, `createViewWeek`, `createViewMonthGrid`, `createViewMonthAgenda` - `defaultView` from Zustand persisted view (D-05 defaults: phone→month-agenda, tablet-desktop→month-grid already encoded in store) - `firstDayOfWeek: SX_FIRST_DAY_OF_WEEK` (7 = Sunday, Temporal convention) — Pitfall 1 guard @@ -61,6 +62,7 @@ Schedule-X CalendarShell component wired to TanStack Query windowed fetch, hydra ### Task 2: CalendarShell Render Smoke Test (CAL-03) **`apps/pwa/src/components/CalendarShell.test.tsx`** (6 tests): + - Render-without-throw smoke (validates `@schedule-x/react@4.1.0` ↔ `@schedule-x/calendar@4.6.0` import compatibility — Pitfall 6) - `ScheduleXCalendar` mounts with non-null `calendarApp` - `hydrateEvents` called with both timed + all-day occurrences; `eventsService.set()` called with hydrated events @@ -87,6 +89,7 @@ vite build: clean (474.27 kB, built in 395ms) ### Auto-fixed Issues **1. [Rule 1 - Bug] CalendarCallbacks nested under config.callbacks — not top-level** + - **Found during:** Task 1 — tsc reported `onRangeUpdate` not in `CalendarConfigExternal` - **Issue:** Research pattern (RESEARCH.md Pattern 4) showed `onRangeUpdate` at the top level of the config object. The actual type (`CalendarConfigExternal extends Partial`) carries `callbacks?: CalendarCallbacks` where `CalendarCallbacks` contains `onRangeUpdate` and `onEventClick`. They must be nested under `config.callbacks`. - **Fix:** Moved `onRangeUpdate` and `onEventClick` into `callbacks: { ... }` in the `useCalendarApp` config @@ -94,6 +97,7 @@ vite build: clean (474.27 kB, built in 395ms) - **Commit:** b79f649 **2. [Rule 3 - Blocking] window.matchMedia not defined in jsdom** + - **Found during:** Task 2 — test run crashed at Zustand store initialisation - **Issue:** `calendarStore.ts` calls `window.matchMedia` inside `readPersistedView()` which runs at `create()` time — i.e. at module load, before any test-file-level polyfill runs. Inline `Object.defineProperty` in the test file is too late. - **Fix:** Created `src/test-setup.ts` with the polyfill; added `setupFiles: ['./src/test-setup.ts']` to `vitest.config.ts` @@ -107,15 +111,18 @@ None — CalendarShell fetches real windowed data from `/api/events`, hydrates t ## Threat Flags No new threat surface beyond the plan's threat model. + - T-02d-01 (XSS): CalendarShell uses React JSX default escaping for all event field rendering — no `dangerouslySetInnerHTML`. Carried to Plan 05 popover. ## Self-Check: PASSED Files created: + - [x] apps/pwa/src/components/CalendarShell.tsx - [x] apps/pwa/src/components/CalendarShell.test.tsx - [x] apps/pwa/src/test-setup.ts Commits: + - [x] b79f649 — Task 1: CalendarShell + App.tsx - [x] f0af43c — Task 2: CalendarShell smoke test diff --git a/.planning/milestones/v1.0-phases/02-calendar-display/02-05-PLAN.md b/.planning/milestones/v1.0-phases/02-calendar-display/02-05-PLAN.md index bbb647f..4ac84f0 100644 --- a/.planning/milestones/v1.0-phases/02-calendar-display/02-05-PLAN.md +++ b/.planning/milestones/v1.0-phases/02-calendar-display/02-05-PLAN.md @@ -3,7 +3,7 @@ phase: 02-calendar-display plan: 05 type: execute wave: 4 -depends_on: ["02-04"] +depends_on: ['02-04'] files_modified: - apps/pwa/src/components/EventDetailPopover.tsx - apps/pwa/src/components/ColorLegend.tsx @@ -20,30 +20,30 @@ user_setup: [] must_haves: truths: - - "Tapping any event opens a read-only detail popover (title, date/time, location, description, calendar name + owner color) built for Phase 3 reuse as the edit surface" + - 'Tapping any event opens a read-only detail popover (title, date/time, location, description, calendar name + owner color) built for Phase 3 reuse as the edit surface' - "A color legend (member → color, plus 'Family' rose row) is always visible so ownership is decodable" - - "Initial load shows a shimmer skeleton; a successful fetch with zero events shows the empty state; a failed fetch shows the error state with a working Retry" - - "All chrome (AppNav, ViewToolbar, legend) is token-styled with 44px minimum touch targets and is keyboard/focus accessible" - - "The popover never renders event fields via dangerouslySetInnerHTML (XSS guard) and traps focus with Escape-to-close" + - 'Initial load shows a shimmer skeleton; a successful fetch with zero events shows the empty state; a failed fetch shows the error state with a working Retry' + - 'All chrome (AppNav, ViewToolbar, legend) is token-styled with 44px minimum touch targets and is keyboard/focus accessible' + - 'The popover never renders event fields via dangerouslySetInnerHTML (XSS guard) and traps focus with Escape-to-close' artifacts: - - path: "apps/pwa/src/components/EventDetailPopover.tsx" - provides: "read-only event detail popover; Phase-3-reusable edit surface; focus trap + XSS-safe" - exports: ["EventDetailPopover"] - - path: "apps/pwa/src/components/ColorLegend.tsx" - provides: "always-visible member→color legend with Family row" - exports: ["ColorLegend"] - - path: "apps/pwa/src/components/SkeletonCalendar.tsx" - provides: "shimmer loading skeleton (month + agenda variants)" - exports: ["SkeletonCalendar"] + - path: 'apps/pwa/src/components/EventDetailPopover.tsx' + provides: 'read-only event detail popover; Phase-3-reusable edit surface; focus trap + XSS-safe' + exports: ['EventDetailPopover'] + - path: 'apps/pwa/src/components/ColorLegend.tsx' + provides: 'always-visible member→color legend with Family row' + exports: ['ColorLegend'] + - path: 'apps/pwa/src/components/SkeletonCalendar.tsx' + provides: 'shimmer loading skeleton (month + agenda variants)' + exports: ['SkeletonCalendar'] key_links: - - from: "apps/pwa/src/components/CalendarShell.tsx" - to: "apps/pwa/src/components/EventDetailPopover.tsx" - via: "customComponents.eventModal + openEventId from Zustand" - pattern: "EventDetailPopover" - - from: "apps/pwa/src/components/CalendarShell.tsx" - to: "SkeletonCalendar | EmptyState | error state" - via: "TanStack Query isLoading/empty/isError branches" - pattern: "SkeletonCalendar" + - from: 'apps/pwa/src/components/CalendarShell.tsx' + to: 'apps/pwa/src/components/EventDetailPopover.tsx' + via: 'customComponents.eventModal + openEventId from Zustand' + pattern: 'EventDetailPopover' + - from: 'apps/pwa/src/components/CalendarShell.tsx' + to: 'SkeletonCalendar | EmptyState | error state' + via: 'TanStack Query isLoading/empty/isError branches' + pattern: 'SkeletonCalendar' --- @@ -98,6 +98,7 @@ EventProof removed; human-verify checkpoint. Wire into CalendarShell: pass `customComponents={{ eventModal: EventDetailPopover }}` to ``, and ensure the event-click path sets openEventId so the popover opens. Keep the eventsService/eventModal plugin wiring from Plan 04. Write `EventDetailPopover.test.tsx` (jsdom): renders an event's fields as text, Escape triggers close, and asserts no dangerouslySetInnerHTML usage (render a title containing an HTML-looking string and assert it appears escaped as text). + cd apps/pwa && pnpm test -- src/components/EventDetailPopover.test.tsx @@ -134,6 +135,7 @@ EventProof removed; human-verify checkpoint. In CalendarShell, render AppNav + ViewToolbar + ColorLegend chrome around `` (grid is the primary focal point per reviewer note). Branch on the events query: `isLoading` (initial) → SkeletonCalendar; success + `occurrences.length === 0` → EmptyState; `isError` (after retry:2) → error state replacing the grid with heading "Couldn't load events", body "Check your connection and try again.", and a "Retry" button calling `queryClient.refetchQueries({ queryKey: ['events'] })`. All token-styled. Delete `apps/pwa/src/components/EventProof.tsx` and remove any remaining imports/references to it (Plan 04 removed it from the render path; confirm the file and its imports are gone). + cd apps/pwa && grep -q "SkeletonCalendar" src/components/CalendarShell.tsx && grep -q "EmptyState" src/components/CalendarShell.tsx && grep -q "Couldn't load events" src/components/CalendarShell.tsx && grep -q "ColorLegend" src/components/CalendarShell.tsx && echo STATES_WIRED @@ -174,18 +176,20 @@ EventProof removed; human-verify checkpoint. + ## Trust Boundaries -| Boundary | Description | -|----------|-------------| +| Boundary | Description | +| -------------------------- | ---------------------------------------------------- | | event fields → popover DOM | title/location/description rendered into the popover | ## STRIDE Threat Register -| Threat ID | Category | Component | Disposition | Mitigation Plan | -|-----------|----------|-----------|-------------|-----------------| -| T-02e-01 | Tampering (XSS) | EventDetailPopover event fields | mitigate | Plain-text JSX children only; no dangerouslySetInnerHTML; test asserts HTML-looking title renders escaped | -| T-02e-02 | Denial of service | Retry button hammering /api/events | accept | Manual user action, retry:2 backoff already on the query; two-person self-hosted app, negligible risk | +| Threat ID | Category | Component | Disposition | Mitigation Plan | +| --------- | ----------------- | ---------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------- | +| T-02e-01 | Tampering (XSS) | EventDetailPopover event fields | mitigate | Plain-text JSX children only; no dangerouslySetInnerHTML; test asserts HTML-looking title renders escaped | +| T-02e-02 | Denial of service | Retry button hammering /api/events | accept | Manual user action, retry:2 backoff already on the query; two-person self-hosted app, negligible risk | + @@ -195,14 +199,16 @@ EventProof removed; human-verify checkpoint. + - Color-coded events + legend decode ownership (criterion 1) - All four views render events correctly (criterion 2) - Recurring occurrences correct incl. DST (criterion 3) - All-day events as full-day banners with no shift (criterion 4) - Tap-to-expand popover + skeleton/empty/error states polished and accessible - + + ## Artifacts this phase produces (Plan 05) - `EventDetailPopover` (React component, Phase-3-reusable edit surface) — EventDetailPopover.tsx @@ -210,7 +216,7 @@ EventProof removed; human-verify checkpoint. - CalendarShell: chrome + loading/empty/error branches + customComponents.eventModal wiring - EventProof.tsx DELETED (Phase 1 proof component retired) - EventDetailPopover.test.tsx - + Create `.planning/phases/02-calendar-display/02-05-SUMMARY.md` when done diff --git a/.planning/milestones/v1.0-phases/02-calendar-display/02-05-SUMMARY.md b/.planning/milestones/v1.0-phases/02-calendar-display/02-05-SUMMARY.md index db4568b..4cbbad6 100644 --- a/.planning/milestones/v1.0-phases/02-calendar-display/02-05-SUMMARY.md +++ b/.planning/milestones/v1.0-phases/02-calendar-display/02-05-SUMMARY.md @@ -56,6 +56,7 @@ Read-only EventDetailPopover (XSS-safe, accessible, Phase-3-reusable), always-vi ### Task 1: EventDetailPopover + CalendarShell wiring (TDD) **`apps/pwa/src/components/EventDetailPopover.tsx`** (259 lines): + - Resolves open event by `openEventId` (Zustand) from TanStack Query `['events']` cache using `queryClient.getQueriesData` - Dual-mode: standalone (primary, driven by Zustand) + `customComponents.eventModal` prop from Schedule-X - Renders title (heading), date/time, location (with MapPin icon when present), description (max-4-lines scroll), calendar name + color swatch footer @@ -67,6 +68,7 @@ Read-only EventDetailPopover (XSS-safe, accessible, Phase-3-reusable), always-vi - Wired in CalendarShell: `customComponents={{ eventModal: EventDetailPopover }}` AND rendered standalone after the layout **`apps/pwa/src/components/EventDetailPopover.test.tsx`** (192 lines, TDD RED → GREEN): + - TDD RED commit: tests written first, failing because file missing - GREEN commit: implementation makes all 12 tests pass - Tests: title/location/description/calendarName render as text; heading role; Escape/close-button/backdrop all call `setOpenEventId(null)`; null openEventId renders nothing @@ -77,31 +79,37 @@ Read-only EventDetailPopover (XSS-safe, accessible, Phase-3-reusable), always-vi ### Task 2: Chrome components, state branches, EventProof retired **`apps/pwa/src/components/ColorLegend.tsx`**: + - One row per member: 12px color circle (`aria-label="{name}: {hex}"`) + display name - "Family" row always rendered last using `--color-shared-family` (#F25C7A) - Font: 13px label weight, `--color-text-secondary` **`apps/pwa/src/components/AppNav.tsx`**: + - Phone: 48px top bar — "FamilySync" display text left, user avatar right with `aria-label` + `title` per reviewer note - Tablet/desktop: 240px left sidebar — app name + "Calendars" section header + `` **`apps/pwa/src/components/ViewToolbar.tsx`**: + - Today | ‹ | › | Day | Week | Month | Agenda - 44px min-height on all buttons; keyboard-activatable - Active view: `rgba(74, 144, 217, 0.12)` surface tint (NOT accent color) per UI-SPEC 60/30/10 rule - Navigation via `calendarApp.$app.calendarState.setRange()` / `setView()` (internal Schedule-X API) **`apps/pwa/src/components/SkeletonCalendar.tsx`**: + - Month variant: 6×7 grid of shimmer cells + 7-col header - Agenda variant: 4 date-group blocks, 2–3 rows each at 60–90% widths - `aria-busy="true"`, `aria-label="Loading calendar"` on root - Shimmer: `@keyframes shimmer` from tokens.css, `background-size: 200% 100%`, 1.5s infinite **`apps/pwa/src/components/EmptyState.tsx`**: + - `CalendarDays` lucide icon (32px, `--color-text-muted`) - Heading "Nothing here" + body "No events in this period. Try a different date or switch views." per UI-SPEC copywriting **`apps/pwa/src/components/CalendarShell.tsx`** (major refactor): + - Phone: AppNav top bar → ViewToolbar → calendar content → ColorLegend below - Tablet/desktop: AppNav sidebar (240px) + main area (ViewToolbar → calendar content) - State branches: `isInitialLoading` → `SkeletonCalendar`; `isEventsError` → "Couldn't load events" + "Check your connection" + **Retry** button calling `queryClient.refetchQueries({ queryKey: ['events'] })`; `isEmptyResult` → `EmptyState`; success+data → `ScheduleXCalendar` @@ -125,6 +133,7 @@ vite build: clean (490.30 kB, 827ms) ### Auto-fixed Issues **1. [Rule 3 - Blocking] @testing-library/jest-dom not imported in test-setup.ts** + - **Found during:** Task 1 GREEN phase — `toHaveTextContent` threw "Invalid Chai property" - **Issue:** `@testing-library/jest-dom` extends Vitest/Chai's `expect` with DOM matchers. It was installed (in devDependencies) but never imported in `src/test-setup.ts`, so matchers were not registered. - **Fix:** Added `import '@testing-library/jest-dom'` to `src/test-setup.ts` @@ -132,6 +141,7 @@ vite build: clean (490.30 kB, 827ms) - **Commit:** 3eebfbf (bundled with Task 1 GREEN commit) **2. [Rule 1 - Bug] CalendarShell.test.tsx: synchronous getByTestId fails after loading state added** + - **Found during:** Task 2 verification — existing CalendarShell smoke test failed - **Issue:** The test did `screen.getByTestId('schedule-x-calendar')` synchronously, but CalendarShell now shows SkeletonCalendar while loading. The calendar element only appears after queries resolve. - **Fix:** Changed to `await screen.findByTestId('schedule-x-calendar')` (async, waits for element) @@ -139,6 +149,7 @@ vite build: clean (490.30 kB, 827ms) - **Commit:** 216ddce (bundled with Task 2 commit) **3. [Rule 1 - Bug] ViewToolbar: CalendarApp.setDate/decrementRange/incrementRange/setView don't exist on public API** + - **Found during:** Task 2 tsc check — 4 type errors - **Issue:** `CalendarApp` class only exposes `render`, `destroy`, `setTheme`, `getTheme`, and `events` (EventsFacade). Navigation methods (`setRange`, `setView`) live on the internal `$app.calendarState` (a `CalendarAppSingleton` property). - **Fix:** Changed `calendarApp` prop type to `any`, accessed internal state via `calendarApp.$app.calendarState` with runtime null-guards. Navigation uses `Temporal.Now.plainDateISO()` for today and `ZonedDateTime.until().days` for range inference. @@ -156,6 +167,7 @@ None — all components render from live data (TanStack Query cache) or accurate ## Threat Flags T-02e-01 mitigated: + - EventDetailPopover: title, description, location, calendarName all rendered as plain-text JSX children - Test asserts `` in title → `heading.innerHTML` does NOT contain `Team Meeting', description: 'Bold description', location: 'Room', -} +}; const ALLDAY_OCCURRENCE: CalendarOccurrence = { id: 'allday-uid::2026-06-20', @@ -90,182 +86,182 @@ const ALLDAY_OCCURRENCE: CalendarOccurrence = { location: null, description: null, hasRrule: false, -} +}; // ── Import component (after mocks are declared) ─────────────────────────────── -import { EventDetailPopover } from './EventDetailPopover.js' -import { useCalendarStore } from '../store/calendarStore.js' +import { EventDetailPopover } from './EventDetailPopover.js'; +import { useCalendarStore } from '../store/calendarStore.js'; // ── Helpers ─────────────────────────────────────────────────────────────────── function renderPopover(occurrence = TIMED_OCCURRENCE) { - mockOpenEventId = occurrence.id - ;(useCalendarStore as unknown as ReturnType).mockImplementation( + mockOpenEventId = occurrence.id; + (useCalendarStore as unknown as ReturnType).mockImplementation( (selector?: (s: Record) => unknown) => { const state = { openEventId: mockOpenEventId, setOpenEventId: mockSetOpenEventId, setEventForm: mockSetEventForm, setDeleteDialog: mockSetDeleteDialog, - } - if (typeof selector === 'function') return selector(state) - return state + }; + if (typeof selector === 'function') return selector(state); + return state; }, - ) + ); const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, - }) + }); // Pre-populate the events cache so EventDetailPopover can resolve by id - client.setQueryData(['events'], { occurrences: [occurrence] }) + client.setQueryData(['events'], { occurrences: [occurrence] }); return render( , - ) + ); } // ── Tests ───────────────────────────────────────────────────────────────────── describe('EventDetailPopover', () => { beforeEach(() => { - vi.clearAllMocks() - mockOpenEventId = null - }) + vi.clearAllMocks(); + mockOpenEventId = null; + }); it('renders the event title as a heading', () => { - renderPopover(TIMED_OCCURRENCE) - expect(screen.getByRole('heading')).toHaveTextContent('Team Standup') - }) + renderPopover(TIMED_OCCURRENCE); + expect(screen.getByRole('heading')).toHaveTextContent('Team Standup'); + }); it('renders location when present', () => { - renderPopover(TIMED_OCCURRENCE) - expect(screen.getByText(/Conference Room B/)).toBeDefined() - }) + renderPopover(TIMED_OCCURRENCE); + expect(screen.getByText(/Conference Room B/)).toBeDefined(); + }); it('renders description when present', () => { - renderPopover(TIMED_OCCURRENCE) - expect(screen.getByText(/Daily team sync meeting/)).toBeDefined() - }) + renderPopover(TIMED_OCCURRENCE); + expect(screen.getByText(/Daily team sync meeting/)).toBeDefined(); + }); it('renders owner name in footer for personal events', () => { - renderPopover(TIMED_OCCURRENCE) + renderPopover(TIMED_OCCURRENCE); // TIMED_OCCURRENCE is personal (isShared:false) with ownerName:'Alice' - expect(screen.getByText(/Alice/)).toBeDefined() - }) + expect(screen.getByText(/Alice/)).toBeDefined(); + }); it('renders "Family" in footer for shared calendar events', () => { - renderPopover(ALLDAY_OCCURRENCE) + renderPopover(ALLDAY_OCCURRENCE); // ALLDAY_OCCURRENCE has isShared:true — footer must show 'Family' - expect(screen.getByText('Family')).toBeDefined() - }) + expect(screen.getByText('Family')).toBeDefined(); + }); it('renders calendarName in footer when ownerName is null', () => { const noOwnerName: CalendarOccurrence = { ...TIMED_OCCURRENCE, id: 'no-owner-uid::2026-06-15T10:00:00', ownerName: null, - } - renderPopover(noOwnerName) - expect(screen.getByText(/My Calendar/)).toBeDefined() - }) + }; + renderPopover(noOwnerName); + expect(screen.getByText(/My Calendar/)).toBeDefined(); + }); it('renders an all-day event without crashing', () => { - renderPopover(ALLDAY_OCCURRENCE) - expect(screen.getByRole('heading')).toHaveTextContent('Birthday Party') - }) + renderPopover(ALLDAY_OCCURRENCE); + expect(screen.getByRole('heading')).toHaveTextContent('Birthday Party'); + }); it('close button has aria-label="Close"', () => { - renderPopover(TIMED_OCCURRENCE) - expect(screen.getByLabelText('Close')).toBeDefined() - }) + renderPopover(TIMED_OCCURRENCE); + expect(screen.getByLabelText('Close')).toBeDefined(); + }); it('pressing Escape calls setOpenEventId(null)', () => { - renderPopover(TIMED_OCCURRENCE) - fireEvent.keyDown(document, { key: 'Escape' }) - expect(mockSetOpenEventId).toHaveBeenCalledWith(null) - }) + renderPopover(TIMED_OCCURRENCE); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(mockSetOpenEventId).toHaveBeenCalledWith(null); + }); it('clicking the close button calls setOpenEventId(null)', () => { - renderPopover(TIMED_OCCURRENCE) - fireEvent.click(screen.getByLabelText('Close')) - expect(mockSetOpenEventId).toHaveBeenCalledWith(null) - }) + renderPopover(TIMED_OCCURRENCE); + fireEvent.click(screen.getByLabelText('Close')); + expect(mockSetOpenEventId).toHaveBeenCalledWith(null); + }); it('clicking the backdrop calls setOpenEventId(null)', () => { - renderPopover(TIMED_OCCURRENCE) - fireEvent.click(screen.getByTestId('popover-backdrop')) - expect(mockSetOpenEventId).toHaveBeenCalledWith(null) - }) + renderPopover(TIMED_OCCURRENCE); + fireEvent.click(screen.getByTestId('popover-backdrop')); + expect(mockSetOpenEventId).toHaveBeenCalledWith(null); + }); it('renders nothing when openEventId is null', () => { - mockOpenEventId = null - ;(useCalendarStore as unknown as ReturnType).mockImplementation( + mockOpenEventId = null; + (useCalendarStore as unknown as ReturnType).mockImplementation( (selector?: (s: Record) => unknown) => { const state = { openEventId: null, setOpenEventId: mockSetOpenEventId, setEventForm: mockSetEventForm, setDeleteDialog: mockSetDeleteDialog, - } - if (typeof selector === 'function') return selector(state) - return state + }; + if (typeof selector === 'function') return selector(state); + return state; }, - ) + ); const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, - }) + }); const { container } = render( , - ) - expect(container.firstChild).toBeNull() - }) + ); + expect(container.firstChild).toBeNull(); + }); it('XSS guard: HTML in title renders as escaped text, not as DOM elements', () => { - renderPopover(OCCURRENCE_WITH_HTML) - const heading = screen.getByRole('heading') + renderPopover(OCCURRENCE_WITH_HTML); + const heading = screen.getByRole('heading'); // Team Meeting') - }) + expect(heading.textContent).toContain('Team Meeting'); + }); it('XSS guard: HTML in description renders as escaped text', () => { - renderPopover(OCCURRENCE_WITH_HTML) - const descEl = screen.getByTestId('event-description') + renderPopover(OCCURRENCE_WITH_HTML); + const descEl = screen.getByTestId('event-description'); // must NOT be rendered as a bold element - expect(descEl.innerHTML).not.toContain('') - expect(descEl.textContent).toContain('Bold description') - }) + expect(descEl.innerHTML).not.toContain(''); + expect(descEl.textContent).toContain('Bold description'); + }); // ── Phase 3 footer: Edit/Delete actions ──────────────────────────────────── it('footer renders an "Edit" button', () => { - renderPopover(TIMED_OCCURRENCE) - expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument() - }) + renderPopover(TIMED_OCCURRENCE); + expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); + }); it('footer renders a "Delete" button', () => { - renderPopover(TIMED_OCCURRENCE) - expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument() - }) + renderPopover(TIMED_OCCURRENCE); + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); + }); it('clicking "Edit" opens EventForm in edit mode and closes popover', () => { - renderPopover(TIMED_OCCURRENCE) - fireEvent.click(screen.getByRole('button', { name: /edit/i })) - expect(mockSetEventForm).toHaveBeenCalledWith(true, 'edit', TIMED_OCCURRENCE.uid) - expect(mockSetOpenEventId).toHaveBeenCalledWith(null) - }) + renderPopover(TIMED_OCCURRENCE); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); + expect(mockSetEventForm).toHaveBeenCalledWith(true, 'edit', TIMED_OCCURRENCE.uid); + expect(mockSetOpenEventId).toHaveBeenCalledWith(null); + }); it('clicking "Delete" opens DeleteConfirmationDialog (setDeleteDialog)', () => { - renderPopover(TIMED_OCCURRENCE) - fireEvent.click(screen.getByRole('button', { name: /delete/i })) - expect(mockSetDeleteDialog).toHaveBeenCalledWith(true, TIMED_OCCURRENCE.uid) - }) + renderPopover(TIMED_OCCURRENCE); + fireEvent.click(screen.getByRole('button', { name: /delete/i })); + expect(mockSetDeleteDialog).toHaveBeenCalledWith(true, TIMED_OCCURRENCE.uid); + }); it('BUG-3 regression: IANA-bracketed start/end does not produce "Invalid Date" in rendered output', () => { // Fastmail events are serialized with IANA bracket notation e.g. '2026-06-18T08:00:00-04:00[America/Toronto]'. @@ -277,16 +273,16 @@ describe('EventDetailPopover', () => { uid: 'iana-bracket-uid', start: '2026-06-18T08:00:00-04:00[America/Toronto]', end: '2026-06-18T09:00:00-04:00[America/Toronto]', - } - renderPopover(occurrence) + }; + renderPopover(occurrence); // The date/time text must not contain 'Invalid Date' - const dialogEl = screen.getByRole('dialog') - expect(dialogEl.textContent).not.toContain('Invalid Date') + const dialogEl = screen.getByRole('dialog'); + expect(dialogEl.textContent).not.toContain('Invalid Date'); // It must contain recognizable date content (month name or a digit) // toLocaleDateString output varies by locale; check for a digit at minimum - const dateTimeText = dialogEl.textContent ?? '' - expect(dateTimeText).toMatch(/\d/) - }) -}) + const dateTimeText = dialogEl.textContent ?? ''; + expect(dateTimeText).toMatch(/\d/); + }); +}); diff --git a/apps/pwa/src/components/EventDetailPopover.tsx b/apps/pwa/src/components/EventDetailPopover.tsx index a57e7b7..26d144a 100644 --- a/apps/pwa/src/components/EventDetailPopover.tsx +++ b/apps/pwa/src/components/EventDetailPopover.tsx @@ -22,26 +22,26 @@ * - aria-modal="true", role="dialog" */ -import { useEffect, useRef } from 'react' -import { useQueryClient } from '@tanstack/react-query' -import { MapPin, Edit2, Trash2 } from 'lucide-react' -import { useCalendarStore } from '../store/calendarStore.js' -import type { CalendarOccurrence } from '../api/client.js' +import { useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { MapPin, Edit2, Trash2 } from 'lucide-react'; +import { useCalendarStore } from '../store/calendarStore.js'; +import type { CalendarOccurrence } from '../api/client.js'; // ── Types ────────────────────────────────────────────────────────────────── /** Shape of props passed by Schedule-X customComponents.eventModal */ interface ScheduleXEventModalProps { calendarEvent?: { - id?: string | number - title?: string - start?: unknown - end?: unknown - calendarId?: string - location?: string - description?: string - _familySync?: { uid: string; color: string; isShared: boolean } - } + id?: string | number; + title?: string; + start?: unknown; + end?: unknown; + calendarId?: string; + location?: string; + description?: string; + _familySync?: { uid: string; color: string; isShared: boolean }; + }; } // ── Helpers ──────────────────────────────────────────────────────────────── @@ -58,41 +58,41 @@ function formatDateTime(start: string, end: string, allDay: boolean): string { if (allDay) { // YYYY-MM-DD — format as a date without time try { - const d = new Date(start + 'T00:00:00') + const d = new Date(start + 'T00:00:00'); return d.toLocaleDateString(undefined, { weekday: 'short', year: 'numeric', month: 'long', day: 'numeric', - }) + }); } catch { - return start + return start; } } // Timed — parse offset-aware ISO string. // Strip trailing IANA bracket e.g. '[America/Toronto]' before passing to new Date(): // new Date() cannot parse the bracket notation and returns Invalid Date. try { - const cleanStart = start.replace(/\[[^\]]*\]$/, '') - const cleanEnd = end.replace(/\[[^\]]*\]$/, '') - const startDate = new Date(cleanStart) - const endDate = new Date(cleanEnd) + const cleanStart = start.replace(/\[[^\]]*\]$/, ''); + const cleanEnd = end.replace(/\[[^\]]*\]$/, ''); + const startDate = new Date(cleanStart); + const endDate = new Date(cleanEnd); const dateStr = startDate.toLocaleDateString(undefined, { weekday: 'short', month: 'long', day: 'numeric', - }) + }); const startTime = startDate.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', - }) + }); const endTime = endDate.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', - }) - return `${dateStr}, ${startTime} – ${endTime}` + }); + return `${dateStr}, ${startTime} – ${endTime}`; } catch { - return start + return start; } } @@ -106,65 +106,65 @@ function formatDateTime(start: string, end: string, allDay: boolean): string { * In standalone mode it resolves the event from TanStack Query cache. */ export function EventDetailPopover(props: ScheduleXEventModalProps = {}) { - const { openEventId, setOpenEventId } = useCalendarStore() - const setEventForm = useCalendarStore((s) => s.setEventForm) - const setDeleteDialog = useCalendarStore((s) => s.setDeleteDialog) - const queryClient = useQueryClient() - const dialogRef = useRef(null) + const { openEventId, setOpenEventId } = useCalendarStore(); + const setEventForm = useCalendarStore((s) => s.setEventForm); + const setDeleteDialog = useCalendarStore((s) => s.setDeleteDialog); + const queryClient = useQueryClient(); + const dialogRef = useRef(null); // Resolve the event to display: // 1. If Schedule-X passed a calendarEvent prop, use it to get the id // 2. Otherwise use Zustand openEventId const activeId: string | null = (() => { if (props.calendarEvent?.id != null) { - return String(props.calendarEvent.id) + return String(props.calendarEvent.id); } - return openEventId - })() + return openEventId; + })(); // Lookup the occurrence in TanStack Query cache. // We search all 'events' query entries for a matching id. const occurrence: CalendarOccurrence | null = (() => { - if (!activeId) return null + if (!activeId) return null; // queryClient.getQueriesData returns [{queryKey, data}] entries const allEntries = queryClient.getQueriesData<{ occurrences: CalendarOccurrence[] }>({ queryKey: ['events'], - }) + }); for (const [, data] of allEntries) { - if (!data?.occurrences) continue - const found = data.occurrences.find((o) => o.id === activeId) - if (found) return found + if (!data?.occurrences) continue; + const found = data.occurrences.find((o) => o.id === activeId); + if (found) return found; } - return null - })() + return null; + })(); // Close handler - const handleClose = () => setOpenEventId(null) + const handleClose = () => setOpenEventId(null); // Escape key listener — add to document so it works even when focus is trapped useEffect(() => { - if (!activeId) return + if (!activeId) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { - handleClose() + handleClose(); } - } - document.addEventListener('keydown', onKeyDown) - return () => document.removeEventListener('keydown', onKeyDown) - }, [activeId]) // eslint-disable-line react-hooks/exhaustive-deps + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [activeId]); // eslint-disable-line react-hooks/exhaustive-deps // Focus trap — when popover opens, focus the dialog useEffect(() => { if (activeId && dialogRef.current) { - dialogRef.current.focus() + dialogRef.current.focus(); } - }, [activeId]) + }, [activeId]); // Nothing to show - if (!activeId || !occurrence) return null + if (!activeId || !occurrence) return null; // Responsive: detect phone breakpoint - const isPhone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches + const isPhone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches; const dialogStyle: React.CSSProperties = isPhone ? { @@ -198,7 +198,7 @@ export function EventDetailPopover(props: ScheduleXEventModalProps = {}) { overflowY: 'auto', zIndex: 200, fontFamily: 'var(--font-family-base)', - } + }; return ( <> @@ -316,11 +316,7 @@ export function EventDetailPopover(props: ScheduleXEventModalProps = {}) { fontFamily: 'var(--font-family-base)', }} > -