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.

Rate limits: sign-in and anonymous requests per IP, signed-in requests and new content per account (#106)

Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent c4e3420commit e04062d4592c85e068fdc4319c485e715ba85a39Browse files
8 files changed+232−6
Files (8)
modifiedapps/diffinery/docs/reference/limits.md+1−1View file
@@ -42,7 +42,7 @@
4242
4343 | Limit | Value | Applies to |
4444 | --- | --- | --- |
45-| Rate limits | None | Diffinery enforces no request rate limits. Responses report a constant budget of 15,000 (`X-RateLimit-*` headers and `GET /api/v3/rate_limit`). |
45+| Rate limits | Per minute: 20 sign-in attempts and 300 other requests per IP address without signing in; 2,000 requests and 60 new repositories, issues, pull requests, comments or reviews per account | Past a limit, `429` with `retry-after: 60`. Jobs, sandboxes, runners and imports aren't counted. See [Rate limits](/docs/reference/rest-api#rate-limits). |
4646 | Request body size | 100 MB | Cloudflare refuses larger request bodies before they reach Diffinery. |
4747 | Items per page | 30 by default, 100 at most | Every paginated list (`per_page`). Larger values are treated as 100. `page` can go up to 100,000. |
4848 | Workflow runs per page | 25 by default, 100 at most | `GET /repos/{owner}/{repo}/actions/runs`. |
modifiedapps/diffinery/docs/reference/rest-api.md+14−3View file
@@ -398,8 +398,19 @@
398398
399399 ## Rate limits
400400
401-Diffinery doesn't meter API calls. To keep clients that check their budget working, every response under `/api/v3` carries a constant, generous budget:
401+Diffinery limits requests per minute to stop floods and scripted abuse:
402402
403+| What | Limit | Counted by |
404+| --- | --- | --- |
405+| Sign-in, passkey registration, invitation and sign-in links | 20 a minute | IP address |
406+| Anything else without signing in (web pages, API, git) | 300 a minute | IP address |
407+| Anything with a session or token (web, API, MCP, git) | 2,000 a minute | Account |
408+| Creating repositories, organizations, issues, pull requests, comments and reviews, and MCP tools that change something | 60 a minute | Account |
409+
410+Workflow jobs, sandboxes, self-hosted runners and imports use their own credentials and aren't counted. The counts are kept in each Cloudflare location, so they stop bursts rather than meter use exactly. Past a limit, the request answers `429` with a message naming the limit, `retry-after: 60` and `x-ratelimit-remaining: 0`; Octokit's throttling plugin waits and retries.
411+
412+Other responses under `/api/v3` still carry GitHub's budget headers with a constant, generous value, so clients that check their budget keep working:
413+
403414 ```http
404415 x-ratelimit-limit: 15000
405416 x-ratelimit-remaining: 15000
@@ -408,9 +419,9 @@
408419 x-ratelimit-resource: core
409420 ```
410421
411-`GET /api/v3/rate_limit` answers like GitHub's, with `resources.core`, `search`, `graphql`, `code_search` and others, and the same `rate`. `remaining` never goes down.
422+`GET /api/v3/rate_limit` answers like GitHub's, with `resources.core`, `search`, `graphql`, `code_search` and others, and the same `rate`.
412423
413-Cloudflare's own platform limits still apply to each request (for example the maximum request body size of 100 MB), and the operator of an instance can add Cloudflare rules in front of it. A few operations have budgets of their own: building new source archives (20 per repository per hour, `429` beyond that) is the only one counted per time window. Write clients that back off and retry on `429`, `503` and network errors anyway, so they keep working if limits are added later. Size and count limits, such as 100 MB per git push or 256 jobs per matrix, are listed in [Limits](/docs/reference/limits).
424+Cloudflare's own platform limits still apply to each request (for example the maximum request body size of 100 MB). A few operations have budgets of their own, such as building new source archives (20 per repository per hour). Write clients that back off and retry on `429`, `503` and network errors. Size and count limits, such as 100 MB per git push or 256 jobs per matrix, are listed in [Limits](/docs/reference/limits).
414425
415426 ## What works, by area
416427
modifiedapps/diffinery/src/app.ts+4−1View file
@@ -8,6 +8,7 @@
88 import { BOOKMARK_COOKIE, BOOKMARK_HEADER, d1Session, sessionConstraint } from "./lib/d1.js";
99 import { SESSION_COOKIE } from "./auth/sessions.js";
1010 import { newId, now } from "./lib/util.js";
11+import { bucketsFor, overLimit, tooMany } from "./lib/rate-limit.js";
1112 import { deliverWebhook, type WebhookDeps, type WebhookRetry } from "./lib/webhooks.js";
1213 import { runStages, type Stage, type StageRetry } from "./lib/stages.js";
1314 import { apiNotFound, errorResponse, loadRepo, repoStub } from "./http/common.js";
@@ -63,7 +64,9 @@
6364 // Avatar images are public and on every page many times over: skip the session lookup for them.
6465 const avatar = (c.req.method === "GET" || c.req.method === "HEAD") && c.req.path.startsWith("/api/avatars/");
6566 c.set("principal", runtime || avatar ? { type: "anonymous" } : await resolvePrincipal(c.req.raw, c.env, dir));
66- await next();
67+ const limited = await overLimit(c.env, bucketsFor(c.req.method, c.req.path, c.get("principal"), c.req.header("cf-connecting-ip") ?? "unknown"));
68+ if (limited) c.res = tooMany(c, limited);
69+ else await next();
6770 if (c.res.status === 101) return;
6871 const h = c.res.headers;
6972 const bookmark = d1.bookmark();
modifiedapps/diffinery/src/env.ts+5−0View file
@@ -32,6 +32,11 @@
3232 RUN_WORKFLOW: Workflow<{ repoId: string; runId: string }>;
3333 /** Cloudflare Email Sending. Missing when sending isn't set up; emails are then logged as skipped. */
3434 EMAIL?: SendEmail;
35+ /** Request rate limits (lib/rate-limit.ts); none in local development. */
36+ RL_AUTH?: RateLimit;
37+ RL_ANON?: RateLimit;
38+ RL_ACCOUNT?: RateLimit;
39+ RL_CREATE?: RateLimit;
3540 /** Email jobs, sent by the queue consumer in mail/send.ts. */
3641 MAIL?: Queue<MailJob>;
3742 IMPORT_WORKFLOW: Workflow<{ importId: string }>;
addedapps/diffinery/src/lib/rate-limit.ts+86−0View file
@@ -1,0 +1,86 @@
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, 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), per account.
16+ *
17+ * Job, sandbox, runner and import credentials aren't counted here: their work is bounded by
18+ * the job, sandbox and import limits.
19+ */
20+
21+export interface RateLimiter {
22+ limit(o: { key: string }): Promise<{ success: boolean }>;
23+}
24+
25+export type Bucket = "RL_AUTH" | "RL_ANON" | "RL_ACCOUNT" | "RL_CREATE";
26+
27+/** The limits each binding is configured with in production (wrangler.jsonc), for messages and docs. */
28+export const RATE_LIMITS: Record<Bucket, number> = { RL_AUTH: 20, RL_ANON: 300, RL_ACCOUNT: 2000, RL_CREATE: 60 };
29+export const RATE_PERIOD_S = 60;
30+
31+/** Paths that never count: health checks, avatars, and the Actions runtime and runners (their own credentials). */
32+const EXEMPT = /^\/(?:healthz$|api\/avatars\/|twirp\/|_runtime\/|_apis\/|runner\/|-\/model\/)/;
33+const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/))/;
34+const CREATE: Array<[method: string, path: RegExp]> = [
35+ ["POST", /^\/api\/v3\/(?:user|orgs\/[^/]+)\/repos$/],
36+ ["POST", /^\/api\/v3\/(?:user\/orgs|admin\/organizations)$/],
37+ ["POST", /^\/api\/v3\/repos\/[^/]+\/[^/]+\/(?:issues|pulls|releases|forks)$/],
38+ ["POST", /^\/api\/v3\/repos\/[^/]+\/[^/]+\/(?:issues|pulls)\/\d+\/(?:comments|reviews)$/],
39+ ["POST", /^\/api\/v3\/repos\/[^/]+\/[^/]+\/(?:commits\/[^/]+\/comments|pulls\/\d+\/comments\/\d+\/replies)$/],
40+ ["POST", /^\/new(?:\/import)?$/],
41+ ["POST", /^\/settings\/orgs$/],
42+ ["POST", /^\/[^/]+\/[^/]+\/(?:issues|pulls|releases)$/],
43+ ["POST", /^\/[^/]+\/[^/]+\/(?:issues\/\d+\/comment|pull\/\d+\/(?:review|line-comment))$/],
44+];
45+
46+/** Which buckets a request counts against, with their keys. */
47+export function bucketsFor(method: string, path: string, p: Principal, ip: string): Array<[Bucket, string]> {
48+ if (EXEMPT.test(path)) return [];
49+ if (AUTH.test(path) && method !== "GET") return [["RL_AUTH", `ip:${ip}`]];
50+ if (p.type === "anonymous") return [["RL_ANON", `ip:${ip}`]];
51+ if (p.type !== "account") return [];
52+ const key = `acct:${p.account.id}`;
53+ const out: Array<[Bucket, string]> = [["RL_ACCOUNT", key]];
54+ // MCP tool calls that change things count in mcp/server.ts, where the tool is known.
55+ if (CREATE.some(([m, re]) => m === method && re.test(path))) out.push(["RL_CREATE", key]);
56+ return out;
57+}
58+
59+/** The bucket that's over its limit for this request, or null. */
60+export async function overLimit(env: Env, buckets: Array<[Bucket, string]>): Promise<Bucket | null> {
61+ for (const [b, key] of buckets) {
62+ const rl = (env as unknown as Record<string, RateLimiter | undefined>)[b];
63+ if (!rl) continue;
64+ try {
65+ if (!(await rl.limit({ key })).success) return b;
66+ } catch (e) {
67+ console.error("rate limiter failed", b, e);
68+ }
69+ }
70+ return null;
71+}
72+
73+const WHAT: Record<Bucket, string> = {
74+ RL_AUTH: "sign-in attempts from your network",
75+ RL_ANON: "requests from your network without signing in",
76+ RL_ACCOUNT: "requests from your account",
77+ RL_CREATE: "new repositories, issues, pull requests and comments from your account",
78+};
79+
80+/** A 429 that GitHub clients understand (`retry-after`, `x-ratelimit-remaining: 0`). */
81+export function tooMany(c: Context, b: Bucket): Response {
82+ const message = `Too many ${WHAT[b]}: at most ${RATE_LIMITS[b]} a minute. Try again in a minute.`;
83+ 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) };
84+ const html = (c.req.header("accept") ?? "").includes("text/html");
85+ return html ? new Response(message, { status: 429, headers: { ...headers, "content-type": "text/plain; charset=utf-8" } }) : Response.json({ message }, { status: 429, headers });
86+}
modifiedapps/diffinery/src/mcp/server.ts+6−1View file
@@ -1,4 +1,5 @@
11 import { Hono } from "hono";
2+import { overLimit, RATE_LIMITS } from "../lib/rate-limit.js";
23 import type { Env } from "../env.js";
34 import { requireAccount, requireAccountScope, requireScope, tokenRepos, type AppContext, type AppVars } from "../auth/principal.js";
45 import { HttpError, b64, fromB64 } from "../lib/util.js";
@@ -601,7 +602,11 @@
601602 if (!tool) return rpcError(req.id, -32602, `unknown tool: ${name}`);
602603 try {
603604 c.set("readOnly", !!tool.readOnly);
604- if (!tool.readOnly) requireScope(c, "write");
605+ if (!tool.readOnly) {
606+ requireScope(c, "write");
607+ const p = c.get("principal");
608+ if (p.type === "account" && (await overLimit(c.env, [["RL_CREATE", `acct:${p.account.id}`]]))) throw new HttpError(429, `Too many changes from your account: at most ${RATE_LIMITS.RL_CREATE} a minute through MCP. Try again in a minute.`);
609+ }
605610 const out = await tool.run(c, (req.params?.arguments as Args) ?? {});
606611 const content = [{ type: "text", text: JSON.stringify(out, null, 2) }, ...(UNTRUSTED_TOOLS.has(name) ? [{ type: "text", text: UNTRUSTED_NOTE }] : [])];
607612 return { jsonrpc: "2.0", id: req.id, result: { content, structuredContent: out && typeof out === "object" && !Array.isArray(out) ? out : { result: out }, isError: false } };
addedapps/diffinery/test/unit/rate-limit.test.ts+108−0View file
@@ -1,0 +1,108 @@
1+import { readFileSync } from "node:fs";
2+import { describe, expect, it } from "vitest";
3+import type { Env } from "../../src/env.js";
4+import type { Principal } from "../../src/auth/principal.js";
5+import { bucketsFor, overLimit, RATE_LIMITS, RATE_PERIOD_S, type RateLimiter } from "../../src/lib/rate-limit.js";
6+
7+const anon: Principal = { type: "anonymous" };
8+const person = { type: "account", account: { id: "u1" }, via: "token", scopes: ["write"], repoIds: null } as unknown as Principal;
9+const job = { type: "job", repoId: "r1" } as unknown as Principal;
10+
11+/** A limiter that allows `n` calls per key. */
12+function limiter(n: number): RateLimiter & { seen: string[] } {
13+ const counts = new Map<string, number>();
14+ const seen: string[] = [];
15+ return {
16+ seen,
17+ async limit({ key }) {
18+ seen.push(key);
19+ const c = (counts.get(key) ?? 0) + 1;
20+ counts.set(key, c);
21+ return { success: c <= n };
22+ },
23+ };
24+}
25+
26+describe("rate limit buckets", () => {
27+ it("counts sign-in attempts per IP address, and other anonymous requests separately", () => {
28+ expect(bucketsFor("POST", "/auth/passkey/login/options", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]);
29+ expect(bucketsFor("POST", "/invite/abc/email", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]);
30+ expect(bucketsFor("GET", "/login", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]);
31+ expect(bucketsFor("GET", "/api/v3/repos/acme/app", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]);
32+ expect(bucketsFor("GET", "/acme/app.git/info/refs", anon, "198.51.100.1")).toEqual([["RL_ANON", "ip:198.51.100.1"]]);
33+ });
34+
35+ it("counts a signed-in account's requests, and its new issues, comments and repositories again", () => {
36+ expect(bucketsFor("GET", "/api/v3/user/repos", person, "x")).toEqual([["RL_ACCOUNT", "acct:u1"]]);
37+ for (const [m, path] of [
38+ ["POST", "/api/v3/user/repos"],
39+ ["POST", "/api/v3/orgs/acme/repos"],
40+ ["POST", "/api/v3/user/orgs"],
41+ ["POST", "/api/v3/repos/acme/app/issues"],
42+ ["POST", "/api/v3/repos/acme/app/issues/3/comments"],
43+ ["POST", "/api/v3/repos/acme/app/pulls/4/reviews"],
44+ ["POST", "/api/v3/repos/acme/app/pulls/4/comments/9/replies"],
45+ ["POST", "/api/v3/repos/acme/app/commits/abc123/comments"],
46+ ["POST", "/new"],
47+ ["POST", "/acme/app/issues"],
48+ ["POST", "/acme/app/issues/3/comment"],
49+ ["POST", "/acme/app/pull/4/review"],
50+ ]) expect(bucketsFor(m!, path!, person, "x"), path).toEqual([["RL_ACCOUNT", "acct:u1"], ["RL_CREATE", "acct:u1"]]);
51+ expect(bucketsFor("PATCH", "/api/v3/repos/acme/app/issues/3", person, "x")).toEqual([["RL_ACCOUNT", "acct:u1"]]);
52+ });
53+
54+ it("leaves runners, the Actions runtime, health checks and avatars alone", () => {
55+ for (const path of ["/healthz", "/api/avatars/u1", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", "/_runtime/x", "/runner/jobs/claim", "/-/model/anthropic/v1/messages"]) expect(bucketsFor("POST", path, anon, "x"), path).toEqual([]);
56+ expect(bucketsFor("GET", "/api/v3/repos/acme/app", job, "x")).toEqual([]);
57+ });
58+});
59+
60+describe("rate limit checks", () => {
61+ it("refuses once a bucket is used up, per key", async () => {
62+ const rl = limiter(2);
63+ const env = { RL_ANON: rl } as unknown as Env;
64+ const a = bucketsFor("GET", "/", anon, "203.0.113.9");
65+ expect(await overLimit(env, a)).toBeNull();
66+ expect(await overLimit(env, a)).toBeNull();
67+ expect(await overLimit(env, a)).toBe("RL_ANON");
68+ expect(await overLimit(env, bucketsFor("GET", "/", anon, "203.0.113.10"))).toBeNull();
69+ });
70+
71+ it("names the create bucket when an account's general budget still has room", async () => {
72+ const env = { RL_ACCOUNT: limiter(100), RL_CREATE: limiter(1) } as unknown as Env;
73+ const b = bucketsFor("POST", "/api/v3/repos/acme/app/issues", person, "x");
74+ expect(await overLimit(env, b)).toBeNull();
75+ expect(await overLimit(env, b)).toBe("RL_CREATE");
76+ });
77+
78+ it("lets requests through when no limiter is configured or the limiter fails", async () => {
79+ expect(await overLimit({} as Env, [["RL_ANON", "ip:x"]])).toBeNull();
80+ const broken = { RL_ANON: { limit: async () => Promise.reject(new Error("down")) } } as unknown as Env;
81+ expect(await overLimit(broken, [["RL_ANON", "ip:x"]])).toBeNull();
82+ });
83+
84+ it("matches the limits configured for production", () => {
85+ const text = readFileSync(new URL("../../wrangler.jsonc", import.meta.url), "utf8");
86+ for (const [name, limit] of Object.entries(RATE_LIMITS)) {
87+ const line = text.split("\n").find((l) => l.includes(`"name": "${name}"`)) ?? "";
88+ const m = /"limit": (\d+), "period": (\d+)/.exec(line);
89+ expect(m, name).not.toBeNull();
90+ expect([Number(m![1]), Number(m![2])], name).toEqual([limit, RATE_PERIOD_S]);
91+ }
92+ });
93+});
94+
95+describe("rate limits in the Worker", () => {
96+ it("answers 429 with retry-after once an anonymous address is over its limit", async () => {
97+ const { app } = await import("../../src/app.js");
98+ const env = { DB: {}, PUBLIC_URL: "https://d.example", RL_ANON: limiter(1) } as unknown as Env;
99+ const ctx = { waitUntil: () => {}, passThroughOnException: () => {}, props: {} } as unknown as ExecutionContext;
100+ const get = () => app.request("/healthz-not-exempt", { headers: { "cf-connecting-ip": "203.0.113.9" } }, env, ctx);
101+ expect((await get()).status).toBe(404);
102+ const r = await get();
103+ expect(r.status).toBe(429);
104+ expect(r.headers.get("retry-after")).toBe("60");
105+ expect(r.headers.get("strict-transport-security")).toBeTruthy();
106+ expect(((await r.json()) as { message: string }).message).toMatch(/at most 300 a minute/);
107+ });
108+});
modifiedapps/diffinery/wrangler.jsonc+8−0View file
@@ -40,6 +40,14 @@
4040 // Cloudflare Email Sending (notifications, security alerts, invites). The sending
4141 // domain is onboarded under Compute > Email Service > Email Sending (see scripts/email-sending.sh).
4242 "send_email": [{ "name": "EMAIL" }],
43+ // Request rate limits per minute (src/lib/rate-limit.ts; RATE_LIMITS there must match). Local
44+ // development declares none, so nothing is limited there.
45+ "ratelimits": [
46+ { "name": "RL_AUTH", "namespace_id": "1001", "simple": { "limit": 20, "period": 60 } },
47+ { "name": "RL_ANON", "namespace_id": "1002", "simple": { "limit": 300, "period": 60 } },
48+ { "name": "RL_ACCOUNT", "namespace_id": "1003", "simple": { "limit": 2000, "period": 60 } },
49+ { "name": "RL_CREATE", "namespace_id": "1004", "simple": { "limit": 60, "period": 60 } }
50+ ],
4351 "durable_objects": {
4452 "bindings": [
4553 { "name": "REPO", "class_name": "RepoDO" },