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 { 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';
|
||||||
|
|||||||
@@ -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
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user