docs: generate project documentation
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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 | 1–255 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 | 1–512 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 | 1–255 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 | 1–500 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 | 1–500 characters |
|
||||
| `position` | string | Fractional rank string, 1–255 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 | 1–512 characters |
|
||||
| `keys.auth` | string | Yes | 1–256 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 -->
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
+168
-275
@@ -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)
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Topology
|
||||
## Deployment Targets
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Public
|
||||
User[Browser / iOS PWA]
|
||||
Pangolin[Pangolin edge<br/>public HTTPS + WAF]
|
||||
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]
|
||||
| Target | Config file |
|
||||
|--------|-------------|
|
||||
| Docker Compose (production) | `docker-compose.yml` |
|
||||
| Docker Compose (dev override) | `docker-compose.dev.yml` |
|
||||
| Container image | `apps/api/Dockerfile` (multi-stage, built from repo root) |
|
||||
|
||||
User -->|https://familysync.DOMAIN| Pangolin
|
||||
Pangolin -->|tunnel| Newt
|
||||
Newt --> API
|
||||
API --> DB
|
||||
API -.Phase 4.-> Redis
|
||||
User -->|OIDC redirect| Authelia
|
||||
API -->|token exchange / userinfo| Authelia
|
||||
```
|
||||
The production compose file brings up three services:
|
||||
|
||||
Key property: **Newt dials outbound to Pangolin** — there are no open inbound ports on the
|
||||
private network (honours the project networking constraint). This is true for both modes.
|
||||
| Service | Image | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `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`.
|
||||
- Authelia already deployed and reachable at `https://auth.DOMAIN` (project constraint).
|
||||
- The familysync image builds: `docker compose build` (see repo `docker-compose.yml`).
|
||||
- A Fastmail app password per member (scope "Mail, Contacts & Calendars") — see
|
||||
`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.
|
||||
- Docker and Docker Compose available on the Unraid host.
|
||||
- Authelia already deployed with a FamilySync OIDC client registered (see [Register the OIDC Client](#register-the-oidc-client)).
|
||||
- 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 `.env` file at the repo root with all required secrets (see [Environment Setup](#environment-setup)).
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
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`:
|
||||
Add the following client block to your Authelia `configuration.yml` under `identity_providers.oidc.clients`:
|
||||
|
||||
```yaml
|
||||
identity_providers:
|
||||
oidc:
|
||||
clients:
|
||||
- client_id: 'familysync' # Mode A: 'familysync-dev'
|
||||
client_name: 'FamilySync'
|
||||
client_secret: '$pbkdf2-sha512$...' # the HASH from the command above
|
||||
- client_id: familysync
|
||||
client_name: FamilySync
|
||||
client_secret: '<pbkdf2-hash-of-your-plaintext-secret>'
|
||||
public: false
|
||||
authorization_policy: 'one_factor'
|
||||
authorization_policy: one_factor
|
||||
redirect_uris:
|
||||
- 'https://familysync.DOMAIN/callback' # Mode A: https://familysync-dev.DOMAIN/callback
|
||||
scopes: [openid, profile, email]
|
||||
response_types: [code]
|
||||
grant_types: [authorization_code, refresh_token]
|
||||
token_endpoint_auth_method: client_secret_basic
|
||||
- https://familysync.DOMAIN/callback # replace DOMAIN with your actual domain
|
||||
scopes:
|
||||
- openid
|
||||
- profile
|
||||
- email
|
||||
- offline_access
|
||||
response_types:
|
||||
- code
|
||||
grant_types:
|
||||
- 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
|
||||
auth params in `CLAUDE.md` (code flow + PKCE S256 + client_secret_basic).
|
||||
These parameters are fixed — do not change `response_types`, `grant_types`, `require_pkce`, `pkce_challenge_method`, or `token_endpoint_auth_method`.
|
||||
|
||||
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
|
||||
# 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'))")
|
||||
```
|
||||
Minimum production `.env`:
|
||||
|
||||
```dotenv
|
||||
# Database
|
||||
DB_HOST=mariadb
|
||||
DB_PORT=3306
|
||||
DB_USER=familysync
|
||||
DB_PASSWORD=<strong>
|
||||
DB_NAME=familysync
|
||||
DB_ROOT_PASSWORD=<strong>
|
||||
DB_PASSWORD=<strong-password>
|
||||
DB_ROOT_PASSWORD=<strong-root-password>
|
||||
|
||||
# OIDC (Authelia)
|
||||
OIDC_AUTH_SECRET=<openssl rand -base64 32>
|
||||
# OIDC
|
||||
OIDC_AUTH_SECRET=<run: openssl rand -base64 32>
|
||||
OIDC_ISSUER=https://auth.DOMAIN
|
||||
OIDC_CLIENT_ID=familysync # or familysync-dev (Mode A)
|
||||
OIDC_CLIENT_SECRET=<plaintext secret matching the Authelia hash>
|
||||
OIDC_CLIENT_SECRET=<plaintext-secret-matching-authelia-hash>
|
||||
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
|
||||
|
||||
# Broker
|
||||
APP_PASSWORD_ENCRYPTION_KEY=<64-hex>
|
||||
# Broker (Fastmail app-password encryption)
|
||||
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.**
|
||||
> 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
|
||||
> 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.
|
||||
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 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:
|
||||
|
||||
```bash
|
||||
docker compose up -d mariadb
|
||||
# from the repo root, host-side (dev override exposes 3306):
|
||||
# 1. Bring up only MariaDB with the host port exposed
|
||||
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> \
|
||||
pnpm --filter @familysync/api exec drizzle-kit migrate
|
||||
# verify: SHOW TABLES; -> users, member_credentials, calendars, calendar_events
|
||||
pnpm --filter @familysync/api db:migrate
|
||||
|
||||
# 4. Start the full stack
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
To author a future schema change: edit `apps/api/src/db/schema.ts`, run
|
||||
`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`.
|
||||
**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`.
|
||||
|
||||
---
|
||||
|
||||
## 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`)
|
||||
- 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.)
|
||||
<!-- VERIFY: Pangolin/Newt route configuration steps depend on your Pangolin dashboard and site-specific settings -->
|
||||
|
||||
### ⚠️ 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
|
||||
killed by a proxy idle timeout. In the Pangolin route config, ensure response buffering is
|
||||
**off** and the idle/read timeout is **>= 120s** (ideally higher). The Gate 2 SSE smoke test
|
||||
below is what confirms this end to end — **it must pass before Phase 4 is built.**
|
||||
```yaml
|
||||
ports:
|
||||
- "3000:3000"
|
||||
```
|
||||
|
||||
### 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
|
||||
# Newt connector (example — use the site token Pangolin issues for this site)
|
||||
docker run -d --name newt --restart unless-stopped \
|
||||
-e PANGOLIN_ENDPOINT=https://pangolin.DOMAIN \
|
||||
-e NEWT_ID=<site-id> -e NEWT_SECRET=<site-secret> \
|
||||
fosrl/newt:latest
|
||||
# From the repo root — builds both the API and the React PWA into one image
|
||||
docker compose build
|
||||
|
||||
# Start all services
|
||||
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
|
||||
docker compose up -d --build
|
||||
curl -s http://localhost:3000/health # local sanity: {"ok":true,"db":"up"}
|
||||
docker compose logs -f api
|
||||
docker compose logs -f mariadb
|
||||
```
|
||||
|
||||
Then proceed to Gate 2 verification through the public URL.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
<!-- VERIFY: No Sentry, Datadog, or OpenTelemetry dependency is present in package.json — confirm whether any external monitoring is wired at the infrastructure level -->
|
||||
|
||||
Reference in New Issue
Block a user