diff --git a/src/channels/index.ts b/src/channels/index.ts index a4c10db..cce14d5 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -15,4 +15,4 @@ export { WebChatAdapter, type WebChatAdapterConfig } from './webchat/index.js'; export { DiscordAdapter, type DiscordAdapterConfig } from './discord/index.js'; export { SlackAdapter, type SlackAdapterConfig } from './slack/index.js'; export { WhatsAppAdapter, type WhatsAppAdapterConfig } from './whatsapp/index.js'; -export { PairingManager, type PairingConfig } from './pairing.js'; +export { PairingManager, type PairingConfig, type PairingStore, type ApprovedSender } from './pairing.js'; diff --git a/src/channels/pairing.test.ts b/src/channels/pairing.test.ts index 095973f..0c23875 100644 --- a/src/channels/pairing.test.ts +++ b/src/channels/pairing.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { PairingManager } from './pairing.js'; +import { PairingManager, type PairingStore } from './pairing.js'; describe('PairingManager', () => { let manager: PairingManager; @@ -156,4 +156,56 @@ describe('PairingManager', () => { }); expect(disabled.enabled).toBe(false); }); + + describe('PairingManager with store', () => { + it('calls store.saveApproved when a code is validated', () => { + const store: PairingStore = { + loadApproved: () => [], + saveApproved: vi.fn(), + removeApproved: vi.fn(), + }; + const mgr = new PairingManager({ enabled: true, codeTtl: 300_000, codeLength: 6 }, store); + const code = mgr.generateCode(); + mgr.validateCode('telegram', '123', code); + expect(store.saveApproved).toHaveBeenCalledWith(expect.objectContaining({ + channel: 'telegram', + senderId: '123', + })); + }); + + it('calls store.removeApproved when approval is revoked', () => { + const store: PairingStore = { + loadApproved: () => [], + saveApproved: vi.fn(), + removeApproved: vi.fn(), + }; + const mgr = new PairingManager({ enabled: true, codeTtl: 300_000, codeLength: 6 }, store); + const code = mgr.generateCode(); + mgr.validateCode('telegram', '123', code); + mgr.revokeApproval('telegram', '123'); + expect(store.removeApproved).toHaveBeenCalledWith('telegram', '123'); + }); + + it('loads approved senders from store on construction', () => { + const store: PairingStore = { + loadApproved: () => [ + { channel: 'telegram', senderId: '111', approvedAt: Date.now(), codeUsed: 'AAAAAA' }, + { channel: 'discord', senderId: '222', approvedAt: Date.now(), codeUsed: 'BBBBBB' }, + ], + saveApproved: vi.fn(), + removeApproved: vi.fn(), + }; + const mgr = new PairingManager({ enabled: true, codeTtl: 300_000, codeLength: 6 }, store); + expect(mgr.isApproved('telegram', '111')).toBe(true); + expect(mgr.isApproved('discord', '222')).toBe(true); + expect(mgr.listApproved()).toHaveLength(2); + }); + + it('works without a store (backward-compatible)', () => { + const mgr = new PairingManager({ enabled: true, codeTtl: 300_000, codeLength: 6 }); + const code = mgr.generateCode(); + mgr.validateCode('telegram', '123', code); + expect(mgr.isApproved('telegram', '123')).toBe(true); + }); + }); }); diff --git a/src/channels/pairing.ts b/src/channels/pairing.ts index 5993ced..dd33d26 100644 --- a/src/channels/pairing.ts +++ b/src/channels/pairing.ts @@ -14,7 +14,7 @@ interface PendingCode { label?: string; } -interface ApprovedSender { +export interface ApprovedSender { channel: string; senderId: string; approvedAt: number; @@ -22,6 +22,12 @@ interface ApprovedSender { codeUsed: string; } +export interface PairingStore { + loadApproved(): ApprovedSender[]; + saveApproved(sender: ApprovedSender): void; + removeApproved(channel: string, senderId: string): void; +} + /** * Manages DM pairing codes for authenticating unknown senders. * @@ -35,9 +41,17 @@ export class PairingManager { private config: PairingConfig; private pendingCodes: Map = new Map(); private approvedSenders: Map = new Map(); + private store?: PairingStore; - constructor(config: PairingConfig) { + constructor(config: PairingConfig, store?: PairingStore) { this.config = config; + this.store = store; + if (store) { + for (const sender of store.loadApproved()) { + const key = `${sender.channel}:${sender.senderId}`; + this.approvedSenders.set(key, sender); + } + } } /** Generate a new pairing code. Returns the code string. */ @@ -77,12 +91,14 @@ export class PairingManager { // Code is valid — approve the sender const key = `${channel}:${senderId}`; - this.approvedSenders.set(key, { + const approved: ApprovedSender = { channel, senderId, approvedAt: Date.now(), codeUsed: normalizedCode, - }); + }; + this.approvedSenders.set(key, approved); + this.store?.saveApproved(approved); // Remove the used code this.pendingCodes.delete(normalizedCode); @@ -98,7 +114,11 @@ export class PairingManager { /** Revoke approval for a sender. Returns true if the sender was found and removed. */ revokeApproval(channel: string, senderId: string): boolean { const key = `${channel}:${senderId}`; - return this.approvedSenders.delete(key); + const deleted = this.approvedSenders.delete(key); + if (deleted) { + this.store?.removeApproved(channel, senderId); + } + return deleted; } /** List all currently approved senders. */