chore(01-01): scaffold monorepo, Docker Compose stack, and Vitest harness

- pnpm workspace with apps/api (Hono/Drizzle) and apps/pwa (Vite/React 19)
- Pinned versions per RESEARCH: hono@4.12.23, drizzle-orm@0.45.2, mysql2@3.22.4, tsdav@2.2.2, ical.js@2.2.1, zod@^3.25.0, node-cron@^4.2.1
- docker-compose.yml with mariadb:11 healthcheck, api depends_on service_healthy, redis stub
- docker-compose.dev.yml overrides for local dev (bind mounts, exposed ports)
- .env.example lists all env vars (DB_*, OIDC_*, APP_PASSWORD_ENCRYPTION_KEY)
- .gitignore excludes .env (never commit secrets)
- apps/api/vitest.config.ts with environment: node
- Wave 0 test stubs: health, auth/user, broker/crypto, broker/sync, broker/poller
This commit is contained in:
Lucas Berger
2026-06-04 09:50:16 -04:00
parent fb849605b1
commit 3f591566d1
22 changed files with 2923 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# Database
DB_HOST=mariadb
DB_PORT=3306
DB_USER=familysync
DB_PASSWORD=
DB_NAME=familysync
DB_ROOT_PASSWORD=
# OIDC (Authelia) — fill in after registering the client
OIDC_AUTH_SECRET=
OIDC_ISSUER=
OIDC_CLIENT_ID=familysync
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=https://familysync.yourdomain.com/callback
OIDC_AUTH_EXTERNAL_URL=https://familysync.yourdomain.com
# CalDAV broker encryption key — generate with:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
APP_PASSWORD_ENCRYPTION_KEY=
+34
View File
@@ -0,0 +1,34 @@
# Dependencies
node_modules/
# Build output
dist/
.dist/
# Environment — NEVER commit .env (secrets at rest: DB passwords, OIDC secrets, encryption key)
.env
# Editor
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# TypeScript
*.tsbuildinfo
# Drizzle migrations (generated — not secrets, but keep clean)
# apps/api/src/db/migrations/
# Test coverage
coverage/
.nyc_output/
+29
View File
@@ -0,0 +1,29 @@
FROM node:22-alpine AS base
WORKDIR /app
RUN corepack enable pnpm
FROM base AS deps
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile --prod
FROM base AS builder
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY tsconfig.json ./
COPY src/ ./src/
RUN pnpm build
FROM base AS production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
# PWA static assets served from ./public (built separately)
COPY apps/pwa/dist/ ./public/ 2>/dev/null || true
CMD ["node", "dist/index.js"]
FROM base AS dev
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY tsconfig.json ./
CMD ["pnpm", "dev"]
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@familysync/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch dist/index.js",
"build": "tsc",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"
},
"dependencies": {
"@hono/node-server": "2.0.4",
"@hono/oidc-auth": "1.8.3",
"@hono/zod-validator": "0.8.0",
"drizzle-orm": "0.45.2",
"hono": "4.12.23",
"ical.js": "2.2.1",
"mysql2": "3.22.4",
"node-cron": "^4.2.1",
"tsdav": "2.2.2",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"drizzle-kit": "0.31.10",
"typescript": "^5.5.0",
"vitest": "^4.1.8"
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Wave 0 stub — Auth: upsertUser color round-robin + identity stability
*
* These tests are RED stubs. Implementation lives in:
* apps/api/src/auth/user.ts (Plan 02)
*
* Tests will be filled GREEN in Plan 02 when upsertUser is implemented.
*/
import { describe, it } from 'vitest'
describe('upsertUser', () => {
it.todo('assigns a color from the palette on first login (Plan 02)')
it.todo('returns the same color on subsequent logins for the same oidc identity (Plan 02)')
it.todo('assigns distinct colors to two different users (Plan 02)')
it.todo('uses oidc_iss + oidc_sub as the composite identity key, not email (Plan 02)')
it.todo('returns the full user row including id, color, displayName (Plan 02)')
})
+22
View File
@@ -0,0 +1,22 @@
/**
* Wave 0 stub — Broker: AES-GCM app-password encryption
*
* These tests are RED stubs. Implementation lives in:
* apps/api/src/broker/crypto.ts (Plan 03)
*
* Tests will be filled GREEN in Plan 03 when crypto helpers are implemented.
*/
import { describe, it } from 'vitest'
describe('encryptPassword / decryptPassword', () => {
it.todo('roundtrip: decrypt(encrypt(plaintext)) === plaintext (Plan 03)')
it.todo('different IVs produce different ciphertext for the same plaintext (Plan 03)')
it.todo('decrypting with a tampered authTag throws (Plan 03)')
it.todo('decrypting with a tampered ciphertext throws (Plan 03)')
it.todo('stored payload is valid JSON with iv, authTag, ciphertext fields (Plan 03)')
})
+24
View File
@@ -0,0 +1,24 @@
/**
* Wave 0 stub — Broker: ctag polling + change detection
*
* These tests are RED stubs. Implementation lives in:
* apps/api/src/broker/poller.ts (Plan 03)
*
* Tests will be filled GREEN in Plan 03 when the poller is implemented.
*
* Key behavior (D-13): ctag unchanged → no DB write (skip sync entirely)
*/
import { describe, it } from 'vitest'
describe('broker poller', () => {
it.todo('skips syncCalendar when ctag is unchanged (Plan 03)')
it.todo('calls syncCalendar when ctag changes (Plan 03)')
it.todo('calls syncCalendar when ctag was null (first sync) (Plan 03)')
it.todo('handles decryptPassword failure gracefully without crashing the poller (Plan 03)')
it.todo('processes all member credentials in a poll cycle (Plan 03)')
})
+29
View File
@@ -0,0 +1,29 @@
/**
* Wave 0 stub — Broker: syncCalendar event upsert + all-day handling
*
* These tests are RED stubs. Implementation lives in:
* apps/api/src/broker/sync.ts (Plan 03)
*
* Tests will be filled GREEN in Plan 03 when syncCalendar is implemented.
*
* Key behaviors to verify (D-13):
* - All-day events: dtstart_date (DATE) set, dtstart_utc NULL, allDay=true
* - Timed events: dtstart_utc (TIMESTAMP UTC) set, dtstart_date NULL, allDay=false
* - UID used as idempotency key: second sync of same UID is an upsert, not duplicate
*/
import { describe, it } from 'vitest'
describe('syncCalendar', () => {
it.todo('stores all-day events with dtstart_date (DATE) and dtstart_utc=NULL (Plan 03)')
it.todo('stores timed events with dtstart_utc (TIMESTAMP) and dtstart_date=NULL (Plan 03)')
it.todo('sets allDay=true for all-day events, allDay=false for timed (Plan 03)')
it.todo('upserts on duplicate UID within the same calendar (Plan 03)')
it.todo('stores the raw VEVENT blob in rawVevent column (Plan 03)')
it.todo('updates the calendar ctag/syncToken after a successful sync (Plan 03)')
})
+16
View File
@@ -0,0 +1,16 @@
/**
* Wave 0 stub — GET /health: 200 + real DB round-trip
*
* This test is a RED stub. Implementation lives in:
* apps/api/src/routes/health.ts (Task 2)
*
* The test will be filled GREEN in Task 2 when the health route is implemented.
*/
import { describe, it } from 'vitest'
describe('GET /health', () => {
it.todo('returns 200 with { ok: true, db: "up" } after a real DB round-trip (Task 2)')
it.todo('returns 503 if the DB round-trip throws (Task 2)')
})
+59
View File
@@ -0,0 +1,59 @@
/**
* Test DB fixture helpers.
*
* For unit tests (Tasks 1-2 scope): provides mock/stub helpers so tests can run
* without a real MariaDB connection. Plan 02/03 will fill in integration-style
* fixtures that hit the Docker MariaDB (DB_HOST=127.0.0.1 from the dev compose override).
*/
// Re-export vi for convenience in test files
export { vi } from 'vitest'
/**
* Creates a minimal mock for the Drizzle `db` singleton.
* Replace individual methods per test as needed.
*
* Plan 02 (auth) will extend this with a real test-schema fixture.
* Plan 03 (broker) will extend with event-fixture helpers.
*/
export function createMockDb() {
return {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([]),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
onDuplicateKeyUpdate: vi.fn().mockResolvedValue([{ id: 1 }]),
$returningId: vi.fn().mockResolvedValue([{ id: 1 }]),
execute: vi.fn().mockResolvedValue([]),
}
}
/**
* Sample VEVENT string for broker tests — a timed event (Plan 03).
*/
export const SAMPLE_VEVENT_TIMED = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//FamilySync//Test//EN
BEGIN:VEVENT
UID:test-timed-event-001@familysync
DTSTART:20260615T100000Z
DTEND:20260615T110000Z
SUMMARY:Test Timed Event
END:VEVENT
END:VCALENDAR`
/**
* Sample VEVENT string for broker tests — an all-day event (Plan 03).
*/
export const SAMPLE_VEVENT_ALLDAY = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//FamilySync//Test//EN
BEGIN:VEVENT
UID:test-allday-event-001@familysync
DTSTART;VALUE=DATE:20260615
DTEND;VALUE=DATE:20260616
SUMMARY:Test All-Day Event
END:VEVENT
END:VCALENDAR`
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
globals: true,
},
})
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#4A90D9" />
<title>FamilySync</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@familysync/pwa",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-query": "5.101.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zustand": "5.0.14"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "8.0.16"
}
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App.js'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/health': 'http://localhost:3000',
'/api': 'http://localhost:3000',
'/callback': 'http://localhost:3000',
},
},
})
+18
View File
@@ -0,0 +1,18 @@
# Local dev overrides — use with: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
services:
api:
build:
context: ./apps/api
target: dev
volumes:
- ./apps/api/src:/app/src
environment:
NODE_ENV: development
mariadb:
ports:
- "3306:3306"
redis:
ports:
- "6379:6379"
+43
View File
@@ -0,0 +1,43 @@
services:
api:
build: ./apps/api
environment:
DB_HOST: mariadb
DB_PORT: 3306
DB_USER: familysync
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: familysync
OIDC_AUTH_SECRET: ${OIDC_AUTH_SECRET:-placeholder_change_me}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-familysync}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-}
OIDC_AUTH_EXTERNAL_URL: ${OIDC_AUTH_EXTERNAL_URL:-}
APP_PASSWORD_ENCRYPTION_KEY: ${APP_PASSWORD_ENCRYPTION_KEY:-}
depends_on:
mariadb:
condition: service_healthy
ports:
- "3000:3000"
mariadb:
image: mariadb:11
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: familysync
MARIADB_USER: familysync
MARIADB_PASSWORD: ${DB_PASSWORD}
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
# Phase 1: present but unused; Phase 4 wires pub/sub for live list sync
volumes:
mariadb_data:
+13
View File
@@ -0,0 +1,13 @@
{
"name": "familysync",
"private": true,
"packageManager": "pnpm@11.5.1",
"scripts": {
"dev:api": "pnpm --filter @familysync/api dev",
"dev:pwa": "pnpm --filter @familysync/pwa dev",
"build": "pnpm --filter @familysync/api build && pnpm --filter @familysync/pwa build",
"test": "pnpm --filter @familysync/api test",
"lint": "pnpm -r lint",
"typecheck": "pnpm -r typecheck"
}
}
+2438
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
packages:
- "apps/*"
allowBuilds:
esbuild: true