- playwright-cli verified: Members tab shows tappable rows, no retired buttons - Row tap opens 'Edit member' sheet; per-section saves keep sheet open - 'Add member' trigger opens 'Add member' sheet in create mode - Profile save fires 'Profile saved.' toast; sheet stays open (D-05) - eslint + prettier + typecheck + vitest (275 tests) all pass - Fix pre-existing prettier drift in docs/*, CLAUDE.md, README.md, api/admin.ts
12 KiB
@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.
What it does
- Calendar broker — polls Fastmail CalDAV every 5 minutes via
tsdav; parses iCalendar payloads withical.jsand expands recurrence rules withical.js'sICAL.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=trueskips 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
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 rootpackage.jsonfor version)- MariaDB reachable at the coordinates in your
.env - Authelia OIDC provider (or use
DEV_AUTH_BYPASS=truefor 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.
# 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
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.
# 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_KEYwas renamed toAPP_PASSWORD_ENCRYPTION_KEY. Update any existing.envfiles if upgrading from an earlier phase.
See ../../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).
# 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 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 withdocker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mariadb .envsourced in your shell (providesDB_PASSWORD,DB_ROOT_PASSWORD, and other credentials)
Run command:
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 — system overview and component diagram
- API reference — endpoint table, request/response shapes, auth flow
- Configuration — all environment variables
- Deployment — Docker Compose, Unraid setup, VAPID key generation