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.

112 lines · 6.2 KBRaw History
1import type { Context } from "hono";
2import type { Env } from "../env.js";
3import 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
26export interface RateLimiter {
27 limit(o: { key: string }): Promise<{ success: boolean }>;
28}
29
30export 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. */
33export const RATE_LIMITS: Record<Bucket, number> = { RL_AUTH: 20, RL_ANON: 300, RL_ACCOUNT: 2000, RL_CREATE: 60 };
34export const RATE_PERIOD_S = 60;
35
36/** Paths that never count: health checks, avatars, and the Actions runtime and runners (their own credentials). */
37const EXEMPT = /^\/(?:healthz$|api\/avatars\/|twirp\/|_runtime\/|_apis\/|runner\/|-\/model\/)/;
38const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/)|security\/report$|early-access(?:\/confirm|\/remove(?:\/confirm)?)?$|-\/cli\/device$|oauth\/register$)/;
39const 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. */
55export 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. */
68export 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 */
86export 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. */
89export 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
99const 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`). */
107export 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}