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:
1 parent
ab5c52c709
commit
8fbacd9f46
47 files changed
+935
-273
No files matched your search
@@ -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.
|
||||
@@ -144,9 +144,11 @@ See [Device Registration & Sync](devices/get_devices.md)
|
||||
- GET /api/devices/:id - Get device
|
||||
- PUT /api/devices/:id - Update device
|
||||
- DELETE /api/devices/:id - Delete device
|
||||
- GET /api/devices/pending - List pending registrations (admin)
|
||||
- GET /api/devices/approve/:registration_id - Approve registration (admin)
|
||||
- POST /api/devices/reject/:registration_id - Reject registration (admin)
|
||||
- GET /api/devices/pending - List pending registrations (always empty — pairing codes replaced the broadcast list)
|
||||
- GET /api/devices/approve/:registration_id - Approve registration by UUID (Android self-approval path)
|
||||
- POST /api/devices/claim - Claim a registration by its pairing code ([Pairing codes](devices/claim_device.md))
|
||||
- POST /api/devices/claim/lookup - Resolve a pairing code without binding ([Lookup](devices/lookup_pairing_code.md))
|
||||
- POST /api/devices/register/cancel - The registering device cancels its own pairing attempt ([Cancel](devices/cancel_device_registration.md))
|
||||
- POST /api/devices/:id/shelves - Add to shelf (Kobo; used by native Kobo sync, coming soon)
|
||||
- GET /api/devices/:id/shelves - Get shelf contents
|
||||
- DELETE /api/devices/:id/shelves - Remove from shelf
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
method: POST
|
||||
path: /api/devices/register/cancel
|
||||
auth: none
|
||||
area: devices
|
||||
---
|
||||
<!-- source: internal/handlers/devices.go DeviceHandler.CancelRegistration -->
|
||||
|
||||
# Cancel Device Registration
|
||||
|
||||
Let the REGISTERING device end its own pairing attempt — the plugin calls this
|
||||
when the user dismisses its waiting dialog. The `registration_id` is the
|
||||
bearer credential (same trust as the status poll): a device can only ever
|
||||
cancel its own registration, and already-claimed registrations are left alone
|
||||
so their token delivery to the device is never disrupted. Idempotent: the
|
||||
response is the same whether or not the registration still exists.
|
||||
|
||||
**Endpoint**: `POST /api/devices/register/cancel`
|
||||
**Auth**: Not required (registration UUID bearer, like the status poll)
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
## Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --------------- | ------ | -------- | ------------------------------ |
|
||||
| registration_id | string | Yes | UUID from the register response |
|
||||
|
||||
### Example Request
|
||||
|
||||
```json example-request
|
||||
{
|
||||
"registration_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"message": "registration cancelled"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
method: POST
|
||||
path: /api/devices/claim
|
||||
auth: jwt
|
||||
area: devices
|
||||
---
|
||||
<!-- source: internal/handlers/devices.go DeviceHandler.ClaimDevice -->
|
||||
|
||||
# Claim Device (Pairing Code)
|
||||
|
||||
Bind a pending device registration to the CALLER's account by entering the
|
||||
6-digit pairing code shown on the registering device's screen. This is the
|
||||
web-side replacement for the retired broadcast-list approve: unclaimed
|
||||
registrations appear in no account's pending list, and only whoever reads the
|
||||
code — the person holding the device — can claim it.
|
||||
|
||||
**Endpoint**: `POST /api/devices/claim`
|
||||
**Auth**: JWT
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
The code is normalized before matching: spaces and dashes typed around the
|
||||
digits ("583 912") are stripped. Lookups and claims share a per-account rate
|
||||
limit (12 attempts/minute).
|
||||
|
||||
## Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------ | ------ | -------- | -------------------------------------------- |
|
||||
| pairing_code | string | Yes | The 6-digit code shown on the device's screen |
|
||||
|
||||
### Example Request
|
||||
|
||||
```json example-request
|
||||
{
|
||||
"pairing_code": "583912"
|
||||
}
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"message": "device paired successfully",
|
||||
"device_name": "My Kindle Paperwhite",
|
||||
"device_type": "koreader",
|
||||
"approved": true
|
||||
}
|
||||
```
|
||||
|
||||
HTMX callers (the Devices page confirm button) receive `HX-Refresh` instead of
|
||||
a JSON body. On success the registering device's status poll returns the fresh
|
||||
`auth_token` and sync endpoints, exactly as in the old approve flow.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ------------------------------------------------------ |
|
||||
| 400 | Invalid pairing code (generic — never confirms whether a registration exists) |
|
||||
| 409 | Pairing code already used, or the device identifier is registered to another user |
|
||||
| 410 | Pairing code expired |
|
||||
| 429 | Too many pairing attempts |
|
||||
|
||||
## Related
|
||||
|
||||
- `POST /api/devices/claim/lookup` — the confirm card's data, without binding
|
||||
- `POST /api/devices/register` — starts the registration and mints the code
|
||||
@@ -4,21 +4,19 @@ path: /api/devices/pending
|
||||
auth: jwt
|
||||
area: devices
|
||||
---
|
||||
<!-- source: internal/handlers/devices.go:703 DeviceHandler.ListPendingRegistrations -->
|
||||
<!-- source: internal/handlers/devices.go DeviceHandler.ListPendingRegistrations -->
|
||||
|
||||
# List Pending Device Registrations:<|end|><|start|><|start|>assistant to=edit<|channel|>analysis <|constrain|>xml<|message|><rfl:parameter name="path">
|
||||
/home/nymusicman/Code/bookhoard/docs/developer/api/devices/reject_device_registration.md
|
||||
# List Pending Device Registrations
|
||||
|
||||
List all pending device registration requests.
|
||||
Always empty by design. Unclaimed device registrations are invisible to every
|
||||
account since pairing codes took over the approval flow: first-approve-wins on
|
||||
a broadcast list let any account claim (or reject) a device it could not even
|
||||
name. Instead, the registering device shows a 6-digit pairing code, and the
|
||||
person holding the device types it into their own account's Devices page —
|
||||
`POST /api/devices/claim`.
|
||||
|
||||
**Endpoint**: `GET /api/devices/pending`
|
||||
**Auth**: Required (Admin only)
|
||||
|
||||
## Request Headers
|
||||
|
||||
| Header | Type | Required | Description |
|
||||
| ------------- | ------ | -------- | ----------------------------------- |
|
||||
| Authorization | string | Yes | Bearer token (must have admin role) |
|
||||
**Auth**: Required (JWT)
|
||||
|
||||
### Example Request
|
||||
|
||||
@@ -31,23 +29,16 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"registrations": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"device_name": "My Kobo",
|
||||
"device_type": "kobo",
|
||||
"user_email": "user@example.com",
|
||||
"created_at": "2026-02-08T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 3,
|
||||
"pending_registrations": null
|
||||
"registrations": [],
|
||||
"pending_registrations": [],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
Both `registrations` and `pending_registrations` key spellings are emitted for
|
||||
API compatibility.
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------------------------------- |
|
||||
| 401 | Invalid or expired token |
|
||||
| 403 | User does not have admin privileges |
|
||||
## Related
|
||||
|
||||
- [Register Device](register_device.md) — mints the pairing code
|
||||
- [Claim Device](claim_device.md) — the code-driven replacement for list-and-approve
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
method: POST
|
||||
path: /api/devices/claim/lookup
|
||||
auth: jwt
|
||||
area: devices
|
||||
---
|
||||
<!-- source: internal/handlers/devices.go DeviceHandler.LookupPairingCode -->
|
||||
|
||||
# Look Up Pairing Code
|
||||
|
||||
Resolve a pairing code to its pending registration WITHOUT binding it — the
|
||||
data the Devices page confirm card shows ("Pair 'My Kindle Paperwhite'?").
|
||||
Shares the per-account attempt budget with `POST /api/devices/claim`.
|
||||
|
||||
**Endpoint**: `POST /api/devices/claim/lookup`
|
||||
**Auth**: JWT
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
## Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------ | ------ | -------- | -------------------------------------------- |
|
||||
| pairing_code | string | Yes | The 6-digit code shown on the device's screen |
|
||||
|
||||
### Example Request
|
||||
|
||||
```json example-request
|
||||
{
|
||||
"pairing_code": "583912"
|
||||
}
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"device_name": "My Kindle Paperwhite",
|
||||
"device_type": "koreader",
|
||||
"device_identifier": "koreader-hostname",
|
||||
"expires_in": 245
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ------------------------------------------------------ |
|
||||
| 400 | Invalid pairing code (generic — never confirms whether a registration exists) |
|
||||
| 409 | Pairing code already used |
|
||||
| 410 | Pairing code expired |
|
||||
| 429 | Too many pairing attempts |
|
||||
@@ -40,6 +40,7 @@ Register a new device for sync.
|
||||
"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": {
|
||||
@@ -48,7 +49,14 @@ Register a new device for sync.
|
||||
}
|
||||
```
|
||||
|
||||
Open `auth_url` (or scan the QR code) while logged in to approve; the registration expires after 5 minutes. 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`.
|
||||
`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
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
method: POST
|
||||
path: /api/devices/reject/:registration_id
|
||||
auth: jwt
|
||||
area: devices
|
||||
---
|
||||
<!-- source: internal/handlers/devices.go:664 DeviceHandler.RejectDevice -->
|
||||
|
||||
# Reject Device Registration
|
||||
|
||||
Reject a pending device registration request.
|
||||
|
||||
**Endpoint**: `POST /api/devices/reject/:registration_id`
|
||||
**Auth**: Required (Admin only)
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------------- | ------------- | -------- | ------------------------- |
|
||||
| registration_id | string (UUID) | Yes | Registration request UUID |
|
||||
|
||||
## Request Headers
|
||||
|
||||
| Header | Type | Required | Description |
|
||||
| ------------- | ------ | -------- | ----------------------------------- |
|
||||
| Authorization | string | Yes | Bearer token (must have admin role) |
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
POST /api/devices/reject/550e8400-e29b-41d4-a716-446655440000
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"message": "device registration rejected"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------------------------------- |
|
||||
| 401 | Invalid or expired token |
|
||||
| 403 | User does not have admin privileges |
|
||||
| 404 | Registration not found |
|
||||
| 400 | Registration already processed |
|
||||
+49
-11
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Bookhoard API
|
||||
version: '2026.10.27'
|
||||
version: '2026.11.02'
|
||||
description: |
|
||||
Generated from the canonical API docs (docs/developer/api/**) by
|
||||
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
|
||||
@@ -395,7 +395,7 @@ paths:
|
||||
}
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
description: Success
|
||||
"/api/conflicts":
|
||||
get:
|
||||
tags: [conflicts]
|
||||
@@ -557,6 +557,38 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
"/api/devices/claim":
|
||||
post:
|
||||
tags: [devices]
|
||||
summary: "Claim Device (Pairing Code)"
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
example: |-
|
||||
{
|
||||
"pairing_code": "583912"
|
||||
}
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
"/api/devices/claim/lookup":
|
||||
post:
|
||||
tags: [devices]
|
||||
summary: "Look Up Pairing Code"
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
example: |-
|
||||
{
|
||||
"pairing_code": "583912"
|
||||
}
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
"/api/devices/pending":
|
||||
get:
|
||||
tags: [devices]
|
||||
@@ -583,6 +615,21 @@ paths:
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
"/api/devices/register/cancel":
|
||||
post:
|
||||
tags: [devices]
|
||||
summary: "Cancel Device Registration"
|
||||
security: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
example: |-
|
||||
{
|
||||
"registration_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
"/api/devices/register/status":
|
||||
post:
|
||||
tags: [devices]
|
||||
@@ -598,15 +645,6 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
"/api/devices/reject/{registration_id}":
|
||||
post:
|
||||
tags: [devices]
|
||||
summary: "Reject Device Registration"
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
"/api/devices/{deviceId}/sync/link-book":
|
||||
post:
|
||||
tags: [book-matching]
|
||||
|
||||
Reference in new issue
Block a user