| 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, private |
| 12 | * security reports (/security/report), early access requests and their confirmations |
| 13 | * (/early-access and its confirm and remove pages), CLI sign-ins (starting one, typing or approving a |
| 14 | * code), and MCP apps registering themselves for OAuth (/oauth/register), per IP address. |
| 15 | * - RL_ANON: everything else from someone who isn't signed in, per IP address. |
| 16 | * - RL_ACCOUNT: everything from a signed-in person, agent or personal token, per account; from an |
| 17 | * app's JWT, per app; from an app's installation token, per installation. |
| 18 | * - RL_CREATE: creating things other people see or that cost storage (repositories, |
| 19 | * organizations, issues, pull requests, comments, reviews, releases), and reports to the site |
| 20 | * administrators, per account. |
| 21 | * |
| 22 | * Job, sandbox, runner and import credentials aren't counted here: their work is bounded by |
| 23 | * the job, sandbox and import limits. |
| 24 | */ |
| 25 | |
| 26 | export interface RateLimiter { |
| 27 | limit(o: { key: string }): Promise<{ success: boolean }>; |
| 28 | } |
| 29 | |
| 30 | export type Bucket = "RL_AUTH" | "RL_ANON" | "RL_ACCOUNT" | "RL_CREATE"; |
| 31 | |
| 32 | /** The limits each binding is configured with in production (wrangler.jsonc), for messages and docs. */ |
| 33 | export const RATE_LIMITS: Record<Bucket, number> = { RL_AUTH: 20, RL_ANON: 300, RL_ACCOUNT: 2000, RL_CREATE: 60 }; |
| 34 | export const RATE_PERIOD_S = 60; |
| 35 | |
| 36 | /** Paths that never count: health checks, avatars, and the Actions runtime and runners (their own credentials). */ |
| 37 | const EXEMPT = /^\/(?:healthz$|api\/avatars\/|twirp\/|_runtime\/|_apis\/|runner\/|-\/model\/)/; |
| 38 | const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/)|security\/report$|early-access(?:\/confirm|\/remove(?:\/confirm)?)?$|-\/cli\/device$|oauth\/register$)/; |
| 39 | const CREATE: Array<[method: string, path: RegExp]> = [ |
| 40 | ["POST", /^\/api\/v3\/(?:user|orgs\/[^/]+)\/repos$/], |
| 41 | ["POST", /^\/api\/v3\/(?:user\/orgs|admin\/organizations)$/], |
| 42 | ["POST", /^\/api\/v3\/repos\/[^/]+\/[^/]+\/(?:issues|pulls|releases|forks)$/], |
| 43 | ["POST", /^\/api\/v3\/repos\/[^/]+\/[^/]+\/(?:issues|pulls)\/\d+\/(?:comments|reviews)$/], |
| 44 | ["POST", /^\/api\/v3\/repos\/[^/]+\/[^/]+\/(?:commits\/[^/]+\/comments|pulls\/\d+\/comments\/\d+\/replies)$/], |
| 45 | ["POST", /^\/new(?:\/import)?$/], |
| 46 | ["POST", /^\/settings\/orgs$/], |
| 47 | ["POST", /^\/[^/]+\/[^/]+\/(?:issues|pulls|releases)$/], |
| 48 | ["POST", /^\/[^/]+\/[^/]+\/(?:issues\/\d+\/comment|pull\/\d+\/(?:review|line-comment))$/], |
| 49 | // Reports to the site administrators (lib/moderation.ts also caps them per day). |
| 50 | ["POST", /^\/api\/v3\/diffinery\/reports$/], |
| 51 | ["POST", /^\/-\/report$/], |
| 52 | ]; |
| 53 | |
| 54 | /** Which buckets a request counts against, with their keys. */ |
| 55 | export function bucketsFor(method: string, path: string, p: Principal, ip: string): Array<[Bucket, string]> { |
| 56 | if (EXEMPT.test(path)) return []; |
| 57 | if (AUTH.test(path) && method !== "GET") return [["RL_AUTH", `ip:${ip}`]]; |
| 58 | if (p.type === "anonymous") return [["RL_ANON", `ip:${ip}`]]; |
| 59 | if (p.type !== "account" && p.type !== "app" && p.type !== "installation") return []; |
| 60 | const key = p.type === "account" ? `acct:${p.account.id}` : p.type === "app" ? `app:${p.app.id}` : `inst:${p.auth.installation.id}`; |
| 61 | const out: Array<[Bucket, string]> = [["RL_ACCOUNT", key]]; |
| 62 | // MCP tool calls that change things count in mcp/server.ts, where the tool is known. |
| 63 | if (CREATE.some(([m, re]) => m === method && re.test(path))) out.push(["RL_CREATE", key]); |
| 64 | return out; |
| 65 | } |
| 66 | |
| 67 | /** The bucket that's over its limit for this request, or null. */ |
| 68 | export async function overLimit(env: Env, buckets: Array<[Bucket, string]>): Promise<Bucket | null> { |
| 69 | for (const [b, key] of buckets) { |
| 70 | const rl = (env as unknown as Record<string, RateLimiter | undefined>)[b]; |
| 71 | if (!rl) continue; |
| 72 | try { |
| 73 | if (!(await rl.limit({ key })).success) return b; |
| 74 | } catch (e) { |
| 75 | console.error("rate limiter failed", b, e); |
| 76 | } |
| 77 | } |
| 78 | return null; |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * Model requests through the proxy (/-/model/*), per paying person across all their runs (phase 5a): above the |
| 83 | * per-run limits and budgets, it caps how fast a leaked run token or a runaway task can spend a person's key. Kept |
| 84 | * out of `bucketsFor` because only the run's ProjectDO knows who pays. Must match RL_MODEL in wrangler.jsonc. |
| 85 | */ |
| 86 | export const MODEL_RATE_LIMIT = 600; |
| 87 | |
| 88 | /** Whether this person is over their model request rate. A missing (local) or failing binding lets it through. */ |
| 89 | export async function modelRateLimited(env: Env, personId: string): Promise<boolean> { |
| 90 | if (!env.RL_MODEL) return false; |
| 91 | try { |
| 92 | return !(await env.RL_MODEL.limit({ key: `model:${personId}` })).success; |
| 93 | } catch (e) { |
| 94 | console.error("model rate limiter failed", e); |
| 95 | return false; |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | const WHAT: Record<Bucket, string> = { |
| 100 | RL_AUTH: "sign-in attempts, security reports and early access requests from your network", |
| 101 | RL_ANON: "requests from your network without signing in", |
| 102 | RL_ACCOUNT: "requests from your account", |
| 103 | RL_CREATE: "new repositories, issues, pull requests and comments from your account", |
| 104 | }; |
| 105 | |
| 106 | /** A 429 that GitHub clients understand (`retry-after`, `x-ratelimit-remaining: 0`). */ |
| 107 | export function tooMany(c: Context, b: Bucket): Response { |
| 108 | const message = `Too many ${WHAT[b]}: at most ${RATE_LIMITS[b]} a minute. Try again in a minute.`; |
| 109 | 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) }; |
| 110 | const html = (c.req.header("accept") ?? "").includes("text/html"); |
| 111 | return html ? new Response(message, { status: 429, headers: { ...headers, "content-type": "text/plain; charset=utf-8" } }) : Response.json({ message }, { status: 429, headers }); |
| 112 | } |