#193
feat(server): single-use pairing links
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.createtakes 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 newpairing_linkstable (migration 18). - Redeeming a link works in two ways:
- A new device posts the token to
POST /pairand gets back apairedcredential, in the same shape/local-owneranswers with. - An already paired device redeems over the socket it already trusts (
pairing.redeem,readis 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.
- A new device posts the token to
- 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
/bootstrapwith the right environment id. That is whyPOST /pairrefuses any body that carries a credential. pairing.listshows 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.revokewithdraws a waiting link.- Building links.
encodePairingLink/parsePairingLinkin@openmanager/environment-clientbuild 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
pairingrate 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, usedByClientIdDemo
This is a real run against a v13 server, rendered from the terminal. In order:
- The owner creates a read-only link.
- A new device trades the token at
POST /pairand gets read access only. - Reusing the token gets
401 used. - A POST that carries a credential gets
400 malformed. - A paired device redeems a wider link over its socket and is reconnected with 4403.
pairing.listshows the link used by the phone.- Wrong guesses hit the rate limit and get 429.
- 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.listnow 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
internaland the socket stays up. - P2:
/pairrequests 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
/pairbodies did not count against the rate limit, and the second check skipped them. Fixed in both places, with tests. - P2: no typed
EnvironmentClientmethods 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
/pairrequest 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
/pairpage, 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
18greptile-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
[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.
firstTurn, that message can disappear before history arrives.Reviews (4) · Last reviewed commit: "fix(pairing): recheck the rate limit bef..."