Files

8.6 KiB

cmux-presence

Realtime device presence and connectivity invalidation service. One TeamPresence Durable Object per team owns presence, and one separately named object per verified user owns revision-only connectivity invalidations. Hosts announce heartbeats; clients subscribe to explicit online/offline transitions. Design, decision memo, and client integration: docs/presence-service.md.

API

All /v1 routes require Authorization: Bearer <Stack access token> and accept optional team scoping via X-Cmux-Team-Id or ?teamId= (must be a team the caller belongs to; defaults to the selected team, then the solo-account user id).

Route Method Purpose
/healthz GET liveness (no auth)
/v1/presence/heartbeat POST announce an app instance; {deviceId, platform, tag?, displayName?, capabilities?, stopping?}; stopping: true is a clean-shutdown goodbye
/v1/presence/snapshot GET one-shot presence map
/v1/presence/subscribe GET WebSocket upgrade or SSE stream: snapshot first, then online / offline / seen events
/v1/connectivity/subscribe GET quiet WebSocket isolated by the verified Stack user; carries only route-revision invalidations
/v1/connectivity/invalidate POST backend-only publication of {revision} to every connected Mac and iPhone for the verified Stack user

The heartbeat response returns heartbeatIntervalMs (15s) and offlineTimeoutMs (45s); hosts should follow the returned cadence rather than hardcoding it. An instance that misses heartbeats for the timeout window is flipped offline by the Durable Object alarm with reason: "timeout".

Every server boundary reduces Iroh routes to the EndpointID plus an exact managed relay URL. Direct addresses and private-network hints remain on the device or move endpoint-to-endpoint after admission. Existing LAN, Tailscale, and custom-network route bodies pass through unchanged for compatibility.

Presence still sends the full sanitized route body in online, routes, and snapshot frames. Current iOS clients write that body into their paired-Mac store and reconnect without a registry round trip. Replacing it with a routes-changed signal requires a new client protocol plus an authenticated fetch or local rendezvous path, so that protocol change is intentionally separate.

Devices are owner-bound, mirroring the registry route: the first authenticated user to announce a deviceId owns it, and a heartbeat for that device from a different team member is rejected with 403 device_owner_mismatch, so a co-member cannot forge another member's device online or goodbye it offline.

Connectivity invalidation is separate from team presence because Iroh routes belong to the personal Stack account even when two devices select different teams. The worker derives a dedicated Durable Object id from the verified user id, pins that same id in every socket attachment, and accepts only one bounded wire shape: {type:"connectivity.invalidate", protocolVersion:1, revision, at}. No route, binding, endpoint, or path data crosses this channel. Mac and iPhone use the revision only to fetch and atomically install the complete /api/connectivity/v2/sync snapshot. Delivery is best-effort, so sleeping devices and reordered frames affect refresh latency rather than correctness. Publication also requires the server-only X-Cmux-Connectivity-Publisher-Secret, matched against the Worker's CONNECTIVITY_INVALIDATION_SECRET; a native client access token cannot forge a revision.

Develop

bun install
bun run typecheck
bun test
bun run dev    # wrangler dev; provide Stack config via .dev.vars or --var

.dev.vars (gitignored) or --var flags supply STACK_PROJECT_ID and STACK_PUBLISHABLE_CLIENT_KEY (dev Stack project values). The full local lifecycle proof, including real Stack sign-in and the alarm-driven timeout, is scripts/local-proof.sh (see header for required env).

Deploy

Deploys run from .github/workflows/presence.yml via manual dispatch on main: gh workflow run presence.yml deploys production, and gh workflow run presence.yml -f target=dev deploys the shared cmux-presence-dev baseline, both with the repository's Cloudflare secrets (no personal Cloudflare account membership needed). wrangler deploy applies the [[migrations]] block atomically with the upload, so Durable Object storage classes can never lag the deployed code.

Required GitHub repository secrets:

  • CLOUDFLARE_API_TOKEN: API token with Workers Scripts:Edit on the account.
  • CLOUDFLARE_ACCOUNT_ID: the Cloudflare account id.

