Federation
Musubi servers can share calendars with each other. Someone on the hosted server can join a calendar on your self-hosted box (and vice versa) through a normal invite link — with the same roles, member management and editing as any local member. No accounts on the other server, no bridges through Google/CalDAV, no dependency on the hosted domain.
This page is the contributor’s reference for how that works: the design, the trust model, every moving part, and what’s deliberately deferred.
The design in one paragraph
Section titled “The design in one paragraph”A calendar lives on exactly one server — its origin. An invite token is already a capability scoped to one calendar (it fetches the preview, it grants membership, it expires). Federation extends that: the invite link carries the origin server’s address, and accepting it from another server runs a small handshake that creates a shadow account — a normal user row on the origin representing the remote person — adds it as a member, and issues a member token the invitee’s app uses to read and write that calendar directly at its origin. A later invite reuses that shadow only when the app proves control with its current member token. Everything else (roles, permissions, attribution, member management) is the unmodified native machinery, because to the origin server the remote member is just a user.
sequenceDiagram
participant U as User's app (home = server B)
participant A as Origin server A
Note over U,A: Invite link: https://A/invite/T → musubi://invite/T?server=A
U->>A: GET /api/v1/calendars/tokens/T (public preview)
A-->>U: minimal calendar/member/30-day event preview
U->>A: POST /api/v1/federation/accept { token: T, profile } + optional Bearer M
A->>A: no M → isolated shadow, valid M → reuse proved shadow
A->>A: addCalendarMember(shadow, calendar) — viewer
A->>A: issue 90-day member token M2 (store SHA-256 hash)
A-->>U: { memberToken: M2, memberTokenExpiresAt, userID, calendar }
Note over U: stores {server: A, token: M2} on home server B (encrypted) — all devices inherit it
U->>A: GET /api/v1/calendars, /api/v1/events (Bearer M2) — sync
U->>A: POST /api/v1/federation/token/rotate near expiry
U->>A: POST/PUT/DELETE /api/v1/events (Bearer M2) — edits, gated by assertCan
Why shadow accounts (and not a federation protocol)
Section titled “Why shadow accounts (and not a federation protocol)”The alternative — teaching calendar_members, event attribution and every permission query about “non-local identities” — would touch the whole data model. A shadow account inverts that: make the remote person locally representable, and change nothing else.
calendar_memberspoints at the shadow user; the owner promotes/demotes/kicks them with the existing member management UI and endpoints.events.creatorID/organizerattribute to the shadow user — edits show who made them.assertCan/assertCanEditEventrun unchanged; the origin server enforces its own permissions on every request.getCalendarMembersreturns them like anyone else (name and avatar; member lists deliberately omit email).
The user table gains two columns: isExternal (this row is a shadow account — no password, no session) and homeServer (their origin). That is the entire data-model footprint besides the token table.
The pieces
Section titled “The pieces”Server (origin side)
Section titled “Server (origin side)”| Piece | File | What it does |
|---|---|---|
| Schema | packages/db/src/schema.ts |
user.isExternal + user.homeServer; member_tokens (id, userID, tokenHash unique, createdAt) |
| Queries | packages/db/src/queries/federation.ts |
isolated shadow creation; token replace, compare-and-swap rotate, non-expired lookup and cleanup |
| Accept handshake | apps/api/src/handlers/federation.ts |
POST /api/v1/federation/accept — public, rate-limited per IP; verifies the invite, creates an isolated shadow or reuses one proved by its current bearer, adds membership (viewer), returns the replacement raw token once |
| Token auth + rotation | apps/api/src/middleware/require_auth.ts, handlers/federation.ts |
hash lookup accepts only external users and hashes younger than 90 days; POST /api/v1/federation/token/rotate exchanges a still-valid token once |
| Public preview | apps/api/src/invite_preview.ts |
GET /api/v1/calendars/tokens/:token is public and rate-limited; returns only calendar identity, member names/avatars and minimal 30-day event data |
| Invite page | handlers/federation.ts → GET /invite/:token |
tiny HTML hand-off every server serves for its own invite links; deep-links musubi://invite/<token>?server=<origin> |
| Connection roaming | musubi_accounts + GET/POST/DELETE /api/v1/users/connections/musubi |
the user’s connections to other servers, stored on their home server with the member token AES-GCM encrypted (same key as CalDAV passwords) — accept once, every device inherits it |
Client (invitee side)
Section titled “Client (invitee side)”| Piece | File | What it does |
|---|---|---|
| Registry | apps/client/services/federation.ts |
federated accounts {id, server, label, userID} — no token since v1.5; source of truth is the home server (GET /api/v1/federation/connections), SecureStore is the offline fallback cache. Reads and writes go through the home gateway |
| Servers screen | components/calendar/SyncCalendarModal.tsx (musubi step) |
Sync Calendar → Musubi server: paste an invite link (parsed by the shared parseInviteLink, then handed to the invite screen) and see the connected servers, each with its state and a disconnect |
| Invite screen | app/invite/[token].tsx |
reads the ?server= param; remote → public preview + federation accept; local → the native join, unchanged. Reached from a tapped link or from a pasted one |
| Sync | hooks/useRefreshData.ts |
after the home sync, pulls calendars + events from every federated server (v1: full fetch), tags calendars provider: "musubi" + serverUrl, merges into the same stores/cache |
| Write routing | services/api.ts |
calendar-scoped methods consult remoteForCalendar(); remote calendars go to their origin with the member token — same endpoints, same shapes |
| Composer guard | components/calendar/AddEventModal.tsx |
an event’s calendars must share one origin server (cross-server linking is v3) |
| Invite links | components/calendar/CalendarSettingsModal.tsx |
built from the calendar’s own server (serverUrl for federated, apiUrl otherwise) — no hardcoded hosted domain |
Why the invite page matters (the “known hosts” problem)
Section titled “Why the invite page matters (the “known hosts” problem)”Android app links only open the app for domains the build verifies (musubi.pro). A self-hosted server’s https://my-server/invite/T can’t be app-link-verified for everyone’s server. So every Musubi server serves GET /invite/:token — a minimal HTML page that forwards into the app via the custom scheme (musubi://invite/T?server=…), which works regardless of domain. The server query param is what tells the app where the calendar lives.
Trust & security model
Section titled “Trust & security model”Be precise about what protects what:
- The member token is the security boundary. It contains a version, a
client-readable issue timestamp and 32 random bytes. The origin stores only
its SHA-256 hash and authoritative database
createdAt, and returns the raw value exactly once. Presenting it authenticates you as that shadow user — nothing more. - Tokens expire and rotate. Authentication rejects hashes aged 90 days. The client exchanges legacy tokens immediately and versioned tokens during their final 14 days. Rotation is compare-and-swap: the same old token cannot produce two successors. The home server stores the successor for other devices.
- Your home server holds your tokens (in
musubi_accounts, AES-GCM encrypted with the same key as CalDAV passwords) so connections roam across your devices. The deliberate trade-off: a home-server admin with the encryption key could act as you on the origin calendar — the same class of trust you already extend by storing CalDAV credentials there. Self-hosters trust themselves; hosted users trust the host. - Authorization is membership, always. Every route the token reaches still
runs
assertCan/assertCanEditEventagainstcalendar_members. A kick cuts resource access immediately; removing the shadow’s last membership also deletes its token hashes in the same transaction. - First-contact profile claims are display-only. v1 does not prove server-to-server that “user U controls server B.” An accept without a current member token therefore creates a new isolated shadow and never looks up an existing identity by submitted email/home server. Reusing an existing shadow requires that shadow’s valid bearer token.
- Shadow accounts can never collide into local accounts.
createExternalUsernever binds to an existing local user; an email collision falls back to a synthetic unique email. An unverified email claim must not impersonate a local account — this is an invariant, keep it. - Invite semantics are unchanged — same expiry/purge behaviour as a native join, same “token required, calendar id is not enough” rule. Preview possession reveals names/avatars and a minimal 30-day agenda, not email or full event records; treat invite links as private capabilities.
- Client-side routing is routing, not authorization.
remoteForCalendar()in the app only decides which server to call with which credential. It grants nothing: a client that “skips” it just sends the request to the wrong server, whereassertCan(and the server-side shape guards) reject it. The same goes for the composer’s same-server guard — it’s a friendly error, the server enforces the rule regardless.
What v1 is — and isn’t
Section titled “What v1 is — and isn’t”v1: the remote calendar lives only at its origin. A full pull per refresh keeps a device-local cache (SQLite) like any other calendar, so remote events and attendance update live. If the origin is unreachable, clients show the last synced copy and edits fail loudly. Originally each client read and wrote the origin directly, holding its member token; v1.5 below moved that custody to the home server without changing any of these semantics.
v1.5 — home-server gateway (docs/adr/ADR-005-federation-gateway.md): the member token stops being handed to clients — both the web and mobile clients now route through it, and rotation happens server-side (lazily, whenever a connection is used). Instead the home server exposes ANY /api/v1/federation/s/:connectionId/api/v1/*, forwarding to the connected origin with the decrypted token attached. :connectionId is a musubi_accounts row id, so the target origin comes from the database, never from the request — that plus an SSRF guard (private/loopback/metadata ranges refused, revalidated per request) is what keeps it from being an open proxy. The origin server is unchanged, so this works against already-released servers. Clients then share one API surface, which is how the web client reaches parity without a 90-day cross-server credential living in browser JS.
Realtime follows the same shape: instead of every client holding one SSE stream per federated server, the home server subscribes once per user to each connected origin and re-emits what arrives into its own hub as federated_sync ({ server }). Clients keep a single stream and refetch that server’s snapshot. The invite handshake also moved server-side — POST /api/v1/federation/connect accepts on the origin and stores the connection, so the credential is never handed out.
Trade-off: the home server is now in the data path — if it is down, federated calendars are unavailable even when the origin is up. Accepted, since without the home server there is no session or home data either. It also holds one outbound stream per connected server per online user (musubi_federated_upstream_streams tracks the cost), which is consistent with the current single-replica SSE design.
v2 — mirror (reconsidered, not planned): reusing the external-provider engine with a musubi adapter was the original plan, but CalendarAdapter only models calendars and events — it cannot carry attendance, members, roles or invites. Mirroring would demote federation from collaboration to a read-ish copy, which is the opposite of its purpose. Mirroring stays right for Google/CalDAV (genuinely external systems) and remains available later as a pure offline/latency optimisation on top of the gateway.
v3 — cross-server linking: still open. Until then the composer enforces one-origin-per-event.
Testing it locally
Section titled “Testing it locally”- Run two servers (two
.envs, two databases, two ports) and apply migrations to both (pnpm db:migrate). - Sign in to server A (as the owner), create a calendar, share an invite — the link is
http://<A>/invite/<token>. - On a device signed in to server B, open the link → the hand-off page deep-links into the app with
?server=<A>→ accept. - The calendar appears grouped under server A’s host in the calendar list, with your role. Create/edit an event in it — the write lands on A; A’s local members see it (SSE/refresh). On A, the owner sees you as a member and can change your role or kick you; after a kick, your next request fails with 403 and the next refresh drops the calendar.
- Run
pnpm test:federation-dbagainst a disposable migrated PostgreSQL database withENVIRONMENT=testto verify expiry, rotation, identity isolation and last-membership revocation. - Run
pnpm test:db:federation-gateway(same setup, plusCALDAV_ENC_KEY) to verify the gateway attaches the member token, drops the home cookie, relays upstream status codes and returns502for a dead origin. The pure SSRF and path-escape guards are covered bypnpm --filter @musubi/api test.