Skip to content
122 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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

This file's history is long; its oldest lines are credited to the oldest commit read.