Files
familysync/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-01-PLAN.md
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

19 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
01-foundation-broker-spike 01 execute 1
package.json
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
false
CAL-01
service why env_vars
mariadb Local stack DB; provisioned via Docker Compose (no external account)
name source
DB_PASSWORD Choose any strong password; set in .env (consumed by both mariadb and api services)
name source
DB_ROOT_PASSWORD Choose any strong password; set in .env (MariaDB root)
truths artifacts key_links
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)
path provides contains
apps/api/src/db/schema.ts Drizzle mysqlTable definitions for users, member_credentials, calendars, calendar_events mysqlTable('users'
path provides exports
apps/api/src/db/client.ts drizzle(mysql2 pool) singleton export `db`
db
path provides
apps/api/src/routes/health.ts GET /health with real DB read/write
path provides contains
docker-compose.yml api + mariadb + redis services with mariadb healthcheck healthcheck
path provides
apps/api/vitest.config.ts Node-environment vitest config
path provides
apps/pwa/src/App.tsx React shell that fetches /health
from to via pattern
apps/api/src/routes/health.ts apps/api/src/db/client.ts db query from ['"].*db/client
from to via pattern
apps/pwa/src/App.tsx /health fetch fetch(.*health
Stand up the FamilySync walking skeleton: a pnpm monorepo (apps/api Hono + apps/pwa Vite/React 19), the full Drizzle/MariaDB schema applied to a live MariaDB via Docker Compose, a `/health` route that performs a real DB write-then-read, and a React shell that fetches it. Also create the Wave 0 test harness (vitest config + the failing/stub test files the rest of Phase 1 fills in).

This is the thinnest end-to-end slice that proves the full stack runs: Browser (React) → Hono API → MariaDB and back. No auth, no Fastmail yet — those are Wave 2 slices built on this skeleton.

Purpose: Establish the architectural backbone (directory layout, schema, Docker stack, test runner) that every later plan and phase inherits. Avoids re-litigating scaffold decisions. Output: Running Docker stack, applied DB schema, a green /health slice, and the Wave 0 test files.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @./CLAUDE.md @.planning/phases/01-foundation-broker-spike/01-CONTEXT.md @.planning/phases/01-foundation-broker-spike/01-RESEARCH.md @.planning/phases/01-foundation-broker-spike/01-VALIDATION.md @.planning/phases/01-foundation-broker-spike/SKELETON.md

<artifacts_produced>

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.

New exported symbols: db (Drizzle client singleton); Drizzle tables users, memberCredentials, calendars, calendarEvents; route healthRouter.

New DB tables: users (id, oidc_iss, oidc_sub, display_name, color, created_at; unique oidc_iss+oidc_sub), member_credentials (id, user_id, encrypted_password, fastmail_email, created_at, updated_at), calendars (id, user_id, url, display_name, color, ctag, sync_token, last_synced_at), calendar_events (id, calendar_id, uid, etag, raw_vevent, dtstart_utc, dtstart_date, all_day, updated_at; unique calendar_id+uid).

New route paths: GET /health.

New env vars: DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME, DB_ROOT_PASSWORD. </artifacts_produced>

Task 1: Scaffold monorepo, Docker Compose stack, and Vitest harness package.json, 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/vitest.config.ts, apps/pwa/package.json, apps/pwa/tsconfig.json, apps/pwa/vite.config.ts, apps/pwa/index.html, apps/pwa/src/main.tsx, apps/api/tests/helpers/db.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/api/tests/health.test.ts - .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Recommended Project Structure", § "Pattern 6: Docker Compose Layout", § "Standard Stack" with pinned versions, § "Validation Architecture" Wave 0 gaps + vitest config) - ./CLAUDE.md (locked stack + "What NOT to Use" + "Version Compatibility" tables — authoritative) - .planning/phases/01-foundation-broker-spike/01-VALIDATION.md (Wave 0 Requirements, Per-Task Verification Map) Create a pnpm workspace at repo root: `pnpm-workspace.yaml` listing `apps/*`; root `package.json` with `packageManager` and workspace scripts. NOTE: dev machine Node is v20.20.2 but Docker runs node:22-alpine (per RESEARCH Environment Availability) — set tsconfig `target: ES2023` so both work. Vitest config from VALIDATION/RESEARCH uses `pnpm vitest run`; align all scripts to pnpm.
`apps/api`: install pinned versions from RESEARCH § Standard Stack — hono@4.12.23, @hono/node-server@2.0.4, @hono/oidc-auth@1.8.3, @hono/zod-validator@0.8.0, drizzle-orm@0.45.2, mysql2@3.22.4, tsdav@2.2.2, ical.js@2.2.1, zod@3.25.x (pin ^3.25.0 — NOT v4, NOT 3.24.x), node-cron@4 (^4.2.1); devDeps drizzle-kit@0.31.10, vitest@^4.1.8, typescript@5.x, @types/node. tsconfig strict:true, module/moduleResolution NodeNext, target ES2023, outDir dist. Dockerfile per RESEARCH (node:22-alpine, COPY dist + pwa build to ./public, CMD node dist/index.js).

`apps/pwa`: install react@19, react-dom@19, @tanstack/react-query@5.101.0, zustand@5.0.14; devDeps vite@8.0.16, @vitejs/plugin-react, typescript. Create index.html, src/main.tsx (mounts App with QueryClientProvider). vite.config.ts with proxy of `/health` and `/api` to http://localhost:3000 for dev.

`apps/api/vitest.config.ts`: environment 'node', globals true (exact config in RESEARCH § Validation Architecture).

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 - `pnpm-workspace.yaml` lists `apps/*`; root `package.json` declares pnpm workspace - `apps/api/package.json` pins zod at `^3.25.0` (NOT `^4`, NOT `3.24.x`) and lists hono@4.12.23, drizzle-orm@0.45.2, mysql2@3.22.4, tsdav@2.2.2, ical.js@2.2.1, node-cron@^4.2.1 - `docker-compose.yml` defines api + mariadb + redis services; mariadb uses image `mariadb:11` and has a `healthcheck` block; api `depends_on` mariadb with `condition: service_healthy` - `.gitignore` contains `.env`; `.env.example` lists DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME, DB_ROOT_PASSWORD - `apps/api/vitest.config.ts` sets `environment: 'node'` - All five Wave 0 test files exist under apps/api/tests/ and `pnpm vitest run` executes them (pass or fail, not "no tests found") pnpm install succeeds in apps/api; vitest discovers and runs the Wave 0 test files; docker-compose.yml validates with mariadb healthcheck. Task 2: Drizzle schema + DB client + /health slice (end-to-end skeleton) apps/api/drizzle.config.ts, apps/api/src/db/schema.ts, apps/api/src/db/client.ts, apps/api/src/routes/health.ts, apps/api/src/index.ts, apps/api/tests/health.test.ts, apps/pwa/src/App.tsx - .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Pattern 2: Drizzle/MariaDB Schema" — full schema, drizzle.config.ts, DB client singleton; § "Serving PWA static files from Hono") - .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-04, D-10, D-13 — schema constraints: encrypted creds keyed by identity, oidc_iss+oidc_sub composite key, all-day DATE vs DATETIME) - apps/api/vitest.config.ts (created in Task 1) - GET /health returns 200 with JSON `{ ok: true, db: "up" }` only after a real DB write+read round-trip succeeds - GET /health returns 503 if the DB round-trip throws - schema.ts exports `users` with columns oidc_iss, oidc_sub, display_name, color and a unique constraint on (oidc_iss, oidc_sub) - calendar_events has BOTH dtstart_utc (timestamp, nullable) and dtstart_date (date, nullable) plus all_day boolean — never a single coerced column Create `src/db/schema.ts` exporting the four mysqlTable definitions exactly per RESEARCH Pattern 2: `users` (id autoincrement PK, oidc_iss varchar(512), oidc_sub varchar(256), display_name varchar(256) nullable, color varchar(7) notNull, created_at timestamp; unique key on oidc_iss+oidc_sub per D-10), `memberCredentials` (user_id FK cascade, encrypted_password text, fastmail_email varchar(256), timestamps; index on user_id per D-04), `calendars` (user_id FK, url varchar(1024), display_name, color, ctag varchar(512), sync_token varchar(1024), last_synced_at), `calendarEvents` (calendar_id FK cascade, uid varchar(512), etag, raw_vevent text, dtstart_utc timestamp nullable, dtstart_date date nullable, all_day boolean default false, updated_at; unique key on calendar_id+uid; indexes on dtstart_utc and dtstart_date — per D-13 all-day uses DATE, never coerced to DATETIME).
Create `src/db/client.ts` exporting `db = drizzle({ client: pool, schema, mode: 'default' })` using a mysql2 createPool from DB_* env (connectionLimit 10). Create `drizzle.config.ts` (dialect 'mysql', schema ./src/db/schema.ts, out ./src/db/migrations, dbCredentials from env).

Create `src/routes/health.ts` exporting `healthRouter` (Hono): GET / performs a real round-trip — write a transient row to a `health_check` scratch table OR do `SELECT 1` + an INSERT/DELETE against `users` count; the slice must prove an actual DB read AND write (Walking Skeleton requirement). Return `{ ok: true, db: 'up' }` on success, 503 on failure. Mount under `/health` in `src/index.ts` BEFORE any auth (health must be unauthenticated). Wire `serve({ fetch: app.fetch, port: 3000 })` and serveStatic for ./public.

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 - `src/db/schema.ts` exports `users`, `memberCredentials`, `calendars`, `calendarEvents` - `users` has a unique constraint covering oidc_iss + oidc_sub (grep: `oidcIss` and `oidcSub` both present in a unique/composite key) - `calendar_events` schema has separate `dtstart_utc` (timestamp) and `dtstart_date` (date) columns plus `all_day` boolean - `src/db/client.ts` exports `db` - `tests/health.test.ts` passes: GET /health returns 200 with `ok: true` - `apps/pwa/src/App.tsx` calls `fetch`/React Query against `/health` - `pnpm exec tsc --noEmit` exits 0 in apps/api tsc clean; health test green; App.tsx wired to /health; schema exports all four tables with all-day DATE separation. Task 3: [BLOCKING] Bring up stack and run drizzle-kit push Docker Compose stack (MariaDB + Hono API) and the Drizzle schema in src/db/schema.ts. 1. Copy `.env.example` to `.env` and set `DB_PASSWORD` and `DB_ROOT_PASSWORD` to strong values. 2. Run: `docker compose up -d mariadb` and wait for the healthcheck to report healthy (`docker compose ps` shows mariadb `healthy`). 3. Apply the schema to the live MariaDB. From `apps/api` with DB_HOST pointing at the running container (use `docker-compose.dev.yml` exposed port 3306, DB_HOST=127.0.0.1): `DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=familysync DB_NAME=familysync DB_PASSWORD= pnpm exec drizzle-kit push` Run it non-interactively; it must exit 0. 4. Confirm the four tables exist: `docker compose exec mariadb mariadb -ufamilysync -p familysync -e "SHOW TABLES;"` — expect users, member_credentials, calendars, calendar_events. 5. Run `docker compose up -d` (full stack) and `curl -s http://localhost:3000/health` — expect `{"ok":true,...}`. Type "approved" once drizzle-kit push exits 0, SHOW TABLES lists all four tables, and /health returns ok:true — or describe the failure.

<threat_model>

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

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_model>

- `pnpm install` succeeds across the workspace - `pnpm exec tsc --noEmit` clean in apps/api - `pnpm vitest run` executes all Wave 0 files; health test green - `docker compose up` brings mariadb to healthy and api serving - `drizzle-kit push` exits 0; SHOW TABLES lists the four tables - `curl /health` returns `{ ok: true }`

<success_criteria>

  • 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 </success_criteria>
Create `.planning/phases/01-foundation-broker-spike/01-01-SUMMARY.md` when done.