docs: generate project documentation

This commit is contained in:
Lucas Berger
2026-06-10 18:17:51 -04:00
parent 581b31916b
commit a9c3304c4e
10 changed files with 2031 additions and 279 deletions
+617
View File
@@ -0,0 +1,617 @@
<!-- 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.
## Authentication
The API uses OIDC session cookies managed by `@hono/oidc-auth`. The authorization-code + PKCE flow is handled transparently:
1. The PWA navigates to `GET /api/login`. This route sits under `/api/*`, where the `@hono/oidc-auth` guard is mounted.
2. For a request with no valid session cookie, the OIDC middleware intercepts it before the route handler runs and 302-redirects to Authelia's authorize endpoint.
3. Authelia posts the authorization code to `GET /callback`, which exchanges it for tokens and sets an `httpOnly; Secure; SameSite` session cookie, then continues back to `/api/login` — whose handler now redirects to `/` (the app shell).
4. All subsequent `/api/*` requests carry the session cookie automatically. (The root `/` and static assets are served outside the `/api/*` guard.)
**Dev bypass:** When `DEV_AUTH_BYPASS=true` and `NODE_ENV != production`, the OIDC guard is disabled and a fixed dev user (id `1`) is injected into every request. Never enable in production.
No API key or `Authorization` header is used. Credentials are never included in response bodies.
## 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 |
---
## Health
### `GET /health`
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" }
```
---
## Identity
### `GET /api/me`
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": {
"id": 1,
"displayName": "Lucas",
"color": "#4A90D9"
}
}
```
**Error responses:** `401` if the session is invalid.
---
## Calendar Events
Calendar data is read from a MariaDB cache populated by the CalDAV broker poller. Write operations enqueue outbox rows; the outbox worker dispatches them to Fastmail CalDAV asynchronously. Clients receive `202 Accepted` immediately and poll `GET /api/events/sync-status` to confirm settlement.
### `GET /api/events?start=YYYY-MM-DD&end=YYYY-MM-DD`
Returns a flat array of concrete event occurrences for the given date window. Recurring events are expanded server-side via `rrule`. The window is capped at 90 days. Returns events from calendars the member owns plus any shared (family) calendars.
**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 |
**Response 200**
```json
{
"occurrences": [
{
"uid": "abc123@familysync",
"title": "Doctor appointment",
"allDay": false,
"start": "2025-06-15T09:00:00Z",
"end": "2025-06-15T10:00:00Z",
"calendarId": 3,
"calendarName": "Personal",
"color": "#4A90D9",
"isShared": false,
"userId": 1,
"ownerName": "Lucas"
}
]
}
```
**Error responses:** `400` if the window is invalid or exceeds 90 days; `401` unauthorized; `503` DB error.
---
### `POST /api/events/create`
Enqueues a new event for creation on Fastmail CalDAV. Returns `202` immediately with the assigned UID.
**Request body**
```json
{
"title": "Family dinner",
"allDay": false,
"start": "2025-06-20T18:00:00Z",
"end": "2025-06-20T20:00:00Z",
"location": "Home",
"description": "Optional notes",
"recurrence": "weekly",
"recurrenceUntil": "2025-12-31",
"recurrenceCount": 10,
"calendarUrl": "https://caldav.fastmail.com/dav/.../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" }
```
**Error responses:** `401` unauthorized; `403` calendar access denied; `422` no writable calendar found; `503` DB error.
---
### `PATCH /api/events/:uid/edit`
Enqueues an update to an existing event. Body shape is identical to `POST /api/events/create`. Returns `202` with the (possibly new) UID.
If `calendarUrl` in the body differs from the event's current calendar, the operation is a calendar move: a delete+create pair is enqueued atomically. A new UID is assigned and returned.
**Path parameter:** `uid` — the event UID as returned by `POST /api/events/create` or `GET /api/events`.
**Request body:** Same schema as `POST /api/events/create`.
**Response 202**
```json
{ "uid": "f47ac10b-58cc-4372-a567-0e02b2c3d479@familysync" }
```
**Error responses:** `401` unauthorized; `403` access denied; `404` event not found; `503` DB error.
---
### `DELETE /api/events/:uid`
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" }
```
**Error responses:** `401` unauthorized; `403` access denied; `404` event not found; `503` DB error.
---
### `GET /api/events/sync-status?uid=<uid>`
Returns the outbox status for a given UID, scoped to the authenticated member. Used by the PWA to confirm write settlement after `202` responses.
If no outbox row exists, the write has settled (or never existed).
**Query parameters**
| Parameter | Required | Notes |
|-----------|----------|-------|
| `uid` | Yes | 1512 characters |
**Response 200**
```json
{ "uid": "f47ac10b-...", "status": "done" }
```
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" }
```
**Error responses:** `401` unauthorized; `503` DB error.
---
### `GET /api/events/writable-calendars`
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": [
{
"url": "https://caldav.fastmail.com/dav/.../Personal/",
"displayName": "Personal",
"color": "#4A90D9",
"isShared": false
},
{
"url": "https://caldav.fastmail.com/dav/.../Family/",
"displayName": "Family",
"color": "#F25C7A",
"isShared": true
}
]
}
```
**Error responses:** `401` unauthorized; `503` DB error.
---
## Lists
Lists are stored in MariaDB. A list can be private (owner-only) or shared (all household members). When `isShared: true`, share rows are created automatically for every other member — there is no client-facing shares endpoint.
### `GET /api/lists`
Returns all lists the authenticated member owns or has been shared. Includes item counts per list.
**Response 200**
```json
{
"lists": [
{
"id": 1,
"name": "Groceries",
"isShared": true,
"ownerId": 1,
"activeCount": 5,
"doneCount": 2,
"createdAt": "2025-06-01T10:00:00.000Z",
"updatedAt": "2025-06-10T08:00:00.000Z"
}
]
}
```
**Error responses:** `401` unauthorized; `503` DB error.
---
### `POST /api/lists`
Creates a new list. If `isShared: true` (the default), share rows are auto-inserted for all other members.
**Request body**
```json
{ "name": "Gift Ideas", "isShared": true }
```
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `name` | string | Yes | 1255 characters |
| `isShared` | boolean | No | Default: `true` |
**Response 201**
```json
{
"id": 2,
"name": "Gift Ideas",
"isShared": true,
"ownerId": 1,
"activeCount": 0,
"doneCount": 0,
"createdAt": "2025-06-10T12:00:00.000Z",
"updatedAt": "2025-06-10T12:00:00.000Z"
}
```
**Error responses:** `401` unauthorized; `503` DB error.
---
### `PATCH /api/lists/:id`
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.
**Path parameter:** `id` — integer list ID.
**Request body** (at least one field required)
```json
{ "name": "Weekly Groceries", "isShared": false }
```
**Response 200**
```json
{
"id": 1,
"name": "Weekly Groceries",
"isShared": false,
"ownerId": 1,
"createdAt": "2025-06-01T10:00:00.000Z",
"updatedAt": "2025-06-10T13:00:00.000Z"
}
```
**Error responses:** `401` unauthorized; `403` access denied or sharee attempted `isShared` change; `404` list not found; `503` DB error.
---
### `DELETE /api/lists/:id`
Deletes a list. Owner-only. Cascades to all items and share rows.
**Path parameter:** `id` — integer list ID.
**Response 200**
```json
{ "id": 1 }
```
**Error responses:** `401` unauthorized; `403` access denied (non-owner); `404` list not found; `503` DB error.
---
### `GET /api/lists/:id/items`
Returns all items in a list, ordered by fractional rank ascending. Accessible to owner and sharees.
**Path parameter:** `id` — integer list ID.
**Response 200**
```json
{
"items": [
{
"id": 10,
"listId": 1,
"text": "Milk",
"checked": false,
"rank": "a0",
"createdAt": "2025-06-10T10:00:00.000Z",
"updatedAt": "2025-06-10T10:00:00.000Z"
}
]
}
```
**Error responses:** `401` unauthorized; `403` access denied; `404` list not found; `503` DB error.
---
### `POST /api/lists/:id/items`
Adds an item to a list at the bottom of the active (unchecked) section. Accessible to owner and sharees.
**Path parameter:** `id` — integer list ID.
**Request body**
```json
{ "text": "Eggs" }
```
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `text` | string | Yes | 1500 characters (plain text only) |
**Response 201**
```json
{
"id": 11,
"listId": 1,
"text": "Eggs",
"checked": false,
"rank": "a1",
"createdAt": "2025-06-10T14:00:00.000Z",
"updatedAt": "2025-06-10T14:00:00.000Z"
}
```
**Error responses:** `401` unauthorized; `403` access denied; `404` list not found; `503` DB error.
---
### `PATCH /api/list-items/:itemId`
Updates exactly one field of a list item (last-write-wins). Exactly one of `checked`, `text`, or `position` must be present.
Special case: setting `checked: false` (uncheck) also recomputes the item's rank to place it at the bottom of the active section.
**Path parameter:** `itemId` — integer item ID.
**Request body** (exactly one field)
```json
{ "checked": true }
```
or
```json
{ "text": "Whole milk" }
```
or
```json
{ "position": "a0V" }
```
| 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).
**Error responses:** `401` unauthorized; `403` access denied; `404` item not found; `503` DB error.
---
### `DELETE /api/list-items/:itemId`
Deletes a list item permanently. Accessible to list owner and sharees. No soft-delete; deletion is final.
**Path parameter:** `itemId` — integer item ID.
**Response 200**
```json
{ "id": 11 }
```
**Error responses:** `401` unauthorized; `403` access denied; `404` item not found; `503` DB error.
---
## Server-Sent Events
SSE streams use `Content-Type: text/event-stream`. The client should reconnect on disconnect with bounded backoff (handled by the `useListSSE` hook in the PWA).
### `GET /api/sse/heartbeat`
Streams `heartbeat` events every 10 seconds indefinitely. Used as a Pangolin tunnel smoke test.
**Event format**
```
event: heartbeat
id: 0
data: {"ts":"2025-06-10T14:00:00.000Z","id":0}
```
---
### `GET /api/sse/lists`
Scoped live-list fan-out stream. Delivers mutation events only for lists the authenticated member can access (owned + shared). Events for other members' private lists are never delivered.
A `heartbeat` event is sent every 30 seconds to keep the Pangolin connection alive.
**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 format example**
```
event: item:added
id: 1-1718020800000
data: {"type":"item:added","listId":1,"payload":{"id":11,"listId":1,"text":"Eggs"}}
```
The PWA treats these events as cache invalidation signals and full-refetches via TanStack Query. Event payloads are not used directly for cache updates.
**Error responses:** `401` if session is invalid (returned as JSON before the stream opens).
---
## Push Notifications
Push uses VAPID direct push (no FCM broker). The PWA must be installed to the home screen on iOS 16.4+ for push to function. `pushManager.subscribe()` must be called inside a user gesture handler.
### `GET /api/push/vapid-public-key`
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..." }
```
---
### `POST /api/push/subscription`
Registers a Web Push subscription for the authenticated member. Upserts on `endpoint` — re-subscribing from the same device updates keys without creating duplicate rows. The `userId` is derived from the OIDC session and never accepted from the request body.
**Request body**
```json
{
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {
"p256dh": "BNF8dFt...",
"auth": "tBHItJI..."
}
}
```
| 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 }
```
**Error responses:** `401` unauthorized; `503` DB error.
---
### `DELETE /api/push/subscription`
Removes all push subscription rows for the authenticated member. Cannot affect another member's subscriptions.
**Response 200**
```json
{ "ok": true }
```
**Error responses:** `401` unauthorized; `503` DB error.
---
## Error Codes
All error responses use a consistent JSON envelope.
```json
{ "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 |
Zod validation failures return `400` with a structured body from `@hono/zod-validator` rather than the `{ "error": "..." }` envelope.
---
## Rate Limits
No rate limiting is configured in the application layer. <!-- VERIFY: confirm whether Pangolin/Newt or Authelia enforce rate limits at the network edge -->