// A bot's API: one key per bot, off by default, that lets an outside caller send the bot a message through the webhook // receiver and read its reply. The key is random, shown once and kept hashed only, like a webhook secret. import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { mkdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { z } from "zod"; import { writeFileAtomic } from "./atomic.ts"; import { DATA_DIR } from "./config.ts"; import { defuse } from "./records-block.ts"; import { redactSecretsInText } from "../shared/redact.ts"; import { API_WHO, AUTHORITY, fromNote } from "./provenance-label.ts"; import { parseJson } from "./schema.ts"; export const BOT_API_RATE_LIMIT = 10; const RATE_WINDOW_MS = 60_000; /** Turns one bot's API may have running at once before a new call is refused. */ export const BOT_API_MAX_PENDING = 50; export const BOT_API_MAX_MESSAGE_CHARS = 20_000; /** Per bot: one bot's callers can't push another's calls out. */ const MAX_ATTEMPTS = 200; const SAVE_AFTER_MS = 1_000; const KEY_PREFIX = "bak_"; const REFUSED_BURST_MS = 15 * 60_000; const recordSchema = z.object({ botId: z.string().min(1), keyHash: z.string().regex(/^[a-f0-9]{64}$/), /** Whose key it is, on a Team Cloud: it goes when they leave. */ mintedBy: z.string().optional(), createdAt: z.number().finite().nonnegative(), keyCreatedAt: z.number().finite().nonnegative(), callCount: z.number().int().nonnegative(), lastCalledAt: z.number().finite().nonnegative().optional(), }); const attemptSchema = z.object({ id: z.string().min(1), botId: z.string().min(1), receivedAt: z.number().finite().nonnegative(), outcome: z.enum(["accepted", "rejected"]), statusCode: z.number().int().min(100).max(599), threadId: z.string().optional(), preview: z.string().optional(), reason: z.string().optional(), repeats: z.number().int().positive().optional(), }); const fileSchema = z.object({ version: z.literal(1), bots: z.array(recordSchema), attempts: z.array(attemptSchema).optional(), }); type StoredBotApi = z.output; export type BotApiAttempt = z.output; /** What the app may see: never the hash. */ export interface BotApiStatus { enabled: boolean; keyCreatedAt?: number; callCount: number; lastCalledAt?: number; } export interface BotApiManagerOptions { file?: string; now?: () => number; } export function hashBotApiKey(key: string): string { return createHash("sha256").update(key).digest("hex"); } function newKey(): string { return `${KEY_PREFIX}${randomBytes(32).toString("base64url")}`; } export class BotApiManager { private readonly file: string; private readonly now: () => number; private bots: StoredBotApi[] = []; private attempts: BotApiAttempt[] = []; private rate = new Map(); private saveTimer: ReturnType | null = null; /** Saves are refused while set, so an empty state never overwrites an unreadable file. */ private unreadable = false; constructor(options: BotApiManagerOptions = {}) { this.file = options.file ?? join(DATA_DIR, "bot-api.json"); this.now = options.now ?? Date.now; try { const parsed = fileSchema.safeParse(parseJson(readFileSync(this.file, "utf8"))); if (!parsed.success) throw parsed.error; this.bots = parsed.data.bots; this.attempts = parsed.data.attempts ?? []; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { this.unreadable = true; console.error(`bot api: ignoring unreadable ${this.file}: ${error instanceof SyntaxError ? "invalid JSON" : error instanceof Error ? error.message : "unable to read"}`); } } } status(botId: string): BotApiStatus { const record = this.bots.find((candidate) => candidate.botId === botId); if (!record) return { enabled: false, callCount: 0 }; return { enabled: true, keyCreatedAt: record.keyCreatedAt, callCount: record.callCount, ...(record.lastCalledAt !== undefined ? { lastCalledAt: record.lastCalledAt } : {}) }; } /** Turns the API on with a fresh key, or replaces the key of one already on. The key is returned once and never kept. */ enable(botId: string, mintedBy?: string): { key: string; status: BotApiStatus } { this.assertWritable(); const key = newKey(); const now = this.now(); const record = this.bots.find((candidate) => candidate.botId === botId); if (record) { record.keyHash = hashBotApiKey(key); record.keyCreatedAt = now; if (mintedBy) record.mintedBy = mintedBy; else delete record.mintedBy; } else { this.bots.unshift({ botId, keyHash: hashBotApiKey(key), ...(mintedBy ? { mintedBy } : {}), createdAt: now, keyCreatedAt: now, callCount: 0 }); } this.rate.delete(botId); this.save(); return { key, status: this.status(botId) }; } disable(botId: string): boolean { const at = this.bots.findIndex((candidate) => candidate.botId === botId); if (at === -1) return false; this.assertWritable(); this.bots.splice(at, 1); this.rate.delete(botId); this.save(); return true; } /** A teammate who left takes the keys they made: whoever still holds one was given it by them. */ revokeMintedBy(person: string): string[] { const gone = this.bots.filter((record) => record.mintedBy === person).map((record) => record.botId); for (const botId of gone) this.disable(botId); return gone; } /** A deleted bot's API goes with it, its calls included. */ removeForBot(botId: string): void { if (!this.bots.some((candidate) => candidate.botId === botId) && !this.attempts.some((attempt) => attempt.botId === botId)) return; this.assertWritable(); this.bots = this.bots.filter((candidate) => candidate.botId !== botId); this.attempts = this.attempts.filter((attempt) => attempt.botId !== botId); this.rate.delete(botId); this.save(); } enabled(botId: string): boolean { return this.bots.some((candidate) => candidate.botId === botId); } /** Constant-time: a wrong key costs the same as a right one, whatever it shares with it. */ authorize(botId: string, key: string): boolean { const record = this.bots.find((candidate) => candidate.botId === botId); if (!record || !key) return false; const actual = Buffer.from(hashBotApiKey(key), "hex"); const expected = Buffer.from(record.keyHash, "hex"); return actual.length === expected.length && timingSafeEqual(actual, expected); } /** Counts a call against the key's minute; false once the minute's calls are used up. */ admit(botId: string): boolean { const now = this.now(); const recent = (this.rate.get(botId) ?? []).filter((at) => now - at < RATE_WINDOW_MS); if (recent.length >= BOT_API_RATE_LIMIT) return false; recent.push(now); this.rate.set(botId, recent); return true; } /** The calls since it was last switched on, which is what its count counts. */ callsSinceOn(botId: string): BotApiAttempt[] { const record = this.bots.find((candidate) => candidate.botId === botId); return record ? this.listAttempts(botId).filter((attempt) => attempt.receivedAt >= record.createdAt) : []; } listAttempts(botId: string): BotApiAttempt[] { return this.attempts.filter((attempt) => attempt.botId === botId).map((attempt) => ({ ...attempt })); } record(botId: string, details: Omit): BotApiAttempt { const attempt: BotApiAttempt = { id: randomUUID(), botId, receivedAt: this.now(), outcome: details.outcome, statusCode: details.statusCode }; if (details.threadId) attempt.threadId = details.threadId; if (details.preview) attempt.preview = redactSecretsInText(details.preview).replace(/\s+/g, " ").trim().slice(0, 200); if (details.reason) attempt.reason = redactSecretsInText(details.reason).slice(0, 500); this.keep(attempt); const record = this.bots.find((candidate) => candidate.botId === botId); if (record && details.outcome === "accepted") { record.callCount += 1; record.lastCalledAt = attempt.receivedAt; } this.saveSoon(); return attempt; } /** Anyone who can reach the receiver can send these: one rolling row per burst, in memory until the next real change. */ recordRefused(botId: string, statusCode: 401 | 403, reason: string): BotApiAttempt { const at = this.attempts.findLastIndex((attempt) => attempt.botId === botId); const burst = at === -1 ? undefined : this.attempts[at]!; if (burst?.outcome === "rejected" && burst.statusCode === statusCode && this.now() - burst.receivedAt <= REFUSED_BURST_MS) { burst.receivedAt = this.now(); burst.repeats = (burst.repeats ?? 1) + 1; return burst; } const attempt: BotApiAttempt = { id: randomUUID(), botId, receivedAt: this.now(), outcome: "rejected", statusCode, reason }; this.keep(attempt); return attempt; } private keep(attempt: BotApiAttempt): void { this.attempts.push(attempt); const own = this.attempts.filter((candidate) => candidate.botId === attempt.botId); if (own.length > MAX_ATTEMPTS) this.attempts.splice(this.attempts.indexOf(own[0]!), 1); } /** A caller decides how often this runs, so it never writes per call: a synchronous save would block the server. */ private saveSoon(): void { if (this.saveTimer) return; this.saveTimer = setTimeout(() => { this.saveTimer = null; this.save(); }, SAVE_AFTER_MS); this.saveTimer.unref?.(); } flush(): void { if (!this.saveTimer) return; clearTimeout(this.saveTimer); this.saveTimer = null; this.save(); } private assertWritable(): void { if (this.unreadable) throw Object.assign(new Error("The saved Bot API settings could not be read. Repair bot-api.json before changing them."), { status: 503 }); } private save(): void { if (this.unreadable) return; if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; } mkdirSync(dirname(this.file), { recursive: true }); writeFileAtomic(this.file, JSON.stringify({ version: 1, bots: this.bots, attempts: this.attempts }, null, 2), { mode: 0o600 }); } } export const BOT_API_MESSAGE_DATA = "[UNTRUSTED BOT API MESSAGE]"; /** Security: the tag names a stored file for the turn to load, so a caller's copy of it never reaches the turn. */ export function withoutAttachedImages(message: string): string { return defuse(message).replace(/<(\s*attached-image)/gi, "($1"); } /** The caller's words go to the model as data from outside, never as the person's request, and can't pass for a note. */ export function botApiPrompt(message: string, receivedAt: number): string { return [ fromNote(API_WHO, "message", AUTHORITY.info), BOT_API_MESSAGE_DATA, defuse(`Received: ${new Date(receivedAt).toISOString()}`), "", withoutAttachedImages(message), "[/UNTRUSTED BOT API MESSAGE]", ].join("\n"); }