# 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 | 1–256 characters | | `vapidPublicKey` | string | Yes | 1–512 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 | 1–500 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 | 1–128 characters (trimmed) | | `password` | string | Yes | 1–1000 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 | 1–500 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 | 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" } ``` **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=` 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 | 1–512 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 | 1–255 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 | 1–500 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 | 1–500 characters | | `position` | string | Fractional rank string, 1–255 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 | 1–512 characters | | `keys.auth` | string | Yes | 1–256 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 | 1–256 characters | | `username` | string | Yes | 1–128 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 | 1–500 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, 1–64 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.