Files
bookhoard/docs/developer/api/devices/register_device.md
T
John O'Keefe 8fbacd9f46 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).
2026-11-02 16:11:03 -05:00

2.1 KiB

method, path, auth, area
method path auth area
POST /api/devices/register none devices

Register Device

Register a new device for sync.

Endpoint: POST /api/devices/register Auth: Not required (device registration flow) Content-Type: application/json

Request Body

Field Type Required Description
device_name string Yes Device name
device_type string Yes Device type: kobo, koreader, web, mobile
device_identifier string Yes Hardware-specific ID

Example Request

{
  "device_name": "My Kobo Clara",
  "device_type": "kobo",
  "device_identifier": "hardware-specific-id"
}

Response (201 Created)

{
  "device_id": "550e8400-e29b-41d4-a716-446655440000",
  "registration_id": "registration-uuid",
  "auth_url": "https://bookhoard.com/devices/approve/abc123",
  "qr_code": "data:image/png;base64,iVBORw0KG...",
  "pairing_code": "583912",
  "expires_in": 300,
  "poll_interval": 3,
  "setup_instructions": {
    "koreader": "Calibre URL: https://bookhoard.com/api/sync/koreader"
  }
}

pairing_code is the claim credential: the registering device displays it, and the only person who can pair the device is whoever reads it (they type it into the Devices page of THEIR account — POST /api/devices/claim). Unclaimed registrations appear in no account's pending list, so there is no first-approver race. Registrations expire after 5 minutes; re-registering the same device_identifier retires the earlier attempt. Poll POST /api/devices/register/status at poll_interval seconds until status is approved, at which point the response includes the device's auth_token, device_id, and sync_endpoints.

Error Responses

Code Description
400 Invalid device data
409 Device already registered