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:
+101
-77
@@ -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 | 1–255 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 | 1–255 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 | 1–512 characters |
|
||||
| Parameter | Required | Notes |
|
||||
| --------- | -------- | ---------------- |
|
||||
| `uid` | Yes | 1–512 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 | 1–255 characters |
|
||||
| `isShared` | boolean | No | Default: `true` |
|
||||
| Field | Type | Required | Constraints |
|
||||
| ---------- | ------- | -------- | ---------------- |
|
||||
| `name` | string | Yes | 1–255 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 | 1–500 characters (plain text only) |
|
||||
| Field | Type | Required | Constraints |
|
||||
| ------ | ------ | -------- | ---------------------------------- |
|
||||
| `text` | string | Yes | 1–500 characters (plain text only) |
|
||||
|
||||
**Response 201**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 11,
|
||||
@@ -458,11 +476,11 @@ or
|
||||
{ "position": "a0V" }
|
||||
```
|
||||
|
||||
| Field | Type | Constraints |
|
||||
|-------|------|-------------|
|
||||
| `checked` | boolean | |
|
||||
| `text` | string | 1–500 characters |
|
||||
| `position` | string | Fractional rank string, 1–255 characters |
|
||||
| Field | Type | Constraints |
|
||||
| ---------- | ------- | ---------------------------------------- |
|
||||
| `checked` | boolean | |
|
||||
| `text` | string | 1–500 characters |
|
||||
| `position` | string | Fractional rank string, 1–255 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 | 1–512 characters |
|
||||
| `keys.auth` | string | Yes | 1–256 characters |
|
||||
| Field | Type | Required | Constraints |
|
||||
| ------------- | ------------ | -------- | ------------------- |
|
||||
| `endpoint` | string (URL) | Yes | Max 2048 characters |
|
||||
| `keys.p256dh` | string | Yes | 1–512 characters |
|
||||
| `keys.auth` | string | Yes | 1–256 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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user