| 1 | import type { Context } from "hono"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import type { Principal } from "../auth/principal.js"; |
| 4 | |
| 5 | /** |
| 6 | * Request rate limits (Workers rate limiting bindings, configured in wrangler.jsonc). Each |
| 7 | * binding counts per key over 60 seconds in the Cloudflare location that serves the request, so |
| 8 | * the limits stop floods and scripted abuse rather than meter use exactly. A binding that isn't |
| 9 | * configured (local development) or that fails lets the request through. |
| 10 | * |
| 11 | * - RL_AUTH: sign-in, passkey registration, invitation and sign-in links, open sign-up, per IP address. |
| 12 | * - RL_ANON: everything else from someone who isn't signed in, per IP address. |
| 13 | * - RL_ACCOUNT: everything from a signed-in person, agent or personal token, per account. |
| 14 | * - RL_CREATE: creating things other people see or that cost storage (repositories, |
| 15 | * organizations, issues, pull requests, comments, reviews, releases), and reports to the site |
| 16 | * administrators, per account. |
| 17 | * |
| 18 | * Job, sandbox, runner and import credentials aren't counted here: their work is bounded by |
| 19 | * the job, sandbox and import limits. |
| 20 | */ |
| 21 | |
| 22 | export interface RateLimiter { |
| 23 | limit(o: { key: string }): Promise<{ success: boolean }>; |
| 24 | } |
| 25 | |
| 26 | export type Bucket = "RL_AUTH" | "RL_ANON" | "RL_ACCOUNT" | "RL_CREATE"; |
| 27 | |
| 28 | /** The limits each binding is configured with in production (wrangler.jsonc), for messages and docs. */ |
| 29 | export const RATE_LIMITS: Record<Bucket, number> = { RL_AUTH: 20, RL_ANON: 300, RL_ACCOUNT: 2000, RL_CREATE: 60 }; |
| 30 | export const RATE_PERIOD_S = 60; |
| 31 | |
| 32 | /** Paths that never count: health checks, avatars, and the Actions runtime and runners (their own credentials). */ |
| 33 | const EXEMPT = /^\/(?:healthz$|api\/avatars\/|twirp\/|_runtime\/|_apis\/|runner\/|-\/model\/)/; |
| 34 | const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/))/; |
| 35 | const CREATE: Array<[method: string, path: RegExp]> = [ |
| 36 | ["POST", /^\/api\/v3\/(?:user|orgs\/[^/]+)\/repos$/], |
| 37 | ["POST", /^\/api\/v3\/(?:user\/orgs|admin\/organizations)$/], |
| 38 | ["POST", /^\/api\/v3\/repos\/[^/]+\/[^/]+\/(?:issues|pulls|releases|forks)$/], |
| 39 | ["POST", /^\/api\/v3\/repos\/[^/]+\/[^/]+\/(?:issues|pulls)\/\d+\/(?:comments|reviews)$/], |
| 40 | ["POST", /^\/api\/v3\/repos\/[^/]+\/[^/]+\/(?:commits\/[^/]+\/comments|pulls\/\d+\/comments\/\d+\/replies)$/], |
| 41 | ["POST", /^\/new(?:\/import)?$/], |
| 42 | ["POST", /^\/settings\/orgs$/], |
| 43 | ["POST", /^\/[^/]+\/[^/]+\/(?:issues|pulls|releases)$/], |
| 44 | ["POST", /^\/[^/]+\/[^/]+\/(?:issues\/\d+\/comment|pull\/\d+\/(?:review|line-comment))$/], |
| 45 | // Reports to the site administrators (lib/moderation.ts also caps them per day). |
| 46 | ["POST", /^\/api\/v3\/diffinery\/reports$/], |
| 47 | ["POST", /^\/-\/report$/], |
| 48 | ]; |
| 49 | |
| 50 | /** Which buckets a request counts against, with their keys. */ |
| 51 | export function bucketsFor(method: string, path: string, p: Principal, ip: string): Array<[Bucket, string]> { |
| 52 | if (EXEMPT.test(path)) return []; |
| 53 | if (AUTH.test(path) && method !== "GET") return [["RL_AUTH", `ip:${ip}`]]; |
| 54 | if (p.type === "anonymous") return [["RL_ANON", `ip:${ip}`]]; |
| 55 | if (p.type !== "account") return []; |
| 56 | const key = `acct:${p.account.id}`; |
| 57 | const out: Array<[Bucket, string]> = [["RL_ACCOUNT", key]]; |
| 58 | // MCP tool calls that change things count in mcp/server.ts, where the tool is known. |
| 59 | if (CREATE.some(([m, re]) => m === method && re.test(path))) out.push(["RL_CREATE", key]); |
| 60 | return out; |
| 61 | } |
| 62 | |
| 63 | /** The bucket that's over its limit for this request, or null. */ |
| 64 | export async function overLimit(env: Env, buckets: Array<[Bucket, string]>): Promise<Bucket | null> { |
| 65 | for (const [b, key] of buckets) { |
| 66 | const rl = (env as unknown as Record<string, RateLimiter | undefined>)[b]; |
| 67 | if (!rl) continue; |
| 68 | try { |
| 69 | if (!(await rl.limit({ key })).success) return b; |
| 70 | } catch (e) { |
| 71 | console.error("rate limiter failed", b, e); |
| 72 | } |
| 73 | } |
| 74 | return null; |
| 75 | } |
| 76 | |
| 77 | const WHAT: Record<Bucket, string> = { |
| 78 | RL_AUTH: "sign-in attempts from your network", |
| 79 | RL_ANON: "requests from your network without signing in", |
| 80 | RL_ACCOUNT: "requests from your account", |
| 81 | RL_CREATE: "new repositories, issues, pull requests and comments from your account", |
| 82 | }; |
| 83 | |
| 84 | /** A 429 that GitHub clients understand (`retry-after`, `x-ratelimit-remaining: 0`). */ |
| 85 | export function tooMany(c: Context, b: Bucket): Response { |
| 86 | const message = `Too many ${WHAT[b]}: at most ${RATE_LIMITS[b]} a minute. Try again in a minute.`; |
| 87 | const headers = { "retry-after": String(RATE_PERIOD_S), "x-ratelimit-limit": String(RATE_LIMITS[b]), "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(Math.floor(Date.now() / 1000) + RATE_PERIOD_S) }; |
| 88 | const html = (c.req.header("accept") ?? "").includes("text/html"); |
| 89 | return html ? new Response(message, { status: 429, headers: { ...headers, "content-type": "text/plain; charset=utf-8" } }) : Response.json({ message }, { status: 429, headers }); |
| 90 | } |