Skip to content

Feat/rate limiting - #1012

Draft
jona159 wants to merge 14 commits into
devfrom
feat/rate-limiting
Draft

Feat/rate limiting#1012
jona159 wants to merge 14 commits into
devfrom
feat/rate-limiting

Conversation

@jona159

@jona159 jona159 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Type of Change

  • Dependency upgrade
  • Bug fix (non-breaking change)
  • Breaking change
    • e.g. a fixed bug or new feature that may break something else
  • New feature
  • Code quality improvements
    • e.g. refactoring, documentation, tests, tooling, ...

Implementation

Checklist

  • I gave this pull request a meaningful title
  • My pull request is targeting the dev branch
  • I have added documentation to my code
  • I have deleted code that I have commented out

Additional Information

  • This PR closes #

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 66.7% 2348 / 3520
🔵 Statements 65.24% 2435 / 3732
🔵 Functions 64.14% 458 / 714
🔵 Branches 51.71% 1145 / 2214
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
app/db/schema/index.ts 100% 100% 100% 100%
app/db/schema/rate-limit-grant.ts 50% 100% 0% 50% 14
app/lib/api-route-matching.ts 100% 100% 100% 100%
app/lib/api-routes.ts 100% 100% 100% 100%
app/middleware/rate-limit-api.server.ts 81.6% 68.42% 85% 85.04% 79-85, 89, 108-109, 119-123, 156, 166, 184, 187, 222, 263, 301-335
Generated in workflow #2912 for commit 76053d7 by the Vitest Coverage Report Action

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added configurable API rate limiting with route-specific thresholds and time windows.
    • Supports grants based on credentials, verified email domains, and JWT claims.
    • Rate-limited responses now include standard rate-limit headers; exceeded requests receive a clear error response.
    • Added an admin page to create, edit, enable, disable, and expire rate-limit grants.
    • Added flexible API route matching, including dynamic paths and trailing-slash handling.
  • Documentation

    • Reformatted API-route guidance in the README for improved readability.

Walkthrough

The change adds database-backed rate-limit grants, shared API route matching, configurable route limits, rate-limit middleware, grant-based tiers, admin grant management, and automated coverage for quota and grant behavior.

Changes

API rate limiting

