Files
MyIP/api/AGENTS.md
T
jason5ng32andClaude Opus 5.5 c29e48687b Refactor(layout): split backend-only code out of common/, group frontend utils
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>
2026-10-05 13:10:02 +08:00

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 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), 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.
  • 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.

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. 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.