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