docs: refresh project documentation against current codebase
Publish / publish (push) Successful in 26s

This commit is contained in:
Lucas Berger
2026-06-18 06:44:29 -04:00
parent 18d3ee6a4f
commit 1e2cc52659
11 changed files with 872 additions and 178 deletions
+519 -34
View File
@@ -2,16 +2,22 @@
# 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
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.
2. For a request with no valid session cookie, the OIDC middleware intercepts it before the route handler runs and 302-redirects to Authelia's authorize endpoint.
3. Authelia posts the authorization code to `GET /callback`, which exchanges it for tokens and sets an `httpOnly; Secure; SameSite` session cookie, then continues back to `/api/login` — whose handler now redirects to `/` (the app shell).
4. All subsequent `/api/*` requests carry the session cookie automatically. (The root `/` and static assets are served outside the `/api/*` guard.)
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.
@@ -19,31 +25,54 @@ No API key or `Authorization` header is used. Credentials are never included in
## Endpoints Overview
| Method | Path | Auth | Description |
| ------ | -------------------------------- | ---- | ------------------------------------ |
| GET | `/health` | None | DB liveness check |
| GET | `/callback` | None | OIDC authorization-code exchange |
| GET | `/api/login` | OIDC | Login entry point, redirects to `/` |
| GET | `/api/me` | OIDC | Current user identity and color |
| GET | `/api/events` | OIDC | Windowed calendar occurrences |
| POST | `/api/events/create` | OIDC | Enqueue a new event write |
| PATCH | `/api/events/:uid/edit` | OIDC | Enqueue an event update |
| DELETE | `/api/events/:uid` | OIDC | Enqueue an event delete |
| GET | `/api/events/sync-status` | OIDC | Outbox status for a UID |
| GET | `/api/events/writable-calendars` | OIDC | Calendars the member can write to |
| GET | `/api/lists` | OIDC | All lists accessible to the member |
| POST | `/api/lists` | OIDC | Create a list |
| PATCH | `/api/lists/:id` | OIDC | Update list name or sharing |
| DELETE | `/api/lists/:id` | OIDC | Delete a list (owner only) |
| GET | `/api/lists/:id/items` | OIDC | All items in a list |
| POST | `/api/lists/:id/items` | OIDC | Add an item to a list |
| PATCH | `/api/list-items/:itemId` | OIDC | Update a single list item field |
| DELETE | `/api/list-items/:itemId` | OIDC | Delete a list item |
| GET | `/api/sse/heartbeat` | OIDC | SSE heartbeat stream |
| GET | `/api/sse/lists` | OIDC | Scoped live-list SSE stream |
| GET | `/api/push/vapid-public-key` | OIDC | VAPID public key for push subscribe |
| POST | `/api/push/subscription` | OIDC | Register a push subscription |
| DELETE | `/api/push/subscription` | OIDC | Remove push subscriptions for caller |
| Method | Path | Auth | Description |
| ------ | ------------------------------------- | --------- | -------------------------------------------------- |
| GET | `/health` | None | DB liveness check |
| GET | `/callback` | None | OIDC authorization-code exchange |
| GET | `/api/setup/status` | None | Setup wizard completion status |
| POST | `/api/setup/config` | None | Store OIDC and VAPID config (wizard step 1) |
| POST | `/api/setup/validate/db` | None | Validate DB connectivity (wizard step) |
| POST | `/api/setup/validate/oidc` | None | Validate OIDC issuer discovery (wizard step) |
| POST | `/api/setup/validate/vapid` | None | Validate VAPID key pair (wizard step) |
| POST | `/api/setup/credential` | None | Store first admin's Fastmail credential (wizard) |
| POST | `/api/setup/complete` | None | Lock the setup wizard |
| GET | `/api/auth/mode` | None | Auth mode discovery (local vs OIDC enabled) |
| POST | `/api/auth/local/login` | None | Local username+password login |
| POST | `/api/auth/local/logout` | None | Clear local session cookie |
| GET | `/api/auth/local/logout` | None | Clear local session cookie (browser redirect alias)|
| GET | `/api/login` | OIDC | OIDC login entry point, redirects to `/` |
| GET | `/api/me` | Session | Current user identity, role, and setup status |
| POST | `/api/me/credential` | Session | Member self-service Fastmail credential update |
| POST | `/api/me/password` | Session | Member self-change local password |
| POST | `/api/me/link-oidc` | Session | Initiate OIDC identity link for a local user |
| GET | `/api/events` | Session | Windowed calendar occurrences |
| POST | `/api/events/create` | Session | Enqueue a new event write |
| PATCH | `/api/events/:uid/edit` | Session | Enqueue an event update |
| DELETE | `/api/events/:uid` | Session | Enqueue an event delete |
| 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 | 1256 characters |
| `vapidPublicKey` | string | Yes | 1512 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 | 1500 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 | 1128 characters (trimmed) |
| `password` | string | Yes | 11000 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
### `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**
@@ -82,15 +288,96 @@ Display name is derived from OIDC claims in priority order: `name` → `preferre
"user": {
"id": 1,
"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.
---
### `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 | 1500 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 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 | 1256 characters |
| `username` | string | Yes | 1128 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 | 1500 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, 164 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
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) |
| `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 |
| `409` | Conflict (e.g., duplicate username) |
| `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 |
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
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 -->
+57 -26
View File
@@ -8,7 +8,7 @@ FamilySync is a self-hosted family organization hub — a unified, color-coded c
## 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
graph TD
@@ -18,8 +18,8 @@ graph TD
end
subgraph "API (apps/api — Hono on Node 22)"
AUTH["OIDC Auth\n(@hono/oidc-auth)"]
ROUTES["API Routes\n/events /lists /me /push /sse"]
AUTH["Auth Layer\n(local session + OIDC middleware)"]
ROUTES["API Routes\n/events /lists /me /push /sse\n/admin /setup /auth"]
BROKER["CalDAV Broker\n(tsdav + ical.js + rrule)"]
OUTBOX["Outbox Worker\n(15s drain loop)"]
POLLER["CalDAV Poller\n(5-min setInterval)"]
@@ -33,7 +33,7 @@ graph TD
end
subgraph "External Services"
AUTHELIA["Authelia\n(OIDC / OAuth2 IdP)"]
AUTHELIA["Authelia\n(OIDC / OAuth2 IdP — optional)"]
FASTMAIL["Fastmail CalDAV\ncaldav.fastmail.com"]
PUSH_SVC["Browser Push Services\n(APNs / FCM)"]
end
@@ -41,7 +41,7 @@ graph TD
PWA -- "HTTPS (same-origin via Pangolin)" --> ROUTES
SW -- "push events" --> PWA
ROUTES --> AUTH
AUTH -- "authorization-code + PKCE" --> AUTHELIA
AUTH -- "authorization-code + PKCE (when oidcEnabled)" --> AUTHELIA
ROUTES --> BROKER
ROUTES --> SSE_LIB
ROUTES --> DB
@@ -68,7 +68,7 @@ familysync/
│ │ └── src/
│ │ ├── index.ts # App entry: mounts routes, starts background workers
│ │ ├── routes/ # HTTP route handlers
│ │ ├── auth/ # OIDC middleware + dev-bypass + session persistence
│ │ ├── auth/ # OIDC middleware + local auth + dev-bypass + session persistence
│ │ ├── broker/ # CalDAV integration layer
│ │ ├── db/ # Drizzle schema, client, migrations
│ │ └── lib/ # Shared dispatchers and utilities
@@ -90,12 +90,12 @@ familysync/
| 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/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/lib/` | Stateless helpers: `listEmitter.ts` (EventEmitter fan-out), `listChangeDispatcher.ts`, `eventChangeDispatcher.ts`, `pushDispatcher.ts` (VAPID send), `pushCoalescer.ts`, `listAccess.ts`, `rank.ts` (fractional indexing) |
| `apps/pwa/src/api/` | Thin typed fetch wrappers — `client.ts` (events, me, sync-status), `listsClient.ts` (lists and items) |
| `apps/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, 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/hooks/` | `useListSSE.ts` (bounded-backoff EventSource), `usePushSubscription.ts` (VAPID subscribe) |
@@ -103,19 +103,25 @@ familysync/
## Key Abstractions
| Abstraction | File | Description |
| ------------------------------------------ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `app` (Hono) | `apps/api/src/index.ts` | Root Hono app; mounts all routes and serves the PWA static build |
| Drizzle schema | `apps/api/src/db/schema.ts` | Single source of truth for all table definitions (`users`, `memberCredentials`, `calendars`, `calendarEvents`, `calendarOutbox`, `lists`, `listShares`, `listItems`, `pushSubscriptions`) |
| `syncCalendar` | `apps/api/src/broker/sync.ts` | REPORT → ical.js parse → `onDuplicateKeyUpdate` upsert into MariaDB |
| `expandOccurrences` | `apps/api/src/broker/expand.ts` | Server-side RRULE expansion using `ical.js` + `rrule`; never runs in the browser |
| `CalendarOccurrence` | `apps/api/src/broker/expand.ts` | Wire type for a single concrete event occurrence; mirrored in the PWA's `api/client.ts` |
| `calendarOutbox` table | `apps/api/src/db/schema.ts` | Transactional outbox pattern — CalDAV writes are enqueued here and drained asynchronously |
| `runOutboxDrain` | `apps/api/src/broker/outboxWorker.ts` | Drains pending outbox rows every 15s; handles retry backoff, 412 conflict, dead-lettering, and edit-as-move ordering |
| `publishListEvent` / `subscribeListEvents` | `apps/api/src/lib/listEmitter.ts` | In-process EventEmitter fan-out keyed per list; SSE route subscribes on open and unsubscribes on disconnect |
| `dispatchPush` | `apps/api/src/lib/pushDispatcher.ts` | Centralised VAPID-signed push sender; handles 410/404 subscription pruning |
| `SessionExpiredError` | `apps/pwa/src/api/client.ts` | Typed error thrown by all fetch wrappers on 401/opaqueredirect; global `QueryCache` handler arms the session-expiry interstitial |
| Zustand stores | `apps/pwa/src/store/` | UI-only ephemeral state (open panels, selected date, active tab); server state always in TanStack Query |
| Abstraction | File | Description |
| ------------------------------------------ | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `app` (Hono) | `apps/api/src/index.ts` | Root Hono app; mounts all routes and serves the PWA static build |
| Drizzle schema | `apps/api/src/db/schema.ts` | Single source of truth for all table definitions (`users`, `memberCredentials`, `localCredentials`, `calendars`, `calendarEvents`, `calendarOutbox`, `lists`, `listShares`, `listItems`, `pushSubscriptions`, `appConfig`) |
| `syncCalendar` | `apps/api/src/broker/sync.ts` | REPORT → ical.js parse → `onDuplicateKeyUpdate` upsert into MariaDB |
| `expandOccurrences` | `apps/api/src/broker/expand.ts` | Server-side RRULE expansion using `ical.js` + `rrule`; never runs in the browser |
| `CalendarOccurrence` | `apps/api/src/broker/expand.ts` | Wire type for a single concrete event occurrence; mirrored in the PWA's `api/client.ts` |
| `calendarOutbox` table | `apps/api/src/db/schema.ts` | Transactional outbox pattern — CalDAV writes are enqueued here and drained asynchronously |
| `runOutboxDrain` | `apps/api/src/broker/outboxWorker.ts` | Drains pending outbox rows every 15s; handles retry backoff, 412 conflict, dead-lettering, and edit-as-move ordering |
| `publishListEvent` / `subscribeListEvents` | `apps/api/src/lib/listEmitter.ts` | In-process EventEmitter fan-out keyed per list; SSE route subscribes on open and unsubscribes on disconnect |
| `dispatchPush` | `apps/api/src/lib/pushDispatcher.ts` | Centralised VAPID-signed push sender; handles 410/404 subscription pruning |
| `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 |
| 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
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`.
2. The `oidcAuthMiddleware` (`@hono/oidc-auth`) issues a `302` to Authelia's `/authorize` endpoint with PKCE (S256).
3. After login, Authelia posts the authorization code to `/callback`; `processOAuthCallback` exchanges it for tokens and issues a signed JWT session cookie.
4. `persistSessionCookie` middleware re-issues the cookie as persistent on every authenticated response so the PWA session survives browser close.
5. All `/api/*` routes require the session cookie; a missing or expired session returns a `302` which the PWA's fetch wrappers detect as `opaqueredirect` and convert to a `SessionExpiredError`.
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)
──→ 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
@@ -196,6 +224,8 @@ routes/sse.ts ──→ lib/listEmitter.ts (subscribeListEvents)
| Current user | TanStack Query `['me']` |
| Writable calendars | TanStack Query `['writableCalendars']` |
| 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` |
| Event form open/mode | Zustand `calendarStore` |
| 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`) |
| Database | MariaDB 11 (Docker volume) |
| ORM | Drizzle ORM 0.45.x (`mysql2` dialect) |
| Auth IdP | Authelia (pre-deployed, external) — OIDC authorization code + PKCE |
| Session middleware | `@hono/oidc-auth` — storage-less signed JWT cookies |
| Auth IdP | Authelia (pre-deployed, external) — OIDC authorization code + PKCE; optional when local auth is enabled |
| 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 parsing | `ical.js` (VCALENDAR/VEVENT parse) + `rrule` (RRULE expansion) |
| 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) |
| 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) |
+47 -12
View File
@@ -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_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_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`.
@@ -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
| 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`. |
| `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`. |
---
@@ -87,17 +114,18 @@ 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:
| Variable | Default | Source |
| ------------------- | ------------------------------------- | ------------------------------------------- |
| `DB_HOST` | `localhost` | `apps/api/src/db/client.ts` |
| `DB_PORT` | `3306` | `apps/api/src/db/client.ts` |
| `DB_USER` | `familysync` | `apps/api/src/db/client.ts` |
| `DB_NAME` | `familysync` | `apps/api/src/db/client.ts` |
| `OIDC_CLIENT_ID` | `familysync` | `docker-compose.yml` |
| `OIDC_SCOPES` | `openid profile email offline_access` | `docker-compose.yml` |
| `OIDC_AUTH_EXPIRES` | `86400` | `apps/api/src/auth/persistSessionCookie.ts` |
| `OIDC_COOKIE_NAME` | `oidc-auth` | `apps/api/src/auth/persistSessionCookie.ts` |
| `OIDC_COOKIE_PATH` | `/` | `apps/api/src/auth/persistSessionCookie.ts` |
| Variable | Default | Source |
| ----------------------- | ------------------------------------- | ------------------------------------------- |
| `DB_HOST` | `localhost` | `apps/api/src/db/client.ts` |
| `DB_PORT` | `3306` | `apps/api/src/db/client.ts` |
| `DB_USER` | `familysync` | `apps/api/src/db/client.ts` |
| `DB_NAME` | `familysync` | `apps/api/src/db/client.ts` |
| `OIDC_CLIENT_ID` | `familysync` | `docker-compose.yml` |
| `OIDC_SCOPES` | `openid profile email offline_access` | `docker-compose.yml` |
| `OIDC_AUTH_EXPIRES` | `86400` | `apps/api/src/auth/persistSessionCookie.ts` |
| `OIDC_COOKIE_NAME` | `oidc-auth` | `apps/api/src/auth/persistSessionCookie.ts` |
| `OIDC_COOKIE_PATH` | `/` | `apps/api/src/auth/persistSessionCookie.ts` |
| `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_AUTH_EXTERNAL_URL=https://familysync.DOMAIN
# Local auth (Phase 19)
LOCAL_SESSION_SECRET=<openssl rand -base64 32>
# Broker encryption
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.
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
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>
```
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.
---
+26 -20
View File
@@ -100,17 +100,19 @@ Vite serves the PWA with HMR on the configured dev port. The PWA's API calls tar
### Root workspace scripts
| Command | Description |
| ------------------- | ------------------------------------------------------------- |
| `pnpm dev:api` | Start API dev watcher (`node --watch dist/index.js`) |
| `pnpm dev:pwa` | Start Vite dev server for the PWA |
| `pnpm build` | Build both `apps/api` (tsc) and `apps/pwa` (tsc + vite build) |
| `pnpm test` | Run API test suite (`vitest run` in `apps/api`) |
| `pnpm test:e2e` | Run Playwright e2e harness (`apps/pwa`) |
| `pnpm lint` | ESLint across all workspaces (`pnpm -r --if-present lint`) |
| `pnpm format` | Reformat all files with Prettier (`prettier --write .`) |
| `pnpm format:check` | Check formatting without writing (`prettier --check .`) |
| `pnpm typecheck` | `tsc --noEmit` in all workspaces |
| Command | Description |
| ------------------------ | ------------------------------------------------------------- |
| `pnpm dev:api` | Start API dev watcher (`node --watch dist/index.js`) |
| `pnpm dev:pwa` | Start Vite dev server for the PWA |
| `pnpm build` | Build both `apps/api` (tsc) and `apps/pwa` (tsc + vite build) |
| `pnpm test` | Run API test suite (`vitest run` in `apps/api`) |
| `pnpm test:e2e` | Run Playwright e2e harness (`apps/pwa`) |
| `pnpm lint` | ESLint across all workspaces (`pnpm -r --if-present lint`) |
| `pnpm format` | Reformat all files with Prettier (`prettier --write .`) |
| `pnpm format:check` | Check formatting without writing (`prettier --check .`) |
| `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
@@ -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 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 md:lint # Markdown lint (also runs in CI fast-checks)
```
### ESLint
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)
- **`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}`** — `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)
- **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)
- **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
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 |
| ------------- | ---------------------------------------------------------------------------------------------------- |
| `fast-checks` | `pnpm lint``pnpm format:check``pnpm typecheck``pnpm --filter @familysync/pwa test` |
| `api` | 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` |
| Job | Runs on | Checks |
| ------------- | ------------------- | ----------------------------------------------------------------------------------------------------------- |
| `fast-checks` | Every PR | `pnpm lint``pnpm format:check` `pnpm md:lint` `pnpm typecheck``pnpm --filter @familysync/pwa test` |
| `api` | Code-change PRs only | DB migrations + `pnpm --filter @familysync/api test` (vitest against a MariaDB 11 service container) |
| `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
+16 -2
View File
@@ -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:
- `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
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`
> **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
@@ -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**
`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**
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/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
+64 -33
View File
@@ -6,12 +6,14 @@
Both apps use **Vitest** (`^4.1.8`).
| App | Environment | Setup file |
| ---------- | ----------- | ---------------------------- |
| `apps/api` | `node` | `apps/api/test/setup.ts` |
| `apps/pwa` | `jsdom` | `apps/pwa/src/test-setup.ts` |
| App | Environment | Global setup | Per-file setup |
| ---------- | ----------- | ----------------------------------- | ---------------------------- |
| `apps/api` | `node` | `apps/api/test/global-setup.ts` | `apps/api/test/setup.ts` |
| `apps/pwa` | `jsdom` | — | `apps/pwa/src/test-setup.ts` |
**apps/api setup** (`test/setup.ts`) registers a global `afterEach` that truncates `list_items`, `list_shares`, `push_subscriptions`, and `lists` in FK-safe order after every test. This keeps DB-backed integration tests isolated without requiring a full DB reset between runs. Parallel file execution is disabled (`fileParallelism: false`) to prevent FK violations when multiple test files share the same MariaDB.
**apps/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.
@@ -49,16 +51,17 @@ pnpm --filter @familysync/api exec vitest run tests/routes/lists.test.ts
### 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 |
| -------- | -------- | -------- | ------------------------- |
| `iphone` | 390×844 | WebKit | Mobile Safari (iPhone 14) |
| `pixel` | 412×915 | Chromium | Chrome Android (Pixel 7) |
| Profile | Viewport | Engine | User-Agent |
| --------- | --------- | -------- | ------------------------- |
| `iphone` | 390×844 | WebKit | Mobile Safari (iPhone 14) |
| `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
pnpm test:e2e
@@ -71,6 +74,7 @@ pnpm --filter @familysync/pwa test:e2e
```bash
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=desktop
```
**Interactive UI mode:**
@@ -79,6 +83,12 @@ pnpm --filter @familysync/pwa exec playwright test --project=iphone
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`.
### 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) |
| 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) |
| Unit / API tests | `pnpm test` | API integration tests via Vitest |
| 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.
@@ -126,7 +137,7 @@ pnpm format # prettier --write .
## 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:**
@@ -142,6 +153,8 @@ export DB_HOST=127.0.0.1 DB_PORT=3306
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:
- `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`:
- `apps/api/tests/auth/` — authentication middleware and session handling
- `apps/api/tests/broker/` — CalDAV sync, outbox worker, event expansion, push dispatch
- `apps/api/tests/lib/` — pure library functions and service logic
- `apps/api/tests/routes/` — HTTP route integration tests
- `apps/api/tests/auth/` — authentication middleware, session handling, local auth, admin guards, and bypass behaviour
- `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 (list access, rank, push coalescer/dispatcher, SSE emitter, timezone, boot guards)
- `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/fixtures/` — shared `.ics` fixture files and DB fixture helpers
### Test helpers
- `apps/api/tests/helpers/db.ts``createMockDb()` returns a Vitest mock of the Drizzle `db` singleton; also exports sample VEVENT strings (`SAMPLE_VEVENT_TIMED`, `SAMPLE_VEVENT_ALLDAY`, `SAMPLE_VEVENT_RECURRING_TIMED`, `SAMPLE_VEVENT_RECURRING_ALLDAY`) for broker tests.
- `apps/api/tests/fixtures/*.ics` — Raw iCalendar fixture files for broker parsing tests (`allday-birthday.ics`, `exdate-series.ics`, `single-duration.ics`, `weekly-count3.ics`, `weekly-dst.ics`).
- `apps/api/tests/fixtures/*.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/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 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`
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 |
| -------------- | ------------------------------------ |
| Lint | `pnpm lint` |
| Format check | `pnpm format:check` |
| Markdown lint | `pnpm md:lint` |
| Typecheck | `pnpm typecheck` |
| PWA unit tests | `pnpm --filter @familysync/pwa test` |
### `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 |
| ----------------- | ---------------------------------------------------------- |
@@ -214,17 +230,32 @@ The throwaway credentials (`DB_USER=familysync`, `DB_PASSWORD=testpass`) are sco
### `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 |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| MariaDB service | Same `mariadb:11` setup as the `api` job |
| Schema migrations | `pnpm --filter @familysync/api db:migrate` |
| Dev user seed | Inserts `users` row id=1 (`INSERT IGNORE`) for `DEV_AUTH_BYPASS` |
| API build | `pnpm --filter @familysync/api build` (dist/ is gitignored) |
| 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` |
| Base URL | `http://127.0.0.1:5173` (not `localhost` — runner resolves `localhost` to `::1` but Vite binds IPv4-only) |
| Artifacts on fail | Traces, screenshots, videos, and HTML report uploaded via `ChristopherHX/gitea-upload-artifact@v4` (standard `upload-artifact` aborts on Gitea) |
| Step | Detail |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MariaDB service | Same `mariadb:11` setup as the `api` job |
| Schema migrations | `pnpm --filter @familysync/api db:migrate` |
| 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) |
| 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` |
| Base URL | `http://127.0.0.1:5173` (not `localhost` — runner resolves `localhost` to `::1` but Vite binds IPv4-only) |
| 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.
### `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
View File
@@ -30,21 +30,23 @@ FamilySync uses a self-hosted Gitea Actions runner. Two workflows govern the rel
### 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 |
| ------------- | --------------------------------------------------------------------------------- |
| `fast-checks` | Lint (`pnpm lint`), format check (`pnpm format:check`), typecheck, PWA unit tests |
| `api` | DB migrations + API integration tests against a live MariaDB service container |
| `harness` | Full Playwright E2E suite (iPhone + Pixel profiles) against the compiled API |
| Job | Runs when | What it checks |
| ------------- | -------------------- | --------------------------------------------------------------------------------------------------------- |
| `fast-checks` | Always | Lint (`pnpm lint`), format check (`pnpm format:check`), markdown lint (`pnpm md:lint`), typecheck, PWA unit tests |
| `api` | Code-changing PRs only | DB migrations + API integration tests against a live MariaDB service container |
| `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`
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`
@@ -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.
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:**
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.
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:
@@ -265,7 +271,7 @@ The Dockerfile uses a multi-stage build:
1. `builder` — compiles the TypeScript API (`pnpm --filter @familysync/api build`).
2. `pwa-builder` — builds the React PWA with Vite (`pnpm --filter @familysync/pwa build`).
3. `production` — installs production-only dependencies, copies the compiled API and the built PWA into `./public`. The API serves the PWA at `/` via `serveStatic`.
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.