Files
Abdulaziz AlbaharandClaude Fable 5 7589f52b81 Isolate every iOS build by bundle identity (#9183)
* 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]>
2026-08-18 13:37:30 -07:00
..

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.