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 hand-typed config file.

  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 encrypts it with Electron safeStorage in a main-process-only 0600 file, the extension writes its extension-origin-isolated chrome.storage.local.deviceToken, and mobile stores it in the OS keychain through Expo SecureStore. Every subsequent request carries it as x-subspace-device-token on HTTP. For WebSockets it exchanges the token for a one-use ?ticket= credential at POST /auth/socket-ticket, 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 encrypted outside the renderer The Electron shell, including its terminal and PTY host.
Mobile Code entry, token stored in the OS keychain 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 is not a blanket credential; what it can reach 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: a paired device still resolves to a Principal with the pairing user’s isAdmin flag and capabilities, and each domain checks that principal the same way it would a session cookie.