Files
MyIP/api/AGENTS.md
T
jason5ng32andClaude Opus 5.5 e766bd84ce Fix(api): make guard query rewrites survive Express 5
Express 5 exposes req.query through a getter that re-parses the URL on
every read, so the guards' in-place normalization was silently dropped:
?type=caa reached dns-resolver lowercase, ?asn=AS13335 reached the ASN
handlers unparsed, ?q=as13335 missed whois's AS<n> branch. Guards now pin
the parsed query as an own property before rewriting it, and a regression
block exercises them on a real Express request.

Fixes #521

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:24:56 +08:00

12 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 from server/. 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), 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 is error, never empty; incomplete = ok but partial); 502 if all fail.
  • Relationship counts follow the graph: asn-connectivity neighbour lists come from local CAIDA (server/as-relationships.js), dropping the providers the graph walk distrusts. The Radar asn view (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 __raw block.
  • 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 at DATASET_UPDATE_CRON (default 04:30 local) unless DATASET_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) => …: read req.query / req.body, call upstream, write one response.
  • Every upstream call uses fetchUpstream (common/fetch-with-timeout.js, 8s timeout), never a bare fetch() / https.get() — a hanging provider must time out. It injects a default User-Agent MyIP/v<version>/<VITE_SITE_URL> (server/upstream-ua.js; some WAFs block undici's node UA); caller-supplied ones, incl. the private-API { ...req.headers } pass-through, win.
  • Error shape. res.status(500).json({ error: error.message }) on upstream failure, 400 on 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. timezone is the exception (see "Response enrichment").
  • ?lang is never validated in a handler. Pass the raw tag through: lookupMaxMind normalizes it onto the languages its data carries (SUPPORTED_LANGS in server/datasets/maxmind-service.js); the private-API proxies forward it for the upstream's own resolution. No allow-lists; /api/map's language only gets a tag-shape check, as each value is a year-cached Static Maps call.
  • Logging. Shared logger only, logger.error({ err, ...ctx }, 'msg'); no console.*, no "received request" lines (pino-http covers those).
  • Error monitoring (Sentry) is env-gated and invisible to handlers. sentry-instrument.js (via node --import, before express, for ESM auto-instrumentation) inits; backend-server.js adds setupExpressErrorHandler after the routes. No SENTRY_DSN_BACKEND → never loaded; SENTRY_ENVIRONMENT=development skips 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 in server/sentry-cron.js; key / token query 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 use common/asn-input.js.
  • requireValidCountry() — ?country= (alpha-2), uppercased, syntactic only. requireValidProviderId() — ?id= ∈ service-status slugs.
  • requireValidRecordType() — whitelists and uppercases ?type= against DNS_RECORD_TYPES (common/dns-record-types.js), the list the DnsResolver picker and resolveDns also read; otherwise the DoH branch would forward any string to four third-party endpoints.
  • requireValidReportId() — /api/report/:id route 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.

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. A req.method !== 'GET' branch the route already gates stays when a test asserts on it.
  • Never hit real upstreams: assert before the first fetchUpstream, or stub globalThis.fetch (restored in the shared afterEach).
  • Middleware is covered by tests/guards.test.js (don't duplicate per handler); fetch timeout / abort by tests/fetch-with-timeout.test.js.