feat(pairing): add PairingStore interface for persistence injection

This commit is contained in:
William Valentin
2026-02-09 21:42:17 -08:00
parent c3ca3f3776
commit 1e1a68924e
3 changed files with 79 additions and 7 deletions
+1 -1
View File
@@ -15,4 +15,4 @@ export { WebChatAdapter, type WebChatAdapterConfig } from './webchat/index.js';
export { DiscordAdapter, type DiscordAdapterConfig } from './discord/index.js'; export { DiscordAdapter, type DiscordAdapterConfig } from './discord/index.js';
export { SlackAdapter, type SlackAdapterConfig } from './slack/index.js'; export { SlackAdapter, type SlackAdapterConfig } from './slack/index.js';
export { WhatsAppAdapter, type WhatsAppAdapterConfig } from './whatsapp/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';
+53 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { PairingManager } from './pairing.js'; import { PairingManager, type PairingStore } from './pairing.js';
describe('PairingManager', () => { describe('PairingManager', () => {
let manager: PairingManager; let manager: PairingManager;
@@ -156,4 +156,56 @@ describe('PairingManager', () => {
}); });
expect(disabled.enabled).toBe(false); 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);
});
});
}); });
+25 -5
View File
@@ -14,7 +14,7 @@ interface PendingCode {
label?: string; label?: string;
} }
interface ApprovedSender { export interface ApprovedSender {
channel: string; channel: string;
senderId: string; senderId: string;
approvedAt: number; approvedAt: number;
@@ -22,6 +22,12 @@ interface ApprovedSender {
codeUsed: string; 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. * Manages DM pairing codes for authenticating unknown senders.
* *
@@ -35,9 +41,17 @@ export class PairingManager {
private config: PairingConfig; private config: PairingConfig;
private pendingCodes: Map<string, PendingCode> = new Map(); private pendingCodes: Map<string, PendingCode> = new Map();
private approvedSenders: Map<string, ApprovedSender> = new Map(); private approvedSenders: Map<string, ApprovedSender> = new Map();
private store?: PairingStore;
constructor(config: PairingConfig) { constructor(config: PairingConfig, store?: PairingStore) {
this.config = config; 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. */ /** Generate a new pairing code. Returns the code string. */
@@ -77,12 +91,14 @@ export class PairingManager {
// Code is valid — approve the sender // Code is valid — approve the sender
const key = `${channel}:${senderId}`; const key = `${channel}:${senderId}`;
this.approvedSenders.set(key, { const approved: ApprovedSender = {
channel, channel,
senderId, senderId,
approvedAt: Date.now(), approvedAt: Date.now(),
codeUsed: normalizedCode, codeUsed: normalizedCode,
}); };
this.approvedSenders.set(key, approved);
this.store?.saveApproved(approved);
// Remove the used code // Remove the used code
this.pendingCodes.delete(normalizedCode); 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. */ /** Revoke approval for a sender. Returns true if the sender was found and removed. */
revokeApproval(channel: string, senderId: string): boolean { revokeApproval(channel: string, senderId: string): boolean {
const key = `${channel}:${senderId}`; 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. */ /** List all currently approved senders. */