Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-core/src/http.ts
Snapshot at 34e3175
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