<!-- 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      | 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 `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      | 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=<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. <!-- VERIFY: confirm whether Pangolin/Newt or Authelia enforce rate limits at the network edge -->
