Cold Radar queries for the per-ASN and per-country views usually answer in 0.6-6s but regularly run past 8s (single segments at 8-30s), so at the fetchUpstream default most cold ASN lookups lost a segment and answered partial, uncached. ASN Info also fetched with the frontend's 5s default, below the backend's own timeout, so a cold lookup often never filled. Every Radar call now defaults to 12s (RADAR_TIMEOUT_MS; the outage poll keeps its 30s). The ASN Profile's radar and prefixes deadlines move to 14s, matching whois, so the page's overall bound is unchanged. ASN Info and the country traffic panel wait 15s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13 KiB
api/AGENTS.md
Conventions for Express 5 handlers under api/ and the back-end code under
server/ and common/. Universal rules live in ../AGENTS.md.
Overview
The Express app lives in backend-server.js at the repo root — every route
is wired there and delegated to one handler module under api/. Code the
handlers share is split by who imports it:
server/— backend-only (guards, logger, upstream clients, service-status poller, …), flat;server/datasets/is the offline dataset engine and its readers (see "Offline datasets").common/— only what the frontend or the build imports too (valid-ip.js,fetch-with-timeout.js,report-schema.js, …); it never imports fromserver/. It also holds the dataset directories (*-db/), which self-hosting docs and Docker volumes mount — they stay put.
Roughly one handler file per route: IP-geolocation sources (ipinfo-io /
ipapi-com / ipapi-is / ip2location-io / ip-sb / ipcheck-ing /
maxmind), tool backends (get-whois / dns-resolver / mac-checker /
cf-radar / asn-history / asn-connectivity / asn-profile /
ooni-blocking / globalping-probes / service-status / google-map /
github-stars / invisibility-test / dns-leak-test / persona /
ip-blocklist / bulk-lookup), user
proxies (get-user-info / update-user-achievement), platform (configs /
sentry-tunnel / share-report). Each file's header comment states its route
and purpose — read those for specifics.
The exception to one-file-per-route is Cloudflare Radar: all Radar data rides
/api/cfradar, dispatched by ?view= over RADAR_VIEWS in
server/cf-radar.js, where each view declares its guards, edge-cache TTL and
fetch function — new Radar data is a view function plus a registry row,
never a new route. outages is served from a 30-minute in-memory poll
(cold Radar queries outrun the request timeout), started at boot with a
Radar key, outside the boot window, and filled on demand by a request that
finds it empty; each source keeps its last good answer, partial until both
have answered once. api/data/ holds contributor-editable static config
consumed by handlers — currently dns-resolvers.js, the country-annotated
resolver list behind dns-resolver (gated by
tests/dns-resolvers-data.test.js).
ASN data
/api/asn-profile(server/asn-profile.js) runs each ASN source's core under a deadline above the upstream timeouts it wraps (INNER_TIMEOUTS); a failed or late one is just absent. Always 200{ asn, status, incomplete, …sections }(ok/empty/error/disabled; an upstream fault iserror, neverempty;incomplete=okbut partial); 502 if all fail.- Relationship counts follow the graph:
asn-connectivityneighbour lists come from local CAIDA (server/as-relationships.js), dropping the providers the graph walk distrusts. The Radarasnview (ASN Info) carries no prefix or relationship counts — the ASN Profile has both. /api/bulk-lookup(POST, ≤ 200 IPs, own 16 KB body limit) takes only a clean list of distinct public IPs — the page extracts, dedupes and splits a larger run into requests of that size (common/bulk-lookup-limits.js); one bad or repeated entry rejects the request, nothing is cleaned server-side. Local data only: the source is aserver/bulk-lookup-sources.jsrow picked byBULK_LOOKUP_SOURCE, never a metered upstream./api/whois?q=: IPs (RDAP, whoiser fallback), domains (whoiser, RDAP fallback),AS<n>(RDAP autnum); all carry a__rawblock.- Offline datasets (MaxMind, CAIDA, PeeringDB, IEEE MAC registries) are rows in
server/datasets/datasets.js, run by one engine,server/datasets/dataset-updater.js(lock, atomic state, publish, boot download, daily schedule). A row holds only its own logic — find the newest remote version, fetch / distill it, validate, reload; a new dataset is a new row, never a new updater. Fetched at boot when its reader has nothing usable (missing, or a file it refuses) or a publish was cut short; refreshed daily atDATASET_UPDATE_CRON(default 04:30 local) unlessDATASET_AUTO_UPDATE=false(a row's pre-engine flag, e.g.CAIDA_AUTO_UPDATE, still applies when that is unset), with a catch-up run at boot for a check a downtime skipped. Files changed on disk by anyone else (another process, a hand-placed.mmdb) are reloaded by the engine's watcher. MaxMind only with its credentials (City + ASN publish together); PeeringDB only with a Cloudflare key; its 116 MB dump is stream-distilled (never parsed whole) to a ~6 MB per-ASN index without contact data (server/datasets/peeringdb-distill.js). The IEEE row (MAC Lookup) needs no key: five registry CSVs published as downloaded, answered by the longest covering assignment (server/datasets/oui-db.js).
Conventions
- Handler shape. Single default export
async (req, res) => …: readreq.query/req.body, call upstream, write one response. - Every upstream call uses
fetchUpstream(common/fetch-with-timeout.js, 8s timeout — Radar's client raises it to 12 s,RADAR_TIMEOUT_MS; its own timeout is anAbortErrorreadingTimed out after <ms>ms: <host>, host only — keys ride paths and queries), never a barefetch()/https.get()— a hanging provider must time out. It injects a defaultUser-AgentMyIP/v<version>/<VITE_SITE_URL>(server/upstream-ua.js; some WAFs block undici'snodeUA); caller-supplied ones, incl. the private-API{ ...req.headers }pass-through, win. - Error shape.
res.status(500).json({ error: error.message })on upstream failure,400on bad input. Terse — the frontend doesn't show them verbatim. - Response shape. IP-geolocation handlers normalize to the canonical
frontend shape (
ip/country_code/latitude/asn/org/ …); new sources match it.timezoneis the exception (see "Response enrichment"). ?langis never validated in a handler. Pass the raw tag through:lookupMaxMindnormalizes it onto the languages its data carries (SUPPORTED_LANGSinserver/datasets/maxmind-service.js); the private-API proxies forward it for the upstream's own resolution. No allow-lists;/api/map'slanguageonly gets a tag-shape check, as each value is a year-cached Static Maps call.- Logging. Shared logger only,
logger.error({ err, ...ctx }, 'msg'); noconsole.*, no "received request" lines (pino-httpcovers those). - Error monitoring (Sentry) is env-gated and invisible to handlers.
sentry-instrument.js(vianode --import, before express, for ESM auto-instrumentation) inits;backend-server.jsaddssetupExpressErrorHandlerafter the routes. NoSENTRY_DSN_BACKEND→ never loaded;SENTRY_ENVIRONMENT=developmentskips the init. Handlers never import Sentry: throws and 5xx traces are automatic; caught failures stay on the logger (its hook mirrors warn+ to Sentry Logs, error+ to Issues). Periodic jobs wrap their tick inserver/sentry-cron.js;key/tokenquery params are redacted (server/sentry-scrub.js).
Security & Boundaries
Guards live in middleware, not handlers
server/guards.js, attached in backend-server.js; handlers never repeat them:
requireReferer— global on/api/*(ALLOWED_DOMAINS + localhost). For self-hosters it logs the allowlist (and entries that can never match) at boot, and the first rejection per Referer host at info — below the Sentry mirror, capped at 50 hosts.requirePublicIP()— per-route?ip=: well-formed and publicly routable; reserved space (RFC 1918, loopback, CGNAT, link-local, documentation, …) never reaches a geo source.isUsablePublicIP(common/valid-ip.js) is the one definition, shared with the front-end IP forms.requireValidDomain()—?domain=, lowercased in place (one cache key); a leading_is allowed below the TLD (RFC 8552_dmarc.…).requireValidPrefix()—?prefix=(CIDR); the frontend quantizes to the BGP DFZ floor (/24 v4, /48 v6) for edge-cache reuse.requireValidASN()—?asn=in AS1…AS4294967295, rewritten as bare digits.normalizeAsnQuery()—/api/whois?q=:AS<n>(prefix required) is canonicalized; anything else passes through. Both usecommon/asn-input.js.requireValidCountry()—?country=(alpha-2), uppercased, syntactic only.requireValidProviderId()—?id=∈ service-status slugs.requireValidRecordType()— whitelists and uppercases?type=againstDNS_RECORD_TYPES(common/dns-record-types.js), the list the DnsResolver picker andresolveDnsalso read; otherwise the DoH branch would forward any string to four third-party endpoints.requireValidReportId()—/api/report/:idroute param (22-char base64url).
New param shape → new guard there, attached in backend-server.js.
Rewrites go through setQueryParam() there: Express 5's req.query re-parses
on every read, so a direct write is lost.
Response enrichment lives in middleware too
withTimeZone() (server/ip-timezone.js), on all seven geo routes, derives
timezone (IANA name) from the latitude / longitude the handler returned
(2xx only, the cacheable hook); no handler computes or forwards one. Own
coordinates keep the zone consistent with the city beside it; only the name
ships — the frontend renders the offset, as a 24h-cached offset breaks at every
DST switch. A new geo source adds the middleware.
Offline data gates boot, not the listener
The backend listens before any missing offline dataset or the service-status
snapshot is fetched; snapshots already on disk load first, so only a first
boot waits on downloads. During that window requireOfflineData([...probes])
(server/offline-data.js) answers 503 on a route whose data is still missing
— never edge-cached — and is a no-op after boot, when a missing dataset means
a failed download served degraded. A route reading local data declares its
probes (isMaxMindReady, isAsRelLoaded, …) in backend-server.js. Answers
assembled from parts don't gate: an ASN Profile section whose data is still
loading (isStillLoading) answers error, leaving the others to answer.
The Radar outage poll stays out of that window (it would stretch the ASN
Profile's no-degraded-caching span); a request that finds it empty fills it
on demand.
Private-API header pass-through (intentional exception)
Handlers proxying our private IPCheck.ing API (ipcheck-ing,
invisibility-test, update-user-achievement, get-user-info,
dns-leak-test, persona, ip-blocklist, and asn-profile's reputation via
server/asn-reputation.js) forward the caller's headers upstream (headers: { ...req.headers }) — the upstream needs caller context (Accept-Language, auth
tokens). persona strips the framing headers (host / content-length / …)
first, as it re-serializes the body. Do not replicate for third-party
upstreams; those get only what's explicitly needed.
Firebase Auth handler proxy
frontend-server.js (production) and vite.config.js (dev) reverse-proxy
/__/auth/* to <projectId>.firebaseapp.com (common/firebase-auth-proxy.js)
when VITE_FIREBASE_PROJECT_ID is set, so VITE_FIREBASE_AUTH_DOMAIN can be
the deployment's own host and the sign-in handler runs first-party — inside
the installed PWA's webview and Safari's storage partition, where
getRedirectResult finds the result. Every OAuth provider must accept
https://<host>/__/auth/handler per environment. Not an /api route: no
guards, caching or per-request logs.
Edge caching
Every /api/* response defaults to Cache-Control: no-store; slowly-changing
public routes opt in via cacheable(maxAge) (server/cache-control.js) in
backend-server.js, e.g. cacheable(24 * 60 * 60) (TTLs written that way, not
raw seconds). maxAge may be a (req) => seconds resolver (cfradar: its view's
TTL; falsy = no-store). { cacheIf } vetoes the full TTL for a 2xx body,
{ degradedMaxAge } gives a vetoed one a shorter TTL instead of no-store:
/api/asn-profile caches a complete answer (no section error / incomplete;
reputation is not per-user) for 7 days and a degraded one for 1 day — some
upstreams fail for some ASes every time — except inside the boot window
(isOfflineBootstrapping); cfradar caches no partial view answer
(isCompleteRadarAnswer). The ASN family (profile, Radar asn /
bgp-prefixes) shares 7 days. Only status < 400 is cached; handlers
never touch Cache-Control. Auth'd / per-user endpoints must not be
wrapped — their caching belongs to the upstream owning the auth context.
Testing
- Handlers get smoke tests in
tests/api-handlers.test.js(method gating, param branches, "API key missing" early returns); a new or touched handler ships its block in the same change. Areq.method !== 'GET'branch the route already gates stays when a test asserts on it. - Never hit real upstreams: assert before the first
fetchUpstream, or stubglobalThis.fetch(restored in the sharedafterEach). - Middleware is covered by
tests/guards.test.js(don't duplicate per handler); fetch timeout / abort bytests/fetch-with-timeout.test.js.