feat: complete DM pairing codes with channel adapters, gateway handlers, and TUI command (Tier 4 feature 4)

This commit is contained in:
William Valentin
2026-02-09 18:28:10 -08:00
parent 9d4d440ecf
commit 1e29da4da2
11 changed files with 270 additions and 7 deletions
+74
View File
@@ -5,6 +5,8 @@ import { createSessionHandlers } from './sessions.js';
import { createToolHandlers } from './tools.js';
import { createAgentHandlers } from './agent.js';
import { createConfigHandlers, redactConfig } from './config.js';
import { createPairingHandlers } from './pairing.js';
import { PairingManager } from '../../channels/pairing.js';
import { LaneQueue } from '../lane-queue.js';
import { ErrorCode } from '../protocol.js';
import type { GatewayRequest, GatewayResponse, GatewayError, GatewayEvent, OutboundMessage } from '../protocol.js';
@@ -747,3 +749,75 @@ describe('redactConfig comprehensive credential redaction', () => {
expect(config.server.token).toBe('bearer-secret');
});
});
describe('pairing handlers', () => {
let pm: PairingManager;
let handlers: ReturnType<typeof createPairingHandlers>;
beforeEach(() => {
pm = new PairingManager({ enabled: true, codeTtl: 300_000, codeLength: 6 });
handlers = createPairingHandlers({ pairingManager: pm });
});
it('pairing.generate returns a code and expiry', async () => {
const req: GatewayRequest = { id: 1, method: 'pairing.generate', params: { label: 'for alice' } };
const result = await handlers['pairing.generate'](req) as GatewayResponse;
const r = result.result as { code: string; expiresAt: number };
expect(r.code).toHaveLength(6);
expect(r.expiresAt).toBeGreaterThan(Date.now());
});
it('pairing.generate works without label', async () => {
const req: GatewayRequest = { id: 2, method: 'pairing.generate' };
const result = await handlers['pairing.generate'](req) as GatewayResponse;
const r = result.result as { code: string; expiresAt: number };
expect(r.code).toHaveLength(6);
});
it('pairing.list returns pending codes and approved senders', async () => {
// Generate a code first
pm.generateCode('test');
// Approve a sender
const code = pm.generateCode('for bob');
pm.validateCode('telegram', '12345', code);
const req: GatewayRequest = { id: 3, method: 'pairing.list' };
const result = await handlers['pairing.list'](req) as GatewayResponse;
const r = result.result as { pending: unknown[]; approved: unknown[] };
expect(r.pending).toHaveLength(1); // one code remaining (the other was consumed)
expect(r.approved).toHaveLength(1);
});
it('pairing.revoke removes an approved sender', async () => {
// Approve a sender
const code = pm.generateCode();
pm.validateCode('discord', 'chan-1', code);
expect(pm.isApproved('discord', 'chan-1')).toBe(true);
const req: GatewayRequest = { id: 4, method: 'pairing.revoke', params: { channel: 'discord', senderId: 'chan-1' } };
const result = await handlers['pairing.revoke'](req) as GatewayResponse;
const r = result.result as { revoked: boolean };
expect(r.revoked).toBe(true);
expect(pm.isApproved('discord', 'chan-1')).toBe(false);
});
it('pairing.revoke returns false for unknown sender', async () => {
const req: GatewayRequest = { id: 5, method: 'pairing.revoke', params: { channel: 'telegram', senderId: 'unknown' } };
const result = await handlers['pairing.revoke'](req) as GatewayResponse;
const r = result.result as { revoked: boolean };
expect(r.revoked).toBe(false);
});
it('pairing.revoke requires channel and senderId', async () => {
const req: GatewayRequest = { id: 6, method: 'pairing.revoke', params: {} };
const result = await handlers['pairing.revoke'](req) as GatewayError;
expect(result.error.code).toBe(ErrorCode.InvalidRequest);
expect(result.error.message).toContain('channel');
});
});
+2
View File
@@ -8,3 +8,5 @@ export { createAgentHandlers } from './agent.js';
export type { AgentHandlerDeps } from './agent.js';
export { createConfigHandlers } from './config.js';
export type { ConfigHandlerDeps } from './config.js';
export { createPairingHandlers } from './pairing.js';
export type { PairingHandlerDeps } from './pairing.js';
+42
View File
@@ -0,0 +1,42 @@
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
import { makeResponse, makeError, ErrorCode } from '../protocol.js';
import type { PairingManager } from '../../channels/pairing.js';
export interface PairingHandlerDeps {
pairingManager: PairingManager;
}
export function createPairingHandlers(deps: PairingHandlerDeps) {
return {
'pairing.generate': async (request: GatewayRequest): Promise<OutboundMessage> => {
const label = request.params?.label as string | undefined;
const code = deps.pairingManager.generateCode(label);
const pending = deps.pairingManager.listPendingCodes();
const entry = pending.find(p => p.code === code);
return makeResponse(request.id, {
code,
expiresAt: entry?.expiresAt ?? null,
});
},
'pairing.list': async (request: GatewayRequest): Promise<OutboundMessage> => {
return makeResponse(request.id, {
pending: deps.pairingManager.listPendingCodes(),
approved: deps.pairingManager.listApproved(),
});
},
'pairing.revoke': async (request: GatewayRequest): Promise<OutboundMessage> => {
const channel = request.params?.channel as string | undefined;
const senderId = request.params?.senderId as string | undefined;
if (!channel || !senderId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'Missing required params: channel, senderId');
}
const revoked = deps.pairingManager.revokeApproval(channel, senderId);
return makeResponse(request.id, { revoked });
},
};
}
+12
View File
@@ -21,6 +21,7 @@ import {
createToolHandlers,
createAgentHandlers,
createConfigHandlers,
createPairingHandlers,
} from './handlers/index.js';
import type { TokenUsageEntry } from './handlers/system.js';
import type { SessionManager } from '../session/manager.js';
@@ -29,6 +30,7 @@ import type { ToolRegistry } from '../tools/registry.js';
import type { ToolExecutor } from '../tools/executor.js';
import type { WebhookHandler } from '../automation/webhooks.js';
import type { GmailWatcher } from '../automation/gmail.js';
import type { PairingManager } from '../channels/pairing.js';
export interface GatewayServerConfig {
port: number;
@@ -55,6 +57,8 @@ export interface GatewayServerConfig {
gmailHandler?: GmailWatcher;
/** Optional callback to retrieve per-session token usage data for the dashboard. */
getTokenUsage?: () => TokenUsageEntry[];
/** Optional pairing manager for DM pairing code management via gateway. */
pairingManager?: PairingManager;
}
export class GatewayServer {
@@ -124,6 +128,14 @@ export class GatewayServer {
}
}
// Pairing handlers (only if pairing manager is provided)
if (this.config.pairingManager) {
const pairingHandlers = createPairingHandlers({ pairingManager: this.config.pairingManager });
for (const [method, handler] of Object.entries(pairingHandlers)) {
this.router.register(method, handler);
}
}
// Register all methods
for (const [method, handler] of Object.entries(systemHandlers)) {
this.router.register(method, handler);