feat: add PairingManager and gateway lock tests (Tier 4 feature 4 foundation)

This commit is contained in:
William Valentin
2026-02-09 13:32:59 -08:00
parent 4413c4dc7c
commit 9d4d440ecf
4 changed files with 389 additions and 0 deletions
+1
View File
@@ -15,3 +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';
+159
View File
@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { PairingManager } from './pairing.js';
describe('PairingManager', () => {
let manager: PairingManager;
beforeEach(() => {
manager = new PairingManager({
enabled: true,
codeTtl: 300_000, // 5 minutes
codeLength: 6,
});
});
afterEach(() => {
vi.useRealTimers();
});
it('generateCode returns a code of the configured length', () => {
const code = manager.generateCode();
expect(code).toHaveLength(6);
expect(code).toMatch(/^[0-9A-F]+$/);
});
it('generateCode with a label stores the label', () => {
const code = manager.generateCode('for alice');
const pending = manager.listPendingCodes();
expect(pending).toHaveLength(1);
expect(pending[0].code).toBe(code);
expect(pending[0].label).toBe('for alice');
});
it('validateCode succeeds with a valid code and approves the sender', () => {
const code = manager.generateCode();
const result = manager.validateCode('telegram', '12345', code);
expect(result).toBe(true);
expect(manager.isApproved('telegram', '12345')).toBe(true);
});
it('validateCode fails with an invalid code', () => {
manager.generateCode();
const result = manager.validateCode('telegram', '12345', 'ZZZZZZ');
expect(result).toBe(false);
expect(manager.isApproved('telegram', '12345')).toBe(false);
});
it('validateCode is case-insensitive', () => {
const code = manager.generateCode();
const result = manager.validateCode('telegram', '12345', code.toLowerCase());
expect(result).toBe(true);
expect(manager.isApproved('telegram', '12345')).toBe(true);
});
it('validateCode fails with an expired code', () => {
vi.useFakeTimers();
const code = manager.generateCode();
// Advance time past the TTL
vi.advanceTimersByTime(300_001);
const result = manager.validateCode('telegram', '12345', code);
expect(result).toBe(false);
expect(manager.isApproved('telegram', '12345')).toBe(false);
});
it('validateCode removes the code after successful use', () => {
const code = manager.generateCode();
manager.validateCode('telegram', '12345', code);
// Code should no longer be pending
expect(manager.listPendingCodes()).toHaveLength(0);
// Second use of the same code should fail
const result = manager.validateCode('discord', '67890', code);
expect(result).toBe(false);
});
it('isApproved returns true after validation', () => {
const code = manager.generateCode();
manager.validateCode('telegram', '12345', code);
expect(manager.isApproved('telegram', '12345')).toBe(true);
});
it('isApproved returns false for unapproved senders', () => {
expect(manager.isApproved('telegram', 'unknown')).toBe(false);
});
it('isApproved distinguishes between channels', () => {
const code = manager.generateCode();
manager.validateCode('telegram', '12345', code);
expect(manager.isApproved('telegram', '12345')).toBe(true);
expect(manager.isApproved('discord', '12345')).toBe(false);
});
it('revokeApproval removes approval', () => {
const code = manager.generateCode();
manager.validateCode('telegram', '12345', code);
expect(manager.isApproved('telegram', '12345')).toBe(true);
const revoked = manager.revokeApproval('telegram', '12345');
expect(revoked).toBe(true);
expect(manager.isApproved('telegram', '12345')).toBe(false);
});
it('revokeApproval returns false for non-existent sender', () => {
const revoked = manager.revokeApproval('telegram', 'nonexistent');
expect(revoked).toBe(false);
});
it('listApproved returns all approved senders', () => {
const code1 = manager.generateCode();
const code2 = manager.generateCode();
manager.validateCode('telegram', '111', code1);
manager.validateCode('discord', '222', code2);
const approved = manager.listApproved();
expect(approved).toHaveLength(2);
expect(approved.map(a => a.senderId)).toContain('111');
expect(approved.map(a => a.senderId)).toContain('222');
});
it('listPendingCodes returns only non-expired codes', () => {
vi.useFakeTimers();
const code1 = manager.generateCode('first');
// Advance time so the first code is almost expired
vi.advanceTimersByTime(200_000);
const code2 = manager.generateCode('second');
// Advance past first code's expiry
vi.advanceTimersByTime(100_001);
const pending = manager.listPendingCodes();
expect(pending).toHaveLength(1);
expect(pending[0].code).toBe(code2);
expect(pending[0].label).toBe('second');
});
it('cleanup removes expired codes', () => {
vi.useFakeTimers();
manager.generateCode();
vi.advanceTimersByTime(300_001);
manager.cleanup();
expect(manager.listPendingCodes()).toHaveLength(0);
});
it('enabled getter reflects config', () => {
expect(manager.enabled).toBe(true);
const disabled = new PairingManager({
enabled: false,
codeTtl: 300_000,
codeLength: 6,
});
expect(disabled.enabled).toBe(false);
});
});
+133
View File
@@ -0,0 +1,133 @@
import { randomBytes } from 'crypto';
export interface PairingConfig {
enabled: boolean;
codeTtl: number; // milliseconds
codeLength: number; // number of characters
}
interface PendingCode {
code: string;
createdAt: number;
expiresAt: number;
/** Optional label for the code (e.g. "for alice"). */
label?: string;
}
interface ApprovedSender {
channel: string;
senderId: string;
approvedAt: number;
/** The code that was used. */
codeUsed: string;
}
/**
* Manages DM pairing codes for authenticating unknown senders.
*
* Flow:
* 1. Admin generates a pairing code via gateway API or TUI command.
* 2. Unknown sender DMs the bot with the code as their first message.
* 3. If the code is valid and not expired, the sender is approved.
* 4. Approved senders bypass the allowlist check for subsequent messages.
*/
export class PairingManager {
private config: PairingConfig;
private pendingCodes: Map<string, PendingCode> = new Map();
private approvedSenders: Map<string, ApprovedSender> = new Map();
constructor(config: PairingConfig) {
this.config = config;
}
/** Generate a new pairing code. Returns the code string. */
generateCode(label?: string): string {
this.cleanup();
const code = randomBytes(Math.ceil(this.config.codeLength / 2))
.toString('hex')
.slice(0, this.config.codeLength)
.toUpperCase();
const now = Date.now();
this.pendingCodes.set(code, {
code,
createdAt: now,
expiresAt: now + this.config.codeTtl,
label,
});
return code;
}
/**
* Validate a code for a given channel+sender.
* If valid, adds the sender to the approved list and removes the code.
* Returns true if the code was valid.
*/
validateCode(channel: string, senderId: string, code: string): boolean {
this.cleanup();
const normalizedCode = code.trim().toUpperCase();
const pending = this.pendingCodes.get(normalizedCode);
if (!pending) return false;
if (Date.now() > pending.expiresAt) {
this.pendingCodes.delete(normalizedCode);
return false;
}
// Code is valid — approve the sender
const key = `${channel}:${senderId}`;
this.approvedSenders.set(key, {
channel,
senderId,
approvedAt: Date.now(),
codeUsed: normalizedCode,
});
// Remove the used code
this.pendingCodes.delete(normalizedCode);
return true;
}
/** Check if a sender is already approved. */
isApproved(channel: string, senderId: string): boolean {
const key = `${channel}:${senderId}`;
return this.approvedSenders.has(key);
}
/** 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);
}
/** List all currently approved senders. */
listApproved(): ApprovedSender[] {
return Array.from(this.approvedSenders.values());
}
/** List all pending (non-expired) codes. */
listPendingCodes(): Array<{ code: string; expiresAt: number; label?: string }> {
this.cleanup();
return Array.from(this.pendingCodes.values()).map(p => ({
code: p.code,
expiresAt: p.expiresAt,
label: p.label,
}));
}
/** Remove expired codes. */
cleanup(): void {
const now = Date.now();
for (const [code, pending] of this.pendingCodes) {
if (now > pending.expiresAt) {
this.pendingCodes.delete(code);
}
}
}
/** Whether pairing is enabled. */
get enabled(): boolean {
return this.config.enabled;
}
}