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