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