Skip to content
125 linesCodeBlameRaw
1/**
2 * Rate limits on g1t's public surfaces: Workers Rate Limiting bindings
3 * (`ratelimits` in each wrangler.jsonc), asked once per request with a key:
4 * the client's address, or a hash of its session or token, never the
5 * secret itself.
6 *
7 * Every binding is listed in `RATE_LIMITS` with its namespace id and limit,
8 * and apps/web's tests check each wrangler.jsonc against it, so this table
9 * is the one place to read or change them. CONTRIBUTING.md, "Rate
10 * limits", says how; the docs' rate limits page
11 * (docs.g1t.sh/reference/rate-limits/) shows the public ones.
12 *
13 * A limit fails open: no binding (self-hosted, `wrangler dev` without one)
14 * or a binding that throws lets the request through. A limit guards
15 * against floods; it is never a reason for g1t to stop answering.
16 */
17
18/** A Workers Rate Limiting binding, as the runtime hands it over. */
19export type RateLimitBinding = { limit(options: { key: string }): Promise<{ success: boolean }> };
20
21/** What asking a limit said. `unavailable` lets the request through. */
22export type LimitVerdict = "allowed" | "limited" | "unavailable";
23
24/**
25 * Every limit's window, in seconds. Workers Rate Limiting counts over 10
26 * or 60 seconds; every g1t limit uses 60, and a client past one is told to
27 * wait that long.
28 */
29export const LIMIT_PERIOD_SECONDS = 60;
30
31export type RateLimitSpec = {
32 /** The Worker whose wrangler.jsonc declares it. */
33 worker: string;
34 /** Unique per Cloudflare account; allocated in blocks per Worker (CONTRIBUTING.md, "Rate limits"). */
35 namespaceId: number;
36 /** Requests per `LIMIT_PERIOD_SECONDS` per key. */
37 limit: number;
38 /** What a key is. */
39 per: string;
40};
41
42/**
43 * Every rate limit binding, by name. Generous for people, tight for floods:
44 * a person browsing makes a few requests a second at most, a clone is three
45 * git requests, and nothing a person does needs dozens of archives a minute.
46 * Namespace ids go in blocks of a hundred per Worker: 41xx packages,
47 * 42xx web, 43xx repos, 44xx api, 45xx og, 46xx status.
48 */
49export const RATE_LIMITS = {
50 // services/packages (src/limits.rs): registry pulls and token requests.
51 ANONYMOUS_LIMIT: { worker: "services/packages", namespaceId: 4101, limit: 300, per: "address" },
52 SIGNED_LIMIT: { worker: "services/packages", namespaceId: 4102, limit: 5000, per: "person, workspace or agent" },
53 // apps/web (workers/app.ts): the front door.
54 WEB_ANONYMOUS_LIMIT: { worker: "apps/web", namespaceId: 4201, limit: 600, per: "address, signed-out pages" },
55 WEB_HEAVY_LIMIT: { worker: "apps/web", namespaceId: 4202, limit: 30, per: "address, signed-out archives, run pages, logs and search" },
56 WEB_SESSION_LIMIT: { worker: "apps/web", namespaceId: 4203, limit: 1200, per: "session, signed-in requests" },
57 WEB_ADDRESS_LIMIT: { worker: "apps/web", namespaceId: 4204, limit: 3000, per: "address, every request that reaches the Worker" },
58 GIT_ANONYMOUS_LIMIT: { worker: "apps/web", namespaceId: 4205, limit: 120, per: "address, git requests without credentials" },
59 GIT_SIGNED_LIMIT: { worker: "apps/web", namespaceId: 4206, limit: 1200, per: "credential, git requests with credentials" },
60 // The same as API_TOKEN_LIMIT: a token counts alike on the website and the API.
61 WEB_TOKEN_LIMIT: { worker: "apps/web", namespaceId: 4207, limit: 1000, per: "token, pages and data requests with an access token" },
62 // services/repos (src/lib.rs): what an anonymous clone can cost a repository's owner.
63 PACK_FILL_LIMIT: { worker: "services/repos", namespaceId: 4301, limit: 30, per: "repository, packs written to the pack cache" },
64 ANONYMOUS_FETCH_LIMIT: { worker: "services/repos", namespaceId: 4302, limit: 120, per: "repository, anonymous fetches the store answers" },
65 // apps/api (src/limits.rs): REST and MCP, which count apart.
66 API_ANONYMOUS_LIMIT: { worker: "apps/api", namespaceId: 4401, limit: 60, per: "address, requests without a token" },
67 API_TOKEN_LIMIT: { worker: "apps/api", namespaceId: 4402, limit: 1000, per: "token" },
68 // services/og: drawing a card that is not in the cache.
69 OG_RENDER_LIMIT: { worker: "services/og", namespaceId: 4501, limit: 60, per: "address, cards drawn" },
70 // apps/status: asking for a subscription, which sends an email.
71 STATUS_SUBSCRIBE_LIMIT: { worker: "apps/status", namespaceId: 4601, limit: 3, per: "address" },
72 STATUS_EMAIL_LIMIT: { worker: "apps/status", namespaceId: 4602, limit: 2, per: "email address" },
73} as const satisfies Record<string, RateLimitSpec>;
74
75export type RateLimitName = keyof typeof RATE_LIMITS;
76
77/**
78 * Counts one request against `binding` under `key`. Never throws: a missing
79 * binding or one that fails is `unavailable`, which callers let through.
80 */
81export async function checkLimit(binding: RateLimitBinding | undefined, key: string): Promise<LimitVerdict> {
82 if (!binding) return "unavailable";
83 try {
84 const { success } = await binding.limit({ key });
85 return success ? "allowed" : "limited";
86 } catch (error) {
87 console.error(JSON.stringify({ event: "rate_limit.unavailable", error: String(error) }));
88 return "unavailable";
89 }
90}
91
92/** Whether the request is past its limit. Fails open, like `checkLimit`. */
93export async function isLimited(binding: RateLimitBinding | undefined, key: string): Promise<boolean> {
94 return (await checkLimit(binding, key)) === "limited";
95}
96
97/** The client's address as Cloudflare saw it, for keys; `unknown` when there is none (local dev). */
98export function clientAddress(request: Request): string {
99 return request.headers.get("cf-connecting-ip")?.trim() || "unknown";
100}
101
102/**
103 * A key for a secret (a session cookie, a token, a credential): `prefix:`
104 * and the first 16 hex digits of its SHA-256, so the secret itself never
105 * reaches the rate limiter.
106 */
107export async function secretKey(prefix: string, secret: string): Promise<string> {
108 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret)));
109 let hex = "";
110 for (const byte of digest.subarray(0, 8)) hex += byte.toString(16).padStart(2, "0");
111 return `${prefix}:${hex}`;
112}
113
114/** A 429 with `Retry-After`, as plain text unless `headers` says otherwise. */
115export function tooManyRequests(body: string, headers: Record<string, string> = {}): Response {
116 return new Response(body, {
117 status: 429,
118 headers: {
119 "content-type": "text/plain; charset=utf-8",
120 "retry-after": String(LIMIT_PERIOD_SECONDS),
121 "cache-control": "no-store",
122 ...headers,
123 },
124 });
125}