Skip to content

Device pairing

Anything that isn’t a browser tab with a session cookie, the desktop app, the mobile app, the Chrome extension, or a future headless client, authenticates with a device token instead. Pairing is how that token gets from the server to the client without ever putting a long-lived credential in a QR code or a config file you’d have to type by hand.

  1. Mint a code

    From a signed-in web session, Settings → Devices → “pair a new device” calls the devices.pairStart tRPC procedure. It mints a one-shot 8-character code drawn from an unambiguous alphabet (no 0/O, 1/I confusion), stores only its sha256 in ops.pairing_codes, and sets a 5-minute TTL. Mobile pairing shows the same code as a QR so you don’t type it on a phone keyboard.

  2. Redeem it

    The new client posts to the open route POST /pair/claim {code, name, kind}. On a valid, unexpired, unclaimed code (matched case-insensitively), the server issues a device token and returns it in the response body, once. Any capability flags attached to the pairing code (see below) carry onto the new device row.

  3. Store it

    The client persists the token: desktop reads it back from SUBSPACE_DEVICE_TOKEN in its environment, the extension writes chrome.storage.local.deviceToken, mobile writes subspace.deviceToken in AsyncStorage. Every subsequent request carries it as x-subspace-device-token on HTTP or ?token= on a WebSocket connect, exactly as described in identity & auth.

Client Pairs via Typical use
Web N/A, uses session cookies, not device tokens The client that mints pairing codes for everything else.
Desktop Code entry during first-run setup, token stored in SUBSPACE_DEVICE_TOKEN The Electron shell, including its terminal and PTY host.
Mobile QR scan, token stored in AsyncStorage The Expo app, reachable over Tailscale.
Extension Code entry in the popup, token stored in chrome.storage.local The Chrome extension’s capture and automation host.

devices.list returns your own devices; an admin’s call returns every device in the instance, each with its kind, last-seen timestamp, and revoked status. devices.revoke is owner-or-admin, so you can kill a lost phone yourself without waiting on an admin, and an admin can kill anyone’s device in an incident.

Revoking a device does three things in one step:

  1. Stamps ops.devices.revoked, so the token immediately fails resolveDeviceToken on the device’s very next request (a fresh 401).
  2. Deletes any web sessions tied to that specific device row, in case a “device” is actually a browser session that shared the row.
  3. Terminates that device’s live /ws connections by pushing a device-revoked outbox notice, closing the socket server-side rather than waiting for the client to notice its token stopped working.

A device token isn’t a blanket credential, what it can reach still depends on the kind of access behind it:

Domain Trust level Why
Disk (the OKF mirror) Operator Filesystem access to the exported graph is equivalent to operating the machine the server runs on, not just using the app.
Comms (mail, calendar, IM) Personal, scoped to one user Covered in full on per-user agents & comms; no device, paired or not, reaches another user’s inbox.
PTY, os.exec, code.* dispatch Admin-only A host shell is the highest tier of access in the system; only an admin principal reaches it, paired or via a browser session.

These rules apply on top of pairing, not instead of it: a paired device still resolves to a Principal with the pairing user’s isAdmin flag and capabilities, and every one of these domains checks that principal the same way it would check a session cookie.