Files
bookhoard/internal/middleware/device_rate_limiter.go
T
John O'Keefe 8fbacd9f46 feat(devices): pairing codes replace the registration broadcast
A pending device registration used to be announced to EVERY account's
Devices page, and whoever clicked Approve first bound the device — and
its freshly minted sync token — to their account. Worse, the pending
card displayed the registration UUID, which is also the bearer
credential on the unauthenticated /api/devices/register/status poll
channel: any family user who could see the list already held everything
needed to take the device. On a family server that is a
wrong-account-binding bug (accidental or deliberate); this commit
removes the class of problem rather than patching the race.

The new model is the Netflix/YouTube TV device flow: the registering
device displays a 6-digit pairing code, and the only person who can
read it — whoever holds the device — types it into THEIR account on the
Devices page. Possession of the device screen becomes the credential;
nothing is broadcast; the reject-on-someone-else's-behalf hole dies
with the list.

Server changes:
- POST /api/devices/register now mints pairing_code (crypto/rand,
  rejection-sampled for uniformity, collision-checked against live
  registrations) and retires prior unclaimed registrations with the
  same device_identifier, so re-registering leaves exactly one live
  attempt.
- POST /api/devices/claim (JWT) binds the registration behind a code to
  the caller's account, reusing the rotate-or-create approval logic
  factored out of ApproveDevice (shared bindRegistrationToUser helper;
  the Android self-approval route keeps working unchanged).
- POST /api/devices/claim/lookup (JWT) returns the confirm-card data
  without binding. Lookup and claim share a per-account budget (12
  attempts/min, DeviceRateLimiter gained a claim case) — the 1M-code
  space over a 5-minute TTL makes guessing infeasible without it.
- POST /api/devices/register/cancel lets the registering device end its
  own attempt (registration UUID bearer, idempotent, never touches a
  claimed registration).
- ListPendingRegistrations / GetPendingRegistrationsData are
  deprecated-by-emptiness: unclaimed registrations return to nobody.
- RejectDevice and its route are deleted — the broadcast list was its
  only consumer, and there is nothing left for anyone to reject.

Additive-only for pinned surfaces: the register response gains
pairing_code (Android DTO untouched), the four KOReader sync surfaces
and approve keep byte-identical shapes. contract_pins.md changelog
records the widening and the reject removal. Docs, schema registry, and
bruno mirrors move together per the drift gate; bruno_gen.go now keeps
hand-authored bruno/podcasts/ (API docs never existed for them, so the
next -update run would have swept them — latent landmine, defused).

Unit tests: code shape, live-collision avoidance, typed-code
normalization, and the resolve scopes (live/expired/claimed/miss).
2026-11-02 16:11:03 -05:00

180 lines
3.7 KiB
Go

package middleware
import (
"sync"
"time"
)
type DeviceRateLimiter struct {
requests map[string]*DeviceRequestInfo
mu sync.RWMutex
cleanup *time.Ticker
}
type DeviceRequestInfo struct {
Requests []time.Time
LastSeen time.Time
DeviceID string
}
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
)
func NewDeviceRateLimiter() *DeviceRateLimiter {
limiter := &DeviceRateLimiter{
requests: make(map[string]*DeviceRequestInfo),
cleanup: time.NewTicker(CleanupInterval),
}
go limiter.cleanupOldEntries()
return limiter
}
func (drl *DeviceRateLimiter) CheckRateLimit(deviceID string, requestType string, config DeviceRateLimitConfig) bool {
drl.mu.Lock()
defer drl.mu.Unlock()
now := time.Now()
key := deviceID + ":" + requestType
info, exists := drl.requests[key]
if !exists {
info = &DeviceRequestInfo{
Requests: []time.Time{},
DeviceID: deviceID,
LastSeen: now,
}
drl.requests[key] = info
}
var limit int
switch requestType {
case "sync":
limit = config.SyncRequestsPerMinute
case "progress":
limit = config.ProgressUpdatesPerMinute
case "metadata":
limit = config.MetadataRequestsPerMinute
case "claim":
limit = config.ClaimAttemptsPerMinute
default:
limit = DefaultSyncRequestsPerMinute
}
if limit == 0 {
switch requestType {
case "progress":
limit = DefaultProgressUpdatesPerMinute
case "metadata":
limit = DefaultMetadataRequestsPerMinute
case "claim":
limit = DefaultClaimAttemptsPerMinute
default:
limit = DefaultSyncRequestsPerMinute
}
}
info.Requests = append(info.Requests, now)
info.LastSeen = now
cutoff := now.Add(-RequestWindow)
validRequests := 0
for _, reqTime := range info.Requests {
if reqTime.After(cutoff) {
validRequests++
}
}
info.Requests = info.Requests[len(info.Requests)-validRequests:]
return validRequests <= limit
}
func (drl *DeviceRateLimiter) GetRemainingRequests(deviceID string, requestType string, config DeviceRateLimitConfig) int {
drl.mu.RLock()
defer drl.mu.RUnlock()
key := deviceID + ":" + requestType
info, exists := drl.requests[key]
if !exists {
var limit int
switch requestType {
case "sync":
limit = config.SyncRequestsPerMinute
case "progress":
limit = config.ProgressUpdatesPerMinute
case "metadata":
limit = config.MetadataRequestsPerMinute
default:
limit = DefaultSyncRequestsPerMinute
}
if limit == 0 {
limit = DefaultSyncRequestsPerMinute
}
return limit
}
now := time.Now()
cutoff := now.Add(-RequestWindow)
validRequests := 0
for _, reqTime := range info.Requests {
if reqTime.After(cutoff) {
validRequests++
}
}
var limit int
switch requestType {
case "sync":
limit = config.SyncRequestsPerMinute
case "progress":
limit = config.ProgressUpdatesPerMinute
case "metadata":
limit = config.MetadataRequestsPerMinute
default:
limit = DefaultSyncRequestsPerMinute
}
if limit == 0 {
limit = DefaultSyncRequestsPerMinute
}
remaining := limit - validRequests
if remaining < 0 {
remaining = 0
}
return remaining
}
func (drl *DeviceRateLimiter) cleanupOldEntries() {
for range drl.cleanup.C {
drl.mu.Lock()
cutoff := time.Now().Add(-10 * time.Minute)
for key, info := range drl.requests {
if info.LastSeen.Before(cutoff) {
delete(drl.requests, key)
}
}
drl.mu.Unlock()
}
}
func (drl *DeviceRateLimiter) Stop() {
drl.cleanup.Stop()
}