| 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 | export function b64url(b: Uint8Array): string { |
| 13 | let s = ""; |
| 14 | for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]!); |
| 15 | return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 16 | } |
| 17 | |
| 18 | export function fromB64url(s: string): Uint8Array { |
| 19 | const pad = s.length % 4 ? "=".repeat(4 - (s.length % 4)) : ""; |
| 20 | const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad); |
| 21 | const out = new Uint8Array(bin.length); |
| 22 | for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); |
| 23 | return out; |
| 24 | } |
| 25 | |
| 26 | export function b64(b: Uint8Array): string { |
| 27 | let s = ""; |
| 28 | for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]!); |
| 29 | return btoa(s); |
| 30 | } |
| 31 | |
| 32 | export function fromB64(s: string): Uint8Array { |
| 33 | const bin = atob(s); |
| 34 | const out = new Uint8Array(bin.length); |
| 35 | for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); |
| 36 | return out; |
| 37 | } |
| 38 | |
| 39 | export async function sha256(s: string): Promise<string> { |
| 40 | const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); |
| 41 | return [...new Uint8Array(d)].map((x) => x.toString(16).padStart(2, "0")).join(""); |
| 42 | } |
| 43 | |
| 44 | export async function hmacSha256Hex(secret: string, body: string | Uint8Array): Promise<string> { |
| 45 | const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); |
| 46 | const data = typeof body === "string" ? new TextEncoder().encode(body) : body; |
| 47 | const sig = await crypto.subtle.sign("HMAC", key, data as BufferSource); |
| 48 | return [...new Uint8Array(sig)].map((x) => x.toString(16).padStart(2, "0")).join(""); |
| 49 | } |
| 50 | |
| 51 | /** Constant-time string comparison. */ |
| 52 | export function safeEqual(a: string, b: string): boolean { |
| 53 | if (a.length !== b.length) return false; |
| 54 | let r = 0; |
| 55 | for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i); |
| 56 | return r === 0; |
| 57 | } |
| 58 | |
| 59 | export class HttpError extends Error { |
| 60 | /** The message without the status prefix. */ |
| 61 | readonly detail: string; |
| 62 | constructor(public status: number, message: string, public extra?: Record<string, unknown>) { |
| 63 | // The status is part of the message so it survives Durable Object RPC boundaries. |
| 64 | super(`${status}: ${message}`); |
| 65 | this.detail = message; |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /** Top-level paths the app serves; no account may use them as its login. */ |
| 70 | export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "security", "-"]); |
| 71 | |
| 72 | export const isValidLogin = (s: string): boolean => isValidName(s) && !s.includes(".") && !RESERVED_LOGINS.has(s.toLowerCase()); |
| 73 | |
| 74 | export const isValidName = (s: string): boolean => /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(s) && !s.endsWith(".git") && s !== "." && s !== ".."; |
| 75 | |
| 76 | export function isValidRefName(ref: string): boolean { |
| 77 | if (!ref.startsWith("refs/")) return false; |
| 78 | if (/(^|\/)\.|\.\.|[\x00-\x20~^:?*[\\\x7f]|@\{|\/\/|\/$|\.lock(\/|$)|\.$/.test(ref)) return false; |
| 79 | return true; |
| 80 | } |