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

+30
View File
@@ -737,3 +737,33 @@ you see, chained back-to-back within the live briefing set.
Pinned (TestAudioResumeRewind): default 0, PUT "5" rides the
chapters payload, non-allow-listed rejections change nothing,
explicit "0" resets everywhere, omitted key leaves it untouched.
## 2026-11-02 — pairing codes replace the registration broadcast
- `POST /api/devices/register` gained one ADDITIVE response key:
`pairing_code` (string, 6 digits) — the claim credential the
registering device displays. Existing keys (`registration_id`,
`auth_url`, `qr_code`, `expires_in`, `poll_interval`,
`setup_instructions`) unchanged, so the Android app's DTO is
untouched. Request parse set unchanged.
- The four pinned KOReader sync surfaces (progress push/pull, library,
bookmarks push) and the Kobo surfaces are byte-identical — pairing
codes only touch the registration/approval path, never sync.
- `GET /api/devices/pending` keeps its response SHAPE but is
deprecated-by-emptiness: unclaimed registrations return to no
account (the broadcast was the first-approve-wins hole). The keys
`registrations`, `pending_registrations`, `total` still emit.
- New unpinned surfaces (no prior shape to freeze):
`POST /api/devices/claim` (pairing-code → bind to caller's account;
HTMX callers get `HX-Refresh` instead of JSON),
`POST /api/devices/claim/lookup` (confirm-card data, no binding),
`POST /api/devices/register/cancel` (the registering device ends its
own attempt; registration UUID bearer).
- `POST /api/devices/reject/:registration_id` REMOVED — the broadcast
list was its only consumer; unclaimed registrations are invisible by
design, so there is nothing left for anyone to reject. Removal
recorded here per rule 4 (user-approved plan; the route was the
theft vector's cleanup half).
- `GET /api/devices/approve/:registration_id` (Android self-approval)
keeps its response shape; the registration UUID is now only ever
known to the device that created it, since no list publishes it.