Files
familysync/docs/API.md
Lucas BergerandClaude Opus 4.8 6ade8d59bf
Publish / publish (push) Successful in 11s
docs: correct rrule drift (code uses ical.js RecurExpansion) + minor fixes
Recurrence expansion uses ical.js ICAL.RecurExpansion, not the rrule
library (never installed/imported). Also fix playwright-cli path
(/usr/bin), prod-compose service count (two, post-Redis-removal), and
CI job count (six). Found via /gsd-docs-update --verify-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:29:03 -04:00

1128 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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, local username/password, or `DEV_AUTH_BYPASS=true` for local development). Unauthenticated requests to protected routes receive a `302` redirect to Authelia's authorize endpoint, not a `401`, except where noted.
## Authentication
The API supports two session mechanisms that coexist on the same `/api/*` guard chain:
**OIDC session (Authelia):** Managed by `@hono/oidc-auth`. The authorization-code + PKCE flow is handled transparently:
1. The PWA navigates to `GET /api/login`. This route sits under `/api/*`, where the `@hono/oidc-auth` guard is mounted.
2. For a request with no valid session cookie, the OIDC middleware intercepts it before the route handler runs and 302-redirects to Authelia's authorize endpoint.
3. Authelia posts the authorization code to `GET /callback`, which exchanges it for tokens and sets an `httpOnly; Secure; SameSite` session cookie, then continues back to `/api/login` — whose handler now redirects to `/` (the app shell).
4. All subsequent `/api/*` requests carry the session cookie automatically.
**Local session (username + password):** Available when OIDC is not configured or for members who have not linked an OIDC identity. `POST /api/auth/local/login` issues a signed JWT `local-session` cookie. The local-session middleware validates it on every `/api/*` request and sets the user context, so OIDC guard is bypassed for already-authenticated local users.
**Session precedence:** Local-session middleware runs first. If `c.get('user')` is already set (local session or dev bypass), the OIDC guard is skipped entirely.
**Dev bypass:** When `DEV_AUTH_BYPASS=true` and `NODE_ENV != production`, the OIDC guard is disabled and a fixed dev user (id `1`) is injected into every request. Never enable in production.
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/setup/status` | None | Setup wizard completion status |
| POST | `/api/setup/config` | None | Store OIDC and VAPID config (wizard step 1) |
| POST | `/api/setup/validate/db` | None | Validate DB connectivity (wizard step) |
| POST | `/api/setup/validate/oidc` | None | Validate OIDC issuer discovery (wizard step) |
| POST | `/api/setup/validate/vapid` | None | Validate VAPID key pair (wizard step) |
| POST | `/api/setup/credential` | None | Store first admin's Fastmail credential (wizard) |
| POST | `/api/setup/complete` | None | Lock the setup wizard |
| GET | `/api/auth/mode` | None | Auth mode discovery (local vs OIDC enabled) |
| POST | `/api/auth/local/login` | None | Local username+password login |
| POST | `/api/auth/local/logout` | None | Clear local session cookie |
| GET | `/api/auth/local/logout` | None | Clear local session cookie (browser redirect alias) |
| GET | `/api/login` | OIDC | OIDC login entry point, redirects to `/` |
| GET | `/api/me` | Session | Current user identity, role, and setup status |
| POST | `/api/me/credential` | Session | Member self-service Fastmail credential update |
| POST | `/api/me/password` | Session | Member self-change local password |
| POST | `/api/me/link-oidc` | Session | Initiate OIDC identity link for a local user |
| GET | `/api/events` | Session | Windowed calendar occurrences |
| POST | `/api/events/create` | Session | Enqueue a new event write |
| PATCH | `/api/events/:uid/edit` | Session | Enqueue an event update |
| DELETE | `/api/events/:uid` | Session | Enqueue an event delete |
| GET | `/api/events/sync-status` | Session | Outbox status for a UID |
| GET | `/api/events/writable-calendars` | Session | Calendars the member can write to |
| GET | `/api/lists` | Session | All lists accessible to the member |
| POST | `/api/lists` | Session | Create a list |
| PATCH | `/api/lists/:id` | Session | Update list name or sharing |
| DELETE | `/api/lists/:id` | Session | Delete a list (owner only) |
| GET | `/api/lists/:id/items` | Session | All items in a list |
| POST | `/api/lists/:id/items` | Session | Add an item to a list |
| PATCH | `/api/list-items/:itemId` | Session | Update a single list item field |
| DELETE | `/api/list-items/:itemId` | Session | Delete a list item |
| GET | `/api/sse/heartbeat` | Session | SSE heartbeat stream |
| GET | `/api/sse/lists` | Session | Scoped live-list SSE stream |
| GET | `/api/push/vapid-public-key` | Session | VAPID public key for push subscribe |
| POST | `/api/push/subscription` | Session | Register a push subscription |
| DELETE | `/api/push/subscription` | Session | Remove push subscriptions for caller |
| GET | `/api/admin/members` | Admin | List members with credential status |
| POST | `/api/admin/members` | Admin | Create a new local member |
| POST | `/api/admin/members/:id/password` | Admin | Reset a local member's password |
| POST | `/api/admin/credentials` | Admin | Validate and store a member's Fastmail credential |
| GET | `/api/admin/calendars` | Admin | List synced calendars |
| PUT | `/api/admin/calendars/:id/shared` | Admin | Designate the shared family calendar |
| GET | `/api/admin/config/timezone` | Admin | Get household timezone |
| PUT | `/api/admin/config/timezone` | Admin | Set household timezone |
| POST | `/api/admin/config/timezone/seed` | Admin | Seed household timezone if not yet set |
---
## 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" }
```
---
## Setup Wizard
The setup wizard surface is reachable pre-authentication. All setup routes return `423` once `isSetupLocked()` returns true (i.e., after `POST /api/setup/complete` has been called or the database has an effective OIDC configuration).
### `GET /api/setup/status`
Returns whether the first-run wizard has been completed. Always reachable (no 423 guard — the PWA needs this to decide whether to show the wizard).
**Response 200**
```json
{ "setupComplete": false, "dbName": "familysync" }
```
`dbName` is the value of the `DB_NAME` environment variable (non-secret, for display in the wizard UI). `setupComplete: true` indicates the wizard is locked.
---
### `POST /api/setup/config`
Stores non-secret OIDC and VAPID configuration into `app_config`. Returns `423` if setup is already locked.
**Request body**
```json
{
"oidcIssuer": "https://auth.example.com",
"oidcClientId": "familysync",
"vapidPublicKey": "BNF8dFt...",
"appExternalUrl": "https://familysync.example.com"
}
```
| Field | Type | Required | Constraints |
| ---------------- | ------ | -------- | ----------------------------- |
| `oidcIssuer` | string | Yes | HTTPS URL |
| `oidcClientId` | string | Yes | 1256 characters |
| `vapidPublicKey` | string | Yes | 1512 characters |
| `appExternalUrl` | string | Yes | HTTPS URL, max 512 characters |
**Response 200**
```json
{ "ok": true }
```
---
### `POST /api/setup/validate/db`
Proves DB connectivity via `SELECT 1`. Returns `423` if setup is locked.
**Response 200**`{ "ok": true }`
**Response 503**`{ "ok": false, "error": "DB unavailable" }`
---
### `POST /api/setup/validate/oidc`
Fetches `{oidcIssuer}/.well-known/openid-configuration` (5-second timeout) to validate the issuer stored by `POST /api/setup/config`. Returns `423` if setup is locked.
**Response 200**`{ "ok": true }`
**Response 400**`{ "ok": false, "error": "OIDC discovery failed. Check the issuer URL." }`
---
### `POST /api/setup/validate/vapid`
Validates the VAPID public key stored in `app_config` against the `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` environment variables. The submitted public key must exactly equal `process.env.VAPID_PUBLIC_KEY`. Returns `423` if setup is locked.
**Response 200**`{ "ok": true }`
**Response 400**`{ "ok": false, "error": "VAPID public key does not match..." }`
---
### `POST /api/setup/credential`
Creates the first admin user (no OIDC identity yet, `claimed: false`) and validates/encrypts/stores their Fastmail CalDAV app password. Returns `423` if setup is locked; `409` if an unclaimed admin row already exists (concurrent wizard request).
**Request body**
```json
{
"fastmailEmail": "broker@fastmail.com",
"appPassword": "xxxx-xxxx-xxxx-xxxx"
}
```
| Field | Type | Required | Constraints |
| --------------- | ------ | -------- | ------------------------------- |
| `fastmailEmail` | string | Yes | Valid email, max 256 characters |
| `appPassword` | string | Yes | 1500 characters |
Zod validation errors for this route never echo received values (the app password is never included in error responses).
**Response 200**`{ "ok": true }`
**Error responses:** `400` invalid request; `409` setup already in progress; `423` setup locked; `503` service unavailable.
---
### `POST /api/setup/complete`
Locks the setup wizard by writing `setup_complete=true` to `app_config`. Requires an unclaimed admin user with a Fastmail credential to exist (guards against skipping the credential step). Returns `423` if already locked.
**Response 200**`{ "ok": true }`
**Response 422**`{ "error": "Cannot lock setup: no credential configured" }`
---
## Auth Mode
### `GET /api/auth/mode`
Pre-auth endpoint. Returns which authentication methods are currently enabled. Used by the PWA on app load to decide which login flow to present.
`oidcEnabled` is true when `OIDC_ISSUER` is set in the environment **or** when `app_config` has an `oidc_issuer` row (wizard-configured OIDC before container restart). `localEnabled` is always `true`.
**Response 200**
```json
{ "localEnabled": true, "oidcEnabled": false }
```
---
## Local Auth
### `POST /api/auth/local/login`
Validates a username + password against `local_credentials` and issues a signed `local-session` JWT cookie. Pre-auth — reachable without a session.
Rate limiting is per-username (not per-IP):
- 5 failures within 60 seconds → `429 Too Many Requests`
- 10 cumulative failures → `423 Account Locked` (auto-expires after 15 minutes or on admin password reset)
Timing-oracle defense: `verifyPassword` (scrypt) is always called, even for unknown usernames.
**Request body**
```json
{ "username": "alice", "password": "hunter2" }
```
| Field | Type | Required | Constraints |
| ---------- | ------ | -------- | -------------------------- |
| `username` | string | Yes | 1128 characters (trimmed) |
| `password` | string | Yes | 11000 characters |
Zod validation errors never echo received values.
**Response 200**
```json
{ "ok": true }
```
Sets a `local-session` cookie (`httpOnly; Secure; SameSite`).
**Error responses:** `400` invalid request; `401` invalid credentials (same body for wrong password and unknown username — no field discrimination); `423` account locked; `429` too many attempts; `503` service unavailable.
---
### `POST /api/auth/local/logout`
Clears the `local-session` cookie. Also available as `GET /api/auth/local/logout` for browser-redirect compatibility.
**Response 200**`{ "ok": true }`
---
## Identity
### `GET /api/me`
Returns the authenticated member's identity, admin role, and credential setup status.
Display name is derived from OIDC claims in priority order: `name``preferred_username``email``sub`. The user row is upserted on first OIDC visit (keyed on `oidc_iss` + `oidc_sub`).
`isAdmin` is exposed for PWA navigation gating only — it is not the security boundary. The server enforces admin role via `requireAdmin` middleware on every `/api/admin/*` request.
**Response 200**
```json
{
"user": {
"id": 1,
"displayName": "Lucas",
"color": "#4A90D9",
"isAdmin": true,
"needsProviderSetup": false,
"hasLocalCredential": true
}
}
```
| Field | Type | Description |
| -------------------- | ------- | ---------------------------------------------------------- |
| `id` | integer | Stable member ID |
| `displayName` | string | Derived from OIDC claims or set by admin |
| `color` | string | Member's assigned color (hex) |
| `isAdmin` | boolean | Whether the member has the admin role |
| `needsProviderSetup` | boolean | True when no Fastmail credential is stored for this member |
| `hasLocalCredential` | boolean | True when a local username/password credential exists |
**Error responses:** `401` if the session is invalid.
---
### `POST /api/me/credential`
Member self-service endpoint to set or rotate their own Fastmail CalDAV app password. Validates against CalDAV (PROPFIND) before storing. Always writes to the authenticated member's record — the request body cannot specify a different user ID.
**Request body**
```json
{
"providerType": "caldav",
"fastmailEmail": "member@fastmail.com",
"appPassword": "xxxx-xxxx-xxxx-xxxx"
}
```
| Field | Type | Required | Constraints |
| --------------- | ------ | -------- | ------------------------------- |
| `providerType` | string | Yes | Must be `"caldav"` |
| `fastmailEmail` | string | Yes | Valid email, max 256 characters |
| `appPassword` | string | Yes | 1500 characters |
**Response 200**`{ "ok": true }`
**Error responses:** `400` invalid request or CalDAV validation failed; `401` unauthorized; `503` service unavailable.
---
### `POST /api/me/password`
Self-service password change for local-auth members. Requires the current password to be supplied. Returns `403` (not `401`) for a wrong current password to avoid triggering the PWA's global session-expiry handler.
**Request body**
```json
{
"currentPassword": "old-password",
"newPassword": "new-password-min8"
}
```
| Field | Type | Required | Constraints |
| ----------------- | ------ | -------- | -------------------- |
| `currentPassword` | string | Yes | 1+ characters |
| `newPassword` | string | Yes | Minimum 8 characters |
**Response 200**`{ "ok": true }`
**Error responses:** `400` invalid request; `401` unauthorized; `403` current password incorrect; `404` no local credential found; `503` service unavailable.
---
### `POST /api/me/link-oidc`
Initiates the OIDC authorization-code flow for a locally-authenticated member. Returns a signed `state` JWT and the OIDC authorization URL. The PWA redirects the user to the authorization URL; on successful OIDC login, the `/callback` handler binds the OIDC identity to the local user and removes the local credential.
Returns `authorizationUrl: null` when OIDC is not configured.
**Response 200**
```json
{
"signedState": "eyJ...",
"authorizationUrl": "https://auth.example.com/api/oidc/authorization?..."
}
```
**Error responses:** `401` unauthorized; `503` service unavailable (missing `LOCAL_SESSION_SECRET`).
---
## Calendar Events
Calendar data is read from a MariaDB cache populated by the CalDAV broker poller. Write operations enqueue outbox rows; the outbox worker dispatches them to Fastmail CalDAV asynchronously. Clients receive `202 Accepted` immediately and poll `GET /api/events/sync-status` to confirm settlement.
### `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 `ical.js` (`ICAL.RecurExpansion`). 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**
```text
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**
```text
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.
---
## Admin
All `/api/admin/*` routes require the authenticated member to have the admin role (`users.isAdmin = true`). The `requireAdmin` middleware is the first statement on the admin router — no sub-route is reachable without passing this guard. Non-admins receive `403`.
### `GET /api/admin/members`
Returns all household members with their credential and local-auth status.
**Response 200**
```json
{
"members": [
{
"id": 1,
"displayName": "Lucas",
"color": "#4A90D9",
"hasCredential": true,
"hasLocalCredential": true
}
]
}
```
---
### `POST /api/admin/members`
Creates a new local-auth member: inserts a `users` row and a `local_credentials` row with a hashed initial password in a single transaction. Returns `409` if the username is already in use.
**Request body**
```json
{
"displayName": "Alice",
"username": "alice",
"initialPassword": "minimum8chars"
}
```
| Field | Type | Required | Constraints |
| ----------------- | ------ | -------- | -------------------- |
| `displayName` | string | Yes | 1256 characters |
| `username` | string | Yes | 1128 characters |
| `initialPassword` | string | Yes | Minimum 8 characters |
Zod validation errors never echo received values (the initial password is never included in error responses).
**Response 201**
```json
{ "id": 2 }
```
**Error responses:** `400` invalid request; `403` not admin; `409` username already in use; `503` service unavailable.
---
### `POST /api/admin/members/:id/password`
Admin resets a local member's password without requiring the current password. Also clears any active rate-limit or lockout state for the member's username.
**Path parameter:** `id` — integer member ID.
**Request body**
```json
{ "newPassword": "minimum8chars" }
```
| Field | Type | Required | Constraints |
| ------------- | ------ | -------- | -------------------- |
| `newPassword` | string | Yes | Minimum 8 characters |
**Response 200**`{ "ok": true }`
**Error responses:** `400` invalid request; `403` not admin; `404` member not found or has no local credential; `503` service unavailable.
---
### `POST /api/admin/credentials`
Validates and stores a Fastmail CalDAV app password for any household member. Performs a PROPFIND against Fastmail CalDAV to verify the credential before encrypting and persisting it.
**Request body**
```json
{
"userId": 2,
"providerType": "caldav",
"fastmailEmail": "member@fastmail.com",
"appPassword": "xxxx-xxxx-xxxx-xxxx"
}
```
| Field | Type | Required | Constraints |
| --------------- | ------- | -------- | ------------------------------- |
| `userId` | integer | Yes | Positive integer |
| `providerType` | string | Yes | Must be `"caldav"` |
| `fastmailEmail` | string | Yes | Valid email, max 256 characters |
| `appPassword` | string | Yes | 1500 characters |
Zod validation errors and CalDAV validation failures return `400` with `{ "error": "Invalid request" }` — the app password is never echoed.
**Response 200**`{ "ok": true }`
**Error responses:** `400` invalid request or CalDAV validation failed; `403` not admin; `503` service unavailable.
---
### `GET /api/admin/calendars`
Lists all synced calendars with their shared-calendar designation.
**Response 200**
```json
{
"calendars": [
{ "id": 1, "displayName": "Personal", "isShared": false },
{ "id": 2, "displayName": "Family", "isShared": true }
]
}
```
---
### `PUT /api/admin/calendars/:id/shared`
Exclusively designates one calendar as the household shared calendar. Clears `isShared` on any previously-shared calendar in the same transaction. Returns `404` if the target calendar does not exist.
**Path parameter:** `id` — integer calendar ID.
**Response 200**`{ "ok": true }`
**Error responses:** `400` invalid calendar ID; `403` not admin; `404` calendar not found.
---
### `GET /api/admin/config/timezone`
Returns the household IANA timezone and whether it has been explicitly configured (vs. using the system default fallback).
**Response 200**
```json
{ "timezone": "America/Toronto", "isExplicitlySet": true }
```
`isExplicitlySet: false` when no `household_timezone` row exists in `app_config`; `timezone` still contains the resolved fallback value.
---
### `PUT /api/admin/config/timezone`
Validates and upserts the household IANA timezone into `app_config`.
**Request body**
```json
{ "timezone": "America/Toronto" }
```
| Field | Type | Required | Constraints |
| ---------- | ------ | -------- | ------------------------------------ |
| `timezone` | string | Yes | Valid IANA timezone, 164 characters |
**Response 200**`{ "ok": true }`
**Error responses:** `400` invalid timezone; `403` not admin.
---
### `POST /api/admin/config/timezone/seed`
Seeds the `household_timezone` key in `app_config` **only when it is not already set** (no-overwrite). Used by the setup wizard and browser timezone auto-detect to store the detected zone without clobbering an admin's explicit choice. Uses `INSERT IGNORE` so the operation is safe under concurrent requests.
**Request body**
```json
{ "timezone": "America/Toronto" }
```
**Response 200**
```json
{ "ok": true, "seeded": true }
```
`seeded: true` when the row was inserted; `seeded: false` when it already existed (no change made).
**Error responses:** `400` invalid timezone; `403` not admin.
---
## Error Codes
All error responses use a consistent JSON envelope.
```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, non-admin on admin route) |
| `404` | Resource not found |
| `409` | Conflict (e.g., duplicate username) |
| `422` | Valid request but cannot be fulfilled (e.g., user has no calendar configured) |
| `423` | Locked (setup already complete, or account locked after too many failed logins) |
| `429` | Too many requests (login rate limit exceeded for this username) |
| `503` | DB or downstream service unavailable |
Zod validation failures return `400` with a structured body from `@hono/zod-validator` rather than the `{ "error": "..." }` envelope. Exception: credential and password routes use a `noEchoHook` that always returns `{ "error": "Invalid request" }` to prevent echoing submitted secrets in error details.
---
## Rate Limits
No rate limiting is configured in the application layer for general API routes. Local auth login is rate-limited per-username: 5 failures within 60 seconds returns `429`; 10 cumulative failures locks the account with `423` for 15 minutes. See `POST /api/auth/local/login` for details. <!-- VERIFY: confirm whether Pangolin/Newt or Authelia enforce rate limits at the network edge -->