The best IP Toolbox. Check your IP address & geolocation, test IP for WebRTC and DNS IP leaks, run an IP quality check, browser fingerprint check, website availability check, network speed test, global latency test, MTR test, Whois search, and more.
# AGENTS.md
Single source of truth for anyone β human or AI β contributing to MyIP.
Area-specific details: @frontend/AGENTS.md (Vue SPA) Β· @api/AGENTS.md (Express API).
## Overview
**MyIP** (IPCheck.ing) is an open-source IP toolbox: IP lookup, connectivity
tests, WebRTC / DNS-leak detection, speed test, MTR, Whois, security
checklist, browser fingerprint, anonymity checks, and more. Single repo, two
halves: a Vue 3 SPA front-end and an Express 5 back-end API.
## Stack
| Layer | Technology |
|---|---|
| Frontend | Vue 3 (`<script setup>`) Β· Pinia Β· vue-router (HTML5 history) Β· vue-i18n (`en`/`zh`/`fr`/`ru`) |
| Build | Vite + `@vitejs/plugin-vue`; Tailwind CSS v4 + `tw-animate-css` |
| UI | shadcn-vue copy-in primitives (reka-ui) Β· lucide icons Β· circle-flags via `@iconify/vue` Β· vaul-vue drawer Β· vue-sonner toast |
| Backend | Express 5 |
| Logger | `pino` singleton at `common/logger.js` (+ `pino-http`, opt-in) |
| Auth | Firebase Auth (optional, env-gated) |
| Error monitoring | Sentry β optional & env-gated on both halves: `@sentry/vue` (no `VITE_SENTRY_DSN_FRONTEND`, no Sentry in the build β see frontend/AGENTS.md) + `@sentry/node` (no `SENTRY_DSN_BACKEND`, never loaded β see api/AGENTS.md) |
| PWA | `manifest.webmanifest` only β installable but online-only, no service worker |
| Tests | Node built-in test runner (`node --test`) |
| Runtime libs | chart.js Β· chartjs-chart-geo Β· @cloudflare/speedtest Β· maxmind Β· whoiser Β· thumbmarkjs Β· ua-parser-js Β· detect-gpu Β· @vueuse/core |
## Commands
| Command | What it does |
|---|---|
| `pnpm dev` | Vite + backend (nodemon) together |
| `pnpm build` | Front-end production build |
| `pnpm preview` | Vite preview of the build output |
| `pnpm start` | Built front-end + backend |
| `pnpm test` | Run all `tests/*.test.js` specs |
| `pnpm check` | `test` + `build` β the pre-commit self-check |
**pnpm only** (pinned via `packageManager`); `pnpm-lock.yaml` is committed and
`pnpm-workspace.yaml` holds the `allowBuilds` install-script approvals. Never
use npm / yarn β they'd produce a competing lockfile.
## Project layout
```
.
βββ AGENTS.md / CLAUDE.md β this file + Claude pointer to it
βββ frontend/ β Vue 3 SPA (see frontend/AGENTS.md)
βββ api/ β Express handlers (see api/AGENTS.md)
βββ common/ β code shared by both halves (valid-ip /
β fetch-with-timeout / guards / logger / β¦)
βββ tests/ β Node test runner specs
βββ backend-server.js β Express app (default port 11966)
βββ sentry-instrument.js β backend Sentry bootstrap via `node --import`;
β no-op without SENTRY_DSN_BACKEND
βββ frontend-server.js β static server for `pnpm start` (+ SPA fallback)
βββ ecosystem.config.cjs β pm2 definitions (carries the `--import` flag)
βββ index.html β Vite entry
βββ vite.config.js / jsconfig.json (alias @ β frontend/) / package.json
```
## Conventions
### Language
- **JavaScript only.** New files are `.js` / `.vue`; no `lang="ts"`, no
TypeScript migration.
- **English by default** for code comments, commit messages, and AGENTS.md.
Locale packs obviously carry their own language; planning docs are free.
### Functions
- **New functions use `const` arrow syntax** (`const fn = async () => {}`),
not `function` declarations. Object methods keep shorthand. Arrow consts
aren't hoisted β declare before use. Applies to new / rewritten code only;
don't mass-convert existing declarations.
### Comments
- **Every new file opens with a header comment** stating its purpose.
- **Large templates / functions carry block comments** per meaningful region.
- **Comments describe the code as it is now** β no changelog narration
(`previouslyβ¦`, `β¦fixes that`); git history covers the past. A comment
stays shorter than the code it explains.
### i18n coverage
- Copy-surfacing features land in **all four locales** in the same change β
including `frontend/data/changelog.json` entries
(`tests/changelog.test.js` enforces it).
### Logging (backend)
- **Always the shared logger** (`common/logger.js`) in backend files; bare
`console.*` is banned there (frontend keeps using `console.*`).
- Pino first-arg-is-context: `logger.error({ err, ip }, 'short message')`.
- Env knobs: `LOG_LEVEL` (default info), `LOG_FORMAT=json` for shippers,
`LOG_HTTP=true` to mount `pino-http` on `/api` (off by default; handlers
never log "received request" lines themselves). No `NODE_ENV` anywhere.
- Startup-only lines lead with an emoji (π listening Β· π¦ ready Β·
π₯ downloading Β· π‘οΈ security Β· π’ throttling Β· ποΈ schedule Β· β οΈ recoverable
Β· β failure); per-request logs stay plain.
## Testing
- Any non-visual logic exercisable without a network call β pure functions,
composables with mockable inputs, transforms, validators β ships with a
spec in `tests/`, in the same change (don't defer; update affected tests
when behavior shifts).
- UI rendering, real network behavior, and browser APIs are out of scope.
- **`pnpm check` must be green before handing off.**
## Security & Boundaries
Access control and timeouts live in shared middleware, not handlers
(details in @api/AGENTS.md):
- `requireReferer` is global on `/api/*`; `requirePublicIP()` per-route β
handlers never repeat these checks.
- Every upstream HTTP call goes through `fetchUpstream`
(`common/fetch-with-timeout.js`, 8s timeout). Never a bare `fetch()` in `api/`.
## Workflow
- **Branch discipline β `dev` in, `dev` out.** `main` only moves via
dev β main PRs. From a worktree, fast-forward dev with `git push . HEAD:dev`
(repo has `receive.denyCurrentBranch=updateInstead`), not `git update-ref`.
- **No commits without explicit user approval** β AI edits β user reviews β
user tests β user says "commit". Even with tests green, visual changes need
user eyes before landing.
- **One concern per commit**, message style per `git log`
(`Feat(xxx):` / `Fix(ui):` / `Refactor(xxx):` / `Style:` / `Chore:`),
AI adds itself as co-author.
- **Self-test before handing off** (`pnpm check`); if a change is visual and
headless-unverifiable, say so explicitly.
- **On every commit, scan AGENTS.md (root + relevant sub-file) for
staleness** β conventions, renames, flipped rules, dead examples get fixed
in the same commit. Doc drift is this file's main failure mode.
---
If [local-context.md](./local-context.md) exists in the workspace root, read
it too β it lists machine-local Knowledge Hub paths (not in git).
CLAUDE.md
# CLAUDE.md
Authoritative project instructions: @AGENTS.md
Claude-specific additions below; on conflict, AGENTS.md wins.
## Claude-specific
None at the moment. This file exists so Claude Code auto-loads AGENTS.md at
session start; additional Claude-only guidance goes here if it ever diverges
from what all contributors should follow.
frontend/AGENTS.md
# frontend/AGENTS.md
Conventions specific to the Vue 3 SPA under `frontend/`. Universal rules
(language, i18n, commits, testing) live in ../AGENTS.md.
## Overview
Vue 3 `<script setup>` + Pinia + vue-router (HTML5 history) + Tailwind CSS v4
over shadcn-vue primitives (copied in, not a package). No TypeScript, no
`dark:` dual-pair utilities.
## Layout
```
frontend/
βββ App.vue β thin shell: global providers + <router-view>
βββ main.js β bootstrap + env-gated dynamic init (Sentry, Firebase)
βββ store.js β Pinia main store
βββ firebase-init.js β env-gated lazy Firebase Auth; boot path picked by the
β utils/auth-hint.js flag (signed-in β gate mount on
β auth; visitor β SDK never loads until sign-in)
βββ sentry-init.js β env-gated Sentry (see "Error monitoring" below)
βββ router/ β `/` Home Β· `/tools/:slug` StandaloneTool Β· `/privacy`
β Β· `/r/:id` shared report (noindex)
β (advanced tools also open in-page via `?tool=<slug>`)
βββ locales/ β en / zh / fr / ru + on-demand sub-packs
βββ style/style.css β Tailwind v4 entry + design tokens
βββ lib/ β cn() only (shadcn support layer)
βββ data/ β static config: achievements + achievement-rules /
β ip-databases / sections / changelog / tools registry
β (router + cards + drawer all derive from it) /
β pulse-statuses (Earth Online vocabulary: presets +
β date-windowed festival statuses + their celebration
β effect mapping; recipes in utils/pulse-celebration.js)/
β connectivity-import-lists (curated target sets; icons
β are committed 64px PNGs under public/favicons/ β one
β per member, enforced by its data test)
βββ utils/ β framework-agnostic helpers + IO
β (app-events bus / getips/ / valid-ip / analytics / β¦)
βββ composables/ β Vue-aware `useXxx` logic
βββ components/ β Home / StandaloneTool / top-level sections, plus
ip-infos/ Β· advanced-tools/ Β· report/ Β· widgets/ Β·
svgicons/ Β· ui/
```
Directory-level only β every file opens with a header comment stating its
purpose; read those for specifics.
## Conventions
- **Composition API.** `<script setup>` everywhere; no Options API.
- **Path alias.** `@` β `frontend/`.
- **Shared-with-backend helpers live in `common/`**, re-exported through a
thin bridge in `utils/` so consumers keep `@/utils/...` imports (pattern:
`utils/valid-ip.js`, `utils/fetch-with-timeout.js`).
- **Helper placement:** needs Vue reactivity / lifecycle β `composables/`
(`useXxx`); otherwise β `utils/` (never `use-` prefixed). `lib/` stays
shadcn-only. A pure function living next to a composable is exported from
that composable's file, not promoted to its own.
### Achievements are event-driven
Components never touch the achievement system. They emit domain events
unconditionally β `emitAppEvent('speedtest:finished', {β¦})` on the
`utils/app-events.js` bus β and the pipeline downstream handles the rest:
`data/achievement-rules.js` maps events to achievement slugs (single place to
look for "what unlocks X"); `composables/use-achievement-engine.js` (init'd
once in App.vue) owns the signed-in / remote-sync / already-achieved / rate
guards (rules never evaluate until the remote achievements snapshot lands β
`store.userAchievementsSynced`; pre-sync hits are parked and re-checked).
New achievement = entry in `data/achievements.js` + rule + (only if no
suitable event exists) a new domain event. Tests:
`tests/achievement-rules.test.js`, `tests/composable-achievement-engine.test.js`.
The shareable diagnostic report rides the same bus: every "my network" test
emits `<domain>:finished` with its full structured result;
`composables/use-report-collector.js` (init'd once in App.vue) normalizes
payloads through `utils/report-builders.js` into sections whitelisted by
`common/report-schema.js`, and `components/report/` consumes the snapshots
(share dialog + read-only /r/:id page). New reportable test = emit event +
builder + schema entry, in the same change. Changing a test's result
semantics or an upstream field means updating that test's builder whitelist
+ schema enum too β builders fail soft (unknown values silently drop the
field) and test fixtures are frozen, so drift shows up as quietly missing
report fields, not as errors.
### Error monitoring (Sentry) is env-gated and invisible to app code
`sentry-init.js` loads via a build-time-gated dynamic import: no
`VITE_SENTRY_DSN_FRONTEND` β no Sentry code in the bundle at all (same
philosophy as `firebase-init.js`). Two rules:
- **Never import `@sentry/vue` in app code** β a static import would drag the
SDK back into the main bundle. All Sentry config lives in `sentry-init.js`.
- **Explicit signals go through the app-events bus**, like achievements: the
component emits, `sentry-init.js` subscribes. One signal is captured:
`ip-source:exhausted` (an IP card's whole source chain failed). Cards
report only when the `ipinfo:finished` snapshot shows some card resolved a
valid IP of the same version β otherwise "our chain failed" is
indistinguishable from visitor-side conditions (no IPv6 / dead network),
which is routine noise.
Capture surface: uncaught errors; `console.error` (fingerprinted on the first
argument, so a call site that fails several ways names the failure there β
`fetchErrorLabel` in `utils/authenticated-fetch.js` renders the HTTP status,
keeping an edge-blocked 403 out of the same issue as a 5xx; individual
`utils/getips/` source failures are `console.warn` β invisible to Sentry by
design, the per-card exhaustion event above is the health signal);
route-change traces;
error-only Replay, page text deliberately unmasked (the visitor's on-screen
network info IS the debugging context; typed input stays masked; disclosed
in the privacy policy). Third-party script errors (Cloudflare's RUM beacon)
are dropped via `denyUrls`.
Backend 5xx is deliberately NOT captured frontend-side β the backend SDK
reports its own failures. Envelopes ship through the first-party tunnel
`/api/monitoring` (`api/sentry-tunnel.js`) to beat ad blockers; source maps
upload at build, gated on `SENTRY_AUTH_TOKEN`.
## UI system
**shadcn-vue first.** Check `components/ui/` (21 copied-in primitives), then
https://www.shadcn-vue.com/docs/components for something to copy in;
hand-rolled Tailwind only when neither fits. Two local notes: `Spinner` is
project-specific (lucide `Loader2` + `role="status"`); `toggle` /
`toggle-group` deliberately use the `primary` pair for the pressed state β
don't revert that when syncing upstream.
### Design tokens
Top of `style/style.css`; four business-semantic colors on top of shadcn
defaults, each with a paired `-foreground`:
`--info` (waiting / in-progress) Β· `--success` (ok-fast) Β· `--warning`
(ok-slow) Β· `--action` (the "run / trigger" brand color)
Rule: semantic tokens only (`bg-info` / `bg-action` / `bg-muted` /
`text-muted-foreground` / β¦). Never write `dark:` dual pairs β tokens theme
themselves.
Button adds `action` and `success` variants to the shadcn set; Badge adds
`success` and has hover globally disabled (display element β wrap it for
interactivity). FAB colors express semantics, never decoration: `action` =
trigger, `default` = stateless panels, `success` = protective state active,
`secondary` = dock controls; at most two accents visible at once.
### Status tones
Every "business state β color" mapping goes through
`composables/use-status-tone.js` (`wait` / `ok-fast` / `ok-slow` / `fail`),
normally via its `ipFieldTone()` helper. No hand-rolled stateβcolor switches.
### Canonical patterns
Copy from the named exemplar instead of re-inventing:
- **Trigger button** β `variant="action"` + `<Spinner v-if />` + `:disabled`
(QueryIP, MacChecker, Whois, β¦).
- **Input + icon trigger** β flex row, compact icon Button (lucide `Search`),
no text label (QueryIP / Whois / DnsResolver).
- **AutoFill-proof inputs** β every free-form Input carries all six:
`autocomplete="off" autocorrect="off" autocapitalize="off"
spellcheck="false" data-1p-ignore data-lpignore="true"`, and placeholder
copy avoids "address / ε°ε / adresse / adresi" β iOS QuickType keys on the
word itself even with autocomplete off.
- **Status card** β `keyboard-shortcut-card jn-card` markers + hover-lift
transition (Connectivity / WebRTC / IPCard). `jn-card` = shadow / border /
keyboard outline; `keyboard-shortcut-card` = J/K navigation target.
- **Flag** β always `<Icon :icon="'circle-flags:' + code.toLowerCase()" />`.
- **Fit-to-width tokens** β IP / MAC strings render inside `<FitText>`
(`HERO_TIERS` hero rows, `INLINE_TIERS` compact rows; `:max-lines="2"` on
heroes). Never per-component length-threshold helpers (IPCard, QueryIP).
- **Tables vs lists** β real per-column header semantics β `<table>`;
otherwise a bordered `<ul class="rounded-lg border bg-card divide-y">`.
- **Dialog header** β the `<DialogHeader :icon :title />` primitive.
- **Drawer vs Sheet** β the vaul-vue bottom Drawer is reserved for the
Advanced Tools panel; side panels use `Sheet`.
- **Motion** β hover lift `transition-transform duration-300 ease-out
hover:-translate-y-1.5`; loading is `<Spinner />`, never pulse-dot clusters.
## Testing
Composables and utils are the target (`tests/composable-*.test.js` and
friends). Vue rendering / browser APIs are out of scope for the Node runner.
Visual changes can't be self-tested β say so and let the user verify in
`pnpm dev`.
api/AGENTS.md
# api/AGENTS.md
Conventions for Express 5 handlers under `api/` and shared back-end code
under `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/`. `common/`
holds shared back-end code (guards, logger, fetch helper, MaxMind / CAIDA
services, service-status poller), parts of which the frontend also imports
(`valid-ip.js`, `fetch-with-timeout.js`).
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` / `net-outages` / `asn-history` / `asn-connectivity` /
`ooni-blocking` / `globalping-probes` / `service-status` / `google-map` /
`github-stars` / `invisibility-test` / `dns-leak-test`), 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.
## Conventions
- **Handler shape.** Single default export `async (req, res) => β¦`: read
`req.query` / `req.body`, call upstream, write one response.
- **Every upstream call uses `fetchUpstream`** from
`common/fetch-with-timeout.js` (8s timeout). Never a bare `fetch()` /
`https.get()` β a hanging provider must time out, not pin the connection.
It also injects a default `User-Agent` of `MyIP/v<version>/<VITE_SITE_URL>`
(registered at boot by `common/upstream-ua.js` β some upstream WAFs block
undici's default `node` UA); caller-supplied `User-Agent` headers, including
the private-API `{ ...req.headers }` pass-through, always win.
- **Error shape.** `res.status(500).json({ error: error.message })` on
upstream failure, `400` on bad input. Terse β the frontend doesn't display
these 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 β no handler produces it;
see "Response enrichment" below.
- **Logging.** Shared logger only, `logger.error({ err, ...ctx }, 'msg')`;
no `console.*`, no "received request" lines (`pino-http` covers those when
enabled).
- **Error monitoring (Sentry) is env-gated and invisible to handlers.**
Root-level `sentry-instrument.js` (loaded via `node --import` *before*
express, so ESM loader hooks can auto-instrument route tracing) does the
init; `backend-server.js` attaches `setupExpressErrorHandler` after the
routes. No `SENTRY_DSN_BACKEND` β `@sentry/node` never loads. Handlers
never import Sentry or capture manually: uncaught throws and 5xx traces
are automatic; caught failures stay on the logger β a hook in
`common/logger.js` mirrors warn+ to Sentry Logs and elevates error+ to
grouped, alertable Issues. Periodic jobs wrap their tick in
`common/sentry-cron.js` for Crons check-ins. API-key query params
(`key` / `token` / β¦) are redacted from telemetry URLs
(`common/sentry-scrub.js`, wired as `beforeBreadcrumb` / `beforeSendSpan`
/ `beforeSend` hooks).
## Security & Boundaries
### Guards live in middleware, not handlers
`common/guards.js`, attached in `backend-server.js` β handlers never repeat
these checks:
- `requireReferer` β global on `/api/*` (ALLOWED_DOMAINS + localhost).
- `requirePublicIP()` β per-route for `?ip=`; handler sees a well-formed,
publicly routable IP. Reserved space (RFC 1918, loopback, CGNAT, link-local,
documentation, β¦) is rejected here, so no geo source is ever asked about an
address it can't answer for β `isUsablePublicIP` in `common/valid-ip.js` is
the single definition, shared with the front-end IP forms.
- `requireValidDomain()` β `?domain=`, lowercases in place so the edge cache
sees one canonical key.
- `requireValidPrefix()` β `?prefix=` (CIDR); lets the frontend quantize to
the BGP DFZ floor (/24 v4, /48 v6) for maximal CF edge-cache reuse.
- `requireValidASN()` β `?asn=`, strips `AS`, rewrites to numeric
(`cf-radar` predates it and still validates inline).
- `requireValidProviderId()` β whitelists `?id=` against service-status slugs.
- `requireValidReportId()` β `/api/report/:id` route param (22-char base64url).
New param shape β new guard in `common/guards.js`, attached in
`backend-server.js`; never open-coded in the handler.
### Response enrichment lives in middleware too
`withTimeZone()` (`common/ip-timezone.js`), attached to all seven geo routes,
derives `timezone` (IANA name) from the `latitude` / `longitude` the handler
just returned and adds it on the way out β 2xx only, same res.json hook and
same rule as `cacheable`. No handler computes or forwards a timezone, not even
the private-API pass-throughs.
Deriving it from the response's own coordinates is what keeps the zone from
contradicting the city beside it; a second database asked about the same IP
would eventually disagree. Only the zone name ships β the frontend renders the
UTC offset from it, because these routes sit behind a 24h edge cache and a
cached offset goes an hour wrong at every DST switch.
A new geo source inherits the field by adding the middleware to its route.
### 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`) forward the caller's headers upstream
(`headers: { ...req.headers }`) β the upstream needs caller context
(Accept-Language, auth tokens). Do **not** replicate for third-party
upstreams; those get only what's explicitly needed.
### Defensive method gates
Some handlers keep a `req.method !== 'GET'` branch although the route
already gates the method β smoke tests assert on that branch directly.
Leave the gate in place when a test covers it.
## Edge caching
Every `/api/*` response defaults to `Cache-Control: no-store`; slowly-changing
public routes opt in via the `cacheable(maxAgeSeconds)` middleware in
`backend-server.js` β e.g. `app.get('/api/cfradar', cacheable(60 * 60), β¦)`.
Write TTLs as multiplied expressions (`24 * 60 * 60`), not raw seconds.
The middleware only sets `public, max-age=N` on status < 400, so CF never
caches error pages; handlers themselves never touch `Cache-Control`.
**Auth'd / per-user endpoints must not be wrapped** β their caching belongs
to the upstream that owns the auth context.
## Testing
- Handlers get smoke tests in `tests/api-handlers.test.js`: method gating,
param branches, "API key missing" early returns. Most are covered; a new
or touched handler ships its block in the same change.
- Never hit real upstreams β assert on branches that return before the first
`fetchUpstream`, or stub `globalThis.fetch` when the behavior under test
lives past it (google-map stream tests; restored in the shared `afterEach`).
- Middleware is covered by `tests/guards.test.js`; don't duplicate its
assertions per-handler. Fetch timeout/abort behavior:
`tests/fetch-with-timeout.test.js`.