feat(devices): pairing codes replace the registration broadcast
A pending device registration used to be announced to EVERY account's Devices page, and whoever clicked Approve first bound the device — and its freshly minted sync token — to their account. Worse, the pending card displayed the registration UUID, which is also the bearer credential on the unauthenticated /api/devices/register/status poll channel: any family user who could see the list already held everything needed to take the device. On a family server that is a wrong-account-binding bug (accidental or deliberate); this commit removes the class of problem rather than patching the race. The new model is the Netflix/YouTube TV device flow: the registering device displays a 6-digit pairing code, and the only person who can read it — whoever holds the device — types it into THEIR account on the Devices page. Possession of the device screen becomes the credential; nothing is broadcast; the reject-on-someone-else's-behalf hole dies with the list. Server changes: - POST /api/devices/register now mints pairing_code (crypto/rand, rejection-sampled for uniformity, collision-checked against live registrations) and retires prior unclaimed registrations with the same device_identifier, so re-registering leaves exactly one live attempt. - POST /api/devices/claim (JWT) binds the registration behind a code to the caller's account, reusing the rotate-or-create approval logic factored out of ApproveDevice (shared bindRegistrationToUser helper; the Android self-approval route keeps working unchanged). - POST /api/devices/claim/lookup (JWT) returns the confirm-card data without binding. Lookup and claim share a per-account budget (12 attempts/min, DeviceRateLimiter gained a claim case) — the 1M-code space over a 5-minute TTL makes guessing infeasible without it. - POST /api/devices/register/cancel lets the registering device end its own attempt (registration UUID bearer, idempotent, never touches a claimed registration). - ListPendingRegistrations / GetPendingRegistrationsData are deprecated-by-emptiness: unclaimed registrations return to nobody. - RejectDevice and its route are deleted — the broadcast list was its only consumer, and there is nothing left for anyone to reject. Additive-only for pinned surfaces: the register response gains pairing_code (Android DTO untouched), the four KOReader sync surfaces and approve keep byte-identical shapes. contract_pins.md changelog records the widening and the reject removal. Docs, schema registry, and bruno mirrors move together per the drift gate; bruno_gen.go now keeps hand-authored bruno/podcasts/ (API docs never existed for them, so the next -update run would have swept them — latent landmine, defused). Unit tests: code shape, live-collision avoidance, typed-code normalization, and the resolve scopes (live/expired/claimed/miss).
This commit is contained in:
1 parent
ab5c52c709
commit
8fbacd9f46
47 files changed
+935
-273
No files matched your search
+24
-5
@@ -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(`{
|
||||
|
||||
Reference in new issue
Block a user