Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
2 changed files with 70 additions and 69 deletions
Showing only changes of commit 3ae7724df1 - Show all commits
+26 -22
View File
@@ -1,40 +1,44 @@
{
"version": "1.0",
"timestamp": "2026-06-06T01:47:16.059Z",
"timestamp": "2026-06-07T02:39:57.236Z",
"phase": "03",
"phase_name": "event-write-back-pwa-install",
"phase_dir": ".planning/phases/03-event-write-back-pwa-install",
"plan": 8,
"task": 1,
"total_tasks": 3,
"plan": "Gate 2 (Part D live verification)",
"task": "delete/edit join fix (diagnosed, not started)",
"total_tasks": null,
"status": "paused",
"completed_tasks": [
{"id": "03-09", "name": "Route-layer reachability: zod title/start/end contract (CR-01) + real OIDC iss/sub->users.id (CR-06)", "status": "done", "commit": "578315c"},
{"id": "03-12", "name": "EventForm edit/a11y: WR-03/05/07, IN-03/04", "status": "done", "commit": "1fc56f4"},
{"id": "03-10", "name": "Outbox real VEVENT dispatch: CR-02/03, WR-01/04/08, IN-01", "status": "done", "commit": "416e813"},
{"id": "03-11", "name": "Outbox durability + concurrency + etag re-read: CR-04/05, WR-02", "status": "done", "commit": "0e4a263"}
{"id": "tunnel", "name": "PWA loads through Pangolin/newt — newt MTU 1280→1200 (operator) fixed large-asset blackhole; API serves full ./public tree (431ab31)", "status": "done"},
{"id": "auth", "name": "Real Authelia OIDC login working — client_id=familysync-dev, scopes incl offline_access, /api/login route + redirect (quick 260606-tv8), fetchMe redirect:manual (1adb460)", "status": "done", "commit": "874f23d"},
{"id": "bringup", "name": "Stack bring-up: PWA built into API image, NODE_ENV=production, broker credential seeded (reused spike app password)", "status": "done", "commit": "b46b25b"},
{"id": "bug-A", "name": "BUG A timezone — events written 4h off; fixed via in-browser UTC serialization (eventDateTime.ts)", "status": "done", "commit": "a9d3de6"},
{"id": "bug-B", "name": "BUG B calendar identity — events attached to wrong user + duplicate calendar rows; unique(userId,url) + per-user predicates + migration applied to live DB", "status": "done", "commit": "a9d3de6"},
{"id": "spike-cleanup", "name": "Deleted obsolete spike user (id=1 Dev User) + calendar id=1 + cached events (DB op, operator-approved)", "status": "done"}
],
"remaining_tasks": [
{"id": "03-08", "name": "Gate 2 live verification: real Authelia OIDC over Pangolin + iOS standalone install + end-to-end Fastmail write round-trips (human/device checkpoint, not a gap plan)", "status": "not_started"},
{"id": "deferred-WR-06", "name": "triggerTargetedResync N+1 fetchCalendars per row (perf warning)", "status": "not_started"},
{"id": "deferred-IN-02", "name": "resolveDefaultView ignores SSR guard return (info/nit)", "status": "not_started"},
{"id": "deferred-IN-05", "name": "Outbox terminal rows never pruned (info, low urgency)", "status": "not_started"}
{"id": "edit-delete-join", "name": "BLOCKING: edit (PATCH /:uid/edit ~line295) + delete (DELETE /:uid ~line392) handlers in apps/api/src/routes/events.ts select calendars.url/userId WITHOUT joining calendars → Drizzle 503 → delete dialog never closes. Add .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) to both + regression test that runs the REAL query builder (existing tests mock db.select() and miss it).", "status": "not_started"},
{"id": "displayName", "name": "me.ts passes (missing) email claim as displayName and doesn't read name/preferred_username; oidc_iss also blank → legend name blank (user id=2 display_name=''). May also need Authelia to put name/email in ID token or call userinfo.", "status": "not_started"},
{"id": "events-filter", "name": "GET /api/events has no userId/isShared filter (returns all users' events). Latent now (only 1 real user) but a real bug for 2nd member.", "status": "not_started"},
{"id": "sync-toast", "name": "Syncing toast not animated / ~27s (outbox 15s drain + CalDAV) looks stalled — UI polish (backlog candidate).", "status": "not_started"},
{"id": "gate2-A2A3", "name": "Gate 2 Part A2 (session persists across browser restart) + A3 (2nd member distinct color) — operator verify.", "status": "not_started"},
{"id": "gate2-B", "name": "Gate 2 Part B — iOS standalone install + login (device-only; manifest/sw fixes now unblock it).", "status": "not_started"},
{"id": "gate2-C", "name": "Gate 2 Part C — 5-min SSE smoke (Phase 4 entry gate).", "status": "not_started"}
],
"blockers": [
{"description": "Docker bring-up for newt/Pangolin exposure paused awaiting two decisions: (1) auth mode, (2) PWA serving", "type": "human_action", "workaround": "Dev-bypass + vite gives a working stack immediately; real OIDC needs creds"},
{"description": "OIDC_ISSUER and OIDC_CLIENT_SECRET are empty in .env — real-OIDC (production) auth path cannot run", "type": "external", "workaround": "Use DEV_AUTH_BYPASS=true with dev compose, or fill Authelia creds"},
{"description": "No PWA build in apps/api/public — API 404s the frontend at root", "type": "technical", "workaround": "Run `pnpm --filter @familysync/pwa build` into apps/api/public, or run vite dev separately on :5173"}
{"description": "Delete (and latently edit) events 503 — missing calendars join in events.ts. Diagnosed; fix not yet applied. User was choosing how to land it (batch vs quick-only vs inline) and paused.", "type": "technical", "workaround": "none — delete is broken until fixed"},
{"description": "playwright-cli daemon wedges/crashes in this WSL2 env (hangs on never-settling pages; even open/run-code fail after). Cannot drive browser verification here.", "type": "technical", "workaround": "Verify via curl + ask the operator to test in their real browser/incognito."}
],
"human_actions_pending": [
{"action": "Decide auth mode: dev-bypass (works now) vs real OIDC (fill OIDC_ISSUER/CLIENT_SECRET for Gate 2 over Pangolin)", "context": "Needed before api container is usable through newt", "blocking": true},
{"action": "Decide PWA serving: build into API (one port :3000) vs vite dev (:5173) vs API-only", "context": "Determines which ports newt maps", "blocking": true},
{"action": "Confirm which ports the Pangolin/newt config actually maps (3000 api, 5173 pwa, 3306 db, 6379 redis)", "context": "Unknown from repo", "blocking": false}
{"action": "Re-test in browser: create a NEW 9am event (verify time + your-user attribution); the OLD wrong-time test event (5am) should be deleted once delete is fixed.", "context": "BUG A/B fixes deployed; old test event was written pre-fix with bad time.", "blocking": false},
{"action": "Decide how to land the edit/delete join fix (batch with displayName+events-filter, quick-only, or inline).", "context": "User paused mid-AskUserQuestion on this.", "blocking": true}
],
"decisions": [
{"decision": "Executed Phase 03 with --gaps-only; 03-08 (Gate 2 human/iOS checkpoint) intentionally excluded", "rationale": "Gap plans (gap_closure: true) were 03-09/10/11/12 only; 03-08 needs human/device and cannot be driven by playwright-cli", "phase": "03"},
{"decision": "Left WR-06, IN-02, IN-05 open", "rationale": "Non-critical (1 perf warning + 2 info); not scoped into any gap plan. All 6 critical CR findings closed", "phase": "03"}
{"decision": "newt -mtu 1200 (systemd drop-in)", "rationale": "newt default tunnel MTU 1280 == eth0 underlay; WireGuard overhead made encrypted packets exceed 1280 → large transfers (JS bundle) blackholed. THE root cause of the 'spinner'.", "phase": "03"},
{"decision": "Spike user (id=1) + its calendar/events deleted", "rationale": "Obsolete Phase 1 test identity polluting the unified view as 'Dev User'; events are re-syncable cache. Operator approved.", "phase": "03"},
{"decision": "fetchMe uses redirect:manual; serve full ./public; OIDC_SCOPES constrained", "rationale": "fetch followed cross-origin 302 and hung; static serving only did /assets/*; empty OIDC_SCOPES requested all scopes_supported (Authelia invalid_scope).", "phase": "03"}
],
"uncommitted_files": [],
"next_action": "Answer the two pending Docker decisions (auth mode + PWA serving), then bring up the stack: `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d` (dev-bypass path) or production compose with OIDC creds filled.",
"context_notes": "Gap-closure execution is fully complete, merged to gsd/v1.0-milestone, 92 api + 126 pwa tests green, both tsc clean. Then user asked to spin up Docker for newt exposure. Investigation found: mariadb up/healthy; api+redis exited 255 (just WSL stop, NOT a crash — api boots fine on :3000, only warns about missing ./public). Architecture: api container serves BOTH api routes AND PWA static from ./public; production mounts OIDC unconditionally (creds empty -> broken), dev with DEV_AUTH_BYPASS=true skips OIDC. I asked the user the two decision questions via AskUserQuestion; they interrupted to pause instead of answering."
"next_action": "Fix the edit+delete missing-calendars-join bug in apps/api/src/routes/events.ts (add .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) to both the PATCH /:uid/edit (~line 295) and DELETE /:uid (~line 392) lookups), with a regression test that exercises the real query builder. Re-offer the user the landing choice they were deciding (batch with displayName + /api/events filter, quick-only, or inline). Then rebuild (docker compose up -d --build) and have the operator re-test delete in the browser.",
"context_notes": "This session went from 'paused awaiting docker decisions' to a full Gate 2 live bring-up. The big unlock was the newt MTU fix — every earlier 'spinner' symptom was the JS bundle blackholing through the tunnel, not auth. Along the way fixed 6+ real bugs (auth redirect, static serving, OIDC scopes/client_id, timezone, calendar identity) and cleaned spike data. Stack is running (docker compose production target); /health 200 through tunnel; real OIDC login works. Write path (create) works end-to-end to Fastmail. Delete/edit are the current blocker (trivial join fix). Do NOT use playwright-cli (broken here). Do NOT read/write .env via tools (permission-locked; operator applies .env changes). Every docker compose recreate drops newt's target ~30s (503) then self-recovers — not a bug. DB now: 1 user (id=2, display_name blank), calendars id=2 Calendar(509ev) + id=3 USA Holidays(32)."
}
@@ -1,78 +1,75 @@
---
context: phase
phase: 03-event-write-back-pwa-install
task: 1
total_tasks: 3
task: "Gate 2 Part D — edit/delete join fix (diagnosed, not started)"
total_tasks: null
status: in_progress
last_updated: 2026-06-06T01:47:16.059Z
last_updated: 2026-06-07T02:39:57.236Z
---
<current_state>
Phase 03 **gap-closure execution is complete** — all four gap plans (03-09, 03-12, 03-10, 03-11) executed in dependency order, merged to `gsd/v1.0-milestone`, with 92 API + 126 PWA tests green and both `tsc --noEmit` clean. Working tree is clean.
# Critical Anti-Patterns
Work then shifted to an **operational task: spin up the Docker stack for newt/Pangolin external exposure**. This is paused mid-investigation, awaiting two decisions from the user (see Human Actions). The user installed newt into WSL2 and wants to map the stack's ports.
| Pattern | Description | Severity | Prevention Mechanism |
|---------|-------------|----------|---------------------|
| playwright-cli wedges in this WSL2 env | After loading a never-settling page (e.g. the infinite-spinner state), the playwright-cli daemon hangs and even `open`/`run-code` fail afterward. Burned a lot of effort on it. | blocking | Do NOT use playwright-cli for verification here. Verify via `curl` against the tunnel + ask the operator to test in their real browser/incognito. |
| Drizzle: referencing joined-table columns without the join | `events.ts` edit + delete handlers select `calendars.url`/`calendars.userId` from `.from(calendarEvents)` with no `.innerJoin(calendars,...)` → runtime 503 "table calendars is not part of the query". Unit tests mock `db.select()` so they DON'T catch it. | blocking | Any handler selecting another table's columns MUST `.innerJoin` it. Regression tests for write endpoints must exercise the REAL query builder (test DB), not a mocked `db.select()`. |
| `.env` is permission-locked | The Read/Edit/Bash tools are denied on `.env` (and `.env.spike`). | advisory | Hand the operator exact `.env` lines to apply via the `!` prefix; never assume you can read/write it. |
| docker compose recreate drops the newt target ~30s | Every `docker compose up -d` recreates the api container, resetting newt's held TCP connection → tunnel returns 503 "no available server" for ~30s, then self-recovers. | advisory | After any recreate, poll `/health` through the tunnel until 200 before testing. Not a bug — do not chase it. |
<current_state>
Phase 03 **Gate 2 live verification is largely working.** The PWA now loads through Pangolin/newt, real Authelia OIDC login works, and create-event round-trips to Fastmail correctly (right time, right user) after this session's fixes. Working tree is clean (all committed).
**Immediate blocker:** deleting (and latently editing) an event 503s — the `events.ts` edit/delete handlers are missing a `calendars` join. Diagnosed, fix NOT yet applied. The operator paused right as I asked how to land the fix.
</current_state>
<completed_work>
Gap-closure plans (all merged, all SUMMARY.md committed):
- 03-09: Route-layer reachability — zod `title/start/end` contract (CR-01) + real OIDC `iss/sub``users.id` via upsertUser (CR-06). merge `578315c`.
- 03-12: EventForm edit/a11y — WR-03 (blank/recurrence edit), WR-05 (zone-consistent dates), WR-07 (focus trap), IN-03/04. merge `1fc56f4`.
- 03-10: Outbox real VEVENT dispatch — CR-02 (`buildVeventString` wired), CR-03 (fail-closed creds), WR-01 (backoff index), WR-04 (all-day DTEND+1), WR-08, IN-01. merge `416e813`.
- 03-11: Outbox durability — CR-04 (create-before-delete gating), CR-05 (drain concurrency guard), WR-02 (etag re-read). merge `0e4a263`.
Review-finding coverage: **16/19 closed — all 6 critical (CR) resolved.** STATE.md + ROADMAP.md updated and committed (`b3eff3c`).
This session (commits b46b25b → bdbb9b8):
- **Tunnel works** — operator set `newt -mtu 1200` (was 1280 == eth0 underlay; WireGuard overhead blackholed large packets). THIS was the real cause of every "spinner" — the 510KB JS bundle never downloaded. Plus API now serves the full `./public` tree (431ab31), so manifest/sw/icons stop returning HTML.
- **Auth works** — `/api/login` route + redirect (quick task 260606-tv8), `fetchMe` uses `redirect:'manual'` (1adb460), `OIDC_CLIENT_ID=familysync-dev`, `OIDC_SCOPES=openid profile email offline_access`, redirect URI corrected. Operator added `offline_access` to the Authelia client.
- **Bring-up** (b46b25b) — PWA built into the API image (single port :3000), `NODE_ENV=production`, broker credential seeded (reused the spike app password).
- **BUG A (timezone)** + **BUG B (calendar identity)** fixed via /gsd-debug (a9d3de6, session `.planning/debug/write-path-event-bugs.md`). Migration `0001_calendars_user_url_unique.sql` applied to the live DB.
- **Spike cleanup** — deleted obsolete user id=1 ("Dev User") + calendar id=1 + cached events (operator-approved DB op).
- **Backlog 999.2** added — slick unauthenticated-entry (no login flash).
</completed_work>
<remaining_work>
1. **BLOCKING — edit/delete join fix.** `apps/api/src/routes/events.ts`: the `PATCH /:uid/edit` lookup (~line 295) and `DELETE /:uid` lookup (~line 392) select `calendarUrl: calendars.url` + `userId: calendars.userId` from `.from(calendarEvents).where(eq(calendarEvents.uid, uid))` with NO join. Add `.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))` to both (mirror the working `GET /api/events` query at ~line 153). Add a regression test that runs the real query builder.
2. `me.ts` blank displayName — passes the (missing) `email` claim as displayName, never reads `name`/`preferred_username`; `oidc_iss` also blank. Legend name is blank (user id=2 `display_name=''`). May also need Authelia to include name/email in the ID token, or call userinfo.
3. `GET /api/events` has no `userId`/`isShared` filter (returns all users' events) — latent now (1 real user), real bug for a 2nd member.
4. "Syncing" toast not animated / ~27s — UI polish (backlog candidate).
5. Gate 2 remaining: A2 (session persistence), A3 (2nd-member color), B (iOS standalone install+login — device-only), C (5-min SSE smoke — Phase 4 entry gate).
Phase 03:
- **03-08** — Gate 2 live verification (real Authelia OIDC over Pangolin + iOS standalone install + end-to-end Fastmail write round-trips). Human/device checkpoint — NOT a gap plan, cannot be driven by playwright-cli. This is the only incomplete plan; phase stays "In Progress" until it passes.
- Deferred (non-blocking, not scoped into any gap plan): WR-06 (N+1 fetchCalendars perf), IN-02 (resolveDefaultView SSR-guard nit), IN-05 (terminal outbox rows never pruned).
Operational (the paused task):
- Bring up the Docker stack for newt exposure once the two decisions below are made.
The operator was choosing how to land #1: **(a)** batch #1+#2+#3 in one /gsd-quick, **(b)** /gsd-quick just #1, or **(c)** fix #1 inline. Re-offer that.
</remaining_work>
<decisions_made>
- Executed Phase 03 with `--gaps-only`; 03-08 intentionally excluded (human/device Gate 2).
- Left WR-06 / IN-02 / IN-05 open — non-critical; all critical findings closed.
- `newt -mtu 1200` — fixes large-asset blackhole through the WireGuard tunnel.
- Deleted the spike user (id=1) + its calendar/events — obsolete test identity, re-syncable cache.
- `fetchMe` `redirect:'manual'`, serve full `./public`, constrained `OIDC_SCOPES` — see HANDOFF.json.
</decisions_made>
<blockers>
- Docker bring-up paused pending two user decisions (auth mode + PWA serving).
- `OIDC_ISSUER` and `OIDC_CLIENT_SECRET` are **empty** in `.env` → the production/real-OIDC path cannot run yet.
- No PWA build in `apps/api/public` → API serves nothing at `/` (root 404s); `/health` and `/api/*` work.
- Delete/edit events 503 (missing calendars join) — trivial fix, not yet applied.
- playwright-cli broken in this env — verify via curl + operator's browser.
</blockers>
## Required Reading (in order)
1. `docker-compose.yml` + `docker-compose.dev.yml` — stack definition. Base = production target (OIDC unconditional); dev override = dev target, `NODE_ENV=development`, exposes mariadb:3306 + redis:6379.
2. `apps/api/src/index.ts` (lines ~15-61) — `devBypassActive` logic (`NODE_ENV!='production' && DEV_AUTH_BYPASS==='true'`) and the `serveStatic('./public')` PWA-serving + catch-all.
3. `apps/api/Dockerfile` — note: production stage does NOT build the PWA into `./public`; comment says PWA "built and served separately".
4. `.planning/HANDOFF.json` — machine-readable mirror of this state.
## Critical Anti-Patterns
_None discovered through failure this session. The "Exited (255)" on api/redis was a WSL/daemon stop 12h ago, NOT a crash — the api boots cleanly on :3000 (logs show "FamilySync API running on http://localhost:3000", only a harmless `serveStatic: root './public' not found` warning). Do not chase it as a bug._
1. `.planning/HANDOFF.json` — machine-readable mirror of this state.
2. `apps/api/src/routes/events.ts` — the edit (~line 295) + delete (~line 392) handlers missing the `calendars` join; compare to the working GET query (~line 153).
3. `.planning/debug/write-path-event-bugs.md` — the resolved timezone + calendar-identity debug session (context for the write path).
4. `.planning/phases/03-event-write-back-pwa-install/03-GATE2-RESULTS.md` — the Gate 2 checklist (still needs updating with what now passes).
## Infrastructure State
- `familysync-mariadb-1`: **Up (healthy)**, `0.0.0.0:3306->3306`.
- `familysync-api-1`: Exited (255) — stale from WSL stop, not a crash. Boots fine when restarted.
- `familysync-redis-1`: Exited (255) — same; present-but-unused until Phase 4.
- Docker 29.3.1, Compose v5.1.1.
- newt installed in WSL2 (per user) for Pangolin tunnel; the ports it maps are unconfirmed from the repo side.
## Human Actions Pending (blocking the Docker bring-up)
1. **Auth mode** — dev-bypass (`DEV_AUTH_BYPASS=true` + dev compose, works immediately, OIDC skipped) **vs** real OIDC (fill `OIDC_ISSUER`/`OIDC_CLIENT_SECRET` for the actual 03-08 Gate 2 login over Pangolin).
2. **PWA serving** — build PWA into `apps/api/public` (single port :3000 for newt) **vs** vite dev on :5173 (two ports) **vs** API-only for now.
3. (non-blocking) Confirm which ports the Pangolin/newt config actually maps.
- Stack: `docker compose` (production target) — api + mariadb (healthy) + redis up. `/health` 200 locally AND through `https://familysync-dev.bergerhouse.net`.
- newt: systemd service, `-mtu 1200` drop-in applied by operator; WireGuard tunnel carries large transfers now.
- DB: 1 user (id=2, real OIDC, `display_name=''`, color #E8734A); calendars id=2 "Calendar" (509 ev) + id=3 "USA Holidays" (32). `uniq_calendar_user_url(user_id,url)` present.
- OIDC: `client_id=familysync-dev` registered in Authelia with `offline_access`; redirect `https://familysync-dev.bergerhouse.net/callback`.
- An old test event (uid `92dd5a80…`) exists in Fastmail at the WRONG time (written pre-BUG-A-fix) — operator should delete it via the app once delete works.
<context>
Mid-flow when paused: I had just run `AskUserQuestion` with the two decisions above and the user interrupted to `/gsd-pause-work` instead of answering. Nothing was started/changed for the Docker task — purely investigation. The gap-closure work is done and safe.
The whole session was a Gate 2 bring-up that turned into a bug hunt. The keystone was the newt MTU fix — until then the JS bundle couldn't traverse the tunnel, so the app showed a perpetual spinner that looked like (and got misdiagnosed as) auth problems. After that unlocked, live testing surfaced a cascade of real write-path bugs, most now fixed. Remaining work is small and well-understood; the edit/delete join is a 2-line fix gated only on the operator's choice of how to land it.
</context>
<next_action>
Ask the user the two pending decisions (auth mode + PWA serving). Then bring up the stack:
- Dev-bypass path: ensure `DEV_AUTH_BYPASS=true` in `.env`, then `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build`. If serving the PWA via API, first `pnpm --filter @familysync/pwa build` and place output in `apps/api/public`; otherwise run `pnpm --filter @familysync/pwa dev` on :5173.
- Real-OIDC path: wait for the user to fill `OIDC_ISSUER`/`OIDC_CLIENT_SECRET`, then `docker compose up -d --build` (production), and build the PWA into `apps/api/public` so the root URL serves.
Start with: re-offer the operator the landing choice for the edit/delete join fix (batch #1+#2+#3 / quick-only #1 / inline). Then apply `.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))` to the edit (~line 295) and delete (~line 392) handlers in `apps/api/src/routes/events.ts`, add a real-query regression test, `docker compose up -d --build`, wait for tunnel `/health` 200, and have the operator re-test delete in the browser.
</next_action>