style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
+101 -77
View File
@@ -1,4 +1,5 @@
<!-- generated-by: gsd-doc-writer -->
# 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.
@@ -18,31 +19,31 @@ 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/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 |
---
@@ -53,11 +54,13 @@ No API key or `Authorization` header is used. Credentials are never included in
Unauthenticated. Performs a `SELECT 1` against MariaDB to prove connectivity.
**Response 200**
```json
{ "ok": true, "db": "up" }
```
**Response 503** (DB unreachable)
```json
{ "ok": false, "db": "down" }
```
@@ -73,6 +76,7 @@ Returns the authenticated member's identity and their assigned color.
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`).
**Response 200**
```json
{
"user": {
@@ -97,12 +101,13 @@ Returns a flat array of concrete event occurrences for the given date window. Re
**Query parameters**
| Parameter | Required | Format | Notes |
|-----------|----------|--------|-------|
| `start` | Yes | `YYYY-MM-DD` | Window start (inclusive) |
| `end` | Yes | `YYYY-MM-DD` | Window end (exclusive); max 90 days from start |
| Parameter | Required | Format | Notes |
| --------- | -------- | ------------ | ---------------------------------------------- |
| `start` | Yes | `YYYY-MM-DD` | Window start (inclusive) |
| `end` | Yes | `YYYY-MM-DD` | Window end (exclusive); max 90 days from start |
**Response 200**
```json
{
"occurrences": [
@@ -148,22 +153,23 @@ Enqueues a new event for creation on Fastmail CalDAV. Returns `202` immediately
}
```
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `title` | string | Yes | 1255 characters |
| `allDay` | boolean | Yes | |
| `start` | string | Yes | ISO datetime or `YYYY-MM-DD` for all-day; max 64 chars |
| `end` | string | Yes | ISO datetime or `YYYY-MM-DD` for all-day; max 64 chars |
| `location` | string | No | Max 2000 characters |
| `description` | string | No | Max 2000 characters |
| `recurrence` | string | No | `none` \| `daily` \| `weekly` \| `monthly` \| `yearly` |
| `recurrenceUntil` | string | No | `YYYY-MM-DD` — maps to RRULE `UNTIL` |
| `recurrenceCount` | integer | No | ≥ 1 — maps to RRULE `COUNT` |
| `calendarUrl` | string | No | CalDAV collection URL (max 1024); defaults to member's first personal calendar |
| Field | Type | Required | Constraints |
| ----------------- | ------- | -------- | ------------------------------------------------------------------------------ |
| `title` | string | Yes | 1255 characters |
| `allDay` | boolean | Yes | |
| `start` | string | Yes | ISO datetime or `YYYY-MM-DD` for all-day; max 64 chars |
| `end` | string | Yes | ISO datetime or `YYYY-MM-DD` for all-day; max 64 chars |
| `location` | string | No | Max 2000 characters |
| `description` | string | No | Max 2000 characters |
| `recurrence` | string | No | `none` \| `daily` \| `weekly` \| `monthly` \| `yearly` |
| `recurrenceUntil` | string | No | `YYYY-MM-DD` — maps to RRULE `UNTIL` |
| `recurrenceCount` | integer | No | ≥ 1 — maps to RRULE `COUNT` |
| `calendarUrl` | string | No | CalDAV collection URL (max 1024); defaults to member's first personal calendar |
`calendarUrl` must belong to the authenticated member or be the shared family calendar. Requests for another member's personal calendar return `403`.
**Response 202**
```json
{ "uid": "f47ac10b-58cc-4372-a567-0e02b2c3d479@familysync" }
```
@@ -183,6 +189,7 @@ If `calendarUrl` in the body differs from the event's current calendar, the oper
**Request body:** Same schema as `POST /api/events/create`.
**Response 202**
```json
{ "uid": "f47ac10b-58cc-4372-a567-0e02b2c3d479@familysync" }
```
@@ -198,6 +205,7 @@ Enqueues a delete for an existing event. Returns `202` immediately.
**Path parameter:** `uid` — the event UID.
**Response 202**
```json
{ "uid": "f47ac10b-58cc-4372-a567-0e02b2c3d479@familysync" }
```
@@ -214,11 +222,12 @@ If no outbox row exists, the write has settled (or never existed).
**Query parameters**
| Parameter | Required | Notes |
|-----------|----------|-------|
| `uid` | Yes | 1512 characters |
| Parameter | Required | Notes |
| --------- | -------- | ---------------- |
| `uid` | Yes | 1512 characters |
**Response 200**
```json
{ "uid": "f47ac10b-...", "status": "done" }
```
@@ -226,6 +235,7 @@ If no outbox row exists, the write has settled (or never existed).
Possible `status` values: `pending` \| `failed` \| `dead` \| `done`.
When `status` is `failed` or `dead`, an `error` field is included:
```json
{ "uid": "f47ac10b-...", "status": "dead", "error": "CalDAV 409 conflict" }
```
@@ -239,6 +249,7 @@ When `status` is `failed` or `dead`, an `error` field is included:
Returns the authoritative writable calendar set for the authenticated member: their own personal calendar(s) and the shared family calendar. Another member's personal calendar is never included.
**Response 200**
```json
{
"calendars": [
@@ -271,6 +282,7 @@ Lists are stored in MariaDB. A list can be private (owner-only) or shared (all h
Returns all lists the authenticated member owns or has been shared. Includes item counts per list.
**Response 200**
```json
{
"lists": [
@@ -302,12 +314,13 @@ Creates a new list. If `isShared: true` (the default), share rows are auto-inser
{ "name": "Gift Ideas", "isShared": true }
```
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `name` | string | Yes | 1255 characters |
| `isShared` | boolean | No | Default: `true` |
| Field | Type | Required | Constraints |
| ---------- | ------- | -------- | ---------------- |
| `name` | string | Yes | 1255 characters |
| `isShared` | boolean | No | Default: `true` |
**Response 201**
```json
{
"id": 2,
@@ -330,6 +343,7 @@ Creates a new list. If `isShared: true` (the default), share rows are auto-inser
Updates a list's `name` and/or `isShared`. At least one field must be present. Only the list owner can change `isShared`; a sharee may rename.
Visibility changes reconcile share rows automatically:
- `false → true`: inserts share rows for all other members.
- `true → false`: deletes all non-owner share rows.
@@ -342,6 +356,7 @@ Visibility changes reconcile share rows automatically:
```
**Response 200**
```json
{
"id": 1,
@@ -364,6 +379,7 @@ Deletes a list. Owner-only. Cascades to all items and share rows.
**Path parameter:** `id` — integer list ID.
**Response 200**
```json
{ "id": 1 }
```
@@ -379,6 +395,7 @@ Returns all items in a list, ordered by fractional rank ascending. Accessible to
**Path parameter:** `id` — integer list ID.
**Response 200**
```json
{
"items": [
@@ -411,11 +428,12 @@ Adds an item to a list at the bottom of the active (unchecked) section. Accessib
{ "text": "Eggs" }
```
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `text` | string | Yes | 1500 characters (plain text only) |
| Field | Type | Required | Constraints |
| ------ | ------ | -------- | ---------------------------------- |
| `text` | string | Yes | 1500 characters (plain text only) |
**Response 201**
```json
{
"id": 11,
@@ -458,11 +476,11 @@ or
{ "position": "a0V" }
```
| Field | Type | Constraints |
|-------|------|-------------|
| `checked` | boolean | |
| `text` | string | 1500 characters |
| `position` | string | Fractional rank string, 1255 characters |
| Field | Type | Constraints |
| ---------- | ------- | ---------------------------------------- |
| `checked` | boolean | |
| `text` | string | 1500 characters |
| `position` | string | Fractional rank string, 1255 characters |
**Response 200** — updated item shape (same as `POST /api/lists/:id/items` response).
@@ -477,6 +495,7 @@ Deletes a list item permanently. Accessible to list owner and sharees. No soft-d
**Path parameter:** `itemId` — integer item ID.
**Response 200**
```json
{ "id": 11 }
```
@@ -494,6 +513,7 @@ SSE streams use `Content-Type: text/event-stream`. The client should reconnect o
Streams `heartbeat` events every 10 seconds indefinitely. Used as a Pangolin tunnel smoke test.
**Event format**
```
event: heartbeat
id: 0
@@ -510,16 +530,17 @@ A `heartbeat` event is sent every 30 seconds to keep the Pangolin connection ali
**Event types**
| Event type | Payload |
|------------|---------|
| `list:updated` | `{ type, listId, payload: { id, name } }` |
| `list:deleted` | `{ type, listId, payload: { id } }` |
| `item:added` | `{ type, listId, payload: { id, listId, text } }` |
| `item:updated` | `{ type, listId, payload: { id, listId } }` |
| `item:deleted` | `{ type, listId, payload: { id } }` |
| `heartbeat` | `{ ts }` |
| Event type | Payload |
| -------------- | ------------------------------------------------- |
| `list:updated` | `{ type, listId, payload: { id, name } }` |
| `list:deleted` | `{ type, listId, payload: { id } }` |
| `item:added` | `{ type, listId, payload: { id, listId, text } }` |
| `item:updated` | `{ type, listId, payload: { id, listId } }` |
| `item:deleted` | `{ type, listId, payload: { id } }` |
| `heartbeat` | `{ ts }` |
**Event format example**
```
event: item:added
id: 1-1718020800000
@@ -541,6 +562,7 @@ Push uses VAPID direct push (no FCM broker). The PWA must be installed to the ho
Returns the server's VAPID public key. The public key is non-secret and required by the browser to call `pushManager.subscribe()`.
**Response 200**
```json
{ "publicKey": "BNF8dFt..." }
```
@@ -563,13 +585,14 @@ Registers a Web Push subscription for the authenticated member. Upserts on `endp
}
```
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `endpoint` | string (URL) | Yes | Max 2048 characters |
| `keys.p256dh` | string | Yes | 1512 characters |
| `keys.auth` | string | Yes | 1256 characters |
| Field | Type | Required | Constraints |
| ------------- | ------------ | -------- | ------------------- |
| `endpoint` | string (URL) | Yes | Max 2048 characters |
| `keys.p256dh` | string | Yes | 1512 characters |
| `keys.auth` | string | Yes | 1256 characters |
**Response 201**
```json
{ "ok": true }
```
@@ -583,6 +606,7 @@ Registers a Web Push subscription for the authenticated member. Upserts on `endp
Removes all push subscription rows for the authenticated member. Cannot affect another member's subscriptions.
**Response 200**
```json
{ "ok": true }
```
@@ -599,14 +623,14 @@ All error responses use a consistent JSON envelope.
{ "error": "Human-readable message" }
```
| HTTP Status | Meaning |
|-------------|---------|
| `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) |
| `404` | Resource not found |
| `422` | Valid request but cannot be fulfilled (e.g., user has no calendar configured) |
| `503` | DB or downstream service unavailable |
| HTTP Status | Meaning |
| ----------- | ------------------------------------------------------------------------------ |
| `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) |
| `404` | Resource not found |
| `422` | Valid request but cannot be fulfilled (e.g., user has no calendar configured) |
| `503` | DB or downstream service unavailable |
Zod validation failures return `400` with a structured body from `@hono/zod-validator` rather than the `{ "error": "..." }` envelope.
+50 -49
View File
@@ -1,4 +1,5 @@
<!-- generated-by: gsd-doc-writer -->
# FamilySync Architecture
FamilySync is a self-hosted family organization hub — a unified, color-coded calendar and shared collaborative lists — delivered as a React PWA. The system is a two-container Docker Compose stack (API + MariaDB) running on Unraid, exposed through a Pangolin/Newt tunnel with Authelia providing OIDC authentication.
@@ -87,34 +88,34 @@ familysync/
### Directory Rationale
| Directory | Purpose |
|-----------|---------|
| `apps/api/src/routes/` | One file per resource — `events.ts`, `lists.ts`, `me.ts`, `push.ts`, `sse.ts`, `health.ts` |
| 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/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/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/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/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/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/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) |
---
## 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`, `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 |
---
@@ -188,35 +189,35 @@ routes/sse.ts ──→ lib/listEmitter.ts (subscribeListEvents)
### Frontend data ownership
| Concern | Owner |
|---------|-------|
| Calendar events | TanStack Query `['events', start, end]` |
| List data + items | TanStack Query `['lists']`, `['list', listId]` |
| Current user | TanStack Query `['me']` |
| Writable calendars | TanStack Query `['writableCalendars']` |
| Outbox sync status | TanStack Query `['syncStatus', uid]` |
| Selected calendar view + date | Zustand `calendarStore` |
| Event form open/mode | Zustand `calendarStore` |
| Active tab, create-list sheet | Zustand `listsStore` |
| Concern | Owner |
| ----------------------------- | ---------------------------------------------- |
| Calendar events | TanStack Query `['events', start, end]` |
| List data + items | TanStack Query `['lists']`, `['list', listId]` |
| Current user | TanStack Query `['me']` |
| Writable calendars | TanStack Query `['writableCalendars']` |
| Outbox sync status | TanStack Query `['syncStatus', uid]` |
| Selected calendar view + date | Zustand `calendarStore` |
| Event form open/mode | Zustand `calendarStore` |
| Active tab, create-list sheet | Zustand `listsStore` |
---
## Infrastructure
| Component | Technology |
|-----------|------------|
| Runtime | Node.js 22 LTS |
| 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 |
| 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` |
| 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) |
| PWA | React 19 + Vite 8 + `vite-plugin-pwa` (Workbox `injectManifest` mode) |
| Networking | Pangolin/Newt tunnel — no open ports; split-DNS internal domain |
| Deployment | Docker Compose on Unraid; single `api` container serves both the API and the PWA static build |
| Component | Technology |
| -------------------- | ------------------------------------------------------------------------------------------------------------------- |
| Runtime | Node.js 22 LTS |
| 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 |
| 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` |
| 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) |
| PWA | React 19 + Vite 8 + `vite-plugin-pwa` (Workbox `injectManifest` mode) |
| Networking | Pangolin/Newt tunnel — no open ports; split-DNS internal domain |
| Deployment | Docker Compose on Unraid; single `api` container serves both the API and the PWA static build |
+50 -48
View File
@@ -1,4 +1,5 @@
<!-- generated-by: gsd-doc-writer -->
# FamilySync — Configuration Reference
All runtime configuration is supplied via environment variables. There are no JSON or YAML config files beyond Docker Compose. Copy `.env.example` to `.env` at the repo root and fill in the values before starting any service.
@@ -9,14 +10,14 @@ All runtime configuration is supplied via environment variables. There are no JS
### Database
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `DB_HOST` | Yes | `localhost` | MariaDB hostname. Use `mariadb` inside Docker Compose; use `localhost` (or `127.0.0.1`) for host-side dev runs. |
| `DB_PORT` | No | `3306` | MariaDB port. |
| `DB_USER` | No | `familysync` | Database user. |
| `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. |
| Variable | Required | Default | Description |
| ------------------ | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------ |
| `DB_HOST` | Yes | `localhost` | MariaDB hostname. Use `mariadb` inside Docker Compose; use `localhost` (or `127.0.0.1`) for host-side dev runs. |
| `DB_PORT` | No | `3306` | MariaDB port. |
| `DB_USER` | No | `familysync` | Database user. |
| `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. |
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.
@@ -26,21 +27,22 @@ Five of these variables — `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_
### OIDC / Authelia Authentication
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `OIDC_AUTH_SECRET` | **Required** | `placeholder_change_me` (Docker default) | 32+ character random string used to sign the `oidc-auth` session JWT cookie. Generate with `openssl rand -base64 32`. |
| `OIDC_ISSUER` | **Required** | _(none)_ | Authelia base URL, e.g. `https://auth.DOMAIN`. The middleware fetches `/.well-known/openid-configuration` from this URL. |
| `OIDC_CLIENT_ID` | No | `familysync` | Registered client ID in Authelia. |
| `OIDC_CLIENT_SECRET` | **Required** | _(none)_ | Plaintext client secret matching the pbkdf2 hash stored in Authelia's `configuration.yml`. Do **not** use the hash here. |
| `OIDC_REDIRECT_URI` | **Required** | _(none)_ | Full callback URL registered in Authelia, e.g. `https://familysync.DOMAIN/callback`. |
| `OIDC_AUTH_EXTERNAL_URL` | **Required** | _(none)_ | Public-facing base URL of the app, e.g. `https://familysync.DOMAIN`. **Mandatory behind the Pangolin/Newt tunnel.** Without it, `@hono/oidc-auth` builds the redirect URI from the internal container hostname, which will not match the registered URI and breaks the OIDC flow. |
| `OIDC_SCOPES` | No | `openid profile email offline_access` | Space-separated list of OIDC scopes to request. `offline_access` is required for refresh-token session persistence. Authelia rejects unknown scopes, so do not add scopes that are not configured on the Authelia client. |
| `OIDC_AUTH_EXPIRES` | No | `86400` | Session cookie `Max-Age` in seconds (default 1 day). Governs the persistent session cookie set by `persistSessionCookie` middleware. |
| `OIDC_COOKIE_NAME` | No | `oidc-auth` | Name of the session cookie. Override only if you need to run multiple instances under the same domain. |
| `OIDC_COOKIE_PATH` | No | `/` | Cookie `Path` attribute. |
| `OIDC_COOKIE_DOMAIN` | No | _(not set)_ | Cookie `Domain` attribute. Set this when the API and PWA are served from different subdomains under the same apex domain. Must match the Authelia and app domains (same-parent-domain requirement). |
| Variable | Required | Default | Description |
| ------------------------ | ------------ | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OIDC_AUTH_SECRET` | **Required** | `placeholder_change_me` (Docker default) | 32+ character random string used to sign the `oidc-auth` session JWT cookie. Generate with `openssl rand -base64 32`. |
| `OIDC_ISSUER` | **Required** | _(none)_ | Authelia base URL, e.g. `https://auth.DOMAIN`. The middleware fetches `/.well-known/openid-configuration` from this URL. |
| `OIDC_CLIENT_ID` | No | `familysync` | Registered client ID in Authelia. |
| `OIDC_CLIENT_SECRET` | **Required** | _(none)_ | Plaintext client secret matching the pbkdf2 hash stored in Authelia's `configuration.yml`. Do **not** use the hash here. |
| `OIDC_REDIRECT_URI` | **Required** | _(none)_ | Full callback URL registered in Authelia, e.g. `https://familysync.DOMAIN/callback`. |
| `OIDC_AUTH_EXTERNAL_URL` | **Required** | _(none)_ | Public-facing base URL of the app, e.g. `https://familysync.DOMAIN`. **Mandatory behind the Pangolin/Newt tunnel.** Without it, `@hono/oidc-auth` builds the redirect URI from the internal container hostname, which will not match the registered URI and breaks the OIDC flow. |
| `OIDC_SCOPES` | No | `openid profile email offline_access` | Space-separated list of OIDC scopes to request. `offline_access` is required for refresh-token session persistence. Authelia rejects unknown scopes, so do not add scopes that are not configured on the Authelia client. |
| `OIDC_AUTH_EXPIRES` | No | `86400` | Session cookie `Max-Age` in seconds (default 1 day). Governs the persistent session cookie set by `persistSessionCookie` middleware. |
| `OIDC_COOKIE_NAME` | No | `oidc-auth` | Name of the session cookie. Override only if you need to run multiple instances under the same domain. |
| `OIDC_COOKIE_PATH` | No | `/` | Cookie `Path` attribute. |
| `OIDC_COOKIE_DOMAIN` | No | _(not set)_ | Cookie `Domain` attribute. Set this when the API and PWA are served from different subdomains under the same apex domain. Must match the Authelia and app domains (same-parent-domain requirement). |
**Locked Authelia client parameters** (these are fixed by the project; do not change):
- `response_types: [code]`
- `grant_types: [authorization_code, refresh_token]`
- `require_pkce: true`, `pkce_challenge_method: S256`
@@ -50,21 +52,21 @@ Five of these variables — `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_
### Broker Encryption
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| Variable | Required | Default | Description |
| ----------------------------- | ------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `APP_PASSWORD_ENCRYPTION_KEY` | **Required** | _(none)_ | 64-character hex string (32 bytes) used as the AES-256-GCM key for encrypting Fastmail app passwords at rest. Generate with: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`. Startup throws if this is missing or the wrong length. |
---
### Web Push (VAPID)
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `VAPID_PUBLIC_KEY` | No* | _(none)_ | URL-safe base64 VAPID public key. Served to the PWA via `GET /api/push/vapid-public-key`. Non-secret. |
| `VAPID_PRIVATE_KEY` | No* | _(none)_ | URL-safe base64 VAPID private key. Used server-side to sign push requests. **Never expose to clients.** |
| `VAPID_SUBJECT` | No* | _(none)_ | Contact URI identifying the operator, e.g. `mailto:admin@example.com` or `https://familysync.DOMAIN`. Must be a `mailto:` or `https:` URL. |
| Variable | Required | Default | Description |
| ------------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `VAPID_PUBLIC_KEY` | No\* | _(none)_ | URL-safe base64 VAPID public key. Served to the PWA via `GET /api/push/vapid-public-key`. Non-secret. |
| `VAPID_PRIVATE_KEY` | No\* | _(none)_ | URL-safe base64 VAPID private key. Used server-side to sign push requests. **Never expose to clients.** |
| `VAPID_SUBJECT` | No\* | _(none)_ | Contact URI identifying the operator, e.g. `mailto:admin@example.com` or `https://familysync.DOMAIN`. Must be a `mailto:` or `https:` URL. |
*All three VAPID variables are optional in the sense that the server starts without them, but push notifications will not function. A startup warning is logged if any are missing. Generate a key pair with:
\*All three VAPID variables are optional in the sense that the server starts without them, but push notifications will not function. A startup warning is logged if any are missing. Generate a key pair with:
```bash
npx web-push generate-vapid-keys --json
@@ -74,10 +76,10 @@ npx web-push generate-vapid-keys --json
### Runtime Mode
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `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. |
| Variable | Required | Default | Description |
| ----------------- | -------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |
---
@@ -85,17 +87,17 @@ 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` |
---
@@ -165,9 +167,9 @@ See `docs/deployment.md` for the full `drizzle-kit migrate` command used to prep
There are no application-level JSON/YAML config files. The two config files that read environment variables at dev/build time are:
| File | Purpose |
|------|---------|
| `apps/api/drizzle.config.ts` | Drizzle Kit migration config — reads `DB_*` variables |
| `apps/pwa/vite.config.ts` | Vite build config — no env var reads; proxy rules for dev server |
| File | Purpose |
| ---------------------------- | ---------------------------------------------------------------- |
| `apps/api/drizzle.config.ts` | Drizzle Kit migration config — reads `DB_*` variables |
| `apps/pwa/vite.config.ts` | Vite build config — no env var reads; proxy rules for dev server |
The PWA Vite dev server proxies `/health`, `/api`, and `/callback` to `http://localhost:3000` so the frontend and API can be developed without CORS configuration.
+26 -24
View File
@@ -1,4 +1,5 @@
<!-- generated-by: gsd-doc-writer -->
# Development Guide
Local development setup and workflows for FamilySync — a pnpm monorepo with two workspaces: `apps/api` (Hono + Node.js) and `apps/pwa` (React + Vite).
@@ -99,37 +100,37 @@ 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 lint` | Run lint in all workspaces (`pnpm -r lint`) |
| `pnpm typecheck` | Run `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 lint` | Run lint in all workspaces (`pnpm -r lint`) |
| `pnpm typecheck` | Run `tsc --noEmit` in all workspaces |
### `apps/api` scripts
| Command | Description |
|---------|-------------|
| `pnpm --filter @familysync/api build` | Compile TypeScript (`tsc`) → `dist/` |
| `pnpm --filter @familysync/api dev` | Start `node --watch dist/index.js` |
| `pnpm --filter @familysync/api start` | Start `node dist/index.js` (no watch) |
| `pnpm --filter @familysync/api test` | Run vitest once (`vitest run`) |
| `pnpm --filter @familysync/api test:watch` | Run vitest in watch mode |
| `pnpm --filter @familysync/api typecheck` | `tsc --noEmit` |
| Command | Description |
| ------------------------------------------- | ------------------------------------------- |
| `pnpm --filter @familysync/api build` | Compile TypeScript (`tsc`) → `dist/` |
| `pnpm --filter @familysync/api dev` | Start `node --watch dist/index.js` |
| `pnpm --filter @familysync/api start` | Start `node dist/index.js` (no watch) |
| `pnpm --filter @familysync/api test` | Run vitest once (`vitest run`) |
| `pnpm --filter @familysync/api test:watch` | Run vitest in watch mode |
| `pnpm --filter @familysync/api typecheck` | `tsc --noEmit` |
| `pnpm --filter @familysync/api db:generate` | Generate SQL migrations from schema changes |
| `pnpm --filter @familysync/api db:migrate` | Apply pending migrations to the database |
| `pnpm --filter @familysync/api db:migrate` | Apply pending migrations to the database |
### `apps/pwa` scripts
| Command | Description |
|---------|-------------|
| `pnpm --filter @familysync/pwa dev` | Start Vite dev server with HMR |
| `pnpm --filter @familysync/pwa build` | `tsc && vite build``dist/` |
| `pnpm --filter @familysync/pwa preview` | Serve the production build locally |
| `pnpm --filter @familysync/pwa typecheck` | `tsc --noEmit` |
| `pnpm --filter @familysync/pwa test` | Run vitest once |
| Command | Description |
| ----------------------------------------- | ---------------------------------- |
| `pnpm --filter @familysync/pwa dev` | Start Vite dev server with HMR |
| `pnpm --filter @familysync/pwa build` | `tsc && vite build``dist/` |
| `pnpm --filter @familysync/pwa preview` | Serve the production build locally |
| `pnpm --filter @familysync/pwa typecheck` | `tsc --noEmit` |
| `pnpm --filter @familysync/pwa test` | Run vitest once |
## Drizzle Migration Workflow
@@ -191,6 +192,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up mariadb redis
```
The dev override:
- Exposes MariaDB on `localhost:3306`
- Exposes Redis on `localhost:6379`
- Mounts `apps/api/src` into the container for live source access
+8 -5
View File
@@ -1,4 +1,5 @@
<!-- generated-by: gsd-doc-writer -->
# FamilySync — Getting Started
This guide walks from a fresh clone to a running local development environment.
@@ -7,11 +8,11 @@ This guide walks from a fresh clone to a running local development environment.
## Prerequisites
| Requirement | Version | Notes |
|-------------|---------|-------|
| Node.js | `22 LTS` | Matches the `node:22-alpine` base in `apps/api/Dockerfile` |
| pnpm | `11.5.1` | Pinned in `package.json` `packageManager` field; enable via `corepack enable pnpm` |
| Docker + Docker Compose | Any recent version | Used to run MariaDB and Redis locally |
| Requirement | Version | Notes |
| ----------------------- | ------------------ | ---------------------------------------------------------------------------------- |
| Node.js | `22 LTS` | Matches the `node:22-alpine` base in `apps/api/Dockerfile` |
| pnpm | `11.5.1` | Pinned in `package.json` `packageManager` field; enable via `corepack enable pnpm` |
| Docker + Docker Compose | Any recent version | Used to run MariaDB and Redis locally |
**Node version management:** If you use nvm or fnm, install Node 22 LTS and set it as the default before continuing. There is no `.nvmrc` in the repo; the target version comes from the Dockerfile.
@@ -111,9 +112,11 @@ MariaDB may still be initialising. Wait a few seconds and retry. If the error pe
**`Cannot connect to DB_HOST=mariadb`**
The API is running on the host but `.env` still has `DB_HOST=mariadb` (the Docker network hostname). Override it inline:
```bash
DB_HOST=localhost pnpm --filter @familysync/api dev
```
Or set `DB_HOST=localhost` directly in your `.env` for host-side dev.
**API starts but all requests return 401 / redirect to Authelia**
+8 -7
View File
@@ -1,14 +1,15 @@
<!-- generated-by: gsd-doc-writer -->
# Testing
## Test framework and setup
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 | Setup file |
| ---------- | ----------- | ---------------------------- |
| `apps/api` | `node` | `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.
@@ -84,10 +85,10 @@ Pure-logic tests (e.g. `apps/api/tests/broker/expand.test.ts`, `apps/api/tests/l
### File naming and location
| App | Convention | Example |
|-----|------------|---------|
| App | Convention | Example |
| ---------- | ------------------------------------- | ------------------------------------ |
| `apps/api` | `apps/api/tests/{category}/*.test.ts` | `apps/api/tests/routes/push.test.ts` |
| `apps/pwa` | co-located `*.test.ts` / `*.test.tsx` | `src/components/AppNav.test.tsx` |
| `apps/pwa` | co-located `*.test.ts` / `*.test.tsx` | `src/components/AppNav.test.tsx` |
Test categories for `apps/api`:
+13 -12
View File
@@ -1,4 +1,5 @@
<!-- generated-by: gsd-doc-writer -->
# FamilySync — Deployment Guide
Self-hosted Docker deployment on Unraid behind Authelia OIDC and a Pangolin/Newt outbound tunnel. The API serves the compiled React PWA as static files on a single port (3000), so only one route needs to be exposed through the tunnel.
@@ -7,19 +8,19 @@ Self-hosted Docker deployment on Unraid behind Authelia OIDC and a Pangolin/Newt
## Deployment Targets
| Target | Config file |
|--------|-------------|
| Docker Compose (production) | `docker-compose.yml` |
| Docker Compose (dev override) | `docker-compose.dev.yml` |
| Container image | `apps/api/Dockerfile` (multi-stage, built from repo root) |
| Target | Config file |
| ----------------------------- | --------------------------------------------------------- |
| Docker Compose (production) | `docker-compose.yml` |
| Docker Compose (dev override) | `docker-compose.dev.yml` |
| Container image | `apps/api/Dockerfile` (multi-stage, built from repo root) |
The production compose file brings up three services:
| Service | Image | Purpose |
|---------|-------|---------|
| `api` | Built from `apps/api/Dockerfile` target `production` | Hono API + compiled React PWA, listens on port 3000 |
| `mariadb` | `mariadb:11` | Persistent MariaDB database |
| `redis` | `redis:7-alpine` | Present for live list sync (pub/sub); unused until Phase 4 |
| Service | Image | Purpose |
| --------- | ---------------------------------------------------- | ---------------------------------------------------------- |
| `api` | Built from `apps/api/Dockerfile` target `production` | Hono API + compiled React PWA, listens on port 3000 |
| `mariadb` | `mariadb:11` | Persistent MariaDB database |
| `redis` | `redis:7-alpine` | Present for live list sync (pub/sub); unused until Phase 4 |
---
@@ -43,7 +44,7 @@ Add the following client block to your Authelia `configuration.yml` under `ident
public: false
authorization_policy: one_factor
redirect_uris:
- https://familysync.DOMAIN/callback # replace DOMAIN with your actual domain
- https://familysync.DOMAIN/callback # replace DOMAIN with your actual domain
scopes:
- openid
- profile
@@ -155,7 +156,7 @@ Configure the Pangolin route to forward HTTPS traffic for `https://familysync.DO
```yaml
ports:
- "3000:3000"
- '3000:3000'
```
Ensure `OIDC_AUTH_EXTERNAL_URL` and `OIDC_REDIRECT_URI` in `.env` match the public hostname Pangolin exposes. Without `OIDC_AUTH_EXTERNAL_URL`, the OIDC middleware builds the callback URI from the internal container hostname, which will not match the URI registered in Authelia and will break the login flow.