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
+933
-271
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Bulk Add Books
|
name: Bulk Add Books
|
||||||
type: http
|
type: http
|
||||||
seq: 2
|
seq: 1
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Bulk Assign Books
|
name: Bulk Assign Books
|
||||||
type: http
|
type: http
|
||||||
seq: 3
|
seq: 2
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Bulk Remove Books
|
name: Bulk Remove Books
|
||||||
type: http
|
type: http
|
||||||
seq: 4
|
seq: 3
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Create Collection
|
name: Create Collection
|
||||||
type: http
|
type: http
|
||||||
seq: 5
|
seq: 4
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Create Shelf Mapping
|
name: Create Shelf Mapping
|
||||||
type: http
|
type: http
|
||||||
seq: 6
|
seq: 5
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Delete Collection
|
name: Delete Collection
|
||||||
type: http
|
type: http
|
||||||
seq: 7
|
seq: 6
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: DELETE
|
method: DELETE
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Delete Shelf Mapping
|
name: Delete Shelf Mapping
|
||||||
type: http
|
type: http
|
||||||
seq: 8
|
seq: 7
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: DELETE
|
method: DELETE
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Collection
|
name: Get Collection
|
||||||
type: http
|
type: http
|
||||||
seq: 9
|
seq: 8
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Book Collections
|
name: Get Book Collections
|
||||||
type: http
|
type: http
|
||||||
seq: 10
|
seq: 9
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: List Collections
|
name: List Collections
|
||||||
type: http
|
type: http
|
||||||
seq: 11
|
seq: 10
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Preview Collection
|
name: Preview Collection
|
||||||
type: http
|
type: http
|
||||||
seq: 12
|
seq: 11
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Reorder Collection Items
|
name: Reorder Collection Items
|
||||||
type: http
|
type: http
|
||||||
seq: 14
|
seq: 12
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Update Collection
|
name: Update Collection
|
||||||
type: http
|
type: http
|
||||||
seq: 16
|
seq: 14
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
|
|||||||
+12
-6
@@ -1,12 +1,18 @@
|
|||||||
info:
|
info:
|
||||||
name: Reject Device Registration
|
name: Cancel Device Registration
|
||||||
type: http
|
type: http
|
||||||
seq: 16
|
seq: 3
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
url: '{{base_url}}/api/devices/reject/{{registration_id}}'
|
url: '{{base_url}}/api/devices/register/cancel'
|
||||||
auth: inherit
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"registration_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
}
|
||||||
settings:
|
settings:
|
||||||
encodeUrl: true
|
encodeUrl: true
|
||||||
timeout: 0
|
timeout: 0
|
||||||
@@ -23,9 +29,9 @@ runtime:
|
|||||||
|
|
||||||
docs:
|
docs:
|
||||||
content: |-
|
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
|
type: text/markdown
|
||||||
@@ -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,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Clear Shelf
|
name: Clear Shelf
|
||||||
type: http
|
type: http
|
||||||
seq: 3
|
seq: 5
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: DELETE
|
method: DELETE
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Delete Device
|
name: Delete Device
|
||||||
type: http
|
type: http
|
||||||
seq: 4
|
seq: 6
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: DELETE
|
method: DELETE
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Download Sidecar Config
|
name: Download Sidecar Config
|
||||||
type: http
|
type: http
|
||||||
seq: 5
|
seq: 7
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Device
|
name: Get Device
|
||||||
type: http
|
type: http
|
||||||
seq: 6
|
seq: 8
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Device Collection Mappings
|
name: Get Device Collection Mappings
|
||||||
type: http
|
type: http
|
||||||
seq: 7
|
seq: 9
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Device / Check Registration Status
|
name: Get Device / Check Registration Status
|
||||||
type: http
|
type: http
|
||||||
seq: 8
|
seq: 10
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Shelf
|
name: Get Shelf
|
||||||
type: http
|
type: http
|
||||||
seq: 9
|
seq: 11
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Device Sidecar Config
|
name: Get Device Sidecar Config
|
||||||
type: http
|
type: http
|
||||||
seq: 10
|
seq: 12
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Link Book
|
name: Link Book
|
||||||
type: http
|
type: http
|
||||||
seq: 11
|
seq: 13
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: List User Devices
|
name: List User Devices
|
||||||
type: http
|
type: http
|
||||||
seq: 12
|
seq: 14
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: List Pending Device Registrations:<|end|><|start|><|start|>assistant to=edit<|channel|>analysis <|constrain|>xml<|message|><rfl:parameter name="path">
|
name: List Pending Device Registrations:<|end|><|start|><|start|>assistant to=edit<|channel|>analysis <|constrain|>xml<|message|><rfl:parameter name="path">
|
||||||
type: http
|
type: http
|
||||||
seq: 13
|
seq: 15
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Regenerate Device Token
|
name: Regenerate Device Token
|
||||||
type: http
|
type: http
|
||||||
seq: 14
|
seq: 17
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Register Device
|
name: Register Device
|
||||||
type: http
|
type: http
|
||||||
seq: 15
|
seq: 18
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Remove from Shelf
|
name: Remove from Shelf
|
||||||
type: http
|
type: http
|
||||||
seq: 17
|
seq: 19
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: DELETE
|
method: DELETE
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Update Device
|
name: Update Device
|
||||||
type: http
|
type: http
|
||||||
seq: 18
|
seq: 20
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Update Device Collection Mapping
|
name: Update Device Collection Mapping
|
||||||
type: http
|
type: http
|
||||||
seq: 19
|
seq: 21
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
|
|||||||
@@ -737,3 +737,33 @@ you see, chained back-to-back within the live briefing set.
|
|||||||
Pinned (TestAudioResumeRewind): default 0, PUT "5" rides the
|
Pinned (TestAudioResumeRewind): default 0, PUT "5" rides the
|
||||||
chapters payload, non-allow-listed rejections change nothing,
|
chapters payload, non-allow-listed rejections change nothing,
|
||||||
explicit "0" resets everywhere, omitted key leaves it untouched.
|
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
|
- GET /api/devices/:id - Get device
|
||||||
- PUT /api/devices/:id - Update device
|
- PUT /api/devices/:id - Update device
|
||||||
- DELETE /api/devices/:id - Delete device
|
- DELETE /api/devices/:id - Delete device
|
||||||
- GET /api/devices/pending - List pending registrations (admin)
|
- GET /api/devices/pending - List pending registrations (always empty — pairing codes replaced the broadcast list)
|
||||||
- GET /api/devices/approve/:registration_id - Approve registration (admin)
|
- GET /api/devices/approve/:registration_id - Approve registration by UUID (Android self-approval path)
|
||||||
- POST /api/devices/reject/:registration_id - Reject registration (admin)
|
- 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)
|
- POST /api/devices/:id/shelves - Add to shelf (Kobo; used by native Kobo sync, coming soon)
|
||||||
- GET /api/devices/:id/shelves - Get shelf contents
|
- GET /api/devices/:id/shelves - Get shelf contents
|
||||||
- DELETE /api/devices/:id/shelves - Remove from shelf
|
- 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
|
auth: jwt
|
||||||
area: devices
|
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">
|
# List Pending Device Registrations
|
||||||
/home/nymusicman/Code/bookhoard/docs/developer/api/devices/reject_device_registration.md
|
|
||||||
|
|
||||||
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`
|
**Endpoint**: `GET /api/devices/pending`
|
||||||
**Auth**: Required (Admin only)
|
**Auth**: Required (JWT)
|
||||||
|
|
||||||
## Request Headers
|
|
||||||
|
|
||||||
| Header | Type | Required | Description |
|
|
||||||
| ------------- | ------ | -------- | ----------------------------------- |
|
|
||||||
| Authorization | string | Yes | Bearer token (must have admin role) |
|
|
||||||
|
|
||||||
### Example Request
|
### Example Request
|
||||||
|
|
||||||
@@ -31,23 +29,16 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|||||||
|
|
||||||
```json example-response
|
```json example-response
|
||||||
{
|
{
|
||||||
"registrations": [
|
"registrations": [],
|
||||||
{
|
"pending_registrations": [],
|
||||||
"id": "uuid",
|
"total": 0
|
||||||
"device_name": "My Kobo",
|
|
||||||
"device_type": "kobo",
|
|
||||||
"user_email": "user@example.com",
|
|
||||||
"created_at": "2026-02-08T10:00:00Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 3,
|
|
||||||
"pending_registrations": null
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Error Responses
|
Both `registrations` and `pending_registrations` key spellings are emitted for
|
||||||
|
API compatibility.
|
||||||
|
|
||||||
| Code | Description |
|
## Related
|
||||||
| ---- | ----------------------------------- |
|
|
||||||
| 401 | Invalid or expired token |
|
- [Register Device](register_device.md) — mints the pairing code
|
||||||
| 403 | User does not have admin privileges |
|
- [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",
|
"registration_id": "registration-uuid",
|
||||||
"auth_url": "https://bookhoard.com/devices/approve/abc123",
|
"auth_url": "https://bookhoard.com/devices/approve/abc123",
|
||||||
"qr_code": "data:image/png;base64,iVBORw0KG...",
|
"qr_code": "data:image/png;base64,iVBORw0KG...",
|
||||||
|
"pairing_code": "583912",
|
||||||
"expires_in": 300,
|
"expires_in": 300,
|
||||||
"poll_interval": 3,
|
"poll_interval": 3,
|
||||||
"setup_instructions": {
|
"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
|
## 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
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Bookhoard API
|
title: Bookhoard API
|
||||||
version: '2026.10.27'
|
version: '2026.11.02'
|
||||||
description: |
|
description: |
|
||||||
Generated from the canonical API docs (docs/developer/api/**) by
|
Generated from the canonical API docs (docs/developer/api/**) by
|
||||||
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
|
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
|
||||||
@@ -395,7 +395,7 @@ paths:
|
|||||||
}
|
}
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: OK
|
description: Success
|
||||||
"/api/conflicts":
|
"/api/conflicts":
|
||||||
get:
|
get:
|
||||||
tags: [conflicts]
|
tags: [conflicts]
|
||||||
@@ -557,6 +557,38 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: OK
|
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":
|
"/api/devices/pending":
|
||||||
get:
|
get:
|
||||||
tags: [devices]
|
tags: [devices]
|
||||||
@@ -583,6 +615,21 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: Created
|
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":
|
"/api/devices/register/status":
|
||||||
post:
|
post:
|
||||||
tags: [devices]
|
tags: [devices]
|
||||||
@@ -598,15 +645,6 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: OK
|
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":
|
"/api/devices/{deviceId}/sync/link-book":
|
||||||
post:
|
post:
|
||||||
tags: [book-matching]
|
tags: [book-matching]
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const docsRoot = "../../docs/developer/api"
|
|||||||
|
|
||||||
// brunoKeepDir / brunoKeepSub name the hand-authored parts of the
|
// brunoKeepDir / brunoKeepSub name the hand-authored parts of the
|
||||||
// collection that generation must never touch or delete.
|
// collection that generation must never touch or delete.
|
||||||
var brunoKeepDir = map[string]bool{"NewDevDBSetup": true, "environments": true}
|
var brunoKeepDir = map[string]bool{"NewDevDBSetup": true, "environments": true, "podcasts": true}
|
||||||
|
|
||||||
var brunoKeepSub = []string{"/scenarios/", "/filters/", "/search/"}
|
var brunoKeepSub = []string{"/scenarios/", "/filters/", "/search/"}
|
||||||
|
|
||||||
|
|||||||
+24
-5
@@ -443,6 +443,25 @@ var Schemas = map[string]Schema{
|
|||||||
Request: handlers.DeviceAuthStatusRequest{},
|
Request: handlers.DeviceAuthStatusRequest{},
|
||||||
Response: handlers.DeviceAuthStatusResponse{},
|
Response: handlers.DeviceAuthStatusResponse{},
|
||||||
},
|
},
|
||||||
|
"POST /api/devices/register/cancel": {
|
||||||
|
// The registering device ends its own pairing attempt; idempotent
|
||||||
|
// (same body whether or not the registration still exists).
|
||||||
|
Request: handlers.CancelRegistrationRequest{},
|
||||||
|
Response: json.RawMessage(`{"message": ""}`),
|
||||||
|
},
|
||||||
|
"POST /api/devices/claim": {
|
||||||
|
// Pairing-code claim: binds the registration behind the code to
|
||||||
|
// the caller's account. HTMX callers get HX-Refresh, not JSON.
|
||||||
|
Request: handlers.PairingCodeRequest{},
|
||||||
|
Response: json.RawMessage(`{
|
||||||
|
"message": "", "device_name": "", "device_type": "", "approved": false
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
"POST /api/devices/claim/lookup": {
|
||||||
|
// Confirm-card data for a pairing code, without binding.
|
||||||
|
Request: handlers.PairingCodeRequest{},
|
||||||
|
Response: handlers.PairingLookupResponse{},
|
||||||
|
},
|
||||||
|
|
||||||
// --- Beam batch 4: media-items core ---
|
// --- Beam batch 4: media-items core ---
|
||||||
"GET /api/media-items": {
|
"GET /api/media-items": {
|
||||||
@@ -664,8 +683,9 @@ var Schemas = map[string]Schema{
|
|||||||
|
|
||||||
// --- Beam batch 8: devices remaining + auth remainder ---
|
// --- Beam batch 8: devices remaining + auth remainder ---
|
||||||
"GET /api/devices/pending": {
|
"GET /api/devices/pending": {
|
||||||
// Device registrations live in an in-memory map; both key spellings
|
// Deprecated-by-emptiness: unclaimed registrations are invisible
|
||||||
// are emitted for compatibility.
|
// since pairing codes took over approval (see POST
|
||||||
|
// /api/devices/claim). Both key spellings kept for compatibility.
|
||||||
Response: json.RawMessage(`{
|
Response: json.RawMessage(`{
|
||||||
"registrations": [], "pending_registrations": [], "total": 0
|
"registrations": [], "pending_registrations": [], "total": 0
|
||||||
}`),
|
}`),
|
||||||
@@ -673,14 +693,13 @@ var Schemas = map[string]Schema{
|
|||||||
"GET /api/devices/approve/:registration_id": {
|
"GET /api/devices/approve/:registration_id": {
|
||||||
// Already-approved variant omits registration_id. Credentials ride
|
// Already-approved variant omits registration_id. Credentials ride
|
||||||
// the registration map polled elsewhere — NOT this JSON body.
|
// the registration map polled elsewhere — NOT this JSON body.
|
||||||
|
// Android self-approval path: the app registers, then approves its
|
||||||
|
// OWN registration UUID with its JWT.
|
||||||
Response: json.RawMessage(`{
|
Response: json.RawMessage(`{
|
||||||
"message": "", "device_name": "", "device_type": "",
|
"message": "", "device_name": "", "device_type": "",
|
||||||
"registration_id": "", "approved": false
|
"registration_id": "", "approved": false
|
||||||
}`),
|
}`),
|
||||||
},
|
},
|
||||||
"POST /api/devices/reject/:registration_id": {
|
|
||||||
Response: json.RawMessage(`{"message": ""}`),
|
|
||||||
},
|
|
||||||
"POST /api/auth/users": {
|
"POST /api/auth/users": {
|
||||||
Request: handlers.RegisterRequest{},
|
Request: handlers.RegisterRequest{},
|
||||||
Response: json.RawMessage(`{
|
Response: json.RawMessage(`{
|
||||||
|
|||||||
+404
-137
@@ -3,6 +3,8 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/config"
|
"bookhoard/internal/config"
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/middleware"
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -10,6 +12,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -47,6 +50,7 @@ type DeviceRegistrationResponse struct {
|
|||||||
RegistrationID string `json:"registration_id"`
|
RegistrationID string `json:"registration_id"`
|
||||||
AuthURL string `json:"auth_url"`
|
AuthURL string `json:"auth_url"`
|
||||||
QRCode string `json:"qr_code"`
|
QRCode string `json:"qr_code"`
|
||||||
|
PairingCode string `json:"pairing_code"`
|
||||||
ExpiresIn int `json:"expires_in"`
|
ExpiresIn int `json:"expires_in"`
|
||||||
PollInterval int `json:"poll_interval"`
|
PollInterval int `json:"poll_interval"`
|
||||||
SetupInstructions map[string]string `json:"setup_instructions"`
|
SetupInstructions map[string]string `json:"setup_instructions"`
|
||||||
@@ -97,11 +101,31 @@ type DeviceApprovalRequest struct {
|
|||||||
Approve bool `json:"approve"`
|
Approve bool `json:"approve"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PairingCodeRequest carries the 6-digit code shown on a registering
|
||||||
|
// device's screen — the claim credential for the pairing flow.
|
||||||
|
type PairingCodeRequest struct {
|
||||||
|
PairingCode string `json:"pairing_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PairingLookupResponse describes a live registration without binding it —
|
||||||
|
// the confirm card's data ("Pair 'My Kindle'?").
|
||||||
|
type PairingLookupResponse struct {
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
DeviceType string `json:"device_type"`
|
||||||
|
DeviceIdentifier string `json:"device_identifier"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CancelRegistrationRequest struct {
|
||||||
|
RegistrationID string `json:"registration_id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
type PendingRegistration struct {
|
type PendingRegistration struct {
|
||||||
RegistrationID string
|
RegistrationID string
|
||||||
DeviceName string
|
DeviceName string
|
||||||
DeviceType string
|
DeviceType string
|
||||||
DeviceIdentifier string
|
DeviceIdentifier string
|
||||||
|
PairingCode string
|
||||||
UserID uuid.UUID
|
UserID uuid.UUID
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
@@ -120,6 +144,119 @@ var (
|
|||||||
pendingMu sync.Mutex
|
pendingMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// pairingRateLimiter throttles claim-code lookups per account: the code is
|
||||||
|
// the only secret guarding a registration, so guessing must not be free.
|
||||||
|
// The per-registration 5-minute TTL bounds each code's exposure; this
|
||||||
|
// bounds the guessing rate against the whole live-code space.
|
||||||
|
var pairingRateLimiter = middleware.NewDeviceRateLimiter()
|
||||||
|
|
||||||
|
const (
|
||||||
|
// pairingCodeDigits: six, the Netflix/YouTube TV pairing shape —
|
||||||
|
// nothing to type on e-ink hardware, and a million-code space that a
|
||||||
|
// rate-limited guesser cannot walk inside the 5-minute TTL.
|
||||||
|
pairingCodeDigits = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
// pairingRateLimitConfig caps lookup/claim attempts per account per
|
||||||
|
// minute (lookups and claims share the budget — a pairing is two calls:
|
||||||
|
// lookup, then confirm).
|
||||||
|
var pairingRateLimitConfig = middleware.DeviceRateLimitConfig{
|
||||||
|
ClaimAttemptsPerMinute: 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePairingCode returns a uniformly random pairingCodeDigits-digit
|
||||||
|
// code, unique among the currently live registrations. The code is a claim
|
||||||
|
// credential — never log it.
|
||||||
|
func generatePairingCode() (string, error) {
|
||||||
|
space := 1
|
||||||
|
for i := 0; i < pairingCodeDigits; i++ {
|
||||||
|
space *= 10
|
||||||
|
}
|
||||||
|
// Rejection-sample: n%space over the full 24-bit range would bias low
|
||||||
|
// codes (16777216 is not a multiple of 10^6), so values at or above
|
||||||
|
// the largest whole multiple of the code space are discarded.
|
||||||
|
max := ((1 << 24) / space) * space
|
||||||
|
for attempt := 0; attempt < 10; attempt++ {
|
||||||
|
b := make([]byte, 3)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
n := int(b[0])<<16 | int(b[1])<<8 | int(b[2])
|
||||||
|
if n >= max {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
code := fmt.Sprintf("%0*d", pairingCodeDigits, n%space)
|
||||||
|
if !pairingCodeInUse(code) {
|
||||||
|
return code, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("pairing code space exhausted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// pairingCodeInUse reports whether any live registration already holds the
|
||||||
|
// code. Callers race on mint→insert; the next mint's collision check
|
||||||
|
// covers the gap well enough for a family-scale server.
|
||||||
|
func pairingCodeInUse(code string) bool {
|
||||||
|
pendingMu.Lock()
|
||||||
|
defer pendingMu.Unlock()
|
||||||
|
for _, reg := range pendingRegistrations {
|
||||||
|
if reg.PairingCode == code {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizePairingCode reduces human-typed input to its digits — the
|
||||||
|
// device shows "583 912", users type "583 912", "583912", "583-912".
|
||||||
|
func normalizePairingCode(raw string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range raw {
|
||||||
|
if r >= '0' && r <= '9' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// errPairingInvalid marks a wrong code (generic by design — no hint
|
||||||
|
// whether the code exists), errPairingExpired a live-registration TTL
|
||||||
|
// miss, errPairingClaimed a code whose registration was already bound.
|
||||||
|
var (
|
||||||
|
errPairingInvalid = errors.New("invalid pairing code")
|
||||||
|
errPairingExpired = errors.New("pairing code expired")
|
||||||
|
errPairingClaimed = errors.New("pairing code already used")
|
||||||
|
)
|
||||||
|
|
||||||
|
// registrationByPairingCode resolves a normalized code to its live,
|
||||||
|
// unclaimed registration, reaping expired entries on sight.
|
||||||
|
func registrationByPairingCode(code string) (*PendingRegistration, error) {
|
||||||
|
pendingMu.Lock()
|
||||||
|
defer pendingMu.Unlock()
|
||||||
|
|
||||||
|
for _, reg := range pendingRegistrations {
|
||||||
|
if reg.PairingCode != code {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if time.Now().After(reg.ExpiresAt) {
|
||||||
|
delete(pendingRegistrations, reg.RegistrationID)
|
||||||
|
return nil, errPairingExpired
|
||||||
|
}
|
||||||
|
if reg.Approved {
|
||||||
|
return nil, errPairingClaimed
|
||||||
|
}
|
||||||
|
return reg, nil
|
||||||
|
}
|
||||||
|
return nil, errPairingInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimAllowed applies the per-account pairing attempt budget. userID
|
||||||
|
// keys the window; IP is folded in so a token shared across browsers
|
||||||
|
// still bounds per-origin abuse.
|
||||||
|
func claimAllowed(userID string, ip string) bool {
|
||||||
|
return pairingRateLimiter.CheckRateLimit(userID+"|"+ip, "claim", pairingRateLimitConfig)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
|
func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
|
||||||
req := DeviceRegistrationRequest{}
|
req := DeviceRegistrationRequest{}
|
||||||
if err := c.Bind(&req); err != nil {
|
if err := c.Bind(&req); err != nil {
|
||||||
@@ -133,16 +270,31 @@ func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
|
|||||||
registrationID := uuid.New().String()
|
registrationID := uuid.New().String()
|
||||||
expiresAt := time.Now().Add(5 * time.Minute)
|
expiresAt := time.Now().Add(5 * time.Minute)
|
||||||
|
|
||||||
|
pairingCode, err := generatePairingCode()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("pairing code generation failed", "error", err)
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate pairing code"})
|
||||||
|
}
|
||||||
|
|
||||||
registration := &PendingRegistration{
|
registration := &PendingRegistration{
|
||||||
RegistrationID: registrationID,
|
RegistrationID: registrationID,
|
||||||
DeviceName: req.DeviceName,
|
DeviceName: req.DeviceName,
|
||||||
DeviceType: req.DeviceType,
|
DeviceType: req.DeviceType,
|
||||||
DeviceIdentifier: req.DeviceIdentifier,
|
DeviceIdentifier: req.DeviceIdentifier,
|
||||||
|
PairingCode: pairingCode,
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingMu.Lock()
|
pendingMu.Lock()
|
||||||
|
// One live registration per device: a re-register (cancel-then-retry,
|
||||||
|
// double-tap) retires the previous unclaimed attempt so exactly one
|
||||||
|
// code is ever claimable for this identifier.
|
||||||
|
for id, existing := range pendingRegistrations {
|
||||||
|
if existing.DeviceIdentifier == req.DeviceIdentifier && !existing.Approved {
|
||||||
|
delete(pendingRegistrations, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
pendingRegistrations[registrationID] = registration
|
pendingRegistrations[registrationID] = registration
|
||||||
pendingMu.Unlock()
|
pendingMu.Unlock()
|
||||||
|
|
||||||
@@ -167,6 +319,7 @@ func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
|
|||||||
RegistrationID: registrationID,
|
RegistrationID: registrationID,
|
||||||
AuthURL: authURL,
|
AuthURL: authURL,
|
||||||
QRCode: "data:image/png;base64," + qrCodeBase64,
|
QRCode: "data:image/png;base64," + qrCodeBase64,
|
||||||
|
PairingCode: pairingCode,
|
||||||
ExpiresIn: 300,
|
ExpiresIn: 300,
|
||||||
PollInterval: 3,
|
PollInterval: 3,
|
||||||
SetupInstructions: setupInstructions,
|
SetupInstructions: setupInstructions,
|
||||||
@@ -519,6 +672,95 @@ func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errIdentifierOwnedByOther marks the cross-account case: the device
|
||||||
|
// identifier's existing row belongs to a different user, so neither
|
||||||
|
// approval nor claim may touch it.
|
||||||
|
var errIdentifierOwnedByOther = errors.New("device identifier registered to another user")
|
||||||
|
|
||||||
|
// bindRegistrationToUser creates or rotates the devices row for a pending
|
||||||
|
// registration and returns the row, its fresh token, and the sync
|
||||||
|
// endpoints for the device type. Shared by ApproveDevice (Android
|
||||||
|
// self-approval by registration UUID) and the claim-by-code endpoint;
|
||||||
|
// callers publish the result under pendingMu themselves.
|
||||||
|
func (h *DeviceHandler) bindRegistrationToUser(ctx context.Context, registration *PendingRegistration, pgUserID pgtype.UUID) (database.Devices, string, map[string]string, error) {
|
||||||
|
authToken, err := generateDeviceToken()
|
||||||
|
if err != nil {
|
||||||
|
return database.Devices{}, "", nil, fmt.Errorf("generate token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-approval: app reinstalls that preserve data (e.g. Android Studio
|
||||||
|
// installDebug over an existing install) re-register with the SAME
|
||||||
|
// device_identifier, but the devices row from the previous install
|
||||||
|
// still exists — device_identifier is UNIQUE, so a blind INSERT fails
|
||||||
|
// with a unique violation (the historical "approve returns 500 after
|
||||||
|
// reinstall" bug). Look the device up first and rotate its token
|
||||||
|
// instead; a fresh install MUST get fresh credentials, so the old
|
||||||
|
// token is invalidated either way.
|
||||||
|
rotateExisting := func(existing database.Devices) (database.Devices, bool, error) {
|
||||||
|
// The identifier is globally unique; a row owned by another user
|
||||||
|
// means cross-account identifier reuse — refuse it.
|
||||||
|
if !existing.UserID.Valid || existing.UserID.Bytes != pgUserID.Bytes {
|
||||||
|
return existing, false, errIdentifierOwnedByOther
|
||||||
|
}
|
||||||
|
updated, err := h.db.UpdateDeviceAuthToken(ctx,
|
||||||
|
database.UpdateDeviceAuthTokenParams{
|
||||||
|
ID: pgtype.UUID{Bytes: existing.ID.Bytes, Valid: true},
|
||||||
|
AuthToken: authToken,
|
||||||
|
})
|
||||||
|
return updated, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := h.db.GetDeviceByIdentifier(ctx, registration.DeviceIdentifier)
|
||||||
|
var device database.Devices
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
// Known device: rotate the token, keep the row (id unchanged —
|
||||||
|
// highlights/bookmarks/progress anchored to it stay valid).
|
||||||
|
device, _, err = rotateExisting(existing)
|
||||||
|
if err != nil {
|
||||||
|
return database.Devices{}, "", nil, err
|
||||||
|
}
|
||||||
|
case errors.Is(err, pgx.ErrNoRows):
|
||||||
|
device, err = h.db.CreateDevice(ctx, database.CreateDeviceParams{
|
||||||
|
UserID: pgUserID,
|
||||||
|
DeviceName: registration.DeviceName,
|
||||||
|
DeviceType: registration.DeviceType,
|
||||||
|
DeviceIdentifier: registration.DeviceIdentifier,
|
||||||
|
AuthToken: authToken,
|
||||||
|
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||||
|
DeviceMetadata: []byte("{}"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// Concurrent approve racing us onto the unique index: fall
|
||||||
|
// through to the rotate path for the winner's row.
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if asErr := errors.As(err, &pgErr); asErr && pgErr.Code == "23505" {
|
||||||
|
if winner, lerr := h.db.GetDeviceByIdentifier(ctx, registration.DeviceIdentifier); lerr == nil {
|
||||||
|
device, _, err = rotateExisting(winner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return database.Devices{}, "", nil, fmt.Errorf("create device: %w", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return database.Devices{}, "", nil, fmt.Errorf("look up device: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncEndpoints := map[string]string{}
|
||||||
|
switch registration.DeviceType {
|
||||||
|
case "koreader":
|
||||||
|
syncEndpoints = koreaderSyncEndpoints(h.cfg.BaseURL)
|
||||||
|
case "kobo":
|
||||||
|
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
|
||||||
|
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return device, authToken, syncEndpoints, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||||
userID := c.Get("user_id").(string)
|
userID := c.Get("user_id").(string)
|
||||||
userUUID, err := uuid.Parse(userID)
|
userUUID, err := uuid.Parse(userID)
|
||||||
@@ -552,103 +794,18 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
pendingMu.Unlock()
|
pendingMu.Unlock()
|
||||||
|
|
||||||
authToken, err := generateDeviceToken()
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
|
|
||||||
}
|
|
||||||
|
|
||||||
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
||||||
|
device, authToken, syncEndpoints, err := h.bindRegistrationToUser(c.Request().Context(), registration, pgUserID)
|
||||||
// Re-approval: app reinstalls that preserve data (e.g. Android Studio
|
|
||||||
// installDebug over an existing install) re-register with the SAME
|
|
||||||
// device_identifier, but the devices row from the previous install
|
|
||||||
// still exists — device_identifier is UNIQUE, so a blind INSERT fails
|
|
||||||
// with a unique violation (the historical "approve returns 500 after
|
|
||||||
// reinstall" bug). Look the device up first and rotate its token
|
|
||||||
// instead; a fresh install MUST get fresh credentials, so the old
|
|
||||||
// token is invalidated either way.
|
|
||||||
rotateExisting := func(existing database.Devices) (database.Devices, bool, error) {
|
|
||||||
// The identifier is globally unique; a row owned by another user
|
|
||||||
// means cross-account identifier reuse — refuse it.
|
|
||||||
if !existing.UserID.Valid || existing.UserID.Bytes != pgUserID.Bytes {
|
|
||||||
return existing, false, nil
|
|
||||||
}
|
|
||||||
updated, err := h.db.UpdateDeviceAuthToken(c.Request().Context(),
|
|
||||||
database.UpdateDeviceAuthTokenParams{
|
|
||||||
ID: pgtype.UUID{Bytes: existing.ID.Bytes, Valid: true},
|
|
||||||
AuthToken: authToken,
|
|
||||||
})
|
|
||||||
return updated, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
existing, err := h.db.GetDeviceByIdentifier(c.Request().Context(),
|
|
||||||
registration.DeviceIdentifier)
|
|
||||||
var device database.Devices
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
// Known device: rotate the token, keep the row (id unchanged —
|
|
||||||
// highlights/bookmarks/progress anchored to it stay valid).
|
|
||||||
var ok bool
|
|
||||||
device, ok, err = rotateExisting(existing)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("device re-approval failed",
|
if errors.Is(err, errIdentifierOwnedByOther) {
|
||||||
|
return c.JSON(http.StatusConflict,
|
||||||
|
map[string]string{"error": "device identifier already registered to another user"})
|
||||||
|
}
|
||||||
|
slog.Error("device approval bind failed",
|
||||||
"registration_id", registrationID, "error", err)
|
"registration_id", registrationID, "error", err)
|
||||||
return c.JSON(http.StatusInternalServerError,
|
return c.JSON(http.StatusInternalServerError,
|
||||||
map[string]string{"error": "failed to approve device"})
|
map[string]string{"error": "failed to approve device"})
|
||||||
}
|
}
|
||||||
if !ok {
|
|
||||||
return c.JSON(http.StatusConflict,
|
|
||||||
map[string]string{"error": "device identifier already registered to another user"})
|
|
||||||
}
|
|
||||||
case errors.Is(err, pgx.ErrNoRows):
|
|
||||||
device, err = h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
|
||||||
UserID: pgUserID,
|
|
||||||
DeviceName: registration.DeviceName,
|
|
||||||
DeviceType: registration.DeviceType,
|
|
||||||
DeviceIdentifier: registration.DeviceIdentifier,
|
|
||||||
AuthToken: authToken,
|
|
||||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
|
||||||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
|
||||||
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
|
||||||
DeviceMetadata: []byte("{}"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
// Concurrent approve racing us onto the unique index: fall
|
|
||||||
// through to the rotate path for the winner's row.
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if asErr := errors.As(err, &pgErr); asErr && pgErr.Code == "23505" {
|
|
||||||
if winner, lerr := h.db.GetDeviceByIdentifier(
|
|
||||||
c.Request().Context(), registration.DeviceIdentifier); lerr == nil {
|
|
||||||
var ok bool
|
|
||||||
device, ok, err = rotateExisting(winner)
|
|
||||||
if err == nil && !ok {
|
|
||||||
return c.JSON(http.StatusConflict,
|
|
||||||
map[string]string{"error": "device identifier already registered to another user"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("device creation failed",
|
|
||||||
"registration_id", registrationID, "error", err)
|
|
||||||
return c.JSON(http.StatusInternalServerError,
|
|
||||||
map[string]string{"error": "failed to create device"})
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
slog.Error("device lookup failed",
|
|
||||||
"registration_id", registrationID, "error", err)
|
|
||||||
return c.JSON(http.StatusInternalServerError,
|
|
||||||
map[string]string{"error": "failed to look up device"})
|
|
||||||
}
|
|
||||||
|
|
||||||
syncEndpoints := map[string]string{}
|
|
||||||
switch registration.DeviceType {
|
|
||||||
case "koreader":
|
|
||||||
syncEndpoints = koreaderSyncEndpoints(h.cfg.BaseURL)
|
|
||||||
case "kobo":
|
|
||||||
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
|
|
||||||
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish the credentials under the map lock: the status poller
|
// Publish the credentials under the map lock: the status poller
|
||||||
// snapshots these fields only after Approved flips, so the token can
|
// snapshots these fields only after Approved flips, so the token can
|
||||||
@@ -670,73 +827,183 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
|
// errPairingRateLimited marks an exhausted per-account attempt budget.
|
||||||
registrationID := c.Param("registration_id")
|
var errPairingRateLimited = errors.New("too many pairing attempts")
|
||||||
|
|
||||||
pendingMu.Lock()
|
// resolvePairingCode is the shared lookup core for the JSON API and the
|
||||||
_, exists := pendingRegistrations[registrationID]
|
// SSR confirm-card partial: apply the per-account budget, normalize the
|
||||||
if !exists {
|
// typed code, resolve to a live unclaimed registration.
|
||||||
pendingMu.Unlock()
|
func resolvePairingCode(userID, ip, rawCode string) (*PendingRegistration, error) {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
if !claimAllowed(userID, ip) {
|
||||||
|
return nil, errPairingRateLimited
|
||||||
|
}
|
||||||
|
return registrationByPairingCode(normalizePairingCode(rawCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(pendingRegistrations, registrationID)
|
// pairingHTTPError maps a resolvePairingCode error to status + message.
|
||||||
pendingMu.Unlock()
|
// The invalid-code case is deliberately generic: it must not reveal
|
||||||
|
// whether any registration is waiting.
|
||||||
|
func pairingHTTPError(err error) (int, string) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errPairingRateLimited):
|
||||||
|
return http.StatusTooManyRequests, "too many pairing attempts; try again in a minute"
|
||||||
|
case errors.Is(err, errPairingExpired):
|
||||||
|
return http.StatusGone, "pairing code expired — register the device again"
|
||||||
|
case errors.Is(err, errPairingClaimed):
|
||||||
|
return http.StatusConflict, "pairing code already used"
|
||||||
|
default:
|
||||||
|
return http.StatusBadRequest, "invalid pairing code"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]string{
|
// ResolvePairingCode is the exported lookup core for the router's SSR
|
||||||
"message": "device registration rejected",
|
// confirm-card partial (the router renders templ; handlers stay JSON).
|
||||||
|
func ResolvePairingCode(userID, ip, rawCode string) (*PendingRegistration, error) {
|
||||||
|
return resolvePairingCode(userID, ip, rawCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PairingErrorStatus maps a ResolvePairingCode error to the status key
|
||||||
|
// consumed by templates.PairingLookupResult.
|
||||||
|
func PairingErrorStatus(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errPairingRateLimited):
|
||||||
|
return "rate_limited"
|
||||||
|
case errors.Is(err, errPairingExpired):
|
||||||
|
return "expired"
|
||||||
|
case errors.Is(err, errPairingClaimed):
|
||||||
|
return "claimed"
|
||||||
|
default:
|
||||||
|
return "invalid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupPairingCode resolves a pairing code to its pending registration
|
||||||
|
// WITHOUT binding it — the confirm card's data. Lookups and claims share
|
||||||
|
// the per-account attempt budget.
|
||||||
|
func (h *DeviceHandler) LookupPairingCode(c *echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
|
||||||
|
req := PairingCodeRequest{}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
|
||||||
|
}
|
||||||
|
|
||||||
|
registration, err := resolvePairingCode(userID, c.RealIP(), req.PairingCode)
|
||||||
|
if err != nil {
|
||||||
|
status, msg := pairingHTTPError(err)
|
||||||
|
return c.JSON(status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, PairingLookupResponse{
|
||||||
|
DeviceName: registration.DeviceName,
|
||||||
|
DeviceType: registration.DeviceType,
|
||||||
|
DeviceIdentifier: registration.DeviceIdentifier,
|
||||||
|
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
// ClaimDevice binds the registration behind a pairing code to the
|
||||||
registrations := []map[string]interface{}{}
|
// CALLER's account — the web-side replacement for the broadcast-list
|
||||||
pendingMu.Lock()
|
// approve. The device's own poll (registration UUID bearer) then delivers
|
||||||
for _, reg := range pendingRegistrations {
|
// the fresh token to the plugin, exactly as in the old approve flow.
|
||||||
if reg.UserID == (uuid.UUID{}) {
|
func (h *DeviceHandler) ClaimDevice(c *echo.Context) error {
|
||||||
registrations = append(registrations, map[string]interface{}{
|
|
||||||
"registration_id": reg.RegistrationID,
|
|
||||||
"device_name": reg.DeviceName,
|
|
||||||
"device_type": reg.DeviceType,
|
|
||||||
"device_identifier": reg.DeviceIdentifier,
|
|
||||||
"expires_at": reg.ExpiresAt,
|
|
||||||
"created_at": reg.CreatedAt,
|
|
||||||
"is_approved": false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pendingMu.Unlock()
|
|
||||||
|
|
||||||
return registrations, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *DeviceHandler) ListPendingRegistrations(c *echo.Context) error {
|
|
||||||
userID := c.Get("user_id").(string)
|
userID := c.Get("user_id").(string)
|
||||||
userUUID, err := uuid.Parse(userID)
|
userUUID, err := uuid.Parse(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID"})
|
||||||
}
|
}
|
||||||
|
|
||||||
registrations := []map[string]interface{}{}
|
if !claimAllowed(userID, c.RealIP()) {
|
||||||
|
return c.JSON(http.StatusTooManyRequests, map[string]string{"error": "too many pairing attempts; try again in a minute"})
|
||||||
|
}
|
||||||
|
|
||||||
|
req := PairingCodeRequest{}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
|
||||||
|
}
|
||||||
|
|
||||||
|
registration, err := resolvePairingCode(userID, c.RealIP(), req.PairingCode)
|
||||||
|
if err != nil {
|
||||||
|
status, msg := pairingHTTPError(err)
|
||||||
|
return c.JSON(status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
||||||
|
device, authToken, syncEndpoints, err := h.bindRegistrationToUser(c.Request().Context(), registration, pgUserID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errIdentifierOwnedByOther) {
|
||||||
|
return c.JSON(http.StatusConflict,
|
||||||
|
map[string]string{"error": "device identifier already registered to another user"})
|
||||||
|
}
|
||||||
|
slog.Error("device claim bind failed", "error", err)
|
||||||
|
return c.JSON(http.StatusInternalServerError,
|
||||||
|
map[string]string{"error": "failed to pair device"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish under the map lock: the device's status poller snapshots
|
||||||
|
// these fields only after Approved flips, so the token can never be
|
||||||
|
// read half-written.
|
||||||
pendingMu.Lock()
|
pendingMu.Lock()
|
||||||
for _, reg := range pendingRegistrations {
|
registration.UserID = userUUID
|
||||||
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
|
registration.Approved = true
|
||||||
registrations = append(registrations, map[string]interface{}{
|
registration.AuthToken = authToken
|
||||||
"registration_id": reg.RegistrationID,
|
registration.DeviceID = device.ID.Bytes
|
||||||
"device_name": reg.DeviceName,
|
registration.SyncEndpoints = syncEndpoints
|
||||||
"device_type": reg.DeviceType,
|
pendingMu.Unlock()
|
||||||
"device_identifier": reg.DeviceIdentifier,
|
|
||||||
"expires_at": reg.ExpiresAt,
|
// HTMX confirm button: htmx reloads the page natively (CSP — no
|
||||||
"created_at": reg.CreatedAt,
|
// inline scripts in the partial).
|
||||||
"is_approved": reg.UserID != (uuid.UUID{}),
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
|
c.Response().Header().Set("HX-Refresh", "true")
|
||||||
|
return c.HTML(http.StatusOK, `<div class="text-green-500">Paired! Reloading …</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "device paired successfully",
|
||||||
|
"device_name": registration.DeviceName,
|
||||||
|
"device_type": registration.DeviceType,
|
||||||
|
"approved": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CancelRegistration lets the REGISTERING device end its own pairing
|
||||||
|
// attempt (dismissed waiting dialog). The registration UUID is the
|
||||||
|
// bearer — same trust as the status poll — so a device can only ever
|
||||||
|
// cancel its own registration, and a claimed one is left alone (its
|
||||||
|
// poller is mid-delivery of the token).
|
||||||
|
func (h *DeviceHandler) CancelRegistration(c *echo.Context) error {
|
||||||
|
req := CancelRegistrationRequest{}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingMu.Lock()
|
||||||
|
if reg, exists := pendingRegistrations[req.RegistrationID]; exists && !reg.Approved {
|
||||||
|
delete(pendingRegistrations, req.RegistrationID)
|
||||||
}
|
}
|
||||||
pendingMu.Unlock()
|
pendingMu.Unlock()
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "registration cancelled"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPendingRegistrationsData returns nothing, deliberately. Unclaimed
|
||||||
|
// registrations are invisible 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. Claiming happens by typing the
|
||||||
|
// code shown on the registering device's screen. Kept as an always-empty
|
||||||
|
// shape so API consumers (and the Android app's docs) keep compiling.
|
||||||
|
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
||||||
|
return []map[string]interface{}{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPendingRegistrations — see GetPendingRegistrationsData: the
|
||||||
|
// broadcast is dead; unclaimed registrations belong to no one's list.
|
||||||
|
func (h *DeviceHandler) ListPendingRegistrations(c *echo.Context) error {
|
||||||
|
registrations := []map[string]interface{}{}
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"registrations": registrations,
|
"registrations": registrations,
|
||||||
"pending_registrations": registrations, // Fixed: Add for test compatibility and API clarity
|
"pending_registrations": registrations, // test/API-shape compatibility
|
||||||
"total": len(registrations),
|
"total": 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGeneratePairingCodeShape(t *testing.T) {
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
code, err := generatePairingCode()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, code, pairingCodeDigits)
|
||||||
|
assert.Regexp(t, `^\d{6}$`, code, "codes are 6 digits")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratePairingCodeAvoidsLiveCollisions(t *testing.T) {
|
||||||
|
reg := &PendingRegistration{RegistrationID: uuid.NewString(), PairingCode: "000000"}
|
||||||
|
pendingMu.Lock()
|
||||||
|
pendingRegistrations[reg.RegistrationID] = reg
|
||||||
|
pendingMu.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pendingMu.Lock()
|
||||||
|
delete(pendingRegistrations, reg.RegistrationID)
|
||||||
|
pendingMu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
code, err := generatePairingCode()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, "000000", code, "mint must not collide with a live registration's code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePairingCode(t *testing.T) {
|
||||||
|
assert.Equal(t, "583912", normalizePairingCode("583 912"), "device shows spaced digits")
|
||||||
|
assert.Equal(t, "583912", normalizePairingCode("583-912"))
|
||||||
|
assert.Equal(t, "583912", normalizePairingCode("583912"))
|
||||||
|
assert.Equal(t, "583912", normalizePairingCode(" 58 39 12 "), "stray whitespace tolerated")
|
||||||
|
assert.Equal(t, "", normalizePairingCode("no digits here"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistrationByPairingCodeScopes(t *testing.T) {
|
||||||
|
live := &PendingRegistration{
|
||||||
|
RegistrationID: uuid.NewString(),
|
||||||
|
PairingCode: "111111",
|
||||||
|
DeviceName: "Live Device",
|
||||||
|
ExpiresAt: time.Now().Add(time.Minute),
|
||||||
|
}
|
||||||
|
expired := &PendingRegistration{
|
||||||
|
RegistrationID: uuid.NewString(),
|
||||||
|
PairingCode: "222222",
|
||||||
|
ExpiresAt: time.Now().Add(-time.Minute),
|
||||||
|
}
|
||||||
|
claimed := &PendingRegistration{
|
||||||
|
RegistrationID: uuid.NewString(),
|
||||||
|
PairingCode: "333333",
|
||||||
|
ExpiresAt: time.Now().Add(time.Minute),
|
||||||
|
Approved: true,
|
||||||
|
}
|
||||||
|
pendingMu.Lock()
|
||||||
|
for _, reg := range []*PendingRegistration{live, expired, claimed} {
|
||||||
|
pendingRegistrations[reg.RegistrationID] = reg
|
||||||
|
}
|
||||||
|
pendingMu.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pendingMu.Lock()
|
||||||
|
for _, reg := range []*PendingRegistration{live, expired, claimed} {
|
||||||
|
delete(pendingRegistrations, reg.RegistrationID)
|
||||||
|
}
|
||||||
|
pendingMu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Live code resolves.
|
||||||
|
got, err := registrationByPairingCode("111111")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Live Device", got.DeviceName)
|
||||||
|
|
||||||
|
// Expired code is reaped on sight.
|
||||||
|
_, err = registrationByPairingCode("222222")
|
||||||
|
assert.ErrorIs(t, err, errPairingExpired)
|
||||||
|
pendingMu.Lock()
|
||||||
|
_, stillThere := pendingRegistrations[expired.RegistrationID]
|
||||||
|
pendingMu.Unlock()
|
||||||
|
assert.False(t, stillThere, "expired registration must be reaped")
|
||||||
|
|
||||||
|
// Claimed code is refused.
|
||||||
|
_, err = registrationByPairingCode("333333")
|
||||||
|
assert.ErrorIs(t, err, errPairingClaimed)
|
||||||
|
|
||||||
|
// Unknown code is the generic miss.
|
||||||
|
_, err = registrationByPairingCode("999999")
|
||||||
|
assert.ErrorIs(t, err, errPairingInvalid)
|
||||||
|
}
|
||||||
@@ -21,12 +21,14 @@ type DeviceRateLimitConfig struct {
|
|||||||
SyncRequestsPerMinute int
|
SyncRequestsPerMinute int
|
||||||
ProgressUpdatesPerMinute int
|
ProgressUpdatesPerMinute int
|
||||||
MetadataRequestsPerMinute int
|
MetadataRequestsPerMinute int
|
||||||
|
ClaimAttemptsPerMinute int
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultSyncRequestsPerMinute = 60
|
DefaultSyncRequestsPerMinute = 60
|
||||||
DefaultProgressUpdatesPerMinute = 120
|
DefaultProgressUpdatesPerMinute = 120
|
||||||
DefaultMetadataRequestsPerMinute = 30
|
DefaultMetadataRequestsPerMinute = 30
|
||||||
|
DefaultClaimAttemptsPerMinute = 12
|
||||||
CleanupInterval = 5 * time.Minute
|
CleanupInterval = 5 * time.Minute
|
||||||
RequestWindow = 1 * time.Minute
|
RequestWindow = 1 * time.Minute
|
||||||
)
|
)
|
||||||
@@ -67,13 +69,24 @@ func (drl *DeviceRateLimiter) CheckRateLimit(deviceID string, requestType string
|
|||||||
limit = config.ProgressUpdatesPerMinute
|
limit = config.ProgressUpdatesPerMinute
|
||||||
case "metadata":
|
case "metadata":
|
||||||
limit = config.MetadataRequestsPerMinute
|
limit = config.MetadataRequestsPerMinute
|
||||||
|
case "claim":
|
||||||
|
limit = config.ClaimAttemptsPerMinute
|
||||||
default:
|
default:
|
||||||
limit = DefaultSyncRequestsPerMinute
|
limit = DefaultSyncRequestsPerMinute
|
||||||
}
|
}
|
||||||
|
|
||||||
if limit == 0 {
|
if limit == 0 {
|
||||||
|
switch requestType {
|
||||||
|
case "progress":
|
||||||
|
limit = DefaultProgressUpdatesPerMinute
|
||||||
|
case "metadata":
|
||||||
|
limit = DefaultMetadataRequestsPerMinute
|
||||||
|
case "claim":
|
||||||
|
limit = DefaultClaimAttemptsPerMinute
|
||||||
|
default:
|
||||||
limit = DefaultSyncRequestsPerMinute
|
limit = DefaultSyncRequestsPerMinute
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
info.Requests = append(info.Requests, now)
|
info.Requests = append(info.Requests, now)
|
||||||
info.LastSeen = now
|
info.LastSeen = now
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ func registerDeviceRoutes(cfg *Config) {
|
|||||||
// Public device registration routes (no auth required)
|
// Public device registration routes (no auth required)
|
||||||
e.POST("/api/devices/register", cfg.DeviceHandler.InitiateRegistration)
|
e.POST("/api/devices/register", cfg.DeviceHandler.InitiateRegistration)
|
||||||
e.POST("/api/devices/register/status", cfg.DeviceHandler.CheckRegistrationStatus)
|
e.POST("/api/devices/register/status", cfg.DeviceHandler.CheckRegistrationStatus)
|
||||||
|
// The REGISTERING device may end its own pairing attempt (dismissed
|
||||||
|
// waiting dialog); bearer is the registration UUID it holds, same
|
||||||
|
// trust as the status poll.
|
||||||
|
e.POST("/api/devices/register/cancel", cfg.DeviceHandler.CancelRegistration)
|
||||||
|
|
||||||
// Device management routes (protected)
|
// Device management routes (protected)
|
||||||
devices.GET("", cfg.DeviceHandler.ListDevices)
|
devices.GET("", cfg.DeviceHandler.ListDevices)
|
||||||
@@ -25,7 +29,10 @@ func registerDeviceRoutes(cfg *Config) {
|
|||||||
|
|
||||||
devices.GET("/pending", cfg.DeviceHandler.ListPendingRegistrations)
|
devices.GET("/pending", cfg.DeviceHandler.ListPendingRegistrations)
|
||||||
devices.GET("/approve/:registration_id", cfg.DeviceHandler.ApproveDevice)
|
devices.GET("/approve/:registration_id", cfg.DeviceHandler.ApproveDevice)
|
||||||
devices.POST("/reject/:registration_id", cfg.DeviceHandler.RejectDevice)
|
// Pairing-code claim flow (JWT): lookup is the confirm-card data,
|
||||||
|
// claim binds the device to the caller's account.
|
||||||
|
devices.POST("/claim/lookup", cfg.DeviceHandler.LookupPairingCode)
|
||||||
|
devices.POST("/claim", cfg.DeviceHandler.ClaimDevice)
|
||||||
|
|
||||||
// Device sidecar configuration routes
|
// Device sidecar configuration routes
|
||||||
devices.GET("/:id/sidecar", cfg.SidecarHandler.GetSidecarConfig)
|
devices.GET("/:id/sidecar", cfg.SidecarHandler.GetSidecarConfig)
|
||||||
|
|||||||
Reference in new issue
Block a user