The Worker secret CONNECTIVITY_INVALIDATION_SECRET and web server secret CMUX_CONNECTIVITY_INVALIDATION_SECRET must contain the same random value of at least 32 characters.

One-time Worker secrets (survive deploys; production Stack project values):

bunx wrangler secret put STACK_PROJECT_ID
bunx wrangler secret put STACK_PUBLISHABLE_CLIENT_KEY

Optional plain var STACK_API_URL defaults to https://api.stack-auth.com.

Dev/staging instance

A dev instance runs as cmux-presence-dev on the team Cloudflare account (the same one the regatta subrouter deploys to), configured with the dev Stack project's Worker secrets:

https://cmux-presence-dev.debussy.workers.dev

Redeploy it with gh workflow run presence.yml -f target=dev (or locally with bunx wrangler deploy --config wrangler.dev.toml if your Cloudflare login has the account); its STACK_* Worker secrets are already provisioned and survive deploys.

Important

Use --config wrangler.dev.toml, NOT --name cmux-presence-dev. The default wrangler.toml carries the production presence.cmux.dev custom domain; --name only overrides the worker name, so it inherits that route and STEALS the production domain (detaching it from cmux-presence, which breaks prod auth since the dev worker uses the dev Stack project). wrangler.dev.toml has workers_dev = true and no custom domain, so the dev instance stays on its own *.workers.dev URL. Point a dev Mac build at it with the CMUX_PRESENCE_BASE_URL env override or the presenceServiceURL defaults key, plus presenceHeartbeatEnabled (see Sources/Cloud/PresenceSettings.swift).

Working on the worker with several people at once

cmux-presence-dev is a single shared instance — last deploy wins, and an unmerged feature (e.g. the paired-Mac backup, which only exists on its branch) lives ONLY on whoever deployed last. So don't push your branch onto the shared worker: get your own isolated one instead.

./scripts/deploy-dev.sh           # deploys cmux-presence-dev-<your-id>

Each cmux-presence-dev-<slug> is a separate worker with its own Durable Object namespace, so presence + paired-Mac-backup state is fully isolated per developer — multiple people dogfood worker changes simultaneously without clobbering each other or the shared baseline. Because Cloudflare secrets are scoped to each Worker, the script also provisions the new Worker with the dev Stack Auth values from your shell environment or .dev.vars (STACK_PROJECT_ID, STACK_PUBLISHABLE_CLIENT_KEY, and CONNECTIVITY_INVALIDATION_SECRET, plus optional STACK_API_URL); it refuses to deploy if those values are missing. Configure the web backend's CMUX_CONNECTIVITY_INVALIDATION_SECRET to the same value. The script prints the worker URL and the env var to export:

export CMUX_PRESENCE_BASE_URL=https://cmux-presence-dev-<slug>.<subdomain>.workers.dev

Point every build in your dogfood loop at it (the Mac that heartbeats AND the iPhone that subscribes/backs up must share one worker):

  • Mac: CMUX_PRESENCE_BASE_URL env, or defaults write <tagged-bundle> presenceServiceURL <url>. Resolved by PresenceSettings.
  • iOS: a tapped device app sees no shell env, so the override is read from the app's Info.plist key CMUXPresenceBaseURL (and from presenceServiceURL UserDefaults / the CMUX_PRESENCE_BASE_URL launch env). Resolution precedence: env → UserDefaults → Info.plist → Debug default. ios/scripts/reload.sh bakes $CMUX_PRESENCE_BASE_URL into that Info.plist key (next to CMUXDevTag, via the CMUX_PRESENCE_BASE_URL build setting in ios/Config/Shared.xcconfig + Info.plist), so once it is exported a normally-tapped dev device build talks to your worker. Empty by default, so release/TestFlight builds are unaffected.

Leave CMUX_PRESENCE_BASE_URL unset to use the shared cmux-presence-dev baseline. The durable fix for any feature is to merge it — then it ships on prod via CI and anyone deploying dev from main carries it, no coordination needed.