6090508bad
- Add curly braces to all if/else/for/while statements - Fix indentation and trailing spaces - Auto-fixed 372 linting errors using eslint --fix - Remaining issues are warnings only (non-null assertions, explicit any types)
154 lines
4.2 KiB
TypeScript
154 lines
4.2 KiB
TypeScript
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;
|
|
}
|
|
|
|
export interface ApprovedSender {
|
|
channel: string;
|
|
senderId: string;
|
|
approvedAt: number;
|
|
/** The code that was used. */
|
|
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.
|
|
*
|
|
* 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();
|
|
private store?: PairingStore;
|
|
|
|
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. */
|
|
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}`;
|
|
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);
|
|
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}`;
|
|
const deleted = this.approvedSenders.delete(key);
|
|
if (deleted) {
|
|
this.store?.removeApproved(channel, senderId);
|
|
}
|
|
return deleted;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
}
|