--- phase: 01-foundation-broker-spike plan: 01 type: execute wave: 1 depends_on: [] files_modified: - 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 autonomous: false requirements: [CAL-01] user_setup: - service: mariadb 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)" - name: DB_ROOT_PASSWORD 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)" artifacts: - 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" 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" pattern: "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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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 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`. 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. ## 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 | - `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 }` - 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.