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.

90 lines · 4.7 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, 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
22export interface RateLimiter {
23 limit(o: { key: string }): Promise<{ success: boolean }>;
24}
25
26export 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. */
29export const RATE_LIMITS: Record<Bucket, number> = { RL_AUTH: 20, RL_ANON: 300, RL_ACCOUNT: 2000, RL_CREATE: 60 };
30export const RATE_PERIOD_S = 60;
31
32/** Paths that never count: health checks, avatars, and the Actions runtime and runners (their own credentials). */
33const EXEMPT = /^\/(?:healthz$|api\/avatars\/|twirp\/|_runtime\/|_apis\/|runner\/|-\/model\/)/;
34const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/))/;
35const 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. */
51export 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. */
64export 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
77const 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`). */
85export 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}