Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

rate-limit.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

rate-limit.tsBlame146 lines · 3 contributors
45e31d0Claude1/**
2 * In-memory rate limiter middleware.
3 *
4 * Provides per-IP rate limiting with sliding window counters.
5 * For production, replace with Redis-based implementation.
6 */
7
8import { createMiddleware } from "hono/factory";
9
10interface RateLimitEntry {
11 count: number;
12 resetAt: number;
13}
14
15const store = new Map<string, RateLimitEntry>();
16
17// Cleanup stale entries every 60 seconds
ea52715copilot-swe-agent[bot]18const _cleanupInterval = setInterval(() => {
45e31d0Claude19 const now = Date.now();
20 for (const [key, entry] of store) {
21 if (entry.resetAt < now) {
22 store.delete(key);
23 }
24 }
25}, 60_000);
26
826eccfTest User27interface RateLimitOptions {
28 /**
29 * Endpoints that should never count against the bucket. Compared against
30 * c.req.path with .startsWith(). Used for high-frequency dashboard
31 * plumbing (e.g. `/api/version` is polled every 15s by the layout) so
32 * normal navigation doesn't exhaust an admin's `/api/*` budget.
33 */
34 skipPaths?: string[];
35 /**
36 * Multiplier applied when c.get("user") is set by an upstream softAuth.
37 * 1 = anonymous limit. Default 4 so an authed user gets 4x the bucket
38 * (admins doing admin work shouldn't hit /api/* limits during normal
39 * navigation; anonymous traffic still gets the strict cap).
40 */
41 authedMultiplier?: number;
42}
43
45e31d0Claude44/**
45 * Create a rate limiter middleware.
826eccfTest User46 * @param maxRequests Maximum requests per window for ANONYMOUS callers
45e31d0Claude47 * @param windowMs Window duration in milliseconds
48 * @param keyPrefix Prefix for the rate limit key (allows different limits per route group)
826eccfTest User49 * @param opts Optional skip-paths and authed multiplier.
45e31d0Claude50 */
51export function rateLimit(
52 maxRequests: number,
53 windowMs: number,
826eccfTest User54 keyPrefix = "global",
55 opts: RateLimitOptions = {}
45e31d0Claude56) {
826eccfTest User57 const skipPaths = opts.skipPaths || [];
58 const authedMultiplier = opts.authedMultiplier ?? 4;
45e31d0Claude59 return createMiddleware(async (c, next) => {
699e5c7Claude60 // In test env, expose informational rate-limit headers but do not actually
61 // enforce limits — the shared in-memory store leaks across test files and
62 // would push requests into 429 once accumulated.
4127ecfClaude63 //
64 // Belt-and-braces: refuse to disable enforcement when NODE_ENV=production
65 // even if BUN_ENV/NODE_ENV are also somehow set to "test". A misconfigured
66 // production container with a leaked test env var must not silently drop
67 // rate limiting.
68 const isTestEnv =
69 process.env.NODE_ENV !== "production" &&
70 (process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test");
71 if (isTestEnv) {
699e5c7Claude72 c.header("X-RateLimit-Limit", String(maxRequests));
73 c.header("X-RateLimit-Remaining", String(maxRequests));
74 c.header("X-RateLimit-Reset", String(Math.ceil((Date.now() + windowMs) / 1000)));
75 return next();
76 }
77
826eccfTest User78 // Skip-path exemption — applied to dashboard plumbing endpoints that the
79 // layout polls on a fixed cadence and that we don't want consuming an
80 // authenticated user's per-IP budget.
81 const path = c.req.path;
82 for (const prefix of skipPaths) {
83 if (path === prefix || path.startsWith(prefix + "/") || path.startsWith(prefix + "?")) {
84 return next();
85 }
86 }
87
45e31d0Claude88 const ip =
89 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
90 c.req.header("x-real-ip") ||
91 "unknown";
92
826eccfTest User93 // Effective cap: authed users get a multiplier so a logged-in admin
94 // clicking around doesn't share the anonymous limit. We DON'T key the
95 // bucket per-user — keeping it per-IP is the strongest defence against
96 // a stolen-session bot — but a logged-in session signals "human at the
97 // keyboard", so we lift the ceiling.
98 const user = c.get("user" as never) as { id?: string } | null | undefined;
99 const effectiveMax = user ? maxRequests * authedMultiplier : maxRequests;
100
45e31d0Claude101 const key = `${keyPrefix}:${ip}`;
102 const now = Date.now();
103 let entry = store.get(key);
104
105 if (!entry || entry.resetAt < now) {
106 entry = { count: 0, resetAt: now + windowMs };
107 store.set(key, entry);
108 }
109
110 entry.count++;
111
826eccfTest User112 // Set rate limit headers (report the effective cap so clients see the
113 // bucket they're actually subject to).
114 c.header("X-RateLimit-Limit", String(effectiveMax));
115 c.header("X-RateLimit-Remaining", String(Math.max(0, effectiveMax - entry.count)));
45e31d0Claude116 c.header("X-RateLimit-Reset", String(Math.ceil(entry.resetAt / 1000)));
117
826eccfTest User118 if (entry.count > effectiveMax) {
45e31d0Claude119 c.header("Retry-After", String(Math.ceil((entry.resetAt - now) / 1000)));
120 return c.json(
121 {
122 error: "Rate limit exceeded",
123 retryAfter: Math.ceil((entry.resetAt - now) / 1000),
124 },
125 429
126 );
127 }
128
129 return next();
130 });
131}
132
133/**
134 * Pre-configured rate limiters for different route groups.
135 */
3e8f8e8Claude136export function clearRateLimitStore() {
137 store.clear();
138}
139
45e31d0Claude140export const apiRateLimit = rateLimit(100, 60_000, "api"); // 100 req/min
141export const authRateLimit = rateLimit(10, 60_000, "auth"); // 10 req/min (login/register)
a41e675Test User142// 300/min for git Smart-HTTP. A clone of a busy repo fans out into many
143// info/refs + upload-pack requests; 60 was way too tight when the same IP
144// (a developer's laptop) was clicking around the UI in another tab.
145export const gitRateLimit = rateLimit(300, 60_000, "git");
45e31d0Claude146export const searchRateLimit = rateLimit(30, 60_000, "search"); // 30 req/min