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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
rate-limit.ts5.2 KB · 146 lines
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