oidcConfigFallbackMiddleware (Phase 12) reads OIDC config from app_config on every /api/* request when OIDC_ISSUER/CLIENT_ID/AUTH_EXTERNAL_URL are absent. CI's api job sets no OIDC env, so events/login tests (which mock db with a partial query chain) 500'd on every request. Local runs passed only because ambient .env supplied the vars. Set dummy OIDC config in vitest test.env so the middleware always takes the env path — hermetic across CI and local. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
git clone <repo-url> familysync
cd familysync
pnpm install
Copy the environment template and fill in values:
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):
# 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):
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 + real MariaDB) |
pnpm test:e2e |
Run Playwright harness (iPhone + Pixel profiles) |
pnpm lint |
ESLint across all workspaces (flat config, TS-aware) |
pnpm typecheck |
Type-check all workspaces |
pnpm format |
Reformat all files with Prettier |
pnpm format:check |
Check formatting without writing (used in CI) |
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 for Unraid/Docker Compose deployment notes including the Pangolin/Newt tunnel configuration.
CI
Every PR to main must pass three required checks before it can merge:
| Job | What it runs |
|---|---|
CI / fast-checks |
pnpm lint, pnpm format:check, pnpm typecheck, PWA unit tests |
CI / api |
DB migrations + API test suite against a real MariaDB 11 service container |
CI / harness |
Playwright end-to-end harness (WebKit iPhone + Chromium Pixel) |
fast-checks and api/harness run in parallel. Defined in .gitea/workflows/ci.yml.
Publishing / Releases
Publishing happens automatically on every push to main — i.e. when a PR merges. The .gitea/workflows/publish.yml workflow runs and builds + pushes the API image to the Gitea container registry.
Image: git.bergerhouse.net/luckberg/familysync-api
Tags (two per release):
:latest— moving pointer for easy pulls:<MILESTONE>-<shortsha>— immutable, rollback-traceable (e.g.v1.1-98acff8)
Required secret: REGISTRY_PAT — a Gitea Actions secret holding a PAT with write:package scope. Named REGISTRY_PAT (not GITEA_*): Gitea reserves the GITEA_ prefix for secret names, so GITEA_-prefixed names cannot be created. GITEA_TOKEN / GITHUB_TOKEN cannot push packages.
Safety gate: Branch protection on main, not a needs: dependency in publish.yml. The PR test jobs (fast-checks, api, harness in ci.yml) run on pull_request — they never run in the same workflow invocation as publish.yml. Tests gate the PR; main is trusted to be green because direct push and force push are blocked and the three required checks (CI / fast-checks (pull_request), CI / api (pull_request), CI / harness (pull_request)) must pass before merge.
To bump the milestone tag at a milestone boundary: edit the MILESTONE env value at the top of .gitea/workflows/publish.yml.
License
Private — not open source.