Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-cli/src/safety.ts

Snapshot at 34e3175

Containing directory

4,141 bytes · typescriptView raw
1export class CliError extends Error {}
2
3export function record(value: unknown): value is Record<string, unknown> {
4  return value !== null && typeof value === "object" && !Array.isArray(value);
5}
6
7export function slug(value: string | undefined, max = 48): string {
8  if (!value || value.length > max || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value))
9    throw new CliError("Expected a lowercase ID with single internal hyphens.");
10  return value;
11}
12
13export function endpoint(value: unknown, originOnly = false): URL {
14  if (typeof value !== "string") throw new CliError("A base URL is required.");
15  let url: URL;
16  try {
17    url = new URL(value);
18  } catch {
19    throw new CliError("Invalid endpoint URL.");
20  }
21  const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
22  if (
23    (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ||
24    url.username ||
25    url.password ||
26    url.search ||
27    url.hash ||
28    (originOnly && url.pathname !== "/")
29  )
30    throw new CliError(
31      "Use an HTTPS origin (loopback HTTP is for fixtures only), without credentials, query or fragment.",
32    );
33  return url;
34}
35
36// Redact credential fields at every depth, plus credentials embedded in ordinary text/keys.
37// Error bodies are never rendered at all. Core's agent API tokens have the api_ prefix.
38export function redact(value: unknown, owner: string): unknown {
39  const clean = (text: string) =>
40    (owner ? text.split(owner).join("[REDACTED]") : text)
41      .replace(/api_[a-zA-Z0-9_-]+/g, "[REDACTED]")
42      .replace(/Bearer\s+[^\s"\\]+/gi, "Bearer [REDACTED]")
43      .replace(/\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, "[REDACTED]");
44  if (typeof value === "string") return clean(value);
45  if (Array.isArray(value)) return value.map((item) => redact(item, owner));
46  if (record(value))
47    return Object.fromEntries(
48      Object.entries(value).map(([key, item]) => [
49        clean(key),
50        /token|secret|password|credential|authorization|cookie|hash/i.test(key)
51          ? "[REDACTED]"
52          : redact(item, owner),
53      ]),
54    );
55  return value;
56}
57
58export class Api {
59  private base: URL;
60  private owner: string;
61  private signal: AbortSignal;
62
63  constructor(base: URL, owner: string, signal: AbortSignal) {
64    this.base = base;
65    this.owner = owner;
66    this.signal = signal;
67  }
68
69  async request(path: string, data?: unknown): Promise<Record<string, unknown>> {
70    const encoded = data === undefined ? undefined : JSON.stringify(data);
71    if (encoded && Buffer.byteLength(encoded) > 16384)
72      throw new CliError("Request exceeds core's 16 KiB body limit.");
73    try {
74      const response = await fetch(new URL(path, this.base), {
75        method: encoded === undefined ? "GET" : "POST",
76        headers: { authorization: `Bearer ${this.owner}`, "content-type": "application/json" },
77        body: encoded,
78        redirect: "manual",
79        signal: AbortSignal.any([this.signal, AbortSignal.timeout(25000)]),
80      });
81      if (!response.ok) {
82        await response.body?.cancel();
83        throw new CliError(
84          `Core returned HTTP ${response.status}; inspect state before retrying writes.`,
85        );
86      }
87      const reader = response.body?.getReader();
88      if (!reader) throw new CliError("Core returned an empty response.");
89      let size = 0;
90      const chunks: Uint8Array[] = [];
91      while (true) {
92        const { done, value } = await reader.read();
93        if (done) break;
94        size += value.byteLength;
95        if (size > 1048576) {
96          await reader.cancel();
97          throw new CliError("Core response exceeds 1 MiB.");
98        }
99        chunks.push(value);
100      }
101      const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
102      if (!record(parsed)) throw new CliError("Core returned an invalid JSON object.");
103      return parsed;
104    } catch (error) {
105      if (error instanceof CliError) throw error;
106      // Native fetch/JSON errors may include URLs, credentials or response text.
107      throw new CliError("Core request failed or timed out; inspect state before retrying writes.");
108    }
109  }
110}
111