feat(devices): pairing codes replace the registration broadcast

A pending device registration used to be announced to EVERY account's
Devices page, and whoever clicked Approve first bound the device — and
its freshly minted sync token — to their account. Worse, the pending
card displayed the registration UUID, which is also the bearer
credential on the unauthenticated /api/devices/register/status poll
channel: any family user who could see the list already held everything
needed to take the device. On a family server that is a
wrong-account-binding bug (accidental or deliberate); this commit
removes the class of problem rather than patching the race.

The new model is the Netflix/YouTube TV device flow: the registering
device displays a 6-digit pairing code, and the only person who can
read it — whoever holds the device — types it into THEIR account on the
Devices page. Possession of the device screen becomes the credential;
nothing is broadcast; the reject-on-someone-else's-behalf hole dies
with the list.

Server changes:
- POST /api/devices/register now mints pairing_code (crypto/rand,
  rejection-sampled for uniformity, collision-checked against live
  registrations) and retires prior unclaimed registrations with the
  same device_identifier, so re-registering leaves exactly one live
  attempt.
- POST /api/devices/claim (JWT) binds the registration behind a code to
  the caller's account, reusing the rotate-or-create approval logic
  factored out of ApproveDevice (shared bindRegistrationToUser helper;
  the Android self-approval route keeps working unchanged).
- POST /api/devices/claim/lookup (JWT) returns the confirm-card data
  without binding. Lookup and claim share a per-account budget (12
  attempts/min, DeviceRateLimiter gained a claim case) — the 1M-code
  space over a 5-minute TTL makes guessing infeasible without it.
- POST /api/devices/register/cancel lets the registering device end its
  own attempt (registration UUID bearer, idempotent, never touches a
  claimed registration).
- ListPendingRegistrations / GetPendingRegistrationsData are
  deprecated-by-emptiness: unclaimed registrations return to nobody.
- RejectDevice and its route are deleted — the broadcast list was its
  only consumer, and there is nothing left for anyone to reject.

Additive-only for pinned surfaces: the register response gains
pairing_code (Android DTO untouched), the four KOReader sync surfaces
and approve keep byte-identical shapes. contract_pins.md changelog
records the widening and the reject removal. Docs, schema registry, and
bruno mirrors move together per the drift gate; bruno_gen.go now keeps
hand-authored bruno/podcasts/ (API docs never existed for them, so the
next -update run would have swept them — latent landmine, defused).

Unit tests: code shape, live-collision avoidance, typed-code
normalization, and the resolve scopes (live/expired/claimed/miss).
This commit is contained in:
John O'Keefe committed 2026-11-02 16:11:03 -05:00
1 parent ab5c52c709
commit 8fbacd9f46
47 files changed
+935 -273

No files matched your search

@@ -1,12 +1,18 @@
info:
name: Reject Device Registration
name: Cancel Device Registration
type: http
seq: 16
seq: 3
http:
method: POST
url: '{{base_url}}/api/devices/reject/{{registration_id}}'
url: '{{base_url}}/api/devices/register/cancel'
auth: inherit
body:
type: json
data: |-
{
"registration_id": "550e8400-e29b-41d4-a716-446655440000"
}
settings:
encodeUrl: true
timeout: 0
@@ -23,9 +29,9 @@ runtime:
docs:
content: |-
# Reject Device Registration
# Cancel Device Registration
`POST /api/devices/reject/:registration_id` — auth: jwt.
`POST /api/devices/register/cancel` — auth: none.
Canonical reference: docs/developer/api/devices/reject_device_registration.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/devices/cancel_device_registration.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+37
View File
@@ -0,0 +1,37 @@
info:
name: Claim Device (Pairing Code)
type: http
seq: 4
http:
method: POST
url: '{{base_url}}/api/devices/claim'
auth: inherit
body:
type: json
data: |-
{
"pairing_code": "583912"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Claim Device (Pairing Code)
`POST /api/devices/claim` — auth: jwt.
Canonical reference: docs/developer/api/devices/claim_device.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Clear Shelf
type: http
seq: 3
seq: 5
http:
method: DELETE
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Delete Device
type: http
seq: 4
seq: 6
http:
method: DELETE
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Download Sidecar Config
type: http
seq: 5
seq: 7
http:
method: GET
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Get Device
type: http
seq: 6
seq: 8
http:
method: GET
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Get Device Collection Mappings
type: http
seq: 7
seq: 9
http:
method: GET
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Get Device / Check Registration Status
type: http
seq: 8
seq: 10
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Get Shelf
type: http
seq: 9
seq: 11
http:
method: GET
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Get Device Sidecar Config
type: http
seq: 10
seq: 12
http:
method: GET
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Link Book
type: http
seq: 11
seq: 13
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: List User Devices
type: http
seq: 12
seq: 14
http:
method: GET
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: List Pending Device Registrations:<|end|><|start|><|start|>assistant to=edit<|channel|>analysis <|constrain|>xml<|message|><rfl:parameter name="path">
type: http
seq: 13
seq: 15
http:
method: GET
+37
View File
@@ -0,0 +1,37 @@
info:
name: Look Up Pairing Code
type: http
seq: 16
http:
method: POST
url: '{{base_url}}/api/devices/claim/lookup'
auth: inherit
body:
type: json
data: |-
{
"pairing_code": "583912"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Look Up Pairing Code
`POST /api/devices/claim/lookup` — auth: jwt.
Canonical reference: docs/developer/api/devices/lookup_pairing_code.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Regenerate Device Token
type: http
seq: 14
seq: 17
http:
method: PUT
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Register Device
type: http
seq: 15
seq: 18
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Remove from Shelf
type: http
seq: 17
seq: 19
http:
method: DELETE
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Update Device
type: http
seq: 18
seq: 20
http:
method: PUT
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Update Device Collection Mapping
type: http
seq: 19
seq: 21
http:
method: PUT