CI / changes (pull_request) Successful in 6s
CI / api (pull_request) Successful in 2m9s
CI / fast-checks (pull_request) Successful in 2m30s
CI / security (pull_request) Successful in 59s
CI / harness (pull_request) Failing after 12m0s
CI / gate (pull_request) Failing after 2s
Reformats 4 phase-17 files (SettingsSheet.tsx, tokens.css, vite.config.ts, pwa-assets.config.ts) plus 11 pre-existing non-conformant docs/READMEs that the repo-wide format:check also flags. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
209 lines
12 KiB
Markdown
209 lines
12 KiB
Markdown
<!-- 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
|
|
- **Auth** — dual-mode: OIDC authorization-code + PKCE flow against Authelia (`@hono/oidc-auth`) for production; local username/password auth (scrypt, JWT session cookie) for no-OIDC or first-boot scenarios. `DEV_AUTH_BYPASS=true` skips both for local development
|
|
- **Setup wizard** — `/api/setup/*` surface guides first-run configuration of OIDC, VAPID keys, and member credentials before the app is locked
|
|
- **Admin** — role-gated `/api/admin/*` for member management, credential rotation, and calendar sharing designation
|
|
- **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
|
|
|
|
```text
|
|
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 + OIDC-link initiation
|
|
push.ts Push subscription registration
|
|
sse.ts SSE stream for live list updates
|
|
health.ts Unauthenticated health check
|
|
setup.ts First-run setup wizard surface (/api/setup/*)
|
|
admin.ts Role-gated admin API (members, credentials, calendars)
|
|
localAuth.ts Local login/logout endpoints (/api/auth/local/*)
|
|
authMode.ts Pre-auth auth-mode discovery (/api/auth/mode)
|
|
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 + oidcConfigFallbackMiddleware
|
|
devBypass.ts DEV_AUTH_BYPASS passthrough (non-production only)
|
|
localAuthMiddleware.ts local-session cookie → c.get('user') middleware
|
|
localCredentials.ts scrypt password hashing and constant-time verification
|
|
localSession.ts HS256 JWT session-cookie issue / verify / clear helpers
|
|
linkNonceStore.ts Single-use nonce store for OIDC-link CSRF prevention
|
|
linkOidc.ts Atomic OIDC-identity binding + local credential removal
|
|
oidcConfig.ts Centralized OIDC config resolution (env OR app_config)
|
|
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
|
|
credentialSync.ts Shared validate→encrypt→store→initial-sync helper
|
|
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 (APP_PASSWORD_ENCRYPTION_KEY)
|
|
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
|
|
bootGuards.ts Boot-time env guards (blocks DEV_AUTH_BYPASS in production; enforces LOCAL_SESSION_SECRET)
|
|
setupGuard.ts isSetupLocked() — prevents re-running the wizard after completion
|
|
householdTimezone.ts Shared IANA timezone accessor with env fallback
|
|
outboxTrigger.ts In-process drain signal between routes and outboxWorker
|
|
requireAdmin.ts DB-enforced admin role middleware
|
|
```
|
|
|
|
## 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 |
|
|
| `OIDC_REDIRECT_URI` | No | Explicit redirect URI (overrides the `${OIDC_AUTH_EXTERNAL_URL}/callback` default) |
|
|
| `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 |
|
|
| `APP_PASSWORD_ENCRYPTION_KEY` | Yes | AES-256-GCM key (64-char hex) for stored Fastmail app passwords |
|
|
| `LOCAL_SESSION_SECRET` | Yes (local auth) | HS256 signing key for local-session JWT cookies (min 32 chars) |
|
|
| `LOCAL_SESSION_EXPIRES` | No (default `86400`) | Local session lifetime in seconds |
|
|
| `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 |
|
|
| `TZ` | No | IANA timezone fallback when household_timezone is not set in app_config |
|
|
|
|
> **Note:** `CREDENTIAL_ENCRYPTION_KEY` was renamed to `APP_PASSWORD_ENCRYPTION_KEY`. Update any existing `.env` files if upgrading from an earlier phase.
|
|
|
|
See [../../docs/CONFIGURATION.md](../../docs/CONFIGURATION.md) for the full reference.
|
|
|
|
## Authentication modes
|
|
|
|
The API supports two non-exclusive auth modes, determined at startup:
|
|
|
|
| Mode | When active | How it works |
|
|
| -------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
| **Local** | Always (default) | `POST /api/auth/local/login` with username + password; issues an HS256 JWT `local-session` cookie. Requires `LOCAL_SESSION_SECRET`. |
|
|
| **OIDC** | When `OIDC_ISSUER` + `OIDC_CLIENT_ID` are set (env or app_config) | `@hono/oidc-auth` authorization-code + PKCE against Authelia. Local users can upgrade to OIDC via `POST /api/me/link-oidc`. |
|
|
| **Dev bypass** | `DEV_AUTH_BYPASS=true` in non-production | Skips both guards and injects a synthetic dev user. Blocked in `NODE_ENV=production` by boot guard. |
|
|
|
|
`GET /api/auth/mode` returns `{ localEnabled, oidcEnabled }` before authentication — the PWA uses this to decide which login form to show.
|
|
|
|
## 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.
|
|
|
|
## Running API tests locally
|
|
|
|
Local test runs use a dedicated `familysync_test` database so the dev `familysync` database is never mutated. `test/global-setup.ts` creates and migrates `familysync_test` automatically on the first run.
|
|
|
|
**Prerequisites:**
|
|
|
|
- Dev MariaDB running and port-bound (`127.0.0.1:3306`) — start with `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mariadb`
|
|
- `.env` sourced in your shell (provides `DB_PASSWORD`, `DB_ROOT_PASSWORD`, and other credentials)
|
|
|
|
**Run command:**
|
|
|
|
```bash
|
|
set -a; source .env; set +a
|
|
DB_HOST=127.0.0.1 pnpm --filter @familysync/api test
|
|
```
|
|
|
|
`DB_ROOT_PASSWORD` must be set in `.env` for the one-time `CREATE DATABASE` / `GRANT` that provisions `familysync_test`. Subsequent runs skip the provisioning step if the database already exists (`CREATE DATABASE IF NOT EXISTS`).
|
|
|
|
**CI is unaffected.** `test/global-setup.ts` returns immediately when `CI` is set (the CI `api` job provisions its own `familysync` service DB and runs `db:migrate` before the test step). The `test.env` DB override in `vitest.config.ts` is also a no-op under CI.
|
|
|
|
## 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
|