common/ had become the backend's library: 30 of its 41 modules were imported by nothing in the frontend, so the name no longer said what belonged there. It now holds only what the frontend or the build imports too; backend-only code moved to server/, flat except for datasets/ (the dataset engine and its readers). The *-db/ dataset directories stay in common/: self-hosting docs and Docker volumes mount those paths. frontend/utils/ keeps its app-wide modules at the root and gains ip/ (IP / ASN shaping and validation), report/ (the report pipeline) and features/ (the pure layer of a single feature). Paths only — no logic changes. The Dockerfile copies server/, and the AGENTS.md files say where a new module goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 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), 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. 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/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), 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.- 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).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.
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.
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, 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.
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.