Layer / File(s) Summary
Grant schema and persistence
app/db/drizzle/*, app/db/schema/*, app/db/models/rate-limit-grant.server.ts
Added the rate_limit_grant table, Drizzle metadata, migration journal entry, typed schema, and helpers for reading, creating, updating, and disabling grants.
Route contracts and matching
app/lib/api-route-matching.ts, app/lib/api-routes.ts, app/middleware/tos-api.server.ts
Added route rate-limit configuration and shared method/path matching. ToS middleware now uses the shared matcher.
Rate-limit evaluation and middleware
app/middleware/rate-limit-api.server.ts, app/routes/api.ts, tests/lib/api-rate-limit.spec.ts
Added sliding-window limits, requester buckets, cached grant lookup, JWT and credential grant matching, rate-limit headers, 429 responses, middleware wiring, and tests.
Grant administration interface
app/routes/admin._index.tsx, app/routes/admin.rate-limits.tsx
Added the admin link and grant management route with validation, creation, editing, enablement, expiration, notes, and field-level errors.

Documentation formatting

Layer / File(s) Summary
README formatting
README.md
Reflowed documentation lines and adjusted whitespace without changing content.

Estimated code review effort: 4 (Complex) | ~60 minutes

Merge Risk: 🟠 High · up to 76053

This PR adds API rate limiting, but callers can currently bypass the limits by changing client-supplied identity headers, so protections for sign-in, registration, and password-reset endpoints may never activate. Expiry handling can also apply limits at the wrong time, and the reset response header is malformed; these concrete correctness and security issues should be fixed before merge.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant apiRateLimitMiddleware
  participant findApiRoute
  participant getActiveRateLimitGrants
  participant tosApiMiddleware
  Client->>apiRateLimitMiddleware: Send API request
  apiRateLimitMiddleware->>findApiRoute: Match method and pathname
  findApiRoute-->>apiRateLimitMiddleware: Return route configuration
  apiRateLimitMiddleware->>getActiveRateLimitGrants: Load active grants
  getActiveRateLimitGrants-->>apiRateLimitMiddleware: Return matching grant tiers
  alt Limit exceeded
    apiRateLimitMiddleware-->>Client: Return JSON 429
  else Limit available
    apiRateLimitMiddleware->>tosApiMiddleware: Continue middleware chain
    tosApiMiddleware-->>Client: Return response with rate-limit headers
  end
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The description contains only an uncompleted template and does not provide meaningful details about the rate-limiting changes. Add a concise summary of the implementation, selected change type, affected areas, and any limitations or related issues.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies rate limiting, which is the primary change in the pull request.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/rate-limiting

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
app/db/drizzle/0048_tired_blue_shield.sql (1)

1-11: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider a unique constraint on (kind, value).

The table permits several enabled rows with the same kind and value but different tier. resolveRateLimitTier in app/middleware/rate-limit-api.server.ts uses grants.find(...), so the applied tier then depends on row order. A partial unique index prevents duplicate active grants.

♻️ Proposed migration addition
 CREATE TABLE "rate_limit_grant" (
 	...
 );
+--> statement-breakpoint
+CREATE UNIQUE INDEX "rate_limit_grant_kind_value_active_uq"
+	ON "rate_limit_grant" ("kind", "value") WHERE "enabled";
app/middleware/rate-limit-api.server.ts (2)

196-222: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard the tier lookup against unknown values.

RATE_LIMIT_TIERS[matchedCredentialGrant.tier] and RATE_LIMIT_TIERS[matchedUserGrant.tier] assume the stored tier is one of the known keys. The tier column is plain text with no CHECK constraint, so a row written outside the typed helpers produces undefined and a TypeError here. The middleware runs on every API request, so one bad row breaks the whole API.

Fall back to DEFAULT_RATE_LIMIT_TIER when the tier is unknown.

♻️ Proposed guard
+function toResolvedTier(tier: RateLimitTier): ResolvedRateLimitTier {
+	const config = RATE_LIMIT_TIERS[tier]
+	if (!config) return DEFAULT_RATE_LIMIT_TIER
+	return { name: tier, multiplier: config.multiplier }
+}

72-76: 🩺 Stability & Availability | 🔵 Trivial

Per-process buckets multiply the effective limit.

buckets is a module-level Map. Each server process or worker keeps its own counters. With N instances behind a load balancer the effective limit is N times the configured value, and a deploy resets all counters. The cachedGrants cache has the same property, so a grant change takes up to GRANT_CACHE_TTL_MS per instance to apply.

If the deployment runs more than one instance, move the counters to a shared store such as Redis, or document the per-instance behavior and set the configured limits accordingly.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 22a81cdf-9c08-4874-811f-1d0a5d1ef2a8

📥 Commits

Reviewing files that changed from the base of the PR and between d7e3d64 and 76053d7.

📒 Files selected for processing (15)
  • README.md
  • app/db/drizzle/0048_tired_blue_shield.sql
  • app/db/drizzle/meta/0048_snapshot.json
  • app/db/drizzle/meta/_journal.json
  • app/db/models/rate-limit-grant.server.ts
  • app/db/schema/index.ts
  • app/db/schema/rate-limit-grant.ts
  • app/lib/api-route-matching.ts
  • app/lib/api-routes.ts
  • app/middleware/rate-limit-api.server.ts
  • app/middleware/tos-api.server.ts
  • app/routes/admin._index.tsx
  • app/routes/admin.rate-limits.tsx
  • app/routes/api.ts
  • tests/lib/api-rate-limit.spec.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Comment on lines +127 to +149
function getRequesterCredential(request: Request) {
const credential =
request.headers.get('authorization') ??
request.headers.get('x-osem-device-api-key') ??
request.headers.get('x-service-key')

if (!credential) return null

return {
raw: credential,
hash: createHash('sha256').update(credential).digest('hex'),
}
}

function getRequesterKey(request: Request) {
const jwtPayload = getVerifiedJwtPayload(request)
if (jwtPayload?.sub) return `user:${normalizeEmail(String(jwtPayload.sub))}`

const credential = getRequesterCredential(request)
if (credential) return `credential:${credential.hash}`

return `ip:${getClientAddress(request)}`
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Build rate-limit bucket keys only from trusted identity data.

getRequesterKey currently accepts caller-controlled authorization and forwarding headers, so a client can rotate either value to obtain a new bucket on every request. This bypasses the limits for sign-in, registration, and password-reset endpoints.

Verify bearer tokens and database-backed device or service keys before using them. Otherwise use the server-derived client address, accepting forwarded headers only from a trusted proxy with configured hop handling. Update the tests so verified credentials share a bucket while unverified authorization and rotating forwarding headers fall back to the same client-address bucket.

📍 Affects 2 files
  • app/middleware/rate-limit-api.server.ts#L127-L149 (this comment)
  • tests/lib/api-rate-limit.spec.ts#L62-L89

Comment on lines +300 to +310
function rateLimitHeaders(result: RateLimitResult) {
return {
'RateLimit-Limit': String(result.limit.maxRequests),
'RateLimit-Remaining': String(result.remaining),
'RateLimit-Reset': String(Math.ceil(result.resetAt / 1000)),
'RateLimit-Policy': `${result.limit.maxRequests};w=${Math.ceil(
result.limit.windowMs / 1000,
)};tier="${result.tier}"`,
'Retry-After': String(result.retryAfterSeconds),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

IETF draft RateLimit header fields RateLimit-Reset delta seconds or timestamp

💡 Result:

The IETF standardization efforts for RateLimit header fields specify that the RateLimit-Reset header must use a relative delta-seconds format, not an absolute timestamp [1][2][3]. Key details regarding this specification include: Format: The RateLimit-Reset field is defined as a non-negative integer representing the number of seconds remaining until the quota resets [1][3]. It is explicitly designed to be compatible with the delay-seconds notation used in the Retry-After header [4][3][5]. Rationale: Standardizing on delta-seconds (a relative time) rather than a timestamp (an absolute time, such as a Unix epoch) is a deliberate choice intended to [1][3][6]: - Avoid dependency on clock synchronization between the client and the server [1][3][7]. - Ensure resilience against clock skew and manual clock adjustments [1][3][7]. - Mitigate the "thundering herd" problem, which can occur when many clients attempt to reconnect simultaneously at the exact moment specified by a shared, absolute timestamp [1][3][7]. Context: While legacy, non-standardized implementations (often using X-RateLimit-Reset) frequently used Unix timestamps, the standardized RateLimit-Reset header mandated by IETF drafts moves away from this practice to improve interoperability and reliability [6][8]. Implementers are advised that the server may dynamically adjust the RateLimit-Reset value between requests, such as during periods of resource saturation or when employing sliding window policies [1][3][9].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
file="$(fd -t f 'rate-limit-api\.server\.ts$' . | head -n 1)"
printf '%s\n' "FILE=$file"
sed -n '1,180p' "$file"
sed -n '270,325p' "$file"
printf '%s\n' '--- related symbols ---'
rg -n --glob '*.ts' 'type RateLimitResult|interface RateLimitResult|resetAt|retryAfterSeconds|RateLimit-Reset' .

Repository: openSenseMap/frontend

Length of output: 7569


🏁 Script executed:

#!/bin/bash
set -eu
file="./app/middleware/rate-limit-api.server.ts"
sed -n '220,315p' "$file"
printf '%s\n' '--- rate-limit tests ---'
sed -n '1,110p' tests/lib/api-rate-limit.spec.ts
printf '%s\n' '--- semantic probe ---'
python3 - <<'PY'
import math
now_ms = 1_700_000_000_000
window_ms = 60_000
reset_at = now_ms + window_ms
retry_after_seconds = math.ceil((reset_at - now_ms) / 1000)
absolute_header = math.ceil(reset_at / 1000)
print({
    "resetAt_ms": reset_at,
    "Retry-After": retry_after_seconds,
    "RateLimit-Reset_current": absolute_header,
    "RateLimit-Reset_expected_delta": retry_after_seconds,
})
PY

Repository: openSenseMap/frontend

Length of output: 5640


Emit remaining seconds in RateLimit-Reset. resetAt is an absolute Unix timestamp in milliseconds, but RateLimit-Reset requires delta-seconds. Use String(result.retryAfterSeconds) to match Retry-After.

Comment on lines +452 to +457
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return { ok: false, error: 'Enter a valid expiry date.' }
}

return { ok: true, value: date }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the repository-declared Node.js version, if present.
for file in .nvmrc .node-version .tool-versions package.json; do
  if [ -f "$file" ]; then
    echo "===== $file ====="
    sed -n '1,120p' "$file"
  fi
done

# A datetime-local value has no offset. Its resulting instant changes with server TZ.
for zone in UTC America/New_York; do
  TZ="$zone" node -e '
    const value = "2026-08-24T12:00";
    console.log(process.env.TZ, new Date(value).toISOString());
  '
done

Repository: openSenseMap/frontend

Length of output: 4606


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== outline ====="
ast-grep outline app/routes/admin.rate-limits.tsx

echo "===== parser and datetime-local references ====="
rg -n -C 4 'parseExpiresAt|datetime-local|expiresAt|expiry|expiration' app/routes/admin.rate-limits.tsx

echo "===== relevant source ranges ====="
sed -n '400,490p' app/routes/admin.rate-limits.tsx
sed -n '490,620p' app/routes/admin.rate-limits.tsx

Repository: openSenseMap/frontend

Length of output: 6204


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== route action and loader ====="
sed -n '1,120p' app/routes/admin.rate-limits.tsx

echo "===== grant persistence references ====="
rg -n -C 5 'create|insert|update|expiresAt|parseGrantForm|rate.limit|grant' app/routes/admin.rate-limits.tsx

Repository: openSenseMap/frontend

Length of output: 13145


Preserve the timezone when parsing an expiry value.

datetime-local submits no offset. new Date(value) therefore uses the server timezone, which can persist the wrong expiry instant when the administrator and server use different timezones.

Define the field as UTC in both the UI and parser, or submit an offset-aware ISO timestamp.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant