Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

72 lines · 3.2 KBRaw History
1import type { Env } from "../env.js";
2
3/**
4 * Unexpected server errors (5xx answers), kept in D1 so site administrators can read them
5 * without Cloudflare log access. A 5xx answer carries its request id, which finds the record.
6 * Records hold the method, route, path, status, message and stack, never request bodies,
7 * query strings or headers; token-like strings in a message or stack are masked.
8 */
9export const KEEP_ERRORS = 500;
10
11export interface ServerError {
12 id: string;
13 at: string;
14 method: string;
15 route: string | null;
16 path: string;
17 status: number;
18 message: string;
19 stack: string | null;
20}
21
22/** The id a 5xx answer reports: Cloudflare's ray id when there is one, so it also finds the request in Cloudflare's logs. */
23export function requestId(req: Request): string {
24 const ray = req.headers.get("cf-ray");
25 return ray && /^[\w-]{8,64}$/.test(ray) ? ray : crypto.randomUUID();
26}
27
28/** Masks credentials an error message might quote: Diffinery and GitHub tokens, bearer and basic auth, and URL user info. */
29export function redact(s: string): string {
30 return s
31 .replace(/\b(dfy|ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{8,}/g, "$1_…")
32 .replace(/\b(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 …")
33 .replace(/(\w+:\/\/)[^/\s@]+@/g, "$1…@");
34}
35
36/** A request path to keep: a route that carries a secret in the path (`/signin/:token`) keeps its pattern instead. */
37export function pathToKeep(path: string, route: string | null): string {
38 return route && /:(token|code|secret|key)\b/i.test(route) ? route : path.slice(0, 500);
39}
40
41export function errorRecord(e: unknown, r: { id: string; method: string; route: string | null; path: string; status: number }): ServerError {
42 const err = e instanceof Error ? e : null;
43 return {
44 id: r.id,
45 at: new Date().toISOString(),
46 method: r.method,
47 route: r.route,
48 path: pathToKeep(r.path, r.route),
49 status: r.status,
50 message: redact(err ? `${err.name}: ${err.message}` : String(e)).slice(0, 2000),
51 stack: err?.stack ? redact(err.stack).slice(0, 8000) : null,
52 };
53}
54
55/** Saves an error and drops all but the newest KEEP_ERRORS. Never throws: the database may be what failed. */
56export async function recordServerError(env: Env, rec: ServerError): Promise<void> {
57 try {
58 await env.DB.prepare("INSERT OR REPLACE INTO server_errors (id, at, method, route, path, status, message, stack) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
59 .bind(rec.id, rec.at, rec.method, rec.route, rec.path, rec.status, rec.message, rec.stack)
60 .run();
61 await env.DB.prepare("DELETE FROM server_errors WHERE at < (SELECT at FROM server_errors ORDER BY at DESC LIMIT 1 OFFSET ?)").bind(KEEP_ERRORS - 1).run();
62 } catch (x) {
63 console.error("could not record a server error", x);
64 }
65}
66
67/** The newest errors first, or the one with request id `id`. */
68export async function serverErrors(env: Env, o: { limit?: number; id?: string } = {}): Promise<ServerError[]> {
69 if (o.id) return (await env.DB.prepare("SELECT * FROM server_errors WHERE id = ?").bind(o.id).all<ServerError>()).results;
70 const limit = Math.min(Math.max(o.limit ?? 100, 1), KEEP_ERRORS);
71 return (await env.DB.prepare("SELECT * FROM server_errors ORDER BY at DESC LIMIT ?").bind(limit).all<ServerError>()).results;
72}