| 1 | export const now = (): string => new Date().toISOString(); |
| 2 | |
| 3 | export function newId(): string { |
| 4 | return crypto.randomUUID().replace(/-/g, ""); |
| 5 | } |
| 6 | |
| 7 | export function randomToken(bytes = 24): string { |
| 8 | const b = crypto.getRandomValues(new Uint8Array(bytes)); |
| 9 | return b64url(b); |
| 10 | } |
| 11 | |
| 12 | const ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; |
| 13 | |
| 14 | /** `length` random letters and digits (no `_` or `-`), drawn without bias. */ |
| 15 | export 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 | |
| 26 | export 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 | |
| 32 | export 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 | |
| 40 | export 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 | |
| 46 | export 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 | |
| 53 | export 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 | |
| 58 | export 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. */ |
| 66 | export 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. */ |
| 74 | export const MAX_TITLE = 256; |
| 75 | export const MAX_BODY = 65_536; |
| 76 | |
| 77 | /** Refuses a title or body over GitHub's limits, with GitHub's error. */ |
| 78 | export 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 | |
| 85 | export 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 | */ |
| 100 | export 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. */ |
| 105 | export 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. */ |
| 114 | export 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", "privacy", "terms", "legal", "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 | */ |
| 120 | export const GHOST = { id: "ghost", login: "ghost", kind: "user" } as const; |
| 121 | |
| 122 | export const isValidLogin = (s: string): boolean => isValidName(s) && !s.includes(".") && !RESERVED_LOGINS.has(s.toLowerCase()); |
| 123 | |
| 124 | export const isValidName = (s: string): boolean => /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(s) && !s.endsWith(".git") && s !== "." && s !== ".."; |
| 125 | |
| 126 | export 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 | } |