Ctrl K
ComunidadGuías…

server/bot-api.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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<typeof recordSchema>;
export type BotApiAttempt = z.output<typeof attemptSchema>;

/** 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<string, number[]>();
  private saveTimer: ReturnType<typeof setTimeout> | 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, "id" | "botId" | "receivedAt">): 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");
}