backend: UserDO/TeamDO network identity and Native dial RPCs for the platform CloudDO

TE
4d ago
+1420-34

Summary

cmux-platform's CloudDO is taking over the Native backend (cloud.vm.network_policy, cloud.vm.receiver.lifecycle, Native mesh create, Native dials). UserDO and TeamDO stay in cmux-api, so the platform calls them through cross-script Durable Object RPCs. This PR adds those RPCs and the install-authority state they read. It adds no ops, routes or catalog entries, and nothing calls the new methods until the platform does.

Carried from #28950 (the HOLD aggregate), whose versions of domains/user-authority.ts and domains/team-authority.ts match the older stack #22281 → #22382 → #22388 → #22413 → #22451:

UserDO (each answer pinned to the caller's live grant: install not revoked, grant held by that install, not revoked or expired):

  • networkIdentity(entity, install, grant, team) → { key, kind, expires_at, authority_version, op_classes } | null: mac/ios/cli/daemon, grant with read and mutate-own; a daemon only for its bound_team.
  • networkIdentities(entity, entries: {install, grant}[] (≤128), team) → Array<identity | null> (a malformed batch returns []).
  • vmNetworkIdentity(entity, install, grant, team, machine) → identity | null: kind vm, bound_team and bound_machine must match, grant has vm-self.
  • readIdentity(entity, install, grant) → identity | null: mac/ios/cli under a live read grant, for host.network.target.
  • deviceKey(entity, install, grant, opClass: "read" | "mutate-own" = "mutate-own"): existing callers are unchanged; any other op class gets null.
  • Install authority state (install_authority: a generation plus a per-install incarnation, written by the reducer). authority_version hashes every admission input together with the incarnation, so a revoked, changed or re-granted install never matches an old version, even when it goes A → B → A.

TeamDO (the #28950 signatures):

  • authorizeCloudDial(entity, p, target: { vm, origin, network }, proof, services) → { ok: true, permit } | { ok: false, code, message } and recheckCloudDial(entity, p, permit) → boolean.
  • vmReceiverPeers(entity, { creator, origin, deadline, policy }) → { peers, expires_at, authority_version } | null.
  • hostNetworkTarget(entity, p, { mesh? }) → ReadResult, the method cmux-platform #174's Target.ts already calls. Around its mesh read it requires the same install authority_version (and the socket's install_authority when one is set) and the same member incarnation before and after. A grant going read → none → read, or a member leaving and rejoining with the same role, is refused (review finding at 0d8f0fa3).
  • Member authority incarnations (member_authority, now a private table) fence the creator and peer views.
  • host.network.bind records the grant it was confirmed under (ServerNetworkBinding.install_grant). Native dials refuse a binding that has no grant or has a different one. Legacy bindings decode but authorize nothing until they are rebound.

Why the dial and peer logic stays in TeamDO instead of moving to the platform: recheckNativeDial and the two-pass peer stamp depend on no await between the final TeamDO state checks and the answer. Splitting that across a cross-script RPC would open a window where a membership, key or grant change goes unseen.

Not carried from #28950: the install.authority.changed / member-authority notices (their consumers are CloudDO and socket fan-out; the platform re-reads, and leases last at most 30 s), host.network.policy, the UserDO socket stamping, and every CloudDO-side file (owned by the platform port).

Differences from #28950: member_authority is in TEAM_PRIVATE_TABLES so subscribers never receive incarnations. hostNetworkTarget checks the caller's read grant class and decodes its own params, because no catalog op exists for it here. NetworkAllow lives in protocol/src/network-allow.ts. cloud-vm-network-policy.ts is taken from #28947's version (identical add).

Generated: backend/catalog/cloud-operations.json, clients/ts/cloud/src/generated.ts and cmux-cloud-wire types_02.rs were regenerated for the optional install_grant.

Tests

  • test/owner-network-rpcs.test.ts (new) covers the refusals: wrong user stream, wrong grant, missing op class, expired grant, revoked install, revoked grant, a daemon bound to another team, a VM install bound to another team or machine, a VM install without vm-self, networkIdentity refusing a VM install, deviceKey op classes, batch bounds, the install-authority A → B → A fence and a forged incarnation, TeamDO RPCs on an unheld team, hostNetworkTarget without a read grant or with a bad selector (no reads made), and the vmReceiverPeers deadline window, creator membership, origin and tenant.
  • Live-authority tests: a successful hostNetworkTarget read; a regrant, rejoin or leave during the mesh read (the regrant and rejoin cases fail without the fence); a populated vmReceiverPeers join; an install authority change between the two identity snapshots; a creator rejoin or leave during the snapshots; a dial with an install or membership incarnation change during the Native read and after authorization.
  • Ported from #28950: cloud-native-dial.test.ts (the dial authorization block), cloud-vm-api-user-network-identity.test.ts (revokes with the session, since install principals cannot install.revoke on base), team-authority.test.ts, and the protocol catalog round-trip.

Observed / Not run

  • Run locally: tsc -p backend/apps/api is clean; vitest run passes on the four files above (39 tests at the current head); protocol vitest run passes (15); bun run catalog:check passes.
  • The full api suite runs on cmux-ci at this head (results will be in a comment).
  • #28950's cloud-vm-api-native-dial-owner.test.ts was not ported: it drives CloudDO.mintLinkToken, which is the platform port's to test.

Changelog

none

Agent session: agent session

🤖 Generated with Claude Code