diff --git a/docs/04-gateway-protocol.md b/docs/04-gateway-protocol.md index 7a703a04..bf6db71b 100644 --- a/docs/04-gateway-protocol.md +++ b/docs/04-gateway-protocol.md @@ -112,7 +112,7 @@ flowchart LR |------|--------------------| | viewer | `agents.list`, `config.get`, `sessions.list`, `sessions.preview`, `health`, `status`, `providers.models`, `skills.list`, `skills.get`, `channels.list`, `channels.status`, `cron.list`, `cron.status`, `cron.runs`, `usage.get`, `usage.summary` | | operator | All viewer methods plus: `chat.send`, `chat.abort`, `chat.history`, `chat.inject`, `sessions.delete`, `sessions.reset`, `sessions.patch`, `cron.create`, `cron.update`, `cron.delete`, `cron.toggle`, `cron.run`, `skills.update`, `send`, `exec.approval.list`, `exec.approval.approve`, `exec.approval.deny`, `device.pair.request`, `device.pair.list` | -| admin | All operator methods plus: `config.apply`, `config.patch`, `config.permissions.*`, `agents.create`, `agents.update`, `agents.delete`, `agents.files.*`, `teams.*`, `channels.toggle`, `device.pair.approve`, `device.pair.revoke` | +| admin | All operator methods plus: `config.apply`, `config.patch`, `config.permissions.*`, `agents.create`, `agents.update`, `agents.delete`, `agents.files.*`, `teams.*`, `channels.toggle`, `device.pair.approve`, `device.pair.revoke`, `device.pair.update` | --- @@ -233,9 +233,10 @@ flowchart TD | Method | Description | |--------|-------------| | `device.pair.request` | Request a pairing code | -| `device.pair.approve` | Approve a pairing request | +| `device.pair.approve` | Approve a pairing request (optional `permanent`) | | `device.pair.list` | List paired devices | | `device.pair.revoke` | Revoke a paired device | +| `device.pair.update` | Make a paired device permanent or restore the default TTL | | `browser.pairing.status` | Poll browser pairing approval status | ### Exec Approval diff --git a/docs/09-security.md b/docs/09-security.md index 809ce0b0..03844124 100644 --- a/docs/09-security.md +++ b/docs/09-security.md @@ -451,7 +451,7 @@ Browser pairing allows web UI clients to authenticate without full admin credent |-----------|--------| | Pairing code | 8-character alphanumeric code (A-Z, 2-9, excludes I/O/L for clarity), generated via `generatePairingCode()` in `internal/store/pg/pairing.go` | | Code TTL | 60 minutes; expired codes are auto-pruned from database | -| Paired device TTL | 30 days; provides defense-in-depth expiry (paired devices auto-cleaned if unused) | +| Paired device TTL | 30 days by default; provides defense-in-depth expiry (expired pairings are auto-cleaned). An admin can opt a single pairing out of expiry (`device.pair.approve` with `permanent`, or `device.pair.update`); such a pairing lasts until revoked | | Pending limit | Max 3 pending pairing requests per account; prevents spam/enumeration | | HTTP access | Paired browsers access HTTP APIs via `X-GoClaw-Sender-Id` header (requires `channel=browser`). Fail-closed: `IsPaired()` check blocks unpaired sessions. Logs failed HTTP pairing auth attempts for security monitoring. | | Approval flow | Requires WebSocket `device.pair.approve` method from authenticated admin session, triggered by `pairing.approve` command. Admin approval adds sender to `paired_devices` table with `paired_by` audit field. | diff --git a/docs/19-websocket-rpc.md b/docs/19-websocket-rpc.md index 9b09783a..bfbaa674 100644 --- a/docs/19-websocket-rpc.md +++ b/docs/19-websocket-rpc.md @@ -378,10 +378,11 @@ Get JSON schema for config form generation. | Method | Description | Auth | |--------|-------------|------| | `device.pair.request` | Request pairing (from device) | Unauthenticated | -| `device.pair.approve` | Approve request (from admin) | Admin | +| `device.pair.approve` | Approve request (from admin); optional `permanent: true` skips the 30-day TTL | Admin | | `device.pair.deny` | Deny request | Admin | | `device.pair.list` | List pending + paired devices | Admin | | `device.pair.revoke` | Revoke device | Admin | +| `device.pair.update` | `{senderId, channel, permanent}` — `true` clears expiry, `false` restarts the 30-day TTL from now; an expired pairing is not revived | Admin | | `browser.pairing.status` | Poll pairing status | Unauthenticated | ### Pairing Flow @@ -391,7 +392,7 @@ sequenceDiagram Device->>Gateway: device.pair.request {senderId, channel} Gateway-->>Device: {code: "A1B2C3D4"} Device->>Gateway: browser.pairing.status {sender_id} (poll) - Admin->>Gateway: device.pair.approve {code, approvedBy} + Admin->>Gateway: device.pair.approve {code, approvedBy, permanent?} Gateway-->>Device: {status: "approved"} ``` @@ -816,7 +817,7 @@ Methods are gated by role. The role is determined at `connect` time from the tok ### Admin-Only Methods -`config.apply`, `config.patch`, `agents.create`, `agents.update`, `agents.delete`, `channels.toggle`, `device.pair.approve`, `device.pair.deny`, `device.pair.revoke`, `teams.*`, `api_keys.*`, `tenants.*` +`config.apply`, `config.patch`, `agents.create`, `agents.update`, `agents.delete`, `channels.toggle`, `device.pair.approve`, `device.pair.deny`, `device.pair.revoke`, `device.pair.update`, `teams.*`, `api_keys.*`, `tenants.*` ### Write Methods (Operator+) 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..9eded369 100644 --- a/internal/gateway/methods/pairing.go +++ b/internal/gateway/methods/pairing.go @@ -3,6 +3,7 @@ package methods import ( "context" "encoding/json" + "errors" "log/slog" "regexp" @@ -26,7 +27,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 +57,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 +103,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 +122,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 +232,52 @@ 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 { + code := protocol.ErrInternal + if errors.Is(err, store.ErrPairedDeviceNotFound) { + code = protocol.ErrNotFound + } + client.SendResponse(protocol.NewErrorResponse(req.ID, code, 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/gateway/methods/pairing_update_test.go b/internal/gateway/methods/pairing_update_test.go new file mode 100644 index 00000000..9a526ecb --- /dev/null +++ b/internal/gateway/methods/pairing_update_test.go @@ -0,0 +1,66 @@ +package methods + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/permissions" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// stubPermanentPairingStore answers SetPairingPermanent with a fixed error. +type stubPermanentPairingStore struct { + store.PairingStore + err error +} + +func (s *stubPermanentPairingStore) SetPairingPermanent(context.Context, string, string, bool) error { + return s.err +} + +func callPairingUpdate(t *testing.T, storeErr error) protocol.ResponseFrame { + t.Helper() + m := NewPairingMethods(&stubPermanentPairingStore{err: storeErr}, bus.New(), nil) + client, out := gateway.NewCapturingTestClient(permissions.RoleAdmin, store.MasterTenantID, "admin-1", 1) + raw, _ := json.Marshal(map[string]any{"senderId": "u1", "channel": "telegram", "permanent": true}) + + m.handleUpdate(t.Context(), client, &protocol.RequestFrame{ID: "r1", Params: raw}) + + var frame protocol.ResponseFrame + if err := json.Unmarshal(<-out, &frame); err != nil { + t.Fatalf("decode response: %v", err) + } + return frame +} + +func TestPairingUpdate_ErrorMapping(t *testing.T) { + cases := []struct { + name string + storeErr error + wantCode string + }{ + {"not found", fmt.Errorf("%w: telegram/u1", store.ErrPairedDeviceNotFound), protocol.ErrNotFound}, + {"db failure", errors.New("connection reset"), protocol.ErrInternal}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + frame := callPairingUpdate(t, tc.storeErr) + if frame.OK || frame.Error == nil || frame.Error.Code != tc.wantCode { + t.Fatalf("want error %s, got ok=%v error=%+v", tc.wantCode, frame.OK, frame.Error) + } + }) + } +} + +func TestPairingUpdate_OK(t *testing.T) { + frame := callPairingUpdate(t, nil) + if !frame.OK { + t.Fatalf("want ok, got error %+v", frame.Error) + } +} 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..7f1fb490 100644 --- a/internal/store/pairing_store.go +++ b/internal/store/pairing_store.go @@ -1,6 +1,13 @@ package store -import "context" +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 { @@ -15,13 +22,16 @@ type PairingRequestData struct { } // 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"` - 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 +41,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..e503f62b 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("%w: %s/%s", store.ErrPairedDeviceNotFound, 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..8dadf96f --- /dev/null +++ b/internal/store/pg/pairing_permanent_test.go @@ -0,0 +1,102 @@ +package pg + +import ( + "context" + "errors" + "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); !errors.Is(err, store.ErrPairedDeviceNotFound) { + t.Fatalf("SetPairingPermanent on expired pairing: want ErrPairedDeviceNotFound, got %v", err) + } + 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..e17cabdb 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("%w: %s/%s", store.ErrPairedDeviceNotFound, 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,19 @@ 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 { + // A parse miss yields 0: the pairing still expires (SQL compares the + // raw value), only the date is unknown. Never report it as permanent. + 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..ba91d952 --- /dev/null +++ b/internal/store/sqlitestore/pairing_permanent_test.go @@ -0,0 +1,136 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "errors" + "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); !errors.Is(err, store.ErrPairedDeviceNotFound) { + t.Fatalf("SetPairingPermanent on expired pairing: want ErrPairedDeviceNotFound, got %v", err) + } + 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); !errors.Is(err, store.ErrPairedDeviceNotFound) { + t.Fatalf("want ErrPairedDeviceNotFound for unknown device, got %v", err) + } +} + +// An expiry the parser cannot read must not turn into "never expires". +func TestSQLitePairing_UnreadableExpiryIsNotPermanent(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 = ?", + "9999-garbage", "u1"); err != nil { + t.Fatalf("corrupt expiry: %v", err) + } + + got := findPaired(s.ListPaired(ctx), "u1") + if got == nil || got.ExpiresAt == nil || *got.ExpiresAt != 0 { + t.Fatalf("want u1 with ExpiresAt=0 (unknown date), got %+v", got) + } +} 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}