From c12d392c099b56db8646bd58ad7c48b89de74285 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:53:07 -0700 Subject: [PATCH 01/10] Add team-scoped device registry (web): schema, routes, migration Server-side device registry so a phone can auto-pair on reload instead of re-scanning a QR. Two-level model: `devices` (a physical Mac/host, keyed by a cmux-generated persisted UUID) -> `device_app_instances` (one running cmux build/tag, holding the attach routes). Both carry a Stack `team_id` and a flexible `labels` jsonb. Registry is a best-effort rendezvous layer: a phone keeps its local paired-Mac store and falls back to it when the registry is down, so pairing survives the cloud dying. - web/db/schema.ts: devices + device_app_instances tables, key-pinning seam documented (device id will later anchor a pinned key for revoke). - web/app/api/devices/route.ts: POST register (idempotent per device / per (device, tag)), GET list, DELETE; team-scoped via X-Cmux-Team-Id and rejects teams the caller is not a member of; per-team cap with an advisory lock. - migration generated via drizzle-kit (v1 snapshot chain). - tests/devices-route.test.ts: register+list, route-refresh on re-register (the auto-pair path), non-member team 403, delete cascade. Co-Authored-By: Claude Opus 4.8 --- web/app/api/devices/route.ts | 325 +++ .../migration.sql | 29 + .../snapshot.json | 1816 +++++++++++++++++ web/db/schema.ts | 84 + web/tests/devices-route.test.ts | 176 ++ 5 files changed, 2430 insertions(+) create mode 100644 web/app/api/devices/route.ts create mode 100644 web/db/migrations/20260608044827_device_registry/migration.sql create mode 100644 web/db/migrations/20260608044827_device_registry/snapshot.json create mode 100644 web/tests/devices-route.test.ts diff --git a/web/app/api/devices/route.ts b/web/app/api/devices/route.ts new file mode 100644 index 000000000000..f6ec2363ad07 --- /dev/null +++ b/web/app/api/devices/route.ts @@ -0,0 +1,325 @@ +// Device registry — register a Mac/host (and its running cmux app instance) and +// list the team's registered devices so a phone can auto-pair on reload. +// +// Auth: Stack Bearer + X-Stack-Refresh-Token from the native client (same as +// /api/device-tokens). Team scope: the caller picks a team via `X-Cmux-Team-Id` +// (or `?teamId=`); the route rejects a team the caller is not a member of and +// otherwise defaults to the caller's selected/billing team. +// +// The registry is a best-effort rendezvous layer. It is NOT authoritative on +// pairing — a phone keeps its own local paired-Mac store and falls back to it +// when the registry is unreachable, so pairing survives the cloud being down. + +import { and, desc, eq, sql } from "drizzle-orm"; +import { cloudDb } from "../../../db/client"; +import { deviceAppInstances, devices } from "../../../db/schema"; +import { jsonResponse } from "../../../services/vms/routeHelpers"; +import { + unauthorized, + verifyRequest, + type AuthedUser, +} from "../../../services/vms/auth"; +import { requestedVmTeamIdFromRequest } from "../../../services/vms/routeHelpers"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const MAX_REQUEST_BYTES = 16 * 1024; +const MAX_DEVICES_PER_TEAM = 200; +const MAX_ROUTES = 16; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const ALLOWED_PLATFORMS = new Set(["mac", "ios", "linux", "windows"]); + +type TeamResolution = + | { ok: true; teamId: string } + | { ok: false; response: Response }; + +/** + * Resolve the team this request operates on and reject teams the caller is not a + * member of. A requested team (`X-Cmux-Team-Id` / `?teamId=`) must appear in the + * caller's verified team list; with no request team we default to the caller's + * selected team, then the billing team (which is the user id for a solo account + * with no team), so single-team callers need no header. + */ +function resolveTeam(request: Request, user: AuthedUser): TeamResolution { + const requested = requestedVmTeamIdFromRequest(request); + if (requested) { + const isMember = user.teamIds.includes(requested) || requested === user.id; + if (!isMember) { + return { + ok: false, + response: jsonResponse({ error: "team_not_found" }, 403), + }; + } + return { ok: true, teamId: requested }; + } + return { ok: true, teamId: user.selectedTeamId ?? user.billingTeamId }; +} + +async function readBoundedJson( + request: Request, +): Promise<{ ok: true; value: Record } | { ok: false; status: number }> { + const lengthHeader = request.headers.get("content-length"); + if (lengthHeader && Number(lengthHeader) > MAX_REQUEST_BYTES) { + return { ok: false, status: 413 }; + } + let raw: string; + try { + raw = await request.text(); + } catch { + return { ok: false, status: 400 }; + } + if (raw.length > MAX_REQUEST_BYTES) return { ok: false, status: 413 }; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ok: false, status: 400 }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { ok: false, status: 400 }; + } + return { ok: true, value: parsed as Record }; +} + +function trimmedString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function recordOrEmpty(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function routesArray(value: unknown): unknown[] { + return Array.isArray(value) ? value.slice(0, MAX_ROUTES) : []; +} + +/** + * Register (or refresh) a device and its running cmux app instance. Idempotent + * per `(deviceId)` for the machine row and per `(deviceId, tag)` for the + * instance row, so a relaunch updates routes in place rather than duplicating. + */ +export async function POST(request: Request): Promise { + const user = await verifyRequest(request, { + requestedTeamId: requestedVmTeamIdFromRequest(request), + allowCookie: false, + }); + if (!user) return unauthorized(); + + const team = resolveTeam(request, user); + if (!team.ok) return team.response; + + const body = await readBoundedJson(request); + if (!body.ok) return jsonResponse({ error: "invalid_request" }, body.status); + + const deviceId = trimmedString(body.value.deviceId).toLowerCase(); + const platform = trimmedString(body.value.platform).toLowerCase(); + const displayName = trimmedString(body.value.displayName) || null; + const labels = recordOrEmpty(body.value.labels); + const tag = trimmedString(body.value.tag) || "default"; + const routes = routesArray(body.value.routes); + const instanceLabels = recordOrEmpty(body.value.instanceLabels); + + if (!UUID_RE.test(deviceId)) { + return jsonResponse({ error: "invalid_device_id" }, 400); + } + if (!ALLOWED_PLATFORMS.has(platform)) { + return jsonResponse({ error: "invalid_platform" }, 400); + } + + const db = cloudDb(); + const now = new Date(); + + const registered = await db.transaction(async (tx) => { + // Serialize concurrent registrations for the same team so the per-team cap + // is enforced without a race (mirrors the device-tokens advisory lock). + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${team.teamId}, 7))`); + + const [existing] = await tx + .select({ id: devices.id, teamId: devices.teamId }) + .from(devices) + .where(eq(devices.id, deviceId)) + .limit(1); + + // A device id is global (cmux-generated UUID). If it already exists under a + // different team, the caller cannot claim it (prevents cross-team takeover). + if (existing && existing.teamId !== team.teamId) { + return { error: "device_team_conflict" as const }; + } + + if (!existing) { + const [{ total }] = await tx + .select({ total: sql`count(*)::int` }) + .from(devices) + .where(eq(devices.teamId, team.teamId)); + if (Number(total) >= MAX_DEVICES_PER_TEAM) { + return { error: "too_many_devices" as const }; + } + } + + await tx + .insert(devices) + .values({ + id: deviceId, + teamId: team.teamId, + userId: user.id, + platform, + displayName, + labels, + lastSeenAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: devices.id, + set: { + teamId: team.teamId, + userId: user.id, + platform, + displayName, + labels, + lastSeenAt: now, + updatedAt: now, + }, + }); + + await tx + .insert(deviceAppInstances) + .values({ + deviceId, + teamId: team.teamId, + tag, + routes, + labels: instanceLabels, + lastSeenAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [deviceAppInstances.deviceId, deviceAppInstances.tag], + set: { + teamId: team.teamId, + routes, + labels: instanceLabels, + lastSeenAt: now, + updatedAt: now, + }, + }); + + return { error: null }; + }); + + if (registered.error === "device_team_conflict") { + return jsonResponse({ error: "device_team_conflict" }, 409); + } + if (registered.error === "too_many_devices") { + return jsonResponse({ error: "too_many_devices" }, 429); + } + + return jsonResponse({ ok: true, deviceId, teamId: team.teamId, tag }); +} + +type DeviceListRow = { + id: string; + platform: string; + displayName: string | null; + labels: Record; + lastSeenAt: Date; +}; + +/** + * List the team's registered devices and their app instances, so a phone can + * find the Mac it last paired with and refresh routes on reload. + */ +export async function GET(request: Request): Promise { + const user = await verifyRequest(request, { + requestedTeamId: requestedVmTeamIdFromRequest(request), + allowCookie: false, + }); + if (!user) return unauthorized(); + + const team = resolveTeam(request, user); + if (!team.ok) return team.response; + + const db = cloudDb(); + + const deviceRows = (await db + .select({ + id: devices.id, + platform: devices.platform, + displayName: devices.displayName, + labels: devices.labels, + lastSeenAt: devices.lastSeenAt, + }) + .from(devices) + .where(eq(devices.teamId, team.teamId)) + .orderBy(desc(devices.lastSeenAt))) as DeviceListRow[]; + + const instanceRows = await db + .select({ + deviceId: deviceAppInstances.deviceId, + tag: deviceAppInstances.tag, + routes: deviceAppInstances.routes, + labels: deviceAppInstances.labels, + lastSeenAt: deviceAppInstances.lastSeenAt, + }) + .from(deviceAppInstances) + .where(eq(deviceAppInstances.teamId, team.teamId)) + .orderBy(desc(deviceAppInstances.lastSeenAt)); + + const instancesByDevice = new Map(); + for (const row of instanceRows) { + const list = instancesByDevice.get(row.deviceId) ?? []; + list.push(row); + instancesByDevice.set(row.deviceId, list); + } + + const devicesPayload = deviceRows.map((device) => ({ + deviceId: device.id, + platform: device.platform, + displayName: device.displayName, + labels: device.labels, + lastSeenAt: device.lastSeenAt.toISOString(), + instances: (instancesByDevice.get(device.id) ?? []).map((instance) => ({ + tag: instance.tag, + routes: instance.routes, + labels: instance.labels, + lastSeenAt: instance.lastSeenAt.toISOString(), + })), + })); + + return jsonResponse({ teamId: team.teamId, devices: devicesPayload }); +} + +/** + * Unregister a device (e.g. when the user forgets/decommissions a Mac). Removes + * the machine row and cascades its app instances. Team-scoped so a caller can + * only delete devices in a team they belong to. + */ +export async function DELETE(request: Request): Promise { + const user = await verifyRequest(request, { + requestedTeamId: requestedVmTeamIdFromRequest(request), + allowCookie: false, + }); + if (!user) return unauthorized(); + + const team = resolveTeam(request, user); + if (!team.ok) return team.response; + + const body = await readBoundedJson(request); + if (!body.ok) return jsonResponse({ error: "invalid_request" }, body.status); + + const deviceId = trimmedString(body.value.deviceId).toLowerCase(); + if (!UUID_RE.test(deviceId)) { + return jsonResponse({ error: "invalid_device_id" }, 400); + } + + const db = cloudDb(); + await db + .delete(devices) + .where(and(eq(devices.id, deviceId), eq(devices.teamId, team.teamId))); + + return jsonResponse({ ok: true }); +} diff --git a/web/db/migrations/20260608044827_device_registry/migration.sql b/web/db/migrations/20260608044827_device_registry/migration.sql new file mode 100644 index 000000000000..2aba4725cc04 --- /dev/null +++ b/web/db/migrations/20260608044827_device_registry/migration.sql @@ -0,0 +1,29 @@ +CREATE TABLE "device_app_instances" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "device_id" uuid NOT NULL, + "team_id" text NOT NULL, + "tag" text DEFAULT 'default' NOT NULL, + "routes" jsonb DEFAULT '[]' NOT NULL, + "labels" jsonb DEFAULT '{}' NOT NULL, + "last_seen_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "devices" ( + "id" uuid PRIMARY KEY, + "team_id" text NOT NULL, + "user_id" text NOT NULL, + "platform" text NOT NULL, + "display_name" text, + "labels" jsonb DEFAULT '{}' NOT NULL, + "last_seen_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "device_app_instances_device_tag_unique" ON "device_app_instances" ("device_id","tag");--> statement-breakpoint +CREATE INDEX "device_app_instances_team_last_seen_idx" ON "device_app_instances" ("team_id","last_seen_at");--> statement-breakpoint +CREATE INDEX "devices_team_last_seen_idx" ON "devices" ("team_id","last_seen_at");--> statement-breakpoint +CREATE INDEX "devices_team_user_idx" ON "devices" ("team_id","user_id");--> statement-breakpoint +ALTER TABLE "device_app_instances" ADD CONSTRAINT "device_app_instances_device_id_devices_id_fkey" FOREIGN KEY ("device_id") REFERENCES "devices"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/web/db/migrations/20260608044827_device_registry/snapshot.json b/web/db/migrations/20260608044827_device_registry/snapshot.json new file mode 100644 index 000000000000..5b53028db8c8 --- /dev/null +++ b/web/db/migrations/20260608044827_device_registry/snapshot.json @@ -0,0 +1,1816 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "c255c7e9-bc36-4114-bd84-68f86dfcbf69", + "prevIds": [ + "82769d22-cd97-490a-af75-97c410198ccc" + ], + "ddl": [ + { + "values": [ + "pty", + "rpc", + "ssh" + ], + "name": "vm_lease_kind", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "e2b", + "freestyle" + ], + "name": "vm_provider", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "provisioning", + "running", + "failed", + "paused", + "destroyed" + ], + "name": "vm_status", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "cloud_vm_billing_grants", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "cloud_vm_leases", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "cloud_vm_usage_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "cloud_vms", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "device_app_instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "device_tokens", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "notification_send_events", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_billing_grants" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "billing_customer_type", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_billing_grants" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "billing_customer_id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_billing_grants" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "billing_plan_id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_billing_grants" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "item_id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_billing_grants" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_billing_grants" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_billing_grants" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "applied_at", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_billing_grants" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_billing_grants" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_billing_grants" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "vm_id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "type": "vm_lease_kind", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_hash", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_identity_handle", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "session_id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "transport", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "consumed_at", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_usage_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_usage_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "billing_team_id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_usage_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "billing_plan_id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_usage_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "vm_id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_usage_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_type", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_usage_events" + }, + { + "type": "vm_provider", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_usage_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image_id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_usage_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_usage_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "cloud_vm_usage_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vms" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vms" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "billing_team_id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vms" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "billing_plan_id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vms" + }, + { + "type": "vm_provider", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "cloud_vms" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_vm_id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vms" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image_id", + "entityType": "columns", + "schema": "public", + "table": "cloud_vms" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image_version", + "entityType": "columns", + "schema": "public", + "table": "cloud_vms" + }, + { + "type": "vm_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'provisioning'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "cloud_vms" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotency_key", + "entityType": "columns", + "schema": "public", + "table": "cloud_vms" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "cloud_vms" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "cloud_vms" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "destroyed_at", + "entityType": "columns", + "schema": "public", + "table": "cloud_vms" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "failure_code", + "entityType": "columns", + "schema": "public", + "table": "cloud_vms" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "failure_message", + "entityType": "columns", + "schema": "public", + "table": "cloud_vms" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "device_app_instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "device_app_instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "team_id", + "entityType": "columns", + "schema": "public", + "table": "device_app_instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "tag", + "entityType": "columns", + "schema": "public", + "table": "device_app_instances" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "routes", + "entityType": "columns", + "schema": "public", + "table": "device_app_instances" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "labels", + "entityType": "columns", + "schema": "public", + "table": "device_app_instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "device_app_instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "device_app_instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "device_app_instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "device_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "device_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_token", + "entityType": "columns", + "schema": "public", + "table": "device_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'ios'", + "generated": null, + "identity": null, + "name": "platform", + "entityType": "columns", + "schema": "public", + "table": "device_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bundle_id", + "entityType": "columns", + "schema": "public", + "table": "device_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'production'", + "generated": null, + "identity": null, + "name": "environment", + "entityType": "columns", + "schema": "public", + "table": "device_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "device_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "device_tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "team_id", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "platform", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "display_name", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "labels", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "notification_send_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "notification_send_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_count", + "entityType": "columns", + "schema": "public", + "table": "notification_send_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "notification_send_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "billing_customer_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "billing_customer_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloud_vm_billing_grants_customer_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloud_vm_billing_grants" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "billing_customer_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "billing_customer_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "item_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reason", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloud_vm_billing_grants_customer_item_reason_unique", + "entityType": "indexes", + "schema": "public", + "table": "cloud_vm_billing_grants" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "vm_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloud_vm_leases_vm_kind_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "provider_identity_handle", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloud_vm_leases_identity_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloud_vm_leases_user_expires_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "token_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloud_vm_leases_token_hash_unique", + "entityType": "indexes", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloud_vm_usage_events_user_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloud_vm_usage_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "billing_team_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloud_vm_usage_events_billing_team_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloud_vm_usage_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "vm_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloud_vm_usage_events_vm_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloud_vm_usage_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "event_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloud_vm_usage_events_type_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloud_vm_usage_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloud_vms_user_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloud_vms" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "billing_team_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloud_vms_billing_team_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloud_vms" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotency_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"idempotency_key\" is not null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloud_vms_user_idempotency_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "cloud_vms" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "provider", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "provider_vm_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"provider_vm_id\" is not null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloud_vms_provider_vm_id_unique", + "entityType": "indexes", + "schema": "public", + "table": "cloud_vms" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "device_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "tag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "device_app_instances_device_tag_unique", + "entityType": "indexes", + "schema": "public", + "table": "device_app_instances" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "team_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "last_seen_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "device_app_instances_team_last_seen_idx", + "entityType": "indexes", + "schema": "public", + "table": "device_app_instances" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "device_tokens_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "device_tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "device_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "device_tokens_device_token_unique", + "entityType": "indexes", + "schema": "public", + "table": "device_tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "team_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "last_seen_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "devices_team_last_seen_idx", + "entityType": "indexes", + "schema": "public", + "table": "devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "team_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "devices_team_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notification_send_events_user_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "notification_send_events" + }, + { + "nameExplicit": false, + "columns": [ + "vm_id" + ], + "schemaTo": "public", + "tableTo": "cloud_vms", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "cloud_vm_leases_vm_id_cloud_vms_id_fk", + "entityType": "fks", + "schema": "public", + "table": "cloud_vm_leases" + }, + { + "nameExplicit": false, + "columns": [ + "vm_id" + ], + "schemaTo": "public", + "tableTo": "cloud_vms", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "cloud_vm_usage_events_vm_id_cloud_vms_id_fk", + "entityType": "fks", + "schema": "public", + "table": "cloud_vm_usage_events" + }, + { + "nameExplicit": false, + "columns": [ + "device_id" + ], + "schemaTo": "public", + "tableTo": "devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "device_app_instances_device_id_devices_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "device_app_instances" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cloud_vm_billing_grants_pkey", + "schema": "public", + "table": "cloud_vm_billing_grants", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cloud_vm_leases_pkey", + "schema": "public", + "table": "cloud_vm_leases", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cloud_vm_usage_events_pkey", + "schema": "public", + "table": "cloud_vm_usage_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cloud_vms_pkey", + "schema": "public", + "table": "cloud_vms", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "device_app_instances_pkey", + "schema": "public", + "table": "device_app_instances", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "device_tokens_pkey", + "schema": "public", + "table": "device_tokens", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "devices_pkey", + "schema": "public", + "table": "devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "notification_send_events_pkey", + "schema": "public", + "table": "notification_send_events", + "entityType": "pks" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/web/db/schema.ts b/web/db/schema.ts index db45068abd2a..e216363bb528 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -166,3 +166,87 @@ export const cloudVmBillingGrants = pgTable( .on(table.billingCustomerType, table.billingCustomerId, table.itemId, table.reason), ], ); + +/** + * Device registry — the team-scoped record of which physical machines (Macs / + * hosts) and their running cmux app instances exist, so a phone can auto-pair + * on reload instead of re-scanning a QR. + * + * Two-level model: + * `devices` (a physical machine) -> `deviceAppInstances` (one running cmux + * build/tag on that machine). + * + * The registry is a best-effort *rendezvous* layer that lets a re-launched + * phone look up the current routes for the Mac it last paired with. It is NOT + * an authority on pairing: a phone keeps its own local paired-Mac store and + * falls back to it if the registry is unreachable, so pairing survives the + * cloud registry being down. + * + * Device identity is a cmux-GENERATED persisted UUID (see Mac + * `MobileHostIdentity.deviceID()` / iOS `MobileDeviceIdentity`), NOT + * IOPlatformUUID. It is cross-platform, survives relaunch, and is + * user-renamable via `displayName`. + */ +export const devices = pgTable( + "devices", + { + // The cmux-generated persisted UUID supplied by the device, used directly + // as the primary key. NOTE (key-pinning phase): this column will later + // anchor a pinned per-device public key for revoke; P1 stores identity + // only and never trusts it for authorization beyond the team scope below. + id: uuid("id").primaryKey(), + // Stack team that owns this device row. All registry reads/writes are + // scoped to a team the caller is a verified member of (`X-Cmux-Team-Id`). + teamId: text("team_id").notNull(), + // Stack user that registered the device (audit / future per-user views). + userId: text("user_id").notNull(), + // "mac" | "ios" | "linux" | ... (free-form so new host platforms need no + // migration). The host that advertises routes is typically "mac". + platform: text("platform").notNull(), + // User-renamable label (e.g. the Mac's name). Optional. + displayName: text("display_name"), + // Flexible bag for arbitrary metadata (OS version, model, capabilities, + // and later a pinned key fingerprint). Avoids a migration per new field. + labels: jsonb("labels").$type>().notNull().default(sql`'{}'::jsonb`), + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("devices_team_last_seen_idx").on(table.teamId, table.lastSeenAt), + index("devices_team_user_idx").on(table.teamId, table.userId), + ], +); + +/** + * A running cmux app instance on a device, keyed by `(deviceId, tag)` so each + * tagged build (`dev.cmux.`, stable, etc.) on the same machine is its own + * row. Holds the attach `routes` the phone uses to reconnect; the registry is + * port-flexible, so the endpoint lives in `routes` jsonb rather than a fixed + * column. A re-register updates the routes for the same `(deviceId, tag)`. + */ +export const deviceAppInstances = pgTable( + "device_app_instances", + { + id: uuid("id").defaultRandom().primaryKey(), + deviceId: uuid("device_id") + .notNull() + .references(() => devices.id, { onDelete: "cascade" }), + teamId: text("team_id").notNull(), + // The cmux build tag this instance is running (e.g. "stable" or a dev tag). + // Defaults to "default" when the build does not distinguish tags. + tag: text("tag").notNull().default("default"), + // Attach routes advertised by this instance, ordered by priority. Shape + // mirrors the Mac/iOS `CmxAttachRoute` (kind + endpoint + priority), kept as + // jsonb so the registry stays port- and transport-flexible. + routes: jsonb("routes").$type().notNull().default(sql`'[]'::jsonb`), + labels: jsonb("labels").$type>().notNull().default(sql`'{}'::jsonb`), + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("device_app_instances_device_tag_unique").on(table.deviceId, table.tag), + index("device_app_instances_team_last_seen_idx").on(table.teamId, table.lastSeenAt), + ], +); diff --git a/web/tests/devices-route.test.ts b/web/tests/devices-route.test.ts new file mode 100644 index 000000000000..58def1ddd6b6 --- /dev/null +++ b/web/tests/devices-route.test.ts @@ -0,0 +1,176 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import postgres, { type Sql } from "postgres"; + +import { closeCloudDbForTests } from "../db/client"; + +const runDbTests = process.env.CMUX_DB_TEST === "1"; +const dbTest = runDbTests ? test : test.skip; + +// Stack user with a single team ("team-a") so requests can scope to that team. +const getUser = mock(async () => ({ + id: "registry-user-1", + displayName: null, + primaryEmail: "registry@example.com", + selectedTeam: { id: "team-a" }, + listTeams: async () => [{ id: "team-a" }], +})); + +mock.module("../app/lib/stack", () => ({ + getStackServerApp: () => ({ getUser }), + isStackConfigured: () => true, +})); + +const { DELETE, GET, POST } = await import("../app/api/devices/route"); + +let sql: Sql | null = null; + +const DEVICE_A = "11111111-1111-4111-8111-111111111111"; +const DEVICE_B = "22222222-2222-4222-8222-222222222222"; + +function authHeaders(teamId?: string): Record { + const base: Record = { + authorization: "Bearer access-token", + "x-stack-refresh-token": "refresh-token", + "content-type": "application/json", + }; + if (teamId) base["x-cmux-team-id"] = teamId; + return base; +} + +function registerRequest(body: Record, teamId?: string): Request { + return new Request("https://cmux.test/api/devices", { + method: "POST", + headers: authHeaders(teamId), + body: JSON.stringify(body), + }); +} + +beforeAll(() => { + if (!runDbTests) return; + const databaseURL = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL; + if (!databaseURL) { + throw new Error("DATABASE_URL is required when CMUX_DB_TEST=1"); + } + sql = postgres(databaseURL, { max: 1 }); +}); + +afterAll(async () => { + await closeCloudDbForTests(); + await sql?.end(); +}); + +beforeEach(async () => { + if (!sql) return; + await sql`truncate devices, device_app_instances restart identity cascade`; + getUser.mockClear(); +}); + +describe("device registry route", () => { + dbTest("registers a Mac and its app instance, then lists it for the team", async () => { + if (!sql) throw new Error("test database not initialized"); + + const register = await POST( + registerRequest({ + deviceId: DEVICE_A, + platform: "mac", + displayName: "Lawrence's Mac", + tag: "stable", + routes: [{ id: "r1", kind: "tailscale", priority: 0, endpoint: { host: "100.1.2.3", port: 51001 } }], + }), + ); + expect(register.status).toBe(200); + + const listResponse = await GET( + new Request("https://cmux.test/api/devices", { method: "GET", headers: authHeaders() }), + ); + expect(listResponse.status).toBe(200); + const list = (await listResponse.json()) as { + teamId: string; + devices: Array<{ + deviceId: string; + displayName: string | null; + platform: string; + instances: Array<{ tag: string; routes: unknown[] }>; + }>; + }; + expect(list.teamId).toBe("team-a"); + expect(list.devices).toHaveLength(1); + expect(list.devices[0].deviceId).toBe(DEVICE_A); + expect(list.devices[0].displayName).toBe("Lawrence's Mac"); + expect(list.devices[0].instances).toHaveLength(1); + expect(list.devices[0].instances[0].tag).toBe("stable"); + expect(list.devices[0].instances[0].routes).toHaveLength(1); + }); + + dbTest("re-registering the same (device, tag) refreshes routes in place (auto-pair path)", async () => { + if (!sql) throw new Error("test database not initialized"); + + await POST( + registerRequest({ + deviceId: DEVICE_A, + platform: "mac", + tag: "stable", + routes: [{ id: "old", kind: "tailscale", priority: 0, endpoint: { host: "100.0.0.1", port: 1 } }], + }), + ); + // Mac moved networks / restarted on a new port: re-register with fresh routes. + await POST( + registerRequest({ + deviceId: DEVICE_A, + platform: "mac", + tag: "stable", + routes: [{ id: "new", kind: "tailscale", priority: 0, endpoint: { host: "100.9.9.9", port: 51999 } }], + }), + ); + + const [{ total }] = await sql<{ total: number }[]>` + select count(*)::int as total from device_app_instances where device_id = ${DEVICE_A} + `; + expect(total).toBe(1); + + const list = (await ( + await GET(new Request("https://cmux.test/api/devices", { method: "GET", headers: authHeaders() })) + ).json()) as { devices: Array<{ instances: Array<{ routes: Array<{ endpoint: { host: string } }> }> }> }; + expect(list.devices[0].instances[0].routes[0].endpoint.host).toBe("100.9.9.9"); + }); + + dbTest("rejects a team the caller is not a member of", async () => { + if (!sql) throw new Error("test database not initialized"); + + const response = await POST( + registerRequest( + { deviceId: DEVICE_A, platform: "mac", routes: [] }, + "team-not-mine", + ), + ); + expect(response.status).toBe(403); + + const [{ total }] = await sql<{ total: number }[]>`select count(*)::int as total from devices`; + expect(total).toBe(0); + }); + + dbTest("delete removes the device and cascades its instances", async () => { + if (!sql) throw new Error("test database not initialized"); + + await POST(registerRequest({ deviceId: DEVICE_A, platform: "mac", tag: "stable", routes: [] })); + await POST(registerRequest({ deviceId: DEVICE_B, platform: "mac", tag: "stable", routes: [] })); + + const del = await DELETE( + new Request("https://cmux.test/api/devices", { + method: "DELETE", + headers: authHeaders(), + body: JSON.stringify({ deviceId: DEVICE_A }), + }), + ); + expect(del.status).toBe(200); + + const [{ devicesTotal }] = await sql<{ devicesTotal: number }[]>` + select count(*)::int as "devicesTotal" from devices + `; + expect(devicesTotal).toBe(1); + const [{ instancesTotal }] = await sql<{ instancesTotal: number }[]>` + select count(*)::int as "instancesTotal" from device_app_instances where device_id = ${DEVICE_A} + `; + expect(instancesTotal).toBe(0); + }); +}); From 4efa7c20cd66bf05c4b2be3b3d97e3c52ef9a8ab Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:09:50 -0700 Subject: [PATCH 02/10] Wire device registry: Mac register + iOS auto-pair route refresh Mac (Sources/Cloud/DeviceRegistryClient.swift): registers this Mac + its app instance's attach routes in the team registry, observing MobileHostService.statusUpdates() and POSTing /api/devices whenever the route set changes (moved networks / new port). Mirrors PhonePushClient auth (Bearer + X-Stack-Refresh-Token + X-Cmux-Team-Id, AuthEnvironment.vmAPIBaseURL), best-effort and non-blocking. Gated implicitly on non-empty routes, so it only registers once the user has enabled mobile pairing (no separate opt-in). A pure shouldReRegister() skips redundant POSTs on connection-only status ticks; wired in AppDelegate next to PhonePushClient/MobileHostService configure. iOS (Packages/CmuxMobileShell): MobileDeviceIdentity persists a cmux-generated device UUID (mirrors the Mac's MobileHostIdentity, not a hardware fingerprint). DeviceRegistryService reads /api/devices for a Mac's fresh routes; injected into MobileShellComposite as DeviceRegistryRefreshing. On reconnect, a detached non-blocking refresh fetches registry routes and, via the pure selectReconnectRoutes() (registry-fresh wins, falls back to local when the registry is empty/unreachable), writes fresher routes into MobilePairedMacStore so the next reconnect trigger reaches a moved Mac. The connect itself still uses local routes with zero added latency on the common case. CMUXMobileRootScene builds the service over the AuthCoordinator (tokens + resolvedTeamID). Phone self-registration as a device row is deferred to the key-pinning phase. Co-Authored-By: Claude Opus 4.8 --- .../DeviceRegistryRefreshing.swift | 46 ++++++ .../DeviceRegistryService.swift | 146 ++++++++++++++++++ .../MobileDeviceIdentity.swift | 28 ++++ .../MobileShellComposite.swift | 49 ++++++ .../DeviceRegistryRouteSelectionTests.swift | 105 +++++++++++++ Sources/AppDelegate.swift | 1 + Sources/Cloud/DeviceRegistryClient.swift | 126 +++++++++++++++ cmux.xcodeproj/project.pbxproj | 8 + cmuxTests/DeviceRegistryClientTests.swift | 52 +++++++ .../cmuxFeature/CMUXMobileRootScene.swift | 25 +++ 10 files changed, 586 insertions(+) create mode 100644 Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift create mode 100644 Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift create mode 100644 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeviceIdentity.swift create mode 100644 Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift create mode 100644 Sources/Cloud/DeviceRegistryClient.swift create mode 100644 cmuxTests/DeviceRegistryClientTests.swift diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift new file mode 100644 index 000000000000..0f2e1c25cc98 --- /dev/null +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift @@ -0,0 +1,46 @@ +public import CMUXMobileCore + +/// A best-effort lookup of fresher attach routes for a paired Mac from the +/// team-scoped device registry. +/// +/// The registry is a rendezvous layer, not an authority: it lets a re-launched +/// phone discover the current routes for the Mac it last paired with (e.g. when +/// the Mac moved networks or restarted on a different port). It is deliberately +/// fallible — a `nil` result means "registry unavailable, use what you have," so +/// reconnect always falls back to the locally persisted paired-Mac routes and +/// pairing survives the cloud registry being down. +public protocol DeviceRegistryRefreshing: Sendable { + /// Fetch the registry's current routes for the given Mac device id, scoped to + /// the signed-in user's team. + /// + /// - Returns: The registry's routes for that Mac, or `nil` when the registry + /// is unreachable, the call is unauthorized, or the Mac is not registered. + /// `nil` and `[]` are both treated as "no fresher routes" by + /// ``DeviceRegistryRouteSelection/selectReconnectRoutes(local:registry:)``. + func freshRoutes(forMacDeviceID macDeviceID: String) async -> [CmxAttachRoute]? +} + +/// Pure route-selection policy for reconnect, isolated so it is unit-testable +/// without any network or store. The reconnect path connects on `local` routes +/// immediately (no added latency on the common case) and only *replaces* the +/// persisted routes when the registry returns a usable, different set. +public enum DeviceRegistryRouteSelection { + /// Choose the routes to persist for the next reconnect. + /// + /// - Parameters: + /// - local: The routes currently persisted for the paired Mac. + /// - registry: The registry's routes, or `nil` when it was unavailable. + /// - Returns: The registry routes when they are non-empty and differ from + /// `local` (so a stale-route Mac gets rescued on the next reconnect + /// trigger); otherwise `local`, so an unavailable or no-op registry never + /// discards working routes. The result is `nil` only to signal "no change + /// needed," letting callers skip a redundant store write. + public static func selectReconnectRoutes( + local: [CmxAttachRoute], + registry: [CmxAttachRoute]? + ) -> [CmxAttachRoute]? { + guard let registry, !registry.isEmpty else { return nil } + guard registry != local else { return nil } + return registry + } +} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift new file mode 100644 index 000000000000..21c8e20aea1c --- /dev/null +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift @@ -0,0 +1,146 @@ +public import CMUXMobileCore +public import Foundation +import os + +private let deviceRegistryLog = Logger(subsystem: "com.cmuxterm.app", category: "DeviceRegistry") + +/// HTTP client for the team-scoped device registry (`/api/devices`). +/// +/// Looks up fresher attach routes for a paired Mac on reload. P1 only needs the +/// phone to *read* the team's Macs; registering the phone itself as a `device` +/// row is deferred to the key-pinning phase (a phone row only matters once it +/// anchors a pinned key for revoke). `deviceID` is already plumbed here so that +/// phase has the persisted identity ready. +/// +/// Auth mirrors ``PushRegistrationService``: native calls send +/// `Authorization: Bearer ` + `X-Stack-Refresh-Token: `, plus an +/// optional `X-Cmux-Team-Id` so the server scopes to the chosen team (defaults to +/// the Stack-selected team when omitted). Tokens are supplied through injected +/// Sendable closures so this service needs no dependency on the auth package. +/// +/// Every call is best-effort and failure-tolerant: a thrown/timed-out request +/// yields `nil` so reconnect falls back to locally persisted routes and pairing +/// survives the registry being down. +public actor DeviceRegistryService: DeviceRegistryRefreshing { + /// Supplies the bearer/refresh tokens for an authenticated request, or `nil` + /// when there is no valid session. + public struct TokenSource: Sendable { + public var accessToken: @Sendable () async -> String? + public var refreshToken: @Sendable () async -> String? + + public init( + accessToken: @escaping @Sendable () async -> String?, + refreshToken: @escaping @Sendable () async -> String? + ) { + self.accessToken = accessToken + self.refreshToken = refreshToken + } + } + + private let apiBaseURL: String + private let deviceID: String + private let tokenSource: TokenSource + private let teamIDProvider: @Sendable () async -> String? + private let session: URLSession + private let requestTimeout: TimeInterval + + /// - Parameters: + /// - apiBaseURL: The cmux web API base URL (no trailing slash). + /// - deviceID: This iOS device's registry id (``MobileDeviceIdentity``). + /// - tokenSource: Supplies the Stack access/refresh tokens. + /// - teamIDProvider: Supplies the team id to scope to, or `nil` to let the + /// server use the Stack-selected team. + /// - session: The URLSession used for API calls. + /// - requestTimeout: Per-request deadline, bounding the worst-case latency + /// of a registry call so it never stalls the reconnect refresh. + public init( + apiBaseURL: String, + deviceID: String, + tokenSource: TokenSource, + teamIDProvider: @escaping @Sendable () async -> String? = { nil }, + session: sending URLSession = .shared, + requestTimeout: TimeInterval = 5 + ) { + self.apiBaseURL = apiBaseURL + self.deviceID = deviceID + self.tokenSource = tokenSource + self.teamIDProvider = teamIDProvider + self.session = session + self.requestTimeout = requestTimeout + } + + // MARK: - DeviceRegistryRefreshing + + public func freshRoutes(forMacDeviceID macDeviceID: String) async -> [CmxAttachRoute]? { + guard let request = await makeRequest(method: "GET", path: "/api/devices", body: nil) else { + return nil + } + let data: Data + do { + let (responseData, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + return nil + } + data = responseData + } catch { + deviceRegistryLog.debug("freshRoutes request failed: \(String(describing: error), privacy: .public)") + return nil + } + return Self.routes(forMacDeviceID: macDeviceID, in: data) + } + + // MARK: - Parsing (pure, testable) + + /// Decode the `/api/devices` list response and return the routes for the + /// device whose id matches `macDeviceID`, preferring its most recently seen + /// app instance. Returns `nil` when the device or routes are absent so the + /// caller falls back to local routes. + static func routes(forMacDeviceID macDeviceID: String, in data: Data) -> [CmxAttachRoute]? { + struct Instance: Decodable { + let routes: [CmxAttachRoute] + } + struct Device: Decodable { + let deviceId: String + let instances: [Instance] + } + struct ListResponse: Decodable { + let devices: [Device] + } + guard let decoded = try? JSONDecoder().decode(ListResponse.self, from: data) else { + return nil + } + let target = macDeviceID.lowercased() + guard let device = decoded.devices.first(where: { $0.deviceId.lowercased() == target }) else { + return nil + } + // Instances are returned most-recently-seen first; the first non-empty + // route set is the freshest reachable instance for this Mac. + for instance in device.instances where !instance.routes.isEmpty { + return instance.routes + } + return nil + } + + // MARK: - Request building + + private func makeRequest(method: String, path: String, body: [String: Any]?) async -> URLRequest? { + guard let accessToken = await tokenSource.accessToken(), + let refreshToken = await tokenSource.refreshToken(), + let url = URL(string: apiBaseURL + path) else { + return nil + } + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = requestTimeout + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token") + if let teamID = await teamIDProvider(), !teamID.isEmpty { + request.setValue(teamID, forHTTPHeaderField: "X-Cmux-Team-Id") + } + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + } + return request + } +} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeviceIdentity.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeviceIdentity.swift new file mode 100644 index 000000000000..0a3a5329098d --- /dev/null +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeviceIdentity.swift @@ -0,0 +1,28 @@ +public import Foundation + +/// This iOS device's stable cmux identity for the device registry. +/// +/// A cmux-GENERATED persisted UUID (NOT `identifierForVendor`, which resets when +/// the last cmux app is removed, and NOT a hardware fingerprint). Persisted in +/// `UserDefaults` so it survives relaunch and reinstall-while-other-cmux-apps- +/// present, is cross-platform, and is user-renamable via its display name. +/// +/// Mirrors the Mac side's `MobileHostIdentity.deviceID()` so both ends of the +/// registry use the same identity shape. The phone sends this id when it +/// registers itself as a device; the registry's key-pinning phase will later +/// anchor a pinned key to it for revoke. +public enum MobileDeviceIdentity { + private static let deviceIDKey = "cmux.deviceRegistry.iosDeviceID" + + /// The persisted device UUID, generating and storing one on first use. + /// - Parameter defaults: Persistence store (injected for tests). + public static func deviceID(defaults: UserDefaults = .standard) -> String { + if let existing = defaults.string(forKey: deviceIDKey), + !existing.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return existing + } + let generated = UUID().uuidString.lowercased() + defaults.set(generated, forKey: deviceIDKey) + return generated + } +} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index d6ead3870798..2e58cd37c5e6 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -200,6 +200,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private let runtime: (any MobileSyncRuntime)? private let pairedMacStore: (any MobilePairedMacStoring)? + /// Best-effort, team-scoped lookup of fresher attach routes from the device + /// registry. Optional and failure-tolerant: when `nil` or unreachable, + /// reconnect uses the locally persisted paired-Mac routes, so pairing + /// survives the cloud registry being down. + private let deviceRegistry: (any DeviceRegistryRefreshing)? private let identityProvider: (any MobileIdentityProviding)? private let reachability: any ReachabilityProviding private let pairingHintDefaults: UserDefaults @@ -323,6 +328,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { pairingCode: String = "", workspaces: [MobileWorkspacePreview] = [], pairedMacStore: (any MobilePairedMacStoring)? = nil, + deviceRegistry: (any DeviceRegistryRefreshing)? = nil, clientIDRepository: MobileClientIDRepository = MobileClientIDRepository(defaults: .standard), identityProvider: (any MobileIdentityProviding)? = nil, reachability: any ReachabilityProviding = ReachabilityService(), @@ -332,6 +338,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) { self.runtime = runtime self.pairedMacStore = pairedMacStore + self.deviceRegistry = deviceRegistry self.identityProvider = identityProvider self.reachability = reachability self.pairingHintDefaults = pairingHintDefaults @@ -915,6 +922,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { finishStoredMacReconnectAttempt(generation: generation) return false } + // Kick off a best-effort registry refresh for this Mac in the background. + // It does NOT block the connect below: the common case (fresh local + // routes) reconnects immediately with no network round-trip. If the Mac + // moved networks / changed port, the refreshed routes land in the store + // and the next reconnect trigger (network change or Retry) uses them. + refreshRoutesFromRegistry(for: mac, stackUserID: stackUserID) let supportedKinds = runtime?.supportedRouteKinds ?? [] guard let (host, port) = Self.firstReconnectHostPortRoute( mac.routes, @@ -980,6 +993,42 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { didFinishStoredMacReconnectAttempt = true } + /// Best-effort, non-blocking registry refresh for the active paired Mac. + /// + /// Runs detached so it never adds latency to the in-flight reconnect (which + /// connects on the locally persisted routes). When the registry returns + /// usable, *different* routes for this Mac, they are written back into the + /// store so the next reconnect trigger (network change / Retry) reaches the + /// Mac at its current address after it moved networks or changed port. A + /// missing registry, an unauthorized call, or no-change routes are no-ops, so + /// a registry outage never disturbs the locally stored routes. + private func refreshRoutesFromRegistry(for mac: MobilePairedMac, stackUserID: String?) { + guard let deviceRegistry, let pairedMacStore else { return } + let macDeviceID = mac.macDeviceID + let localRoutes = mac.routes + let displayName = mac.displayName + Task { [weak self] in + let registryRoutes = await deviceRegistry.freshRoutes(forMacDeviceID: macDeviceID) + guard let updated = DeviceRegistryRouteSelection.selectReconnectRoutes( + local: localRoutes, + registry: registryRoutes + ) else { return } + do { + try await pairedMacStore.upsert( + macDeviceID: macDeviceID, + displayName: displayName, + routes: updated, + markActive: true, + stackUserID: stackUserID + ) + } catch { + mobileShellLog.debug("registry route refresh upsert failed: \(String(describing: error), privacy: .public)") + return + } + await self?.loadPairedMacs() + } + } + // MARK: - Paired Mac switching /// Every Mac paired with this device, for the host switcher. Refreshed via diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift new file mode 100644 index 000000000000..0c24ee842764 --- /dev/null +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift @@ -0,0 +1,105 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxMobileShell + +/// Tests the pure reconnect-route policy and registry-response parsing. These +/// are the heart of the auto-pair-on-reload path: the policy decides when a +/// stale-route Mac is rescued by registry routes versus when the locally +/// persisted routes win (so pairing survives the registry being down). +@Suite struct DeviceRegistryRouteSelectionTests { + private func route(host: String, port: Int, id: String = "r", priority: Int = 0) throws -> CmxAttachRoute { + try CmxAttachRoute( + id: id, + kind: .tailscale, + endpoint: .hostPort(host: host, port: port), + priority: priority + ) + } + + @Test func registryUnavailableFallsBackToLocal() throws { + let local = [try route(host: "100.0.0.1", port: 51000)] + // nil == registry unreachable / unauthorized / Mac not registered. + #expect(DeviceRegistryRouteSelection.selectReconnectRoutes(local: local, registry: nil) == nil) + } + + @Test func registryEmptyFallsBackToLocal() throws { + let local = [try route(host: "100.0.0.1", port: 51000)] + #expect(DeviceRegistryRouteSelection.selectReconnectRoutes(local: local, registry: []) == nil) + } + + @Test func identicalRegistryRoutesAreANoOp() throws { + let routes = [try route(host: "100.0.0.1", port: 51000)] + #expect(DeviceRegistryRouteSelection.selectReconnectRoutes(local: routes, registry: routes) == nil) + } + + @Test func differentRegistryRoutesWin() throws { + // The Mac moved networks / changed port: registry has the current route. + let local = [try route(host: "100.0.0.1", port: 51000)] + let registry = [try route(host: "100.9.9.9", port: 51999)] + let selected = DeviceRegistryRouteSelection.selectReconnectRoutes(local: local, registry: registry) + #expect(selected == registry) + } + + @Test func parsesRoutesForMatchingMacFromListResponse() throws { + let json = """ + { + "teamId": "team-a", + "devices": [ + { + "deviceId": "AAAA1111-1111-4111-8111-111111111111", + "platform": "mac", + "displayName": "Other Mac", + "instances": [{ "tag": "stable", "routes": [] }] + }, + { + "deviceId": "BBBB2222-2222-4222-8222-222222222222", + "platform": "mac", + "displayName": "Lawrence's Mac", + "instances": [ + { "tag": "stale", "routes": [] }, + { + "tag": "stable", + "routes": [ + { "id": "r1", "kind": "tailscale", "priority": 0, + "endpoint": { "type": "host_port", "host": "100.9.9.9", "port": 51999 } } + ] + } + ] + } + ] + } + """.data(using: .utf8)! + + // Case-insensitive id match (the wire id may be upper- or lower-cased). + let routes = DeviceRegistryService.routes( + forMacDeviceID: "bbbb2222-2222-4222-8222-222222222222", + in: json + ) + #expect(routes?.count == 1) + if case let .hostPort(host, port) = routes?.first?.endpoint { + #expect(host == "100.9.9.9") + #expect(port == 51999) + } else { + Issue.record("expected a host_port route") + } + } + + @Test func returnsNilWhenMacNotInListResponse() { + let json = #"{ "teamId": "team-a", "devices": [] }"#.data(using: .utf8)! + #expect(DeviceRegistryService.routes(forMacDeviceID: "missing", in: json) == nil) + } + + @Test func deviceIdentityPersistsAcrossLookups() { + let suite = "test.deviceRegistry.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + + let first = MobileDeviceIdentity.deviceID(defaults: defaults) + let second = MobileDeviceIdentity.deviceID(defaults: defaults) + #expect(first == second) + #expect(!first.isEmpty) + // Stable across a fresh accessor reading the same store (relaunch proxy). + #expect(UUID(uuidString: first) != nil) + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a1dc079c3d4c..fd2b79426b4e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1908,6 +1908,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent VMClient.bootstrap(auth: auth.coordinator) PhonePushClient.shared.configure(auth: auth.coordinator) MobileHostService.shared.configure(auth: auth.coordinator) + DeviceRegistryClient.shared.configure(auth: auth.coordinator) TerminalController.shared.attachAuth( coordinator: auth.coordinator, browserSignIn: auth.browserSignIn diff --git a/Sources/Cloud/DeviceRegistryClient.swift b/Sources/Cloud/DeviceRegistryClient.swift new file mode 100644 index 000000000000..86998f9fdc50 --- /dev/null +++ b/Sources/Cloud/DeviceRegistryClient.swift @@ -0,0 +1,126 @@ +import CMUXMobileCore +import CmuxAuthRuntime +import Foundation + +/// Registers this Mac (and its running cmux app instance's attach routes) in the +/// team-scoped device registry (`POST /api/devices`), so a phone can look up the +/// Mac's current routes on reload and auto-pair instead of re-scanning a QR. +/// +/// Event-driven: it observes ``MobileHostService/statusUpdates()`` and registers +/// whenever the advertised route set changes (e.g. the Mac moved networks or +/// rebound to a different port), which is exactly the freshness the phone needs. +/// Gating falls out of the routes: ``MobileHostService`` advertises no routes +/// until the user has enabled mobile pairing, so an empty route set is never +/// registered. There is no separate opt-in flag — the registry is core to the +/// pairing the user already turned on, not a distinct privacy surface. +/// +/// Best-effort and non-blocking, mirroring ``PhonePushClient``: a registry +/// outage never disturbs the Mac, and pairing still works through the phone's +/// locally stored routes. +@MainActor +final class DeviceRegistryClient { + static let shared = DeviceRegistryClient() + + private let session: URLSession = .shared + private var auth: AuthCoordinator? + private var observeTask: Task? + /// The route set most recently registered, used to skip redundant POSTs when + /// `statusUpdates()` fires for a connection change rather than a route change. + private var lastRegisteredRoutes: [CmxAttachRoute]? + + private init() {} + + /// Inject the auth dependency and begin observing host-route changes. Call + /// once at the composition root (after `auth` is constructed). + func configure(auth: AuthCoordinator) { + self.auth = auth + startObserving() + } + + /// Whether the current advertised routes differ from what was last registered. + /// + /// Pure so it is unit-testable without any network or host service. Returns + /// `false` for an empty current set (nothing to advertise / pairing off) and + /// for an unchanged set (a connection-only `statusUpdates()` tick), and + /// `true` only when there are routes that differ from the last registration. + static func shouldReRegister( + previous: [CmxAttachRoute]?, + current: [CmxAttachRoute] + ) -> Bool { + guard !current.isEmpty else { return false } + return previous != current + } + + private func startObserving() { + observeTask?.cancel() + observeTask = Task { @MainActor [weak self] in + for await status in MobileHostService.shared.statusUpdates() { + if Task.isCancelled { break } + await self?.registerIfRoutesChanged(routes: status.routes) + } + } + } + + private func registerIfRoutesChanged(routes: [CmxAttachRoute]) async { + guard Self.shouldReRegister(previous: lastRegisteredRoutes, current: routes) else { return } + guard let auth else { return } + let tokens: (accessToken: String, refreshToken: String) + do { + tokens = try await auth.currentTokens() + } catch { + return // not signed in → nothing to do + } + let teamID = auth.resolvedTeamID + + guard var comps = URLComponents(url: AuthEnvironment.vmAPIBaseURL, resolvingAgainstBaseURL: false) else { + return + } + comps.path = (comps.path.hasSuffix("/") ? String(comps.path.dropLast()) : comps.path) + "/api/devices" + guard let url = comps.url else { return } + + var bodyDict: [String: Any] = [ + "deviceId": MobileHostIdentity.deviceID(), + "platform": "mac", + "tag": Self.buildTag(), + "routes": routes.map(\.mobileHostJSONObject), + ] + if let displayName = MobileHostIdentity.displayName(), !displayName.isEmpty { + bodyDict["displayName"] = displayName + } + + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.timeoutInterval = 10 + req.setValue("Bearer \(tokens.accessToken)", forHTTPHeaderField: "Authorization") + req.setValue(tokens.refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token") + if let teamID, !teamID.isEmpty { + req.setValue(teamID, forHTTPHeaderField: "X-Cmux-Team-Id") + } + req.setValue("application/json", forHTTPHeaderField: "content-type") + req.httpBody = try? JSONSerialization.data(withJSONObject: bodyDict, options: []) + + do { + let (_, response) = try await session.data(for: req) + if let http = response as? HTTPURLResponse { + if (200...299).contains(http.statusCode) { + // Only remember the routes once the server accepted them, so a + // transient failure retries on the next status tick. + lastRegisteredRoutes = routes + } else { + NSLog("cmux.deviceRegistry register failed status=%d", http.statusCode) + } + } + } catch { + // best-effort; registry must never disrupt the Mac. + } + } + + /// The build tag for this cmux instance, distinguishing dev/tagged builds + /// from stable. Defaults to "default" so untagged stable builds register + /// under a stable instance key. + private static func buildTag() -> String { + let tag = ProcessInfo.processInfo.environment["CMUX_TAG"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + return (tag?.isEmpty == false) ? tag! : "default" + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 697bb013c705..f0b723afbb7e 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -270,6 +270,8 @@ DEBDADADADADADADAD000003 /* DebugDogfoodCredentialResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBDADADADADADADAD000004 /* DebugDogfoodCredentialResolverTests.swift */; }; A500D010A1B2C3D4E5F60718 /* DebugLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */; }; A5001F000000000000000001 /* DetachedFolderDragIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001F000000000000000002 /* DetachedFolderDragIcon.swift */; }; + DE71CE000000000000000004 /* DeviceRegistryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE71CE000000000000000003 /* DeviceRegistryClient.swift */; }; + DE71CE000000000000000002 /* DeviceRegistryClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE71CE000000000000000001 /* DeviceRegistryClientTests.swift */; }; B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */; }; D0C0D0C0D0C0D0C0D0C0D003 /* DockEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C0D0C0D0C0D0C0D0C0D004 /* DockEmptyView.swift */; }; D0C0D0C0D0C0D0C0D0C0D001 /* DockPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C0D0C0D0C0D0C0D0C0D002 /* DockPanelView.swift */; }; @@ -998,6 +1000,8 @@ DEBDADADADADADADAD000004 /* DebugDogfoodCredentialResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugDogfoodCredentialResolverTests.swift; sourceTree = ""; }; A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/DebugLogging.swift; sourceTree = ""; }; A5001F000000000000000002 /* DetachedFolderDragIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetachedFolderDragIcon.swift; sourceTree = ""; }; + DE71CE000000000000000003 /* DeviceRegistryClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceRegistryClient.swift; sourceTree = ""; }; + DE71CE000000000000000001 /* DeviceRegistryClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRegistryClientTests.swift; sourceTree = ""; }; B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayResolutionRegressionUITests.swift; sourceTree = ""; }; D0C0D0C0D0C0D0C0D0C0D004 /* DockEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DockEmptyView.swift; sourceTree = ""; }; D0C0D0C0D0C0D0C0D0C0D002 /* DockPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DockPanelView.swift; sourceTree = ""; }; @@ -1600,6 +1604,7 @@ children = ( 9CEC6EF35B71AE59FA45BA69 /* VMClient.swift */, D1F0A00100000000000000A2 /* PhonePushClient.swift */, + DE71CE000000000000000003 /* DeviceRegistryClient.swift */, 9CEC6F0035B71AE59FA45BA7 /* VMClientSocketCommands.swift */, ); name = Cloud; @@ -2104,6 +2109,7 @@ F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */, + DE71CE000000000000000001 /* DeviceRegistryClientTests.swift */, AA5269A0C0DE0003FACE0003 /* ForeignFirstResponderPolicyTests.swift */, F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */, FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */, @@ -2758,6 +2764,7 @@ DEBDADADADADADADAD000001 /* DebugDogfoodCredentialResolver.swift in Sources */, A500D010A1B2C3D4E5F60718 /* DebugLogging.swift in Sources */, A5001F000000000000000001 /* DetachedFolderDragIcon.swift in Sources */, + DE71CE000000000000000004 /* DeviceRegistryClient.swift in Sources */, D0C0D0C0D0C0D0C0D0C0D003 /* DockEmptyView.swift in Sources */, D0C0D0C0D0C0D0C0D0C0D001 /* DockPanelView.swift in Sources */, D0B10014A1B2C3D4E5F60001 /* DragOverlayRoutingPolicy.swift in Sources */, @@ -3173,6 +3180,7 @@ C0DEF4120000000000000001 /* CommandPaletteSettingsToggleTests.swift in Sources */, C1713006C1713006C1713006 /* CommandPaletteShortcutCustomizationTests.swift in Sources */, DEBDADADADADADADAD000003 /* DebugDogfoodCredentialResolverTests.swift in Sources */, + DE71CE000000000000000002 /* DeviceRegistryClientTests.swift in Sources */, D3622100A1B2C3D4E5F60718 /* EditableTextViewArrowKeyForwardingTests.swift in Sources */, E50320010000000000000001 /* ExtensionWorktreeSpawnArgsTests.swift in Sources */, FEEDC0DEC0DEC0DEC0DE0001 /* FeedCoordinatorTests.swift in Sources */, diff --git a/cmuxTests/DeviceRegistryClientTests.swift b/cmuxTests/DeviceRegistryClientTests.swift new file mode 100644 index 000000000000..5f536154dde7 --- /dev/null +++ b/cmuxTests/DeviceRegistryClientTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +import CMUXMobileCore + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Tests the Mac device-registry re-registration policy. `statusUpdates()` fires +/// on connection changes as well as route changes, so the client must skip a +/// POST when only the connection set changed (routes identical) and never +/// register an empty (pairing-off) route set. This is the seam that keeps the +/// Mac from spamming `/api/devices` on every phone connect/disconnect. +@Suite struct DeviceRegistryClientTests { + private func route(host: String, port: Int, id: String = "r") throws -> CmxAttachRoute { + try CmxAttachRoute( + id: id, + kind: .tailscale, + endpoint: .hostPort(host: host, port: port) + ) + } + + @Test func emptyRoutesNeverRegister() { + #expect(DeviceRegistryClient.shouldReRegister(previous: nil, current: []) == false) + } + + @Test func firstNonEmptyRoutesRegister() throws { + let routes = [try route(host: "100.0.0.1", port: 51000)] + #expect(DeviceRegistryClient.shouldReRegister(previous: nil, current: routes) == true) + } + + @Test func identicalRoutesSkipRegistration() throws { + // A connection-only status tick: same routes, must not re-POST. + let routes = [try route(host: "100.0.0.1", port: 51000)] + #expect(DeviceRegistryClient.shouldReRegister(previous: routes, current: routes) == false) + } + + @Test func changedRoutesReRegister() throws { + // The Mac moved networks / rebound to a new port. + let previous = [try route(host: "100.0.0.1", port: 51000)] + let current = [try route(host: "100.9.9.9", port: 51999)] + #expect(DeviceRegistryClient.shouldReRegister(previous: previous, current: current) == true) + } + + @Test func clearingRoutesSkipsRegistration() throws { + // Pairing turned off after having registered: nothing new to advertise. + let previous = [try route(host: "100.0.0.1", port: 51000)] + #expect(DeviceRegistryClient.shouldReRegister(previous: previous, current: []) == false) + } +} diff --git a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift index 7b93cb447c06..1803cc934b88 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift @@ -107,6 +107,28 @@ public struct CMUXMobileRootScene: View { } } + /// Build the team-scoped device-registry client over the auth coordinator. + /// + /// Tokens and the target team are read live through the coordinator so the + /// registry call always uses the current session and selected team. The + /// service is failure-tolerant, so a missing API base URL or a registry + /// outage simply means reconnect falls back to local paired-Mac routes. + @MainActor + private func makeDeviceRegistry() -> DeviceRegistryService? { + let baseURL = auth.config.apiBaseURL + guard !baseURL.isEmpty else { return nil } + let coordinator = auth.coordinator + return DeviceRegistryService( + apiBaseURL: baseURL, + deviceID: MobileDeviceIdentity.deviceID(), + tokenSource: DeviceRegistryService.TokenSource( + accessToken: { try? await coordinator.accessToken() }, + refreshToken: { await coordinator.refreshToken() } + ), + teamIDProvider: { await coordinator.resolvedTeamID } + ) + } + public var body: some View { content .environment(auth.coordinator) @@ -133,10 +155,12 @@ public struct CMUXMobileRootScene: View { @MainActor private func makeStore() -> CMUXMobileShellStore { let identityProvider = AuthCoordinatorIdentityProvider(coordinator: auth.coordinator) + let deviceRegistry = makeDeviceRegistry() #if DEBUG return CMUXMobileShellStore( runtime: runtime, pairedMacStore: pairedMacStore, + deviceRegistry: deviceRegistry, identityProvider: identityProvider, reachability: reachability, analytics: analytics, @@ -146,6 +170,7 @@ public struct CMUXMobileRootScene: View { return CMUXMobileShellStore( runtime: runtime, pairedMacStore: pairedMacStore, + deviceRegistry: deviceRegistry, identityProvider: identityProvider, reachability: reachability, analytics: analytics From 90fc5e4e1294023cff36c813e0c1455196d8804d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:31:41 -0700 Subject: [PATCH 03/10] Harden device registry: instance cap, route validation, off-state publish Addresses autoreview findings on the new registry path: - Cap app instances per device (MAX_INSTANCES_PER_DEVICE) in the same advisory- lock transaction, mirroring the per-team device cap. `tag` is client-supplied and the instance key is (deviceId, tag), so without this one device could create unbounded rows by varying the tag. Re-registering an existing tag stays an update. Also bound tag length. - Server stores only structurally valid route entries (plain objects, bounded by MAX_ROUTES); scalars/arrays are dropped. Semantic CmxAttachRoute validation stays with the typed clients so the server is forward-compatible with new route kinds. - iOS parser decodes each route failably and per-element, so one malformed or unknown-kind route from any instance (even another Mac's) is skipped instead of nil-ing the whole /api/devices response and disabling refresh for every Mac. - Mac DeviceRegistryClient.shouldReRegister now fires once on the nonempty->empty transition (pairing turned off), publishing the empty route set so the registry stops advertising stale routes; the phone already skips empty-route instances. Initial-empty and repeated-empty ticks stay no-ops. Tests: web instance-cap + route-filtering cases; iOS malformed-sibling and malformed-within-target skip cases; Mac clear-publishes-once and still-empty no-op cases. Co-Authored-By: Claude Opus 4.8 --- .../DeviceRegistryService.swift | 26 +++++-- .../DeviceRegistryRouteSelectionTests.swift | 67 +++++++++++++++++++ Sources/Cloud/DeviceRegistryClient.swift | 20 ++++-- cmuxTests/DeviceRegistryClientTests.swift | 16 +++-- web/app/api/devices/route.ts | 43 +++++++++++- web/tests/devices-route.test.ts | 61 +++++++++++++++++ 6 files changed, 217 insertions(+), 16 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift index 21c8e20aea1c..db0b8bd99bf1 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift @@ -95,9 +95,24 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { /// device whose id matches `macDeviceID`, preferring its most recently seen /// app instance. Returns `nil` when the device or routes are absent so the /// caller falls back to local routes. + /// + /// Each route is decoded *failably* and individually: a malformed or + /// unknown-kind route from any instance (even another Mac's) is skipped + /// rather than failing the whole response. This keeps one bad sibling row + /// from disabling registry refresh for every Mac, and makes old clients + /// forward-compatible when a newer build advertises a route kind they cannot + /// decode. static func routes(forMacDeviceID macDeviceID: String, in data: Data) -> [CmxAttachRoute]? { + // Decode each route element through an optional wrapper so a single bad + // element decodes to `nil` and is dropped, never throwing for the array. + struct FailableRoute: Decodable { + let value: CmxAttachRoute? + init(from decoder: Decoder) throws { + value = try? CmxAttachRoute(from: decoder) + } + } struct Instance: Decodable { - let routes: [CmxAttachRoute] + let routes: [FailableRoute] } struct Device: Decodable { let deviceId: String @@ -113,10 +128,11 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { guard let device = decoded.devices.first(where: { $0.deviceId.lowercased() == target }) else { return nil } - // Instances are returned most-recently-seen first; the first non-empty - // route set is the freshest reachable instance for this Mac. - for instance in device.instances where !instance.routes.isEmpty { - return instance.routes + // Instances are returned most-recently-seen first; the first instance + // with at least one decodable route is the freshest reachable one. + for instance in device.instances { + let routes = instance.routes.compactMap(\.value) + if !routes.isEmpty { return routes } } return nil } diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift index 0c24ee842764..fd4254404f0d 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift @@ -90,6 +90,73 @@ import Testing #expect(DeviceRegistryService.routes(forMacDeviceID: "missing", in: json) == nil) } + @Test func malformedSiblingRouteDoesNotPoisonTheList() throws { + // One instance has a malformed/unknown route; the target Mac's own valid + // route must still parse (a bad sibling must not nil the whole response). + let json = """ + { + "teamId": "team-a", + "devices": [ + { + "deviceId": "AAAA1111-1111-4111-8111-111111111111", + "platform": "mac", + "instances": [ + { "tag": "stable", "routes": [ + { "id": "bad", "kind": "unknown_future_kind", "endpoint": { "type": "???" } } + ] } + ] + }, + { + "deviceId": "BBBB2222-2222-4222-8222-222222222222", + "platform": "mac", + "instances": [ + { "tag": "stable", "routes": [ + { "id": "r1", "kind": "tailscale", "priority": 0, + "endpoint": { "type": "host_port", "host": "100.9.9.9", "port": 51999 } } + ] } + ] + } + ] + } + """.data(using: .utf8)! + + let routes = DeviceRegistryService.routes( + forMacDeviceID: "bbbb2222-2222-4222-8222-222222222222", + in: json + ) + #expect(routes?.count == 1) + } + + @Test func malformedRouteWithinTargetInstanceIsSkipped() throws { + // A bad route mixed with a good one in the target's own instance: keep + // the good one, drop the bad one. + let json = """ + { + "teamId": "team-a", + "devices": [ + { + "deviceId": "BBBB2222-2222-4222-8222-222222222222", + "platform": "mac", + "instances": [ + { "tag": "stable", "routes": [ + { "id": "bad", "kind": "tailscale", "endpoint": { "type": "host_port", "host": "", "port": 0 } }, + { "id": "good", "kind": "tailscale", "priority": 0, + "endpoint": { "type": "host_port", "host": "100.9.9.9", "port": 51999 } } + ] } + ] + } + ] + } + """.data(using: .utf8)! + + let routes = DeviceRegistryService.routes( + forMacDeviceID: "bbbb2222-2222-4222-8222-222222222222", + in: json + ) + #expect(routes?.count == 1) + #expect(routes?.first?.id == "good") + } + @Test func deviceIdentityPersistsAcrossLookups() { let suite = "test.deviceRegistry.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! diff --git a/Sources/Cloud/DeviceRegistryClient.swift b/Sources/Cloud/DeviceRegistryClient.swift index 86998f9fdc50..a282de3cf13a 100644 --- a/Sources/Cloud/DeviceRegistryClient.swift +++ b/Sources/Cloud/DeviceRegistryClient.swift @@ -39,16 +39,24 @@ final class DeviceRegistryClient { /// Whether the current advertised routes differ from what was last registered. /// - /// Pure so it is unit-testable without any network or host service. Returns - /// `false` for an empty current set (nothing to advertise / pairing off) and - /// for an unchanged set (a connection-only `statusUpdates()` tick), and - /// `true` only when there are routes that differ from the last registration. + /// Pure so it is unit-testable without any network or host service. + /// + /// Fires (returns `true`) only when the advertised routes differ from the + /// last registration. That includes the nonempty -> empty transition (the + /// user turned mobile pairing off): publishing the now-empty route set once + /// clears the stale routes from the registry, and the phone already skips + /// empty-route instances. It does NOT fire for an unchanged set (a + /// connection-only `statusUpdates()` tick) or for the empty -> still-empty + /// case (`nil`/`[]` start with pairing off), so the off-state is published + /// exactly once rather than on every empty tick. static func shouldReRegister( previous: [CmxAttachRoute]?, current: [CmxAttachRoute] ) -> Bool { - guard !current.isEmpty else { return false } - return previous != current + // Treat "never registered" as an empty baseline so an initial empty set + // (pairing off at launch) is a no-op, but a later clear still fires once. + let baseline = previous ?? [] + return baseline != current } private func startObserving() { diff --git a/cmuxTests/DeviceRegistryClientTests.swift b/cmuxTests/DeviceRegistryClientTests.swift index 5f536154dde7..080b5e232894 100644 --- a/cmuxTests/DeviceRegistryClientTests.swift +++ b/cmuxTests/DeviceRegistryClientTests.swift @@ -22,7 +22,8 @@ import CMUXMobileCore ) } - @Test func emptyRoutesNeverRegister() { + @Test func initialEmptyRoutesDoNotRegister() { + // Pairing off at launch: nothing was ever advertised, nothing to publish. #expect(DeviceRegistryClient.shouldReRegister(previous: nil, current: []) == false) } @@ -44,9 +45,16 @@ import CMUXMobileCore #expect(DeviceRegistryClient.shouldReRegister(previous: previous, current: current) == true) } - @Test func clearingRoutesSkipsRegistration() throws { - // Pairing turned off after having registered: nothing new to advertise. + @Test func clearingRoutesRegistersOnceToPublishOffState() throws { + // Pairing turned off after having registered: publish the now-empty set + // once so the registry no longer advertises stale routes for this Mac. let previous = [try route(host: "100.0.0.1", port: 51000)] - #expect(DeviceRegistryClient.shouldReRegister(previous: previous, current: []) == false) + #expect(DeviceRegistryClient.shouldReRegister(previous: previous, current: []) == true) + } + + @Test func stillEmptyAfterClearDoesNotReRegister() { + // Once the empty off-state has been published, repeated empty ticks are + // no-ops (the baseline is now empty too). + #expect(DeviceRegistryClient.shouldReRegister(previous: [], current: []) == false) } } diff --git a/web/app/api/devices/route.ts b/web/app/api/devices/route.ts index f6ec2363ad07..e32af6be5ba0 100644 --- a/web/app/api/devices/route.ts +++ b/web/app/api/devices/route.ts @@ -26,7 +26,12 @@ export const dynamic = "force-dynamic"; const MAX_REQUEST_BYTES = 16 * 1024; const MAX_DEVICES_PER_TEAM = 200; +// A device's app instances are keyed by `(deviceId, tag)`, and `tag` is +// client-supplied, so cap instances per device to keep one device from creating +// unbounded rows (and an unbounded GET response) by varying the tag. +const MAX_INSTANCES_PER_DEVICE = 25; const MAX_ROUTES = 16; +const MAX_TAG_LENGTH = 64; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -94,8 +99,19 @@ function recordOrEmpty(value: unknown): Record { : {}; } +/** + * Keep only structurally valid route entries (a plain object), bounded by + * `MAX_ROUTES`. Semantic validation of the `CmxAttachRoute` wire schema is left + * to the typed Mac/iOS clients, so the server stays forward-compatible with new + * route kinds; this only guarantees the stored jsonb is a bounded array of + * objects (never a string, number, or array element that would corrupt the + * column or bloat the row). + */ function routesArray(value: unknown): unknown[] { - return Array.isArray(value) ? value.slice(0, MAX_ROUTES) : []; + if (!Array.isArray(value)) return []; + return value + .filter((entry) => entry !== null && typeof entry === "object" && !Array.isArray(entry)) + .slice(0, MAX_ROUTES); } /** @@ -130,6 +146,9 @@ export async function POST(request: Request): Promise { if (!ALLOWED_PLATFORMS.has(platform)) { return jsonResponse({ error: "invalid_platform" }, 400); } + if (tag.length > MAX_TAG_LENGTH) { + return jsonResponse({ error: "invalid_tag" }, 400); + } const db = cloudDb(); const now = new Date(); @@ -186,6 +205,25 @@ export async function POST(request: Request): Promise { }, }); + // Cap instances per device. `tag` is client-supplied and the instance key is + // `(deviceId, tag)`, so without this a single device could create unbounded + // rows by varying the tag. Re-registering an existing tag is an update (the + // onConflict below), so only a genuinely new tag counts against the cap. + const [existingInstance] = await tx + .select({ id: deviceAppInstances.id }) + .from(deviceAppInstances) + .where(and(eq(deviceAppInstances.deviceId, deviceId), eq(deviceAppInstances.tag, tag))) + .limit(1); + if (!existingInstance) { + const [{ total }] = await tx + .select({ total: sql`count(*)::int` }) + .from(deviceAppInstances) + .where(eq(deviceAppInstances.deviceId, deviceId)); + if (Number(total) >= MAX_INSTANCES_PER_DEVICE) { + return { error: "too_many_instances" as const }; + } + } + await tx .insert(deviceAppInstances) .values({ @@ -217,6 +255,9 @@ export async function POST(request: Request): Promise { if (registered.error === "too_many_devices") { return jsonResponse({ error: "too_many_devices" }, 429); } + if (registered.error === "too_many_instances") { + return jsonResponse({ error: "too_many_instances" }, 429); + } return jsonResponse({ ok: true, deviceId, teamId: team.teamId, tag }); } diff --git a/web/tests/devices-route.test.ts b/web/tests/devices-route.test.ts index 58def1ddd6b6..eb719adb6c2c 100644 --- a/web/tests/devices-route.test.ts +++ b/web/tests/devices-route.test.ts @@ -134,6 +134,67 @@ describe("device registry route", () => { expect(list.devices[0].instances[0].routes[0].endpoint.host).toBe("100.9.9.9"); }); + dbTest("caps app instances per device when the tag varies", async () => { + if (!sql) throw new Error("test database not initialized"); + + // Register one device under 25 distinct tags (the cap), then a 26th. + const statuses: number[] = []; + for (let i = 0; i < 26; i++) { + const response = await POST( + registerRequest({ + deviceId: DEVICE_A, + platform: "mac", + tag: `tag-${i}`, + routes: [], + }), + ); + statuses.push(response.status); + } + // First 25 succeed, the 26th distinct tag is rejected. + expect(statuses.slice(0, 25).every((s) => s === 200)).toBe(true); + expect(statuses[25]).toBe(429); + + const [{ total }] = await sql<{ total: number }[]>` + select count(*)::int as total from device_app_instances where device_id = ${DEVICE_A} + `; + expect(total).toBe(25); + + // Re-registering an existing tag is an update, not a new row, so it is not + // capped even at the limit. + const reRegister = await POST( + registerRequest({ deviceId: DEVICE_A, platform: "mac", tag: "tag-0", routes: [] }), + ); + expect(reRegister.status).toBe(200); + }); + + dbTest("drops structurally invalid route entries on register", async () => { + if (!sql) throw new Error("test database not initialized"); + + await POST( + registerRequest({ + deviceId: DEVICE_A, + platform: "mac", + tag: "stable", + // Mix of valid objects and junk (string, number, null, nested array). + routes: [ + { id: "r1", kind: "tailscale", endpoint: { type: "host_port", host: "100.1.1.1", port: 1 } }, + "not-a-route", + 42, + null, + ["nested"], + { id: "r2", kind: "tailscale", endpoint: { type: "host_port", host: "100.2.2.2", port: 2 } }, + ], + }), + ); + + const [{ routes }] = await sql<{ routes: unknown[] }[]>` + select routes from device_app_instances where device_id = ${DEVICE_A} and tag = 'stable' + `; + // Only the two object entries are stored; scalars/arrays are dropped. + expect(Array.isArray(routes)).toBe(true); + expect(routes).toHaveLength(2); + }); + dbTest("rejects a team the caller is not a member of", async () => { if (!sql) throw new Error("test database not initialized"); From 9a86cb466a56e48afbcde2a651937c00c087f472 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:33:23 -0700 Subject: [PATCH 04/10] CI: run devices-route DB behavior test in web-db-migrations job So the device registry route's behavioral coverage (register/list, team-scope 403, instance cap, route filtering, delete cascade) gates in CI, not just locally. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 791e0d837583..d47829885de1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,6 +208,7 @@ jobs: bun test tests/db-schema.test.ts bun test tests/drizzle-effect.test.ts bun test tests/vm-db-read-model.test.ts + bun test tests/devices-route.test.ts tests: runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }} From 536a857d9f56a9205fa7af98b59a57b6173037da Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:41:15 -0700 Subject: [PATCH 05/10] Fix device registry lifecycle: team-scoped Mac dedup + no resurrection on iOS Addresses round-2 autoreview lifecycle findings: - Mac DeviceRegistryClient deduped on routes alone, so an account/team switch with unchanged routes never registered the Mac in the newly selected team. Dedup now keys on a (teamID, tag, routes) scope, resolved before the skip decision, so a team switch re-registers even when routes are identical. Leaving the old team's row behind is acceptable by design (best-effort, stale-tolerant registry); registering in the new team is the fix. - iOS registry refresh ran a detached upsert(markActive: true) after the network await without re-checking lifecycle, so signing out, forgetting the Mac, or switching the active Mac while freshRoutes was in flight could resurrect or reactivate the removed pairing (and expose it to the next user on a shared device). It now re-reads the active Mac and applies a pure shouldApplyRegistryRefresh guard (still signed in, same user, same active Mac) before writing, mirroring the existing user-switch guard in loadPairedMacs. Tests: Mac team-switch-with-unchanged-routes fires; iOS shouldApplyRegistryRefresh sign-out / user-switch / forgotten / active-mac-switched all reject. Co-Authored-By: Claude Opus 4.8 --- .../DeviceRegistryRefreshing.swift | 29 ++++++++++ .../MobileShellComposite.swift | 23 +++++++- .../DeviceRegistryRouteSelectionTests.swift | 54 +++++++++++++++++ Sources/Cloud/DeviceRegistryClient.swift | 58 ++++++++++++------- cmuxTests/DeviceRegistryClientTests.swift | 50 +++++++++++----- 5 files changed, 176 insertions(+), 38 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift index 0f2e1c25cc98..df0ba89462a6 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift @@ -43,4 +43,33 @@ public enum DeviceRegistryRouteSelection { guard registry != local else { return nil } return registry } + + /// Whether a background registry refresh may write back into the paired-Mac + /// store, re-evaluated *after* the network call. + /// + /// The refresh upserts with `markActive: true`, so it must not resurrect a + /// pairing that the user removed or deactivated while the network call was in + /// flight. It is safe to apply only when the same user is still signed in and + /// the Mac it refreshed is still the active paired Mac. If the user signed + /// out, switched accounts, forgot the Mac, or switched to a different active + /// Mac, the captured user no longer matches, or the active Mac id is now + /// `nil`/different, so the write is rejected. + /// + /// - Parameters: + /// - isSignedIn: Whether a user is signed in now. + /// - capturedUserID: The signed-in user when the refresh started. + /// - currentUserID: The signed-in user now. + /// - activeMacID: The still-active paired Mac id now, or `nil` if none. + /// - targetMacID: The Mac id this refresh fetched routes for. + public static func shouldApplyRegistryRefresh( + isSignedIn: Bool, + capturedUserID: String?, + currentUserID: String?, + activeMacID: String?, + targetMacID: String + ) -> Bool { + guard isSignedIn else { return false } + guard capturedUserID == currentUserID else { return false } + return activeMacID == targetMacID + } } diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 2e58cd37c5e6..4b079d05c3b9 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1013,6 +1013,27 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { local: localRoutes, registry: registryRoutes ) else { return } + guard let self else { return } + // The network await above suspended; the user may have signed out, + // switched accounts, forgotten this Mac, or switched the active Mac + // meanwhile. Re-evaluate against the *current* store/identity before + // the `markActive: true` upsert, so a stale refresh can never + // resurrect or reactivate a pairing the user removed. Mirrors the + // user-switch guard in `loadPairedMacs`. + let activeMacID: String? + do { + activeMacID = try await pairedMacStore.activeMac(stackUserID: stackUserID)?.macDeviceID + } catch { + mobileShellLog.debug("registry refresh active-mac recheck failed: \(String(describing: error), privacy: .public)") + return + } + guard DeviceRegistryRouteSelection.shouldApplyRegistryRefresh( + isSignedIn: self.isSignedIn, + capturedUserID: stackUserID, + currentUserID: self.identityProvider?.currentUserID, + activeMacID: activeMacID, + targetMacID: macDeviceID + ) else { return } do { try await pairedMacStore.upsert( macDeviceID: macDeviceID, @@ -1025,7 +1046,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { mobileShellLog.debug("registry route refresh upsert failed: \(String(describing: error), privacy: .public)") return } - await self?.loadPairedMacs() + await self.loadPairedMacs() } } diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift index fd4254404f0d..ae20b4c32fb9 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift @@ -157,6 +157,60 @@ import Testing #expect(routes?.first?.id == "good") } + @Test func appliesRefreshWhenStillSignedInSameUserSameActiveMac() { + #expect(DeviceRegistryRouteSelection.shouldApplyRegistryRefresh( + isSignedIn: true, + capturedUserID: "user-1", + currentUserID: "user-1", + activeMacID: "mac-1", + targetMacID: "mac-1" + ) == true) + } + + @Test func rejectsRefreshAfterSignOut() { + // User signed out while freshRoutes was in flight: never resurrect. + #expect(DeviceRegistryRouteSelection.shouldApplyRegistryRefresh( + isSignedIn: false, + capturedUserID: "user-1", + currentUserID: nil, + activeMacID: nil, + targetMacID: "mac-1" + ) == false) + } + + @Test func rejectsRefreshAfterUserSwitch() { + #expect(DeviceRegistryRouteSelection.shouldApplyRegistryRefresh( + isSignedIn: true, + capturedUserID: "user-1", + currentUserID: "user-2", + activeMacID: "mac-1", + targetMacID: "mac-1" + ) == false) + } + + @Test func rejectsRefreshAfterMacForgotten() { + // The Mac was forgotten (no active Mac now): do not recreate it. + #expect(DeviceRegistryRouteSelection.shouldApplyRegistryRefresh( + isSignedIn: true, + capturedUserID: "user-1", + currentUserID: "user-1", + activeMacID: nil, + targetMacID: "mac-1" + ) == false) + } + + @Test func rejectsRefreshAfterActiveMacSwitched() { + // The user switched to a different active Mac (e.g. rescanned a QR): + // do not reactivate the old one. + #expect(DeviceRegistryRouteSelection.shouldApplyRegistryRefresh( + isSignedIn: true, + capturedUserID: "user-1", + currentUserID: "user-1", + activeMacID: "mac-2", + targetMacID: "mac-1" + ) == false) + } + @Test func deviceIdentityPersistsAcrossLookups() { let suite = "test.deviceRegistry.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! diff --git a/Sources/Cloud/DeviceRegistryClient.swift b/Sources/Cloud/DeviceRegistryClient.swift index a282de3cf13a..f28d4aeb6893 100644 --- a/Sources/Cloud/DeviceRegistryClient.swift +++ b/Sources/Cloud/DeviceRegistryClient.swift @@ -24,9 +24,18 @@ final class DeviceRegistryClient { private let session: URLSession = .shared private var auth: AuthCoordinator? private var observeTask: Task? - /// The route set most recently registered, used to skip redundant POSTs when - /// `statusUpdates()` fires for a connection change rather than a route change. - private var lastRegisteredRoutes: [CmxAttachRoute]? + /// The scope (team + tag + routes) most recently registered, used to skip + /// redundant POSTs. Keyed on the full scope rather than routes alone so an + /// account/team switch with unchanged routes still re-registers in the newly + /// selected team instead of being deduped away. + private var lastRegistration: Registration? + + /// The identity of a registration POST, for deduplication. + struct Registration: Equatable { + var teamID: String? + var tag: String + var routes: [CmxAttachRoute] + } private init() {} @@ -37,25 +46,28 @@ final class DeviceRegistryClient { startObserving() } - /// Whether the current advertised routes differ from what was last registered. + /// Whether a registration with `current` scope differs from what was last + /// registered, and therefore should be POSTed. /// /// Pure so it is unit-testable without any network or host service. /// - /// Fires (returns `true`) only when the advertised routes differ from the - /// last registration. That includes the nonempty -> empty transition (the - /// user turned mobile pairing off): publishing the now-empty route set once - /// clears the stale routes from the registry, and the phone already skips - /// empty-route instances. It does NOT fire for an unchanged set (a - /// connection-only `statusUpdates()` tick) or for the empty -> still-empty - /// case (`nil`/`[]` start with pairing off), so the off-state is published + /// Fires (returns `true`) when the team, tag, or routes differ from the last + /// registration. The team is part of the key so an account/team switch with + /// unchanged routes still registers in the new team. The routes-empty + /// transition (the user turned mobile pairing off) also fires once, so the + /// registry stops advertising stale routes; the phone already skips + /// empty-route instances. An unchanged scope (a connection-only + /// `statusUpdates()` tick) and the never-registered empty start (`nil` + /// previous with empty routes) are both no-ops, so the off-state is published /// exactly once rather than on every empty tick. static func shouldReRegister( - previous: [CmxAttachRoute]?, - current: [CmxAttachRoute] + previous: Registration?, + current: Registration ) -> Bool { - // Treat "never registered" as an empty baseline so an initial empty set - // (pairing off at launch) is a no-op, but a later clear still fires once. - let baseline = previous ?? [] + // Treat "never registered" as an empty-routes baseline in the same scope + // so an initial empty set (pairing off at launch) is a no-op, but a later + // clear, or any team/tag change, still fires. + let baseline = previous ?? Registration(teamID: current.teamID, tag: current.tag, routes: []) return baseline != current } @@ -70,15 +82,19 @@ final class DeviceRegistryClient { } private func registerIfRoutesChanged(routes: [CmxAttachRoute]) async { - guard Self.shouldReRegister(previous: lastRegisteredRoutes, current: routes) else { return } guard let auth else { return } + // Resolve the auth scope BEFORE the dedup decision so a team switch with + // unchanged routes is detected (and not skipped). + let teamID = auth.resolvedTeamID + let tag = Self.buildTag() + let registration = Registration(teamID: teamID, tag: tag, routes: routes) + guard Self.shouldReRegister(previous: lastRegistration, current: registration) else { return } let tokens: (accessToken: String, refreshToken: String) do { tokens = try await auth.currentTokens() } catch { return // not signed in → nothing to do } - let teamID = auth.resolvedTeamID guard var comps = URLComponents(url: AuthEnvironment.vmAPIBaseURL, resolvingAgainstBaseURL: false) else { return @@ -89,7 +105,7 @@ final class DeviceRegistryClient { var bodyDict: [String: Any] = [ "deviceId": MobileHostIdentity.deviceID(), "platform": "mac", - "tag": Self.buildTag(), + "tag": tag, "routes": routes.map(\.mobileHostJSONObject), ] if let displayName = MobileHostIdentity.displayName(), !displayName.isEmpty { @@ -111,9 +127,9 @@ final class DeviceRegistryClient { let (_, response) = try await session.data(for: req) if let http = response as? HTTPURLResponse { if (200...299).contains(http.statusCode) { - // Only remember the routes once the server accepted them, so a + // Only remember the scope once the server accepted it, so a // transient failure retries on the next status tick. - lastRegisteredRoutes = routes + lastRegistration = registration } else { NSLog("cmux.deviceRegistry register failed status=%d", http.statusCode) } diff --git a/cmuxTests/DeviceRegistryClientTests.swift b/cmuxTests/DeviceRegistryClientTests.swift index 080b5e232894..f50dd5bb13c7 100644 --- a/cmuxTests/DeviceRegistryClientTests.swift +++ b/cmuxTests/DeviceRegistryClientTests.swift @@ -10,9 +10,9 @@ import CMUXMobileCore /// Tests the Mac device-registry re-registration policy. `statusUpdates()` fires /// on connection changes as well as route changes, so the client must skip a -/// POST when only the connection set changed (routes identical) and never -/// register an empty (pairing-off) route set. This is the seam that keeps the -/// Mac from spamming `/api/devices` on every phone connect/disconnect. +/// POST when only the connection set changed, register the off-state once when +/// routes clear, and re-register after an account/team switch even when the +/// routes are unchanged. @Suite struct DeviceRegistryClientTests { private func route(host: String, port: Int, id: String = "r") throws -> CmxAttachRoute { try CmxAttachRoute( @@ -22,39 +22,57 @@ import CMUXMobileCore ) } + private func reg(team: String?, tag: String = "default", routes: [CmxAttachRoute]) -> DeviceRegistryClient.Registration { + DeviceRegistryClient.Registration(teamID: team, tag: tag, routes: routes) + } + @Test func initialEmptyRoutesDoNotRegister() { // Pairing off at launch: nothing was ever advertised, nothing to publish. - #expect(DeviceRegistryClient.shouldReRegister(previous: nil, current: []) == false) + let current = reg(team: "team-a", routes: []) + #expect(DeviceRegistryClient.shouldReRegister(previous: nil, current: current) == false) } @Test func firstNonEmptyRoutesRegister() throws { - let routes = [try route(host: "100.0.0.1", port: 51000)] - #expect(DeviceRegistryClient.shouldReRegister(previous: nil, current: routes) == true) + let current = reg(team: "team-a", routes: [try route(host: "100.0.0.1", port: 51000)]) + #expect(DeviceRegistryClient.shouldReRegister(previous: nil, current: current) == true) } - @Test func identicalRoutesSkipRegistration() throws { - // A connection-only status tick: same routes, must not re-POST. + @Test func identicalScopeSkipsRegistration() throws { + // A connection-only status tick: same team/tag/routes, must not re-POST. let routes = [try route(host: "100.0.0.1", port: 51000)] - #expect(DeviceRegistryClient.shouldReRegister(previous: routes, current: routes) == false) + let previous = reg(team: "team-a", routes: routes) + let current = reg(team: "team-a", routes: routes) + #expect(DeviceRegistryClient.shouldReRegister(previous: previous, current: current) == false) } @Test func changedRoutesReRegister() throws { // The Mac moved networks / rebound to a new port. - let previous = [try route(host: "100.0.0.1", port: 51000)] - let current = [try route(host: "100.9.9.9", port: 51999)] + let previous = reg(team: "team-a", routes: [try route(host: "100.0.0.1", port: 51000)]) + let current = reg(team: "team-a", routes: [try route(host: "100.9.9.9", port: 51999)]) + #expect(DeviceRegistryClient.shouldReRegister(previous: previous, current: current) == true) + } + + @Test func teamSwitchReRegistersEvenWithUnchangedRoutes() throws { + // Account/team switch with the same routes must register in the new team. + let routes = [try route(host: "100.0.0.1", port: 51000)] + let previous = reg(team: "team-a", routes: routes) + let current = reg(team: "team-b", routes: routes) #expect(DeviceRegistryClient.shouldReRegister(previous: previous, current: current) == true) } @Test func clearingRoutesRegistersOnceToPublishOffState() throws { // Pairing turned off after having registered: publish the now-empty set // once so the registry no longer advertises stale routes for this Mac. - let previous = [try route(host: "100.0.0.1", port: 51000)] - #expect(DeviceRegistryClient.shouldReRegister(previous: previous, current: []) == true) + let previous = reg(team: "team-a", routes: [try route(host: "100.0.0.1", port: 51000)]) + let current = reg(team: "team-a", routes: []) + #expect(DeviceRegistryClient.shouldReRegister(previous: previous, current: current) == true) } @Test func stillEmptyAfterClearDoesNotReRegister() { - // Once the empty off-state has been published, repeated empty ticks are - // no-ops (the baseline is now empty too). - #expect(DeviceRegistryClient.shouldReRegister(previous: [], current: []) == false) + // Once the empty off-state has been published, repeated empty ticks in + // the same scope are no-ops. + let previous = reg(team: "team-a", routes: []) + let current = reg(team: "team-a", routes: []) + #expect(DeviceRegistryClient.shouldReRegister(previous: previous, current: current) == false) } } From cab19faca46c995892b5cc36e1619a88adb6a451 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:01:55 -0700 Subject: [PATCH 06/10] Per-team device identity + safe cross-tag route selection Addresses round-3 autoreview findings on the registry's correctness for supported multi-team and multi-build cmux workflows: - Device identity is now modeled per team. `devices` gains a surrogate primary key and a `device_uuid` column (the cmux-generated UUID, still the global device identity) with a unique `(team_id, device_uuid)` index, so a Mac that belongs to two teams registers a row in each. This unblocks the Mac's team-switch re-registration, which the old global device PK rejected with `device_team_conflict`. That guard is removed: per-team rows make cross-team takeover structurally impossible (team B's row can't touch team A's). GET returns `device_uuid` as `deviceId` so the phone's macDeviceID match is unchanged; instance cap and FK are unchanged (the instances FK already references the surrogate id). DELETE keys on `(team_id, device_uuid)`. Migration regenerated as a single clean migration. - iOS route selection no longer silently substitutes across tagged app instances. A Mac may run stable + a debug build, each its own `(deviceId, tag)` instance; the phone has no tag to match in P1, so `routes(forMacDeviceID:)` now returns routes only when exactly one instance is advertising any, and otherwise returns nil to fall back to local routes rather than risk connecting to the wrong build's workspaces. Tag-aware matching is a follow-up (alongside key pinning). Tests: web multi-team registration (same UUID in team A and B -> two rows, each team sees only its own); iOS multiple-non-empty-instances -> nil and single-non-empty-among-empty -> used. Co-Authored-By: Claude Opus 4.8 --- .../DeviceRegistryService.swift | 18 +++-- .../DeviceRegistryRouteSelectionTests.swift | 61 +++++++++++++++++ web/app/api/devices/route.ts | 66 ++++++++++--------- .../migration.sql | 4 +- .../snapshot.json | 45 ++++++++++++- web/db/schema.ts | 16 +++-- web/tests/devices-route.test.ts | 40 +++++++++-- 7 files changed, 197 insertions(+), 53 deletions(-) rename web/db/migrations/{20260608044827_device_registry => 20260608055850_device_registry}/migration.sql (88%) rename web/db/migrations/{20260608044827_device_registry => 20260608055850_device_registry}/snapshot.json (97%) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift index db0b8bd99bf1..993cd1d607f7 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift @@ -128,13 +128,17 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { guard let device = decoded.devices.first(where: { $0.deviceId.lowercased() == target }) else { return nil } - // Instances are returned most-recently-seen first; the first instance - // with at least one decodable route is the freshest reachable one. - for instance in device.instances { - let routes = instance.routes.compactMap(\.value) - if !routes.isEmpty { return routes } - } - return nil + // A Mac may run multiple tagged app instances (stable + a debug build). + // The phone's stored routes have no tag to match against in P1, so only + // substitute routes when exactly one instance is advertising any (the + // single-build common case). With zero or 2+ candidate instances, return + // nil and let reconnect fall back to the locally persisted routes, rather + // than risk connecting a stable phone to a different tagged build's + // workspaces. Tag-aware matching is a follow-up (see key-pinning phase). + let nonEmpty = device.instances + .map { $0.routes.compactMap(\.value) } + .filter { !$0.isEmpty } + return nonEmpty.count == 1 ? nonEmpty[0] : nil } // MARK: - Request building diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift index ae20b4c32fb9..dde6fa5f31f6 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift @@ -90,6 +90,67 @@ import Testing #expect(DeviceRegistryService.routes(forMacDeviceID: "missing", in: json) == nil) } + @Test func multipleNonEmptyInstancesReturnNilToAvoidWrongTag() { + // A Mac running two tagged builds (stable + debug), both advertising + // routes. Without a tag to match, substituting either could connect the + // phone to the wrong app, so fall back to local routes (nil). + let json = """ + { + "teamId": "team-a", + "devices": [ + { + "deviceId": "BBBB2222-2222-4222-8222-222222222222", + "platform": "mac", + "instances": [ + { "tag": "stable", "routes": [ + { "id": "r1", "kind": "tailscale", "priority": 0, + "endpoint": { "type": "host_port", "host": "100.1.1.1", "port": 51001 } } + ] }, + { "tag": "debug", "routes": [ + { "id": "r2", "kind": "tailscale", "priority": 0, + "endpoint": { "type": "host_port", "host": "100.2.2.2", "port": 51002 } } + ] } + ] + } + ] + } + """.data(using: .utf8)! + + #expect(DeviceRegistryService.routes( + forMacDeviceID: "bbbb2222-2222-4222-8222-222222222222", + in: json + ) == nil) + } + + @Test func singleNonEmptyInstanceAmongEmptyOnesIsUsed() throws { + // Multiple instances but only one advertising routes (e.g. stable on, + // a debug build that turned pairing off): use the single non-empty one. + let json = """ + { + "teamId": "team-a", + "devices": [ + { + "deviceId": "BBBB2222-2222-4222-8222-222222222222", + "platform": "mac", + "instances": [ + { "tag": "debug", "routes": [] }, + { "tag": "stable", "routes": [ + { "id": "r1", "kind": "tailscale", "priority": 0, + "endpoint": { "type": "host_port", "host": "100.1.1.1", "port": 51001 } } + ] } + ] + } + ] + } + """.data(using: .utf8)! + + let routes = DeviceRegistryService.routes( + forMacDeviceID: "bbbb2222-2222-4222-8222-222222222222", + in: json + ) + #expect(routes?.count == 1) + } + @Test func malformedSiblingRouteDoesNotPoisonTheList() throws { // One instance has a malformed/unknown route; the target Mac's own valid // route must still parse (a bad sibling must not nil the whole response). diff --git a/web/app/api/devices/route.ts b/web/app/api/devices/route.ts index e32af6be5ba0..62e4a6debe85 100644 --- a/web/app/api/devices/route.ts +++ b/web/app/api/devices/route.ts @@ -132,7 +132,7 @@ export async function POST(request: Request): Promise { const body = await readBoundedJson(request); if (!body.ok) return jsonResponse({ error: "invalid_request" }, body.status); - const deviceId = trimmedString(body.value.deviceId).toLowerCase(); + const deviceUuid = trimmedString(body.value.deviceId).toLowerCase(); const platform = trimmedString(body.value.platform).toLowerCase(); const displayName = trimmedString(body.value.displayName) || null; const labels = recordOrEmpty(body.value.labels); @@ -140,7 +140,7 @@ export async function POST(request: Request): Promise { const routes = routesArray(body.value.routes); const instanceLabels = recordOrEmpty(body.value.instanceLabels); - if (!UUID_RE.test(deviceId)) { + if (!UUID_RE.test(deviceUuid)) { return jsonResponse({ error: "invalid_device_id" }, 400); } if (!ALLOWED_PLATFORMS.has(platform)) { @@ -158,19 +158,17 @@ export async function POST(request: Request): Promise { // is enforced without a race (mirrors the device-tokens advisory lock). await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${team.teamId}, 7))`); - const [existing] = await tx - .select({ id: devices.id, teamId: devices.teamId }) + // Device identity is per team: a row keyed by (teamId, deviceUuid). The + // same cmux device UUID registering under a different team is a separate, + // legitimate row (a Mac in two teams), so there is no cross-team conflict to + // guard against. Team B creating its own row cannot read or mutate team A's. + const [existingDevice] = await tx + .select({ id: devices.id }) .from(devices) - .where(eq(devices.id, deviceId)) + .where(and(eq(devices.teamId, team.teamId), eq(devices.deviceUuid, deviceUuid))) .limit(1); - // A device id is global (cmux-generated UUID). If it already exists under a - // different team, the caller cannot claim it (prevents cross-team takeover). - if (existing && existing.teamId !== team.teamId) { - return { error: "device_team_conflict" as const }; - } - - if (!existing) { + if (!existingDevice) { const [{ total }] = await tx .select({ total: sql`count(*)::int` }) .from(devices) @@ -180,11 +178,11 @@ export async function POST(request: Request): Promise { } } - await tx + const [deviceRow] = await tx .insert(devices) .values({ - id: deviceId, teamId: team.teamId, + deviceUuid, userId: user.id, platform, displayName, @@ -193,9 +191,8 @@ export async function POST(request: Request): Promise { updatedAt: now, }) .onConflictDoUpdate({ - target: devices.id, + target: [devices.teamId, devices.deviceUuid], set: { - teamId: team.teamId, userId: user.id, platform, displayName, @@ -203,22 +200,24 @@ export async function POST(request: Request): Promise { lastSeenAt: now, updatedAt: now, }, - }); + }) + .returning({ id: devices.id }); + const deviceRowId = deviceRow.id; - // Cap instances per device. `tag` is client-supplied and the instance key is - // `(deviceId, tag)`, so without this a single device could create unbounded - // rows by varying the tag. Re-registering an existing tag is an update (the - // onConflict below), so only a genuinely new tag counts against the cap. + // Cap instances per device row. `tag` is client-supplied and the instance + // key is `(deviceId, tag)`, so without this a single device could create + // unbounded rows by varying the tag. Re-registering an existing tag is an + // update (the onConflict below), so only a genuinely new tag counts. const [existingInstance] = await tx .select({ id: deviceAppInstances.id }) .from(deviceAppInstances) - .where(and(eq(deviceAppInstances.deviceId, deviceId), eq(deviceAppInstances.tag, tag))) + .where(and(eq(deviceAppInstances.deviceId, deviceRowId), eq(deviceAppInstances.tag, tag))) .limit(1); if (!existingInstance) { const [{ total }] = await tx .select({ total: sql`count(*)::int` }) .from(deviceAppInstances) - .where(eq(deviceAppInstances.deviceId, deviceId)); + .where(eq(deviceAppInstances.deviceId, deviceRowId)); if (Number(total) >= MAX_INSTANCES_PER_DEVICE) { return { error: "too_many_instances" as const }; } @@ -227,7 +226,7 @@ export async function POST(request: Request): Promise { await tx .insert(deviceAppInstances) .values({ - deviceId, + deviceId: deviceRowId, teamId: team.teamId, tag, routes, @@ -249,9 +248,6 @@ export async function POST(request: Request): Promise { return { error: null }; }); - if (registered.error === "device_team_conflict") { - return jsonResponse({ error: "device_team_conflict" }, 409); - } if (registered.error === "too_many_devices") { return jsonResponse({ error: "too_many_devices" }, 429); } @@ -259,11 +255,12 @@ export async function POST(request: Request): Promise { return jsonResponse({ error: "too_many_instances" }, 429); } - return jsonResponse({ ok: true, deviceId, teamId: team.teamId, tag }); + return jsonResponse({ ok: true, deviceId: deviceUuid, teamId: team.teamId, tag }); } type DeviceListRow = { id: string; + deviceUuid: string; platform: string; displayName: string | null; labels: Record; @@ -289,6 +286,7 @@ export async function GET(request: Request): Promise { const deviceRows = (await db .select({ id: devices.id, + deviceUuid: devices.deviceUuid, platform: devices.platform, displayName: devices.displayName, labels: devices.labels, @@ -318,7 +316,9 @@ export async function GET(request: Request): Promise { } const devicesPayload = deviceRows.map((device) => ({ - deviceId: device.id, + // The phone matches its stored `macDeviceID` (the cmux device UUID) against + // this, so expose `deviceUuid`, not the internal surrogate row id. + deviceId: device.deviceUuid, platform: device.platform, displayName: device.displayName, labels: device.labels, @@ -352,15 +352,17 @@ export async function DELETE(request: Request): Promise { const body = await readBoundedJson(request); if (!body.ok) return jsonResponse({ error: "invalid_request" }, body.status); - const deviceId = trimmedString(body.value.deviceId).toLowerCase(); - if (!UUID_RE.test(deviceId)) { + const deviceUuid = trimmedString(body.value.deviceId).toLowerCase(); + if (!UUID_RE.test(deviceUuid)) { return jsonResponse({ error: "invalid_device_id" }, 400); } + // Delete only this team's row for the device (the (teamId, deviceUuid) row), + // never another team's row for the same physical Mac. const db = cloudDb(); await db .delete(devices) - .where(and(eq(devices.id, deviceId), eq(devices.teamId, team.teamId))); + .where(and(eq(devices.deviceUuid, deviceUuid), eq(devices.teamId, team.teamId))); return jsonResponse({ ok: true }); } diff --git a/web/db/migrations/20260608044827_device_registry/migration.sql b/web/db/migrations/20260608055850_device_registry/migration.sql similarity index 88% rename from web/db/migrations/20260608044827_device_registry/migration.sql rename to web/db/migrations/20260608055850_device_registry/migration.sql index 2aba4725cc04..d9c15c72dc32 100644 --- a/web/db/migrations/20260608044827_device_registry/migration.sql +++ b/web/db/migrations/20260608055850_device_registry/migration.sql @@ -11,8 +11,9 @@ CREATE TABLE "device_app_instances" ( ); --> statement-breakpoint CREATE TABLE "devices" ( - "id" uuid PRIMARY KEY, + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "team_id" text NOT NULL, + "device_uuid" uuid NOT NULL, "user_id" text NOT NULL, "platform" text NOT NULL, "display_name" text, @@ -24,6 +25,7 @@ CREATE TABLE "devices" ( --> statement-breakpoint CREATE UNIQUE INDEX "device_app_instances_device_tag_unique" ON "device_app_instances" ("device_id","tag");--> statement-breakpoint CREATE INDEX "device_app_instances_team_last_seen_idx" ON "device_app_instances" ("team_id","last_seen_at");--> statement-breakpoint +CREATE UNIQUE INDEX "devices_team_device_uuid_unique" ON "devices" ("team_id","device_uuid");--> statement-breakpoint CREATE INDEX "devices_team_last_seen_idx" ON "devices" ("team_id","last_seen_at");--> statement-breakpoint CREATE INDEX "devices_team_user_idx" ON "devices" ("team_id","user_id");--> statement-breakpoint ALTER TABLE "device_app_instances" ADD CONSTRAINT "device_app_instances_device_id_devices_id_fkey" FOREIGN KEY ("device_id") REFERENCES "devices"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/web/db/migrations/20260608044827_device_registry/snapshot.json b/web/db/migrations/20260608055850_device_registry/snapshot.json similarity index 97% rename from web/db/migrations/20260608044827_device_registry/snapshot.json rename to web/db/migrations/20260608055850_device_registry/snapshot.json index 5b53028db8c8..acd336363990 100644 --- a/web/db/migrations/20260608044827_device_registry/snapshot.json +++ b/web/db/migrations/20260608055850_device_registry/snapshot.json @@ -1,7 +1,7 @@ { "version": "8", "dialect": "postgres", - "id": "c255c7e9-bc36-4114-bd84-68f86dfcbf69", + "id": "ba8f09f6-d710-41c7-b9c0-153faea51c65", "prevIds": [ "82769d22-cd97-490a-af75-97c410198ccc" ], @@ -935,7 +935,7 @@ "typeSchema": null, "notNull": true, "dimensions": 0, - "default": null, + "default": "gen_random_uuid()", "generated": null, "identity": null, "name": "id", @@ -956,6 +956,19 @@ "schema": "public", "table": "devices" }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_uuid", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, { "type": "text", "typeSchema": null, @@ -1596,6 +1609,34 @@ "schema": "public", "table": "device_tokens" }, + { + "nameExplicit": true, + "columns": [ + { + "value": "team_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "device_uuid", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "devices_team_device_uuid_unique", + "entityType": "indexes", + "schema": "public", + "table": "devices" + }, { "nameExplicit": true, "columns": [ diff --git a/web/db/schema.ts b/web/db/schema.ts index e216363bb528..e89fef5ff3d4 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -190,14 +190,19 @@ export const cloudVmBillingGrants = pgTable( export const devices = pgTable( "devices", { - // The cmux-generated persisted UUID supplied by the device, used directly - // as the primary key. NOTE (key-pinning phase): this column will later - // anchor a pinned per-device public key for revoke; P1 stores identity - // only and never trusts it for authorization beyond the team scope below. - id: uuid("id").primaryKey(), + // Surrogate primary key for the team-scoped device row. + id: uuid("id").defaultRandom().primaryKey(), // Stack team that owns this device row. All registry reads/writes are // scoped to a team the caller is a verified member of (`X-Cmux-Team-Id`). teamId: text("team_id").notNull(), + // The cmux-generated persisted UUID supplied by the device. It is the + // device's stable, global identity (mirrors Mac `MobileHostIdentity` / iOS + // `MobileDeviceIdentity`), but identity is modeled per team: one row per + // (team, device), so a Mac in two teams registers a row in each and a phone + // scoped to either team can find it. NOTE (key-pinning phase): a pinned + // per-device key for revoke attaches per team-device row, which is the + // correct revoke granularity. P1 stores identity only. + deviceUuid: uuid("device_uuid").notNull(), // Stack user that registered the device (audit / future per-user views). userId: text("user_id").notNull(), // "mac" | "ios" | "linux" | ... (free-form so new host platforms need no @@ -213,6 +218,7 @@ export const devices = pgTable( updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [ + uniqueIndex("devices_team_device_uuid_unique").on(table.teamId, table.deviceUuid), index("devices_team_last_seen_idx").on(table.teamId, table.lastSeenAt), index("devices_team_user_idx").on(table.teamId, table.userId), ], diff --git a/web/tests/devices-route.test.ts b/web/tests/devices-route.test.ts index eb719adb6c2c..108f043d370c 100644 --- a/web/tests/devices-route.test.ts +++ b/web/tests/devices-route.test.ts @@ -6,13 +6,14 @@ import { closeCloudDbForTests } from "../db/client"; const runDbTests = process.env.CMUX_DB_TEST === "1"; const dbTest = runDbTests ? test : test.skip; -// Stack user with a single team ("team-a") so requests can scope to that team. +// Stack user in two teams ("team-a" default-selected, plus "team-b"), so the +// multi-team registration path can be exercised. const getUser = mock(async () => ({ id: "registry-user-1", displayName: null, primaryEmail: "registry@example.com", selectedTeam: { id: "team-a" }, - listTeams: async () => [{ id: "team-a" }], + listTeams: async () => [{ id: "team-a" }, { id: "team-b" }], })); mock.module("../app/lib/stack", () => ({ @@ -124,7 +125,7 @@ describe("device registry route", () => { ); const [{ total }] = await sql<{ total: number }[]>` - select count(*)::int as total from device_app_instances where device_id = ${DEVICE_A} + select count(*)::int as total from device_app_instances where device_id in (select id from devices where device_uuid = ${DEVICE_A}) `; expect(total).toBe(1); @@ -155,7 +156,7 @@ describe("device registry route", () => { expect(statuses[25]).toBe(429); const [{ total }] = await sql<{ total: number }[]>` - select count(*)::int as total from device_app_instances where device_id = ${DEVICE_A} + select count(*)::int as total from device_app_instances where device_id in (select id from devices where device_uuid = ${DEVICE_A}) `; expect(total).toBe(25); @@ -188,13 +189,40 @@ describe("device registry route", () => { ); const [{ routes }] = await sql<{ routes: unknown[] }[]>` - select routes from device_app_instances where device_id = ${DEVICE_A} and tag = 'stable' + select routes from device_app_instances where device_id in (select id from devices where device_uuid = ${DEVICE_A}) and tag = 'stable' `; // Only the two object entries are stored; scalars/arrays are dropped. expect(Array.isArray(routes)).toBe(true); expect(routes).toHaveLength(2); }); + dbTest("registers the same device UUID in two teams the user belongs to", async () => { + if (!sql) throw new Error("test database not initialized"); + + // Same physical Mac (same cmux UUID), registered under team-a then team-b. + const inA = await POST( + registerRequest({ deviceId: DEVICE_A, platform: "mac", tag: "stable", routes: [] }, "team-a"), + ); + const inB = await POST( + registerRequest({ deviceId: DEVICE_A, platform: "mac", tag: "stable", routes: [] }, "team-b"), + ); + expect(inA.status).toBe(200); + expect(inB.status).toBe(200); + + // Two distinct per-team rows for the one device UUID. + const [{ total }] = await sql<{ total: number }[]>` + select count(*)::int as total from devices where device_uuid = ${DEVICE_A} + `; + expect(total).toBe(2); + + // Each team only sees its own row for the device. + const listA = (await ( + await GET(new Request("https://cmux.test/api/devices", { method: "GET", headers: authHeaders("team-a") })) + ).json()) as { teamId: string; devices: Array<{ deviceId: string }> }; + expect(listA.teamId).toBe("team-a"); + expect(listA.devices.map((d) => d.deviceId)).toEqual([DEVICE_A]); + }); + dbTest("rejects a team the caller is not a member of", async () => { if (!sql) throw new Error("test database not initialized"); @@ -230,7 +258,7 @@ describe("device registry route", () => { `; expect(devicesTotal).toBe(1); const [{ instancesTotal }] = await sql<{ instancesTotal: number }[]>` - select count(*)::int as "instancesTotal" from device_app_instances where device_id = ${DEVICE_A} + select count(*)::int as "instancesTotal" from device_app_instances where device_id in (select id from devices where device_uuid = ${DEVICE_A}) `; expect(instancesTotal).toBe(0); }); From b61ace60e318e49fab0aa9b2c063050f27fe21dd Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:19:31 -0700 Subject: [PATCH 07/10] Restrict device route updates to the registering user Round-4 autoreview: GET exposes device UUIDs to every team member, and POST upserted by UUID for the team, so a co-member could overwrite another member's device routes (redirecting that user's phone reconnect at the attacker's host). The POST path now rejects (403 device_not_owned) when an existing device row was registered by a different user, so route population stays owned by the registering user, matching the pre-registry trust boundary. Cryptographic proof-of-possession is the deferred key-pinning phase. Consciously accepted (not fixed) from the same review, with rationale: - Initial-empty / post-kill off-state: lastRegistration is in-memory, so a clear during process death can leave the user's own last routes advertised. With the ownership guard those are the user's own routes, so the worst case is one failed connect then local fallback (no security impact). Persisting registration state to publish an off-state after death is follow-up. - Team switch with unchanged routes only re-registers on the next host status tick (registration is route-driven). Documented as a known limitation; teamID stays in the dedup key so it is correct once triggered. An explicit auth/team-change trigger is a follow-up. Test: a second same-team user POSTing another user's device UUID is rejected and the routes are unchanged; the owner can still update. Co-Authored-By: Claude Opus 4.8 --- Sources/Cloud/DeviceRegistryClient.swift | 5 +++ web/app/api/devices/route.ts | 17 ++++++- web/tests/devices-route.test.ts | 56 ++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/Sources/Cloud/DeviceRegistryClient.swift b/Sources/Cloud/DeviceRegistryClient.swift index f28d4aeb6893..20a49c69ade7 100644 --- a/Sources/Cloud/DeviceRegistryClient.swift +++ b/Sources/Cloud/DeviceRegistryClient.swift @@ -73,6 +73,11 @@ final class DeviceRegistryClient { private func startObserving() { observeTask?.cancel() + // Registration is currently driven only by host-route changes. The dedup + // key includes the team, so a team switch *does* re-register once the + // next status tick arrives, but a mid-session team switch with otherwise + // unchanged routes is not registered in the new team until then. Known + // limitation; an explicit auth/team-change trigger is a follow-up. observeTask = Task { @MainActor [weak self] in for await status in MobileHostService.shared.statusUpdates() { if Task.isCancelled { break } diff --git a/web/app/api/devices/route.ts b/web/app/api/devices/route.ts index 62e4a6debe85..be45f025bf46 100644 --- a/web/app/api/devices/route.ts +++ b/web/app/api/devices/route.ts @@ -163,11 +163,23 @@ export async function POST(request: Request): Promise { // legitimate row (a Mac in two teams), so there is no cross-team conflict to // guard against. Team B creating its own row cannot read or mutate team A's. const [existingDevice] = await tx - .select({ id: devices.id }) + .select({ id: devices.id, userId: devices.userId }) .from(devices) .where(and(eq(devices.teamId, team.teamId), eq(devices.deviceUuid, deviceUuid))) .limit(1); + // Only the user who registered a device row may update it. GET exposes + // device UUIDs to every team member, so without this a co-member could POST + // another member's device UUID and overwrite its attach routes (redirecting + // that user's phone reconnect at their own host). This keeps route + // population owned by the registering user, matching the pre-registry trust + // boundary where only the user's own pairing populated their phone's routes. + // Cryptographic proof-of-possession (so even the same user must prove they + // hold the device) is the deferred key-pinning phase. + if (existingDevice && existingDevice.userId !== user.id) { + return { error: "device_not_owned" as const }; + } + if (!existingDevice) { const [{ total }] = await tx .select({ total: sql`count(*)::int` }) @@ -248,6 +260,9 @@ export async function POST(request: Request): Promise { return { error: null }; }); + if (registered.error === "device_not_owned") { + return jsonResponse({ error: "device_not_owned" }, 403); + } if (registered.error === "too_many_devices") { return jsonResponse({ error: "too_many_devices" }, 429); } diff --git a/web/tests/devices-route.test.ts b/web/tests/devices-route.test.ts index 108f043d370c..48e6b91676df 100644 --- a/web/tests/devices-route.test.ts +++ b/web/tests/devices-route.test.ts @@ -7,11 +7,13 @@ const runDbTests = process.env.CMUX_DB_TEST === "1"; const dbTest = runDbTests ? test : test.skip; // Stack user in two teams ("team-a" default-selected, plus "team-b"), so the -// multi-team registration path can be exercised. +// multi-team registration path can be exercised. `currentUserId` is switchable +// so a test can impersonate a second member of the same team. +let currentUserId = "registry-user-1"; const getUser = mock(async () => ({ - id: "registry-user-1", + id: currentUserId, displayName: null, - primaryEmail: "registry@example.com", + primaryEmail: `${currentUserId}@example.com`, selectedTeam: { id: "team-a" }, listTeams: async () => [{ id: "team-a" }, { id: "team-b" }], })); @@ -64,6 +66,7 @@ beforeEach(async () => { if (!sql) return; await sql`truncate devices, device_app_instances restart identity cascade`; getUser.mockClear(); + currentUserId = "registry-user-1"; }); describe("device registry route", () => { @@ -223,6 +226,53 @@ describe("device registry route", () => { expect(listA.devices.map((d) => d.deviceId)).toEqual([DEVICE_A]); }); + dbTest("only the registering user may overwrite a device's routes", async () => { + if (!sql) throw new Error("test database not initialized"); + + // User 1 registers their Mac with their own routes. + const ownRoutes = [ + { id: "own", kind: "tailscale", priority: 0, endpoint: { type: "host_port", host: "100.1.1.1", port: 51001 } }, + ]; + expect( + (await POST(registerRequest({ deviceId: DEVICE_A, platform: "mac", tag: "stable", routes: ownRoutes }))).status, + ).toBe(200); + + // A second member of the same team tries to overwrite those routes with + // their own host (a redirect attack). It must be rejected, routes untouched. + currentUserId = "registry-user-2"; + const attack = await POST( + registerRequest({ + deviceId: DEVICE_A, + platform: "mac", + tag: "stable", + routes: [ + { id: "evil", kind: "tailscale", priority: 0, endpoint: { type: "host_port", host: "100.6.6.6", port: 51666 } }, + ], + }), + ); + expect(attack.status).toBe(403); + + const [{ routes }] = await sql<{ routes: Array<{ endpoint: { host: string } }> }[]>` + select routes from device_app_instances + where device_id in (select id from devices where device_uuid = ${DEVICE_A}) and tag = 'stable' + `; + expect(routes[0].endpoint.host).toBe("100.1.1.1"); + + // The owner can still update their own device. + currentUserId = "registry-user-1"; + const reRegister = await POST( + registerRequest({ + deviceId: DEVICE_A, + platform: "mac", + tag: "stable", + routes: [ + { id: "own2", kind: "tailscale", priority: 0, endpoint: { type: "host_port", host: "100.9.9.9", port: 51999 } }, + ], + }), + ); + expect(reRegister.status).toBe(200); + }); + dbTest("rejects a team the caller is not a member of", async () => { if (!sql) throw new Error("test database not initialized"); From 7aa8606d1662822ba565ca17fbf3cae146d9838e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:33:13 -0700 Subject: [PATCH 08/10] Fix Mac test actor isolation + scope device DELETE to the owner Round-5 autoreview: - DeviceRegistryClient.shouldReRegister is pure but inherited @MainActor from the enclosing class, so the non-@MainActor cmuxTests suite calling it synchronously would fail to compile the test bundle (only CI compiles it). Marked the policy method `nonisolated`. - DELETE only scoped by (team, deviceUuid), so a co-member who learns a device UUID via GET could delete another user's Mac and break their reconnect. Now also scopes by userId, mirroring the POST ownership guard. The delete stays an idempotent no-op (200) when the row is not the caller's. Test: a second same-team user's DELETE of another user's device is a no-op and the row survives. Co-Authored-By: Claude Opus 4.8 --- Sources/Cloud/DeviceRegistryClient.swift | 2 +- web/app/api/devices/route.ts | 15 ++++++++++++--- web/tests/devices-route.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/Sources/Cloud/DeviceRegistryClient.swift b/Sources/Cloud/DeviceRegistryClient.swift index 20a49c69ade7..0144c72adaa2 100644 --- a/Sources/Cloud/DeviceRegistryClient.swift +++ b/Sources/Cloud/DeviceRegistryClient.swift @@ -60,7 +60,7 @@ final class DeviceRegistryClient { /// `statusUpdates()` tick) and the never-registered empty start (`nil` /// previous with empty routes) are both no-ops, so the off-state is published /// exactly once rather than on every empty tick. - static func shouldReRegister( + nonisolated static func shouldReRegister( previous: Registration?, current: Registration ) -> Bool { diff --git a/web/app/api/devices/route.ts b/web/app/api/devices/route.ts index be45f025bf46..6119ee993768 100644 --- a/web/app/api/devices/route.ts +++ b/web/app/api/devices/route.ts @@ -372,12 +372,21 @@ export async function DELETE(request: Request): Promise { return jsonResponse({ error: "invalid_device_id" }, 400); } - // Delete only this team's row for the device (the (teamId, deviceUuid) row), - // never another team's row for the same physical Mac. + // Delete only the caller's own row for this device in this team. Scoping by + // userId (not just team) mirrors the POST ownership guard, so a co-member who + // sees the device UUID via GET cannot remove another member's registered Mac + // and break their phone reconnect. Never touches another team's row for the + // same physical Mac. const db = cloudDb(); await db .delete(devices) - .where(and(eq(devices.deviceUuid, deviceUuid), eq(devices.teamId, team.teamId))); + .where( + and( + eq(devices.deviceUuid, deviceUuid), + eq(devices.teamId, team.teamId), + eq(devices.userId, user.id), + ), + ); return jsonResponse({ ok: true }); } diff --git a/web/tests/devices-route.test.ts b/web/tests/devices-route.test.ts index 48e6b91676df..acce145682a4 100644 --- a/web/tests/devices-route.test.ts +++ b/web/tests/devices-route.test.ts @@ -312,4 +312,27 @@ describe("device registry route", () => { `; expect(instancesTotal).toBe(0); }); + + dbTest("a non-owner cannot delete another user's device", async () => { + if (!sql) throw new Error("test database not initialized"); + + // User 1 registers their Mac. + await POST(registerRequest({ deviceId: DEVICE_A, platform: "mac", tag: "stable", routes: [] })); + + // A second same-team member tries to delete it: the row must survive. + currentUserId = "registry-user-2"; + const del = await DELETE( + new Request("https://cmux.test/api/devices", { + method: "DELETE", + headers: authHeaders(), + body: JSON.stringify({ deviceId: DEVICE_A }), + }), + ); + expect(del.status).toBe(200); // idempotent no-op, not an error + + const [{ total }] = await sql<{ total: number }[]>` + select count(*)::int as total from devices where device_uuid = ${DEVICE_A} + `; + expect(total).toBe(1); + }); }); From 8de2e919a835a9f6484c328de9eb09c704177426 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:03:28 -0700 Subject: [PATCH 09/10] Scope registry namespace enums onto DeviceRegistryService (package conventions) package-conventions-lint flags standalone caseless namespace enums. Folded the two standalone enums into DeviceRegistryService as static members: - MobileDeviceIdentity.deviceID(defaults:) -> DeviceRegistryService.deviceID(defaults:) - DeviceRegistryRouteSelection.{selectReconnectRoutes,shouldApplyRegistryRefresh} -> static methods on DeviceRegistryService DeviceRegistryRefreshing keeps only the protocol. Call sites in MobileShellComposite, CMUXMobileRootScene, and the tests updated. Behavior and test coverage unchanged. Co-Authored-By: Claude Opus 4.8 --- .../DeviceRegistryRefreshing.swift | 59 ++-------------- .../DeviceRegistryService.swift | 67 ++++++++++++++++++- .../MobileDeviceIdentity.swift | 28 -------- .../MobileShellComposite.swift | 4 +- .../DeviceRegistryRouteSelectionTests.swift | 22 +++--- .../cmuxFeature/CMUXMobileRootScene.swift | 2 +- 6 files changed, 84 insertions(+), 98 deletions(-) delete mode 100644 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeviceIdentity.swift diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift index df0ba89462a6..38c98727075c 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift @@ -9,6 +9,9 @@ public import CMUXMobileCore /// fallible — a `nil` result means "registry unavailable, use what you have," so /// reconnect always falls back to the locally persisted paired-Mac routes and /// pairing survives the cloud registry being down. +/// +/// The pure reconnect route-selection policy lives on ``DeviceRegistryService`` +/// (`selectReconnectRoutes` / `shouldApplyRegistryRefresh`). public protocol DeviceRegistryRefreshing: Sendable { /// Fetch the registry's current routes for the given Mac device id, scoped to /// the signed-in user's team. @@ -16,60 +19,6 @@ public protocol DeviceRegistryRefreshing: Sendable { /// - Returns: The registry's routes for that Mac, or `nil` when the registry /// is unreachable, the call is unauthorized, or the Mac is not registered. /// `nil` and `[]` are both treated as "no fresher routes" by - /// ``DeviceRegistryRouteSelection/selectReconnectRoutes(local:registry:)``. + /// ``DeviceRegistryService/selectReconnectRoutes(local:registry:)``. func freshRoutes(forMacDeviceID macDeviceID: String) async -> [CmxAttachRoute]? } - -/// Pure route-selection policy for reconnect, isolated so it is unit-testable -/// without any network or store. The reconnect path connects on `local` routes -/// immediately (no added latency on the common case) and only *replaces* the -/// persisted routes when the registry returns a usable, different set. -public enum DeviceRegistryRouteSelection { - /// Choose the routes to persist for the next reconnect. - /// - /// - Parameters: - /// - local: The routes currently persisted for the paired Mac. - /// - registry: The registry's routes, or `nil` when it was unavailable. - /// - Returns: The registry routes when they are non-empty and differ from - /// `local` (so a stale-route Mac gets rescued on the next reconnect - /// trigger); otherwise `local`, so an unavailable or no-op registry never - /// discards working routes. The result is `nil` only to signal "no change - /// needed," letting callers skip a redundant store write. - public static func selectReconnectRoutes( - local: [CmxAttachRoute], - registry: [CmxAttachRoute]? - ) -> [CmxAttachRoute]? { - guard let registry, !registry.isEmpty else { return nil } - guard registry != local else { return nil } - return registry - } - - /// Whether a background registry refresh may write back into the paired-Mac - /// store, re-evaluated *after* the network call. - /// - /// The refresh upserts with `markActive: true`, so it must not resurrect a - /// pairing that the user removed or deactivated while the network call was in - /// flight. It is safe to apply only when the same user is still signed in and - /// the Mac it refreshed is still the active paired Mac. If the user signed - /// out, switched accounts, forgot the Mac, or switched to a different active - /// Mac, the captured user no longer matches, or the active Mac id is now - /// `nil`/different, so the write is rejected. - /// - /// - Parameters: - /// - isSignedIn: Whether a user is signed in now. - /// - capturedUserID: The signed-in user when the refresh started. - /// - currentUserID: The signed-in user now. - /// - activeMacID: The still-active paired Mac id now, or `nil` if none. - /// - targetMacID: The Mac id this refresh fetched routes for. - public static func shouldApplyRegistryRefresh( - isSignedIn: Bool, - capturedUserID: String?, - currentUserID: String?, - activeMacID: String?, - targetMacID: String - ) -> Bool { - guard isSignedIn else { return false } - guard capturedUserID == currentUserID else { return false } - return activeMacID == targetMacID - } -} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift index 993cd1d607f7..de2debb3cd7f 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift @@ -46,7 +46,7 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { /// - Parameters: /// - apiBaseURL: The cmux web API base URL (no trailing slash). - /// - deviceID: This iOS device's registry id (``MobileDeviceIdentity``). + /// - deviceID: This iOS device's registry id (``deviceID(defaults:)``). /// - tokenSource: Supplies the Stack access/refresh tokens. /// - teamIDProvider: Supplies the team id to scope to, or `nil` to let the /// server use the Stack-selected team. @@ -69,6 +69,71 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { self.requestTimeout = requestTimeout } + // MARK: - Device identity + + private static let deviceIDKey = "cmux.deviceRegistry.iosDeviceID" + + /// This iOS device's stable cmux identity for the device registry. + /// + /// A cmux-GENERATED persisted UUID (NOT `identifierForVendor`, which resets + /// when the last cmux app is removed, and NOT a hardware fingerprint). + /// Persisted in `UserDefaults` so it survives relaunch and reinstall, is + /// cross-platform, and is user-renamable via its display name. Mirrors the + /// Mac side's `MobileHostIdentity.deviceID()`. The phone sends this id when + /// it registers itself as a device; the key-pinning phase will anchor a + /// pinned key to it for revoke. + /// - Parameter defaults: Persistence store (injected for tests). + public static func deviceID(defaults: UserDefaults = .standard) -> String { + if let existing = defaults.string(forKey: deviceIDKey), + !existing.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return existing + } + let generated = UUID().uuidString.lowercased() + defaults.set(generated, forKey: deviceIDKey) + return generated + } + + // MARK: - Reconnect route policy (pure, testable) + + /// Choose the routes to persist for the next reconnect. + /// + /// The reconnect path connects on `local` routes immediately (no added + /// latency on the common case) and only *replaces* the persisted routes when + /// the registry returns a usable, different set, so a stale-route Mac gets + /// rescued on the next reconnect trigger. Returns `nil` to signal "no change + /// needed" (registry unavailable, empty, or identical), letting callers skip + /// a redundant store write and fall back to the locally persisted routes. + public static func selectReconnectRoutes( + local: [CmxAttachRoute], + registry: [CmxAttachRoute]? + ) -> [CmxAttachRoute]? { + guard let registry, !registry.isEmpty else { return nil } + guard registry != local else { return nil } + return registry + } + + /// Whether a background registry refresh may write back into the paired-Mac + /// store, re-evaluated *after* the network call. + /// + /// The refresh upserts with `markActive: true`, so it must not resurrect a + /// pairing the user removed or deactivated while the network call was in + /// flight. It is safe to apply only when the same user is still signed in and + /// the Mac it refreshed is still the active paired Mac. If the user signed + /// out, switched accounts, forgot the Mac, or switched to a different active + /// Mac, the captured user no longer matches, or the active Mac id is now + /// `nil`/different, so the write is rejected. + public static func shouldApplyRegistryRefresh( + isSignedIn: Bool, + capturedUserID: String?, + currentUserID: String?, + activeMacID: String?, + targetMacID: String + ) -> Bool { + guard isSignedIn else { return false } + guard capturedUserID == currentUserID else { return false } + return activeMacID == targetMacID + } + // MARK: - DeviceRegistryRefreshing public func freshRoutes(forMacDeviceID macDeviceID: String) async -> [CmxAttachRoute]? { diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeviceIdentity.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeviceIdentity.swift deleted file mode 100644 index 0a3a5329098d..000000000000 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeviceIdentity.swift +++ /dev/null @@ -1,28 +0,0 @@ -public import Foundation - -/// This iOS device's stable cmux identity for the device registry. -/// -/// A cmux-GENERATED persisted UUID (NOT `identifierForVendor`, which resets when -/// the last cmux app is removed, and NOT a hardware fingerprint). Persisted in -/// `UserDefaults` so it survives relaunch and reinstall-while-other-cmux-apps- -/// present, is cross-platform, and is user-renamable via its display name. -/// -/// Mirrors the Mac side's `MobileHostIdentity.deviceID()` so both ends of the -/// registry use the same identity shape. The phone sends this id when it -/// registers itself as a device; the registry's key-pinning phase will later -/// anchor a pinned key to it for revoke. -public enum MobileDeviceIdentity { - private static let deviceIDKey = "cmux.deviceRegistry.iosDeviceID" - - /// The persisted device UUID, generating and storing one on first use. - /// - Parameter defaults: Persistence store (injected for tests). - public static func deviceID(defaults: UserDefaults = .standard) -> String { - if let existing = defaults.string(forKey: deviceIDKey), - !existing.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return existing - } - let generated = UUID().uuidString.lowercased() - defaults.set(generated, forKey: deviceIDKey) - return generated - } -} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 4b079d05c3b9..6605a6e1b892 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1009,7 +1009,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { let displayName = mac.displayName Task { [weak self] in let registryRoutes = await deviceRegistry.freshRoutes(forMacDeviceID: macDeviceID) - guard let updated = DeviceRegistryRouteSelection.selectReconnectRoutes( + guard let updated = DeviceRegistryService.selectReconnectRoutes( local: localRoutes, registry: registryRoutes ) else { return } @@ -1027,7 +1027,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { mobileShellLog.debug("registry refresh active-mac recheck failed: \(String(describing: error), privacy: .public)") return } - guard DeviceRegistryRouteSelection.shouldApplyRegistryRefresh( + guard DeviceRegistryService.shouldApplyRegistryRefresh( isSignedIn: self.isSignedIn, capturedUserID: stackUserID, currentUserID: self.identityProvider?.currentUserID, diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift index dde6fa5f31f6..7c3033f0590d 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift @@ -20,24 +20,24 @@ import Testing @Test func registryUnavailableFallsBackToLocal() throws { let local = [try route(host: "100.0.0.1", port: 51000)] // nil == registry unreachable / unauthorized / Mac not registered. - #expect(DeviceRegistryRouteSelection.selectReconnectRoutes(local: local, registry: nil) == nil) + #expect(DeviceRegistryService.selectReconnectRoutes(local: local, registry: nil) == nil) } @Test func registryEmptyFallsBackToLocal() throws { let local = [try route(host: "100.0.0.1", port: 51000)] - #expect(DeviceRegistryRouteSelection.selectReconnectRoutes(local: local, registry: []) == nil) + #expect(DeviceRegistryService.selectReconnectRoutes(local: local, registry: []) == nil) } @Test func identicalRegistryRoutesAreANoOp() throws { let routes = [try route(host: "100.0.0.1", port: 51000)] - #expect(DeviceRegistryRouteSelection.selectReconnectRoutes(local: routes, registry: routes) == nil) + #expect(DeviceRegistryService.selectReconnectRoutes(local: routes, registry: routes) == nil) } @Test func differentRegistryRoutesWin() throws { // The Mac moved networks / changed port: registry has the current route. let local = [try route(host: "100.0.0.1", port: 51000)] let registry = [try route(host: "100.9.9.9", port: 51999)] - let selected = DeviceRegistryRouteSelection.selectReconnectRoutes(local: local, registry: registry) + let selected = DeviceRegistryService.selectReconnectRoutes(local: local, registry: registry) #expect(selected == registry) } @@ -219,7 +219,7 @@ import Testing } @Test func appliesRefreshWhenStillSignedInSameUserSameActiveMac() { - #expect(DeviceRegistryRouteSelection.shouldApplyRegistryRefresh( + #expect(DeviceRegistryService.shouldApplyRegistryRefresh( isSignedIn: true, capturedUserID: "user-1", currentUserID: "user-1", @@ -230,7 +230,7 @@ import Testing @Test func rejectsRefreshAfterSignOut() { // User signed out while freshRoutes was in flight: never resurrect. - #expect(DeviceRegistryRouteSelection.shouldApplyRegistryRefresh( + #expect(DeviceRegistryService.shouldApplyRegistryRefresh( isSignedIn: false, capturedUserID: "user-1", currentUserID: nil, @@ -240,7 +240,7 @@ import Testing } @Test func rejectsRefreshAfterUserSwitch() { - #expect(DeviceRegistryRouteSelection.shouldApplyRegistryRefresh( + #expect(DeviceRegistryService.shouldApplyRegistryRefresh( isSignedIn: true, capturedUserID: "user-1", currentUserID: "user-2", @@ -251,7 +251,7 @@ import Testing @Test func rejectsRefreshAfterMacForgotten() { // The Mac was forgotten (no active Mac now): do not recreate it. - #expect(DeviceRegistryRouteSelection.shouldApplyRegistryRefresh( + #expect(DeviceRegistryService.shouldApplyRegistryRefresh( isSignedIn: true, capturedUserID: "user-1", currentUserID: "user-1", @@ -263,7 +263,7 @@ import Testing @Test func rejectsRefreshAfterActiveMacSwitched() { // The user switched to a different active Mac (e.g. rescanned a QR): // do not reactivate the old one. - #expect(DeviceRegistryRouteSelection.shouldApplyRegistryRefresh( + #expect(DeviceRegistryService.shouldApplyRegistryRefresh( isSignedIn: true, capturedUserID: "user-1", currentUserID: "user-1", @@ -277,8 +277,8 @@ import Testing let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } - let first = MobileDeviceIdentity.deviceID(defaults: defaults) - let second = MobileDeviceIdentity.deviceID(defaults: defaults) + let first = DeviceRegistryService.deviceID(defaults: defaults) + let second = DeviceRegistryService.deviceID(defaults: defaults) #expect(first == second) #expect(!first.isEmpty) // Stable across a fresh accessor reading the same store (relaunch proxy). diff --git a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift index 1803cc934b88..33fd80401f6c 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift @@ -120,7 +120,7 @@ public struct CMUXMobileRootScene: View { let coordinator = auth.coordinator return DeviceRegistryService( apiBaseURL: baseURL, - deviceID: MobileDeviceIdentity.deviceID(), + deviceID: DeviceRegistryService.deviceID(), tokenSource: DeviceRegistryService.TokenSource( accessToken: { try? await coordinator.accessToken() }, refreshToken: { await coordinator.refreshToken() } From 17562bc629008aac3945319e348d9c343ce48861 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:19:54 -0700 Subject: [PATCH 10/10] Resolve registry team after auth bootstrap to avoid wrong-team registration DeviceRegistryClient read auth.resolvedTeamID before awaiting currentTokens(). resolvedTeamID derives from availableTeams, which is empty until launch auth bootstrap completes, so on launch it could resolve nil even with a persisted selected team and publish the Mac into the Stack-default team. The team is now resolved after the currentTokens() await (which gates on signed-in and waits for bootstrap) and used for both the dedup key and the X-Cmux-Team-Id header. After bootstrap currentTokens() returns the cached token, so awaiting it per status tick is cheap. The residual team-handling limitation (a mid-session team switch with unchanged routes only re-registers on the next host-status tick, since registration is route-driven) is documented in startObserving; an explicit auth/team-change trigger is a follow-up. Co-Authored-By: Claude Opus 4.8 --- Sources/Cloud/DeviceRegistryClient.swift | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Sources/Cloud/DeviceRegistryClient.swift b/Sources/Cloud/DeviceRegistryClient.swift index 0144c72adaa2..bb82aed05635 100644 --- a/Sources/Cloud/DeviceRegistryClient.swift +++ b/Sources/Cloud/DeviceRegistryClient.swift @@ -88,18 +88,26 @@ final class DeviceRegistryClient { private func registerIfRoutesChanged(routes: [CmxAttachRoute]) async { guard let auth else { return } - // Resolve the auth scope BEFORE the dedup decision so a team switch with - // unchanged routes is detected (and not skipped). - let teamID = auth.resolvedTeamID - let tag = Self.buildTag() - let registration = Registration(teamID: teamID, tag: tag, routes: routes) - guard Self.shouldReRegister(previous: lastRegistration, current: registration) else { return } + // Await tokens FIRST: this both gates on "signed in" and waits for launch + // auth bootstrap. `resolvedTeamID` is derived from `availableTeams`, which + // is empty until bootstrap completes, so reading the team before this + // await could resolve nil even when the user has a persisted selected team + // and publish the Mac into the wrong (Stack-default) team. After bootstrap + // `currentTokens()` returns the cached token, so awaiting it per tick is + // cheap. let tokens: (accessToken: String, refreshToken: String) do { tokens = try await auth.currentTokens() } catch { return // not signed in → nothing to do } + // Resolve the team AFTER bootstrap, and use that same scope for both the + // dedup decision and the request header, so a team switch with unchanged + // routes is detected and the POST targets the intended team. + let teamID = auth.resolvedTeamID + let tag = Self.buildTag() + let registration = Registration(teamID: teamID, tag: tag, routes: routes) + guard Self.shouldReRegister(previous: lastRegistration, current: registration) else { return } guard var comps = URLComponents(url: AuthEnvironment.vmAPIBaseURL, resolvingAgainstBaseURL: false) else { return