- Fix MD040 (11 bare fences): add language tags (text/bash) across 7 files - Fix MD031 (2 violations): add blank lines around fence in GETTING-STARTED.md - Wire 'Markdown lint' step to fast-checks job (after Format check, before Typecheck) - Reformat .markdownlint-cli2.jsonc per Prettier (trailing commas in JSONC) - pnpm md:lint exits 0; pnpm format:check exits 0; gate can fail on bare fence (verified)
20 KiB
API Reference
FamilySync exposes a Hono HTTP API served on port 3000. All /api/* routes require an authenticated session (OIDC via Authelia, or DEV_AUTH_BYPASS=true for local development). Unauthenticated requests to protected routes receive a 302 redirect to Authelia's authorize endpoint, not a 401, except where noted.
Authentication
The API uses OIDC session cookies managed by @hono/oidc-auth. The authorization-code + PKCE flow is handled transparently:
- The PWA navigates to
GET /api/login. This route sits under/api/*, where the@hono/oidc-authguard is mounted. - 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.
- Authelia posts the authorization code to
GET /callback, which exchanges it for tokens and sets anhttpOnly; Secure; SameSitesession cookie, then continues back to/api/login— whose handler now redirects to/(the app shell). - All subsequent
/api/*requests carry the session cookie automatically. (The root/and static assets are served outside the/api/*guard.)
Dev bypass: When DEV_AUTH_BYPASS=true and NODE_ENV != production, the OIDC guard is disabled and a fixed dev user (id 1) is injected into every request. Never enable in production.
No API key or Authorization header is used. Credentials are never included in response bodies.
Endpoints Overview
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health |
None | DB liveness check |
| GET | /callback |
None | OIDC authorization-code exchange |
| GET | /api/login |
OIDC | Login entry point, redirects to / |
| GET | /api/me |
OIDC | Current user identity and color |
| GET | /api/events |
OIDC | Windowed calendar occurrences |
| POST | /api/events/create |
OIDC | Enqueue a new event write |
| PATCH | /api/events/:uid/edit |
OIDC | Enqueue an event update |
| DELETE | /api/events/:uid |
OIDC | Enqueue an event delete |
| GET | /api/events/sync-status |
OIDC | Outbox status for a UID |
| GET | /api/events/writable-calendars |
OIDC | Calendars the member can write to |
| GET | /api/lists |
OIDC | All lists accessible to the member |
| POST | /api/lists |
OIDC | Create a list |
| PATCH | /api/lists/:id |
OIDC | Update list name or sharing |
| DELETE | /api/lists/:id |
OIDC | Delete a list (owner only) |
| GET | /api/lists/:id/items |
OIDC | All items in a list |
| POST | /api/lists/:id/items |
OIDC | Add an item to a list |
| PATCH | /api/list-items/:itemId |
OIDC | Update a single list item field |
| DELETE | /api/list-items/:itemId |
OIDC | Delete a list item |
| GET | /api/sse/heartbeat |
OIDC | SSE heartbeat stream |
| GET | /api/sse/lists |
OIDC | Scoped live-list SSE stream |
| GET | /api/push/vapid-public-key |
OIDC | VAPID public key for push subscribe |
| POST | /api/push/subscription |
OIDC | Register a push subscription |
| DELETE | /api/push/subscription |
OIDC | Remove push subscriptions for caller |
Health
GET /health
Unauthenticated. Performs a SELECT 1 against MariaDB to prove connectivity.
Response 200
{ "ok": true, "db": "up" }
Response 503 (DB unreachable)
{ "ok": false, "db": "down" }
Identity
GET /api/me
Returns the authenticated member's identity and their assigned color.
Display name is derived from OIDC claims in priority order: name → preferred_username → email → sub. The user row is upserted on first visit (keyed on oidc_iss + oidc_sub).
Response 200
{
"user": {
"id": 1,
"displayName": "Lucas",
"color": "#4A90D9"
}
}
Error responses: 401 if the session is invalid.
Calendar Events
Calendar data is read from a MariaDB cache populated by the CalDAV broker poller. Write operations enqueue outbox rows; the outbox worker dispatches them to Fastmail CalDAV asynchronously. Clients receive 202 Accepted immediately and poll GET /api/events/sync-status to confirm settlement.
GET /api/events?start=YYYY-MM-DD&end=YYYY-MM-DD
Returns a flat array of concrete event occurrences for the given date window. Recurring events are expanded server-side via rrule. The window is capped at 90 days. Returns events from calendars the member owns plus any shared (family) calendars.
Query parameters
| Parameter | Required | Format | Notes |
|---|---|---|---|
start |
Yes | YYYY-MM-DD |
Window start (inclusive) |
end |
Yes | YYYY-MM-DD |
Window end (exclusive); max 90 days from start |
Response 200
{
"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
{
"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
{ "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
{ "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
{ "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
{ "uid": "f47ac10b-...", "status": "done" }
Possible status values: pending | failed | dead | done.
When status is failed or dead, an error field is included:
{ "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
{
"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
{
"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
{ "name": "Gift Ideas", "isShared": true }
| Field | Type | Required | Constraints |
|---|---|---|---|
name |
string | Yes | 1–255 characters |
isShared |
boolean | No | Default: true |
Response 201
{
"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)
{ "name": "Weekly Groceries", "isShared": false }
Response 200
{
"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
{ "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
{
"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
{ "text": "Eggs" }
| Field | Type | Required | Constraints |
|---|---|---|---|
text |
string | Yes | 1–500 characters (plain text only) |
Response 201
{
"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)
{ "checked": true }
or
{ "text": "Whole milk" }
or
{ "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
{ "id": 11 }
Error responses: 401 unauthorized; 403 access denied; 404 item not found; 503 DB error.
Server-Sent Events
SSE streams use Content-Type: text/event-stream. The client should reconnect on disconnect with bounded backoff (handled by the useListSSE hook in the PWA).
GET /api/sse/heartbeat
Streams heartbeat events every 10 seconds indefinitely. Used as a Pangolin tunnel smoke test.
Event format
event: heartbeat
id: 0
data: {"ts":"2025-06-10T14:00:00.000Z","id":0}
GET /api/sse/lists
Scoped live-list fan-out stream. Delivers mutation events only for lists the authenticated member can access (owned + shared). Events for other members' private lists are never delivered.
A heartbeat event is sent every 30 seconds to keep the Pangolin connection alive.
Event types
| Event type | Payload |
|---|---|
list:updated |
{ type, listId, payload: { id, name } } |
list:deleted |
{ type, listId, payload: { id } } |
item:added |
{ type, listId, payload: { id, listId, text } } |
item:updated |
{ type, listId, payload: { id, listId } } |
item:deleted |
{ type, listId, payload: { id } } |
heartbeat |
{ ts } |
Event format example
event: item:added
id: 1-1718020800000
data: {"type":"item:added","listId":1,"payload":{"id":11,"listId":1,"text":"Eggs"}}
The PWA treats these events as cache invalidation signals and full-refetches via TanStack Query. Event payloads are not used directly for cache updates.
Error responses: 401 if session is invalid (returned as JSON before the stream opens).
Push Notifications
Push uses VAPID direct push (no FCM broker). The PWA must be installed to the home screen on iOS 16.4+ for push to function. pushManager.subscribe() must be called inside a user gesture handler.
GET /api/push/vapid-public-key
Returns the server's VAPID public key. The public key is non-secret and required by the browser to call pushManager.subscribe().
Response 200
{ "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
{
"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
{ "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
{ "ok": true }
Error responses: 401 unauthorized; 503 DB error.
Error Codes
All error responses use a consistent JSON envelope.
{ "error": "Human-readable message" }
| HTTP Status | Meaning |
|---|---|
400 |
Invalid request parameters (e.g., malformed date window) |
401 |
Session missing or invalid |
403 |
Authenticated but not authorized (wrong owner, sharee attempted owner-only op) |
404 |
Resource not found |
422 |
Valid request but cannot be fulfilled (e.g., user has no calendar configured) |
503 |
DB or downstream service unavailable |
Zod validation failures return 400 with a structured body from @hono/zod-validator rather than the { "error": "..." } envelope.
Rate Limits
No rate limiting is configured in the application layer.