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.

130 lines · 5.8 KBRaw History
1export const now = (): string => new Date().toISOString();
2
3export function newId(): string {
4 return crypto.randomUUID().replace(/-/g, "");
5}
6
7export function randomToken(bytes = 24): string {
8 const b = crypto.getRandomValues(new Uint8Array(bytes));
9 return b64url(b);
10}
11
12const ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
13
14/** `length` random letters and digits (no `_` or `-`), drawn without bias. */
15export function randomAlnum(length: number): string {
16 let out = "";
17 while (out.length < length) {
18 for (const v of crypto.getRandomValues(new Uint8Array(length))) {
19 // 248 = 4 × 62: values above it would favour the first characters.
20 if (v < 248 && out.length < length) out += ALNUM[v % 62];
21 }
22 }
23 return out;
24}
25
26export function b64url(b: Uint8Array): string {
27 let s = "";
28 for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]!);
29 return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
30}
31
32export function fromB64url(s: string): Uint8Array {
33 const pad = s.length % 4 ? "=".repeat(4 - (s.length % 4)) : "";
34 const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
35 const out = new Uint8Array(bin.length);
36 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
37 return out;
38}
39
40export function b64(b: Uint8Array): string {
41 let s = "";
42 for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]!);
43 return btoa(s);
44}
45
46export function fromB64(s: string): Uint8Array {
47 const bin = atob(s);
48 const out = new Uint8Array(bin.length);
49 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
50 return out;
51}
52
53export async function sha256(s: string): Promise<string> {
54 const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
55 return [...new Uint8Array(d)].map((x) => x.toString(16).padStart(2, "0")).join("");
56}
57
58export async function hmacSha256Hex(secret: string, body: string | Uint8Array): Promise<string> {
59 const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
60 const data = typeof body === "string" ? new TextEncoder().encode(body) : body;
61 const sig = await crypto.subtle.sign("HMAC", key, data as BufferSource);
62 return [...new Uint8Array(sig)].map((x) => x.toString(16).padStart(2, "0")).join("");
63}
64
65/** Constant-time string comparison. */
66export function safeEqual(a: string, b: string): boolean {
67 if (a.length !== b.length) return false;
68 let r = 0;
69 for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
70 return r === 0;
71}
72
73/** GitHub's limits on issue, pull request and comment text. */
74export const MAX_TITLE = 256;
75export const MAX_BODY = 65_536;
76
77/** Refuses a title or body over GitHub's limits, with GitHub's error. */
78export function checkText(resource: string, text: { title?: unknown; body?: unknown }): void {
79 for (const [field, max] of [["title", MAX_TITLE], ["body", MAX_BODY]] as const) {
80 const v = text[field];
81 if (typeof v === "string" && v.length > max) throw new HttpError(422, `${field} is too long (maximum is ${max} characters)`, { errors: [{ resource, code: "custom", field }] });
82 }
83}
84
85export class HttpError extends Error {
86 /** The message without the status prefix. */
87 readonly detail: string;
88 constructor(public status: number, message: string, public extra?: Record<string, unknown>) {
89 // The status is part of the message so it survives Durable Object RPC boundaries.
90 super(`${status}: ${message}`);
91 this.detail = message;
92 }
93}
94
95/**
96 * The address commits made in Diffinery (web edits, merges, the merge queue, sandboxes) carry for
97 * an account: <login>@users.noreply.<host of PUBLIC_URL>, like GitHub's users.noreply.github.com.
98 * On diffinery.com that is <login>@users.noreply.diffinery.com; a self-hosted instance uses its own host.
99 */
100export function noreplyEmail(publicUrl: string, login: string): string {
101 return `${login}@users.noreply.${noreplyHost(publicUrl)}`;
102}
103
104/** The instance's own host for noreply addresses: PUBLIC_URL's host name, without a port. */
105export function noreplyHost(publicUrl: string): string {
106 try {
107 return new URL(publicUrl).hostname || "localhost";
108 } catch {
109 return "localhost";
110 }
111}
112
113/** Top-level paths the app serves; no account may use them as its login. */
114export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "install", "install.sh", "install.ps1", "install-editor", "install-editor.sh", "install-editor.ps1", "cli", "oauth", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "features", "agents", "performance", "compare", "pricing", "blog", "changelog", "customers", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "early-access", "security", "status", "faq", "built-on-cloudflare", "open-source", "v2", "imports", "apps", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]);
115
116/**
117 * Who a deleted account's issues, pull requests and comments are attributed to, as on GitHub. Not an account:
118 * pages show the login, and its profile page says what it means (accounts/lifecycle.ts).
119 */
120export const GHOST = { id: "ghost", login: "ghost", kind: "user" } as const;
121
122export const isValidLogin = (s: string): boolean => isValidName(s) && !s.includes(".") && !RESERVED_LOGINS.has(s.toLowerCase());
123
124export const isValidName = (s: string): boolean => /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(s) && !s.endsWith(".git") && s !== "." && s !== "..";
125
126export function isValidRefName(ref: string): boolean {
127 if (!ref.startsWith("refs/")) return false;
128 if (/(^|\/)\.|\.\.|[\x00-\x20~^:?*[\\\x7f]|@\{|\/\/|\/$|\.lock(\/|$)|\.$/.test(ref)) return false;
129 return true;
130}