docs: generate project documentation

This commit is contained in:
Lucas Berger
2026-06-10 18:17:51 -04:00
parent 581b31916b
commit a9c3304c4e
10 changed files with 2031 additions and 279 deletions
+127
View File
@@ -0,0 +1,127 @@
<!-- generated-by: gsd-doc-writer -->
# FamilySync
A self-hosted family organization hub for a two-person household. One color-coded calendar view across all family members' Fastmail calendars, plus shared collaborative lists (groceries, gift ideas) — delivered as a React PWA with no app store required.
## What It Does
- **Unified calendar** — aggregates each member's Fastmail CalDAV calendars into a single color-coded view via tsdav + ical.js
- **Shared lists** — collaborative grocery and gift-idea lists with live sync via Server-Sent Events
- **PWA** — installable on iOS (Home Screen) and Android; push notifications via VAPID
- **Single sign-on** — all auth flows through your existing Authelia OIDC deployment
## Prerequisites
- Node.js 22 LTS
- pnpm 11.5.1 (`corepack enable pnpm`)
- Docker + Docker Compose (for MariaDB, Redis, and production deployment)
## Installation
```bash
git clone <repo-url> familysync
cd familysync
pnpm install
```
Copy the environment template and fill in values:
```bash
cp .env.example .env # then fill in values — see Environment Variables below
```
Required environment variables (set in `.env` or your Docker host):
| Variable | Description |
|---|---|
| `DB_PASSWORD` | MariaDB password for the `familysync` user |
| `DB_ROOT_PASSWORD` | MariaDB root password |
| `OIDC_ISSUER` | Authelia OIDC issuer URL |
| `OIDC_CLIENT_ID` | OIDC client ID (default: `familysync`) |
| `OIDC_CLIENT_SECRET` | OIDC client secret |
| `OIDC_REDIRECT_URI` | Callback URL registered in Authelia |
| `OIDC_AUTH_SECRET` | Random secret for session cookie signing |
| `APP_PASSWORD_ENCRYPTION_KEY` | Key used to encrypt stored Fastmail app passwords |
| `VAPID_PUBLIC_KEY` | VAPID public key (`npx web-push generate-vapid-keys --json`) |
| `VAPID_PRIVATE_KEY` | VAPID private key (never commit) |
| `VAPID_SUBJECT` | VAPID subject (`mailto:you@example.com`) |
## Quick Start
**Development (with hot-reload API and Vite HMR):**
```bash
# Start backing services
docker compose -f docker-compose.yml -f docker-compose.dev.yml up mariadb redis
# Run migrations
pnpm --filter @familysync/api db:migrate
# Start API (in one terminal)
pnpm dev:api
# Start PWA (in another terminal)
pnpm dev:pwa
```
**Production (Docker Compose):**
```bash
docker compose up --build
```
The API listens on port 3000. The PWA build is served separately (Vite `preview` or a static host in front of the API container).
## Monorepo Structure
```
apps/
api/ Hono backend — CalDAV sync, OIDC auth, lists API, push notifications
pwa/ React 19 PWA — calendar view, lists UI, service worker
docker-compose.yml Production services (API, MariaDB 11, Redis 7)
docker-compose.dev.yml Dev overrides (bind-mount src/, expose DB/Redis ports)
```
## Commands
| Command | What it does |
|---|---|
| `pnpm dev:api` | Start API in watch mode (`dist/` must be built first) |
| `pnpm dev:pwa` | Start Vite dev server with HMR |
| `pnpm build` | Build both api and pwa |
| `pnpm test` | Run API test suite (vitest) |
| `pnpm lint` | Lint all workspaces |
| `pnpm typecheck` | Type-check all workspaces |
| `pnpm --filter @familysync/api db:generate` | Generate Drizzle migration from schema changes |
| `pnpm --filter @familysync/api db:migrate` | Apply pending migrations to MariaDB |
## Tech Stack
| Layer | Technology |
|---|---|
| Backend runtime | Node.js 22 + TypeScript, Hono 4.12.23 |
| Database ORM | Drizzle ORM 0.45.2 on MariaDB 11 (via mysql2) |
| Auth | `@hono/oidc-auth` 1.8.3 — authorization code + PKCE against Authelia |
| Calendar | tsdav 2.2.2 (CalDAV) + ical.js 2.2.1 against Fastmail |
| Push | web-push 3.6.7 (VAPID) |
| Live sync | Server-Sent Events + Redis 7 pub/sub |
| Frontend | React 19, Vite 8, vite-plugin-pwa 1.3, TanStack Query 5, Zustand 5 |
| Calendar UI | Schedule-X 4.6 |
## Calendar Integration
FamilySync reads and writes calendars via CalDAV against Fastmail — not JMAP (not available for Fastmail calendars). Configure your Fastmail app password under the "Mail, Contacts & Calendars" scope. The principal URL follows the pattern:
```
https://caldav.fastmail.com/dav/principals/user/<your-fastmail-address>/
```
Store the app password in the database via the `/me` endpoint after first login.
## Deployment
See [`docs/deployment.md`](docs/deployment.md) for Unraid/Docker Compose deployment notes including the Pangolin/Newt tunnel configuration.
## License
Private — not open source.
+151
View File
@@ -0,0 +1,151 @@
<!-- generated-by: gsd-doc-writer -->
# @familysync/api
The Hono backend for FamilySync. Acts as a calendar broker over Fastmail CalDAV, stores collaborative lists in MariaDB, enforces OIDC auth via Authelia, and delivers live list updates via SSE and push notifications via VAPID.
Part of the [FamilySync monorepo](../../README.md).
## What it does
- **Calendar broker** — polls Fastmail CalDAV every 5 minutes via `tsdav`; parses iCalendar payloads with `ical.js` and expands recurrence rules with `ical.js`'s `ICAL.RecurExpansion`; writes changes back to Fastmail through an outbox worker
- **Collaborative lists** — creates, reorders (fractional indexing), and syncs grocery/gift lists in MariaDB via Drizzle ORM
- **OIDC auth** — all `/api/*` routes protected by `@hono/oidc-auth` with authorization-code + PKCE flow against Authelia; `DEV_AUTH_BYPASS=true` skips OIDC for local development
- **Live sync** — Server-Sent Events stream list mutations to connected PWA clients in real time
- **Push notifications** — web-push (VAPID) delivers reminders for shared timed events to subscribed browsers
## Source layout
```
src/
index.ts Hono app entrypoint; server startup; background worker initialization
routes/
events.ts CalDAV event CRUD endpoints
lists.ts List and list-item CRUD endpoints
me.ts Authenticated user profile endpoint
push.ts Push subscription registration
sse.ts SSE stream for live list updates
health.ts Unauthenticated health check
db/
schema.ts Drizzle table definitions (MariaDB/mysql2)
client.ts Drizzle client singleton
migrations/ SQL migrations generated by drizzle-kit
auth/
middleware.ts oidcAuthMiddleware + processOAuthCallback
devBypass.ts DEV_AUTH_BYPASS passthrough (non-production only)
persistSessionCookie.ts Re-issues session cookie as persistent for PWA
user.ts User upsert on first login
broker/
poller.ts 5-minute setInterval CalDAV ctag change-detection
outboxWorker.ts 15-second drain of pending CalDAV writes to Fastmail
reminderScheduler.ts 1-minute scan for upcoming shared events → push
client.ts tsdav client factory
sync.ts REPORT → ical.js → DB upsert logic
write.ts CalDAV PUT/DELETE helpers
expand.ts recurrence expansion via ICAL.RecurExpansion
vevent.ts VEVENT ↔ DB row mapping
crypto.ts AES-256-GCM encrypt/decrypt for stored app passwords
lib/
listEmitter.ts In-process EventEmitter for SSE fan-out
listChangeDispatcher.ts Publishes list mutations to listEmitter
eventChangeDispatcher.ts Publishes calendar mutations
pushDispatcher.ts Dispatches VAPID push payloads
pushCoalescer.ts Debounces push for rapid successive edits
listAccess.ts List permission helpers
rank.ts Fractional indexing helpers
```
## Running in the workspace
All commands below run from the monorepo root via the `--filter` flag, or from `apps/api/` directly.
### Prerequisites
- Node.js 22 LTS
- `pnpm` (see root `package.json` for version)
- MariaDB reachable at the coordinates in your `.env`
- Authelia OIDC provider (or use `DEV_AUTH_BYPASS=true` for local development)
### Development
`dev` runs the compiled `dist/` with `node --watch`. You must build first — `tsc` output in `dist/` is the source of truth at runtime.
```bash
# From monorepo root:
pnpm --filter @familysync/api build # compile TypeScript → dist/
pnpm --filter @familysync/api dev # node --watch dist/index.js
# Or from apps/api/:
pnpm build
pnpm dev
```
Rebuild after any source change; `node --watch` reloads on `dist/` file changes but does not invoke `tsc` itself.
### Production
```bash
pnpm --filter @familysync/api build
pnpm --filter @familysync/api start # node dist/index.js
```
The server listens on port `3000`.
## Database migrations
Never use `drizzle-kit push` against a populated MariaDB instance — it emits false destructive diffs and will truncate data.
```bash
# 1. Generate SQL migration files from schema changes:
pnpm --filter @familysync/api db:generate
# 2. Apply pending migrations:
pnpm --filter @familysync/api db:migrate
```
Migration files are written to `src/db/migrations/` and checked into source control.
## Environment variables
| Variable | Required | Description |
|---|---|---|
| `DB_HOST` | Yes | MariaDB host |
| `DB_USER` | Yes | MariaDB user |
| `DB_PASSWORD` | Yes | MariaDB password |
| `DB_NAME` | Yes | MariaDB database name |
| `DB_PORT` | No (default `3306`) | MariaDB port |
| `OIDC_ISSUER` | Yes (production) | Authelia issuer URL |
| `OIDC_CLIENT_ID` | Yes (production) | OIDC client ID |
| `OIDC_CLIENT_SECRET` | Yes (production) | OIDC client secret |
| `OIDC_AUTH_EXTERNAL_URL` | Yes (production) | External-facing URL for redirect_uri behind Pangolin tunnel |
| `VAPID_SUBJECT` | Yes (push) | `mailto:` or `https:` operator identifier |
| `VAPID_PUBLIC_KEY` | Yes (push) | VAPID public key |
| `VAPID_PRIVATE_KEY` | Yes (push) | VAPID private key |
| `CREDENTIAL_ENCRYPTION_KEY` | Yes | AES-256-GCM key for stored Fastmail app passwords |
| `DEV_AUTH_BYPASS` | No | Set to `true` (non-production only) to skip OIDC and inject a dev user |
| `NODE_ENV` | No | Set to `production` to enforce OIDC unconditionally |
See [../../docs/CONFIGURATION.md](../../docs/CONFIGURATION.md) for the full reference.
## Tests
Tests live in `tests/` (integration, route, broker unit) and `test/setup.ts` (global setup/teardown).
```bash
# Run full suite (sequential — shared MariaDB requires serial file execution):
pnpm --filter @familysync/api test
# Watch mode:
pnpm --filter @familysync/api test:watch
# Type-check without emitting:
pnpm --filter @familysync/api typecheck
```
Integration tests that hit MariaDB require a running dev DB with `DB_HOST=127.0.0.1` and credentials from your `.env`. See [../../docs/TESTING.md](../../docs/TESTING.md) for the full setup.
## Further reading
- [Architecture](../../docs/ARCHITECTURE.md) — system overview and component diagram
- [API reference](../../docs/API.md) — endpoint table, request/response shapes, auth flow
- [Configuration](../../docs/CONFIGURATION.md) — all environment variables
- [Deployment](../../docs/deployment.md) — Docker Compose, Unraid setup, VAPID key generation
+97
View File
@@ -0,0 +1,97 @@
<!-- generated-by: gsd-doc-writer -->
# @familysync/pwa
The React 19 PWA frontend for FamilySync. Delivers a single installable, low-friction interface showing the color-coded family calendar and shared collaborative lists, designed for a mixed Android/Apple household.
Part of the [FamilySync monorepo](../../README.md).
## Stack
| Layer | Library | Version |
|-------|---------|---------|
| UI framework | React | ^19.0.0 |
| Build + dev server | Vite | 8.0.16 |
| PWA service worker + manifest | vite-plugin-pwa | ^1.3.0 |
| Calendar widget | @schedule-x/calendar | 4.6.0 |
| Server state | @tanstack/react-query | 5.101.0 |
| UI state | zustand | 5.0.14 |
| Routing | react-router | ^7.17.0 |
| iCalendar parsing | ical.js | 2.2.1 |
| Drag-and-drop (lists) | @dnd-kit/core + @dnd-kit/sortable | ^6 / ^10 |
## Development
Run the PWA dev server from the monorepo root:
```bash
pnpm --filter @familysync/pwa dev
```
The API backend must also be running for most features. See [GETTING-STARTED.md](../../docs/GETTING-STARTED.md) for full stack bring-up instructions.
## Scripts
| Command | What it does |
|---------|--------------|
| `pnpm --filter @familysync/pwa dev` | Start Vite dev server (HMR) |
| `pnpm --filter @familysync/pwa build` | Type-check then build production bundle (`tsc && vite build`) |
| `pnpm --filter @familysync/pwa preview` | Serve the production build locally |
| `pnpm --filter @familysync/pwa typecheck` | Run `tsc --noEmit` without emitting files |
| `pnpm --filter @familysync/pwa test` | Run Vitest test suite once (`vitest run`) |
## Source layout
```
src/
api/ # Typed fetch wrappers for @familysync/api (client.ts, listsClient.ts)
components/ # Shared UI components co-located with their *.test.tsx files
hooks/ # Custom React hooks (useListSSE, usePushSubscription, useFocusTrap)
lib/ # Pure helpers: calendarConfig, colorUtils, eventDateTime, hydrateEvents, loginRedirect
routes/ # React Router route components with co-located tests (ListDetail, ListsIndex)
store/ # Zustand stores: calendarStore, listsStore
styles/ # Global CSS
main.tsx # App entry point — React Query client, router, global error handlers
App.tsx # Root component
sw.ts # Workbox service worker entry
test-setup.ts # Vitest + jest-dom global setup
```
Routes and their co-located components own the feature slice. Library code (pure, side-effect-free) lives in `lib/`. Zustand stores hold UI-only state; server data is exclusively managed by TanStack Query.
## Communication with the API
All backend calls go through `src/api/client.ts` and `src/api/listsClient.ts`. Key behavior:
- `credentials: 'include'` on every request so the OIDC session cookie is forwarded.
- `redirect: 'manual'` — a 401 or opaque redirect (the Authelia 302) is caught and thrown as a typed `SessionExpiredError`. The global `QueryCache` / `MutationCache` error handler in `main.tsx` intercepts this and shows the session-expiry interstitial.
- Re-authentication requires a top-level navigation to `/api/login` (handled by `src/lib/loginRedirect.ts`), not a fetch redirect, because browsers block CORS redirects to an external IdP.
- Live list updates are delivered via SSE through `src/hooks/useListSSE.ts`; the hook calls `queryClient.invalidateQueries` on each event so TanStack Query re-fetches.
In development the Vite proxy routes `/api` requests to the API server on port 3000, keeping cookies same-site. In production both apps are served same-origin via the Pangolin/Newt tunnel.
## Testing
Tests are co-located with their source files (`*.test.tsx` / `*.test.ts`) and use React Testing Library + `@testing-library/jest-dom`. The test environment is `jsdom`.
```bash
# run once
pnpm --filter @familysync/pwa test
# watch mode (during development)
pnpm --filter @familysync/pwa exec vitest
```
No coverage threshold is configured. Run `pnpm --filter @familysync/pwa typecheck` separately — Vitest uses esbuild and will not surface TypeScript errors.
## PWA install notes
- The app must be added to the Home Screen on iOS for push notifications to work (iOS 16.4+ minimum).
- Push permission must be requested inside a tap handler; calling `pushManager.subscribe()` on page load is blocked.
- Background sync and background push are not supported on iOS; all push messages must display a visible notification.
## Further reading
- [Architecture overview](../../docs/ARCHITECTURE.md)
- [Getting started](../../docs/GETTING-STARTED.md)
- [Development guide](../../docs/DEVELOPMENT.md)
- [Testing guide](../../docs/TESTING.md)
+617
View File
@@ -0,0 +1,617 @@
<!-- generated-by: gsd-doc-writer -->
# API Reference
FamilySync exposes a Hono HTTP API served on port 3000. All `/api/*` routes require an authenticated session (OIDC via Authelia, or `DEV_AUTH_BYPASS=true` for local development). Unauthenticated requests to protected routes receive a `302` redirect to Authelia's authorize endpoint, not a `401`, except where noted.
## Authentication
The API uses OIDC session cookies managed by `@hono/oidc-auth`. The authorization-code + PKCE flow is handled transparently:
1. The PWA navigates to `GET /api/login`. This route sits under `/api/*`, where the `@hono/oidc-auth` guard is mounted.
2. For a request with no valid session cookie, the OIDC middleware intercepts it before the route handler runs and 302-redirects to Authelia's authorize endpoint.
3. Authelia posts the authorization code to `GET /callback`, which exchanges it for tokens and sets an `httpOnly; Secure; SameSite` session cookie, then continues back to `/api/login` — whose handler now redirects to `/` (the app shell).
4. All subsequent `/api/*` requests carry the session cookie automatically. (The root `/` and static assets are served outside the `/api/*` guard.)
**Dev bypass:** When `DEV_AUTH_BYPASS=true` and `NODE_ENV != production`, the OIDC guard is disabled and a fixed dev user (id `1`) is injected into every request. Never enable in production.
No API key or `Authorization` header is used. Credentials are never included in response bodies.
## Endpoints Overview
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | None | DB liveness check |
| GET | `/callback` | None | OIDC authorization-code exchange |
| GET | `/api/login` | OIDC | Login entry point, redirects to `/` |
| GET | `/api/me` | OIDC | Current user identity and color |
| GET | `/api/events` | OIDC | Windowed calendar occurrences |
| POST | `/api/events/create` | OIDC | Enqueue a new event write |
| PATCH | `/api/events/:uid/edit` | OIDC | Enqueue an event update |
| DELETE | `/api/events/:uid` | OIDC | Enqueue an event delete |
| GET | `/api/events/sync-status` | OIDC | Outbox status for a UID |
| GET | `/api/events/writable-calendars` | OIDC | Calendars the member can write to |
| GET | `/api/lists` | OIDC | All lists accessible to the member |
| POST | `/api/lists` | OIDC | Create a list |
| PATCH | `/api/lists/:id` | OIDC | Update list name or sharing |
| DELETE | `/api/lists/:id` | OIDC | Delete a list (owner only) |
| GET | `/api/lists/:id/items` | OIDC | All items in a list |
| POST | `/api/lists/:id/items` | OIDC | Add an item to a list |
| PATCH | `/api/list-items/:itemId` | OIDC | Update a single list item field |
| DELETE | `/api/list-items/:itemId` | OIDC | Delete a list item |
| GET | `/api/sse/heartbeat` | OIDC | SSE heartbeat stream |
| GET | `/api/sse/lists` | OIDC | Scoped live-list SSE stream |
| GET | `/api/push/vapid-public-key` | OIDC | VAPID public key for push subscribe |
| POST | `/api/push/subscription` | OIDC | Register a push subscription |
| DELETE | `/api/push/subscription` | OIDC | Remove push subscriptions for caller |
---
## Health
### `GET /health`
Unauthenticated. Performs a `SELECT 1` against MariaDB to prove connectivity.
**Response 200**
```json
{ "ok": true, "db": "up" }
```
**Response 503** (DB unreachable)
```json
{ "ok": false, "db": "down" }
```
---
## Identity
### `GET /api/me`
Returns the authenticated member's identity and their assigned color.
Display name is derived from OIDC claims in priority order: `name``preferred_username``email``sub`. The user row is upserted on first visit (keyed on `oidc_iss` + `oidc_sub`).
**Response 200**
```json
{
"user": {
"id": 1,
"displayName": "Lucas",
"color": "#4A90D9"
}
}
```
**Error responses:** `401` if the session is invalid.
---
## Calendar Events
Calendar data is read from a MariaDB cache populated by the CalDAV broker poller. Write operations enqueue outbox rows; the outbox worker dispatches them to Fastmail CalDAV asynchronously. Clients receive `202 Accepted` immediately and poll `GET /api/events/sync-status` to confirm settlement.
### `GET /api/events?start=YYYY-MM-DD&end=YYYY-MM-DD`
Returns a flat array of concrete event occurrences for the given date window. Recurring events are expanded server-side via `rrule`. The window is capped at 90 days. Returns events from calendars the member owns plus any shared (family) calendars.
**Query parameters**
| Parameter | Required | Format | Notes |
|-----------|----------|--------|-------|
| `start` | Yes | `YYYY-MM-DD` | Window start (inclusive) |
| `end` | Yes | `YYYY-MM-DD` | Window end (exclusive); max 90 days from start |
**Response 200**
```json
{
"occurrences": [
{
"uid": "abc123@familysync",
"title": "Doctor appointment",
"allDay": false,
"start": "2025-06-15T09:00:00Z",
"end": "2025-06-15T10:00:00Z",
"calendarId": 3,
"calendarName": "Personal",
"color": "#4A90D9",
"isShared": false,
"userId": 1,
"ownerName": "Lucas"
}
]
}
```
**Error responses:** `400` if the window is invalid or exceeds 90 days; `401` unauthorized; `503` DB error.
---
### `POST /api/events/create`
Enqueues a new event for creation on Fastmail CalDAV. Returns `202` immediately with the assigned UID.
**Request body**
```json
{
"title": "Family dinner",
"allDay": false,
"start": "2025-06-20T18:00:00Z",
"end": "2025-06-20T20:00:00Z",
"location": "Home",
"description": "Optional notes",
"recurrence": "weekly",
"recurrenceUntil": "2025-12-31",
"recurrenceCount": 10,
"calendarUrl": "https://caldav.fastmail.com/dav/.../calendar/"
}
```
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `title` | string | Yes | 1255 characters |
| `allDay` | boolean | Yes | |
| `start` | string | Yes | ISO datetime or `YYYY-MM-DD` for all-day; max 64 chars |
| `end` | string | Yes | ISO datetime or `YYYY-MM-DD` for all-day; max 64 chars |
| `location` | string | No | Max 2000 characters |
| `description` | string | No | Max 2000 characters |
| `recurrence` | string | No | `none` \| `daily` \| `weekly` \| `monthly` \| `yearly` |
| `recurrenceUntil` | string | No | `YYYY-MM-DD` — maps to RRULE `UNTIL` |
| `recurrenceCount` | integer | No | ≥ 1 — maps to RRULE `COUNT` |
| `calendarUrl` | string | No | CalDAV collection URL (max 1024); defaults to member's first personal calendar |
`calendarUrl` must belong to the authenticated member or be the shared family calendar. Requests for another member's personal calendar return `403`.
**Response 202**
```json
{ "uid": "f47ac10b-58cc-4372-a567-0e02b2c3d479@familysync" }
```
**Error responses:** `401` unauthorized; `403` calendar access denied; `422` no writable calendar found; `503` DB error.
---
### `PATCH /api/events/:uid/edit`
Enqueues an update to an existing event. Body shape is identical to `POST /api/events/create`. Returns `202` with the (possibly new) UID.
If `calendarUrl` in the body differs from the event's current calendar, the operation is a calendar move: a delete+create pair is enqueued atomically. A new UID is assigned and returned.
**Path parameter:** `uid` — the event UID as returned by `POST /api/events/create` or `GET /api/events`.
**Request body:** Same schema as `POST /api/events/create`.
**Response 202**
```json
{ "uid": "f47ac10b-58cc-4372-a567-0e02b2c3d479@familysync" }
```
**Error responses:** `401` unauthorized; `403` access denied; `404` event not found; `503` DB error.
---
### `DELETE /api/events/:uid`
Enqueues a delete for an existing event. Returns `202` immediately.
**Path parameter:** `uid` — the event UID.
**Response 202**
```json
{ "uid": "f47ac10b-58cc-4372-a567-0e02b2c3d479@familysync" }
```
**Error responses:** `401` unauthorized; `403` access denied; `404` event not found; `503` DB error.
---
### `GET /api/events/sync-status?uid=<uid>`
Returns the outbox status for a given UID, scoped to the authenticated member. Used by the PWA to confirm write settlement after `202` responses.
If no outbox row exists, the write has settled (or never existed).
**Query parameters**
| Parameter | Required | Notes |
|-----------|----------|-------|
| `uid` | Yes | 1512 characters |
**Response 200**
```json
{ "uid": "f47ac10b-...", "status": "done" }
```
Possible `status` values: `pending` \| `failed` \| `dead` \| `done`.
When `status` is `failed` or `dead`, an `error` field is included:
```json
{ "uid": "f47ac10b-...", "status": "dead", "error": "CalDAV 409 conflict" }
```
**Error responses:** `401` unauthorized; `503` DB error.
---
### `GET /api/events/writable-calendars`
Returns the authoritative writable calendar set for the authenticated member: their own personal calendar(s) and the shared family calendar. Another member's personal calendar is never included.
**Response 200**
```json
{
"calendars": [
{
"url": "https://caldav.fastmail.com/dav/.../Personal/",
"displayName": "Personal",
"color": "#4A90D9",
"isShared": false
},
{
"url": "https://caldav.fastmail.com/dav/.../Family/",
"displayName": "Family",
"color": "#F25C7A",
"isShared": true
}
]
}
```
**Error responses:** `401` unauthorized; `503` DB error.
---
## Lists
Lists are stored in MariaDB. A list can be private (owner-only) or shared (all household members). When `isShared: true`, share rows are created automatically for every other member — there is no client-facing shares endpoint.
### `GET /api/lists`
Returns all lists the authenticated member owns or has been shared. Includes item counts per list.
**Response 200**
```json
{
"lists": [
{
"id": 1,
"name": "Groceries",
"isShared": true,
"ownerId": 1,
"activeCount": 5,
"doneCount": 2,
"createdAt": "2025-06-01T10:00:00.000Z",
"updatedAt": "2025-06-10T08:00:00.000Z"
}
]
}
```
**Error responses:** `401` unauthorized; `503` DB error.
---
### `POST /api/lists`
Creates a new list. If `isShared: true` (the default), share rows are auto-inserted for all other members.
**Request body**
```json
{ "name": "Gift Ideas", "isShared": true }
```
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `name` | string | Yes | 1255 characters |
| `isShared` | boolean | No | Default: `true` |
**Response 201**
```json
{
"id": 2,
"name": "Gift Ideas",
"isShared": true,
"ownerId": 1,
"activeCount": 0,
"doneCount": 0,
"createdAt": "2025-06-10T12:00:00.000Z",
"updatedAt": "2025-06-10T12:00:00.000Z"
}
```
**Error responses:** `401` unauthorized; `503` DB error.
---
### `PATCH /api/lists/:id`
Updates a list's `name` and/or `isShared`. At least one field must be present. Only the list owner can change `isShared`; a sharee may rename.
Visibility changes reconcile share rows automatically:
- `false → true`: inserts share rows for all other members.
- `true → false`: deletes all non-owner share rows.
**Path parameter:** `id` — integer list ID.
**Request body** (at least one field required)
```json
{ "name": "Weekly Groceries", "isShared": false }
```
**Response 200**
```json
{
"id": 1,
"name": "Weekly Groceries",
"isShared": false,
"ownerId": 1,
"createdAt": "2025-06-01T10:00:00.000Z",
"updatedAt": "2025-06-10T13:00:00.000Z"
}
```
**Error responses:** `401` unauthorized; `403` access denied or sharee attempted `isShared` change; `404` list not found; `503` DB error.
---
### `DELETE /api/lists/:id`
Deletes a list. Owner-only. Cascades to all items and share rows.
**Path parameter:** `id` — integer list ID.
**Response 200**
```json
{ "id": 1 }
```
**Error responses:** `401` unauthorized; `403` access denied (non-owner); `404` list not found; `503` DB error.
---
### `GET /api/lists/:id/items`
Returns all items in a list, ordered by fractional rank ascending. Accessible to owner and sharees.
**Path parameter:** `id` — integer list ID.
**Response 200**
```json
{
"items": [
{
"id": 10,
"listId": 1,
"text": "Milk",
"checked": false,
"rank": "a0",
"createdAt": "2025-06-10T10:00:00.000Z",
"updatedAt": "2025-06-10T10:00:00.000Z"
}
]
}
```
**Error responses:** `401` unauthorized; `403` access denied; `404` list not found; `503` DB error.
---
### `POST /api/lists/:id/items`
Adds an item to a list at the bottom of the active (unchecked) section. Accessible to owner and sharees.
**Path parameter:** `id` — integer list ID.
**Request body**
```json
{ "text": "Eggs" }
```
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `text` | string | Yes | 1500 characters (plain text only) |
**Response 201**
```json
{
"id": 11,
"listId": 1,
"text": "Eggs",
"checked": false,
"rank": "a1",
"createdAt": "2025-06-10T14:00:00.000Z",
"updatedAt": "2025-06-10T14:00:00.000Z"
}
```
**Error responses:** `401` unauthorized; `403` access denied; `404` list not found; `503` DB error.
---
### `PATCH /api/list-items/:itemId`
Updates exactly one field of a list item (last-write-wins). Exactly one of `checked`, `text`, or `position` must be present.
Special case: setting `checked: false` (uncheck) also recomputes the item's rank to place it at the bottom of the active section.
**Path parameter:** `itemId` — integer item ID.
**Request body** (exactly one field)
```json
{ "checked": true }
```
or
```json
{ "text": "Whole milk" }
```
or
```json
{ "position": "a0V" }
```
| Field | Type | Constraints |
|-------|------|-------------|
| `checked` | boolean | |
| `text` | string | 1500 characters |
| `position` | string | Fractional rank string, 1255 characters |
**Response 200** — updated item shape (same as `POST /api/lists/:id/items` response).
**Error responses:** `401` unauthorized; `403` access denied; `404` item not found; `503` DB error.
---
### `DELETE /api/list-items/:itemId`
Deletes a list item permanently. Accessible to list owner and sharees. No soft-delete; deletion is final.
**Path parameter:** `itemId` — integer item ID.
**Response 200**
```json
{ "id": 11 }
```
**Error responses:** `401` unauthorized; `403` access denied; `404` item not found; `503` DB error.
---
## Server-Sent Events
SSE streams use `Content-Type: text/event-stream`. The client should reconnect on disconnect with bounded backoff (handled by the `useListSSE` hook in the PWA).
### `GET /api/sse/heartbeat`
Streams `heartbeat` events every 10 seconds indefinitely. Used as a Pangolin tunnel smoke test.
**Event format**
```
event: heartbeat
id: 0
data: {"ts":"2025-06-10T14:00:00.000Z","id":0}
```
---
### `GET /api/sse/lists`
Scoped live-list fan-out stream. Delivers mutation events only for lists the authenticated member can access (owned + shared). Events for other members' private lists are never delivered.
A `heartbeat` event is sent every 30 seconds to keep the Pangolin connection alive.
**Event types**
| Event type | Payload |
|------------|---------|
| `list:updated` | `{ type, listId, payload: { id, name } }` |
| `list:deleted` | `{ type, listId, payload: { id } }` |
| `item:added` | `{ type, listId, payload: { id, listId, text } }` |
| `item:updated` | `{ type, listId, payload: { id, listId } }` |
| `item:deleted` | `{ type, listId, payload: { id } }` |
| `heartbeat` | `{ ts }` |
**Event format example**
```
event: item:added
id: 1-1718020800000
data: {"type":"item:added","listId":1,"payload":{"id":11,"listId":1,"text":"Eggs"}}
```
The PWA treats these events as cache invalidation signals and full-refetches via TanStack Query. Event payloads are not used directly for cache updates.
**Error responses:** `401` if session is invalid (returned as JSON before the stream opens).
---
## Push Notifications
Push uses VAPID direct push (no FCM broker). The PWA must be installed to the home screen on iOS 16.4+ for push to function. `pushManager.subscribe()` must be called inside a user gesture handler.
### `GET /api/push/vapid-public-key`
Returns the server's VAPID public key. The public key is non-secret and required by the browser to call `pushManager.subscribe()`.
**Response 200**
```json
{ "publicKey": "BNF8dFt..." }
```
---
### `POST /api/push/subscription`
Registers a Web Push subscription for the authenticated member. Upserts on `endpoint` — re-subscribing from the same device updates keys without creating duplicate rows. The `userId` is derived from the OIDC session and never accepted from the request body.
**Request body**
```json
{
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {
"p256dh": "BNF8dFt...",
"auth": "tBHItJI..."
}
}
```
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `endpoint` | string (URL) | Yes | Max 2048 characters |
| `keys.p256dh` | string | Yes | 1512 characters |
| `keys.auth` | string | Yes | 1256 characters |
**Response 201**
```json
{ "ok": true }
```
**Error responses:** `401` unauthorized; `503` DB error.
---
### `DELETE /api/push/subscription`
Removes all push subscription rows for the authenticated member. Cannot affect another member's subscriptions.
**Response 200**
```json
{ "ok": true }
```
**Error responses:** `401` unauthorized; `503` DB error.
---
## Error Codes
All error responses use a consistent JSON envelope.
```json
{ "error": "Human-readable message" }
```
| HTTP Status | Meaning |
|-------------|---------|
| `400` | Invalid request parameters (e.g., malformed date window) |
| `401` | Session missing or invalid |
| `403` | Authenticated but not authorized (wrong owner, sharee attempted owner-only op) |
| `404` | Resource not found |
| `422` | Valid request but cannot be fulfilled (e.g., user has no calendar configured) |
| `503` | DB or downstream service unavailable |
Zod validation failures return `400` with a structured body from `@hono/zod-validator` rather than the `{ "error": "..." }` envelope.
---
## Rate Limits
No rate limiting is configured in the application layer. <!-- VERIFY: confirm whether Pangolin/Newt or Authelia enforce rate limits at the network edge -->
+222
View File
@@ -0,0 +1,222 @@
<!-- generated-by: gsd-doc-writer -->
# FamilySync Architecture
FamilySync is a self-hosted family organization hub — a unified, color-coded calendar and shared collaborative lists — delivered as a React PWA. The system is a two-container Docker Compose stack (API + MariaDB) running on Unraid, exposed through a Pangolin/Newt tunnel with Authelia providing OIDC authentication.
---
## System Overview
FamilySync follows a layered architecture with Fastmail CalDAV as the external calendar source of truth. The React PWA talks exclusively to a single Hono API backend. The backend handles authentication (Authelia OIDC), calendar read/write via CalDAV (Fastmail), list persistence (MariaDB), and real-time push delivery.
```mermaid
graph TD
subgraph "Client (Browser / Home Screen PWA)"
PWA["React 19 PWA\n(Vite + vite-plugin-pwa)"]
SW["Service Worker\n(Workbox precache + push)"]
end
subgraph "API (apps/api — Hono on Node 22)"
AUTH["OIDC Auth\n(@hono/oidc-auth)"]
ROUTES["API Routes\n/events /lists /me /push /sse"]
BROKER["CalDAV Broker\n(tsdav + ical.js + rrule)"]
OUTBOX["Outbox Worker\n(15s drain loop)"]
POLLER["CalDAV Poller\n(5-min setInterval)"]
REMINDER["Reminder Scheduler\n(1-min setInterval)"]
SSE_LIB["List Emitter\n(in-process EventEmitter)"]
PUSH_LIB["Push Dispatcher\n(web-push VAPID)"]
end
subgraph "Persistence"
DB["MariaDB 11\n(Drizzle ORM)"]
end
subgraph "External Services"
AUTHELIA["Authelia\n(OIDC / OAuth2 IdP)"]
FASTMAIL["Fastmail CalDAV\ncaldav.fastmail.com"]
PUSH_SVC["Browser Push Services\n(APNs / FCM)"]
end
PWA -- "HTTPS (same-origin via Pangolin)" --> ROUTES
SW -- "push events" --> PWA
ROUTES --> AUTH
AUTH -- "authorization-code + PKCE" --> AUTHELIA
ROUTES --> BROKER
ROUTES --> SSE_LIB
ROUTES --> DB
BROKER -- "PROPFIND / REPORT / PUT / DELETE" --> FASTMAIL
POLLER --> BROKER
OUTBOX --> BROKER
BROKER --> DB
REMINDER --> DB
REMINDER --> PUSH_LIB
OUTBOX --> PUSH_LIB
SSE_LIB -- "text/event-stream" --> PWA
PUSH_LIB -- "VAPID push" --> PUSH_SVC
PUSH_SVC --> SW
```
---
## Directory Structure
```
familysync/
├── apps/
│ ├── api/ # Hono backend (Node 22 + TypeScript)
│ │ └── src/
│ │ ├── index.ts # App entry: mounts routes, starts background workers
│ │ ├── routes/ # HTTP route handlers
│ │ ├── auth/ # OIDC middleware + dev-bypass + session persistence
│ │ ├── broker/ # CalDAV integration layer
│ │ ├── db/ # Drizzle schema, client, migrations
│ │ └── lib/ # Shared dispatchers and utilities
│ └── pwa/ # React 19 PWA (Vite + vite-plugin-pwa)
│ └── src/
│ ├── App.tsx # BrowserRouter shell with persistent nav chrome
│ ├── routes/ # Page-level React components
│ ├── components/ # Shared UI components
│ ├── api/ # Typed fetch wrappers (client.ts, listsClient.ts)
│ ├── hooks/ # Custom hooks (useListSSE, usePushSubscription)
│ ├── store/ # Zustand UI-state stores
│ ├── lib/ # Pure utilities (event date/time, login redirect)
│ └── sw.ts # Custom Workbox service worker
├── docker-compose.yml # Production stack
└── docker-compose.dev.yml # Dev overrides (port-binds, volume mounts)
```
### Directory Rationale
| Directory | Purpose |
|-----------|---------|
| `apps/api/src/routes/` | One file per resource — `events.ts`, `lists.ts`, `me.ts`, `push.ts`, `sse.ts`, `health.ts` |
| `apps/api/src/broker/` | All CalDAV I/O: `client.ts` (tsdav factory), `sync.ts` (REPORT→DB), `poller.ts` (5-min ctag check), `outboxWorker.ts` (async write-back), `expand.ts` (RRULE expansion), `write.ts` (PUT/DELETE), `vevent.ts` (ICS builder), `crypto.ts` (AES-256-GCM for app passwords) |
| `apps/api/src/auth/` | `middleware.ts` (re-exports `@hono/oidc-auth`), `devBypass.ts` (DEV_AUTH_BYPASS inject), `persistSessionCookie.ts` (session lifetime extension), `user.ts` (upsert on first OIDC login) |
| `apps/api/src/db/` | `schema.ts` (Drizzle `mysqlTable` definitions), `client.ts` (mysql2 pool), `migrations/` (drizzle-kit output) |
| `apps/api/src/lib/` | Stateless helpers: `listEmitter.ts` (EventEmitter fan-out), `listChangeDispatcher.ts`, `eventChangeDispatcher.ts`, `pushDispatcher.ts` (VAPID send), `pushCoalescer.ts`, `listAccess.ts`, `rank.ts` (fractional indexing) |
| `apps/pwa/src/api/` | Thin typed fetch wrappers — `client.ts` (events, me, sync-status), `listsClient.ts` (lists and items) |
| `apps/pwa/src/store/` | `calendarStore.ts` and `listsStore.ts` — Zustand UI-only state (no server data) |
| `apps/pwa/src/hooks/` | `useListSSE.ts` (bounded-backoff EventSource), `usePushSubscription.ts` (VAPID subscribe) |
---
## Key Abstractions
| Abstraction | File | Description |
|-------------|------|-------------|
| `app` (Hono) | `apps/api/src/index.ts` | Root Hono app; mounts all routes and serves the PWA static build |
| Drizzle schema | `apps/api/src/db/schema.ts` | Single source of truth for all table definitions (`users`, `memberCredentials`, `calendars`, `calendarEvents`, `calendarOutbox`, `lists`, `listShares`, `listItems`, `pushSubscriptions`) |
| `syncCalendar` | `apps/api/src/broker/sync.ts` | REPORT → ical.js parse → `onDuplicateKeyUpdate` upsert into MariaDB |
| `expandOccurrences` | `apps/api/src/broker/expand.ts` | Server-side RRULE expansion using `ical.js` + `rrule`; never runs in the browser |
| `CalendarOccurrence` | `apps/api/src/broker/expand.ts` | Wire type for a single concrete event occurrence; mirrored in the PWA's `api/client.ts` |
| `calendarOutbox` table | `apps/api/src/db/schema.ts` | Transactional outbox pattern — CalDAV writes are enqueued here and drained asynchronously |
| `runOutboxDrain` | `apps/api/src/broker/outboxWorker.ts` | Drains pending outbox rows every 15s; handles retry backoff, 412 conflict, dead-lettering, and edit-as-move ordering |
| `publishListEvent` / `subscribeListEvents` | `apps/api/src/lib/listEmitter.ts` | In-process EventEmitter fan-out keyed per list; SSE route subscribes on open and unsubscribes on disconnect |
| `dispatchPush` | `apps/api/src/lib/pushDispatcher.ts` | Centralised VAPID-signed push sender; handles 410/404 subscription pruning |
| `SessionExpiredError` | `apps/pwa/src/api/client.ts` | Typed error thrown by all fetch wrappers on 401/opaqueredirect; global `QueryCache` handler arms the session-expiry interstitial |
| Zustand stores | `apps/pwa/src/store/` | UI-only ephemeral state (open panels, selected date, active tab); server state always in TanStack Query |
---
## Data Flow
### Calendar read (typical request)
1. On mount, the PWA's `CalendarShell` computes a date window and fires `fetchEvents(start, end)` via TanStack Query.
2. `GET /api/events?start=&end=` hits the Hono `eventsRouter`.
3. The route queries `calendarEvents` + `calendars` from MariaDB, filtering by the authenticated user's accessible calendars.
4. Raw `rawVevent` blobs are passed through `expandOccurrences()` (ical.js + rrule) to produce concrete `CalendarOccurrence` objects for the window.
5. The JSON response is cached by TanStack Query; Schedule-X renders the events.
### Calendar write (create/edit/delete)
1. The PWA calls `POST /api/events/create` (or `PATCH /api/events/:uid/edit` / `DELETE /api/events/:uid`).
2. The route validates the payload with Zod, assigns a UID, and inserts a `pending` row into `calendarOutbox`. Returns `202 Accepted` immediately.
3. Every 15 seconds `runOutboxDrain` picks up `pending` rows, decrypts the member's Fastmail app password (AES-256-GCM), and dispatches the CalDAV write via `tsdav` (PUT/DELETE).
4. On success, the worker triggers a targeted `syncCalendar` re-sync for that calendar, then marks the outbox row `done`.
5. The PWA's `SyncStateToast` polls `GET /api/events/sync-status?uid=` and invalidates the `['events']` TanStack Query cache when status transitions to `done`.
### Calendar background sync (poller)
1. `startBrokerPoller` fires every 5 minutes via `setInterval`.
2. For each row in `member_credentials`, the poller decrypts the app password, builds a `tsdav` client, and runs `fetchCalendars()` (PROPFIND).
3. Each calendar's live `ctag` is compared against the stored value. If unchanged, the calendar is skipped.
4. On change, `syncCalendar` fetches all objects (REPORT), parses with ical.js, and upserts `calendarEvents` rows.
5. Detected event changes are passed to `dispatchEventChange`, which sends VAPID push notifications to non-actor members' subscriptions.
### Live list sync
1. When the PWA opens a list, `useListSSE` opens an `EventSource` to `GET /api/sse/lists` with `withCredentials: true`.
2. The SSE route resolves the caller's accessible list IDs via `getAccessibleListIds`, then calls `subscribeListEvents(listId, handler)` for each one.
3. When any list is mutated via the REST routes (`POST /api/lists`, `PATCH /api/list-items/:id`, etc.), the route calls `publishListEvent(listId, event)`.
4. The in-process `EventEmitter` fans the event out to all active `subscribeListEvents` handlers, which write it to the SSE stream.
5. The PWA's SSE handler calls `queryClient.invalidateQueries(['list', listId])`, triggering a full refetch.
6. A 30-second polling fallback (`refetchInterval: 30000`) is always active in `ListDetail` as a safety net.
### Authentication
1. An unauthenticated browser navigates to `/api/login`.
2. The `oidcAuthMiddleware` (`@hono/oidc-auth`) issues a `302` to Authelia's `/authorize` endpoint with PKCE (S256).
3. After login, Authelia posts the authorization code to `/callback`; `processOAuthCallback` exchanges it for tokens and issues a signed JWT session cookie.
4. `persistSessionCookie` middleware re-issues the cookie as persistent on every authenticated response so the PWA session survives browser close.
5. All `/api/*` routes require the session cookie; a missing or expired session returns a `302` which the PWA's fetch wrappers detect as `opaqueredirect` and convert to a `SessionExpiredError`.
---
## Component Interaction
### Backend modules
```
routes/events.ts ──→ broker/expand.ts (read: RRULE expansion)
──→ calendarOutbox (DB) (write: enqueue)
──→ broker/sync.ts (write-sync after outbox drain)
broker/outboxWorker.ts ──→ broker/write.ts (CalDAV PUT/DELETE)
──→ broker/sync.ts (targeted re-sync)
──→ lib/eventChangeDispatcher.ts → lib/pushDispatcher.ts
broker/poller.ts ──→ broker/sync.ts
──→ lib/eventChangeDispatcher.ts
routes/lists.ts ──→ db (MariaDB)
──→ lib/listEmitter.ts (publishListEvent)
routes/sse.ts ──→ lib/listEmitter.ts (subscribeListEvents)
──→ lib/listAccess.ts
```
### Frontend data ownership
| Concern | Owner |
|---------|-------|
| Calendar events | TanStack Query `['events', start, end]` |
| List data + items | TanStack Query `['lists']`, `['list', listId]` |
| Current user | TanStack Query `['me']` |
| Writable calendars | TanStack Query `['writableCalendars']` |
| Outbox sync status | TanStack Query `['syncStatus', uid]` |
| Selected calendar view + date | Zustand `calendarStore` |
| Event form open/mode | Zustand `calendarStore` |
| Active tab, create-list sheet | Zustand `listsStore` |
---
## Infrastructure
| Component | Technology |
|-----------|------------|
| Runtime | Node.js 22 LTS |
| HTTP framework | Hono 4.x (`@hono/node-server`) |
| Database | MariaDB 11 (Docker volume) |
| ORM | Drizzle ORM 0.45.x (`mysql2` dialect) |
| Auth IdP | Authelia (pre-deployed, external) — OIDC authorization code + PKCE |
| Session middleware | `@hono/oidc-auth` — storage-less signed JWT cookies |
| Calendar source | Fastmail CalDAV (`caldav.fastmail.com`) — read via `tsdav`, write via transactional outbox |
| Calendar parsing | `ical.js` (VCALENDAR/VEVENT parse) + `rrule` (RRULE expansion) |
| App password storage | AES-256-GCM encrypted in `member_credentials.encrypted_password` |
| Push notifications | `web-push` (VAPID) → APNs (iOS) / FCM (Android) |
| Live list sync | In-process Node.js `EventEmitter` → SSE (`text/event-stream`) |
| Redis | Present in stack (image: `redis:7-alpine`); not used in current runtime (reserved for future multi-process pub/sub) |
| PWA | React 19 + Vite 8 + `vite-plugin-pwa` (Workbox `injectManifest` mode) |
| Networking | Pangolin/Newt tunnel — no open ports; split-DNS internal domain |
| Deployment | Docker Compose on Unraid; single `api` container serves both the API and the PWA static build |
+173
View File
@@ -0,0 +1,173 @@
<!-- generated-by: gsd-doc-writer -->
# FamilySync — Configuration Reference
All runtime configuration is supplied via environment variables. There are no JSON or YAML config files beyond Docker Compose. Copy `.env.example` to `.env` at the repo root and fill in the values before starting any service.
---
## Environment Variables
### Database
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `DB_HOST` | Yes | `localhost` | MariaDB hostname. Use `mariadb` inside Docker Compose; use `localhost` (or `127.0.0.1`) for host-side dev runs. |
| `DB_PORT` | No | `3306` | MariaDB port. |
| `DB_USER` | No | `familysync` | Database user. |
| `DB_PASSWORD` | **Required** | _(none)_ | Database password. Also used by the `mariadb` service as `MARIADB_PASSWORD`. |
| `DB_NAME` | No | `familysync` | Database name. |
| `DB_ROOT_PASSWORD` | **Required** | _(none)_ | MariaDB root password. Used only by the `mariadb` Docker service (`MARIADB_ROOT_PASSWORD`). Not read by the API process. |
Five of these variables — `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_PORT` — are read by `drizzle.config.ts` when running migrations (`db:generate` / `db:migrate`) and by the API process to build its connection pool. `DB_ROOT_PASSWORD` is **not** read by either; it is consumed only by the `mariadb` Docker service.
**Important:** Do not use `drizzle-kit push` against this MariaDB. The `mysql` dialect mis-reads MariaDB 11.x metadata and schedules false destructive operations. Always use `db:generate` + `db:migrate`.
---
### OIDC / Authelia Authentication
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `OIDC_AUTH_SECRET` | **Required** | `placeholder_change_me` (Docker default) | 32+ character random string used to sign the `oidc-auth` session JWT cookie. Generate with `openssl rand -base64 32`. |
| `OIDC_ISSUER` | **Required** | _(none)_ | Authelia base URL, e.g. `https://auth.DOMAIN`. The middleware fetches `/.well-known/openid-configuration` from this URL. |
| `OIDC_CLIENT_ID` | No | `familysync` | Registered client ID in Authelia. |
| `OIDC_CLIENT_SECRET` | **Required** | _(none)_ | Plaintext client secret matching the pbkdf2 hash stored in Authelia's `configuration.yml`. Do **not** use the hash here. |
| `OIDC_REDIRECT_URI` | **Required** | _(none)_ | Full callback URL registered in Authelia, e.g. `https://familysync.DOMAIN/callback`. |
| `OIDC_AUTH_EXTERNAL_URL` | **Required** | _(none)_ | Public-facing base URL of the app, e.g. `https://familysync.DOMAIN`. **Mandatory behind the Pangolin/Newt tunnel.** Without it, `@hono/oidc-auth` builds the redirect URI from the internal container hostname, which will not match the registered URI and breaks the OIDC flow. |
| `OIDC_SCOPES` | No | `openid profile email offline_access` | Space-separated list of OIDC scopes to request. `offline_access` is required for refresh-token session persistence. Authelia rejects unknown scopes, so do not add scopes that are not configured on the Authelia client. |
| `OIDC_AUTH_EXPIRES` | No | `86400` | Session cookie `Max-Age` in seconds (default 1 day). Governs the persistent session cookie set by `persistSessionCookie` middleware. |
| `OIDC_COOKIE_NAME` | No | `oidc-auth` | Name of the session cookie. Override only if you need to run multiple instances under the same domain. |
| `OIDC_COOKIE_PATH` | No | `/` | Cookie `Path` attribute. |
| `OIDC_COOKIE_DOMAIN` | No | _(not set)_ | Cookie `Domain` attribute. Set this when the API and PWA are served from different subdomains under the same apex domain. Must match the Authelia and app domains (same-parent-domain requirement). |
**Locked Authelia client parameters** (these are fixed by the project; do not change):
- `response_types: [code]`
- `grant_types: [authorization_code, refresh_token]`
- `require_pkce: true`, `pkce_challenge_method: S256`
- `token_endpoint_auth_method: client_secret_basic`
---
### Broker Encryption
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `APP_PASSWORD_ENCRYPTION_KEY` | **Required** | _(none)_ | 64-character hex string (32 bytes) used as the AES-256-GCM key for encrypting Fastmail app passwords at rest. Generate with: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`. Startup throws if this is missing or the wrong length. |
---
### Web Push (VAPID)
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `VAPID_PUBLIC_KEY` | No* | _(none)_ | URL-safe base64 VAPID public key. Served to the PWA via `GET /api/push/vapid-public-key`. Non-secret. |
| `VAPID_PRIVATE_KEY` | No* | _(none)_ | URL-safe base64 VAPID private key. Used server-side to sign push requests. **Never expose to clients.** |
| `VAPID_SUBJECT` | No* | _(none)_ | Contact URI identifying the operator, e.g. `mailto:admin@example.com` or `https://familysync.DOMAIN`. Must be a `mailto:` or `https:` URL. |
*All three VAPID variables are optional in the sense that the server starts without them, but push notifications will not function. A startup warning is logged if any are missing. Generate a key pair with:
```bash
npx web-push generate-vapid-keys --json
```
---
### Runtime Mode
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `NODE_ENV` | No | _(not set)_ | Set to `production` in the production Docker Compose. When `production`, the dev-auth bypass is unconditionally disabled regardless of `DEV_AUTH_BYPASS`. |
| `DEV_AUTH_BYPASS` | No | _(not set)_ | Set to `true` to bypass OIDC authentication for local development without a live Authelia instance. **Only active when `NODE_ENV !== 'production'`.** The production `docker-compose.yml` must never include this variable. |
---
## Defaults Summary
Variables with non-empty defaults do not cause startup failure if absent, but should be reviewed for production:
| Variable | Default | Source |
|----------|---------|--------|
| `DB_HOST` | `localhost` | `apps/api/src/db/client.ts` |
| `DB_PORT` | `3306` | `apps/api/src/db/client.ts` |
| `DB_USER` | `familysync` | `apps/api/src/db/client.ts` |
| `DB_NAME` | `familysync` | `apps/api/src/db/client.ts` |
| `OIDC_CLIENT_ID` | `familysync` | `docker-compose.yml` |
| `OIDC_SCOPES` | `openid profile email offline_access` | `docker-compose.yml` |
| `OIDC_AUTH_EXPIRES` | `86400` | `apps/api/src/auth/persistSessionCookie.ts` |
| `OIDC_COOKIE_NAME` | `oidc-auth` | `apps/api/src/auth/persistSessionCookie.ts` |
| `OIDC_COOKIE_PATH` | `/` | `apps/api/src/auth/persistSessionCookie.ts` |
---
## Per-Environment Configuration
### Production (Docker Compose)
`docker-compose.yml` sets `NODE_ENV=production` and injects all secrets via `${VAR}` interpolation from a `.env` file on the Docker host. No `.env` file is committed to the repository.
Minimum production `.env`:
```dotenv
# Database
DB_PASSWORD=<strong-password>
DB_ROOT_PASSWORD=<strong-root-password>
# OIDC
OIDC_AUTH_SECRET=<openssl rand -base64 32>
OIDC_ISSUER=https://auth.DOMAIN
OIDC_CLIENT_SECRET=<plaintext secret>
OIDC_REDIRECT_URI=https://familysync.DOMAIN/callback
OIDC_AUTH_EXTERNAL_URL=https://familysync.DOMAIN
# Broker encryption
APP_PASSWORD_ENCRYPTION_KEY=<64-hex-chars>
# VAPID push (optional but recommended)
VAPID_PUBLIC_KEY=<base64>
VAPID_PRIVATE_KEY=<base64>
VAPID_SUBJECT=mailto:admin@example.com
```
### Local Development (host-side)
The dev Docker Compose override (`docker-compose.dev.yml`) exposes MariaDB on `localhost:3306` and Redis on `localhost:6379`. To run the API and PWA directly on the host:
```bash
# Build the API first (dev script runs compiled output)
pnpm --filter @familysync/api build
# Source .env, then override DB_HOST and activate the bypass
set -a; source .env; set +a && DEV_AUTH_BYPASS=true DB_HOST=localhost pnpm --filter @familysync/api dev
```
```bash
# PWA (separate terminal)
pnpm --filter @familysync/pwa dev
```
The `DB_HOST` override is necessary because `.env` sets `DB_HOST=mariadb` (the Docker network hostname), which does not resolve on the host. The `set -a; source .env; set +a` idiom exports all variables; the `DB_HOST=localhost` prefix on the same line overrides that one variable for the child process.
**`DEV_AUTH_BYPASS=true` is a local-only option.** The API hard-checks `NODE_ENV === 'production'` before reading `DEV_AUTH_BYPASS` — the bypass has zero effect in a production container even if the variable is present.
### Test
Integration tests targeting the real database require the dev MariaDB running with the host port exposed and the following overrides:
```bash
DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=familysync DB_NAME=familysync DB_PASSWORD=<value>
```
See `docs/deployment.md` for the full `drizzle-kit migrate` command used to prepare the test database.
---
## Config File Reference
There are no application-level JSON/YAML config files. The two config files that read environment variables at dev/build time are:
| File | Purpose |
|------|---------|
| `apps/api/drizzle.config.ts` | Drizzle Kit migration config — reads `DB_*` variables |
| `apps/pwa/vite.config.ts` | Vite build config — no env var reads; proxy rules for dev server |
The PWA Vite dev server proxies `/health`, `/api`, and `/callback` to `http://localhost:3000` so the frontend and API can be developed without CORS configuration.
+216
View File
@@ -0,0 +1,216 @@
<!-- generated-by: gsd-doc-writer -->
# Development Guide
Local development setup and workflows for FamilySync — a pnpm monorepo with two workspaces: `apps/api` (Hono + Node.js) and `apps/pwa` (React + Vite).
## Repo Layout
```
familysync/
├── apps/
│ ├── api/ # Hono API server — Node.js 22, TypeScript, Drizzle/MariaDB
│ └── pwa/ # React 19 PWA — Vite, TanStack Query, Schedule-X
├── docker-compose.yml
├── docker-compose.dev.yml
├── package.json # Root workspace scripts
└── pnpm-workspace.yaml
```
Key paths inside `apps/api/src/`:
```
src/
├── db/
│ ├── schema.ts # Drizzle table definitions (source of truth for migrations)
│ ├── client.ts # mysql2 pool + drizzle instance
│ └── migrations/ # Generated SQL migration files
├── routes/ # Hono route files (events, lists, push, sse, me, health)
├── auth/ # OIDC middleware
├── broker/ # CalDAV broker (tsdav + ical.js)
└── lib/ # Shared utilities
```
## Prerequisites
- **Node.js 22 LTS** — the Dockerfile base is `node:22-alpine`; match this locally
- **pnpm 11.5.1** — managed via corepack (`corepack enable pnpm`)
- **Docker + Docker Compose** — for MariaDB and Redis in dev
- **TypeScript 5.x** — installed per-workspace as a dev dependency
## Local Setup
### 1. Install dependencies
```bash
pnpm install
```
This installs all workspace packages (`apps/api` and `apps/pwa`) in a single pass.
### 2. Start the dev database and Redis
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up mariadb redis -d
```
The dev override (`docker-compose.dev.yml`) exposes MariaDB on `localhost:3306` and Redis on `localhost:6379`.
### 3. Configure environment variables
Copy the root `.env.example` to `.env` and fill in the required values. The root `.env` is sourced by docker-compose for container env vars. When running the API server directly on the host (outside Docker), you must override `DB_HOST`:
```bash
# Source the root .env, then override DB_HOST for host-side execution
DB_HOST=127.0.0.1 node dist/index.js
```
Or set `DB_HOST=127.0.0.1` in your local `.env` for the dev workflow. The docker-compose production config sets `DB_HOST: mariadb` (the service name); that value does not resolve on the host.
For auth bypass during local UI development, set `DEV_AUTH_BYPASS=true` — this skips OIDC and authenticates as the dev user (id 1).
## Development Workflow
### API dev loop
The `dev` script runs the compiled output via `node --watch`. **A build must exist before starting the dev server** — the watcher restarts `dist/index.js` on file changes, but it does not recompile TypeScript. You must rebuild when source changes.
```bash
# From apps/api — or use the root shortcut
pnpm --filter @familysync/api build # compile src/ → dist/
pnpm --filter @familysync/api dev # node --watch dist/index.js
# Root shortcuts
pnpm dev:api # runs dev in apps/api (requires dist/ to already exist)
```
Recommended inner loop: run `pnpm --filter @familysync/api build` after each change, the `--watch` process restarts automatically.
### PWA dev server
```bash
pnpm --filter @familysync/pwa dev
# or from root:
pnpm dev:pwa
```
Vite serves the PWA with HMR on the configured dev port. The PWA's API calls target the backend; set `VITE_API_URL` (or the Vite proxy config) to point at the running API.
## Build Commands
### Root workspace scripts
| Command | Description |
|---------|-------------|
| `pnpm dev:api` | Start API dev watcher (`node --watch dist/index.js`) |
| `pnpm dev:pwa` | Start Vite dev server for the PWA |
| `pnpm build` | Build both `apps/api` (tsc) and `apps/pwa` (tsc + vite build) |
| `pnpm test` | Run API test suite (`vitest run` in `apps/api`) |
| `pnpm lint` | Run lint in all workspaces (`pnpm -r lint`) |
| `pnpm typecheck` | Run `tsc --noEmit` in all workspaces |
### `apps/api` scripts
| Command | Description |
|---------|-------------|
| `pnpm --filter @familysync/api build` | Compile TypeScript (`tsc`) → `dist/` |
| `pnpm --filter @familysync/api dev` | Start `node --watch dist/index.js` |
| `pnpm --filter @familysync/api start` | Start `node dist/index.js` (no watch) |
| `pnpm --filter @familysync/api test` | Run vitest once (`vitest run`) |
| `pnpm --filter @familysync/api test:watch` | Run vitest in watch mode |
| `pnpm --filter @familysync/api typecheck` | `tsc --noEmit` |
| `pnpm --filter @familysync/api db:generate` | Generate SQL migrations from schema changes |
| `pnpm --filter @familysync/api db:migrate` | Apply pending migrations to the database |
### `apps/pwa` scripts
| Command | Description |
|---------|-------------|
| `pnpm --filter @familysync/pwa dev` | Start Vite dev server with HMR |
| `pnpm --filter @familysync/pwa build` | `tsc && vite build``dist/` |
| `pnpm --filter @familysync/pwa preview` | Serve the production build locally |
| `pnpm --filter @familysync/pwa typecheck` | `tsc --noEmit` |
| `pnpm --filter @familysync/pwa test` | Run vitest once |
## Drizzle Migration Workflow
Schema changes follow a strict two-step process. **`drizzle-kit push` is not available** — it has been removed from the scripts because it emits a false destructive diff (table truncation) on populated MariaDB databases.
### Step 1 — Generate the migration
After editing `apps/api/src/db/schema.ts`:
```bash
pnpm --filter @familysync/api db:generate
```
This runs `drizzle-kit generate` and writes a new `.sql` file to `apps/api/src/db/migrations/`. Review the generated SQL before proceeding — confirm it matches the intended schema change with no unexpected `DROP` or truncation statements.
### Step 2 — Apply the migration
```bash
pnpm --filter @familysync/api db:migrate
```
This runs `drizzle-kit migrate` and applies any pending migration files to the target database. Drizzle reads `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, and optionally `DB_PORT` (default `3306`) from the environment, as defined in `apps/api/drizzle.config.ts`.
When running migrations from the host against the Docker database:
```bash
DB_HOST=127.0.0.1 pnpm --filter @familysync/api db:migrate
```
Migration files live in `apps/api/src/db/migrations/` and are committed to version control.
## TypeScript Strict Checks
Both workspaces use `"strict": true` in their `tsconfig.json`. The build step (`tsc`) catches type errors in `apps/api` (since it emits output). For `apps/pwa`, Vite uses esbuild to transpile and does not perform type checking — **vitest will pass even when there are type errors in the PWA**. Always run the typecheck script explicitly:
```bash
# Check both workspaces
pnpm typecheck
# Or individually
pnpm --filter @familysync/api typecheck
pnpm --filter @familysync/pwa typecheck
```
Run `pnpm typecheck` before opening a PR to catch errors that vitest and Vite builds will silently miss.
## Code Style
ESLint and Prettier are listed as the intended linting and formatting tools. Check for config files in each workspace and confirm the `lint` script is wired before running `pnpm lint`. <!-- VERIFY: ESLint and Prettier configs are present in apps/api and apps/pwa -->
## Docker Compose Dev Stack
```bash
# Bring up the full dev stack (API in Docker + MariaDB + Redis, with ports exposed)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# Bring up only backing services (run API on host for faster iteration)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up mariadb redis -d
```
The dev override:
- Exposes MariaDB on `localhost:3306`
- Exposes Redis on `localhost:6379`
- Mounts `apps/api/src` into the container for live source access
- Sets `NODE_ENV=development`
The production `docker-compose.yml` builds the API and PWA into a single image (`production` target in `apps/api/Dockerfile`). The PWA `dist/` is copied into the API image's `./public` directory and served on port `3000`.
## Common Issues
**`dev` script fails with "Cannot find module"**
The `dev` script runs `node --watch dist/index.js`. If `dist/` does not exist or is stale, run `pnpm --filter @familysync/api build` first.
**DB connection refused when running API on host**
`DB_HOST` defaults to `mariadb` (the Docker service name). When running the API outside Docker, override it: `DB_HOST=127.0.0.1`. The dev compose override exposes port 3306 on the host.
**drizzle-kit migrate says "cannot connect"**
Same `DB_HOST` issue. Prepend `DB_HOST=127.0.0.1` to the migrate command when running from the host.
**API integration tests fail with FK errors**
The vitest config sets `fileParallelism: false` to prevent concurrent test files from conflicting via the shared MariaDB. Ensure you are not overriding this. Tests require a running MariaDB — set `DB_HOST=127.0.0.1` and ensure the dev database is up.
**TypeScript errors missed during development**
Vite/esbuild strips types; type errors will not surface in `vitest run` or `vite build` output. Run `pnpm typecheck` explicitly to catch them.
+134
View File
@@ -0,0 +1,134 @@
<!-- generated-by: gsd-doc-writer -->
# FamilySync — Getting Started
This guide walks from a fresh clone to a running local development environment.
---
## Prerequisites
| Requirement | Version | Notes |
|-------------|---------|-------|
| Node.js | `22 LTS` | Matches the `node:22-alpine` base in `apps/api/Dockerfile` |
| pnpm | `11.5.1` | Pinned in `package.json` `packageManager` field; enable via `corepack enable pnpm` |
| Docker + Docker Compose | Any recent version | Used to run MariaDB and Redis locally |
**Node version management:** If you use nvm or fnm, install Node 22 LTS and set it as the default before continuing. There is no `.nvmrc` in the repo; the target version comes from the Dockerfile.
---
## Installation
### 1. Clone the repository
```bash
git clone <repository-url>
cd familysync
```
### 2. Enable pnpm via corepack
```bash
corepack enable pnpm
```
### 3. Install dependencies
```bash
pnpm install
```
### 4. Copy the environment file
```bash
cp .env.example .env
```
Open `.env` and fill in the required values. See [docs/CONFIGURATION.md](CONFIGURATION.md) for the full variable reference. At minimum for local development you need:
- `DB_PASSWORD` and `DB_ROOT_PASSWORD` — pick any local passwords
- `APP_PASSWORD_ENCRYPTION_KEY` — 64 hex characters; generate with:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
- `DEV_AUTH_BYPASS=true` — bypasses the live Authelia OIDC flow for local dev
- `DB_HOST=localhost` — the dev Docker Compose exposes MariaDB on the host at `localhost:3306`
---
## First Run
### 5. Start the database services
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mariadb redis
```
This starts MariaDB (bound to `localhost:3306`) and Redis (`localhost:6379`) using the dev override. Wait for MariaDB to pass its health check before proceeding.
### 6. Run database migrations
```bash
set -a; source .env; set +a
pnpm --filter @familysync/api db:migrate
```
This runs `drizzle-kit migrate` against your local MariaDB using the credentials from `.env`. Do **not** use `drizzle-kit push` — see [CONFIGURATION.md](CONFIGURATION.md) for why.
### 7. Build and start the API
The API dev script (`node --watch dist/index.js`) runs compiled output, so the project must be built before the first start and after any TypeScript changes:
```bash
# Terminal 1 — build once, then start in watch mode
set -a; source .env; set +a
pnpm --filter @familysync/api build
DEV_AUTH_BYPASS=true DB_HOST=localhost pnpm --filter @familysync/api dev
```
The API listens on `http://localhost:3000`.
### 8. Start the PWA dev server
```bash
# Terminal 2
pnpm --filter @familysync/pwa dev
```
The Vite dev server (default port `5173`) proxies `/health`, `/api`, and `/callback` to `http://localhost:3000`, so you do not need CORS configuration.
Open `http://localhost:5173` in your browser. With `DEV_AUTH_BYPASS=true` the login step is skipped and you are signed in as the dev user.
---
## Common Setup Issues
**`pnpm: command not found` after `corepack enable pnpm`**
Corepack installs pnpm into a path that may not be on your `PATH` in the current shell. Run `hash -r` or open a new terminal.
**`Access denied for user 'familysync'@'localhost'` on migration**
MariaDB may still be initialising. Wait a few seconds and retry. If the error persists, verify `DB_PASSWORD` in `.env` matches `MARIADB_PASSWORD` in `docker-compose.yml` (both use `${DB_PASSWORD}`).
**`Cannot connect to DB_HOST=mariadb`**
The API is running on the host but `.env` still has `DB_HOST=mariadb` (the Docker network hostname). Override it inline:
```bash
DB_HOST=localhost pnpm --filter @familysync/api dev
```
Or set `DB_HOST=localhost` directly in your `.env` for host-side dev.
**API starts but all requests return 401 / redirect to Authelia**
`DEV_AUTH_BYPASS` is not set or is not being exported to the process. Make sure you source `.env` with `set -a; source .env; set +a` or prefix the command with `DEV_AUTH_BYPASS=true`. The bypass only works when `NODE_ENV` is not `production`.
**PWA shows a blank screen after first load**
Run the API build step first (`pnpm --filter @familysync/api build`). The dev script runs `dist/index.js`; if `dist/` is missing or stale, the API process exits immediately.
**Port 3306 already in use**
Another local MySQL/MariaDB service is running. Stop it before starting Docker Compose, or change the host-side port in `docker-compose.dev.yml`.
---
## Next Steps
- [docs/ARCHITECTURE.md](ARCHITECTURE.md) — System design, component diagram, data flow
- [docs/CONFIGURATION.md](CONFIGURATION.md) — All environment variables, defaults, and per-environment guidance
- [docs/deployment.md](deployment.md) — Production deployment on Unraid via Docker Compose
+122
View File
@@ -0,0 +1,122 @@
<!-- generated-by: gsd-doc-writer -->
# Testing
## Test framework and setup
Both apps use **Vitest** (`^4.1.8`).
| App | Environment | Setup file |
|-----|-------------|------------|
| `apps/api` | `node` | `apps/api/test/setup.ts` |
| `apps/pwa` | `jsdom` | `apps/pwa/src/test-setup.ts` |
**apps/api setup** (`test/setup.ts`) registers a global `afterEach` that truncates `list_items`, `list_shares`, `push_subscriptions`, and `lists` in FK-safe order after every test. This keeps DB-backed integration tests isolated without requiring a full DB reset between runs. Parallel file execution is disabled (`fileParallelism: false`) to prevent FK violations when multiple test files share the same MariaDB.
**apps/pwa setup** (`src/test-setup.ts`) imports `@testing-library/jest-dom` for extended matchers and polyfills `window.matchMedia` for jsdom (required because Zustand's `calendarStore` calls `window.matchMedia` at module initialisation time). The timezone is pinned to `UTC` via `env: { TZ: 'UTC' }` so date-extraction assertions are deterministic across developer machines and CI.
No additional install step is needed beyond the normal `pnpm install` at the repo root.
## Running tests
**All API tests (from repo root):**
```bash
pnpm --filter @familysync/api test
```
This is also the command run by `pnpm test` at the root.
**All PWA tests:**
```bash
pnpm --filter @familysync/pwa test
```
**Watch mode (API):**
```bash
pnpm --filter @familysync/api test:watch
```
**Single test file:**
```bash
pnpm --filter @familysync/api exec vitest run tests/routes/lists.test.ts
```
**Type checking (separate from tests — required):**
Vitest uses esbuild, which strips TypeScript types at runtime. A test run can pass while `tsc` reports errors. Always run type checks separately:
```bash
pnpm typecheck # runs tsc --noEmit across both apps
pnpm --filter @familysync/api typecheck
pnpm --filter @familysync/pwa typecheck
```
## Integration tests requiring a real database
Several API tests in `apps/api/tests/lib/` and `apps/api/tests/routes/` connect to the real dev MariaDB rather than mocking the DB layer. These tests require the dev Docker stack to be running with port 3306 exposed.
**Start the dev stack:**
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mariadb
```
**Set environment variables, then run:**
```bash
set -a; . ./.env; set +a
export DB_HOST=127.0.0.1 DB_PORT=3306
pnpm --filter @familysync/api test
```
DB-backed tests that require this setup include:
- `apps/api/tests/lib/listAccess.test.ts``getAccessibleListIds` access-scope queries
- `apps/api/tests/lib/listChangeDispatcher.test.ts` — list change dispatcher with real DB rows
- `apps/api/tests/routes/lists.test.ts` — full lists API router (creates/deletes real rows)
Pure-logic tests (e.g. `apps/api/tests/broker/expand.test.ts`, `apps/api/tests/lib/rank.test.ts`) do not require DB — the `afterEach` cleanup is a no-op when tables are empty or no DB connection is available.
## Writing new tests
### File naming and location
| App | Convention | Example |
|-----|------------|---------|
| `apps/api` | `apps/api/tests/{category}/*.test.ts` | `apps/api/tests/routes/push.test.ts` |
| `apps/pwa` | co-located `*.test.ts` / `*.test.tsx` | `src/components/AppNav.test.tsx` |
Test categories for `apps/api`:
- `apps/api/tests/auth/` — authentication middleware and session handling
- `apps/api/tests/broker/` — CalDAV sync, outbox worker, event expansion, push dispatch
- `apps/api/tests/lib/` — pure library functions and service logic
- `apps/api/tests/routes/` — HTTP route integration tests
- `apps/api/tests/health.test.ts` — health check endpoint
- `apps/api/tests/fixtures/` — shared `.ics` fixture files and DB fixture helpers
### Test helpers
- `apps/api/tests/helpers/db.ts``createMockDb()` returns a Vitest mock of the Drizzle `db` singleton; also exports sample VEVENT strings (`SAMPLE_VEVENT_TIMED`, `SAMPLE_VEVENT_ALLDAY`, `SAMPLE_VEVENT_RECURRING_TIMED`, `SAMPLE_VEVENT_RECURRING_ALLDAY`) for broker tests.
- `apps/api/tests/fixtures/*.ics` — Raw iCalendar fixture files for broker parsing tests (`allday-birthday.ics`, `exdate-series.ics`, `single-duration.ics`, `weekly-count3.ics`, `weekly-dst.ics`).
- `apps/api/tests/fixtures/vapid.ts` — VAPID key fixture for push tests.
- `apps/pwa/src/test-setup.ts` — Provides `matchMedia` polyfill and jest-dom matchers for all PWA tests automatically via `setupFiles`.
For PWA component tests, use `@testing-library/react` (`^16.3.0`) render helpers. Import from `vitest` for assertions — `@testing-library/jest-dom` matchers are available globally via the setup file.
## Coverage requirements
No coverage thresholds are configured in either `vitest.config.ts`. There is no minimum coverage enforcement in CI.
## CI integration
No CI pipeline is configured in this repository. Tests are run manually by developers before opening pull requests against the self-hosted Gitea remote.
The recommended pre-PR gate is:
```bash
pnpm test && pnpm typecheck
```
+172 -279
View File
@@ -1,342 +1,235 @@
# FamilySync — Deployment & Live-Verification Runbook <!-- generated-by: gsd-doc-writer -->
# FamilySync — Deployment Guide
This document is the operator runbook for getting FamilySync running behind Authelia (OIDC) Self-hosted Docker deployment on Unraid behind Authelia OIDC and a Pangolin/Newt outbound tunnel. The API serves the compiled React PWA as static files on a single port (3000), so only one route needs to be exposed through the tunnel.
and Pangolin/Newt (public tunnel), and for executing the **Phase 1 Gate 2** live-verification
items (`01-HUMAN-UAT.md`). It covers two deployment modes:
- **Mode A — Local test rig** (recommended for Gate 2): familysync + a Newt connector run on
your dev box, routed through your existing Pangolin under a *test* subdomain. Validates the
real external topology (HTTPS, Pangolin SSE pass-through, Authelia OIDC) **without** deploying
to Unraid and without touching the production stack.
- **Mode B — Unraid production**: the real household deployment. Identical app + config; only
the host and the Newt site differ.
> The behaviours Gate 2 is checking — Authelia OIDC redirect/session, and SSE survival through
> the tunnel — live in **Authelia** and **Pangolin/Newt**, not in *where* the origin container
> runs. So Mode A is a faithful test of both. Reserve Unraid (Mode B) for go-live.
--- ---
## Topology ## Deployment Targets
```mermaid | Target | Config file |
flowchart LR |--------|-------------|
subgraph Public | Docker Compose (production) | `docker-compose.yml` |
User[Browser / iOS PWA] | Docker Compose (dev override) | `docker-compose.dev.yml` |
Pangolin[Pangolin edge<br/>public HTTPS + WAF] | Container image | `apps/api/Dockerfile` (multi-stage, built from repo root) |
end
subgraph Private[Private network - no inbound ports]
Newt[Newt connector<br/>outbound tunnel]
API[familysync api<br/>Hono :3000]
DB[(MariaDB)]
Redis[(Redis - Phase 4)]
end
Authelia[Authelia OIDC<br/>auth.DOMAIN]
User -->|https://familysync.DOMAIN| Pangolin The production compose file brings up three services:
Pangolin -->|tunnel| Newt
Newt --> API
API --> DB
API -.Phase 4.-> Redis
User -->|OIDC redirect| Authelia
API -->|token exchange / userinfo| Authelia
```
Key property: **Newt dials outbound to Pangolin** — there are no open inbound ports on the | Service | Image | Purpose |
private network (honours the project networking constraint). This is true for both modes. |---------|-------|---------|
| `api` | Built from `apps/api/Dockerfile` target `production` | Hono API + compiled React PWA, listens on port 3000 |
| `mariadb` | `mariadb:11` | Persistent MariaDB database |
| `redis` | `redis:7-alpine` | Present for live list sync (pub/sub); unused until Phase 4 |
--- ---
## Prerequisites (both modes) ## Prerequisites
- A Pangolin instance you control, with a wildcard or per-host cert for `*.DOMAIN`. - Docker and Docker Compose available on the Unraid host.
- Authelia already deployed and reachable at `https://auth.DOMAIN` (project constraint). - Authelia already deployed with a FamilySync OIDC client registered (see [Register the OIDC Client](#register-the-oidc-client)).
- The familysync image builds: `docker compose build` (see repo `docker-compose.yml`). - Pangolin/Newt tunnel configured to route an external HTTPS hostname to the Docker host on port 3000 (see [Pangolin / Newt Tunnel](#pangolin--newt-tunnel)).
- A Fastmail app password per member (scope "Mail, Contacts & Calendars") — see - A `.env` file at the repo root with all required secrets (see [Environment Setup](#environment-setup)).
`CAL-08-DECISION.md`. **Never commit it; it lives in a gitignored `.env`/`.env.spike`.**
### ⚠️ Same-parent-domain requirement (Pitfall 1)
FamilySync **must** be served under the same parent domain as Authelia so the session cookie is
same-site. e.g. Authelia at `auth.DOMAIN` and the app at `familysync.DOMAIN` (Mode B) or
`familysync-dev.DOMAIN` (Mode A). A different apex domain will break the OIDC session cookie.
--- ---
## Step 1 — Register the OIDC client in Authelia ## Register the OIDC Client
Authelia client registration is **additive** — adding a new `client_id` does not affect existing Add the following client block to your Authelia `configuration.yml` under `identity_providers.oidc.clients`:
clients, and is trivially reversible. For Mode A use a distinct id + redirect so it never collides
with the eventual production client.
Generate a hashed client secret:
```bash
authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72
# Record BOTH the plaintext (for the app's OIDC_CLIENT_SECRET) and the hash (for Authelia).
```
Add to Authelia `configuration.yml` under `identity_providers.oidc.clients`:
```yaml ```yaml
identity_providers: - client_id: familysync
oidc: client_name: FamilySync
clients: client_secret: '<pbkdf2-hash-of-your-plaintext-secret>'
- client_id: 'familysync' # Mode A: 'familysync-dev' public: false
client_name: 'FamilySync' authorization_policy: one_factor
client_secret: '$pbkdf2-sha512$...' # the HASH from the command above redirect_uris:
public: false - https://familysync.DOMAIN/callback # replace DOMAIN with your actual domain
authorization_policy: 'one_factor' scopes:
redirect_uris: - openid
- 'https://familysync.DOMAIN/callback' # Mode A: https://familysync-dev.DOMAIN/callback - profile
scopes: [openid, profile, email] - email
response_types: [code] - offline_access
grant_types: [authorization_code, refresh_token] response_types:
token_endpoint_auth_method: client_secret_basic - code
require_pkce: true grant_types:
pkce_challenge_method: S256 - authorization_code
- refresh_token
require_pkce: true
pkce_challenge_method: S256
token_endpoint_auth_method: client_secret_basic
userinfo_signed_response_alg: none
``` ```
Reload Authelia (`docker restart authelia` or its reload mechanism). These match the locked These parameters are fixed — do not change `response_types`, `grant_types`, `require_pkce`, `pkce_challenge_method`, or `token_endpoint_auth_method`.
auth params in `CLAUDE.md` (code flow + PKCE S256 + client_secret_basic).
To generate the pbkdf2 hash from your chosen plaintext secret:
```bash
# Authelia CLI — run on the host where Authelia is installed
authelia crypto hash generate pbkdf2 --variant sha512
```
<!-- VERIFY: Authelia CLI command syntax may vary by version; verify against your installed Authelia release -->
Store the **plaintext** secret in `.env` as `OIDC_CLIENT_SECRET`. Never use the hash in `.env`.
--- ---
## Step 2 — App environment (`.env`) ## Environment Setup
Copy `.env.example` `.env` and fill in. Generate secrets as noted: Copy `.env.example` to `.env` at the repo root and fill in every value. The file is gitignored and must never be committed.
```bash Minimum production `.env`:
# Session cookie signing secret for @hono/oidc-auth
OIDC_AUTH_SECRET=$(openssl rand -base64 32)
# Broker app-password encryption key (32 bytes hex)
APP_PASSWORD_ENCRYPTION_KEY=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")
```
```dotenv ```dotenv
# Database # Database
DB_HOST=mariadb DB_PASSWORD=<strong-password>
DB_PORT=3306 DB_ROOT_PASSWORD=<strong-root-password>
DB_USER=familysync
DB_PASSWORD=<strong>
DB_NAME=familysync
DB_ROOT_PASSWORD=<strong>
# OIDC (Authelia) # OIDC
OIDC_AUTH_SECRET=<openssl rand -base64 32> OIDC_AUTH_SECRET=<run: openssl rand -base64 32>
OIDC_ISSUER=https://auth.DOMAIN OIDC_ISSUER=https://auth.DOMAIN
OIDC_CLIENT_ID=familysync # or familysync-dev (Mode A) OIDC_CLIENT_SECRET=<plaintext-secret-matching-authelia-hash>
OIDC_CLIENT_SECRET=<plaintext secret matching the Authelia hash>
OIDC_REDIRECT_URI=https://familysync.DOMAIN/callback OIDC_REDIRECT_URI=https://familysync.DOMAIN/callback
# MANDATORY behind a tunnel — without it @hono/oidc-auth builds redirect_uri from the
# internal container hostname, which will not match the registered URI.
OIDC_AUTH_EXTERNAL_URL=https://familysync.DOMAIN OIDC_AUTH_EXTERNAL_URL=https://familysync.DOMAIN
# Broker # Broker (Fastmail app-password encryption)
APP_PASSWORD_ENCRYPTION_KEY=<64-hex> APP_PASSWORD_ENCRYPTION_KEY=<run: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))">
# Web Push (VAPID) — optional but required for push notifications
VAPID_PUBLIC_KEY=<base64-public-key>
VAPID_PRIVATE_KEY=<base64-private-key>
VAPID_SUBJECT=mailto:admin@example.com
``` ```
`OIDC_CLIENT_ID` defaults to `familysync` and does not need to be set unless you registered a different ID in Authelia.
`OIDC_SCOPES` defaults to `openid profile email offline_access`. Do not add scopes that are not configured on the Authelia client.
**`DEV_AUTH_BYPASS` must NOT appear in the production `.env` or `docker-compose.yml`.** The API enforces this in code: when `NODE_ENV=production` the bypass is unconditionally disabled regardless of other variables, but omitting it entirely is the correct posture.
Generate VAPID keys:
```bash
npx web-push generate-vapid-keys --json
```
See [docs/CONFIGURATION.md](CONFIGURATION.md) for the full variable reference including optional variables and defaults.
--- ---
## Step 3 — Apply the database schema ## Apply Database Migrations
The image does not auto-migrate. Bring up MariaDB and apply the committed migrations once: The production image does **not** auto-migrate on startup. Migrations must be applied manually before the first container start and again after any schema change.
> **WARNING — do NOT use the `push` subcommand of drizzle-kit on this MariaDB.** Migrations are run with `drizzle-kit`, which is a **devDependency**. The production image is built with `pnpm install --prod` (see `apps/api/Dockerfile`), so `drizzle-kit` is **not** present inside the running `api` container — you cannot migrate by exec-ing into it. Instead, run migrations from a host that has the full (dev) dependencies and can reach MariaDB.
> The `mysql` dialect misreads MariaDB 11.x metadata and schedules a false truncate/recreate
> that **wipes data**. The `push` workflow has been removed from the project scripts for this The production `docker-compose.yml` does not expose the MariaDB port externally, so bring the database up with the dev compose override (which binds port 3306), apply the migrations from the host, then start the rest of the stack:
> reason. Always use the committed-migration path: `db:generate` to author a new migration
> (diffs `schema.ts` against committed JSON snapshots, never the live DB), `db:migrate` to apply it.
```bash ```bash
docker compose up -d mariadb # 1. Bring up only MariaDB with the host port exposed
# from the repo root, host-side (dev override exposes 3306):
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mariadb docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mariadb
# 2. Wait for it to be healthy
docker compose ps
# 3. Apply migrations from the host (requires dev deps installed: `pnpm install`)
DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=familysync DB_NAME=familysync DB_PASSWORD=<value> \ DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=familysync DB_NAME=familysync DB_PASSWORD=<value> \
pnpm --filter @familysync/api exec drizzle-kit migrate pnpm --filter @familysync/api db:migrate
# verify: SHOW TABLES; -> users, member_credentials, calendars, calendar_events
# 4. Start the full stack
docker compose up -d
``` ```
To author a future schema change: edit `apps/api/src/db/schema.ts`, run **Never use `drizzle-kit push` against this MariaDB.** The `mysql` dialect mis-reads MariaDB 11.x schema metadata and schedules false destructive operations (table truncation). Always use `db:generate` + `db:migrate`.
`pnpm --filter @familysync/api run db:generate` (diffs schema against committed snapshots — no DB
connection needed), commit the generated SQL, then apply with `db:migrate`.
--- ---
## Step 4 — Pangolin route + Newt connector ## Pangolin / Newt Tunnel
In Pangolin, create a **resource/route** for the hostname: The app is exposed to the public internet via an outbound Pangolin/Newt tunnel — no inbound ports are opened on the Unraid host.
- Host: `familysync.DOMAIN` (Mode A: `familysync-dev.DOMAIN`) <!-- VERIFY: Pangolin/Newt route configuration steps depend on your Pangolin dashboard and site-specific settings -->
- Upstream: the Newt connector → `http://<api-host>:3000`
- Auth: leave Pangolin's own auth **off** for this route — FamilySync does its own Authelia OIDC
at the app layer. (Do not double-gate.)
### ⚠️ SSE idle timeout (Phase 4 dependency, issue #1034) Configure the Pangolin route to forward HTTPS traffic for `https://familysync.DOMAIN` to `http://<docker-host-ip>:3000`. The `api` service in `docker-compose.yml` publishes port 3000 on the host:
FamilySync uses Server-Sent Events for live list sync (Phase 4). Long-lived SSE streams can be ```yaml
killed by a proxy idle timeout. In the Pangolin route config, ensure response buffering is ports:
**off** and the idle/read timeout is **>= 120s** (ideally higher). The Gate 2 SSE smoke test - "3000:3000"
below is what confirms this end to end — **it must pass before Phase 4 is built.** ```
### Newt connector Ensure `OIDC_AUTH_EXTERNAL_URL` and `OIDC_REDIRECT_URI` in `.env` match the public hostname Pangolin exposes. Without `OIDC_AUTH_EXTERNAL_URL`, the OIDC middleware builds the callback URI from the internal container hostname, which will not match the URI registered in Authelia and will break the login flow.
- **Mode A (local):** run Newt on your dev box pointing at your Pangolin site token. It dials out; ---
no local ports are exposed. `familysync` (api) listens on `:3000` reachable by Newt.
- **Mode B (Unraid):** run the Newt container in the same Unraid stack (see Step 6). ## Build and Start
```bash ```bash
# Newt connector (example — use the site token Pangolin issues for this site) # From the repo root — builds both the API and the React PWA into one image
docker run -d --name newt --restart unless-stopped \ docker compose build
-e PANGOLIN_ENDPOINT=https://pangolin.DOMAIN \
-e NEWT_ID=<site-id> -e NEWT_SECRET=<site-secret> \ # Start all services
fosrl/newt:latest docker compose up -d
```
The Dockerfile uses a multi-stage build:
1. `builder` — compiles the TypeScript API (`pnpm --filter @familysync/api build`).
2. `pwa-builder` — builds the React PWA with Vite (`pnpm --filter @familysync/pwa build`).
3. `production` — installs production-only dependencies, copies the compiled API and the built PWA into `./public`. The API serves the PWA at `/` via `serveStatic`.
Both `builder` and `pwa-builder` stages run in parallel under BuildKit.
Rebuild after any source change:
```bash
docker compose build api && docker compose up -d api
```
The `api` service waits for the `mariadb` healthcheck to pass before starting (`depends_on: condition: service_healthy`).
---
## Health Check
The `/health` endpoint is unauthenticated and confirms a live database connection:
```bash
curl https://familysync.DOMAIN/health
# {"ok":true,"db":"up"}
```
A `503` response (`{"ok":false,"db":"down"}`) means the API cannot reach MariaDB. Check `docker compose logs api` and `docker compose logs mariadb`.
---
## Rollback
There is no automated rollback pipeline. To revert to a previous build:
1. Identify the prior working Git commit.
2. Stop the API: `docker compose stop api`.
3. Rebuild from the target commit: `git checkout <commit> && docker compose build api`.
4. Start: `docker compose up -d api`.
5. If the rollback crosses a schema migration boundary, restore the MariaDB volume from a backup — schema downgrades are not supported by Drizzle Kit's migrate command.
Take a MariaDB dump before every deployment that includes a migration:
```bash
docker compose exec mariadb mariadb-dump -u root -p familysync > backup-$(date +%Y%m%d).sql
``` ```
--- ---
## Step 5 — Bring up the app ## Monitoring
No monitoring agent is configured in the repository. The `/health` endpoint is available for uptime monitoring tools.
Application logs are written to stdout/stderr and captured by Docker:
```bash ```bash
docker compose up -d --build docker compose logs -f api
curl -s http://localhost:3000/health # local sanity: {"ok":true,"db":"up"} docker compose logs -f mariadb
``` ```
Then proceed to Gate 2 verification through the public URL. <!-- VERIFY: No Sentry, Datadog, or OpenTelemetry dependency is present in package.json — confirm whether any external monitoring is wired at the infrastructure level -->
---
## Step 6 — Unraid production (Mode B only)
1. Copy the repo (or just `docker-compose.yml`, `apps/api/Dockerfile`, built image) to Unraid.
2. Create the `.env` on the Unraid host (do **not** commit it; store via Unraid's secrets/template).
3. Add the `newt` service to the production compose (or run as a separate Unraid container) bound
to the production Pangolin site.
4. Use a named Docker volume for `mariadb_data` on the array (not a throwaway volume).
5. `docker compose up -d --build`, then `drizzle-kit migrate` once (Step 3) against the prod DB.
6. Register the **production** Authelia client (`client_id: familysync`, prod redirect URI) if you
used `familysync-dev` for Mode A.
Differences from Mode A are limited to: host, Newt site token, volume location, and the OIDC
client id/redirect. The app code and `docker-compose.yml` are identical.
---
## Gate 2 — Live verification checklist (`01-HUMAN-UAT.md`)
Run from an **external** network (phone on cellular is ideal for a true external path).
| # | Item | Pass condition |
|---|------|----------------|
| 1 | **AUTH-01** login | `https://familysync.DOMAIN` → redirects to Authelia → after login, shell shows name + color + one cached event |
| 2 | **AUTH-02** session | Fully close + reopen browser → no re-login |
| 3 | **AUTH-03** colors | Wife logs in on iPhone → distinct, stable color |
| 4 | **iOS PWA** (pairs with Phase 3) | Add-to-Home-Screen, launch standalone, login completes in standalone mode (watch for redirect breaking out of standalone) |
| 5 | **SSE smoke** (gate before Phase 4) | Hold the stream open 5+ min without it being cut: |
```bash
# get the session cookie from the browser after logging in (DevTools → Application → Cookies)
curl -N -H "Cookie: oidc-auth=<value>" https://familysync.DOMAIN/api/sse/heartbeat
# expect a `heartbeat` event ~every 10s for 5+ minutes
```
- **SSE PASS** → SSE transport confirmed for Phase 4.
- **SSE FAIL** (stream cut early) → adjust Pangolin idle-timeout/buffering; if still failing, record
as a Phase 4 constraint and plan a reconnect/fallback strategy.
Record results in `.planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md`.
---
## Dev-auth bypass (Phase 2+ local development)
FamilySync builds Phase 2 and Phase 3 features behind a dev-auth bypass so live Authelia is not
required during development (D-14). The bypass injects a fixed dev user into the request context
and short-circuits the OIDC guard.
**Activation (local dev only):**
```bash
# In your local .env:
DEV_AUTH_BYPASS=true
NODE_ENV=development # or test, or any value other than 'production'
```
**Hard production guard:**
The bypass middleware's FIRST conditional is `process.env.NODE_ENV === 'production'`. If this is
true, the middleware returns a no-op passthrough regardless of any other env var. This means:
- Even if `DEV_AUTH_BYPASS=true` is accidentally present in a production container, it has zero
effect. The OIDC guard fires normally.
- The hard guard is checked before `DEV_AUTH_BYPASS` is read — there is no code path where
production + bypass = unauthenticated access.
**Production Docker Compose prohibition:**
The production `docker-compose.yml` MUST NOT include `DEV_AUTH_BYPASS` in the environment block.
The `.env.example` entry for `DEV_AUTH_BYPASS` is commented out by default as a reminder.
**What the bypass does:**
Sets `c.set('user', DEV_USER)` in the Hono context before `oidcAuthMiddleware` runs. Routes that
read `c.get('user')` receive a fixed dev user `{ id: 1, displayName: 'Dev User', color: '#4A90D9' }`.
Routes that call `getAuth(c)` from `@hono/oidc-auth` will still return null (no OIDC cookie is
present) — those routes must be updated to prefer `c.get('user')` when building Phase 2+.
### Running locally (host-side, no Docker)
Use this when you want to run the API and PWA directly on the host (no `docker compose up` for the
app containers), e.g. during Phase 2+ feature development with the dev-auth bypass active.
**Prerequisite: dev MariaDB must be running with the host port exposed.**
Use the dev compose override from Step 3 — it binds MariaDB to `localhost:3306`:
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mariadb
```
**Why a plain `pnpm --filter @familysync/api dev` is not enough:**
The API `dev` script is `node --watch dist/index.js`. It does **not** auto-load `.env` — there is
no `dotenv` call and no `--env-file` flag in the script. Without any env vars, `db/client.ts`
defaults `DB_HOST` to `'localhost'`, which works for a host-side run. However, once you source the
root `.env` to pick up OIDC secrets and other variables, the problem surfaces: root `.env` sets
`DB_HOST=mariadb` (the Docker service name, only resolvable inside the Docker network). On the
host, `mariadb` does not resolve, so the DB connection fails.
The fix is to source `.env` for all the other variables and then immediately override `DB_HOST` back
to `localhost`.
**Run the API and PWA:**
Open two terminals from the repo root.
Terminal 1 — API:
```bash
# Build first (the dev script runs the compiled output, not ts-node)
pnpm --filter @familysync/api build
# Source root .env, then override DB_HOST and activate the bypass
set -a; source .env; set +a && DEV_AUTH_BYPASS=true DB_HOST=localhost pnpm --filter @familysync/api dev
```
Terminal 2 — PWA:
```bash
pnpm --filter @familysync/pwa dev
```
The `set -a; source .env; set +a` idiom exports every variable from the root `.env` into the shell
environment. The `DEV_AUTH_BYPASS=true DB_HOST=localhost` prefix on the same command line then
overrides those two specific vars for the `pnpm` child process — `DB_HOST=localhost` wins over the
`DB_HOST=mariadb` that was exported from `.env`.
**Why `--env-file` is not baked into the dev script:**
If `--env-file .env` were added to the API `dev` script, it would load `DB_HOST=mariadb`
automatically on every `pnpm dev` invocation. That value only works inside the Docker network; on
the host it resolves to nothing and the DB connection fails. Keeping `.env` loading out of the
script is intentional — the developer sources it manually and overrides `DB_HOST` as shown above.