Introduce a cacheable(maxAge) middleware factory in backend-server.js that hooks res.json to attach Cache-Control: public, max-age=N — but only on 2xx responses, so Cloudflare's edge never caches 4xx/5xx. Wire it on the routes whose upstreams refresh slowly: cfradar / asn-history / whois / maxmind / macchecker plus the third-party IP geolocation handlers (ipinfo / ipapicom / ipsb / ipapiis / ip2location). Auth-context handlers (ipchecking, invisibility, dns-leak-test/session, getuserinfo, updateuserachievement) remain on the global no-store default — their caching belongs at the upstream that owns the auth. Also trim stale "what" comments throughout backend-server.js and document the new pattern in api/AGENTS.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.6 KiB
api/AGENTS.md
Conventions for Express 5 back-end handlers under api/ and the shared back-end
code under common/. See ../AGENTS.md for universal project rules
(language, commits, testing expectations).
Overview
The Express app is defined in backend-server.js at the repo root. Every route
is wired there and delegated to a handler module under api/. Shared code
(referer check, IP validator, fetch helper, Express middleware, MaxMind service)
lives under common/ and is also consumed by the frontend where it makes sense
(e.g. valid-ip.js, fetch-with-timeout.js).
Project layout
api/
├── configs.js ← /api/configs — reports which env-gated features are on
├── google-map.js ← /api/map — Google Static Maps image proxy (binary stream)
├── ipinfo-io.js, ipapi-com.js, ipapi-is.js, ip2location-io.js, ip-sb.js,
│ ipcheck-ing.js, maxmind.js ← IP geolocation source handlers (route per source)
├── invisibility-test.js ← /api/invisibility — proxy to private IPCheck.ing endpoint
├── mac-checker.js ← /api/macchecker — MAC vendor lookup
├── get-whois.js ← /api/whois — whoiser primary + RDAP fallback for new gTLDs
├── cf-radar.js ← /api/cfradar — ASN details via Cloudflare Radar
├── dns-resolver.js ← /api/dnsresolver — DNS + DoH parallel query
├── dns-leak-test.js ← /api/dnsleaktest/session/:token — proxy to private
│ IPCheck.ing endpoint (Firebase-gated) that drives the
│ in-depth DNS Leak Test advanced tool
├── get-user-info.js ← /api/getuserinfo — user-profile proxy
└── update-user-achievement.js ← /api/updateuserachievement — user-achievement proxy
common/
├── fetch-with-timeout.js ← fetchWithTimeout (5s default) + fetchUpstream (8s preset)
├── guards.js ← requireReferer + requireValidIP Express middleware
├── logger.js ← shared pino logger (pretty in dev, JSON in prod)
├── referer-check.js ← low-level referer allow-list check
├── valid-ip.js ← IPv4 / IPv6 validator (also re-exported from frontend)
├── rdap.js ← RDAP client (domain fallback when whoiser returns no __raw)
├── maxmind-service.js ← mmdb reader + lookup
├── maxmind-updater.js ← mmdb bootstrap download at boot +
│ scheduled auto-update
└── maxmind-db/ ← GeoLite2-ASN.mmdb + GeoLite2-City.mmdb
Conventions
-
Handler shape. Each handler is a single default export,
async (req, res) => …. It reads fromreq.query/req.body, calls upstream, and writes one response. -
Every upstream HTTP call uses
fetchUpstreamfromcommon/fetch-with-timeout.js:import { fetchUpstream } from '../common/fetch-with-timeout.js'; const apiRes = await fetchUpstream(url); const json = await apiRes.json(); res.json(transform(json));Default timeout is 8s. Never add a bare
fetch()orhttps.get()— if a provider hangs, the Express connection should time out, not pin indefinitely. -
Error shape.
res.status(500).json({ error: error.message })on upstream failures;res.status(400).json({ error: '…' })on bad input. Be terse — the frontend doesn't display these error strings verbatim. -
Response shape. IP-geolocation handlers normalize their upstream's response into the canonical shape consumed by the frontend (
ip/country/country_name/country_code/latitude/longitude/asn/org/ …). If you add a new source, match the existing shape. -
Logging.
import logger from '../common/logger.js'and uselogger.error({ err: error, ...context }, 'short message')on upstream failures, never bareconsole.*(banned project-wide for backend code — see root AGENTS.md "Logging"). Thepino-httpmiddleware mounted on/apialready records the request line + status + response time, so handlers should only log domain-specific events / errors, not "received request" lines.
Security & Boundaries
Guards live in middleware, not in handlers
requireRefereris mounted globally on/api/*inbackend-server.js. It rejects any request whoseRefererheader isn't on theALLOWED_DOMAINSlist (pluslocalhostalways). Handlers must not repeat the referer check.requireValidIP()is attached per-route to every handler that takes?ip=. It rejects missing or malformed IPs before the handler runs. Handlers must not repeat the IP check — inside the handler body,req.query.ipis already known to be a well-formed string.- If you add a new handler that needs a different-shape param guard, add the guard to
common/guards.jsand attach it inbackend-server.jsrather than open-coding the check in the handler.
Private-API header pass-through (intentional exception)
Handlers that call our own private IPCheck.ing API (ipcheck-ing.js, invisibility-test.js, update-user-achievement.js, get-user-info.js, dns-leak-test.js) forward the caller's request headers to the upstream:
const apiResponse = await fetchUpstream(url, { headers: { ...req.headers } });
This is deliberate — the upstream expects caller context (Accept-Language, auth tokens, etc.). Do not replicate this pattern for third-party upstreams; third-party handlers should send only what's explicitly needed.
Defensive method gates
Some handlers keep a req.method !== 'GET' (or PUT) branch even though Express routes already gate the method. These exist because dedicated smoke tests assert on that branch directly against the handler. If you add or copy a handler, leave the defensive gate in place if a test covers it.
Edge caching
Default: every /api/* response gets Cache-Control: no-store from the global middleware in backend-server.js. Routes that serve slowly-changing public data opt in to Cloudflare edge caching by attaching the cacheable(maxAgeSeconds) middleware (defined in backend-server.js):
app.get('/api/cfradar', cacheable(60 * 60), cfHander);
app.get('/api/asn-history', requireValidIP(), cacheable(24 * 60 * 60), asnHistoryHandler);
Write the TTL as a multiplied expression (60 * 60 / 24 * 60 * 60 / 30 * 24 * 60 * 60) rather than a raw second count — the intent is self-evident at a glance, and JS folds the constant at call time so there's no runtime cost.
The middleware hooks res.json and only sets Cache-Control: public, max-age=N when the response status is < 400 — error responses keep no-store so CF never caches a 4xx/5xx page. Handlers themselves stay pure and don't touch Cache-Control. Auth'd or per-user endpoints (ipchecking / invisibility / dns-leak-test/session / getuserinfo / etc.) must not be wrapped with cacheable — their caching belongs at the upstream service that owns the auth context.
Testing
- Smoke tests for every handler live in
tests/api-handlers.test.js. They cover method gating, param presence / validity (beyond what middleware handles), and the "API key missing" early-return branches. - Never hit real upstream APIs in tests — assert on branches that
returnbefore the firstfetchUpstream. - Middleware itself is covered by
tests/guards.test.js(referer + IP validation). Don't duplicate those assertions at the handler level. fetchUpstream/fetchWithTimeouttiming + abort behavior is covered bytests/fetch-with-timeout.test.js.