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:
John O'Keefe committed 2026-11-02 16:11:03 -05:00
1 parent ab5c52c709
commit 8fbacd9f46
47 files changed
+935 -273

No files matched your search

+1 -1
View File
@@ -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/"}
+24 -5
View File
@@ -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(`{
+405 -138
View File
@@ -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, `<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()
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,
})
}
+98
View File
@@ -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)
}
+14 -1
View File
@@ -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)
+8 -1
View File
@@ -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)