docs: refresh project documentation against current codebase
Publish / publish (push) Successful in 26s
Publish / publish (push) Successful in 26s
This commit is contained in:
@@ -192,7 +192,39 @@ FamilySync is a self-hosted, Dockerized family organization hub for a two-person
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Architecture not yet mapped. Follow existing patterns found in the codebase.
|
FamilySync is a pnpm monorepo with two apps: `apps/api` (Hono 4.x on Node 22 LTS, Drizzle ORM + MariaDB 11, TypeScript) and `apps/pwa` (React 19 + Vite 8 + vite-plugin-pwa). A single Docker Compose stack runs the API container (which also serves the PWA static build) and a MariaDB container, exposed through a Pangolin/Newt tunnel.
|
||||||
|
|
||||||
|
The backend handles two auth paths: local username/password (scrypt + HS256 JWT `local-session` cookie) and Authelia OIDC (authorization code + PKCE via `@hono/oidc-auth`). Both populate `c.get('user')`; the OIDC guard is skipped when a valid local session is present. A local user may link an OIDC identity later.
|
||||||
|
|
||||||
|
Calendar data lives exclusively in Fastmail CalDAV. The broker layer (`apps/api/src/broker/`) uses `tsdav` for PROPFIND/REPORT/PUT/DELETE, `ical.js` for VCALENDAR parsing, and `rrule` for server-side recurrence expansion. Writes are enqueued in a `calendarOutbox` table and drained asynchronously every 15 seconds; a ctag-based poller re-syncs calendars every 5 minutes.
|
||||||
|
|
||||||
|
Lists are persisted in MariaDB. Live list updates flow over SSE (`text/event-stream`) via an in-process Node.js `EventEmitter`; a 30-second polling fallback is always active. Push notifications (reminders + calendar change alerts) are dispatched via `web-push` (VAPID) to APNs/FCM. Redis is present in the stack but not yet used at runtime (reserved for future multi-process pub/sub).
|
||||||
|
|
||||||
|
The PWA uses TanStack Query for all server state (events, lists, user, sync status, auth mode) and Zustand for UI-only state (selected date, open panels, active tab).
|
||||||
|
|
||||||
|
```text
|
||||||
|
familysync/
|
||||||
|
├── apps/
|
||||||
|
│ ├── api/src/
|
||||||
|
│ │ ├── index.ts # App entry: mounts routes, starts background workers
|
||||||
|
│ │ ├── routes/ # HTTP handlers (events, lists, me, push, sse, auth, admin, setup)
|
||||||
|
│ │ ├── auth/ # Local session + OIDC middleware + dev-bypass + OIDC-link
|
||||||
|
│ │ ├── broker/ # CalDAV client, sync, poller, outbox worker, RRULE expand, write
|
||||||
|
│ │ ├── db/ # Drizzle schema, mysql2 pool, migrations
|
||||||
|
│ │ └── lib/ # List/event emitters, push dispatcher, rank, guards, admin/setup helpers
|
||||||
|
│ └── pwa/src/
|
||||||
|
│ ├── App.tsx # BrowserRouter shell
|
||||||
|
│ ├── routes/ # Page-level components
|
||||||
|
│ ├── components/ # Shared UI components
|
||||||
|
│ ├── api/ # Typed fetch wrappers (client.ts, listsClient.ts)
|
||||||
|
│ ├── hooks/ # useListSSE, usePushSubscription
|
||||||
|
│ ├── store/ # Zustand stores (calendarStore, listsStore)
|
||||||
|
│ └── sw.ts # Custom Workbox service worker
|
||||||
|
├── docker-compose.yml # Production stack (api + mariadb + redis)
|
||||||
|
└── docker-compose.dev.yml # Dev overrides
|
||||||
|
```
|
||||||
|
|
||||||
|
See `docs/ARCHITECTURE.md` for the full Mermaid component diagram, data-flow walkthroughs, and key abstractions table.
|
||||||
|
|
||||||
<!-- GSD:architecture-end -->
|
<!-- GSD:architecture-end -->
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ docker-compose.dev.yml Dev overrides (bind-mount src/, expose DB/Redis ports)
|
|||||||
| `pnpm typecheck` | Type-check all workspaces |
|
| `pnpm typecheck` | Type-check all workspaces |
|
||||||
| `pnpm format` | Reformat all files with Prettier |
|
| `pnpm format` | Reformat all files with Prettier |
|
||||||
| `pnpm format:check` | Check formatting without writing (used in CI) |
|
| `pnpm format:check` | Check formatting without writing (used in CI) |
|
||||||
|
| `pnpm md:lint` | Lint Markdown files with markdownlint-cli2 |
|
||||||
|
| `pnpm generate-secrets` | Generate random secrets for `.env` setup |
|
||||||
| `pnpm --filter @familysync/api db:generate` | Generate Drizzle migration from schema changes |
|
| `pnpm --filter @familysync/api db:generate` | Generate Drizzle migration from schema changes |
|
||||||
| `pnpm --filter @familysync/api db:migrate` | Apply pending migrations to MariaDB |
|
| `pnpm --filter @familysync/api db:migrate` | Apply pending migrations to MariaDB |
|
||||||
|
|
||||||
@@ -128,15 +130,17 @@ See [`docs/deployment.md`](docs/deployment.md) for Unraid/Docker Compose deploym
|
|||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
Every PR to `main` must pass three required checks before it can merge:
|
Every PR to `main` must pass four jobs before it can merge:
|
||||||
|
|
||||||
| Job | What it runs |
|
| Job | What it runs |
|
||||||
| ------------------ | -------------------------------------------------------------------------- |
|
| -------------------- | -------------------------------------------------------------------------- |
|
||||||
| `CI / fast-checks` | `pnpm lint`, `pnpm format:check`, `pnpm typecheck`, PWA unit tests |
|
| `CI / fast-checks` | `pnpm lint`, `pnpm format:check`, `pnpm md:lint`, `pnpm typecheck`, PWA unit tests |
|
||||||
| `CI / api` | DB migrations + API test suite against a real MariaDB 11 service container |
|
| `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) |
|
| `CI / harness` | Playwright end-to-end harness (WebKit iPhone + Chromium Pixel) |
|
||||||
|
| `CI / security` | Gitleaks secret scan (all PRs) + `pnpm audit` + outdated report (code PRs) |
|
||||||
|
| `CI / gate` | Aggregate: asserts all jobs above passed or were legitimately skipped |
|
||||||
|
|
||||||
`fast-checks` and `api`/`harness` run in parallel. Defined in `.gitea/workflows/ci.yml`.
|
`fast-checks` and `security` always run. `api` and `harness` are skipped for doc-only PRs (no changes outside `.gitea/`, `.planning/`, or `*.md`). The `gate` job is the single required check for merge. Defined in `.gitea/workflows/ci.yml`.
|
||||||
|
|
||||||
## Publishing / Releases
|
## Publishing / Releases
|
||||||
|
|
||||||
@@ -151,7 +155,7 @@ Publishing happens automatically on every push to `main` — i.e. when a PR merg
|
|||||||
|
|
||||||
**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.
|
**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.
|
**Safety gate:** Branch protection on `main`, not a `needs:` dependency in `publish.yml`. The PR test jobs (`fast-checks`, `api`, `harness`, `security`, `gate` 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 two required checks (`CI / fast-checks` and `CI / gate`) must pass before merge. `CI / api` and `CI / harness` are conditionally skipped on doc-only PRs and are gated via the always-running `CI / gate` aggregate.
|
||||||
|
|
||||||
**To bump the milestone tag** at a milestone boundary: edit the `MILESTONE` env value at the top of `.gitea/workflows/publish.yml`.
|
**To bump the milestone tag** at a milestone boundary: edit the `MILESTONE` env value at the top of `.gitea/workflows/publish.yml`.
|
||||||
|
|
||||||
|
|||||||
+43
-7
@@ -10,7 +10,9 @@ Part of the [FamilySync monorepo](../../README.md).
|
|||||||
|
|
||||||
- **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
|
- **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
|
- **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
|
- **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
|
- **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
|
- **Push notifications** — web-push (VAPID) delivers reminders for shared timed events to subscribed browsers
|
||||||
|
|
||||||
@@ -22,17 +24,27 @@ src/
|
|||||||
routes/
|
routes/
|
||||||
events.ts CalDAV event CRUD endpoints
|
events.ts CalDAV event CRUD endpoints
|
||||||
lists.ts List and list-item CRUD endpoints
|
lists.ts List and list-item CRUD endpoints
|
||||||
me.ts Authenticated user profile endpoint
|
me.ts Authenticated user profile endpoint + OIDC-link initiation
|
||||||
push.ts Push subscription registration
|
push.ts Push subscription registration
|
||||||
sse.ts SSE stream for live list updates
|
sse.ts SSE stream for live list updates
|
||||||
health.ts Unauthenticated health check
|
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/
|
db/
|
||||||
schema.ts Drizzle table definitions (MariaDB/mysql2)
|
schema.ts Drizzle table definitions (MariaDB/mysql2)
|
||||||
client.ts Drizzle client singleton
|
client.ts Drizzle client singleton
|
||||||
migrations/ SQL migrations generated by drizzle-kit
|
migrations/ SQL migrations generated by drizzle-kit
|
||||||
auth/
|
auth/
|
||||||
middleware.ts oidcAuthMiddleware + processOAuthCallback
|
middleware.ts oidcAuthMiddleware + processOAuthCallback + oidcConfigFallbackMiddleware
|
||||||
devBypass.ts DEV_AUTH_BYPASS passthrough (non-production only)
|
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
|
persistSessionCookie.ts Re-issues session cookie as persistent for PWA
|
||||||
user.ts User upsert on first login
|
user.ts User upsert on first login
|
||||||
broker/
|
broker/
|
||||||
@@ -40,11 +52,12 @@ src/
|
|||||||
outboxWorker.ts 15-second drain of pending CalDAV writes to Fastmail
|
outboxWorker.ts 15-second drain of pending CalDAV writes to Fastmail
|
||||||
reminderScheduler.ts 1-minute scan for upcoming shared events → push
|
reminderScheduler.ts 1-minute scan for upcoming shared events → push
|
||||||
client.ts tsdav client factory
|
client.ts tsdav client factory
|
||||||
|
credentialSync.ts Shared validate→encrypt→store→initial-sync helper
|
||||||
sync.ts REPORT → ical.js → DB upsert logic
|
sync.ts REPORT → ical.js → DB upsert logic
|
||||||
write.ts CalDAV PUT/DELETE helpers
|
write.ts CalDAV PUT/DELETE helpers
|
||||||
expand.ts recurrence expansion via ICAL.RecurExpansion
|
expand.ts Recurrence expansion via ICAL.RecurExpansion
|
||||||
vevent.ts VEVENT ↔ DB row mapping
|
vevent.ts VEVENT ↔ DB row mapping
|
||||||
crypto.ts AES-256-GCM encrypt/decrypt for stored app passwords
|
crypto.ts AES-256-GCM encrypt/decrypt for stored app passwords (APP_PASSWORD_ENCRYPTION_KEY)
|
||||||
lib/
|
lib/
|
||||||
listEmitter.ts In-process EventEmitter for SSE fan-out
|
listEmitter.ts In-process EventEmitter for SSE fan-out
|
||||||
listChangeDispatcher.ts Publishes list mutations to listEmitter
|
listChangeDispatcher.ts Publishes list mutations to listEmitter
|
||||||
@@ -53,6 +66,11 @@ src/
|
|||||||
pushCoalescer.ts Debounces push for rapid successive edits
|
pushCoalescer.ts Debounces push for rapid successive edits
|
||||||
listAccess.ts List permission helpers
|
listAccess.ts List permission helpers
|
||||||
rank.ts Fractional indexing 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
|
## Running in the workspace
|
||||||
@@ -108,7 +126,7 @@ Migration files are written to `src/db/migrations/` and checked into source cont
|
|||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
| Variable | Required | Description |
|
| Variable | Required | Description |
|
||||||
| --------------------------- | ------------------- | ---------------------------------------------------------------------- |
|
| ----------------------------- | ------------------- | ---------------------------------------------------------------------------------- |
|
||||||
| `DB_HOST` | Yes | MariaDB host |
|
| `DB_HOST` | Yes | MariaDB host |
|
||||||
| `DB_USER` | Yes | MariaDB user |
|
| `DB_USER` | Yes | MariaDB user |
|
||||||
| `DB_PASSWORD` | Yes | MariaDB password |
|
| `DB_PASSWORD` | Yes | MariaDB password |
|
||||||
@@ -118,15 +136,33 @@ Migration files are written to `src/db/migrations/` and checked into source cont
|
|||||||
| `OIDC_CLIENT_ID` | Yes (production) | OIDC client ID |
|
| `OIDC_CLIENT_ID` | Yes (production) | OIDC client ID |
|
||||||
| `OIDC_CLIENT_SECRET` | Yes (production) | OIDC client secret |
|
| `OIDC_CLIENT_SECRET` | Yes (production) | OIDC client secret |
|
||||||
| `OIDC_AUTH_EXTERNAL_URL` | Yes (production) | External-facing URL for redirect_uri behind Pangolin tunnel |
|
| `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_SUBJECT` | Yes (push) | `mailto:` or `https:` operator identifier |
|
||||||
| `VAPID_PUBLIC_KEY` | Yes (push) | VAPID public key |
|
| `VAPID_PUBLIC_KEY` | Yes (push) | VAPID public key |
|
||||||
| `VAPID_PRIVATE_KEY` | Yes (push) | VAPID private key |
|
| `VAPID_PRIVATE_KEY` | Yes (push) | VAPID private key |
|
||||||
| `CREDENTIAL_ENCRYPTION_KEY` | Yes | AES-256-GCM key for stored Fastmail app passwords |
|
| `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 |
|
| `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 |
|
| `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.
|
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
|
||||||
|
|
||||||
Tests live in `tests/` (integration, route, broker unit) and `test/setup.ts` (global setup/teardown).
|
Tests live in `tests/` (integration, route, broker unit) and `test/setup.ts` (global setup/teardown).
|
||||||
|
|||||||
+19
-5
@@ -33,12 +33,16 @@ The API backend must also be running for most features. See [GETTING-STARTED.md]
|
|||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
| Command | What it does |
|
| Command | What it does |
|
||||||
| ----------------------------------------- | ------------------------------------------------------------- |
|
| ------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||||
| `pnpm --filter @familysync/pwa dev` | Start Vite dev server (HMR) |
|
| `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 build` | Type-check then build production bundle (`tsc && vite build`) |
|
||||||
| `pnpm --filter @familysync/pwa preview` | Serve the production build locally |
|
| `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 lint` | Run ESLint over `src/` and `e2e/` with zero warnings allowed |
|
||||||
| `pnpm --filter @familysync/pwa test` | Run Vitest test suite once (`vitest run`) |
|
| `pnpm --filter @familysync/pwa typecheck` | Run `tsc --noEmit` for both `src/` and `e2e/` tsconfigs |
|
||||||
|
| `pnpm --filter @familysync/pwa test` | Run Vitest unit/integration suite once (`vitest run`) |
|
||||||
|
| `pnpm --filter @familysync/pwa test:e2e` | Run Playwright end-to-end tests headlessly |
|
||||||
|
| `pnpm --filter @familysync/pwa test:e2e:ui` | Open the Playwright UI runner |
|
||||||
|
| `pnpm --filter @familysync/pwa test:e2e:headed` | Run Playwright tests in a headed browser |
|
||||||
|
|
||||||
## Source layout
|
## Source layout
|
||||||
|
|
||||||
@@ -48,7 +52,7 @@ src/
|
|||||||
components/ # Shared UI components co-located with their *.test.tsx files
|
components/ # Shared UI components co-located with their *.test.tsx files
|
||||||
hooks/ # Custom React hooks (useListSSE, usePushSubscription, useFocusTrap)
|
hooks/ # Custom React hooks (useListSSE, usePushSubscription, useFocusTrap)
|
||||||
lib/ # Pure helpers: calendarConfig, colorUtils, eventDateTime, hydrateEvents, loginRedirect
|
lib/ # Pure helpers: calendarConfig, colorUtils, eventDateTime, hydrateEvents, loginRedirect
|
||||||
routes/ # React Router route components with co-located tests (ListDetail, ListsIndex)
|
routes/ # React Router route components with co-located tests (AdminPage, ListDetail, ListsIndex, LoginPage, SetupPage)
|
||||||
store/ # Zustand stores: calendarStore, listsStore
|
store/ # Zustand stores: calendarStore, listsStore
|
||||||
styles/ # Global CSS
|
styles/ # Global CSS
|
||||||
main.tsx # App entry point — React Query client, router, global error handlers
|
main.tsx # App entry point — React Query client, router, global error handlers
|
||||||
@@ -72,7 +76,7 @@ In development the Vite proxy routes `/api` requests to the API server on port 3
|
|||||||
|
|
||||||
## Testing
|
## 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`.
|
Unit and integration 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
|
```bash
|
||||||
# run once
|
# run once
|
||||||
@@ -82,6 +86,16 @@ pnpm --filter @familysync/pwa test
|
|||||||
pnpm --filter @familysync/pwa exec vitest
|
pnpm --filter @familysync/pwa exec vitest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
End-to-end tests live in the `e2e/` directory and run with Playwright (`@playwright/test` 1.60.0). They cover login, calendar, lists, layout, admin, and timezone verification flows.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# headless
|
||||||
|
pnpm --filter @familysync/pwa test:e2e
|
||||||
|
|
||||||
|
# with Playwright UI
|
||||||
|
pnpm --filter @familysync/pwa test:e2e:ui
|
||||||
|
```
|
||||||
|
|
||||||
No coverage threshold is configured. Run `pnpm --filter @familysync/pwa typecheck` separately — Vitest uses esbuild and will not surface TypeScript errors.
|
No coverage threshold is configured. Run `pnpm --filter @familysync/pwa typecheck` separately — Vitest uses esbuild and will not surface TypeScript errors.
|
||||||
|
|
||||||
## PWA install notes
|
## PWA install notes
|
||||||
|
|||||||
+516
-31
@@ -2,16 +2,22 @@
|
|||||||
|
|
||||||
# API Reference
|
# 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.
|
FamilySync exposes a Hono HTTP API served on port 3000. All `/api/*` routes require an authenticated session (OIDC via Authelia, local username/password, 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
|
## Authentication
|
||||||
|
|
||||||
The API uses OIDC session cookies managed by `@hono/oidc-auth`. The authorization-code + PKCE flow is handled transparently:
|
The API supports two session mechanisms that coexist on the same `/api/*` guard chain:
|
||||||
|
|
||||||
|
**OIDC session (Authelia):** 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.
|
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.
|
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).
|
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.)
|
4. All subsequent `/api/*` requests carry the session cookie automatically.
|
||||||
|
|
||||||
|
**Local session (username + password):** Available when OIDC is not configured or for members who have not linked an OIDC identity. `POST /api/auth/local/login` issues a signed JWT `local-session` cookie. The local-session middleware validates it on every `/api/*` request and sets the user context, so OIDC guard is bypassed for already-authenticated local users.
|
||||||
|
|
||||||
|
**Session precedence:** Local-session middleware runs first. If `c.get('user')` is already set (local session or dev bypass), the OIDC guard is skipped entirely.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
@@ -20,30 +26,53 @@ No API key or `Authorization` header is used. Credentials are never included in
|
|||||||
## Endpoints Overview
|
## Endpoints Overview
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
| ------ | -------------------------------- | ---- | ------------------------------------ |
|
| ------ | ------------------------------------- | --------- | -------------------------------------------------- |
|
||||||
| GET | `/health` | None | DB liveness check |
|
| GET | `/health` | None | DB liveness check |
|
||||||
| GET | `/callback` | None | OIDC authorization-code exchange |
|
| GET | `/callback` | None | OIDC authorization-code exchange |
|
||||||
| GET | `/api/login` | OIDC | Login entry point, redirects to `/` |
|
| GET | `/api/setup/status` | None | Setup wizard completion status |
|
||||||
| GET | `/api/me` | OIDC | Current user identity and color |
|
| POST | `/api/setup/config` | None | Store OIDC and VAPID config (wizard step 1) |
|
||||||
| GET | `/api/events` | OIDC | Windowed calendar occurrences |
|
| POST | `/api/setup/validate/db` | None | Validate DB connectivity (wizard step) |
|
||||||
| POST | `/api/events/create` | OIDC | Enqueue a new event write |
|
| POST | `/api/setup/validate/oidc` | None | Validate OIDC issuer discovery (wizard step) |
|
||||||
| PATCH | `/api/events/:uid/edit` | OIDC | Enqueue an event update |
|
| POST | `/api/setup/validate/vapid` | None | Validate VAPID key pair (wizard step) |
|
||||||
| DELETE | `/api/events/:uid` | OIDC | Enqueue an event delete |
|
| POST | `/api/setup/credential` | None | Store first admin's Fastmail credential (wizard) |
|
||||||
| GET | `/api/events/sync-status` | OIDC | Outbox status for a UID |
|
| POST | `/api/setup/complete` | None | Lock the setup wizard |
|
||||||
| GET | `/api/events/writable-calendars` | OIDC | Calendars the member can write to |
|
| GET | `/api/auth/mode` | None | Auth mode discovery (local vs OIDC enabled) |
|
||||||
| GET | `/api/lists` | OIDC | All lists accessible to the member |
|
| POST | `/api/auth/local/login` | None | Local username+password login |
|
||||||
| POST | `/api/lists` | OIDC | Create a list |
|
| POST | `/api/auth/local/logout` | None | Clear local session cookie |
|
||||||
| PATCH | `/api/lists/:id` | OIDC | Update list name or sharing |
|
| GET | `/api/auth/local/logout` | None | Clear local session cookie (browser redirect alias)|
|
||||||
| DELETE | `/api/lists/:id` | OIDC | Delete a list (owner only) |
|
| GET | `/api/login` | OIDC | OIDC login entry point, redirects to `/` |
|
||||||
| GET | `/api/lists/:id/items` | OIDC | All items in a list |
|
| GET | `/api/me` | Session | Current user identity, role, and setup status |
|
||||||
| POST | `/api/lists/:id/items` | OIDC | Add an item to a list |
|
| POST | `/api/me/credential` | Session | Member self-service Fastmail credential update |
|
||||||
| PATCH | `/api/list-items/:itemId` | OIDC | Update a single list item field |
|
| POST | `/api/me/password` | Session | Member self-change local password |
|
||||||
| DELETE | `/api/list-items/:itemId` | OIDC | Delete a list item |
|
| POST | `/api/me/link-oidc` | Session | Initiate OIDC identity link for a local user |
|
||||||
| GET | `/api/sse/heartbeat` | OIDC | SSE heartbeat stream |
|
| GET | `/api/events` | Session | Windowed calendar occurrences |
|
||||||
| GET | `/api/sse/lists` | OIDC | Scoped live-list SSE stream |
|
| POST | `/api/events/create` | Session | Enqueue a new event write |
|
||||||
| GET | `/api/push/vapid-public-key` | OIDC | VAPID public key for push subscribe |
|
| PATCH | `/api/events/:uid/edit` | Session | Enqueue an event update |
|
||||||
| POST | `/api/push/subscription` | OIDC | Register a push subscription |
|
| DELETE | `/api/events/:uid` | Session | Enqueue an event delete |
|
||||||
| DELETE | `/api/push/subscription` | OIDC | Remove push subscriptions for caller |
|
| GET | `/api/events/sync-status` | Session | Outbox status for a UID |
|
||||||
|
| GET | `/api/events/writable-calendars` | Session | Calendars the member can write to |
|
||||||
|
| GET | `/api/lists` | Session | All lists accessible to the member |
|
||||||
|
| POST | `/api/lists` | Session | Create a list |
|
||||||
|
| PATCH | `/api/lists/:id` | Session | Update list name or sharing |
|
||||||
|
| DELETE | `/api/lists/:id` | Session | Delete a list (owner only) |
|
||||||
|
| GET | `/api/lists/:id/items` | Session | All items in a list |
|
||||||
|
| POST | `/api/lists/:id/items` | Session | Add an item to a list |
|
||||||
|
| PATCH | `/api/list-items/:itemId` | Session | Update a single list item field |
|
||||||
|
| DELETE | `/api/list-items/:itemId` | Session | Delete a list item |
|
||||||
|
| GET | `/api/sse/heartbeat` | Session | SSE heartbeat stream |
|
||||||
|
| GET | `/api/sse/lists` | Session | Scoped live-list SSE stream |
|
||||||
|
| GET | `/api/push/vapid-public-key` | Session | VAPID public key for push subscribe |
|
||||||
|
| POST | `/api/push/subscription` | Session | Register a push subscription |
|
||||||
|
| DELETE | `/api/push/subscription` | Session | Remove push subscriptions for caller |
|
||||||
|
| GET | `/api/admin/members` | Admin | List members with credential status |
|
||||||
|
| POST | `/api/admin/members` | Admin | Create a new local member |
|
||||||
|
| POST | `/api/admin/members/:id/password` | Admin | Reset a local member's password |
|
||||||
|
| POST | `/api/admin/credentials` | Admin | Validate and store a member's Fastmail credential |
|
||||||
|
| GET | `/api/admin/calendars` | Admin | List synced calendars |
|
||||||
|
| PUT | `/api/admin/calendars/:id/shared` | Admin | Designate the shared family calendar |
|
||||||
|
| GET | `/api/admin/config/timezone` | Admin | Get household timezone |
|
||||||
|
| PUT | `/api/admin/config/timezone` | Admin | Set household timezone |
|
||||||
|
| POST | `/api/admin/config/timezone/seed` | Admin | Seed household timezone if not yet set |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -67,13 +96,190 @@ Unauthenticated. Performs a `SELECT 1` against MariaDB to prove connectivity.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Setup Wizard
|
||||||
|
|
||||||
|
The setup wizard surface is reachable pre-authentication. All setup routes return `423` once `isSetupLocked()` returns true (i.e., after `POST /api/setup/complete` has been called or the database has an effective OIDC configuration).
|
||||||
|
|
||||||
|
### `GET /api/setup/status`
|
||||||
|
|
||||||
|
Returns whether the first-run wizard has been completed. Always reachable (no 423 guard — the PWA needs this to decide whether to show the wizard).
|
||||||
|
|
||||||
|
**Response 200**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "setupComplete": false, "dbName": "familysync" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`dbName` is the value of the `DB_NAME` environment variable (non-secret, for display in the wizard UI). `setupComplete: true` indicates the wizard is locked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/setup/config`
|
||||||
|
|
||||||
|
Stores non-secret OIDC and VAPID configuration into `app_config`. Returns `423` if setup is already locked.
|
||||||
|
|
||||||
|
**Request body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"oidcIssuer": "https://auth.example.com",
|
||||||
|
"oidcClientId": "familysync",
|
||||||
|
"vapidPublicKey": "BNF8dFt...",
|
||||||
|
"appExternalUrl": "https://familysync.example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Constraints |
|
||||||
|
| ---------------- | ------ | -------- | ------------------------------ |
|
||||||
|
| `oidcIssuer` | string | Yes | HTTPS URL |
|
||||||
|
| `oidcClientId` | string | Yes | 1–256 characters |
|
||||||
|
| `vapidPublicKey` | string | Yes | 1–512 characters |
|
||||||
|
| `appExternalUrl` | string | Yes | HTTPS URL, max 512 characters |
|
||||||
|
|
||||||
|
**Response 200**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/setup/validate/db`
|
||||||
|
|
||||||
|
Proves DB connectivity via `SELECT 1`. Returns `423` if setup is locked.
|
||||||
|
|
||||||
|
**Response 200** — `{ "ok": true }`
|
||||||
|
|
||||||
|
**Response 503** — `{ "ok": false, "error": "DB unavailable" }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/setup/validate/oidc`
|
||||||
|
|
||||||
|
Fetches `{oidcIssuer}/.well-known/openid-configuration` (5-second timeout) to validate the issuer stored by `POST /api/setup/config`. Returns `423` if setup is locked.
|
||||||
|
|
||||||
|
**Response 200** — `{ "ok": true }`
|
||||||
|
|
||||||
|
**Response 400** — `{ "ok": false, "error": "OIDC discovery failed. Check the issuer URL." }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/setup/validate/vapid`
|
||||||
|
|
||||||
|
Validates the VAPID public key stored in `app_config` against the `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` environment variables. The submitted public key must exactly equal `process.env.VAPID_PUBLIC_KEY`. Returns `423` if setup is locked.
|
||||||
|
|
||||||
|
**Response 200** — `{ "ok": true }`
|
||||||
|
|
||||||
|
**Response 400** — `{ "ok": false, "error": "VAPID public key does not match..." }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/setup/credential`
|
||||||
|
|
||||||
|
Creates the first admin user (no OIDC identity yet, `claimed: false`) and validates/encrypts/stores their Fastmail CalDAV app password. Returns `423` if setup is locked; `409` if an unclaimed admin row already exists (concurrent wizard request).
|
||||||
|
|
||||||
|
**Request body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fastmailEmail": "broker@fastmail.com",
|
||||||
|
"appPassword": "xxxx-xxxx-xxxx-xxxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Constraints |
|
||||||
|
| --------------- | ------ | -------- | ---------------- |
|
||||||
|
| `fastmailEmail` | string | Yes | Valid email, max 256 characters |
|
||||||
|
| `appPassword` | string | Yes | 1–500 characters |
|
||||||
|
|
||||||
|
Zod validation errors for this route never echo received values (the app password is never included in error responses).
|
||||||
|
|
||||||
|
**Response 200** — `{ "ok": true }`
|
||||||
|
|
||||||
|
**Error responses:** `400` invalid request; `409` setup already in progress; `423` setup locked; `503` service unavailable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/setup/complete`
|
||||||
|
|
||||||
|
Locks the setup wizard by writing `setup_complete=true` to `app_config`. Requires an unclaimed admin user with a Fastmail credential to exist (guards against skipping the credential step). Returns `423` if already locked.
|
||||||
|
|
||||||
|
**Response 200** — `{ "ok": true }`
|
||||||
|
|
||||||
|
**Response 422** — `{ "error": "Cannot lock setup: no credential configured" }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth Mode
|
||||||
|
|
||||||
|
### `GET /api/auth/mode`
|
||||||
|
|
||||||
|
Pre-auth endpoint. Returns which authentication methods are currently enabled. Used by the PWA on app load to decide which login flow to present.
|
||||||
|
|
||||||
|
`oidcEnabled` is true when `OIDC_ISSUER` is set in the environment **or** when `app_config` has an `oidc_issuer` row (wizard-configured OIDC before container restart). `localEnabled` is always `true`.
|
||||||
|
|
||||||
|
**Response 200**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "localEnabled": true, "oidcEnabled": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Auth
|
||||||
|
|
||||||
|
### `POST /api/auth/local/login`
|
||||||
|
|
||||||
|
Validates a username + password against `local_credentials` and issues a signed `local-session` JWT cookie. Pre-auth — reachable without a session.
|
||||||
|
|
||||||
|
Rate limiting is per-username (not per-IP):
|
||||||
|
- 5 failures within 60 seconds → `429 Too Many Requests`
|
||||||
|
- 10 cumulative failures → `423 Account Locked` (auto-expires after 15 minutes or on admin password reset)
|
||||||
|
|
||||||
|
Timing-oracle defense: `verifyPassword` (scrypt) is always called, even for unknown usernames.
|
||||||
|
|
||||||
|
**Request body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "username": "alice", "password": "hunter2" }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Constraints |
|
||||||
|
| ---------- | ------ | -------- | ---------------- |
|
||||||
|
| `username` | string | Yes | 1–128 characters (trimmed) |
|
||||||
|
| `password` | string | Yes | 1–1000 characters |
|
||||||
|
|
||||||
|
Zod validation errors never echo received values.
|
||||||
|
|
||||||
|
**Response 200**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
Sets a `local-session` cookie (`httpOnly; Secure; SameSite`).
|
||||||
|
|
||||||
|
**Error responses:** `400` invalid request; `401` invalid credentials (same body for wrong password and unknown username — no field discrimination); `423` account locked; `429` too many attempts; `503` service unavailable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/auth/local/logout`
|
||||||
|
|
||||||
|
Clears the `local-session` cookie. Also available as `GET /api/auth/local/logout` for browser-redirect compatibility.
|
||||||
|
|
||||||
|
**Response 200** — `{ "ok": true }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
|
|
||||||
### `GET /api/me`
|
### `GET /api/me`
|
||||||
|
|
||||||
Returns the authenticated member's identity and their assigned color.
|
Returns the authenticated member's identity, admin role, and credential setup status.
|
||||||
|
|
||||||
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`).
|
Display name is derived from OIDC claims in priority order: `name` → `preferred_username` → `email` → `sub`. The user row is upserted on first OIDC visit (keyed on `oidc_iss` + `oidc_sub`).
|
||||||
|
|
||||||
|
`isAdmin` is exposed for PWA navigation gating only — it is not the security boundary. The server enforces admin role via `requireAdmin` middleware on every `/api/admin/*` request.
|
||||||
|
|
||||||
**Response 200**
|
**Response 200**
|
||||||
|
|
||||||
@@ -82,15 +288,96 @@ Display name is derived from OIDC claims in priority order: `name` → `preferre
|
|||||||
"user": {
|
"user": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"displayName": "Lucas",
|
"displayName": "Lucas",
|
||||||
"color": "#4A90D9"
|
"color": "#4A90D9",
|
||||||
|
"isAdmin": true,
|
||||||
|
"needsProviderSetup": false,
|
||||||
|
"hasLocalCredential": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| -------------------- | ------- | ---------------------------------------------------------------- |
|
||||||
|
| `id` | integer | Stable member ID |
|
||||||
|
| `displayName` | string | Derived from OIDC claims or set by admin |
|
||||||
|
| `color` | string | Member's assigned color (hex) |
|
||||||
|
| `isAdmin` | boolean | Whether the member has the admin role |
|
||||||
|
| `needsProviderSetup` | boolean | True when no Fastmail credential is stored for this member |
|
||||||
|
| `hasLocalCredential` | boolean | True when a local username/password credential exists |
|
||||||
|
|
||||||
**Error responses:** `401` if the session is invalid.
|
**Error responses:** `401` if the session is invalid.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### `POST /api/me/credential`
|
||||||
|
|
||||||
|
Member self-service endpoint to set or rotate their own Fastmail CalDAV app password. Validates against CalDAV (PROPFIND) before storing. Always writes to the authenticated member's record — the request body cannot specify a different user ID.
|
||||||
|
|
||||||
|
**Request body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providerType": "caldav",
|
||||||
|
"fastmailEmail": "member@fastmail.com",
|
||||||
|
"appPassword": "xxxx-xxxx-xxxx-xxxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Constraints |
|
||||||
|
| --------------- | ------ | -------- | ---------------- |
|
||||||
|
| `providerType` | string | Yes | Must be `"caldav"` |
|
||||||
|
| `fastmailEmail` | string | Yes | Valid email, max 256 characters |
|
||||||
|
| `appPassword` | string | Yes | 1–500 characters |
|
||||||
|
|
||||||
|
**Response 200** — `{ "ok": true }`
|
||||||
|
|
||||||
|
**Error responses:** `400` invalid request or CalDAV validation failed; `401` unauthorized; `503` service unavailable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/me/password`
|
||||||
|
|
||||||
|
Self-service password change for local-auth members. Requires the current password to be supplied. Returns `403` (not `401`) for a wrong current password to avoid triggering the PWA's global session-expiry handler.
|
||||||
|
|
||||||
|
**Request body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"currentPassword": "old-password",
|
||||||
|
"newPassword": "new-password-min8"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Constraints |
|
||||||
|
| ----------------- | ------ | -------- | ---------------- |
|
||||||
|
| `currentPassword` | string | Yes | 1+ characters |
|
||||||
|
| `newPassword` | string | Yes | Minimum 8 characters |
|
||||||
|
|
||||||
|
**Response 200** — `{ "ok": true }`
|
||||||
|
|
||||||
|
**Error responses:** `400` invalid request; `401` unauthorized; `403` current password incorrect; `404` no local credential found; `503` service unavailable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/me/link-oidc`
|
||||||
|
|
||||||
|
Initiates the OIDC authorization-code flow for a locally-authenticated member. Returns a signed `state` JWT and the OIDC authorization URL. The PWA redirects the user to the authorization URL; on successful OIDC login, the `/callback` handler binds the OIDC identity to the local user and removes the local credential.
|
||||||
|
|
||||||
|
Returns `authorizationUrl: null` when OIDC is not configured.
|
||||||
|
|
||||||
|
**Response 200**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"signedState": "eyJ...",
|
||||||
|
"authorizationUrl": "https://auth.example.com/api/oidc/authorization?..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error responses:** `401` unauthorized; `503` service unavailable (missing `LOCAL_SESSION_SECRET`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Calendar Events
|
## 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.
|
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.
|
||||||
@@ -615,6 +902,201 @@ Removes all push subscription rows for the authenticated member. Cannot affect a
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Admin
|
||||||
|
|
||||||
|
All `/api/admin/*` routes require the authenticated member to have the admin role (`users.isAdmin = true`). The `requireAdmin` middleware is the first statement on the admin router — no sub-route is reachable without passing this guard. Non-admins receive `403`.
|
||||||
|
|
||||||
|
### `GET /api/admin/members`
|
||||||
|
|
||||||
|
Returns all household members with their credential and local-auth status.
|
||||||
|
|
||||||
|
**Response 200**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"members": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"displayName": "Lucas",
|
||||||
|
"color": "#4A90D9",
|
||||||
|
"hasCredential": true,
|
||||||
|
"hasLocalCredential": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/admin/members`
|
||||||
|
|
||||||
|
Creates a new local-auth member: inserts a `users` row and a `local_credentials` row with a hashed initial password in a single transaction. Returns `409` if the username is already in use.
|
||||||
|
|
||||||
|
**Request body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"displayName": "Alice",
|
||||||
|
"username": "alice",
|
||||||
|
"initialPassword": "minimum8chars"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Constraints |
|
||||||
|
| ----------------- | ------ | -------- | ---------------- |
|
||||||
|
| `displayName` | string | Yes | 1–256 characters |
|
||||||
|
| `username` | string | Yes | 1–128 characters |
|
||||||
|
| `initialPassword` | string | Yes | Minimum 8 characters |
|
||||||
|
|
||||||
|
Zod validation errors never echo received values (the initial password is never included in error responses).
|
||||||
|
|
||||||
|
**Response 201**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": 2 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error responses:** `400` invalid request; `403` not admin; `409` username already in use; `503` service unavailable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/admin/members/:id/password`
|
||||||
|
|
||||||
|
Admin resets a local member's password without requiring the current password. Also clears any active rate-limit or lockout state for the member's username.
|
||||||
|
|
||||||
|
**Path parameter:** `id` — integer member ID.
|
||||||
|
|
||||||
|
**Request body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "newPassword": "minimum8chars" }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Constraints |
|
||||||
|
| ------------- | ------ | -------- | -------------------- |
|
||||||
|
| `newPassword` | string | Yes | Minimum 8 characters |
|
||||||
|
|
||||||
|
**Response 200** — `{ "ok": true }`
|
||||||
|
|
||||||
|
**Error responses:** `400` invalid request; `403` not admin; `404` member not found or has no local credential; `503` service unavailable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/admin/credentials`
|
||||||
|
|
||||||
|
Validates and stores a Fastmail CalDAV app password for any household member. Performs a PROPFIND against Fastmail CalDAV to verify the credential before encrypting and persisting it.
|
||||||
|
|
||||||
|
**Request body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": 2,
|
||||||
|
"providerType": "caldav",
|
||||||
|
"fastmailEmail": "member@fastmail.com",
|
||||||
|
"appPassword": "xxxx-xxxx-xxxx-xxxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Constraints |
|
||||||
|
| --------------- | ------- | -------- | ---------------- |
|
||||||
|
| `userId` | integer | Yes | Positive integer |
|
||||||
|
| `providerType` | string | Yes | Must be `"caldav"` |
|
||||||
|
| `fastmailEmail` | string | Yes | Valid email, max 256 characters |
|
||||||
|
| `appPassword` | string | Yes | 1–500 characters |
|
||||||
|
|
||||||
|
Zod validation errors and CalDAV validation failures return `400` with `{ "error": "Invalid request" }` — the app password is never echoed.
|
||||||
|
|
||||||
|
**Response 200** — `{ "ok": true }`
|
||||||
|
|
||||||
|
**Error responses:** `400` invalid request or CalDAV validation failed; `403` not admin; `503` service unavailable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/admin/calendars`
|
||||||
|
|
||||||
|
Lists all synced calendars with their shared-calendar designation.
|
||||||
|
|
||||||
|
**Response 200**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"calendars": [
|
||||||
|
{ "id": 1, "displayName": "Personal", "isShared": false },
|
||||||
|
{ "id": 2, "displayName": "Family", "isShared": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `PUT /api/admin/calendars/:id/shared`
|
||||||
|
|
||||||
|
Exclusively designates one calendar as the household shared calendar. Clears `isShared` on any previously-shared calendar in the same transaction. Returns `404` if the target calendar does not exist.
|
||||||
|
|
||||||
|
**Path parameter:** `id` — integer calendar ID.
|
||||||
|
|
||||||
|
**Response 200** — `{ "ok": true }`
|
||||||
|
|
||||||
|
**Error responses:** `400` invalid calendar ID; `403` not admin; `404` calendar not found.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/admin/config/timezone`
|
||||||
|
|
||||||
|
Returns the household IANA timezone and whether it has been explicitly configured (vs. using the system default fallback).
|
||||||
|
|
||||||
|
**Response 200**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "timezone": "America/Toronto", "isExplicitlySet": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
`isExplicitlySet: false` when no `household_timezone` row exists in `app_config`; `timezone` still contains the resolved fallback value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `PUT /api/admin/config/timezone`
|
||||||
|
|
||||||
|
Validates and upserts the household IANA timezone into `app_config`.
|
||||||
|
|
||||||
|
**Request body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "timezone": "America/Toronto" }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Constraints |
|
||||||
|
| ---------- | ------ | -------- | -------------------------- |
|
||||||
|
| `timezone` | string | Yes | Valid IANA timezone, 1–64 characters |
|
||||||
|
|
||||||
|
**Response 200** — `{ "ok": true }`
|
||||||
|
|
||||||
|
**Error responses:** `400` invalid timezone; `403` not admin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/admin/config/timezone/seed`
|
||||||
|
|
||||||
|
Seeds the `household_timezone` key in `app_config` **only when it is not already set** (no-overwrite). Used by the setup wizard and browser timezone auto-detect to store the detected zone without clobbering an admin's explicit choice. Uses `INSERT IGNORE` so the operation is safe under concurrent requests.
|
||||||
|
|
||||||
|
**Request body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "timezone": "America/Toronto" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "seeded": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
`seeded: true` when the row was inserted; `seeded: false` when it already existed (no change made).
|
||||||
|
|
||||||
|
**Error responses:** `400` invalid timezone; `403` not admin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Error Codes
|
## Error Codes
|
||||||
|
|
||||||
All error responses use a consistent JSON envelope.
|
All error responses use a consistent JSON envelope.
|
||||||
@@ -627,15 +1109,18 @@ All error responses use a consistent JSON envelope.
|
|||||||
| ----------- | ------------------------------------------------------------------------------ |
|
| ----------- | ------------------------------------------------------------------------------ |
|
||||||
| `400` | Invalid request parameters (e.g., malformed date window) |
|
| `400` | Invalid request parameters (e.g., malformed date window) |
|
||||||
| `401` | Session missing or invalid |
|
| `401` | Session missing or invalid |
|
||||||
| `403` | Authenticated but not authorized (wrong owner, sharee attempted owner-only op) |
|
| `403` | Authenticated but not authorized (wrong owner, sharee attempted owner-only op, non-admin on admin route) |
|
||||||
| `404` | Resource not found |
|
| `404` | Resource not found |
|
||||||
|
| `409` | Conflict (e.g., duplicate username) |
|
||||||
| `422` | Valid request but cannot be fulfilled (e.g., user has no calendar configured) |
|
| `422` | Valid request but cannot be fulfilled (e.g., user has no calendar configured) |
|
||||||
|
| `423` | Locked (setup already complete, or account locked after too many failed logins)|
|
||||||
|
| `429` | Too many requests (login rate limit exceeded for this username) |
|
||||||
| `503` | DB or downstream service unavailable |
|
| `503` | DB or downstream service unavailable |
|
||||||
|
|
||||||
Zod validation failures return `400` with a structured body from `@hono/zod-validator` rather than the `{ "error": "..." }` envelope.
|
Zod validation failures return `400` with a structured body from `@hono/zod-validator` rather than the `{ "error": "..." }` envelope. Exception: credential and password routes use a `noEchoHook` that always returns `{ "error": "Invalid request" }` to prevent echoing submitted secrets in error details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Rate Limits
|
## 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 -->
|
No rate limiting is configured in the application layer for general API routes. Local auth login is rate-limited per-username: 5 failures within 60 seconds returns `429`; 10 cumulative failures locks the account with `423` for 15 minutes. See `POST /api/auth/local/login` for details. <!-- VERIFY: confirm whether Pangolin/Newt or Authelia enforce rate limits at the network edge -->
|
||||||
|
|||||||
+46
-15
@@ -8,7 +8,7 @@ FamilySync is a self-hosted family organization hub — a unified, color-coded c
|
|||||||
|
|
||||||
## System Overview
|
## 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.
|
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 (local username/password and/or Authelia OIDC), calendar read/write via CalDAV (Fastmail), list persistence (MariaDB), and real-time push delivery.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
@@ -18,8 +18,8 @@ graph TD
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph "API (apps/api — Hono on Node 22)"
|
subgraph "API (apps/api — Hono on Node 22)"
|
||||||
AUTH["OIDC Auth\n(@hono/oidc-auth)"]
|
AUTH["Auth Layer\n(local session + OIDC middleware)"]
|
||||||
ROUTES["API Routes\n/events /lists /me /push /sse"]
|
ROUTES["API Routes\n/events /lists /me /push /sse\n/admin /setup /auth"]
|
||||||
BROKER["CalDAV Broker\n(tsdav + ical.js + rrule)"]
|
BROKER["CalDAV Broker\n(tsdav + ical.js + rrule)"]
|
||||||
OUTBOX["Outbox Worker\n(15s drain loop)"]
|
OUTBOX["Outbox Worker\n(15s drain loop)"]
|
||||||
POLLER["CalDAV Poller\n(5-min setInterval)"]
|
POLLER["CalDAV Poller\n(5-min setInterval)"]
|
||||||
@@ -33,7 +33,7 @@ graph TD
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph "External Services"
|
subgraph "External Services"
|
||||||
AUTHELIA["Authelia\n(OIDC / OAuth2 IdP)"]
|
AUTHELIA["Authelia\n(OIDC / OAuth2 IdP — optional)"]
|
||||||
FASTMAIL["Fastmail CalDAV\ncaldav.fastmail.com"]
|
FASTMAIL["Fastmail CalDAV\ncaldav.fastmail.com"]
|
||||||
PUSH_SVC["Browser Push Services\n(APNs / FCM)"]
|
PUSH_SVC["Browser Push Services\n(APNs / FCM)"]
|
||||||
end
|
end
|
||||||
@@ -41,7 +41,7 @@ graph TD
|
|||||||
PWA -- "HTTPS (same-origin via Pangolin)" --> ROUTES
|
PWA -- "HTTPS (same-origin via Pangolin)" --> ROUTES
|
||||||
SW -- "push events" --> PWA
|
SW -- "push events" --> PWA
|
||||||
ROUTES --> AUTH
|
ROUTES --> AUTH
|
||||||
AUTH -- "authorization-code + PKCE" --> AUTHELIA
|
AUTH -- "authorization-code + PKCE (when oidcEnabled)" --> AUTHELIA
|
||||||
ROUTES --> BROKER
|
ROUTES --> BROKER
|
||||||
ROUTES --> SSE_LIB
|
ROUTES --> SSE_LIB
|
||||||
ROUTES --> DB
|
ROUTES --> DB
|
||||||
@@ -68,7 +68,7 @@ familysync/
|
|||||||
│ │ └── src/
|
│ │ └── src/
|
||||||
│ │ ├── index.ts # App entry: mounts routes, starts background workers
|
│ │ ├── index.ts # App entry: mounts routes, starts background workers
|
||||||
│ │ ├── routes/ # HTTP route handlers
|
│ │ ├── routes/ # HTTP route handlers
|
||||||
│ │ ├── auth/ # OIDC middleware + dev-bypass + session persistence
|
│ │ ├── auth/ # OIDC middleware + local auth + dev-bypass + session persistence
|
||||||
│ │ ├── broker/ # CalDAV integration layer
|
│ │ ├── broker/ # CalDAV integration layer
|
||||||
│ │ ├── db/ # Drizzle schema, client, migrations
|
│ │ ├── db/ # Drizzle schema, client, migrations
|
||||||
│ │ └── lib/ # Shared dispatchers and utilities
|
│ │ └── lib/ # Shared dispatchers and utilities
|
||||||
@@ -90,12 +90,12 @@ familysync/
|
|||||||
|
|
||||||
| Directory | Purpose |
|
| 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/routes/` | One file per resource — `events.ts`, `lists.ts`, `me.ts`, `push.ts`, `sse.ts`, `health.ts`, `admin.ts`, `setup.ts`, `authMode.ts`, `localAuth.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/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/auth/` | `middleware.ts` (re-exports `@hono/oidc-auth`), `devBypass.ts` (DEV_AUTH_BYPASS inject), `localAuthMiddleware.ts` (local-session cookie → user), `localCredentials.ts` (scrypt hash/verify), `localSession.ts` (JWT cookie issue/verify/clear), `oidcConfig.ts` (env+DB fallback for OIDC config), `linkNonceStore.ts` (single-use OIDC-link nonces), `linkOidc.ts` (bind OIDC identity to local user), `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/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/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), `bootGuards.ts` (startup safety assertions), `requireAdmin.ts` (admin-role guard), `setupGuard.ts` (isSetupLocked check) |
|
||||||
| `apps/pwa/src/api/` | Thin typed fetch wrappers — `client.ts` (events, me, sync-status), `listsClient.ts` (lists and items) |
|
| `apps/pwa/src/api/` | Thin typed fetch wrappers — `client.ts` (events, me, sync-status, auth-mode, local login/logout), `listsClient.ts` (lists and items) |
|
||||||
| `apps/pwa/src/store/` | `calendarStore.ts` and `listsStore.ts` — Zustand UI-only state (no server data) |
|
| `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) |
|
| `apps/pwa/src/hooks/` | `useListSSE.ts` (bounded-backoff EventSource), `usePushSubscription.ts` (VAPID subscribe) |
|
||||||
|
|
||||||
@@ -104,9 +104,9 @@ familysync/
|
|||||||
## Key Abstractions
|
## Key Abstractions
|
||||||
|
|
||||||
| Abstraction | File | Description |
|
| Abstraction | File | Description |
|
||||||
| ------------------------------------------ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------ | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `app` (Hono) | `apps/api/src/index.ts` | Root Hono app; mounts all routes and serves the PWA static build |
|
| `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`) |
|
| Drizzle schema | `apps/api/src/db/schema.ts` | Single source of truth for all table definitions (`users`, `memberCredentials`, `localCredentials`, `calendars`, `calendarEvents`, `calendarOutbox`, `lists`, `listShares`, `listItems`, `pushSubscriptions`, `appConfig`) |
|
||||||
| `syncCalendar` | `apps/api/src/broker/sync.ts` | REPORT → ical.js parse → `onDuplicateKeyUpdate` upsert into MariaDB |
|
| `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 |
|
| `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` |
|
| `CalendarOccurrence` | `apps/api/src/broker/expand.ts` | Wire type for a single concrete event occurrence; mirrored in the PWA's `api/client.ts` |
|
||||||
@@ -114,6 +114,12 @@ familysync/
|
|||||||
| `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 |
|
| `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 |
|
| `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 |
|
| `dispatchPush` | `apps/api/src/lib/pushDispatcher.ts` | Centralised VAPID-signed push sender; handles 410/404 subscription pruning |
|
||||||
|
| `issueLocalSessionCookie` / `verifyLocalSessionCookie` | `apps/api/src/auth/localSession.ts` | Issues and verifies the `local-session` JWT cookie used by local username/password auth |
|
||||||
|
| `localAuthMiddleware` | `apps/api/src/auth/localAuthMiddleware.ts` | Reads `local-session` cookie → populates `c.get('user')`; no-op passthrough when cookie absent (OIDC guard fires for unauthenticated requests) |
|
||||||
|
| `linkOidcToUser` / `OidcLinkConflictError` | `apps/api/src/auth/linkOidc.ts` | Binds an OIDC iss+sub to an existing local user; throws `OidcLinkConflictError` on identity collision |
|
||||||
|
| `localCredentials` table | `apps/api/src/db/schema.ts` | Per-member local login credentials (scrypt PHC hash); a row exists iff the member can log in with username/password |
|
||||||
|
| `appConfig` table | `apps/api/src/db/schema.ts` | Key/value store for setup wizard output (OIDC config, VAPID public key, setup_complete flag) |
|
||||||
|
| `isSetupLocked` | `apps/api/src/lib/setupGuard.ts` | Returns true when the first-run wizard is complete; setup mutation routes call this as their first guard |
|
||||||
| `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 |
|
| `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 |
|
| Zustand stores | `apps/pwa/src/store/` | UI-only ephemeral state (open panels, selected date, active tab); server state always in TanStack Query |
|
||||||
|
|
||||||
@@ -156,11 +162,23 @@ familysync/
|
|||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
|
The app supports two auth modes, selectable per-deployment and per-user. `GET /api/auth/mode` (pre-auth) tells the PWA which modes are active.
|
||||||
|
|
||||||
|
**Local auth path (Phase 19):**
|
||||||
|
|
||||||
|
1. The PWA fetches `GET /api/auth/mode`; when `localEnabled === true` it renders `/login` (`LoginPage`).
|
||||||
|
2. The user submits credentials; the PWA calls `POST /api/auth/local/login`.
|
||||||
|
3. The route verifies the scrypt hash from `local_credentials`, then calls `issueLocalSessionCookie` — a signed HS256 JWT issued as a `local-session` HttpOnly cookie.
|
||||||
|
4. On subsequent requests, `localAuthMiddleware` reads the cookie, verifies the JWT, fetches the users row, and populates `c.get('user')`. The OIDC guard is skipped when `c.get('user')` is already set.
|
||||||
|
5. A local user can optionally link an OIDC identity via `POST /api/me/link-oidc`; on completion `linkOidcToUser` binds `oidc_iss`/`oidc_sub` to the users row and deletes the `local_credentials` row, converting the account to OIDC-only.
|
||||||
|
|
||||||
|
**OIDC path (Authelia):**
|
||||||
|
|
||||||
1. An unauthenticated browser navigates to `/api/login`.
|
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).
|
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.
|
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.
|
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`.
|
5. All `/api/*` routes require a session (local or OIDC); a missing or expired session returns a `302` which the PWA's fetch wrappers detect as `opaqueredirect` and convert to a `SessionExpiredError`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -185,6 +203,16 @@ routes/lists.ts ──→ db (MariaDB)
|
|||||||
|
|
||||||
routes/sse.ts ──→ lib/listEmitter.ts (subscribeListEvents)
|
routes/sse.ts ──→ lib/listEmitter.ts (subscribeListEvents)
|
||||||
──→ lib/listAccess.ts
|
──→ lib/listAccess.ts
|
||||||
|
|
||||||
|
routes/localAuth.ts ──→ auth/localCredentials.ts (scrypt verify)
|
||||||
|
──→ auth/localSession.ts (issue cookie)
|
||||||
|
|
||||||
|
routes/admin.ts ──→ db (MariaDB: users, local_credentials, calendars)
|
||||||
|
──→ broker/credentialSync.ts (validate+encrypt+store)
|
||||||
|
──→ lib/requireAdmin.ts (role guard)
|
||||||
|
|
||||||
|
routes/setup.ts ──→ db (app_config)
|
||||||
|
──→ lib/setupGuard.ts (isSetupLocked)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend data ownership
|
### Frontend data ownership
|
||||||
@@ -196,6 +224,8 @@ routes/sse.ts ──→ lib/listEmitter.ts (subscribeListEvents)
|
|||||||
| Current user | TanStack Query `['me']` |
|
| Current user | TanStack Query `['me']` |
|
||||||
| Writable calendars | TanStack Query `['writableCalendars']` |
|
| Writable calendars | TanStack Query `['writableCalendars']` |
|
||||||
| Outbox sync status | TanStack Query `['syncStatus', uid]` |
|
| Outbox sync status | TanStack Query `['syncStatus', uid]` |
|
||||||
|
| Auth mode (local/OIDC flags) | TanStack Query `['authMode']` |
|
||||||
|
| Setup completion status | TanStack Query `['setupStatus']` |
|
||||||
| Selected calendar view + date | Zustand `calendarStore` |
|
| Selected calendar view + date | Zustand `calendarStore` |
|
||||||
| Event form open/mode | Zustand `calendarStore` |
|
| Event form open/mode | Zustand `calendarStore` |
|
||||||
| Active tab, create-list sheet | Zustand `listsStore` |
|
| Active tab, create-list sheet | Zustand `listsStore` |
|
||||||
@@ -210,11 +240,12 @@ routes/sse.ts ──→ lib/listEmitter.ts (subscribeListEvents)
|
|||||||
| HTTP framework | Hono 4.x (`@hono/node-server`) |
|
| HTTP framework | Hono 4.x (`@hono/node-server`) |
|
||||||
| Database | MariaDB 11 (Docker volume) |
|
| Database | MariaDB 11 (Docker volume) |
|
||||||
| ORM | Drizzle ORM 0.45.x (`mysql2` dialect) |
|
| ORM | Drizzle ORM 0.45.x (`mysql2` dialect) |
|
||||||
| Auth IdP | Authelia (pre-deployed, external) — OIDC authorization code + PKCE |
|
| Auth IdP | Authelia (pre-deployed, external) — OIDC authorization code + PKCE; optional when local auth is enabled |
|
||||||
| Session middleware | `@hono/oidc-auth` — storage-less signed JWT cookies |
|
| Session middleware | `@hono/oidc-auth` (OIDC session — storage-less signed JWT cookies) + custom `localSession.ts` (local-auth HS256 JWT cookie) |
|
||||||
| Calendar source | Fastmail CalDAV (`caldav.fastmail.com`) — read via `tsdav`, write via transactional outbox |
|
| Calendar source | Fastmail CalDAV (`caldav.fastmail.com`) — read via `tsdav`, write via transactional outbox |
|
||||||
| Calendar parsing | `ical.js` (VCALENDAR/VEVENT parse) + `rrule` (RRULE expansion) |
|
| Calendar parsing | `ical.js` (VCALENDAR/VEVENT parse) + `rrule` (RRULE expansion) |
|
||||||
| App password storage | AES-256-GCM encrypted in `member_credentials.encrypted_password` |
|
| App password storage | AES-256-GCM encrypted in `member_credentials.encrypted_password` |
|
||||||
|
| Local auth storage | scrypt PHC hash in `local_credentials.password_hash`; session signed with `LOCAL_SESSION_SECRET` env var |
|
||||||
| Push notifications | `web-push` (VAPID) → APNs (iOS) / FCM (Android) |
|
| Push notifications | `web-push` (VAPID) → APNs (iOS) / FCM (Android) |
|
||||||
| Live list sync | In-process Node.js `EventEmitter` → SSE (`text/event-stream`) |
|
| 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) |
|
| Redis | Present in stack (image: `redis:7-alpine`); not used in current runtime (reserved for future multi-process pub/sub) |
|
||||||
|
|||||||
+37
-2
@@ -18,8 +18,9 @@ All runtime configuration is supplied via environment variables. There are no JS
|
|||||||
| `DB_PASSWORD` | **Required** | _(none)_ | Database password. Also used by the `mariadb` service as `MARIADB_PASSWORD`. |
|
| `DB_PASSWORD` | **Required** | _(none)_ | Database password. Also used by the `mariadb` service as `MARIADB_PASSWORD`. |
|
||||||
| `DB_NAME` | No | `familysync` | Database name. |
|
| `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. |
|
| `DB_ROOT_PASSWORD` | **Required** | _(none)_ | MariaDB root password. Used only by the `mariadb` Docker service (`MARIADB_ROOT_PASSWORD`). Not read by the API process. |
|
||||||
|
| `DB_ROOT_USER` | No | `root` | MariaDB root username. Read only by `apps/api/test/global-setup.ts` during local test provisioning. Never used by the API or Docker Compose in production. |
|
||||||
|
|
||||||
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.
|
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. `DB_ROOT_USER` is only used by the local Vitest global setup to create and grant the `familysync_test` database.
|
||||||
|
|
||||||
**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`.
|
**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`.
|
||||||
|
|
||||||
@@ -50,6 +51,19 @@ Five of these variables — `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Local Authentication (No-OIDC Mode)
|
||||||
|
|
||||||
|
These variables govern the stateless local-auth path introduced in Phase 19. Local auth issues a separate `local-session` JWT cookie (distinct from `oidc-auth`) signed with `LOCAL_SESSION_SECRET`.
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
| ----------------------- | ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `LOCAL_SESSION_SECRET` | **Required** (non-bypass) | _(none)_ | 32+ character secret used to sign and verify `local-session` JWT cookies (HS256). Generate with `openssl rand -base64 32`. The API refuses to start with a fatal error if this is absent or shorter than 32 characters, unless `DEV_AUTH_BYPASS=true`. |
|
||||||
|
| `LOCAL_SESSION_EXPIRES` | No | `86400` | `local-session` cookie `Max-Age` in seconds (default 1 day). Mirrors `OIDC_AUTH_EXPIRES` but applies to the local-auth cookie. Malformed (non-numeric) values silently fall back to the default. Source: `apps/api/src/auth/localSession.ts`. |
|
||||||
|
|
||||||
|
**Security note:** `LOCAL_SESSION_SECRET` must be a distinct value from `OIDC_AUTH_SECRET`. Both are JWT signing keys, but they govern different cookies and must not be shared.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Broker Encryption
|
### Broker Encryption
|
||||||
|
|
||||||
| Variable | Required | Default | Description |
|
| Variable | Required | Default | Description |
|
||||||
@@ -80,6 +94,19 @@ npx web-push generate-vapid-keys --json
|
|||||||
| ----------------- | -------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------- | -------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `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`. |
|
| `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. |
|
| `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. |
|
||||||
|
| `TZ` | No | _(not set)_ | IANA timezone identifier (e.g. `America/Toronto`) used as the server-side fallback for the household timezone when no value is stored in `app_config`. The full fallback chain is: stored DB value → `TZ` env → `Intl.DateTimeFormat().resolvedOptions().timeZone`. Empty or whitespace values are ignored. Source: `apps/api/src/lib/householdTimezone.ts`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Developer / Test-Only Variables
|
||||||
|
|
||||||
|
These variables are never needed in production and should not appear in the production `.env`.
|
||||||
|
|
||||||
|
| Variable | Scope | Default | Description |
|
||||||
|
| ----------------------- | ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `FASTMAIL_EMAIL` | Dev spike script only | _(none)_ | Fastmail account email. Read only by `apps/api/src/broker/spike.ts`, a standalone dev script for enumerating CalDAV collections. Not imported by the API or Docker image. |
|
||||||
|
| `FASTMAIL_APP_PASSWORD` | Dev spike script only | _(none)_ | Fastmail app password. Read only by `apps/api/src/broker/spike.ts`. **Never logged.** Not used by the API in any environment. |
|
||||||
|
| `PLAYWRIGHT_BASE_URL` | E2E tests only | `http://localhost:5173` | Base URL for Playwright e2e tests. Overridden to `http://127.0.0.1:5173` in CI to avoid IPv6 resolution failures. Source: `apps/pwa/playwright.config.ts`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -88,7 +115,7 @@ npx web-push generate-vapid-keys --json
|
|||||||
Variables with non-empty defaults do not cause startup failure if absent, but should be reviewed for production:
|
Variables with non-empty defaults do not cause startup failure if absent, but should be reviewed for production:
|
||||||
|
|
||||||
| Variable | Default | Source |
|
| Variable | Default | Source |
|
||||||
| ------------------- | ------------------------------------- | ------------------------------------------- |
|
| ----------------------- | ------------------------------------- | ------------------------------------------- |
|
||||||
| `DB_HOST` | `localhost` | `apps/api/src/db/client.ts` |
|
| `DB_HOST` | `localhost` | `apps/api/src/db/client.ts` |
|
||||||
| `DB_PORT` | `3306` | `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_USER` | `familysync` | `apps/api/src/db/client.ts` |
|
||||||
@@ -98,6 +125,7 @@ Variables with non-empty defaults do not cause startup failure if absent, but sh
|
|||||||
| `OIDC_AUTH_EXPIRES` | `86400` | `apps/api/src/auth/persistSessionCookie.ts` |
|
| `OIDC_AUTH_EXPIRES` | `86400` | `apps/api/src/auth/persistSessionCookie.ts` |
|
||||||
| `OIDC_COOKIE_NAME` | `oidc-auth` | `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` |
|
| `OIDC_COOKIE_PATH` | `/` | `apps/api/src/auth/persistSessionCookie.ts` |
|
||||||
|
| `LOCAL_SESSION_EXPIRES` | `86400` | `apps/api/src/auth/localSession.ts` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -121,6 +149,9 @@ OIDC_CLIENT_SECRET=<plaintext secret>
|
|||||||
OIDC_REDIRECT_URI=https://familysync.DOMAIN/callback
|
OIDC_REDIRECT_URI=https://familysync.DOMAIN/callback
|
||||||
OIDC_AUTH_EXTERNAL_URL=https://familysync.DOMAIN
|
OIDC_AUTH_EXTERNAL_URL=https://familysync.DOMAIN
|
||||||
|
|
||||||
|
# Local auth (Phase 19)
|
||||||
|
LOCAL_SESSION_SECRET=<openssl rand -base64 32>
|
||||||
|
|
||||||
# Broker encryption
|
# Broker encryption
|
||||||
APP_PASSWORD_ENCRYPTION_KEY=<64-hex-chars>
|
APP_PASSWORD_ENCRYPTION_KEY=<64-hex-chars>
|
||||||
|
|
||||||
@@ -151,6 +182,8 @@ The `DB_HOST` override is necessary because `.env` sets `DB_HOST=mariadb` (the D
|
|||||||
|
|
||||||
**`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.
|
**`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.
|
||||||
|
|
||||||
|
The dev Docker Compose (`docker-compose.dev.yml`) sets `LOCAL_SESSION_SECRET` to a fixed dev placeholder value (`dev-secret-change-me-0000000000000000`). This value is intentionally weak and public — it is never used in production.
|
||||||
|
|
||||||
### Test
|
### Test
|
||||||
|
|
||||||
Integration tests targeting the real database require the dev MariaDB running with the host port exposed and the following overrides:
|
Integration tests targeting the real database require the dev MariaDB running with the host port exposed and the following overrides:
|
||||||
@@ -159,6 +192,8 @@ Integration tests targeting the real database require the dev MariaDB running wi
|
|||||||
DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=familysync DB_NAME=familysync DB_PASSWORD=<value>
|
DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=familysync DB_NAME=familysync DB_PASSWORD=<value>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The Vitest global setup (`apps/api/test/global-setup.ts`) also reads `DB_ROOT_USER` (default `root`) and `DB_ROOT_PASSWORD` (default `root`) to create and grant the `familysync_test` database on first run. These are local dev credentials only; CI uses hardcoded throwaway values (`familysync` / `testpass`) in ephemeral service containers.
|
||||||
|
|
||||||
See `docs/deployment.md` for the full `drizzle-kit migrate` command used to prepare the test database.
|
See `docs/deployment.md` for the full `drizzle-kit migrate` command used to prepare the test database.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+16
-10
@@ -101,7 +101,7 @@ Vite serves the PWA with HMR on the configured dev port. The PWA's API calls tar
|
|||||||
### Root workspace scripts
|
### Root workspace scripts
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------------------- | ------------------------------------------------------------- |
|
| ------------------------ | ------------------------------------------------------------- |
|
||||||
| `pnpm dev:api` | Start API dev watcher (`node --watch dist/index.js`) |
|
| `pnpm dev:api` | Start API dev watcher (`node --watch dist/index.js`) |
|
||||||
| `pnpm dev:pwa` | Start Vite dev server for the PWA |
|
| `pnpm dev:pwa` | Start Vite dev server for the PWA |
|
||||||
| `pnpm build` | Build both `apps/api` (tsc) and `apps/pwa` (tsc + vite build) |
|
| `pnpm build` | Build both `apps/api` (tsc) and `apps/pwa` (tsc + vite build) |
|
||||||
@@ -111,6 +111,8 @@ Vite serves the PWA with HMR on the configured dev port. The PWA's API calls tar
|
|||||||
| `pnpm format` | Reformat all files with Prettier (`prettier --write .`) |
|
| `pnpm format` | Reformat all files with Prettier (`prettier --write .`) |
|
||||||
| `pnpm format:check` | Check formatting without writing (`prettier --check .`) |
|
| `pnpm format:check` | Check formatting without writing (`prettier --check .`) |
|
||||||
| `pnpm typecheck` | `tsc --noEmit` in all workspaces |
|
| `pnpm typecheck` | `tsc --noEmit` in all workspaces |
|
||||||
|
| `pnpm md:lint` | Markdown lint (`markdownlint-cli2`) across the repo |
|
||||||
|
| `pnpm generate-secrets` | Generate VAPID and session secret values via `scripts/generate-secrets.mjs` |
|
||||||
|
|
||||||
### `apps/api` scripts
|
### `apps/api` scripts
|
||||||
|
|
||||||
@@ -148,14 +150,16 @@ CI gates every PR to `main` on these checks. Run them locally before pushing to
|
|||||||
pnpm lint # ESLint --max-warnings 0 across apps/api (src/ + tests/) and apps/pwa (src/ + e2e/)
|
pnpm lint # ESLint --max-warnings 0 across apps/api (src/ + tests/) and apps/pwa (src/ + e2e/)
|
||||||
pnpm format:check # Prettier formatting check (use `pnpm format` to auto-fix)
|
pnpm format:check # Prettier formatting check (use `pnpm format` to auto-fix)
|
||||||
pnpm typecheck # tsc --noEmit in both apps (includes apps/pwa tsconfig.e2e.json)
|
pnpm typecheck # tsc --noEmit in both apps (includes apps/pwa tsconfig.e2e.json)
|
||||||
|
pnpm md:lint # Markdown lint (also runs in CI fast-checks)
|
||||||
```
|
```
|
||||||
|
|
||||||
### ESLint
|
### ESLint
|
||||||
|
|
||||||
Config: `eslint.config.js` (root, flat ESLint 9 format). The config covers:
|
Config: `eslint.config.js` (root, flat ESLint 9 format). The config covers:
|
||||||
|
|
||||||
- **All `apps/**/\*.{ts,tsx}`** — `js.configs.recommended`+`tseslint.configs.recommendedTypeChecked`with`projectService: true`(type-aware rules, auto-discovers all`tsconfig.json` files)
|
- **All `apps/**/*.{ts,tsx}`** — `js.configs.recommended` + `tseslint.configs.recommendedTypeChecked` with `projectService: true` (type-aware rules, auto-discovers all `tsconfig.json` files)
|
||||||
- **`apps/pwa/**/\*.{ts,tsx}`additionally** —`eslint-plugin-react`+`eslint-plugin-react-hooks` (React 19 flat config; React Compiler rules disabled — this codebase does not use the Compiler)
|
- **`apps/pwa/**/*.{ts,tsx}` additionally** — `eslint-plugin-react` + `eslint-plugin-react-hooks` (React 19 flat config; React Compiler rules disabled — this codebase does not use the Compiler)
|
||||||
|
- **All `apps/**/*.{ts,tsx}`** — `eslint-plugin-security` (14 of 15 rules at error; `detect-object-injection` disabled due to high false-positive rate on schema-derived numeric keys)
|
||||||
- **Tool configs + test dirs** (`drizzle.config.ts`, `vitest.config.ts`, `apps/api/tests/**`, `apps/pwa/e2e/**`) — type-aware rules disabled via `disableTypeChecked` (these files are outside the main tsconfig projects)
|
- **Tool configs + test dirs** (`drizzle.config.ts`, `vitest.config.ts`, `apps/api/tests/**`, `apps/pwa/e2e/**`) — type-aware rules disabled via `disableTypeChecked` (these files are outside the main tsconfig projects)
|
||||||
- **Prettier integration** — `eslint-config-prettier` last in the config disables all formatting rules that conflict with Prettier
|
- **Prettier integration** — `eslint-config-prettier` last in the config disables all formatting rules that conflict with Prettier
|
||||||
|
|
||||||
@@ -189,15 +193,17 @@ Run `pnpm typecheck` before opening a PR to catch errors that vitest and Vite bu
|
|||||||
|
|
||||||
## CI Pipeline Overview
|
## CI Pipeline Overview
|
||||||
|
|
||||||
Every PR to `main` runs three parallel jobs (`.gitea/workflows/ci.yml`):
|
Every PR to `main` runs through `.gitea/workflows/ci.yml`. A `changes` path-filter job determines whether code files changed; the `api` and `harness` jobs are skipped entirely for doc-only PRs (changes only to `.planning/**`, `.gitea/**`, or `*.md` files).
|
||||||
|
|
||||||
| Job | Checks |
|
| Job | Runs on | Checks |
|
||||||
| ------------- | ---------------------------------------------------------------------------------------------------- |
|
| ------------- | ------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||||
| `fast-checks` | `pnpm lint` → `pnpm format:check` → `pnpm typecheck` → `pnpm --filter @familysync/pwa test` |
|
| `fast-checks` | Every PR | `pnpm lint` → `pnpm format:check` → `pnpm md:lint` → `pnpm typecheck` → `pnpm --filter @familysync/pwa test` |
|
||||||
| `api` | DB migrations + `pnpm --filter @familysync/api test` (vitest against a MariaDB 11 service container) |
|
| `api` | Code-change PRs only | DB migrations + `pnpm --filter @familysync/api test` (vitest against a MariaDB 11 service container) |
|
||||||
| `harness` | DB migrations + API build + Playwright e2e (WebKit + Chromium) with `DEV_AUTH_BYPASS=true` |
|
| `harness` | Code-change PRs only | DB migrations + seed dev user + API build + Playwright e2e (WebKit + Chromium) with `DEV_AUTH_BYPASS=true` |
|
||||||
|
| `security` | Every PR | Gitleaks secret scan (PR diff); `pnpm audit` (High+Critical blocking) + outdated report on code-change PRs |
|
||||||
|
| `gate` | Always | Final aggregator — requires `fast-checks` and `security` to succeed; `api` and `harness` may be skipped |
|
||||||
|
|
||||||
All three jobs must pass before a PR can merge. See [docs/TESTING.md](TESTING.md) for test suite details.
|
All five jobs must pass (or be legitimately skipped) before a PR can merge. See [docs/TESTING.md](TESTING.md) for test suite details.
|
||||||
|
|
||||||
## Drizzle Migration Workflow
|
## Drizzle Migration Workflow
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -48,15 +48,23 @@ 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:
|
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
|
- `DB_PASSWORD` and `DB_ROOT_PASSWORD` — pick any local passwords
|
||||||
- `APP_PASSWORD_ENCRYPTION_KEY` — 64 hex characters; generate with:
|
- `APP_PASSWORD_ENCRYPTION_KEY`, `SESSION_SECRET`, `LOCAL_SESSION_SECRET`, and VAPID keys — generate all at once with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm generate-secrets
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste the output into your `.env`. Alternatively, generate `APP_PASSWORD_ENCRYPTION_KEY` alone with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
```
|
```
|
||||||
|
|
||||||
- `DEV_AUTH_BYPASS=true` — bypasses the live Authelia OIDC flow for local dev
|
- `DEV_AUTH_BYPASS=true` — bypasses the live Authelia OIDC flow for local dev. When this is set, `LOCAL_SESSION_SECRET` is not required at startup (bypass mode skips the local-auth JWT path entirely).
|
||||||
- `DB_HOST=localhost` — the dev Docker Compose exposes MariaDB on the host at `localhost:3306`
|
- `DB_HOST=localhost` — the dev Docker Compose exposes MariaDB on the host at `localhost:3306`
|
||||||
|
|
||||||
|
> **Note:** If you run without `DEV_AUTH_BYPASS=true` (local-auth mode), `LOCAL_SESSION_SECRET` must be set to a value of at least 32 characters. The API will refuse to start otherwise. `pnpm generate-secrets` always produces a valid value.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## First Run
|
## First Run
|
||||||
@@ -124,6 +132,11 @@ Or set `DB_HOST=localhost` directly in your `.env` for host-side dev.
|
|||||||
**API starts but all requests return 401 / redirect to Authelia**
|
**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`.
|
`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`.
|
||||||
|
|
||||||
|
**`[FATAL] LOCAL_SESSION_SECRET is not set or is shorter than 32 characters`**
|
||||||
|
The API refuses to start in non-bypass mode without a valid `LOCAL_SESSION_SECRET`. Either:
|
||||||
|
- Set `DEV_AUTH_BYPASS=true` in `.env` for local dev (bypass mode exempts the requirement), or
|
||||||
|
- Run `pnpm generate-secrets` and add the generated `LOCAL_SESSION_SECRET` value to `.env`.
|
||||||
|
|
||||||
**PWA shows a blank screen after first load**
|
**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.
|
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.
|
||||||
|
|
||||||
@@ -136,4 +149,5 @@ Another local MySQL/MariaDB service is running. Stop it before starting Docker C
|
|||||||
|
|
||||||
- [docs/ARCHITECTURE.md](ARCHITECTURE.md) — System design, component diagram, data flow
|
- [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/CONFIGURATION.md](CONFIGURATION.md) — All environment variables, defaults, and per-environment guidance
|
||||||
|
- [docs/DEVELOPMENT.md](DEVELOPMENT.md) — Build commands, code style, and contribution workflow
|
||||||
- [docs/deployment.md](deployment.md) — Production deployment on Unraid via Docker Compose
|
- [docs/deployment.md](deployment.md) — Production deployment on Unraid via Docker Compose
|
||||||
|
|||||||
+52
-21
@@ -6,12 +6,14 @@
|
|||||||
|
|
||||||
Both apps use **Vitest** (`^4.1.8`).
|
Both apps use **Vitest** (`^4.1.8`).
|
||||||
|
|
||||||
| App | Environment | Setup file |
|
| App | Environment | Global setup | Per-file setup |
|
||||||
| ---------- | ----------- | ---------------------------- |
|
| ---------- | ----------- | ----------------------------------- | ---------------------------- |
|
||||||
| `apps/api` | `node` | `apps/api/test/setup.ts` |
|
| `apps/api` | `node` | `apps/api/test/global-setup.ts` | `apps/api/test/setup.ts` |
|
||||||
| `apps/pwa` | `jsdom` | `apps/pwa/src/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/api global setup** (`test/global-setup.ts`) runs once before any test file. Locally it provisions an isolated `familysync_test` database (root connection → `CREATE DATABASE IF NOT EXISTS familysync_test` → GRANT → `drizzle migrate`) and then truncates every table to give each run a clean slate. Under CI (`process.env.CI` truthy) it returns immediately — the CI `api` job provisions its own `familysync` service container via `db:migrate`.
|
||||||
|
|
||||||
|
**apps/api per-file setup** (`test/setup.ts`) registers a global `afterEach` that truncates `list_items`, `list_shares`, `push_subscriptions`, `lists`, and `local_credentials` in FK-safe order after every test. This keeps DB-backed integration tests isolated without requiring a full DB reset between runs. The `users` table is intentionally left intact across tests within a single run — many tests seed user id=1 once and reuse it. 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.
|
**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.
|
||||||
|
|
||||||
@@ -49,16 +51,17 @@ pnpm --filter @familysync/api exec vitest run tests/routes/lists.test.ts
|
|||||||
|
|
||||||
### End-to-end tests (Playwright)
|
### End-to-end tests (Playwright)
|
||||||
|
|
||||||
The PWA has a Playwright harness configured in `apps/pwa/playwright.config.ts` with two device profiles:
|
The PWA has a Playwright harness configured in `apps/pwa/playwright.config.ts` with three device profiles:
|
||||||
|
|
||||||
| Profile | Viewport | Engine | User-Agent |
|
| Profile | Viewport | Engine | User-Agent |
|
||||||
| -------- | -------- | -------- | ------------------------- |
|
| --------- | --------- | -------- | ------------------------- |
|
||||||
| `iphone` | 390×844 | WebKit | Mobile Safari (iPhone 14) |
|
| `iphone` | 390×844 | WebKit | Mobile Safari (iPhone 14) |
|
||||||
| `pixel` | 412×915 | Chromium | Chrome Android (Pixel 7) |
|
| `pixel` | 412×915 | Chromium | Chrome Android (Pixel 7) |
|
||||||
|
| `desktop` | 1280×720 | Chromium | Desktop Chrome |
|
||||||
|
|
||||||
Both profiles block the service worker (`serviceWorkers: 'block'`) so the Workbox SW does not intercept requests during tests. Auth is handled via `DEV_AUTH_BYPASS=true` on the API — never via stored browser state.
|
All profiles block the service worker (`serviceWorkers: 'block'`) so the Workbox SW does not intercept requests during tests. Auth is handled via `DEV_AUTH_BYPASS=true` on the API — never via stored browser state.
|
||||||
|
|
||||||
**Run all e2e tests (both profiles):**
|
**Run all e2e tests (all profiles):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm test:e2e
|
pnpm test:e2e
|
||||||
@@ -71,6 +74,7 @@ pnpm --filter @familysync/pwa test:e2e
|
|||||||
```bash
|
```bash
|
||||||
pnpm --filter @familysync/pwa exec playwright test --project=pixel
|
pnpm --filter @familysync/pwa exec playwright test --project=pixel
|
||||||
pnpm --filter @familysync/pwa exec playwright test --project=iphone
|
pnpm --filter @familysync/pwa exec playwright test --project=iphone
|
||||||
|
pnpm --filter @familysync/pwa exec playwright test --project=desktop
|
||||||
```
|
```
|
||||||
|
|
||||||
**Interactive UI mode:**
|
**Interactive UI mode:**
|
||||||
@@ -79,6 +83,12 @@ pnpm --filter @familysync/pwa exec playwright test --project=iphone
|
|||||||
pnpm --filter @familysync/pwa test:e2e:ui
|
pnpm --filter @familysync/pwa test:e2e:ui
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Headed mode (for local debugging):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @familysync/pwa test:e2e:headed
|
||||||
|
```
|
||||||
|
|
||||||
The `baseURL` is driven by `PLAYWRIGHT_BASE_URL` (default: `http://localhost:5173`). In local mode the config reuses a running Vite dev server; in CI it starts Vite itself. The API, MariaDB, and Redis must already be running via Docker Compose before launching e2e tests locally — see `docs/DEVELOPMENT.md`.
|
The `baseURL` is driven by `PLAYWRIGHT_BASE_URL` (default: `http://localhost:5173`). In local mode the config reuses a running Vite dev server; in CI it starts Vite itself. The API, MariaDB, and Redis must already be running via Docker Compose before launching e2e tests locally — see `docs/DEVELOPMENT.md`.
|
||||||
|
|
||||||
### Type checking (separate from tests — required)
|
### Type checking (separate from tests — required)
|
||||||
@@ -111,10 +121,11 @@ pnpm test:e2e
|
|||||||
| ---------------- | ------------------------------------ | -------------------------------------------------------- |
|
| ---------------- | ------------------------------------ | -------------------------------------------------------- |
|
||||||
| Lint | `pnpm lint` | ESLint `--max-warnings 0` across both apps (type-aware) |
|
| Lint | `pnpm lint` | ESLint `--max-warnings 0` across both apps (type-aware) |
|
||||||
| Format check | `pnpm format:check` | Prettier — fails on any unformatted file |
|
| Format check | `pnpm format:check` | Prettier — fails on any unformatted file |
|
||||||
|
| Markdown lint | `pnpm md:lint` | markdownlint-cli2 across all `.md` files |
|
||||||
| Typecheck | `pnpm typecheck` | `tsc --noEmit` across both apps (including e2e tsconfig) |
|
| Typecheck | `pnpm typecheck` | `tsc --noEmit` across both apps (including e2e tsconfig) |
|
||||||
| Unit / API tests | `pnpm test` | API integration tests via Vitest |
|
| Unit / API tests | `pnpm test` | API integration tests via Vitest |
|
||||||
| PWA unit tests | `pnpm --filter @familysync/pwa test` | Component and logic tests in jsdom |
|
| PWA unit tests | `pnpm --filter @familysync/pwa test` | Component and logic tests in jsdom |
|
||||||
| E2E | `pnpm test:e2e` | Playwright iphone + pixel profiles |
|
| E2E | `pnpm test:e2e` | Playwright iphone + pixel + desktop profiles |
|
||||||
|
|
||||||
A deliberate ESLint violation makes `pnpm lint` exit non-zero; a formatting deviation makes `pnpm format:check` exit non-zero. Both block the PR in CI.
|
A deliberate ESLint violation makes `pnpm lint` exit non-zero; a formatting deviation makes `pnpm format:check` exit non-zero. Both block the PR in CI.
|
||||||
|
|
||||||
@@ -126,7 +137,7 @@ pnpm format # prettier --write .
|
|||||||
|
|
||||||
## Integration tests requiring a real database
|
## 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.
|
Several API tests in `apps/api/tests/lib/` and `apps/api/tests/routes/` connect to a real MariaDB instance. Locally, the Vitest global setup (`test/global-setup.ts`) auto-provisions and migrates the `familysync_test` database — there is no need to manually set `DB_NAME`. The dev `familysync` database is never touched by the test suite.
|
||||||
|
|
||||||
**Start the dev stack:**
|
**Start the dev stack:**
|
||||||
|
|
||||||
@@ -142,6 +153,8 @@ export DB_HOST=127.0.0.1 DB_PORT=3306
|
|||||||
pnpm --filter @familysync/api test
|
pnpm --filter @familysync/api test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The global setup requires root access to create and grant the test database. By default it reads `DB_ROOT_PASSWORD` from the environment (defaults to `root` to match the dev Docker Compose). The app user (`DB_USER`) is validated against `/^[A-Za-z0-9_]+$/` before the GRANT statement is interpolated.
|
||||||
|
|
||||||
DB-backed tests that require this setup include:
|
DB-backed tests that require this setup include:
|
||||||
|
|
||||||
- `apps/api/tests/lib/listAccess.test.ts` — `getAccessibleListIds` access-scope queries
|
- `apps/api/tests/lib/listAccess.test.ts` — `getAccessibleListIds` access-scope queries
|
||||||
@@ -162,17 +175,17 @@ Pure-logic tests (e.g. `apps/api/tests/broker/expand.test.ts`, `apps/api/tests/l
|
|||||||
|
|
||||||
Test categories for `apps/api`:
|
Test categories for `apps/api`:
|
||||||
|
|
||||||
- `apps/api/tests/auth/` — authentication middleware and session handling
|
- `apps/api/tests/auth/` — authentication middleware, session handling, local auth, admin guards, and bypass behaviour
|
||||||
- `apps/api/tests/broker/` — CalDAV sync, outbox worker, event expansion, push dispatch
|
- `apps/api/tests/broker/` — CalDAV sync, outbox worker, event expansion, push dispatch, crypto utilities, and VEVENT parsing
|
||||||
- `apps/api/tests/lib/` — pure library functions and service logic
|
- `apps/api/tests/lib/` — pure library functions and service logic (list access, rank, push coalescer/dispatcher, SSE emitter, timezone, boot guards)
|
||||||
- `apps/api/tests/routes/` — HTTP route integration tests
|
- `apps/api/tests/routes/` — HTTP route integration tests (events, lists, push, admin, login, me, local auth, setup)
|
||||||
- `apps/api/tests/health.test.ts` — health check endpoint
|
- `apps/api/tests/health.test.ts` — health check endpoint
|
||||||
- `apps/api/tests/fixtures/` — shared `.ics` fixture files and DB fixture helpers
|
- `apps/api/tests/fixtures/` — shared `.ics` fixture files and DB fixture helpers
|
||||||
|
|
||||||
### Test 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/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/*.ics` — Raw iCalendar fixture files for broker parsing tests (`absolute-alarm.ics`, `allday-birthday.ics`, `exdate-series.ics`, `multi-alarm.ics`, `single-duration.ics`, `weekly-count3.ics`, `weekly-dst.ics`).
|
||||||
- `apps/api/tests/fixtures/vapid.ts` — VAPID key fixture for push tests.
|
- `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`.
|
- `apps/pwa/src/test-setup.ts` — Provides `matchMedia` polyfill and jest-dom matchers for all PWA tests automatically via `setupFiles`.
|
||||||
|
|
||||||
@@ -184,22 +197,25 @@ No coverage thresholds are configured in either `vitest.config.ts`. There is no
|
|||||||
|
|
||||||
## CI integration
|
## CI integration
|
||||||
|
|
||||||
CI runs on a self-hosted Gitea Actions runner and triggers on every pull request targeting `main` (`.gitea/workflows/ci.yml`). Three jobs run in parallel:
|
CI runs on a self-hosted Gitea Actions runner and triggers on every pull request targeting `main` (`.gitea/workflows/ci.yml`). A `changes` job using `dorny/paths-filter@v4` determines whether the PR touches code (as opposed to docs or planning files only). The `api` and `harness` jobs are skipped for doc-only PRs.
|
||||||
|
|
||||||
|
Five jobs run in total — `fast-checks` and `security` always run; `api`, `harness`, and `changes` run conditionally.
|
||||||
|
|
||||||
### `fast-checks`
|
### `fast-checks`
|
||||||
|
|
||||||
Runs lint, format check, typecheck, and PWA unit tests — no external services required.
|
Runs lint, format check, markdown lint, typecheck, and PWA unit tests — no external services required. Always runs regardless of the `changes` filter.
|
||||||
|
|
||||||
| Step | Command |
|
| Step | Command |
|
||||||
| -------------- | ------------------------------------ |
|
| -------------- | ------------------------------------ |
|
||||||
| Lint | `pnpm lint` |
|
| Lint | `pnpm lint` |
|
||||||
| Format check | `pnpm format:check` |
|
| Format check | `pnpm format:check` |
|
||||||
|
| Markdown lint | `pnpm md:lint` |
|
||||||
| Typecheck | `pnpm typecheck` |
|
| Typecheck | `pnpm typecheck` |
|
||||||
| PWA unit tests | `pnpm --filter @familysync/pwa test` |
|
| PWA unit tests | `pnpm --filter @familysync/pwa test` |
|
||||||
|
|
||||||
### `api`
|
### `api`
|
||||||
|
|
||||||
Runs the full API test suite against a `mariadb:11` service container.
|
Runs the full API test suite against a `mariadb:11` service container. Skipped for doc-only PRs.
|
||||||
|
|
||||||
| Step | Detail |
|
| Step | Detail |
|
||||||
| ----------------- | ---------------------------------------------------------- |
|
| ----------------- | ---------------------------------------------------------- |
|
||||||
@@ -214,13 +230,14 @@ The throwaway credentials (`DB_USER=familysync`, `DB_PASSWORD=testpass`) are sco
|
|||||||
|
|
||||||
### `harness`
|
### `harness`
|
||||||
|
|
||||||
Runs the Playwright mobile e2e harness (iphone + pixel) against a runner-hosted dev stack.
|
Runs the Playwright mobile and desktop e2e harness (iphone + pixel + desktop) against a runner-hosted dev stack. Skipped for doc-only PRs.
|
||||||
|
|
||||||
| Step | Detail |
|
| Step | Detail |
|
||||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| MariaDB service | Same `mariadb:11` setup as the `api` job |
|
| MariaDB service | Same `mariadb:11` setup as the `api` job |
|
||||||
| Schema migrations | `pnpm --filter @familysync/api db:migrate` |
|
| Schema migrations | `pnpm --filter @familysync/api db:migrate` |
|
||||||
| Dev user seed | Inserts `users` row id=1 (`INSERT IGNORE`) for `DEV_AUTH_BYPASS` |
|
| Dev user seed | Inserts `users` row id=1 (`INSERT IGNORE`) for `DEV_AUTH_BYPASS` |
|
||||||
|
| Local credentials seed | Inserts `local_credentials` row for dev user (username: `devuser`, password: `devpass`) via inline scrypt hash — Phase 19 requirement |
|
||||||
| API build | `pnpm --filter @familysync/api build` (dist/ is gitignored) |
|
| API build | `pnpm --filter @familysync/api build` (dist/ is gitignored) |
|
||||||
| Playwright install | `npx playwright install --with-deps webkit chromium` (no cache) |
|
| Playwright install | `npx playwright install --with-deps webkit chromium` (no cache) |
|
||||||
| API start + tests | API started as a background process in the same step as `playwright test` to survive the step boundary; `DEV_AUTH_BYPASS=true`, `NODE_ENV=development` |
|
| API start + tests | API started as a background process in the same step as `playwright test` to survive the step boundary; `DEV_AUTH_BYPASS=true`, `NODE_ENV=development` |
|
||||||
@@ -228,3 +245,17 @@ Runs the Playwright mobile e2e harness (iphone + pixel) against a runner-hosted
|
|||||||
| Artifacts on fail | Traces, screenshots, videos, and HTML report uploaded via `ChristopherHX/gitea-upload-artifact@v4` (standard `upload-artifact` aborts on Gitea) |
|
| Artifacts on fail | Traces, screenshots, videos, and HTML report uploaded via `ChristopherHX/gitea-upload-artifact@v4` (standard `upload-artifact` aborts on Gitea) |
|
||||||
|
|
||||||
The API process is started and the Playwright suite invoked within a single CI step. Starting the API in an earlier step causes it to be reaped at the step boundary before Playwright runs.
|
The API process is started and the Playwright suite invoked within a single CI step. Starting the API in an earlier step causes it to be reaped at the step boundary before Playwright runs.
|
||||||
|
|
||||||
|
### `security`
|
||||||
|
|
||||||
|
Runs secret scanning and dependency audits. Always runs regardless of the `changes` filter (secrets can appear in doc-only commits). Dependency audit and outdated checks run only when code changes are detected.
|
||||||
|
|
||||||
|
| Step | Tool/Command | Detail |
|
||||||
|
| ------------------- | ------------------------------- | -------------------------------------------------------------- |
|
||||||
|
| Secret scan | `gitleaks` (v8.30.1) | Scans the PR diff range; blocks on any finding |
|
||||||
|
| Dependency audit | `node scripts/check-audit.mjs` | Blocks on High or Critical severity vulnerabilities |
|
||||||
|
| Outdated report | `node scripts/check-outdated.mjs` | Advisory only — always exits 0, logged but never gates |
|
||||||
|
|
||||||
|
### `gate`
|
||||||
|
|
||||||
|
A required final job that checks all other jobs passed or were legitimately skipped. `fast-checks` and `security` must succeed; `api` and `harness` may be skipped (doc-only PRs) but not failed.
|
||||||
|
|||||||
+17
-11
@@ -30,21 +30,23 @@ FamilySync uses a self-hosted Gitea Actions runner. Two workflows govern the rel
|
|||||||
|
|
||||||
### PR gate — `.gitea/workflows/ci.yml`
|
### PR gate — `.gitea/workflows/ci.yml`
|
||||||
|
|
||||||
Triggered on every pull request targeting `main`. Three jobs run in parallel; all three must pass before the PR can be merged:
|
Triggered on every pull request targeting `main`. The workflow runs a `changes` filter job first, then launches the following jobs in parallel:
|
||||||
|
|
||||||
| Job | What it checks |
|
| Job | Runs when | What it checks |
|
||||||
| ------------- | --------------------------------------------------------------------------------- |
|
| ------------- | -------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||||
| `fast-checks` | Lint (`pnpm lint`), format check (`pnpm format:check`), typecheck, PWA unit tests |
|
| `fast-checks` | Always | Lint (`pnpm lint`), format check (`pnpm format:check`), markdown lint (`pnpm md:lint`), typecheck, PWA unit tests |
|
||||||
| `api` | DB migrations + API integration tests against a live MariaDB service container |
|
| `api` | Code-changing PRs only | DB migrations + API integration tests against a live MariaDB service container |
|
||||||
| `harness` | Full Playwright E2E suite (iPhone + Pixel profiles) against the compiled API |
|
| `harness` | Code-changing PRs only | Full Playwright E2E suite (iPhone + Pixel + desktop profiles) against the compiled API |
|
||||||
|
| `security` | Always | Secret scan (gitleaks, PR diff); dependency audit and outdated report on code-changing PRs |
|
||||||
|
| `gate` | Always | Aggregates results — fails if any non-skipped required job did not succeed |
|
||||||
|
|
||||||
A PR with lint or format violations is blocked from merging by the `fast-checks` job.
|
The `api` and `harness` jobs are **skipped on doc-only PRs** (changes confined to `.gitea/**`, `.planning/**`, or `*.md` files). A doc-only PR must pass `fast-checks` and `security`; the heavy jobs are not required.
|
||||||
|
|
||||||
Branch protection on `main` blocks direct push and force push. Only PRs with all three required checks (`CI / fast-checks`, `CI / api`, `CI / harness`) passing can merge.
|
Branch protection on `main` blocks direct push and force push. Only PRs where both `CI / fast-checks` and `CI / gate` pass can merge.
|
||||||
|
|
||||||
### Publish — `.gitea/workflows/publish.yml`
|
### Publish — `.gitea/workflows/publish.yml`
|
||||||
|
|
||||||
Triggered on push to `main` (i.e., when any PR merges). Builds the `apps/api` Docker image and pushes it to the Gitea container registry.
|
Triggered on push to `main` (i.e., when any PR merges). Skipped when every changed file is under `.gitea/**` or `.planning/**`. Builds the `apps/api` Docker image and pushes it to the Gitea container registry.
|
||||||
|
|
||||||
**Registry:** `git.bergerhouse.net/luckberg/familysync-api`
|
**Registry:** `git.bergerhouse.net/luckberg/familysync-api`
|
||||||
|
|
||||||
@@ -59,6 +61,10 @@ The current milestone prefix (`v1.1`) is set in the `MILESTONE` env var at the t
|
|||||||
|
|
||||||
The immutable `:<milestone>-<sha>` tag is pushed first. `:latest` is only moved after the immutable tag has landed, so a failed second push can never leave `:latest` advanced without a corresponding rollback tag.
|
The immutable `:<milestone>-<sha>` tag is pushed first. `:latest` is only moved after the immutable tag has landed, so a failed second push can never leave `:latest` advanced without a corresponding rollback tag.
|
||||||
|
|
||||||
|
Before pushing, the workflow runs two image hygiene assertions:
|
||||||
|
1. **Static assertions** — verifies `.dockerignore` contains all required exclusion patterns and that the build targets `--target production`.
|
||||||
|
2. **Boot-smoke** — starts the image with `NODE_ENV=production` and `DEV_AUTH_BYPASS=true` and asserts that it refuses to start (confirming the D-08 guard fires in the shipped image).
|
||||||
|
|
||||||
**Authentication — `REGISTRY_PAT` secret:**
|
**Authentication — `REGISTRY_PAT` secret:**
|
||||||
|
|
||||||
The workflow authenticates with the Gitea container registry using a PAT stored in the `REGISTRY_PAT` Actions secret. The secret must have `write:package` scope. It is named `REGISTRY_PAT` — not `GITEA_REGISTRY_PAT` or any `GITEA_`-prefixed name, because Gitea reserves the `GITEA_` prefix and will reject those names at secret-creation time. `GITEA_TOKEN` and `GITHUB_TOKEN` cannot push packages.
|
The workflow authenticates with the Gitea container registry using a PAT stored in the `REGISTRY_PAT` Actions secret. The secret must have `write:package` scope. It is named `REGISTRY_PAT` — not `GITEA_REGISTRY_PAT` or any `GITEA_`-prefixed name, because Gitea reserves the `GITEA_` prefix and will reject those names at secret-creation time. `GITEA_TOKEN` and `GITHUB_TOKEN` cannot push packages.
|
||||||
@@ -178,7 +184,7 @@ See [docs/CONFIGURATION.md](CONFIGURATION.md) for the full variable reference in
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
Migrations are run with `drizzle-kit`, which is a **devDependency**. The production image is built with `pnpm install --frozen-lockfile --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:
|
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:
|
||||||
|
|
||||||
@@ -265,7 +271,7 @@ The Dockerfile uses a multi-stage build:
|
|||||||
|
|
||||||
1. `builder` — compiles the TypeScript API (`pnpm --filter @familysync/api 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`).
|
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`.
|
3. `production` — installs production-only dependencies (`pnpm install --frozen-lockfile --prod`), 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.
|
Both `builder` and `pwa-builder` stages run in parallel under BuildKit.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user