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

packages/forkhorse-core/src/http.ts

Snapshot at 34e3175

Containing directory

5,346 bytes · typescriptView raw
1export class HttpError extends Error {
2  constructor(
3    public status: number,
4    message: string,
5  ) {
6    super(message);
7  }
8}
9
10export function json(value: unknown, status = 200): Response {
11  return Response.json(value, { status, headers: { "cache-control": "no-store" } });
12}
13
14const operations = [
15  "request", "project.create", "project.join", "fork.commit", "review.create",
16  "review.approve", "review.promote", "dashboard", "oauth",
17] as const;
18const stages = ["heads", "prepare", "pending", "token", "push", "ledger"] as const;
19export interface ErrorContext {
20  op: typeof operations[number];
21  stage?: typeof stages[number];
22}
23
24/** Static labels only: project IDs, fork names, review IDs and query strings never enter logs. */
25export function operation(request: Request): ErrorContext["op"] {
26  const path = new URL(request.url).pathname;
27  if (request.method === "POST") {
28    if (path === "/projects") return "project.create";
29    if (/^\/projects\/[^/]+\/join$/.test(path)) return "project.join";
30    if (/^\/projects\/[^/]+\/forks\/[^/]+\/commit$/.test(path)) return "fork.commit";
31    if (/^\/projects\/[^/]+\/reviews$/.test(path)) return "review.create";
32    if (/^\/projects\/[^/]+\/reviews\/[^/]+\/approve$/.test(path)) return "review.approve";
33    if (/^\/projects\/[^/]+\/reviews\/[^/]+\/promote$/.test(path)) return "review.promote";
34  }
35  if (path === "/app" || path.startsWith("/app/")) return "dashboard";
36  if (path === "/mcp" || path === "/authorize" || path.startsWith("/oauth/") || path.startsWith("/.well-known/")) return "oauth";
37  return "request";
38}
39
40function errorField(error: unknown, key: string): unknown {
41  if ((typeof error !== "object" || error === null) && typeof error !== "function") return;
42  try {
43    return Reflect.get(error, key);
44  } catch {
45    // A provider's accessor must not hide the original failure or expose its exception.
46    return;
47  }
48}
49function tag(value: unknown): string | undefined {
50  // Test every character, including trailing newlines; never coerce provider values.
51  return typeof value === "string" && value.length >= 1 && value.length <= 64 &&
52    !/[^A-Za-z0-9_.-]/.test(value) ? value : undefined;
53}
54function httpStatus(value: unknown): number | undefined {
55  return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599
56    ? value : undefined;
57}
58
59export function errorResponse(error: unknown, context: ErrorContext = { op: "request" }): Response {
60  // Provider messages, stacks and nested objects never enter either output.
61  if (error instanceof HttpError) return json({ error: error.message }, error.status);
62  const id = crypto.randomUUID();
63  console.error(JSON.stringify({
64    event: "core.error",
65    id,
66    op: operations.includes(context.op) ? context.op : "request",
67    stage: context.stage && stages.includes(context.stage) ? context.stage : undefined,
68    name: tag(errorField(error, "name")),
69    code: tag(errorField(error, "code")),
70    status: httpStatus(errorField(error, "status")) ?? httpStatus(errorField(error, "statusCode")),
71  }));
72  return json({
73    error: "Repository operation failed; retry or ask the owner to inspect it.",
74    diagnosticId: id,
75  }, 502);
76}
77
78export function isRecord(value: unknown): value is Record<string, unknown> {
79  return typeof value === "object" && value !== null && !Array.isArray(value);
80}
81
82export function slug(value: unknown, max = 48): value is string {
83  return (
84    typeof value === "string" && value.length <= max && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)
85  );
86}
87
88export async function body(request: Request, maxBytes = 16384): Promise<Record<string, unknown>> {
89  // Bound buffering even when Content-Length is absent or dishonest.
90  const reader = request.body?.getReader();
91  if (!reader) throw new HttpError(400, "A JSON object is required.");
92  const chunks: Uint8Array[] = [];
93  let size = 0;
94  while (true) {
95    const { value, done } = await reader.read();
96    if (done) break;
97    size += value.byteLength;
98    if (size > maxBytes) {
99      await reader.cancel();
100      throw new HttpError(413, `Request body exceeds ${maxBytes} bytes.`);
101    }
102    chunks.push(value);
103  }
104  const bytes = new Uint8Array(size);
105  let offset = 0;
106  for (const chunk of chunks) {
107    bytes.set(chunk, offset);
108    offset += chunk.byteLength;
109  }
110  try {
111    const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
112    if (isRecord(parsed)) return parsed;
113  } catch {
114    /* Report a bounded public error below. */
115  }
116  throw new HttpError(400, "A JSON object is required.");
117}
118
119export async function digest(value: string): Promise<string> {
120  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
121  return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
122}
123
124export async function bearerHash(request: Request): Promise<string> {
125  const match = /^Bearer ([^\s]{1,512})$/i.exec(request.headers.get("authorization") ?? "");
126  if (!match?.[1]) throw new HttpError(401, "A bearer token is required.");
127  return digest(match[1]);
128}
129
130export function equalHash(a: string, b: string): boolean {
131  if (!/^[0-9a-f]{64}$/.test(a) || !/^[0-9a-f]{64}$/.test(b)) return false;
132  let difference = 0;
133  for (let i = 0; i < 64; i++) difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
134  return difference === 0;
135}
136