From ecd3aca7c1c01e79d7504ca5f5c8c7a67dfaf24b Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 9 Feb 2026 21:46:51 -0800 Subject: [PATCH] feat(session): add pairing_approved table and getPairingStore() --- src/session/store.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ src/session/store.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/session/store.test.ts b/src/session/store.test.ts index b7e8bed..5e18912 100644 --- a/src/session/store.test.ts +++ b/src/session/store.test.ts @@ -62,4 +62,42 @@ describe('SessionStore', () => { expect(sessions).toContain('session-a'); expect(sessions).toContain('session-b'); }); + + describe('pairing persistence', () => { + it('getPairingStore returns a PairingStore', () => { + const pairingStore = store.getPairingStore(); + expect(pairingStore).toBeDefined(); + expect(pairingStore.loadApproved).toBeInstanceOf(Function); + expect(pairingStore.saveApproved).toBeInstanceOf(Function); + expect(pairingStore.removeApproved).toBeInstanceOf(Function); + }); + + it('saveApproved and loadApproved round-trip', () => { + const ps = store.getPairingStore(); + ps.saveApproved({ channel: 'telegram', senderId: '123', approvedAt: 1000, codeUsed: 'AABB11' }); + ps.saveApproved({ channel: 'discord', senderId: '456', approvedAt: 2000, codeUsed: 'CC33DD' }); + const loaded = ps.loadApproved(); + expect(loaded).toHaveLength(2); + expect(loaded).toEqual(expect.arrayContaining([ + expect.objectContaining({ channel: 'telegram', senderId: '123', codeUsed: 'AABB11' }), + expect.objectContaining({ channel: 'discord', senderId: '456', codeUsed: 'CC33DD' }), + ])); + }); + + it('removeApproved deletes a sender', () => { + const ps = store.getPairingStore(); + ps.saveApproved({ channel: 'telegram', senderId: '123', approvedAt: 1000, codeUsed: 'AABB11' }); + ps.removeApproved('telegram', '123'); + expect(ps.loadApproved()).toHaveLength(0); + }); + + it('saveApproved upserts on duplicate channel+senderId', () => { + const ps = store.getPairingStore(); + ps.saveApproved({ channel: 'telegram', senderId: '123', approvedAt: 1000, codeUsed: 'FIRST1' }); + ps.saveApproved({ channel: 'telegram', senderId: '123', approvedAt: 2000, codeUsed: 'SECOND' }); + const loaded = ps.loadApproved(); + expect(loaded).toHaveLength(1); + expect(loaded[0].codeUsed).toBe('SECOND'); + }); + }); }); diff --git a/src/session/store.ts b/src/session/store.ts index ee904e1..f756924 100644 --- a/src/session/store.ts +++ b/src/session/store.ts @@ -1,5 +1,6 @@ import Database from 'better-sqlite3'; import type { Message } from '../models/types.js'; +import type { PairingStore, ApprovedSender } from '../channels/pairing.js'; /** Parse a duration string like '30d', '7d', '12h' to milliseconds. Returns null if invalid or '0'. */ export function parseDuration(s: string): number | null { @@ -28,6 +29,13 @@ export class SessionStore { created_at INTEGER NOT NULL DEFAULT (unixepoch()) ); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id); + CREATE TABLE IF NOT EXISTS pairing_approved ( + channel TEXT NOT NULL, + sender_id TEXT NOT NULL, + approved_at INTEGER NOT NULL, + code_used TEXT NOT NULL, + PRIMARY KEY (channel, sender_id) + ); `); } @@ -101,6 +109,33 @@ export class SessionStore { return stale.map(r => r.session_id); } + getPairingStore(): PairingStore { + return { + loadApproved: (): ApprovedSender[] => { + const rows = this.db.prepare( + 'SELECT channel, sender_id, approved_at, code_used FROM pairing_approved' + ).all() as Array<{ channel: string; sender_id: string; approved_at: number; code_used: string }>; + return rows.map(r => ({ + channel: r.channel, + senderId: r.sender_id, + approvedAt: r.approved_at, + codeUsed: r.code_used, + })); + }, + saveApproved: (sender: ApprovedSender): void => { + this.db.prepare(` + INSERT OR REPLACE INTO pairing_approved (channel, sender_id, approved_at, code_used) + VALUES (?, ?, ?, ?) + `).run(sender.channel, sender.senderId, sender.approvedAt, sender.codeUsed); + }, + removeApproved: (channel: string, senderId: string): void => { + this.db.prepare( + 'DELETE FROM pairing_approved WHERE channel = ? AND sender_id = ?' + ).run(channel, senderId); + }, + }; + } + close(): void { this.db.close(); }