> For the complete documentation index, see [llms.txt](https://docs.ipcheck.ing/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ipcheck.ing/developer/architecture/backend.md).

# Backend

The backend is one Express 5 app. `backend-server.js` at the repository root is the only file that wires routes; every route delegates to exactly one handler module under `api/`. Shared back-end code lives in `common/`.

The rule of thumb: **`backend-server.js` decides who may call a route and how long the answer may be cached; the handler only fetches and shapes data.**

## Middleware chain, in order

```mermaid
flowchart TD
    R["Incoming request"] --> P["pino-http on /api  (only when LOG_HTTP=true)"]
    P --> RL["rate limiter  (only when SECURITY_RATE_LIMIT is set)"]
    RL --> SD["slow-down  (only when SECURITY_DELAY_AFTER is set)"]
    SD --> J["express.json, 500kb limit"]
    J --> NS["Cache-Control: no-store on every /api response"]
    NS --> CA["cacheable(maxAge) — only on routes that opt in"]
    CA --> RF["requireReferer — global on /api/*"]
    RF --> G["Per-route param guard(s)"]
    G --> H["Handler in api/"]
```

Every step is worth knowing:

1. **`pino-http`** mounts on `/api` only when `LOG_HTTP=true`. It sits before the rate limiter so 429s are logged too. Handlers never write "received request" lines themselves. See [Logging](/developer/configuration/logging.md).
2. **Rate limiter** (`express-rate-limit`): a 20-minute window with `SECURITY_RATE_LIMIT` as the cap; disabled when the value is `0` or unset. On the exact transition into the limited state it writes one `logger.warn({ ip }, 'IP rate-limited')` line — not one per blocked request — and optionally appends to an on-disk ledger when `SECURITY_BLACKLIST_LOG_FILE_PATH` is set. The client IP is read from `cf-connecting-ip`, then the first `x-forwarded-for` entry, then `cf-connecting-ipv6`, then `req.ip` (`trust proxy` is `1`).
3. **Slow-down** (`express-slow-down`): a 1-hour window that adds `hits × 400ms` of delay after `SECURITY_DELAY_AFTER` requests; disabled when unset. Both limiters skip `/monitoring`, which has its own limiter — a throttled telemetry tunnel silently kills error reporting.
4. **`express.json({ limit: '500kb' })`** — raised from the 100kb default because shared diagnostic reports legitimately reach \~100KB. It must stay above `REPORT_MAX_BYTES` in `common/report-schema.js`, otherwise report uploads die here with a raw 413 before the handler's own size check.
5. **`no-store` default** on every `/api/*` response.
6. **`cacheable(maxAge)`** where a route opted in — see [Edge caching](#edge-caching).
7. **`requireReferer`**, global on `/api/*`.
8. **Per-route param guards** from `common/guards.js`.
9. **The handler.**

Security-related environment variables are documented in [Security Options](/developer/configuration/security-options.md) and [Environment Variables](/developer/reference/environment-variables.md).

## Guards

Access control and parameter validation live in middleware, never inside a handler. All of it is in `common/guards.js` and attached in `backend-server.js`, so a handler can assume its inputs are already well-formed.

| Guard                      | Checks                                                                                                          | On failure                                                                              |
| -------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `requireReferer`           | The `Referer` hostname is `localhost` or in `ALLOWED_DOMAINS`. Unparseable referer counts as denied.            | `403` `{ error: 'Access denied' }`, or `'What are you doing?'` when no referer was sent |
| `requireValidIP()`         | `?ip=` is present and a valid IPv4/IPv6 address                                                                 | `400` `No IP address provided` / `Invalid IP address`                                   |
| `requireValidDomain()`     | `?domain=` is a syntactically valid domain. **Lowercases it in place** so the edge cache sees one canonical key | `400` `No domain provided` / `Invalid domain`                                           |
| `requireValidPrefix()`     | `?prefix=` is a well-formed CIDR. Quantization policy stays the frontend's job                                  | `400` `No prefix provided` / `Invalid prefix`                                           |
| `requireValidASN()`        | `?asn=` is numeric, optionally `AS`-prefixed. **Rewrites it to the numeric form**                               | `400` `No ASN provided` / `Invalid ASN`                                                 |
| `requireValidProviderId()` | `?id=` is a known service-status provider slug                                                                  | `400` `No provider id provided` / `Invalid provider id`                                 |
| `requireValidReportId()`   | The `:id` **route param** matches 22 base64url chars (16 random bytes)                                          | `400` `Invalid report id`                                                               |

A new parameter shape means a new guard in `common/guards.js` wired in `backend-server.js` — not an inline check in the handler. (`cf-radar` predates `requireValidASN()` and still validates its ASN inline.) Guards are covered by `tests/guards.test.js`.

## Handler shape

Each file in `api/` has a single default export, `async (req, res) => …`, that reads `req.query` or `req.body`, calls upstream, and writes exactly one response. Each file opens with a header comment naming its route and purpose.

The error shape is deliberately terse — the frontend does not display these strings verbatim:

```js
res.status(500).json({ error: error.message });  // upstream failure
res.status(400).json({ error: 'Invalid …' });    // bad input (usually a guard)
```

Some handlers keep a defensive `req.method !== 'GET'` branch returning `405` even though the route already gates the method, because smoke tests assert on that branch directly.

The five IP-geolocation source handlers share an even thinner shape: they are built by the `makeGeoHandler({ name, buildUrl, normalize })` factory in `common/geo-handler.js`, which owns the fetch, the non-2xx check, the normalization call, and the uniform log-and-500 catch. See [IP Data Sources](/developer/architecture/ip-data-sources.md).

## Upstream calls

Every outbound HTTP call from `api/` goes through `fetchUpstream` from `common/fetch-with-timeout.js`. Never a bare `fetch()` or `https.get()` — a hanging provider must time out rather than pin the connection.

* **8-second timeout** by default (the browser-side sibling, `fetchWithTimeout`, defaults to 5s). Both accept a `timeoutMs` override and chain a caller-supplied `signal`. Timeouts surface as `AbortError`.
* **A project User-Agent** of `MyIP/v<version>/<VITE_SITE_URL>`, registered at boot by `common/upstream-ua.js`. Some upstream WAFs hard-block undici's default `User-Agent: node`. Forks advertise their own `VITE_SITE_URL`.
* **Caller-supplied `User-Agent` headers always win**, including the `{ ...req.headers }` pass-through described below.

{% hint style="info" %}
**Private-API header pass-through.** The handlers that proxy the private IPCheck.ing API — `ipcheck-ing`, `invisibility-test`, `update-user-achievement`, `get-user-info`, `dns-leak-test` — forward the caller's headers upstream because that API needs caller context (`Accept-Language`, auth tokens). This is an intentional exception. Third-party upstreams get only what they explicitly need.
{% endhint %}

## Edge caching

Every `/api/*` response starts as `Cache-Control: no-store`. Slowly-changing public routes opt in through the `cacheable(maxAgeSeconds)` middleware factory defined in `backend-server.js`:

```js
const cacheable = (maxAgeSeconds) => (req, res, next) => {
    res.locals.cacheControl = `public, max-age=${maxAgeSeconds}`;
    const originalJson = res.json.bind(res);
    res.json = function (body) {
        if (res.statusCode < 400) {
            res.setHeader('Cache-Control', res.locals.cacheControl);
        }
        return originalJson(body);
    };
    next();
};
```

Two consequences matter. It hooks `res.json`, so the header only lands on responses with a status below 400 — a CDN never caches an error page. And it stashes the intended value on `res.locals.cacheControl`, so handlers that stream binary data (bypassing `res.json`) can apply it themselves on their own 2xx path. Handlers otherwise never touch `Cache-Control`.

TTL tiers currently in use:

| TTL       | Routes                                                                                                                                                   | Why                                                                                                                   |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| 5 minutes | `/api/service-status`, `/api/service-status/detail`                                                                                                      | Matches the background poller's own 5-minute refresh                                                                  |
| 1 hour    | `/api/configs`                                                                                                                                           | Feature flags derived from env vars; they change on redeploy                                                          |
| 1 day     | `/api/ipinfo`, `/api/ipapicom`, `/api/ipsb`, `/api/ipapiis`, `/api/ip2location`, `/api/maxmind`, `/api/whois`, `/api/github-stars`, `/api/ooni-blocking` | Geolocation and registry data barely moves within a day; also keeps free upstream quotas polite                       |
| 7 days    | `/api/globalping-probes`                                                                                                                                 | Probe-country coverage changes slowly and the pickers fail open                                                       |
| 30 days   | `/api/cfradar`, `/api/asn-history`, `/api/asn-connectivity`, `/api/macchecker`                                                                           | Registry and historical data: IEEE OUI assignments, ASN metadata and interconnection, append-only BGP routing history |
| 1 year    | `/api/map`                                                                                                                                               | A static map tile for a quantized coordinate                                                                          |

TTLs are written as multiplied expressions (`24 * 60 * 60`), not raw seconds.

Everything else stays `no-store`: `/api/ipchecking`, `/api/dnsresolver`, `/api/dnsleaktest/session/:token`, `/api/invisibility`, `/api/getuserinfo`, `PUT /api/updateuserachievement`, and both `/api/report` endpoints.

{% hint style="warning" %}
Never wrap an authenticated or per-user endpoint in `cacheable()`. Its caching belongs to the upstream that owns the auth context. Shared reports stay `no-store` for a second reason: an edge cache could serve a report past its KV expiry, and private diagnostic data does not belong in a public cache.
{% endhint %}

## Route inventory at a glance

The full contract is in [API Endpoints](/developer/reference/api-endpoints.md); this is the wiring view.

| Route                                 | Guard                      | Cache    | Handler                          |
| ------------------------------------- | -------------------------- | -------- | -------------------------------- |
| `GET /api/ipinfo`                     | `requireValidIP()`         | 1 day    | `api/ipinfo-io.js`               |
| `GET /api/ipapicom`                   | `requireValidIP()`         | 1 day    | `api/ipapi-com.js`               |
| `GET /api/ipapiis`                    | `requireValidIP()`         | 1 day    | `api/ipapi-is.js`                |
| `GET /api/ip2location`                | `requireValidIP()`         | 1 day    | `api/ip2location-io.js`          |
| `GET /api/ipsb`                       | `requireValidIP()`         | 1 day    | `api/ip-sb.js`                   |
| `GET /api/maxmind`                    | `requireValidIP()`         | 1 day    | `api/maxmind.js`                 |
| `GET /api/ipchecking`                 | `requireValidIP()`         | no-store | `api/ipcheck-ing.js`             |
| `GET /api/whois`                      | —                          | 1 day    | `api/get-whois.js`               |
| `GET /api/macchecker`                 | —                          | 30 days  | `api/mac-checker.js`             |
| `GET /api/dnsresolver`                | —                          | no-store | `api/dns-resolver.js`            |
| `GET /api/cfradar`                    | inline ASN check           | 30 days  | `api/cf-radar.js`                |
| `GET /api/asn-history`                | `requireValidPrefix()`     | 30 days  | `api/asn-history.js`             |
| `GET /api/asn-connectivity`           | `requireValidASN()`        | 30 days  | `api/asn-connectivity.js`        |
| `GET /api/ooni-blocking`              | `requireValidDomain()`     | 1 day    | `api/ooni-blocking.js`           |
| `GET /api/globalping-probes`          | —                          | 7 days   | `api/globalping-probes.js`       |
| `GET /api/service-status`             | —                          | 5 min    | `api/service-status.js`          |
| `GET /api/service-status/detail`      | `requireValidProviderId()` | 5 min    | `api/service-status.js`          |
| `GET /api/map`                        | —                          | 1 year   | `api/google-map.js`              |
| `GET /api/github-stars`               | —                          | 1 day    | `api/github-stars.js`            |
| `GET /api/configs`                    | —                          | 1 hour   | `api/configs.js`                 |
| `GET /api/invisibility`               | —                          | no-store | `api/invisibility-test.js`       |
| `GET /api/dnsleaktest/session/:token` | —                          | no-store | `api/dns-leak-test.js`           |
| `GET /api/getuserinfo`                | —                          | no-store | `api/get-user-info.js`           |
| `PUT /api/updateuserachievement`      | —                          | no-store | `api/update-user-achievement.js` |
| `POST /api/report`                    | —                          | no-store | `api/share-report.js`            |
| `GET /api/report/:id`                 | `requireValidReportId()`   | no-store | `api/share-report.js`            |
| `POST /api/monitoring`                | own rate limiter           | no-store | `api/sentry-tunnel.js`           |

`/api/monitoring` is mounted **only** when `VITE_SENTRY_DSN_FRONTEND` is set. It uses `express.raw({ type: () => true })` — a catch-all function, because Replay envelopes are binary and arrive with no `Content-Type` at all, which a `'*/*'` string matcher would skip.

## Boot sequence

`bootBackend()` prepares every offline dataset **before** the listener opens, so the server never serves a half-downloaded database:

1. `bootstrapMaxMindIfMissing()` then `reloadMaxMindDatabases('startup')`
2. `bootstrapCaidaIfMissing()`
3. `bootstrapServiceStatus()`
4. Start the MaxMind file watcher, the MaxMind and CAIDA auto-updaters, and the service-status poller
5. `app.listen(BACKEND_PORT)`

Each step is non-fatal. A failure leaves the dependent API degraded — MaxMind answers `503`, CAIDA-backed views return an empty graph or fall back to RIPEstat — but never blocks startup. Dataset details are in [IP Data Sources](/developer/architecture/ip-data-sources.md).

## Logging and error monitoring

Backend files always use the shared pino logger from `common/logger.js`; bare `console.*` is not used there. Pino puts context first: `logger.error({ err, ip }, 'short message')`. Startup lines lead with an emoji (🚀 listening, 📦 ready, 📥 downloading, 🛡️ security, 🐢 throttling, 🗓️ schedule, ⚠️ recoverable, ❌ failure); per-request lines stay plain.

Sentry is env-gated and invisible to handlers. `sentry-instrument.js` is loaded via `node --import` **before** Express so the ESM loader hooks can auto-instrument route tracing; `backend-server.js` attaches `setupExpressErrorHandler` after all routes. Without `SENTRY_DSN_BACKEND`, `@sentry/node` is never loaded. Handlers never import Sentry: uncaught throws and 5xx traces are automatic, while caught failures stay on the logger, where a hook mirrors warn-and-above to Sentry Logs. Periodic jobs wrap their tick in `common/sentry-cron.js` for check-ins, and API-key query parameters are redacted from telemetry URLs by `common/sentry-scrub.js`.

## Adding a route

1. Create `api/<name>.js` with a header comment and a single default export.
2. Use `fetchUpstream` for any outbound call.
3. Wire it in `backend-server.js`, in the right cache tier, with the guards it needs.
4. If the parameter shape is new, add a guard to `common/guards.js` first.
5. Add smoke tests to `tests/api-handlers.test.js` — method gating, parameter branches, missing-API-key early returns. Never hit a real upstream; assert only on branches that return before the first `fetchUpstream`. See [Testing](/developer/development/testing.md).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.ipcheck.ing/developer/architecture/backend.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
