From 5379cc116346131c21ac5a54722f6a68afa9e09c Mon Sep 17 00:00:00 2001 From: yatul Date: Fri, 18 Sep 2026 12:12:23 +0400 Subject: [PATCH 1/2] feat(pairing): let an operator make a device pairing permanent Every approved pairing expires 30 days after approval (pairedDeviceTTL), and nothing lets an operator change that: owners of a personal setup have to re-pair their own Telegram account and browser every month. The schema already treats a NULL paired_devices.expires_at as "never expires" (IsPaired and the prune both check for it), only no code path ever writes NULL. - store: SetPairingPermanent(senderID, channel, permanent) on PairingStore, PG and SQLite. permanent=true clears expires_at, false restarts the default TTL from now. An already expired pairing is not revived. PairedDeviceData gains expires_at (Unix ms, null = never), returned by ApprovePairing and ListPaired. - gateway: device.pair.approve accepts `permanent`; new admin RPC device.pair.update {senderId, channel, permanent} for existing pairings, classified in permissions/policy.go next to the other pairing methods. - mcp: pairing approve tool accepts `permanent`. - web UI (Nodes): "Never expires" switch in the approve dialog, an Expires column, and a Make permanent / Set expiry action per device. ConfirmDialog takes optional children for the switch. The default stays 30 days; permanence is an explicit operator choice. Co-Authored-By: Claude Opus 5 --- internal/channels/bitrix24/pairing_test.go | 4 + internal/channels/feishu/bot_policy_test.go | 4 + internal/channels/policy_test.go | 4 + internal/channels/slack/policy_test.go | 4 + .../channels/whatsapp/inbound_policy_test.go | 4 + internal/gateway/methods/pairing.go | 55 +++++++- internal/http/auth_test.go | 4 + internal/mcp/crud_pairing.go | 8 ++ internal/permissions/policy.go | 1 + internal/store/pairing_store.go | 17 ++- internal/store/pg/pairing.go | 57 +++++++-- internal/store/pg/pairing_permanent_test.go | 101 +++++++++++++++ internal/store/sqlitestore/pairing.go | 47 +++++-- .../sqlitestore/pairing_permanent_test.go | 119 ++++++++++++++++++ pkg/protocol/methods.go | 1 + ui/web/src/api/protocol.ts | 1 + .../src/components/shared/confirm-dialog.tsx | 4 + ui/web/src/i18n/locales/en/nodes.json | 17 ++- ui/web/src/i18n/locales/ko/nodes.json | 17 ++- ui/web/src/i18n/locales/ru/nodes.json | 17 ++- ui/web/src/i18n/locales/vi/nodes.json | 17 ++- ui/web/src/i18n/locales/zh/nodes.json | 17 ++- ui/web/src/pages/nodes/hooks/use-nodes.ts | 25 +++- ui/web/src/pages/nodes/nodes-page.tsx | 76 ++++++++++- 24 files changed, 580 insertions(+), 41 deletions(-) create mode 100644 internal/store/pg/pairing_permanent_test.go create mode 100644 internal/store/sqlitestore/pairing_permanent_test.go diff --git a/internal/channels/bitrix24/pairing_test.go b/internal/channels/bitrix24/pairing_test.go index d7a99fbb..2011dbaa 100644 --- a/internal/channels/bitrix24/pairing_test.go +++ b/internal/channels/bitrix24/pairing_test.go @@ -29,6 +29,10 @@ func (s *fakeBitrixPairingStore) ApprovePairing(context.Context, string, string) } func (s *fakeBitrixPairingStore) DenyPairing(context.Context, string) error { return nil } func (s *fakeBitrixPairingStore) RevokePairing(context.Context, string, string) error { return nil } +func (s *fakeBitrixPairingStore) SetPairingPermanent(context.Context, string, string, bool) error { + return nil +} + func (s *fakeBitrixPairingStore) IsPaired(context.Context, string, string) (bool, error) { return false, nil } diff --git a/internal/channels/feishu/bot_policy_test.go b/internal/channels/feishu/bot_policy_test.go index 2b157920..bd3b07b4 100644 --- a/internal/channels/feishu/bot_policy_test.go +++ b/internal/channels/feishu/bot_policy_test.go @@ -231,6 +231,10 @@ func (s *feishuPolicyPairingStore) RequestPairing(_ context.Context, senderID, c return "PAIR1234", nil } +func (s *feishuPolicyPairingStore) SetPairingPermanent(context.Context, string, string, bool) error { + return nil +} + func (s *feishuPolicyPairingStore) IsPaired(_ context.Context, senderID, channel string) (bool, error) { return s.paired[senderID+"|"+channel], nil } diff --git a/internal/channels/policy_test.go b/internal/channels/policy_test.go index 33541d4e..ad3dbce5 100644 --- a/internal/channels/policy_test.go +++ b/internal/channels/policy_test.go @@ -22,6 +22,10 @@ func newMockPairingStore() *mockPairingStore { } } +func (s *mockPairingStore) SetPairingPermanent(context.Context, string, string, bool) error { + return nil +} + func (m *mockPairingStore) IsPaired(ctx context.Context, senderID, channel string) (bool, error) { if m.failIsPaired { return false, errors.New("pairing service error") diff --git a/internal/channels/slack/policy_test.go b/internal/channels/slack/policy_test.go index 1c4ecbb2..87f872c7 100644 --- a/internal/channels/slack/policy_test.go +++ b/internal/channels/slack/policy_test.go @@ -30,6 +30,10 @@ func (s *slackPolicyPairingStore) DenyPairing(context.Context, string) error { r func (s *slackPolicyPairingStore) RevokePairing(context.Context, string, string) error { return nil } +func (s *slackPolicyPairingStore) SetPairingPermanent(context.Context, string, string, bool) error { + return nil +} + func (s *slackPolicyPairingStore) IsPaired(_ context.Context, senderID, channel string) (bool, error) { if s.paired[senderID] == nil { return false, nil diff --git a/internal/channels/whatsapp/inbound_policy_test.go b/internal/channels/whatsapp/inbound_policy_test.go index 59b6b918..f9189b44 100644 --- a/internal/channels/whatsapp/inbound_policy_test.go +++ b/internal/channels/whatsapp/inbound_policy_test.go @@ -36,6 +36,10 @@ func (s whatsappInboundPairingStore) RevokePairing(context.Context, string, stri return nil } +func (s whatsappInboundPairingStore) SetPairingPermanent(context.Context, string, string, bool) error { + return nil +} + func (s whatsappInboundPairingStore) IsPaired(_ context.Context, senderID, channel string) (bool, error) { return s.paired[senderID+"|"+channel], nil } diff --git a/internal/gateway/methods/pairing.go b/internal/gateway/methods/pairing.go index 5374f5b4..52be455c 100644 --- a/internal/gateway/methods/pairing.go +++ b/internal/gateway/methods/pairing.go @@ -26,7 +26,8 @@ func isValidSenderID(id string) bool { // senderID identifies the paired entity (e.g., "group:XXXX" for group pairings). type PairingApproveCallback func(ctx context.Context, channel, chatID, senderID string) -// PairingMethods handles device.pair.request, device.pair.approve, device.pair.list, device.pair.revoke. +// PairingMethods handles device.pair.request, device.pair.approve, device.pair.list, device.pair.revoke, +// device.pair.update. type PairingMethods struct { service store.PairingStore msgBus *bus.MessageBus @@ -55,6 +56,7 @@ func (m *PairingMethods) Register(router *gateway.MethodRouter) { router.Register(protocol.MethodPairingDeny, m.handleDeny) router.Register(protocol.MethodPairingList, m.handleList) router.Register(protocol.MethodPairingRevoke, m.handleRevoke) + router.Register(protocol.MethodPairingUpdate, m.handleUpdate) router.Register(protocol.MethodBrowserPairingStatus, m.handleBrowserPairingStatus) } @@ -100,6 +102,7 @@ func (m *PairingMethods) handleApprove(ctx context.Context, client *gateway.Clie var params struct { Code string `json:"code"` ApprovedBy string `json:"approvedBy"` + Permanent bool `json:"permanent"` } if req.Params != nil { json.Unmarshal(req.Params, ¶ms) @@ -118,6 +121,14 @@ func (m *PairingMethods) handleApprove(ctx context.Context, client *gateway.Clie client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return } + if params.Permanent { + if err := m.service.SetPairingPermanent(ctx, paired.SenderID, paired.Channel, true); err != nil { + // The pairing itself is in place with the default TTL; report the partial result. + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "paired, but could not make it permanent: "+err.Error())) + return + } + paired.ExpiresAt = nil + } // Notify the user via channel (matching TS notifyPairingApproved). // Use Background context: the CLI client may disconnect before the notification is sent. @@ -220,6 +231,48 @@ func (m *PairingMethods) handleRevoke(ctx context.Context, client *gateway.Clien })) } +// handleUpdate switches an existing pairing between permanent (never expires) +// and the default TTL counted from now. +func (m *PairingMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + locale := store.LocaleFromContext(ctx) + var params struct { + SenderID string `json:"senderId"` + Channel string `json:"channel"` + Permanent *bool `json:"permanent"` + } + if req.Params != nil { + json.Unmarshal(req.Params, ¶ms) + } + + if params.SenderID == "" || params.Channel == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgSenderChannelRequired))) + return + } + if !isValidSenderID(params.SenderID) { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid sender_id format")) + return + } + if params.Permanent == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "permanent is required")) + return + } + + if err := m.service.SetPairingPermanent(ctx, params.SenderID, params.Channel, *params.Permanent); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) + return + } + + if m.broadcaster != nil { + m.broadcaster(*protocol.NewEvent(protocol.EventDevicePairRes, map[string]any{"action": "updated"})) + } + + emitAudit(m.msgBus, client, "pairing.updated", "pairing", params.SenderID) + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ + "updated": true, + "permanent": *params.Permanent, + })) +} + // handleBrowserPairingStatus lets a pending browser client check if its pairing code has been approved. // Called by unauthenticated clients during the browser pairing flow. func (m *PairingMethods) handleBrowserPairingStatus(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { diff --git a/internal/http/auth_test.go b/internal/http/auth_test.go index 795b25b6..7d51065c 100644 --- a/internal/http/auth_test.go +++ b/internal/http/auth_test.go @@ -144,6 +144,10 @@ func (m *mockPairingStore) ApprovePairing(context.Context, string, string) (*sto } func (m *mockPairingStore) DenyPairing(context.Context, string) error { return nil } func (m *mockPairingStore) RevokePairing(context.Context, string, string) error { return nil } +func (s *mockPairingStore) SetPairingPermanent(context.Context, string, string, bool) error { + return nil +} + func (m *mockPairingStore) IsPaired(_ context.Context, senderID, channel string) (bool, error) { return m.paired[senderID+":"+channel], nil } diff --git a/internal/mcp/crud_pairing.go b/internal/mcp/crud_pairing.go index a39b6bd4..cb9ce4e3 100644 --- a/internal/mcp/crud_pairing.go +++ b/internal/mcp/crud_pairing.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "fmt" "log/slog" "regexp" "strings" @@ -89,6 +90,7 @@ func registerPairingCRUDTools(srv *mcpserver.MCPServer, deps pairingCRUDDeps) { mcpgo.WithDescription("Approve a pending pairing code."), mcpgo.WithString("code", mcpgo.Required(), mcpgo.Description("Pairing code.")), mcpgo.WithString("approved_by", mcpgo.Description("Approver identifier; defaults to \"operator\".")), + mcpgo.WithBoolean("permanent", mcpgo.Description("Pairing never expires; defaults to false (30-day TTL).")), ), handlePairingDeviceApprove(deps)) srv.AddTool(mcpgo.NewTool("goclaw_pairing_device_deny", @@ -149,6 +151,12 @@ func handlePairingDeviceApprove(deps pairingCRUDDeps) mcpserver.ToolHandlerFunc if err != nil { return toolError("pairing.approve", err) } + if req.GetBool("permanent", false) { + if err := deps.pairing.SetPairingPermanent(ctx, paired.SenderID, paired.Channel, true); err != nil { + return toolError("pairing.approve", fmt.Errorf("paired, but could not make it permanent: %w", err)) + } + paired.ExpiresAt = nil + } notifyPairingApproved(ctx, deps, paired) return jsonToolResult(map[string]any{"paired": paired}) } diff --git a/internal/permissions/policy.go b/internal/permissions/policy.go index b9f99a1a..8e9aebc4 100644 --- a/internal/permissions/policy.go +++ b/internal/permissions/policy.go @@ -268,6 +268,7 @@ func isAdminMethod(method string) bool { protocol.MethodPairingDeny, protocol.MethodPairingList, protocol.MethodPairingRevoke, + protocol.MethodPairingUpdate, // Teams — create/delete/update/member management. protocol.MethodTeamsCreate, diff --git a/internal/store/pairing_store.go b/internal/store/pairing_store.go index b94c4c67..32449f33 100644 --- a/internal/store/pairing_store.go +++ b/internal/store/pairing_store.go @@ -15,13 +15,15 @@ type PairingRequestData struct { } // PairedDeviceData represents an approved pairing. +// ExpiresAt is Unix ms; nil means the pairing never expires. 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"` - Metadata map[string]string `json:"metadata,omitempty" db:"metadata"` + 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. @@ -31,6 +33,9 @@ type PairingStore interface { 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 diff --git a/internal/store/pg/pairing.go b/internal/store/pg/pairing.go index 9a97043c..81952621 100644 --- a/internal/store/pg/pairing.go +++ b/internal/store/pg/pairing.go @@ -116,13 +116,15 @@ func (s *PGPairingStore) ApprovePairing(ctx context.Context, code, approvedBy st json.Unmarshal(metaJSON, &meta) } + expiresAtMs := expiresAt.UnixMilli() return &store.PairedDeviceData{ - SenderID: senderID, - Channel: channel, - ChatID: chatID, - PairedAt: now.UnixMilli(), - PairedBy: approvedBy, - Metadata: meta, + SenderID: senderID, + Channel: channel, + ChatID: chatID, + PairedAt: now.UnixMilli(), + PairedBy: approvedBy, + ExpiresAt: &expiresAtMs, + Metadata: meta, }, nil } @@ -152,6 +154,30 @@ func (s *PGPairingStore) RevokePairing(ctx context.Context, senderID, channel st return nil } +// SetPairingPermanent clears (permanent=true) or restarts (permanent=false) +// the expiry of a live pairing. An already expired pairing is not revived. +func (s *PGPairingStore) SetPairingPermanent(ctx context.Context, senderID, channel string, permanent bool) error { + tid := tenantIDForInsert(ctx) + var expiresAt *time.Time + if !permanent { + t := time.Now().Add(pairedDeviceTTL) + expiresAt = &t + } + result, err := s.db.ExecContext(ctx, + `UPDATE paired_devices SET expires_at = $1 + WHERE sender_id = $2 AND channel = $3 AND tenant_id = $4 AND (expires_at IS NULL OR expires_at > NOW())`, + expiresAt, senderID, channel, tid, + ) + if err != nil { + return err + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("paired device not found: %s/%s", channel, senderID) + } + return nil +} + func (s *PGPairingStore) IsPaired(ctx context.Context, senderID, channel string) (bool, error) { tid := tenantIDForInsert(ctx) var count int64 @@ -180,12 +206,13 @@ type pairingRequestRow struct { // pairedDeviceRow is an sqlx scan struct for paired_devices. type pairedDeviceRow struct { - SenderID string `json:"sender_id" db:"sender_id"` - Channel string `json:"channel" db:"channel"` - ChatID string `json:"chat_id" db:"chat_id"` - PairedBy string `json:"paired_by" db:"paired_by"` - PairedAt time.Time `json:"paired_at" db:"paired_at"` - Metadata []byte `json:"metadata" db:"metadata"` + SenderID string `json:"sender_id" db:"sender_id"` + Channel string `json:"channel" db:"channel"` + ChatID string `json:"chat_id" db:"chat_id"` + PairedBy string `json:"paired_by" db:"paired_by"` + PairedAt time.Time `json:"paired_at" db:"paired_at"` + ExpiresAt *time.Time `json:"expires_at" db:"expires_at"` + Metadata []byte `json:"metadata" db:"metadata"` } func (s *PGPairingStore) ListPending(ctx context.Context) []store.PairingRequestData { @@ -224,7 +251,7 @@ func (s *PGPairingStore) ListPaired(ctx context.Context) []store.PairedDeviceDat var rows []pairedDeviceRow err := pkgSqlxDB.SelectContext(ctx, &rows, - `SELECT sender_id, channel, chat_id, paired_by, paired_at, COALESCE(metadata, '{}') AS metadata + `SELECT sender_id, channel, chat_id, paired_by, paired_at, expires_at, COALESCE(metadata, '{}') AS metadata FROM paired_devices WHERE tenant_id = $1 ORDER BY paired_at DESC`, tid) if err != nil { return []store.PairedDeviceData{} @@ -236,6 +263,10 @@ func (s *PGPairingStore) ListPaired(ctx context.Context) []store.PairedDeviceDat SenderID: r.SenderID, Channel: r.Channel, ChatID: r.ChatID, PairedBy: r.PairedBy, PairedAt: r.PairedAt.UnixMilli(), } + if r.ExpiresAt != nil { + ms := r.ExpiresAt.UnixMilli() + result[i].ExpiresAt = &ms + } if len(r.Metadata) > 0 { json.Unmarshal(r.Metadata, &result[i].Metadata) } diff --git a/internal/store/pg/pairing_permanent_test.go b/internal/store/pg/pairing_permanent_test.go new file mode 100644 index 00000000..e66547d5 --- /dev/null +++ b/internal/store/pg/pairing_permanent_test.go @@ -0,0 +1,101 @@ +package pg + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func newTestPGPairingStore(t *testing.T) (*PGPairingStore, context.Context) { + t.Helper() + db := hooksTestDB(t) + + tenantID := uuid.Must(uuid.NewV7()) + if _, err := db.Exec( + `INSERT INTO tenants (id, name, slug, status) VALUES ($1,$2,$3,'active')`, + tenantID, "pairing-test-"+tenantID.String()[:8], "pt-"+tenantID.String()); err != nil { + t.Fatalf("seed tenant: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM paired_devices WHERE tenant_id = $1", tenantID) + db.Exec("DELETE FROM pairing_requests WHERE tenant_id = $1", tenantID) + db.Exec("DELETE FROM tenants WHERE id = $1", tenantID) + }) + + return NewPGPairingStore(db), store.WithTenantID(context.Background(), tenantID) +} + +func pairTestPGDevice(t *testing.T, s *PGPairingStore, ctx context.Context, senderID string) *store.PairedDeviceData { + t.Helper() + code, err := s.RequestPairing(ctx, senderID, "telegram", "chat-1", "default", nil) + if err != nil { + t.Fatalf("RequestPairing: %v", err) + } + paired, err := s.ApprovePairing(ctx, code, "operator") + if err != nil { + t.Fatalf("ApprovePairing: %v", err) + } + return paired +} + +func findPGPaired(list []store.PairedDeviceData, senderID string) *store.PairedDeviceData { + for i := range list { + if list[i].SenderID == senderID { + return &list[i] + } + } + return nil +} + +func TestPGPairing_SetPermanentRoundTrip(t *testing.T) { + s, ctx := newTestPGPairingStore(t) + + paired := pairTestPGDevice(t, s, ctx, "u1") + if paired.ExpiresAt == nil { + t.Fatal("ApprovePairing returned nil ExpiresAt, want default TTL") + } + + if err := s.SetPairingPermanent(ctx, "u1", "telegram", true); err != nil { + t.Fatalf("SetPairingPermanent(true): %v", err) + } + got := findPGPaired(s.ListPaired(ctx), "u1") + if got == nil || got.ExpiresAt != nil { + t.Fatalf("after permanent: want u1 with nil ExpiresAt, got %+v", got) + } + if ok, err := s.IsPaired(ctx, "u1", "telegram"); err != nil || !ok { + t.Fatalf("IsPaired after permanent = %v, %v; want true", ok, err) + } + + if err := s.SetPairingPermanent(ctx, "u1", "telegram", false); err != nil { + t.Fatalf("SetPairingPermanent(false): %v", err) + } + got = findPGPaired(s.ListPaired(ctx), "u1") + if got == nil || got.ExpiresAt == nil { + t.Fatalf("after reverting: want u1 with expiry, got %+v", got) + } + want := time.Now().Add(pairedDeviceTTL) + if d := time.UnixMilli(*got.ExpiresAt).Sub(want); d > time.Minute || d < -time.Minute { + t.Fatalf("expires_at off by %v from now+TTL", d) + } +} + +func TestPGPairing_SetPermanentDoesNotReviveExpired(t *testing.T) { + s, ctx := newTestPGPairingStore(t) + pairTestPGDevice(t, s, ctx, "u1") + + if _, err := s.db.ExecContext(ctx, "UPDATE paired_devices SET expires_at = NOW() - INTERVAL '1 hour' WHERE sender_id = $1 AND tenant_id = $2", + "u1", store.TenantIDFromContext(ctx)); err != nil { + t.Fatalf("expire pairing: %v", err) + } + + if err := s.SetPairingPermanent(ctx, "u1", "telegram", true); err == nil { + t.Fatal("SetPairingPermanent on expired pairing: want error, got nil") + } + if ok, _ := s.IsPaired(ctx, "u1", "telegram"); ok { + t.Fatal("expired pairing was revived") + } +} diff --git a/internal/store/sqlitestore/pairing.go b/internal/store/sqlitestore/pairing.go index 8be7b592..11da8853 100644 --- a/internal/store/sqlitestore/pairing.go +++ b/internal/store/sqlitestore/pairing.go @@ -110,13 +110,15 @@ func (s *SQLitePairingStore) ApprovePairing(ctx context.Context, code, approvedB json.Unmarshal(metaJSON, &meta) } + expiresAtMs := expiresAt.UnixMilli() return &store.PairedDeviceData{ - SenderID: senderID, - Channel: channel, - ChatID: chatID, - PairedAt: now.UnixMilli(), - PairedBy: approvedBy, - Metadata: meta, + SenderID: senderID, + Channel: channel, + ChatID: chatID, + PairedAt: now.UnixMilli(), + PairedBy: approvedBy, + ExpiresAt: &expiresAtMs, + Metadata: meta, }, nil } @@ -145,6 +147,30 @@ func (s *SQLitePairingStore) RevokePairing(ctx context.Context, senderID, channe return nil } +// SetPairingPermanent clears (permanent=true) or restarts (permanent=false) +// the expiry of a live pairing. An already expired pairing is not revived. +func (s *SQLitePairingStore) SetPairingPermanent(ctx context.Context, senderID, channel string, permanent bool) error { + tid := tenantIDForInsert(ctx) + now := time.Now().Round(0) + var expiresAt any + if !permanent { + expiresAt = now.Add(pairedDeviceTTL) + } + result, err := s.db.ExecContext(ctx, + `UPDATE paired_devices SET expires_at = ? + WHERE sender_id = ? AND channel = ? AND tenant_id = ? AND (expires_at IS NULL OR expires_at > ?)`, + expiresAt, senderID, channel, tid, now, + ) + if err != nil { + return err + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("paired device not found: %s/%s", channel, senderID) + } + return nil +} + func (s *SQLitePairingStore) IsPaired(ctx context.Context, senderID, channel string) (bool, error) { tid := tenantIDForInsert(ctx) var count int64 @@ -204,7 +230,7 @@ func (s *SQLitePairingStore) ListPaired(ctx context.Context) []store.PairedDevic s.db.ExecContext(ctx, "DELETE FROM paired_devices WHERE expires_at IS NOT NULL AND expires_at < ?", now) rows, err := s.db.QueryContext(ctx, - `SELECT sender_id, channel, chat_id, paired_by, paired_at, COALESCE(metadata, '{}') + `SELECT sender_id, channel, chat_id, paired_by, paired_at, expires_at, COALESCE(metadata, '{}') FROM paired_devices WHERE tenant_id = ? ORDER BY paired_at DESC`, tid) if err != nil { return nil @@ -215,12 +241,17 @@ func (s *SQLitePairingStore) ListPaired(ctx context.Context) []store.PairedDevic for rows.Next() { var d store.PairedDeviceData var pairedAtStr string + var expiresAtStr sql.NullString var metaJSON []byte - if err := rows.Scan(&d.SenderID, &d.Channel, &d.ChatID, &d.PairedBy, &pairedAtStr, &metaJSON); err != nil { + if err := rows.Scan(&d.SenderID, &d.Channel, &d.ChatID, &d.PairedBy, &pairedAtStr, &expiresAtStr, &metaJSON); err != nil { slog.Warn("pairing: scan paired error", "error", err) continue } d.PairedAt = parseTimeToMillis(pairedAtStr) + if expiresAtStr.Valid { + ms := parseTimeToMillis(expiresAtStr.String) + d.ExpiresAt = &ms + } if len(metaJSON) > 0 { json.Unmarshal(metaJSON, &d.Metadata) } diff --git a/internal/store/sqlitestore/pairing_permanent_test.go b/internal/store/sqlitestore/pairing_permanent_test.go new file mode 100644 index 00000000..53931acb --- /dev/null +++ b/internal/store/sqlitestore/pairing_permanent_test.go @@ -0,0 +1,119 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func newTestSQLitePairingStore(t *testing.T) (*SQLitePairingStore, context.Context) { + t.Helper() + + db, err := OpenDB(filepath.Join(t.TempDir(), "pairing.db")) + if err != nil { + t.Fatalf("OpenDB error: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + if err := EnsureSchema(db); err != nil { + t.Fatalf("EnsureSchema error: %v", err) + } + + ctx := store.WithTenantID(context.Background(), store.MasterTenantID) + return NewSQLitePairingStore(db), ctx +} + +func pairTestDevice(t *testing.T, s *SQLitePairingStore, ctx context.Context, senderID string) *store.PairedDeviceData { + t.Helper() + + code, err := s.RequestPairing(ctx, senderID, "telegram", "chat-1", "default", nil) + if err != nil { + t.Fatalf("RequestPairing: %v", err) + } + paired, err := s.ApprovePairing(ctx, code, "operator") + if err != nil { + t.Fatalf("ApprovePairing: %v", err) + } + return paired +} + +func findPaired(list []store.PairedDeviceData, senderID string) *store.PairedDeviceData { + for i := range list { + if list[i].SenderID == senderID { + return &list[i] + } + } + return nil +} + +func TestSQLitePairing_ApproveSetsDefaultTTL(t *testing.T) { + s, ctx := newTestSQLitePairingStore(t) + + paired := pairTestDevice(t, s, ctx, "u1") + if paired.ExpiresAt == nil { + t.Fatal("ApprovePairing returned nil ExpiresAt, want default TTL") + } + + got := findPaired(s.ListPaired(ctx), "u1") + if got == nil || got.ExpiresAt == nil { + t.Fatalf("ListPaired: want u1 with expiry, got %+v", got) + } + want := time.Now().Add(pairedDeviceTTL) + if d := time.UnixMilli(*got.ExpiresAt).Sub(want); d > time.Minute || d < -time.Minute { + t.Fatalf("expires_at off by %v from now+TTL", d) + } +} + +func TestSQLitePairing_SetPermanentRoundTrip(t *testing.T) { + s, ctx := newTestSQLitePairingStore(t) + pairTestDevice(t, s, ctx, "u1") + + if err := s.SetPairingPermanent(ctx, "u1", "telegram", true); err != nil { + t.Fatalf("SetPairingPermanent(true): %v", err) + } + got := findPaired(s.ListPaired(ctx), "u1") + if got == nil || got.ExpiresAt != nil { + t.Fatalf("after permanent: want u1 with nil ExpiresAt, got %+v", got) + } + if ok, err := s.IsPaired(ctx, "u1", "telegram"); err != nil || !ok { + t.Fatalf("IsPaired after permanent = %v, %v; want true", ok, err) + } + + if err := s.SetPairingPermanent(ctx, "u1", "telegram", false); err != nil { + t.Fatalf("SetPairingPermanent(false): %v", err) + } + got = findPaired(s.ListPaired(ctx), "u1") + if got == nil || got.ExpiresAt == nil { + t.Fatalf("after reverting: want u1 with expiry, got %+v", got) + } +} + +func TestSQLitePairing_SetPermanentDoesNotReviveExpired(t *testing.T) { + s, ctx := newTestSQLitePairingStore(t) + pairTestDevice(t, s, ctx, "u1") + + if _, err := s.db.ExecContext(ctx, "UPDATE paired_devices SET expires_at = ? WHERE sender_id = ?", + time.Now().Add(-time.Hour).Round(0), "u1"); err != nil { + t.Fatalf("expire pairing: %v", err) + } + + if err := s.SetPairingPermanent(ctx, "u1", "telegram", true); err == nil { + t.Fatal("SetPairingPermanent on expired pairing: want error, got nil") + } + if ok, _ := s.IsPaired(ctx, "u1", "telegram"); ok { + t.Fatal("expired pairing was revived") + } +} + +func TestSQLitePairing_SetPermanentUnknownDevice(t *testing.T) { + s, ctx := newTestSQLitePairingStore(t) + + if err := s.SetPairingPermanent(ctx, "nobody", "telegram", true); err == nil { + t.Fatal("want error for unknown device, got nil") + } +} diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 4c2d52cf..03e3f0c5 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -73,6 +73,7 @@ const ( MethodPairingDeny = "device.pair.deny" MethodPairingList = "device.pair.list" MethodPairingRevoke = "device.pair.revoke" + MethodPairingUpdate = "device.pair.update" MethodBrowserPairingStatus = "browser.pairing.status" diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index 7132e7c6..97ea36ab 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -110,6 +110,7 @@ export const Methods = { PAIRING_DENY: "device.pair.deny", PAIRING_LIST: "device.pair.list", PAIRING_REVOKE: "device.pair.revoke", + PAIRING_UPDATE: "device.pair.update", BROWSER_PAIRING_STATUS: "browser.pairing.status", diff --git a/ui/web/src/components/shared/confirm-dialog.tsx b/ui/web/src/components/shared/confirm-dialog.tsx index be593184..34b6e990 100644 --- a/ui/web/src/components/shared/confirm-dialog.tsx +++ b/ui/web/src/components/shared/confirm-dialog.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Dialog, @@ -18,6 +19,7 @@ interface ConfirmDialogProps { variant?: "default" | "destructive"; onConfirm: () => void; loading?: boolean; + children?: ReactNode; } export function ConfirmDialog({ @@ -29,6 +31,7 @@ export function ConfirmDialog({ variant = "default", onConfirm, loading, + children, }: ConfirmDialogProps) { const { t } = useTranslation("common"); return ( @@ -38,6 +41,7 @@ export function ConfirmDialog({ {title} {description} + {children}