diff --git a/bruno/collections/bulk_add_books.yml b/bruno/collections/bulk_add_books.yml index 43730ed8..48358bfb 100644 --- a/bruno/collections/bulk_add_books.yml +++ b/bruno/collections/bulk_add_books.yml @@ -1,7 +1,7 @@ info: name: Bulk Add Books type: http - seq: 2 + seq: 1 http: method: POST diff --git a/bruno/collections/bulk_assign.yml b/bruno/collections/bulk_assign.yml index ca98f446..eb859339 100644 --- a/bruno/collections/bulk_assign.yml +++ b/bruno/collections/bulk_assign.yml @@ -1,7 +1,7 @@ info: name: Bulk Assign Books type: http - seq: 3 + seq: 2 http: method: POST diff --git a/bruno/collections/bulk_remove_books.yml b/bruno/collections/bulk_remove_books.yml index 8fbd747d..bc246038 100644 --- a/bruno/collections/bulk_remove_books.yml +++ b/bruno/collections/bulk_remove_books.yml @@ -1,7 +1,7 @@ info: name: Bulk Remove Books type: http - seq: 4 + seq: 3 http: method: POST diff --git a/bruno/collections/create_collection.yml b/bruno/collections/create_collection.yml index 62a74e94..a693d948 100644 --- a/bruno/collections/create_collection.yml +++ b/bruno/collections/create_collection.yml @@ -1,7 +1,7 @@ info: name: Create Collection type: http - seq: 5 + seq: 4 http: method: POST diff --git a/bruno/collections/create_shelf_mapping.yml b/bruno/collections/create_shelf_mapping.yml index e93fa5f3..f6a89592 100644 --- a/bruno/collections/create_shelf_mapping.yml +++ b/bruno/collections/create_shelf_mapping.yml @@ -1,7 +1,7 @@ info: name: Create Shelf Mapping type: http - seq: 6 + seq: 5 http: method: POST diff --git a/bruno/collections/delete_collection.yml b/bruno/collections/delete_collection.yml index d5ff6ca5..69bb40e6 100644 --- a/bruno/collections/delete_collection.yml +++ b/bruno/collections/delete_collection.yml @@ -1,7 +1,7 @@ info: name: Delete Collection type: http - seq: 7 + seq: 6 http: method: DELETE diff --git a/bruno/collections/delete_shelf_mapping.yml b/bruno/collections/delete_shelf_mapping.yml index 3f79c0ef..a62b15ef 100644 --- a/bruno/collections/delete_shelf_mapping.yml +++ b/bruno/collections/delete_shelf_mapping.yml @@ -1,7 +1,7 @@ info: name: Delete Shelf Mapping type: http - seq: 8 + seq: 7 http: method: DELETE diff --git a/bruno/collections/get_collection.yml b/bruno/collections/get_collection.yml index 48027b6d..2369ec08 100644 --- a/bruno/collections/get_collection.yml +++ b/bruno/collections/get_collection.yml @@ -1,7 +1,7 @@ info: name: Get Collection type: http - seq: 9 + seq: 8 http: method: GET diff --git a/bruno/collections/get_collection_books.yml b/bruno/collections/get_collection_books.yml index a295c9ef..2ddc8865 100644 --- a/bruno/collections/get_collection_books.yml +++ b/bruno/collections/get_collection_books.yml @@ -1,7 +1,7 @@ info: name: Get Book Collections type: http - seq: 10 + seq: 9 http: method: GET diff --git a/bruno/collections/list_collections.yml b/bruno/collections/list_collections.yml index 02c780c2..2e63a450 100644 --- a/bruno/collections/list_collections.yml +++ b/bruno/collections/list_collections.yml @@ -1,7 +1,7 @@ info: name: List Collections type: http - seq: 11 + seq: 10 http: method: GET diff --git a/bruno/collections/preview_collection.yml b/bruno/collections/preview_collection.yml index 474488c3..70fb18e5 100644 --- a/bruno/collections/preview_collection.yml +++ b/bruno/collections/preview_collection.yml @@ -1,7 +1,7 @@ info: name: Preview Collection type: http - seq: 12 + seq: 11 http: method: POST diff --git a/bruno/collections/reorder_items.yml b/bruno/collections/reorder_items.yml index 09d93225..0bc6e76f 100644 --- a/bruno/collections/reorder_items.yml +++ b/bruno/collections/reorder_items.yml @@ -1,7 +1,7 @@ info: name: Reorder Collection Items type: http - seq: 14 + seq: 12 http: method: PUT diff --git a/bruno/collections/update_collection.yml b/bruno/collections/update_collection.yml index 0b762094..3c5d1a40 100644 --- a/bruno/collections/update_collection.yml +++ b/bruno/collections/update_collection.yml @@ -1,7 +1,7 @@ info: name: Update Collection type: http - seq: 16 + seq: 14 http: method: PUT diff --git a/bruno/devices/reject_device_registration.yml b/bruno/devices/cancel_device_registration.yml similarity index 56% rename from bruno/devices/reject_device_registration.yml rename to bruno/devices/cancel_device_registration.yml index 5683c95b..adca8db1 100644 --- a/bruno/devices/reject_device_registration.yml +++ b/bruno/devices/cancel_device_registration.yml @@ -1,12 +1,18 @@ info: - name: Reject Device Registration + name: Cancel Device Registration type: http - seq: 16 + seq: 3 http: method: POST - url: '{{base_url}}/api/devices/reject/{{registration_id}}' + url: '{{base_url}}/api/devices/register/cancel' auth: inherit + body: + type: json + data: |- + { + "registration_id": "550e8400-e29b-41d4-a716-446655440000" + } settings: encodeUrl: true timeout: 0 @@ -23,9 +29,9 @@ runtime: docs: content: |- - # Reject Device Registration + # Cancel Device Registration - `POST /api/devices/reject/:registration_id` — auth: jwt. + `POST /api/devices/register/cancel` — auth: none. - Canonical reference: docs/developer/api/devices/reject_device_registration.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update. + Canonical reference: docs/developer/api/devices/cancel_device_registration.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update. type: text/markdown diff --git a/bruno/devices/claim_device.yml b/bruno/devices/claim_device.yml new file mode 100644 index 00000000..3f4750e2 --- /dev/null +++ b/bruno/devices/claim_device.yml @@ -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 diff --git a/bruno/devices/clear_shelf.yml b/bruno/devices/clear_shelf.yml index f82b0981..2ed4142a 100644 --- a/bruno/devices/clear_shelf.yml +++ b/bruno/devices/clear_shelf.yml @@ -1,7 +1,7 @@ info: name: Clear Shelf type: http - seq: 3 + seq: 5 http: method: DELETE diff --git a/bruno/devices/delete_device.yml b/bruno/devices/delete_device.yml index 285a7522..22b74227 100644 --- a/bruno/devices/delete_device.yml +++ b/bruno/devices/delete_device.yml @@ -1,7 +1,7 @@ info: name: Delete Device type: http - seq: 4 + seq: 6 http: method: DELETE diff --git a/bruno/devices/download_sidecar_config.yml b/bruno/devices/download_sidecar_config.yml index 9bf6292c..41d0c65b 100644 --- a/bruno/devices/download_sidecar_config.yml +++ b/bruno/devices/download_sidecar_config.yml @@ -1,7 +1,7 @@ info: name: Download Sidecar Config type: http - seq: 5 + seq: 7 http: method: GET diff --git a/bruno/devices/get_device.yml b/bruno/devices/get_device.yml index 5848703f..2a2c8e20 100644 --- a/bruno/devices/get_device.yml +++ b/bruno/devices/get_device.yml @@ -1,7 +1,7 @@ info: name: Get Device type: http - seq: 6 + seq: 8 http: method: GET diff --git a/bruno/devices/get_device_mappings.yml b/bruno/devices/get_device_mappings.yml index ae4e5dc3..b6c80e56 100644 --- a/bruno/devices/get_device_mappings.yml +++ b/bruno/devices/get_device_mappings.yml @@ -1,7 +1,7 @@ info: name: Get Device Collection Mappings type: http - seq: 7 + seq: 9 http: method: GET diff --git a/bruno/devices/get_devices.yml b/bruno/devices/get_devices.yml index 49375408..b60869d6 100644 --- a/bruno/devices/get_devices.yml +++ b/bruno/devices/get_devices.yml @@ -1,7 +1,7 @@ info: name: Get Device / Check Registration Status type: http - seq: 8 + seq: 10 http: method: POST diff --git a/bruno/devices/get_shelf.yml b/bruno/devices/get_shelf.yml index a35e6d33..6233e092 100644 --- a/bruno/devices/get_shelf.yml +++ b/bruno/devices/get_shelf.yml @@ -1,7 +1,7 @@ info: name: Get Shelf type: http - seq: 9 + seq: 11 http: method: GET diff --git a/bruno/devices/get_sidecar_config.yml b/bruno/devices/get_sidecar_config.yml index 8c549859..f7fa193f 100644 --- a/bruno/devices/get_sidecar_config.yml +++ b/bruno/devices/get_sidecar_config.yml @@ -1,7 +1,7 @@ info: name: Get Device Sidecar Config type: http - seq: 10 + seq: 12 http: method: GET diff --git a/bruno/devices/link_book.yml b/bruno/devices/link_book.yml index 615d6587..f0d1f555 100644 --- a/bruno/devices/link_book.yml +++ b/bruno/devices/link_book.yml @@ -1,7 +1,7 @@ info: name: Link Book type: http - seq: 11 + seq: 13 http: method: POST diff --git a/bruno/devices/list_devices.yml b/bruno/devices/list_devices.yml index 0da76069..c92707ca 100644 --- a/bruno/devices/list_devices.yml +++ b/bruno/devices/list_devices.yml @@ -1,7 +1,7 @@ info: name: List User Devices type: http - seq: 12 + seq: 14 http: method: GET diff --git a/bruno/devices/list_pending_registrations.yml b/bruno/devices/list_pending_registrations.yml index f7e00f11..5f943bad 100644 --- a/bruno/devices/list_pending_registrations.yml +++ b/bruno/devices/list_pending_registrations.yml @@ -1,7 +1,7 @@ info: name: List Pending Device Registrations:<|end|><|start|><|start|>assistant to=edit<|channel|>analysis <|constrain|>xml<|message|> type: http - seq: 13 + seq: 15 http: method: GET diff --git a/bruno/devices/lookup_pairing_code.yml b/bruno/devices/lookup_pairing_code.yml new file mode 100644 index 00000000..b8e10e06 --- /dev/null +++ b/bruno/devices/lookup_pairing_code.yml @@ -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 diff --git a/bruno/devices/regenerate_device_token.yml b/bruno/devices/regenerate_device_token.yml index 2b66e0b0..7710c418 100644 --- a/bruno/devices/regenerate_device_token.yml +++ b/bruno/devices/regenerate_device_token.yml @@ -1,7 +1,7 @@ info: name: Regenerate Device Token type: http - seq: 14 + seq: 17 http: method: PUT diff --git a/bruno/devices/register_device.yml b/bruno/devices/register_device.yml index 100d2883..3d41f4db 100644 --- a/bruno/devices/register_device.yml +++ b/bruno/devices/register_device.yml @@ -1,7 +1,7 @@ info: name: Register Device type: http - seq: 15 + seq: 18 http: method: POST diff --git a/bruno/devices/remove_from_shelf.yml b/bruno/devices/remove_from_shelf.yml index d926dd50..c5c06550 100644 --- a/bruno/devices/remove_from_shelf.yml +++ b/bruno/devices/remove_from_shelf.yml @@ -1,7 +1,7 @@ info: name: Remove from Shelf type: http - seq: 17 + seq: 19 http: method: DELETE diff --git a/bruno/devices/update_device.yml b/bruno/devices/update_device.yml index 3ca5513f..32d46bbb 100644 --- a/bruno/devices/update_device.yml +++ b/bruno/devices/update_device.yml @@ -1,7 +1,7 @@ info: name: Update Device type: http - seq: 18 + seq: 20 http: method: PUT diff --git a/bruno/devices/update_device_mapping.yml b/bruno/devices/update_device_mapping.yml index a8e911f3..feb9a8d2 100644 --- a/bruno/devices/update_device_mapping.yml +++ b/bruno/devices/update_device_mapping.yml @@ -1,7 +1,7 @@ info: name: Update Device Collection Mapping type: http - seq: 19 + seq: 21 http: method: PUT diff --git a/docs/contract_pins.md b/docs/contract_pins.md index b329f5b5..a5b6d28d 100644 --- a/docs/contract_pins.md +++ b/docs/contract_pins.md @@ -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. diff --git a/docs/developer/api/api-reference.md b/docs/developer/api/api-reference.md index 45379a84..701656f1 100644 --- a/docs/developer/api/api-reference.md +++ b/docs/developer/api/api-reference.md @@ -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 diff --git a/docs/developer/api/devices/cancel_device_registration.md b/docs/developer/api/devices/cancel_device_registration.md new file mode 100644 index 00000000..d6bb60e6 --- /dev/null +++ b/docs/developer/api/devices/cancel_device_registration.md @@ -0,0 +1,42 @@ +--- +method: POST +path: /api/devices/register/cancel +auth: none +area: devices +--- + + +# 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" +} +``` diff --git a/docs/developer/api/devices/claim_device.md b/docs/developer/api/devices/claim_device.md new file mode 100644 index 00000000..17332172 --- /dev/null +++ b/docs/developer/api/devices/claim_device.md @@ -0,0 +1,66 @@ +--- +method: POST +path: /api/devices/claim +auth: jwt +area: devices +--- + + +# 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 diff --git a/docs/developer/api/devices/list_pending_registrations.md b/docs/developer/api/devices/list_pending_registrations.md index e4b9e4fb..09af2add 100644 --- a/docs/developer/api/devices/list_pending_registrations.md +++ b/docs/developer/api/devices/list_pending_registrations.md @@ -4,21 +4,19 @@ path: /api/devices/pending auth: jwt area: devices --- - + -# List Pending Device Registrations:<|end|><|start|><|start|>assistant to=edit<|channel|>analysis <|constrain|>xml<|message|> -/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 diff --git a/docs/developer/api/devices/lookup_pairing_code.md b/docs/developer/api/devices/lookup_pairing_code.md new file mode 100644 index 00000000..6bbb0d4c --- /dev/null +++ b/docs/developer/api/devices/lookup_pairing_code.md @@ -0,0 +1,51 @@ +--- +method: POST +path: /api/devices/claim/lookup +auth: jwt +area: devices +--- + + +# 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 | diff --git a/docs/developer/api/devices/register_device.md b/docs/developer/api/devices/register_device.md index f6c8b072..222efd7c 100644 --- a/docs/developer/api/devices/register_device.md +++ b/docs/developer/api/devices/register_device.md @@ -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 diff --git a/docs/developer/api/devices/reject_device_registration.md b/docs/developer/api/devices/reject_device_registration.md deleted file mode 100644 index d1392783..00000000 --- a/docs/developer/api/devices/reject_device_registration.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -method: POST -path: /api/devices/reject/:registration_id -auth: jwt -area: devices ---- - - -# 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 | diff --git a/docs/developer/openapi.yaml b/docs/developer/openapi.yaml index afc4d38b..417eece4 100644 --- a/docs/developer/openapi.yaml +++ b/docs/developer/openapi.yaml @@ -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] diff --git a/internal/docs/bruno_gen.go b/internal/docs/bruno_gen.go index 4a2750b5..0d95b21c 100644 --- a/internal/docs/bruno_gen.go +++ b/internal/docs/bruno_gen.go @@ -26,7 +26,7 @@ const docsRoot = "../../docs/developer/api" // brunoKeepDir / brunoKeepSub name the hand-authored parts of the // 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/"} diff --git a/internal/docs/schema.go b/internal/docs/schema.go index 37e7d2fd..6497e310 100644 --- a/internal/docs/schema.go +++ b/internal/docs/schema.go @@ -443,6 +443,25 @@ var Schemas = map[string]Schema{ Request: handlers.DeviceAuthStatusRequest{}, 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 --- "GET /api/media-items": { @@ -664,8 +683,9 @@ var Schemas = map[string]Schema{ // --- Beam batch 8: devices remaining + auth remainder --- "GET /api/devices/pending": { - // Device registrations live in an in-memory map; both key spellings - // are emitted for compatibility. + // Deprecated-by-emptiness: unclaimed registrations are invisible + // since pairing codes took over approval (see POST + // /api/devices/claim). Both key spellings kept for compatibility. Response: json.RawMessage(`{ "registrations": [], "pending_registrations": [], "total": 0 }`), @@ -673,14 +693,13 @@ var Schemas = map[string]Schema{ "GET /api/devices/approve/:registration_id": { // Already-approved variant omits registration_id. Credentials ride // 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(`{ "message": "", "device_name": "", "device_type": "", "registration_id": "", "approved": false }`), }, - "POST /api/devices/reject/:registration_id": { - Response: json.RawMessage(`{"message": ""}`), - }, "POST /api/auth/users": { Request: handlers.RegisterRequest{}, Response: json.RawMessage(`{ diff --git a/internal/handlers/devices.go b/internal/handlers/devices.go index bd793b72..ffe2d77f 100644 --- a/internal/handlers/devices.go +++ b/internal/handlers/devices.go @@ -3,6 +3,8 @@ package handlers import ( "bookhoard/internal/config" "bookhoard/internal/database" + "bookhoard/internal/middleware" + "context" "crypto/rand" "encoding/base64" "encoding/json" @@ -10,6 +12,7 @@ import ( "fmt" "log/slog" "net/http" + "strings" "sync" "time" @@ -47,6 +50,7 @@ type DeviceRegistrationResponse struct { RegistrationID string `json:"registration_id"` AuthURL string `json:"auth_url"` QRCode string `json:"qr_code"` + PairingCode string `json:"pairing_code"` ExpiresIn int `json:"expires_in"` PollInterval int `json:"poll_interval"` SetupInstructions map[string]string `json:"setup_instructions"` @@ -97,11 +101,31 @@ type DeviceApprovalRequest struct { 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 { RegistrationID string DeviceName string DeviceType string DeviceIdentifier string + PairingCode string UserID uuid.UUID ExpiresAt time.Time CreatedAt time.Time @@ -120,6 +144,119 @@ var ( 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 { req := DeviceRegistrationRequest{} if err := c.Bind(&req); err != nil { @@ -133,16 +270,31 @@ func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error { registrationID := uuid.New().String() 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{ RegistrationID: registrationID, DeviceName: req.DeviceName, DeviceType: req.DeviceType, DeviceIdentifier: req.DeviceIdentifier, + PairingCode: pairingCode, ExpiresAt: expiresAt, CreatedAt: time.Now(), } 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 pendingMu.Unlock() @@ -167,6 +319,7 @@ func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error { RegistrationID: registrationID, AuthURL: authURL, QRCode: "data:image/png;base64," + qrCodeBase64, + PairingCode: pairingCode, ExpiresIn: 300, PollInterval: 3, 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 { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) @@ -552,102 +794,17 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error { } 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} - - // 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 { - slog.Error("device re-approval failed", - "registration_id", registrationID, "error", err) - return c.JSON(http.StatusInternalServerError, - map[string]string{"error": "failed to approve device"}) - } - if !ok { + 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"}) } - 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", + slog.Error("device approval bind 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) + map[string]string{"error": "failed to approve device"}) } // Publish the credentials under the map lock: the status poller @@ -670,73 +827,183 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error { }) } -func (h *DeviceHandler) RejectDevice(c *echo.Context) error { - registrationID := c.Param("registration_id") +// errPairingRateLimited marks an exhausted per-account attempt budget. +var errPairingRateLimited = errors.New("too many pairing attempts") - pendingMu.Lock() - _, exists := pendingRegistrations[registrationID] - if !exists { - pendingMu.Unlock() - return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"}) +// resolvePairingCode is the shared lookup core for the JSON API and the +// SSR confirm-card partial: apply the per-account budget, normalize the +// typed code, resolve to a live unclaimed registration. +func resolvePairingCode(userID, ip, rawCode string) (*PendingRegistration, error) { + if !claimAllowed(userID, ip) { + return nil, errPairingRateLimited + } + return registrationByPairingCode(normalizePairingCode(rawCode)) +} + +// pairingHTTPError maps a resolvePairingCode error to status + message. +// 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" + } +} + +// ResolvePairingCode is the exported lookup core for the router's SSR +// 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"}) } - delete(pendingRegistrations, registrationID) - pendingMu.Unlock() + 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, map[string]string{ - "message": "device registration rejected", + 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) { - registrations := []map[string]interface{}{} - pendingMu.Lock() - for _, reg := range pendingRegistrations { - if reg.UserID == (uuid.UUID{}) { - 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 { +// ClaimDevice binds the registration behind a pairing code to the +// CALLER's account — the web-side replacement for the broadcast-list +// approve. The device's own poll (registration UUID bearer) then delivers +// the fresh token to the plugin, exactly as in the old approve flow. +func (h *DeviceHandler) ClaimDevice(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID"}) } - registrations := []map[string]interface{}{} - pendingMu.Lock() - for _, reg := range pendingRegistrations { - if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) { - 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": reg.UserID != (uuid.UUID{}), - }) + 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() + registration.UserID = userUUID + registration.Approved = true + registration.AuthToken = authToken + registration.DeviceID = device.ID.Bytes + registration.SyncEndpoints = syncEndpoints + pendingMu.Unlock() + + // HTMX confirm button: htmx reloads the page natively (CSP — no + // inline scripts in the partial). + if c.Request().Header.Get("HX-Request") == "true" { + c.Response().Header().Set("HX-Refresh", "true") + return c.HTML(http.StatusOK, `
Paired! Reloading …
`) + } + + 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() + 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{}{ "registrations": registrations, - "pending_registrations": registrations, // Fixed: Add for test compatibility and API clarity - "total": len(registrations), + "pending_registrations": registrations, // test/API-shape compatibility + "total": 0, }) } diff --git a/internal/handlers/devices_pairing_test.go b/internal/handlers/devices_pairing_test.go new file mode 100644 index 00000000..f418b187 --- /dev/null +++ b/internal/handlers/devices_pairing_test.go @@ -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) +} diff --git a/internal/middleware/device_rate_limiter.go b/internal/middleware/device_rate_limiter.go index 97445307..1f376238 100644 --- a/internal/middleware/device_rate_limiter.go +++ b/internal/middleware/device_rate_limiter.go @@ -21,12 +21,14 @@ type DeviceRateLimitConfig struct { SyncRequestsPerMinute int ProgressUpdatesPerMinute int MetadataRequestsPerMinute int + ClaimAttemptsPerMinute int } const ( DefaultSyncRequestsPerMinute = 60 DefaultProgressUpdatesPerMinute = 120 DefaultMetadataRequestsPerMinute = 30 + DefaultClaimAttemptsPerMinute = 12 CleanupInterval = 5 * time.Minute RequestWindow = 1 * time.Minute ) @@ -67,12 +69,23 @@ func (drl *DeviceRateLimiter) CheckRateLimit(deviceID string, requestType string limit = config.ProgressUpdatesPerMinute case "metadata": limit = config.MetadataRequestsPerMinute + case "claim": + limit = config.ClaimAttemptsPerMinute default: limit = DefaultSyncRequestsPerMinute } if limit == 0 { - limit = DefaultSyncRequestsPerMinute + switch requestType { + case "progress": + limit = DefaultProgressUpdatesPerMinute + case "metadata": + limit = DefaultMetadataRequestsPerMinute + case "claim": + limit = DefaultClaimAttemptsPerMinute + default: + limit = DefaultSyncRequestsPerMinute + } } info.Requests = append(info.Requests, now) diff --git a/internal/router/device.go b/internal/router/device.go index 503dc83b..9355d121 100644 --- a/internal/router/device.go +++ b/internal/router/device.go @@ -13,6 +13,10 @@ func registerDeviceRoutes(cfg *Config) { // Public device registration routes (no auth required) e.POST("/api/devices/register", cfg.DeviceHandler.InitiateRegistration) 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) devices.GET("", cfg.DeviceHandler.ListDevices) @@ -25,7 +29,10 @@ func registerDeviceRoutes(cfg *Config) { devices.GET("/pending", cfg.DeviceHandler.ListPendingRegistrations) 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 devices.GET("/:id/sidecar", cfg.SidecarHandler.GetSidecarConfig)