// Access policy storage: pairing, allowlists, group policies, and delivery/UX // config. All state in access.json — managed by the /zalo:access skill (no MCP // tool mutates access; that keeps mutations out of reach of prompt injection // via channel messages). import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs' import { STATE_DIR, ACCESS_FILE, STATIC } from './paths.ts' import { log } from './log.ts' export type PendingEntry = { senderId: string chatId: string createdAt: number expiresAt: number replies: number } export type GroupPolicy = { requireMention: boolean allowFrom: string[] } export type Access = { dmPolicy: 'pairing' | 'allowlist' | 'disabled' allowFrom: string[] groups: Record pending: Record mentionPatterns?: string[] // delivery/UX config — optional, defaults live in the reply handler /** Reaction to add on receipt. Zalo reaction code (e.g. "/-strong") or a mapped emoji (👍 ❤️ …). Empty string disables. */ ackReaction?: string /** Which chunks get Zalo's quote reference when reply_to is passed. Default: 'first'. 'off' = never quote. */ replyToMode?: 'off' | 'first' | 'all' /** Max chars per outbound message before splitting. Default: 2000 (safe under Zalo's cap). */ textChunkLimit?: number /** Split on paragraph boundaries instead of hard char count. */ chunkMode?: 'length' | 'newline' } export const MAX_CHUNK_LIMIT = 2000 function defaultAccess(): Access { return { dmPolicy: 'pairing', allowFrom: [], groups: {}, pending: {}, } } function readAccessFile(): Access { try { const raw = readFileSync(ACCESS_FILE, 'utf8') const parsed = JSON.parse(raw) as Partial return { dmPolicy: parsed.dmPolicy ?? 'pairing', allowFrom: parsed.allowFrom ?? [], groups: parsed.groups ?? {}, pending: parsed.pending ?? {}, mentionPatterns: parsed.mentionPatterns, ackReaction: parsed.ackReaction, replyToMode: parsed.replyToMode, textChunkLimit: parsed.textChunkLimit, chunkMode: parsed.chunkMode, } } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return defaultAccess() try { renameSync(ACCESS_FILE, `${ACCESS_FILE}.corrupt-${Date.now()}`) } catch { } log('access.json is corrupt, moved aside. Starting fresh.') return defaultAccess() } } // In static mode, access is snapshotted at boot and never re-read or written. // Pairing requires runtime mutation, so it's downgraded to allowlist with a // startup warning — handing out codes that never get approved would be worse. const BOOT_ACCESS: Access | null = STATIC ? (() => { const a = readAccessFile() if (a.dmPolicy === 'pairing') { log('static mode — dmPolicy "pairing" downgraded to "allowlist"') a.dmPolicy = 'allowlist' } a.pending = {} return a })() : null export function loadAccess(): Access { return BOOT_ACCESS ?? readAccessFile() } export function saveAccess(a: Access): void { if (STATIC) return mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 }) const tmp = ACCESS_FILE + '.tmp' writeFileSync(tmp, JSON.stringify(a, null, 2) + '\n', { mode: 0o600 }) renameSync(tmp, ACCESS_FILE) } export function pruneExpired(a: Access): boolean { const now = Date.now() let changed = false for (const [code, p] of Object.entries(a.pending)) { if (p.expiresAt < now) { delete a.pending[code] changed = true } } return changed } // Outbound gate — reply/react can only target chats the inbound gate would // deliver from. Zalo DM threadId == peer userId, so allowFrom covers DMs. export function assertAllowedChat(chat_id: string): void { const access = loadAccess() if (access.allowFrom.includes(chat_id)) return if (chat_id in access.groups) return throw new Error(`chat ${chat_id} is not allowlisted — add via /zalo:access`) }