Files
yatulandClaude Opus 5 ea4890c257 fix(pairing): address review — docs, error mapping, approve feedback, unreadable expiry
- docs: device.pair.update and the `permanent` option on approve in
  docs/04-gateway-protocol.md, docs/19-websocket-rpc.md and
  websocket-protocol.md; the paired-device TTL row in docs/09-security.md
  now mentions the admin opt-out.
- store.ErrPairedDeviceNotFound: SetPairingPermanent wraps it in both stores.
  device.pair.update maps it to NOT_FOUND and any other store error to
  INTERNAL, so a DB failure no longer reads as "not found".
- web UI: approve and make-permanent/set-expiry now toast the server error
  and reload the list in `finally`. A partially applied approve (paired, but
  the permanent write failed) shows up in the table instead of leaving the
  dialog dead-ended.
- SQLite ListPaired: a stored expiry that fails to parse stays 0 (expires,
  date unknown) rather than being mistaken for permanent; the UI renders it
  as "--" instead of a 1970 date.

Tests: gateway handler error mapping (NOT_FOUND / INTERNAL / OK), sentinel
checks in the PG and SQLite store tests, SQLite unreadable-expiry case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:26:43 +04:00

54 lines
2.6 KiB
Go

package store
import (
"context"
"errors"
)
// ErrPairedDeviceNotFound is returned by SetPairingPermanent when there is no
// live (non-expired) pairing for the sender/channel.
var ErrPairedDeviceNotFound = errors.New("paired device not found")
// PairingRequest represents a pending pairing code.
type PairingRequestData struct {
Code string `json:"code" db:"code"`
SenderID string `json:"sender_id" db:"sender_id"`
Channel string `json:"channel" db:"channel"`
ChatID string `json:"chat_id" db:"chat_id"`
AccountID string `json:"account_id" db:"account_id"`
CreatedAt int64 `json:"created_at" db:"created_at"`
ExpiresAt int64 `json:"expires_at" db:"expires_at"`
Metadata map[string]string `json:"metadata,omitempty" db:"metadata"`
}
// PairedDeviceData represents an approved pairing.
// ExpiresAt is Unix ms; nil means the pairing never expires, 0 means it
// expires but the stored date could not be read.
type PairedDeviceData struct {
SenderID string `json:"sender_id" db:"sender_id"`
Channel string `json:"channel" db:"channel"`
ChatID string `json:"chat_id" db:"chat_id"`
PairedAt int64 `json:"paired_at" db:"paired_at"`
PairedBy string `json:"paired_by" db:"paired_by"`
ExpiresAt *int64 `json:"expires_at" db:"expires_at"`
Metadata map[string]string `json:"metadata,omitempty" db:"metadata"`
}
// PairingStore manages device pairing.
type PairingStore interface {
RequestPairing(ctx context.Context, senderID, channel, chatID, accountID string, metadata map[string]string) (string, error)
ApprovePairing(ctx context.Context, code, approvedBy string) (*PairedDeviceData, error)
DenyPairing(ctx context.Context, code string) error
RevokePairing(ctx context.Context, senderID, channel string) error
IsPaired(ctx context.Context, senderID, channel string) (bool, error)
// SetPairingPermanent clears the expiry of an existing pairing (permanent=true)
// or restarts the default TTL from now (permanent=false).
SetPairingPermanent(ctx context.Context, senderID, channel string, permanent bool) error
ListPending(ctx context.Context) []PairingRequestData
ListPaired(ctx context.Context) []PairedDeviceData
// MigrateGroupChatID updates all references from oldChatID to newChatID
// across paired_devices, sessions, and channel_contacts within a transaction.
// Scoped by tenant_id and channel. Idempotent (safe to call multiple times).
MigrateGroupChatID(ctx context.Context, channel, oldChatID, newChatID string) error
}