Files
familysync/docs/API.md
T
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

39 KiB
Raw Blame History

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

{ "ok": true, "db": "up" }

Response 503 (DB unreachable)

{ "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

{ "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

{
  "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

{ "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

{
  "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

{ "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

{ "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

{ "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: namepreferred_usernameemailsub. 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

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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 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

{ "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 1512 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 1255 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 1500 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 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

{ "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 1512 characters
keys.auth string Yes 1256 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.


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

{
  "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

{
  "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

{ "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

{ "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

{
  "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

{
  "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

{ "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

{ "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

{ "timezone": "America/Toronto" }

Response 200

{ "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.

{ "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.