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 {server, token, userID} — source of truth is the home server (getMusubiAccounts on every refresh), SecureStore is the offline fallback cache; proves an existing shadow on later accepts and rotates legacy/near-expiry tokens |
| Invite screen | app/invite/[token].tsx |
reads the ?server= param; remote → public preview + federation accept; local → the native join, unchanged |
| 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 (this): the remote calendar lives only at its origin. The invitee’s app reads and writes it there directly; a full pull per refresh keeps a device-local cache (SQLite) like any other calendar, and the app also holds an SSE stream to each federated server (member token as bearer), so remote events and attendance update live. If the origin is unreachable, the app shows the last synced copy and edits fail loudly.
v2 — mirror (planned): reuse the external-provider engine (external_calendars / external_events, cursors, upsertExternalEvent) with a musubi adapter, so server B keeps a server-side mirror of the shared calendar. The origin stays authoritative — B pulls and pushes back exactly like the Google adapter, reusing the delta endpoint (GET /events?since → {events, deletedIds, serverTime}) as the cursor. No master-master merging; conflicts stay last-write-wins. This buys offline resilience (and if an origin ever disappears for good, the fork mechanic can promote a mirror into a native calendar).
v3 — cross-server linking: falls out of v2 almost for free — once the shared event is a real local row on B, linking it into B’s own calendars is a normal in-server link. 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.