feat(pairing): add PairingStore interface for persistence injection
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+25
-5
@@ -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<string, PendingCode> = new Map();
|
||||
private approvedSenders: Map<string, ApprovedSender> = 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. */
|
||||
|
||||
Reference in New Issue
Block a user