API Server
The backend is a plain Express server in apps/api/. No framework magic —
routes, middleware, handlers, and a thin permission layer. This page gives you
the request lifecycle and the common pattern used by mutating handlers.
Boot sequence
Section titled “Boot sequence”apps/api/src/index.ts is the front door. It wires the app in this order (order matters):
flowchart TD
A["middlewareMetrics"] --> B["middlewareLogHandler · request id"]
B --> C["cors() · origin whitelist + credentials"]
C --> D["express.json · 512 KB cap"]
D --> E["/api/auth/* · Better Auth"]
E --> F["application + public page routes"]
F --> G["middlewareErrorHandler · MUST be last"]
G --> H["API listener + optional metrics listener"]
H --> I["cleanup + external-sync timers"]
The main route surfaces are:
/api/auth/*— entirely owned by Better Auth (email/password + native Google/Apple sign-in). Don’t add routes here. See Authentication./api/v1/*— the versioned application API./api/stream— authenticated SSE, kept outside/api/v1for the client.- root pages and
/.well-known/*— public invite/auth hand-offs and the Apple association document.
The handler rhythm
Section titled “The handler rhythm”Most mutating handlers follow the same five concerns. Their order can differ when an external provider participates, so always read the documented failure semantics:
flowchart LR
V["validate<br/>(BadRequest)"] --> A["authorise<br/>(Forbidden)"] --> P["persist<br/>(packages/db)"] --> S["sync outward<br/>(sync engine)"] --> B["broadcast<br/>(SSE)"]
A representative handler (handlerCreateEvent in handlers/events.ts):
export async function handlerCreateEvent(req: Request, res: Response) { const event = parseEventOrBadRequest(req.body); // 1. validate for (const calendarID of event.calendars) { await assertCan(req.user!.id, calendarID, "editEvents"); // 2. authorise all } const created = await createEvent( // 3. persist { ...event, creatorID: req.user!.id }, event.calendars, ); await pushEventToProviders( // 4. best-effort push { ...created, calendars: event.calendars }, "create", ); notifyCalendarMembers(memberIds, "event_created", created); // 5. broadcast res.status(201).json(created);}Conventions baked into that snippet:
req.useris set by therequireAuthmiddleware (type augmented inapps/api/src/express.d.ts). Public routes omitrequireAuth.- Identity fields (
creatorID,originCalendarID) are set server-side, never taken from the client body.handlerUpdateEventstrips them:const { creatorID, originCalendarID, ...editable } = event. - Handlers throw; they don’t return error responses. A
wrap()helper (const wrap = h => (req,res,next) => Promise.resolve(h(req,res)).catch(next)) funnels rejected promises into the error middleware. That’s why every route iswrap(handlerX). - Multi-row database invariants belong in one
@musubi/dbquery. The query owns its Drizzle transaction; the handler authorizes first, calls the single persistence operation, and broadcasts only after commit. Provider calls stay outside that transaction.
Errors → HTTP status
Section titled “Errors → HTTP status”Handlers throw the typed error classes from @musubi/types (errors.ts). middlewareErrorHandler maps them:
| Thrown | Status |
|---|---|
BadRequestError |
400 |
UnauthorizedError |
401 |
ForbiddenError |
403 |
NotFoundError |
404 |
| anything else | 500 |
ZodError is a special route-boundary case and maps to 400 even if a handler
did not wrap Schema.parse() itself. Event requests additionally use
request_validation.ts to reject malformed UUID/timestamp values before
PostgreSQL can turn them into internal errors.
if (!event) throw new NotFoundError("Event not found...");if (start > end) throw new BadRequestError("Start must precede end.");Query modules return undefined/[] for “not found” — the handler decides whether that’s a 404.
Structured logging
Section titled “Structured logging”The shared logger exported by @musubi/config emits one JSON object per line.
middlewareLogHandler assigns every request an x-request-id, stores it in an
AsyncLocalStorage context, and returns it as a response header. Logs produced
deeper in auth, handlers, or sync automatically carry that id; authenticated
requests also carry the internal userId. Route patterns are logged instead of
concrete URLs so invite and auth tokens in paths are not exposed. Request bodies,
credentials, and bearer tokens are never logged.
LOG_LEVEL accepts debug, info, warn, error, or silent and defaults to
info. Successful HTTP requests are info, 4xx responses are warn, and 5xx
responses are error. Provider-sync counts and timings are debug; turn that
level on temporarily when diagnosing Google, Outlook, or CalDAV without making normal
production logs excessively noisy.
Prometheus metrics
Section titled “Prometheus metrics”The API exposes Prometheus data from a separate internal listener rather than a
route on the public API. METRICS_PORT defaults to 9464; 0 disables the
listener. GET /metrics contains the standard prom-client Node.js/process
metrics (prefixed musubi_) and these application metrics:
| Metric | Meaning |
|---|---|
musubi_http_requests_total |
Completed requests by method, registered route pattern, and status |
musubi_http_request_duration_seconds |
Request-duration histogram using the same bounded labels |
musubi_http_requests_in_flight |
Requests currently being processed by method |
musubi_external_sync_failures_total |
Failed provider discovery, account sync, event push, or scheduler runs by provider |
musubi_scheduled_task_skips_total |
Cleanup/provider-sync ticks skipped because the previous run is still active |
Unknown HTTP methods collapse to OTHER, unmatched paths collapse to
<unmatched>, and registered Express patterns are used instead of concrete
URLs. This bounds label cardinality and prevents identifiers or tokens in paths
from entering the monitoring system. The Dokploy Compose network alias is
musubi-api; Prometheus on the same Docker network scrapes
musubi-api:9464/metrics without exposing the listener through Traefik.
Ready-to-use availability, HTTP 5xx, and provider-sync alert rules live in
ops/prometheus/musubi-alerts.yml.
The server must trust nothing from the client
Section titled “The server must trust nothing from the client”Client-side checks (the composer’s validation, the same-server guard, federation routing) are UX only — every guarantee is re-established here:
assertCan/canDodouble as existence checks. A calendar id with no membership row yields a null role →ForbiddenError. Since every calendar id a mutation touches passes through one of these gates before any insert, an unknown or foreign id gets a clean 403 — never an FK violation.handlerCreateEventvalidates the event’s shape: at least one calendar, andoriginCalendarIDmust be one of the linked calendars (defaulting to the first). The home calendar governs edit rights, so a client must not be able to smuggle in a foreign calendar as origin — and since the linked calendars are membership-verified, this also proves the origin exists (clean 400 instead of an FK 500).- Identity fields are overwritten server-side (
creatorIDon create,creatorID/originCalendarIDstripped on update), as described above.
Permissions
Section titled “Permissions”Two files, two responsibilities:
packages/types/src/permissions.ts— the truth: theCalendarRole×CalendarActionmatrix and the purecan(role, action)function. Shared with the client so UI can hide what the user can’t do.apps/api/src/permissions.ts— the server gates that call it against the DB.
| Role | Allowed actions |
|---|---|
owner |
editCalendar, deleteCalendar, manageMembers, editEvents, invite |
editor |
editEvents, invite |
viewer |
(read-only) |
Three gate functions:
assertCan(userID, calendarID, action)— throwsForbiddenErrorif not allowed. The default gate.canDo(userID, calendarID, action): Promise<boolean>— non-throwing, for branching (e.g. “unlink only the calendars you can edit”).assertCanEditEvent(userID, eventID)— the home-calendar gate. Edit rights followoriginCalendarID, not whichever calendar you’re viewing the event through. Falls back to the creator if the home calendar was deleted.
Link vs Fork
Section titled “Link vs Fork”The two signature endpoints in handlers/events.ts, both POST /api/v1/events/:eventId/{link|fork}:
- Link (
handlerLinkEvent) — adds an existing event to another calendar. Requires you can see the event and can edit the target calendar. One event row, a newcalendar_eventslink. Ownership (originCalendarID) is unchanged. Broadcastsevent_updatedto all members of all linked calendars. - Fork (
handlerForkEvent) — creates an independent copy: new UUID, newcreatorID, neworiginCalendarID. Fully divergent from the original. Broadcastsevent_createdto the target.
Rule of thumb: Link to share one truth; Fork to claim your own version.
Attendees
Section titled “Attendees”Attendance is per-event opt-in: the events.hasAttendees flag (set in the composer at create/edit) gates the client UI only — the endpoints below don’t check it, and flipping it off is non-destructive (event_users rows survive, re-enabling restores the list).
GET /events/:id/attendees and PUT /events/:id/attendance ({ attending: boolean }, idempotent — the client sends desired state). Both gate on assertCanViewEvent (membership in any linked calendar) — viewers RSVP too. Responses carry { id, name, image } only, no emails: an event can span calendars whose members aren’t mutuals. Attendance changes broadcast an attendance_changed SSE frame ({ eventID, attendees }) to members of all linked calendars, so an open detail modal updates live; the client keeps lists in useAttendeesStore, fetched fresh on modal open.
Realtime (SSE)
Section titled “Realtime (SSE)”GET /api/stream is a long-lived Server-Sent Events connection, registered outside the /api/v1 prefix to match the client’s URL. handlerStream sets the SSE headers and stores responses in a Map<userID, Set<Response>>, allowing multiple devices/connections per user; notifyCalendarMembers(userIds, type, payload) writes event_created / event_updated / event_removed / calendar_updated / calendar_removed / attendance_changed / external_sync frames to the affected users. Mutating handlers broadcast after persistence, and the scheduled provider sync sends external_sync when provider changes land, which clients answer with a silent delta refresh.
It’s an authenticated route (requireAuth): the client sends its session as a Bearer token, which Better Auth’s bearer plugin validates. The client subscribes via react-native-sse (apps/client/hooks/useEventsStream.ts).
Route map
Section titled “Route map”For payloads, status codes, authentication, and response shapes, use the HTTP API reference. This table is only an orientation map.
| Group | Routes |
|---|---|
| Health | GET /api/v1/server, /server/ok (public) |
| Events | GET/POST/PUT/DELETE /api/v1/events, POST /api/v1/events/:id/link, /fork, GET /events/:id/attendees, PUT /events/:id/attendance |
| Calendars | GET/POST/PUT/DELETE /api/v1/calendars, GET /calendars/:id, /calendars/google, /calendars/tokens/:token, GET /calendars/:id/export (.ics snapshot, any member), POST /calendars/import (raw .ics body → new native calendar; own 10 MB text parser) |
| Members | GET /calendars/:id/members, POST/DELETE /calendars/members/:id (join/leave), PUT/DELETE /calendars/:id/members/:userId (role/kick) |
| Invites | POST /calendars/invites (create — expiresAt: null = never, maxUses: null = unlimited), GET /calendars/:id/invites (list, with uses), DELETE /calendars/invites/:inviteId (revoke) — all gated on the invite action |
| Users | GET/PUT /users/settings, DELETE /users, POST /users/avatar, GET /users/:id/avatar (public) |
| Connections | google check/revoke, caldav check/connect/disconnect, GET/POST/DELETE /users/connections/musubi (federated connections, roaming), POST /users/connections/disconnect |
| Federation | POST /api/v1/federation/accept (public — invite capability; optional current member bearer proves identity reuse), POST /api/v1/federation/token/rotate, GET /invite/:token (public HTML hand-off page, outside /api) — see Federation |
GET /api/v1/server is public and returns { minClientVersion, socials } — socials lists the social logins this server has credentials for (e.g. ["google"]), so the client’s welcome screen renders only the buttons that will work against it (matters for self-hosted servers with a partial config).
POST /calendars can carry provider + accountId to create the calendar into a connected account — the server creates it at the provider first, then imports the mirror (see Calendar-level writes). PUT/DELETE /calendars on an external mirror push the rename/recolor/delete to the provider first and abort locally if it refuses.
Two auth notes: GET /calendars/tokens/:token is public (possession of the unguessable invite token is the credential — cross-server invitees have no session here). The token is fully validated at every read by getCalendarIDFromToken: uuid-shaped (a raw string against a uuid column would be a PG 500), unexpired, and under its maxUses — and the two public token endpoints (/calendars/tokens/:token, /federation/accept) sit behind a per-IP in-memory rateLimit middleware. The preview contract deliberately omits email and full event records.
requireAuth has a second path: when no Better Auth session matches, it hashes
the presented Bearer and looks up an external user through a member-token row
younger than 90 days. This is authentication only; each protected resource
route must still authorize against calendar_members. Clients rotate in the
final 14 days through a compare-and-swap endpoint, and removing the final
membership revokes token hashes transactionally.
How to add an endpoint
Section titled “How to add an endpoint”-
Write the handler in the right
handlers/*.tsfile. Follow the five-beat rhythm — validate (throwBadRequestError), authorise (assertCan*→ForbiddenError), persist (a@musubi/dbquery, throwNotFoundErroron missing), sync outward if it touches events (pushEventToProviders), broadcast (notifyCalendarMembers), respond (201create /200update). -
Add a query function in
packages/db/src/queries/*.tsif you need new DB access — never inlinedb.selectin a handler. -
Register the route in
index.ts, beforemiddlewareErrorHandler, wrapped and (usually) authed:app.put("/api/v1/events/:eventId/reschedule", requireAuth, wrap(handlerRescheduleEvent));Watch route order if the path could collide with a
:paramroute. -
Add the client method in
apps/client/services/api.ts(see the client guide) and callthrowOnError(error)after the fetch. -
Run the repository check from the root:
pnpm check. Runpnpm lintseparately until the known client lint baseline is repaired.
Gotchas worth knowing
Section titled “Gotchas worth knowing”- Body limit is 512 KB (base64 avatars). Larger payloads 413.
- Avatar upload sniffs magic bytes — no extension trust; served from a public, timestamp-versioned URL (
<Image>can’t send auth headers). - Owner can’t leave a calendar (
handlerLeaveCalendarblocks it) — transfer ownership or delete. Prevents orphaned calendars. Ownership transfer is unavailable for personal calendars and provider mirrors; a mirror must remain attached to the user who owns its provider credentials. - Invites are token-based, not email-based —
handlerJoinCalendarrequires a token matching the calendar, preventing ID enumeration. - Broadcasts target all members of affected calendars, deduped via a
Set, not just the requester.