()
+
+ return (
+
+
+
+ ← Back to admin
+
+
+
+
+
Rate limit grants
+
+ Grant finite higher rate-limit tiers to users, domains, or credential
+ hashes.
+
+
+
+ {actionData?.message ? (
+
+ {actionData.message}
+
+ ) : null}
+
+
+
+
+
+
Existing grants
+ Total: {grants.length}
+
+
+
+
+
+ )
+}
+
+function GrantFields({ actionData }: { actionData?: ActionData }) {
+ return (
+ <>
+
+
+
+ {actionData?.fieldErrors?.kind}
+
+
+
+
+
+ {actionData?.fieldErrors?.value}
+
+
+
+
+
+ {actionData?.fieldErrors?.tier}
+
+
+
+
+
+ {actionData?.fieldErrors?.expiresAt}
+
+
+
+
+
+
+ >
+ )
+}
+
+function FieldError({ children }: { children?: string }) {
+ return children ? (
+ {children}
+ ) : null
+}
+
+function RowFieldError({
+ actionData,
+ grantId,
+ children,
+}: {
+ actionData?: ActionData
+ grantId: string
+ children?: string
+}) {
+ if (actionData?.grantId !== grantId) return null
+ return {children}
+}
+
+function parseGrantForm(formData: FormData):
+ | {
+ ok: true
+ value: {
+ kind: RateLimitGrantKind
+ value: string
+ tier: RateLimitTier
+ enabled: boolean
+ note: string | null
+ expiresAt: Date | null
+ }
+ }
+ | { ok: false; fieldErrors: ActionData['fieldErrors'] } {
+ const kind = parseKind(formData.get('kind'))
+ const tier = parseTier(formData.get('tier'))
+ const rawValue = getString(formData, 'value')
+ const note = getString(formData, 'note').trim() || null
+ const expiresAtResult = parseExpiresAt(getString(formData, 'expiresAt'))
+ const fieldErrors: ActionData['fieldErrors'] = {}
+
+ if (!kind) fieldErrors.kind = 'Choose a valid grant kind.'
+ if (!tier) fieldErrors.tier = 'Choose a valid tier.'
+ if (!rawValue.trim()) fieldErrors.value = 'Value is required.'
+ else if (kind) {
+ const valueError = validateValue(kind, rawValue)
+ if (valueError) fieldErrors.value = valueError
+ }
+ if (!expiresAtResult.ok) fieldErrors.expiresAt = expiresAtResult.error
+
+ if (
+ fieldErrors.kind ||
+ fieldErrors.value ||
+ fieldErrors.tier ||
+ fieldErrors.expiresAt ||
+ !expiresAtResult.ok
+ ) {
+ return { ok: false, fieldErrors }
+ }
+
+ const expiresAt = expiresAtResult.value
+
+ return {
+ ok: true,
+ value: {
+ kind: kind!,
+ value: rawValue,
+ tier: tier!,
+ enabled: formData.get('enabled') === 'on' || !formData.has('id'),
+ note,
+ expiresAt,
+ },
+ }
+}
+
+function parseKind(
+ value: FormDataEntryValue | null,
+): RateLimitGrantKind | null {
+ return GRANT_KINDS.includes(value as RateLimitGrantKind)
+ ? (value as RateLimitGrantKind)
+ : null
+}
+
+function parseTier(value: FormDataEntryValue | null): RateLimitTier | null {
+ return RATE_LIMIT_TIERS.includes(value as RateLimitTier)
+ ? (value as RateLimitTier)
+ : null
+}
+
+function validateValue(kind: RateLimitGrantKind, value: string) {
+ const trimmed = value.trim()
+ if (kind === 'user_email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
+ return 'Enter a valid email address.'
+ }
+
+ if (kind === 'email_domain') {
+ const domain = trimmed.replace(/^@/, '')
+ if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(domain)) {
+ return 'Enter a valid domain, for example school.example.'
+ }
+ }
+
+ if (kind === 'credential_hash' && !/^[a-f0-9]{64}$/.test(trimmed)) {
+ return 'Enter a lowercase SHA-256 hash.'
+ }
+
+ return null
+}
+
+function parseExpiresAt(
+ value: string,
+): { ok: true; value: Date | null } | { ok: false; error: string } {
+ if (!value.trim()) return { ok: true, value: null }
+
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) {
+ return { ok: false, error: 'Enter a valid expiry date.' }
+ }
+
+ return { ok: true, value: date }
+}
+
+function toDateTimeLocalValue(value: Date | string | null) {
+ if (!value) return ''
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return ''
+ const offsetMs = date.getTimezoneOffset() * 60_000
+ return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16)
+}
+
+function getString(formData: FormData, key: string) {
+ const value = formData.get(key)
+ return typeof value === 'string' ? value : ''
+}
diff --git a/app/routes/api.ts b/app/routes/api.ts
index 0de6316a..2be4fea5 100644
--- a/app/routes/api.ts
+++ b/app/routes/api.ts
@@ -2,10 +2,14 @@ import { type Route } from '../+types/root'
import * as z from 'zod/v4'
import { type ZodOpenApiPathItemObject } from 'zod-openapi'
import { apiRoutes as routes } from '~/lib/api-routes'
+import { apiRateLimitMiddleware } from '~/middleware/rate-limit-api.server'
import { tosApiMiddleware } from '~/middleware/tos-api.server'
export { routes }
-export const middleware: Route.MiddlewareFunction[] = [tosApiMiddleware]
+export const middleware: Route.MiddlewareFunction[] = [
+ apiRateLimitMiddleware,
+ tosApiMiddleware,
+]
const ApiIndexResponseSchema = z.string().meta({
id: 'ApiIndexResponse',
diff --git a/tests/lib/api-rate-limit.spec.ts b/tests/lib/api-rate-limit.spec.ts
new file mode 100644
index 00000000..dc52967b
--- /dev/null
+++ b/tests/lib/api-rate-limit.spec.ts
@@ -0,0 +1,182 @@
+import { createHash } from 'node:crypto'
+import jsonwebtoken from 'jsonwebtoken'
+
+const { activeGrants } = vi.hoisted(() => ({
+ activeGrants: [] as Array<{
+ kind: 'user_email' | 'email_domain' | 'credential_hash'
+ value: string
+ tier: 'standard_plus' | 'trusted' | 'high_volume'
+ }>,
+}))
+
+vi.mock('~/db/models/rate-limit-grant.server', () => ({
+ getActiveRateLimitGrants: vi.fn(async () => activeGrants),
+}))
+
+import {
+ checkApiRateLimit,
+ resetApiRateLimitForTests,
+} from '~/middleware/rate-limit-api.server'
+
+const BASE_URL = 'http://localhost:4200'
+const { sign } = jsonwebtoken
+
+function restoreEnv(name: string, value: string | undefined) {
+ if (value === undefined) delete process.env[name]
+ else process.env[name] = value
+}
+
+describe('API rate limiting', () => {
+ beforeEach(() => {
+ activeGrants.length = 0
+ resetApiRateLimitForTests()
+ })
+
+ it('limits sensitive auth endpoints with route-specific limits', async () => {
+ let result: Awaited> = null
+
+ for (let i = 0; i < 10; i++) {
+ result = await checkApiRateLimit(
+ new Request(`${BASE_URL}/api/users/sign-in`, {
+ method: 'POST',
+ headers: { 'x-forwarded-for': '203.0.113.1' },
+ }),
+ 1_000,
+ )
+ expect(result?.allowed).toBe(true)
+ }
+
+ result = await checkApiRateLimit(
+ new Request(`${BASE_URL}/api/users/sign-in`, {
+ method: 'POST',
+ headers: { 'x-forwarded-for': '203.0.113.1' },
+ }),
+ 1_000,
+ )
+
+ expect(result?.allowed).toBe(false)
+ expect(result?.limit.maxRequests).toBe(10)
+ expect(result?.retryAfterSeconds).toBe(60)
+ })
+
+ it('separates buckets by credential before falling back to the client address', async () => {
+ for (let i = 0; i < 10; i++) {
+ await checkApiRateLimit(
+ new Request(`${BASE_URL}/api/users/sign-in`, {
+ method: 'POST',
+ headers: {
+ authorization: 'Bearer first-token',
+ 'x-forwarded-for': '203.0.113.2',
+ },
+ }),
+ 1_000,
+ )
+ }
+
+ const sameAddressDifferentCredential = await checkApiRateLimit(
+ new Request(`${BASE_URL}/api/users/sign-in`, {
+ method: 'POST',
+ headers: {
+ authorization: 'Bearer second-token',
+ 'x-forwarded-for': '203.0.113.2',
+ },
+ }),
+ 1_000,
+ )
+
+ expect(sameAddressDifferentCredential?.allowed).toBe(true)
+ expect(sameAddressDifferentCredential?.remaining).toBe(9)
+ })
+
+ it('can grant a higher tier by credential hash', async () => {
+ const credential = 'school-device-api-key'
+ const credentialHash = createHash('sha256').update(credential).digest('hex')
+ activeGrants.push({
+ kind: 'credential_hash',
+ value: credentialHash,
+ tier: 'standard_plus',
+ })
+
+ const result = await checkApiRateLimit(
+ new Request(`${BASE_URL}/api/users/sign-in`, {
+ method: 'POST',
+ headers: { 'x-osem-device-api-key': credential },
+ }),
+ 1_000,
+ )
+
+ expect(result?.tier).toBe('standard_plus')
+ expect(result?.limit.maxRequests).toBe(50)
+ expect(result?.remaining).toBe(49)
+ })
+
+ it('can grant a higher tier by verified JWT email domain', async () => {
+ const originalAlgorithm = process.env.JWT_ALGORITHM
+ const originalIssuer = process.env.JWT_ISSUER
+ const originalSecret = process.env.JWT_SECRET
+
+ try {
+ process.env.JWT_ALGORITHM = 'HS256'
+ process.env.JWT_ISSUER = 'opensensemap-test'
+ process.env.JWT_SECRET = 'test-secret'
+ activeGrants.push({
+ kind: 'email_domain',
+ value: 'school.example',
+ tier: 'standard_plus',
+ })
+
+ const token = sign({ role: 'user' }, process.env.JWT_SECRET, {
+ algorithm: 'HS256',
+ issuer: process.env.JWT_ISSUER,
+ subject: 'teacher@school.example',
+ })
+
+ const result = await checkApiRateLimit(
+ new Request(`${BASE_URL}/api/users/sign-in`, {
+ method: 'POST',
+ headers: { authorization: `Bearer ${token}` },
+ }),
+ 1_000,
+ )
+
+ expect(result?.tier).toBe('standard_plus')
+ expect(result?.limit.maxRequests).toBe(50)
+ } finally {
+ restoreEnv('JWT_ALGORITHM', originalAlgorithm)
+ restoreEnv('JWT_ISSUER', originalIssuer)
+ restoreEnv('JWT_SECRET', originalSecret)
+ }
+ })
+
+ it('opens a new bucket after the configured window resets', async () => {
+ for (let i = 0; i < 10; i++) {
+ await checkApiRateLimit(
+ new Request(`${BASE_URL}/api/users/sign-in`, {
+ method: 'POST',
+ headers: { 'x-forwarded-for': '203.0.113.3' },
+ }),
+ 1_000,
+ )
+ }
+
+ const resetResult = await checkApiRateLimit(
+ new Request(`${BASE_URL}/api/users/sign-in`, {
+ method: 'POST',
+ headers: { 'x-forwarded-for': '203.0.113.3' },
+ }),
+ 61_001,
+ )
+
+ expect(resetResult?.allowed).toBe(true)
+ expect(resetResult?.remaining).toBe(9)
+ })
+
+ it('does not rate limit URLs that are not defined in apiRoutes', async () => {
+ const result = await checkApiRateLimit(
+ new Request(`${BASE_URL}/api/unknown`, { method: 'GET' }),
+ 1_000,
+ )
+
+ expect(result).toBeNull()
+ })
+})