* test: require iOS build isolation * fix: isolate iOS builds by bundle identity * fix: compile namespaced Mac backup publisher * chore: remove shared app group example * test: require isolated iOS OAuth cookies * fix: isolate iOS OAuth browser cookies * fix: close autoreview namespace gaps * fix: complete iOS namespace isolation * refactor: satisfy namespace isolation policy * fix: close namespace migration review gaps * fix: preserve isolated Iroh and OAuth admission * test: cover isolated iOS rollout paths * fix: authenticate isolated iOS rollout paths * test: cover authenticated management recovery * fix: authorize isolated management operations * test: cover cached proof and Mac forget * fix: preserve proof across isolated lifecycle paths * fix: satisfy Iroh API package policy * test: cover autoreview isolation regressions * fix: close isolated rollout review gaps * test: cover target and trust boundary regressions * fix: enforce isolated pairing boundaries * test: cover legacy tombstone migration * fix: preserve tombstones across backup migration * test: cover migration precedence and discovery privacy * fix: enforce migration and discovery precedence * test: cover push targeting and migration bounds * fix: close push and migration isolation gaps * test: prevent entitlement dumps on signing failures * fix: redact signed entitlements on upload failure * fix: make backup scope provider sendable * fix: return localized pairing target names * fix: drain legacy namespaced revocations * test: track push token policy limits * test: cover post-revocation fallback paths * fix: refresh authority after binding revocations * test: cover paired-Mac migration boundaries * fix: bound and scope legacy paired-Mac migration * fix: capture migration account without async coalescing * fix: keep legacy migration and keychain deletion safe * chore: refresh pull request head * fix: bound legacy backup reconciliation * test: cover conditional paired Mac migration * fix: make paired Mac migration conditional * test: cover pairing migration recovery races * fix: serialize identity persistence recovery * test: cover migration team and pairing lanes * fix: pin migration team and pairing channel * test: await development identity storage * test: cover pairing emission and keychain scope * fix: fail closed on app and keychain scope * test: cover bundle-derived Mac namespaces * fix: namespace Mac bindings by bundle * test: cover legacy sign-out binding namespaces * fix: revoke bindings in their stored namespace * test: expect APNs bundle in send outcome * test: cover untagged debug pairing identity * fix: preserve untagged debug pairing identity * test: cover rate-limited pending revocations * fix: drain revocations after rate-limited registration * test: cover safe pending revocation reconciliation * fix: preserve active binding during revocation recovery * fix: preserve broker proof through client wrappers * test: retain authorization during rate-limited recovery * fix: preserve retained binding during rate-limit recovery * test: cover stale binding cleanup authorization * fix: authorize stale binding cleanup * docs: document binding authorization helpers * test: type stale cleanup response * fix(macos): restore compilable cleanupSurfaceState call https://github.com/manaflow-ai/cmux/pull/10072 changed this call site to pass workspaceID: but never landed that overload, so the macOS target has not compiled since it merged (CI is dispatch-only and did not catch it). Restore the existing signature; the native-mobile-surface preservation intent needs to re-land together with its implementation. Co-Authored-By: Claude Fable 5 <[email protected]> * fix: wire mobile surface artifact integration * fix: require explicit trust broker namespace * fix: require explicit management revocation routes * fix: reject ambiguous iOS bundle namespaces * fix: bound identity waits and scope legacy auth * fix: cancel queued identity operations * fix: modernize browser change handlers * fix: scope legacy token deletion --------- Co-authored-by: Claude Fable 5 <[email protected]>
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 defaultwrangler.tomlcarries the productionpresence.cmux.devcustom domain;--nameonly overrides the worker name, so it inherits that route and STEALS the production domain (detaching it fromcmux-presence, which breaks prod auth since the dev worker uses the dev Stack project).wrangler.dev.tomlhasworkers_dev = trueand no custom domain, so the dev instance stays on its own*.workers.devURL. Point a dev Mac build at it with theCMUX_PRESENCE_BASE_URLenv override or thepresenceServiceURLdefaults key, pluspresenceHeartbeatEnabled(seeSources/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_URLenv, ordefaults write <tagged-bundle> presenceServiceURL <url>. Resolved byPresenceSettings. - iOS: a tapped device app sees no shell env, so the override is read from the
app's Info.plist key
CMUXPresenceBaseURL(and frompresenceServiceURLUserDefaults / theCMUX_PRESENCE_BASE_URLlaunch env). Resolution precedence: env → UserDefaults → Info.plist → Debug default.ios/scripts/reload.shbakes$CMUX_PRESENCE_BASE_URLinto that Info.plist key (next toCMUXDevTag, via theCMUX_PRESENCE_BASE_URLbuild setting inios/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.