#193

feat(server): single-use pairing links

Opencalledforthwants to merge intofrom

Summary

The environment can now hand out access to another device through a single-use pairing link. Before this, the only credential was the owner's.

  • Creating a link needs admin. pairing.create takes the capabilities to offer and an optional device label. It can never offer more than the creator holds. It answers with a 12-symbol token (32-symbol alphabet with no look-alikes, about 60 bits) that lives five minutes and is only ever stored as a hash, in the new pairing_links table (migration 18).
  • Redeeming a link works in two ways:
    • A new device posts the token to POST /pair and gets back a paired credential, in the same shape /local-owner answers with.
    • An already paired device redeems over the socket it already trusts (pairing.redeem, read is enough). It keeps its id, label and credential and takes the link's grant.
    • Either way the link is used and the client is written in one transaction, so a token works exactly once, even when two attempts race.
  • The creator is checked again at redeem time. A revoked or expired creator voids its links. A creator that has lost a capability only hands out what it still holds.
  • A credential is never sent to a link's route. Everything in a link can be forged, including a route that answers /bootstrap with the right environment id. That is why POST /pair refuses any body that carries a credential.
  • pairing.list shows what became of each recent link. Its status is waiting, used, expired, revoked or void, and once used it names the device. This lets the screen showing a QR code tell when it was scanned. pairing.revoke withdraws a waiting link.
  • Building links. encodePairingLink / parsePairingLink in @openmanager/environment-client build and read <app>/pair#v=1&route=…&environment=…&token=…. Nothing in the format is specific to the LAN or a tunnel.
  • Rate limits. Only refused attempts count against the pairing rate limit: per address at /pair, per client over the socket.
  • Protocol version goes to 13.
sequenceDiagram
    participant A as Admin client
    participant S as Environment
    participant N as New device
    participant P as Paired device
    A->>S: pairing.create {capabilities, label}
    S-->>A: token (once) + link
    A-->>N: QR / link #v=1&route&environment&token
    N->>S: POST /pair {token, label?}  (no credential, ever)
    S-->>N: {clientId, grant, credential}
    A-->>P: another link
    P->>S: pairing.redeem {token} on its trusted socket
    S-->>P: {clientId, grant, grantChanged}
    S--xP: close 4403 grant_changed, reconnect under the new grant
    A->>S: pairing.list
    S-->>A: link status used, usedByClientId

Demo

This is a real run against a v13 server, rendered from the terminal. In order:

  1. The owner creates a read-only link.
  2. A new device trades the token at POST /pair and gets read access only.
  3. Reusing the token gets 401 used.
  4. A POST that carries a credential gets 400 malformed.
  5. A paired device redeems a wider link over its socket and is reconnected with 4403.
  6. pairing.list shows the link used by the phone.
  7. Wrong guesses hit the rate limit and get 429.
  8. The audit trail records all of it.

Reviews

gpt-6.1-sol, pass 1

  • P2: re-pairing a device whose stored label predates the label rules failed with a 500 after the commit. Fixed: the response is now built and validated inside the transaction, and stored labels are read loosely.

Fable

  • P1: a forged route in a link could receive an already-paired device's credential. Fixed: the credential is never sent over HTTP, and re-pairing uses the trusted socket.
  • P2: links with no visible outcome. Fixed: pairing.list now has statuses and names the device that used a link.
  • P2: void links held places under the 32-link cap. Fixed: they no longer count.
  • P2: tunnel rate limit. Documented. Only failures count, so devices on one network don't lock each other out.
  • The P3s are fixed as well: format characters are refused in labels, link parsing gives a reason, the grant is read inside the transaction, and there is a second close pass.

gpt-6.1-sol, pass 2

  • P1: a downgraded socket served frames batched behind the redeem until the next tick. Fixed: the socket is retired synchronously when the redeem commits, and later frames get auth.
  • P2: a database error during a socket command could crash the process. Fixed: dispatch now answers internal and the socket stays up.
  • P2: /pair requests held open together could all get past the rate-limit check. Fixed: it is checked again after the body is read.
  • P2: settled history could push a waiting link out of pairing.list. Fixed: links that can still be used are always listed first.
  • Each fix has a regression test, and each of those tests fails without its fix.

Greptile (three passes, now clean)

  • P2: oversized /pair bodies did not count against the rate limit, and the second check skipped them. Fixed in both places, with tests.
  • P2: no typed EnvironmentClient methods for pairing. Deferred to the web pairing stage, where the first caller lands. Answered on the thread.

The Validate desktop failure is not from this branch. The same app-core drafts test (environment-drafts.test.tsx) also fails on main's own CI run.

Follow-ups

  • Behind a tunnel, every /pair request comes from the tunnel's local address, so they all share one failure budget. Trusting the tunnel's client-address header is left to the Cloudflare work.
  • The web /pair page, QR display and grant preview come in the next stage of this project. That page must never overwrite a saved credential for a route it has not verified.

Test plan

  • pnpm run ci:protocol (263)
  • pnpm run ci:environment-client (218)
  • pnpm run ci:server (742 passed, 7 skipped; later commits also add 3 pairing tests, which pass)
  • Manual run against a live server (the demo above)

🤖 Generated with Claude Code

Activity

18
greptile-apps[bot]28d ago


[Critical risk] Adds pairing link exchange and device authorization.

The PR appears safe to merge, with a non-blocking first-message display gap when a web session-creation response omits its first turn.

Summary

The PR adds single-use pairing links and their server, protocol, and client-link contracts. Since the previous review, it also changes web draft launches to keep one chat pane mounted across the landing-to-session URL transition.

  • Pairing exchanges and existing-device redemption use transactional, single-use links.
  • The web launch now shows an optimistic first message and a status pill; when creation omits firstTurn, that message can disappear before history arrives.

Reviews (4) · Last reviewed commit: "fix(pairing): recheck the rate limit bef..."

greptile-apps[bot]reviewed28d ago
greptile-apps[bot]reviewed28d ago
greptile-apps[bot]reviewed28d ago
calledforthreviewed28d ago
calledforthreviewed28d ago
calledforthreviewed28d ago
calledforthreviewed28d ago
greptile-apps[bot]reviewed28d ago
greptile-apps[bot]reviewed28d ago
calledforthreviewed28d ago
calledforthreviewed28d ago