| 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. 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. */ |
| 18 | export type RateLimitBinding = { limit(options: { key: string }): Promise<{ success: boolean }> }; |
| 19 | |
| 20 | /** What asking a limit said. `unavailable` lets the request through. */ |
| 21 | export 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 | */ |
| 28 | export const LIMIT_PERIOD_SECONDS = 60; |
| 29 | |
| 30 | export 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 | */ |
| 48 | export 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 | |
| 72 | export 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 | */ |
| 78 | export 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`. */ |
| 90 | export 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). */ |
| 95 | export 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 | */ |
| 104 | export 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. */ |
| 112 | export 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 | } |