Repository navigation
Rate-limit and abuse-protect public MCP reads and LLM endpoints #11
Description
Context
The only throttle today is the 1/day demo-IP quota on /api/demo/extractions (consumeDemoQuota, apps/api/src/worker.ts:4296). Token-less public namespace MCP reads (/mcp, worker.ts:800) and the LLM-backed endpoints — /api/memwal/chat (worker.ts:634/3498), aiQueryRun (worker.ts:1349), /api/memwal/recall (worker.ts:631) — are completely uncapped: an open cost/abuse vector that blocks flipping the repo public (docs/launch-checklist.md).
Goal / user story
As the platform owner, I want per-IP and per-token rate limits on public reads and LLM endpoints so a single client can't run up unbounded Workers AI / OpenRouter cost or scrape the directory, returning clear 429s.
Acceptance criteria
- A
rateLimit(env, key, { limit, windowSec })helper returns{ allowed, remaining, resetAt }, implemented via Cloudflare's Rate Limiting binding (preferred) or a D1 sliding window mirroringconsumeDemoQuota. - Distinct buckets: anonymous (per-IP via
clientIp,worker.ts:4313) vs authed (per read-token/account) with higher authed limits. - Enforced on: public MCP reads (
/mcp),/api/memwal/chat,aiQueryRun,/api/memwal/recall, and the directory listing (/api/directory,worker.ts:687). - On limit: HTTP 429 with
Retry-Afterand a typed code (reuse thestatusError(..., 429, "RATE_LIMITED")shape, seeworker.ts:4302);X-RateLimit-Remaining/-Resetheaders on success. - Limits are env-configurable via
wrangler.jsoncvars (e.g.RATE_LIMIT_CHAT_PER_MIN) with safe defaults; staging can override. - Tests cover: under-limit passes, over-limit 429s, authed bucket > anon bucket, and limits are per-key not global.
Implementation notes
The Cloudflare Rate Limiting binding needs a ratelimit/[[unsafe.bindings]] entry in wrangler.jsonc (both top-level and env.staging); document the binding in .env.example/README. If the binding is awkward under tests, fall back to a D1 token-bucket keyed ${windowStart}:${ipHashOrToken}:${endpoint} reusing the upsert at worker.ts:4304. Keep this separate from the usage ledger (rate-limit = ephemeral sliding window; ledger = durable audit), though you may emit a rate_limited ledger event for abuse visibility. Do not regress the existing SSRF guard (isPrivateIpv4, worker.ts:4288).
Sui Overflow angle
A public hackathon demo with an open, unauthenticated MCP + LLM surface is a guaranteed cost/abuse incident the moment the link is shared. Capping it is what makes it safe to flip the repo public and submit to the Smithery/Claude/Cursor marketplaces (docs/launch-checklist.md D5) — it unblocks the growth/demo loop.
Dependencies
Per-token/per-account buckets get stronger once the accounts/owner-auth issue lands, but per-IP limiting ships independently. None blocking.
Part of the ContextMEM roadmap (#4) • Sui Overflow build.
Activity
harrymove-ctrl commentedon Jun 19, 2026
Implementation Plan
Approach
Add a binding-agnostic
rateLimit(env, key, { limit, windowSec })helper backed by a D1 fixed-window counter that mirrorsconsumeDemoQuota(apps/api/src/worker.ts:4296) and returns{ allowed, remaining, resetAt }. Enforce it from a single chokepoint — the routerrouteWorkerRequest(worker.ts:575) — wrapping each protected route so the handler bodies stay untouched. Use two bucket classes: anon keyed by hashedclientIp(worker.ts:4313) and authed keyed by the validated read-token hash / MemWalownerId, each with its own env-var limit (authed > anon). Key design decision: go D1-first rather than the native Cloudflare Rate Limiting binding — the native binding only returns{ success }(noremaining/resetAt), bakes its limit into config (can't be driven by aRATE_LIMIT_*_PER_MINvar), and isn't exercisable in vitest/Miniflare — all three are hard acceptance criteria. Keep the helper signature binding-shaped so a CFratelimitbinding can be slotted in later for the hottest unauth path.Prerequisites & dependencies
ownerId(readHostedRunAuth,worker.ts:1461) andctxm_read tokens already give us a usable authed key today.CONTEXTMEM_DB). If the team later wants the CF binding accelerator, it needs anunsafe.bindingsentry oftype: "ratelimit"in both top-level andenv.stagingofcloudflare/wrangler.jsonc— document but do not depend on it.WorkerEnv(worker.ts:32-55) andcloudflare/wrangler.jsoncvars(top-level +env.staging).wrangler deploy --config apps/api/cloudflare/wrangler.jsoncand--env staging, and apply migration 0007 to both D1 dbs.Step-by-step
DB migration. Create
apps/api/migrations/0007_rate_limits.sql(next number after0006):Mirror
contextmem_demo_limitsshape (migrations/0004_growth_loops.sql:12).Core helper. In
worker.tsnearconsumeDemoQuota(~4296) add:async function rateLimit(env, key, { limit, windowSec }): Promise<{ allowed; remaining; resetAt }>— computenow = Math.floor(Date.now()/1000),windowStart = now - (now % windowSec),resetAt = (windowStart + windowSec) * 1000,bucketKey = ${windowSec}:${windowStart}:${key}. Atomic increment-and-read via the upsert pattern atworker.ts:4304plusRETURNING count:allowed = count <= limit;remaining = Math.max(0, limit - count). Counting rejected hits too is intentional (keeps hammered keys blocked).function rateLimitConfig(env)— parseRATE_LIMIT_*vars to numbers with defaults; honorRATE_LIMIT_ENABLED !== "false".async function rateLimitKey(request, endpoint, opts)— resolve identity: ifreadHostedRunAuth(request)(worker.ts:1461) present →acct:${ownerId}(authed); else if a validated read token is known →tok:${sha256Hex(token)}(authed); elseip:${await sha256Hex(clientIp(request))}(anon). Return{ key: ${endpoint}:${idPart}, authed }. Reusesha256Hex(worker.ts:4507).async function enforceRateLimit(request, env, endpoint, { authedLimit, anonLimit, windowSec })→ returns therateLimitresult +authedflag.function rateLimited429(rl, isMcp)— for MCP returnmcpJsonError(message, 429)(worker.ts:5281); else build aResponsedirectly (do notthrow statusError, so we control headers) with body shaped likestatusError(..., 429, "RATE_LIMITED")→{ error: { code: "RATE_LIMITED", message, hint }, message }, status 429, headersRetry-After: ceil((resetAt-now)/1000),X-RateLimit-Remaining: 0,X-RateLimit-Reset: resetAt, wrapped incors().function withRateLimitHeaders(res, rl)— clone response, setX-RateLimit-Remaining/X-RateLimit-Reset.Wire enforcement into the router (
routeWorkerRequest,worker.ts:575), each as a one-liner guard before the existing handler call:/api/memwal/recall(worker.ts:631),/api/memwal/chat(worker.ts:634):const rl = await enforceRateLimit(request, env, "chat"|"recall", ...); if (!rl.allowed) return rateLimited429(rl, false); return withRateLimitHeaders(await memwalChat(request, env), rl);aiQueryRunin thehostedRunRouteblock (worker.ts:652): same wrapper, endpoint"ai_query". (Demo runs key by anon IP sinceownerIdisdemo:<ip>; authed runs key byownerId.)/api/directory(worker.ts:687): wraplistDirectoryNamespaces(anon-heavy, the scrape target), endpoint"directory".worker.ts:772-795): enforce afterauthorizeNamespacesucceeds (so a valid read token yields the authed bucket) but beforecreateMcpHandler. Skip the GET browser-landing path (worker.ts:774-776). On block returnrateLimited429(rl, true). Endpoint"mcp".createDemoExtraction/consumeDemoQuotaas-is (separate durable quota; do not double-enforce).Expose headers to browsers. In
cors()(worker.ts:5303) extendaccess-control-expose-headersfrom"mcp-session-id"to also includex-ratelimit-remaining, x-ratelimit-reset, retry-after, else fetch clients can't read them.Env + config. Add to
WorkerEnv(worker.ts:32):RATE_LIMIT_ENABLED?,RATE_LIMIT_WINDOW_SEC?,RATE_LIMIT_MCP_ANON_PER_MIN?/_AUTHED_,RATE_LIMIT_CHAT_PER_MIN?/_AUTHED_,RATE_LIMIT_RECALL_PER_MIN?/_AUTHED_,RATE_LIMIT_AI_QUERY_PER_MIN?/_AUTHED_,RATE_LIMIT_DIRECTORY_PER_MIN?(allstringsince wrangler vars are strings). Add the same keys with conservative defaults tocloudflare/wrangler.jsoncvars(:6) andenv.staging.vars(:66) so staging can override. Suggested defaults: chat/ai_query 10/min anon, 60/min authed; recall 20/120; mcp 30/300; directory 30 anon.Cron cleanup (optional, cheap). In
processDueSchedules(cron*/30,worker.ts:540) add aDELETE FROM contextmem_rate_limits WHERE window_start < ?(older than ~1h) so the table doesn't grow unbounded.Docs. Document the new vars + behavior in
.env.example(root) andREADME.md; note the optional CFratelimitbinding stub. Tick the relevant line indocs/launch-checklist.md(D5 abuse-protection precondition for going public).New / changed files
apps/api/migrations/0007_rate_limits.sql(new)apps/api/src/worker.ts(helpers + router guards +cors/WorkerEnvedits)apps/api/cloudflare/wrangler.jsonc(vars top-level +env.staging)apps/api/cloudflare/wrangler.example.jsonc(mirror the new vars)apps/api/src/worker.test.ts(new tests + MemoryD1 mock support).env.example,README.md,docs/launch-checklist.md(docs)Test plan
MemoryD1Database(worker.test.ts:845) only answers enumerated queries; unknown queries return null/empty, so the rate-limit upsert would never count. Add acontextmem_rate_limitsmap + handle theINSERT ... ON CONFLICT ... RETURNING countinMemoryD1Statement.first()(worker.test.ts:878) returning{ count }, incrementing perbucket_key.worker.test.ts), usingvi.useFakeTimers()/vi.setSystemTimeto pin the window:/api/memwal/chatreturn non-429 and a decreasingX-RateLimit-Remaining.Retry-Afterheader and body codeRATE_LIMITED.RATE_LIMIT_CHAT_PER_MINlow; exhaust as anon (no headers) → 429; same count withx-memwal-account-id+x-memwal-authorizationdelegate headers still passes (uses_AUTHED_limit).cf-connecting-ip: 1.1.1.1) → 429; IP B (2.2.2.2) still 200. Repeat for two distinct read tokens on/mcp.windowSec; a blocked key passes again./mcpreturns JSON-RPC 429 (mcpJsonError) not the REST body.for i in $(seq 1 40); do curl -s -o /dev/null -w "%{http_code} " https://contextmem-backend-staging.petlofi.workers.dev/api/memwal/chat -d '{"message":"hi"}' -H 'content-type: application/json'; doneshows 200s then 429s; inspectX-RateLimit-Remainingcounting down andRetry-Afteron the 429. Confirm SSRF guardisPrivateIpv4(worker.ts:4288) and/api/demo/extractions1/day quota still behave (no regression).Risks & gotchas
X-RateLimit-*.clientIppreferscf-connecting-ip(trustworthy behind CF); thex-forwarded-forfallback is spoofable but only hit off-CF (local/tests).Retry-After;mcp-remotemay not surface the body, so status + header carry the signal.throw statusErrorfor 429 — the central catch inhandleWorkerRequest(worker.ts:565) rebuilds the body viajson()and would dropRetry-After/X-RateLimit-*; build the Response directly.consumeDemoQuotaindependent (durable daily quota vs ephemeral window).Acceptance-criteria mapping
rateLimit(env, key, {limit, windowSec})→{allowed, remaining, resetAt}via D1 sliding/fixed window mirroringconsumeDemoQuota→ Step 2 (D1 chosen over CF binding for testability + remaining/resetAt; rationale in Approach).rateLimitKey) + 5 (separate_AUTHED_vars); test fix(ci): force bun + pin wrangler in Deploy workflow #3./mcp,/api/memwal/chat,aiQueryRun,/api/memwal/recall,/api/directory→ Step 3 (all five wired).Retry-After+ typedRATE_LIMITEDcode;X-RateLimit-Remaining/-Reseton success → Step 2 (rateLimited429/withRateLimitHeaders) + Step 4 (expose headers); tests Walrus storage + Memory via Tatum, plus CI deploy #1, Memory knowledge graph: 3D neural constellation + grounded entity panel #2, Build HarborClient: reserve→sign→finalize bucket, multipart upload, status poll, download #6.wrangler.jsoncvars with safe defaults, staging override → Step 5.rate_limitedledger event deferred (no ledger exists yet — issue's usage-ledger item is separate).Effort
M — ~2 person-days. ~0.5d helper + router wiring + config, ~0.5d MemoryD1 mock + tests, ~0.5d docs + staging deploy/verify, ~0.5d buffer for tuning defaults and the MCP 429 path.
Implementation plan generated for execution (planning phase). Part of the roadmap (#4).
roblambert9 commentedon Jun 21, 2026
Hi, I read issue #11 on contextMeM. You wrote that the token-less public MCP reads and LLM endpoints "are completely uncapped: an open cost/abuse vector that blocks flipping the repo public," and that you want per-IP and per-token limits so a single client can't run up unbounded Workers AI / OpenRouter cost.
Rate limiting stops the bleeding. The adjacent question is whether you want those same per-token reads to eventually meter and bill per call instead of just 429ing, since you already have the read-token and account buckets you describe.
I run a small, fixed-scope thing for exactly that gap: a $99 MCP Tool Metering Readiness Audit. It is async, takes me about one to two days, and for your worker.ts shape it gives you: where a per-call meter and a tamper-evident SHA-256 receipt would sit relative to your existing consumeDemoQuota and ledger split, a pricing shape for the /mcp reads and the chat/recall endpoints, a runnable PAPER_MODE demo wired to your endpoint shape so no real money moves, and a copy-pasteable go-live plan. It is built on an MIT SDK I maintain (nano-empire-tollbooth).
Full honesty so there is no confusion: the SDK has earned $0 so far and runs in paper mode. I am selling readiness, not revenue, and I am not claiming live payments or traction. The only thing I can prove today is a reproducible dogfood run where an external agent hits a 402, pays a 1 cent toll through a mock verifier, escrow locks then releases, and 33 tests pass. That proves the protocol mechanics, not real money changing hands.
Repo: github.com/roblambert9/nano-empire-tollbooth · PyPI: pypi.org/project/nano-empire-tollbooth
If it's not useful, no worries, I won't follow up.