> 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/development/adding-a-new-tool.md).

# Adding a New Tool

The "Advanced Tools" on the homepage — MAC Lookup, Whois, DNS Resolver, Censorship Check, and the rest — all follow one wiring pattern. This page walks through adding a new one end to end.

## The example

We will add a hypothetical **Certificate Check**: type a domain, get its TLS certificate details back from an upstream API.

| Piece          | Value                                              |
| -------------- | -------------------------------------------------- |
| Slug           | `certcheck`                                        |
| Component      | `frontend/components/advanced-tools/CertCheck.vue` |
| API handler    | `api/cert-check.js`                                |
| Route          | `GET /api/certcheck?domain=…`                      |
| i18n namespace | `certcheck.*`                                      |

It is modeled directly on the **MAC Lookup** tool (`macchecker` → `MacChecker.vue` → `api/mac-checker.js`), which is the smallest complete example in the repo. Open those three files alongside this page.

{% hint style="info" %}
**Not every tool needs a backend.** Browser Info and the Security Checklist run entirely in the browser. If yours does too, skip steps 1–3 and go straight to the component.
{% endhint %}

## Naming

* **Slug** — lowercase, no separators: `macchecker`, `dnsresolver`, `censorshipcheck`. It is the URL at `/tools/<slug>` and the drawer query `?tool=<slug>`, so it is effectively permanent once shipped.
* **Component** — PascalCase `.vue` under `frontend/components/advanced-tools/`.
* **Handler file** — kebab-case `.js` under `api/`.
* **Route path** — matches the slug for older tools (`/api/macchecker`), kebab-case for newer ones (`/api/ooni-blocking`, `/api/service-status`). Either reads fine; pick one and use it consistently.

***

{% stepper %}
{% step %}

### Write the API handler

One file per route under `api/`, opening with a header comment that states the route and its purpose. Single default export, `fetchUpstream` for the upstream call, the shared logger for failures.

{% code title="api/cert-check.js" %}

```js
// /api/certcheck — TLS certificate details for a domain, fetched from the
// upstream certificate API. Powers the frontend CertCheck tool.

import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';

const CERT_API_URL = 'https://example-cert-api.test/v1/cert';

export default async (req, res) => {
    if (req.method !== 'GET') {
        return res.status(405).json({ error: 'Method Not Allowed' });
    }

    // Presence, shape and lowercasing guaranteed by requireValidDomain.
    const domain = req.query.domain;

    const token = process.env.CERT_API_KEY || '';
    if (!token) {
        return res.status(500).json({ error: 'API key missing' });
    }

    try {
        const upstream = await fetchUpstream(`${CERT_API_URL}?host=${domain}&key=${token}`);
        if (!upstream.ok) {
            throw new Error(`Certificate API responded with status ${upstream.status}`);
        }
        res.json(await upstream.json());
    } catch (error) {
        logger.error({ err: error, domain }, 'cert-check handler failed');
        res.status(500).json({ error: error.message });
    }
};
```

{% endcode %}

Four things that are not negotiable:

* **`fetchUpstream`, never a bare `fetch()`.** It carries the 8-second timeout and the project `User-Agent`.
* **The shared logger, never `console.*`.** Context object first, short message second.
* **Terse error shapes.** `400` on bad input, `500` with `{ error: … }` on failure.
* **No referer or parameter checks in the handler.** Middleware already did that — see the next step.

The `req.method !== 'GET'` gate is defensive: the route below already restricts the method. It stays because the smoke test asserts on it directly.
{% endstep %}

{% step %}

### Reuse a guard — or add one

Parameter validation lives in `common/guards.js`, never inside handlers. Our tool takes `?domain=`, and that guard already exists:

```js
requireValidDomain()   // rejects missing/malformed domains, lowercases in place
```

Lowercasing in place matters: the edge cache keys on the URL, so a mixed-case query must not become a separate cache entry.

The full set available today:

| Guard                      | Validates                                        |
| -------------------------- | ------------------------------------------------ |
| `requireReferer`           | Global on `/api/*` — allowed domains + localhost |
| `requireValidIP()`         | `?ip=`                                           |
| `requireValidDomain()`     | `?domain=` (also lowercases)                     |
| `requireValidPrefix()`     | `?prefix=` (CIDR)                                |
| `requireValidASN()`        | `?asn=` (strips `AS`, rewrites to numeric)       |
| `requireValidProviderId()` | `?id=` against the service-status slug list      |
| `requireValidReportId()`   | `/api/report/:id` route param                    |

{% hint style="warning" %}
**A new parameter shape means a new guard.** Add it as an exported factory in `common/guards.js`, attach it in `backend-server.js`, and cover it in `tests/guards.test.js`. Do not open-code the check inside your handler — that is exactly the drift the guard layer exists to prevent.
{% endhint %}
{% endstep %}

{% step %}

### Wire the route in `backend-server.js`

Every route in the app is declared in this one file. Import the handler at the top, next to the others:

{% code title="backend-server.js" %}

```js
import certCheckHandler from './api/cert-check.js';
```

{% endcode %}

Then declare the route. Middleware order is guard first, then cache, then handler:

{% code title="backend-server.js" %}

```js
app.get('/api/certcheck', requireValidDomain(), cacheable(ONE_DAY_CACHE), certCheckHandler);
```

{% endcode %}

Pick the TTL against how fast the upstream data actually changes. The file already defines the constants — `FIVE_MIN_CACHE`, `ONE_HOUR_CACHE`, `ONE_DAY_CACHE`, `SEVEN_DAYS_CACHE`, `THIRTY_DAYS_CACHE`, `ONE_YEAR_CACHE` — all written as multiplied expressions rather than raw seconds.

If the data is per-user, authenticated, or changes every request, **omit `cacheable()` entirely**. Everything under `/api/*` defaults to `Cache-Control: no-store`, so leaving it out is the safe choice.

New environment variable? Add it to `.env.example` with a comment, and document it in [Environment Variables](/developer/reference/environment-variables.md) and [Optional API Keys](/developer/configuration/optional-api-keys.md).
{% endstep %}

{% step %}

### Build the component

Create `frontend/components/advanced-tools/CertCheck.vue`. It is a normal `<script setup>` component — the drawer and the standalone page both mount it as-is, so it needs no wrapper chrome, no title bar, and no route awareness.

Copy the canonical patterns rather than inventing new ones. From `MacChecker.vue`:

{% code title="frontend/components/advanced-tools/CertCheck.vue" %}

```vue
<template>
    <div class="cert-check-section my-4 space-y-4">
        <p class="text-sm text-muted-foreground leading-relaxed">{{ t('certcheck.Note') }}</p>

        <div class="space-y-2">
            <Label for="queryDomain">{{ t('certcheck.Note2') }}</Label>
            <div class="flex items-center gap-2">
                <Input type="text" id="queryDomain" name="queryDomain"
                    autocomplete="off" autocorrect="off" autocapitalize="off"
                    spellcheck="false" data-1p-ignore data-lpignore="true"
                    :disabled="status === 'running'"
                    :placeholder="t('certcheck.Placeholder')"
                    v-model="queryDomain" @keyup.enter="onSubmit" />
                <Button variant="action" :disabled="status === 'running' || !queryDomain"
                    @click="onSubmit" class="cursor-pointer">
                    <Spinner v-if="status === 'running'" />
                    <Search v-else class="size-4 shrink-0" />
                </Button>
            </div>
            <p v-if="errorMsg" class="text-sm text-destructive">{{ errorMsg }}</p>
        </div>

        <!-- Result area -->
        <Card v-if="result.subject">…</Card>
    </div>
</template>

<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { Search } from '@lucide/vue';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Spinner } from '@/components/ui/spinner';
import { Label } from '@/components/ui/label';

const { t } = useI18n();

const queryDomain = ref('');
const status = ref('idle');
const result = ref({});
const errorMsg = ref('');

const onSubmit = () => {
    trackEvent('Section', 'StartClick', 'CertCheck');
    errorMsg.value = '';
    result.value = {};
    if (queryDomain.value) fetchCert(queryDomain.value);
};

const fetchCert = async (domain) => {
    status.value = 'running';
    try {
        const response = await fetch(`/api/certcheck?domain=${domain}`);
        if (!response.ok) throw new Error('Network response was not ok');
        result.value = await response.json();
    } catch (error) {
        console.error('Error fetching certificate:', error);
        errorMsg.value = t('certcheck.fetchError');
    } finally {
        status.value = 'idle';
    }
};
</script>
```

{% endcode %}

Things worth calling out:

* **Trigger button** — `variant="action"` with `<Spinner v-if />` and a `:disabled` guard. That is the project-wide "run this" affordance.
* **AutoFill-proof inputs** — every free-form `Input` carries all six attributes shown above, and the placeholder avoids the word "address" (and its translations) because iOS QuickType keys on the word itself even with `autocomplete="off"`.
* **`console.*` is fine here.** The frontend uses it; only backend files are restricted.
* **No hand-rolled state→color switches.** If your tool maps a business state to a color, go through `composables/use-status-tone.js`.
* **Every string is a `t()` call.** Nothing user-visible is hardcoded.

More canonical patterns — status cards, flags, tables vs lists, dialog headers, motion — are catalogued in [Frontend](/developer/architecture/frontend.md).
{% endstep %}

{% step %}

### Register the tool

`frontend/data/tools.js` is the single source of truth. One entry there gives you the card on the homepage, the bottom drawer, the standalone `/tools/<slug>` page, and the nav menu item — **you do not touch the router**.

{% code title="frontend/data/tools.js" %}

```js
export const ADVANCED_TOOLS = [
  // …existing entries…
  { slug: 'certcheck', emoji: '🔐', titleKey: 'certcheck.Title', noteKey: 'advancedtools.CertCheck', component: () => import('@/components/advanced-tools/CertCheck.vue') },
];
```

{% endcode %}

The entry shape:

<table><thead><tr><th width="230">Field</th><th>Meaning</th></tr></thead><tbody><tr><td><code>slug</code></td><td>Stable URL identifier — <code>/tools/&#x3C;slug></code> and the drawer's <code>?tool=&#x3C;slug></code> query.</td></tr><tr><td><code>emoji</code></td><td>Card glyph and drawer header glyph.</td></tr><tr><td><code>titleKey</code></td><td>i18n key for the tool's title.</td></tr><tr><td><code>noteKey</code></td><td>i18n key for the one-line card description.</td></tr><tr><td><code>component</code></td><td>Lazy import of the <code>.vue</code> file — used by both the drawer and the standalone page.</td></tr><tr><td><code>requiresOriginalSite</code></td><td>Optional. <code>true</code> hides the tool on self-hosted instances. Omit it for a public tool.</td></tr></tbody></table>

Order in the array is the order of the cards on the homepage.

{% hint style="info" %}
**`requiresOriginalSite: true`** is for tools that depend on the private IPCheck.ing API and a signed-in account — they are hidden on forks and self-hosted instances, which have no way to reach that backend. See [Features Tied to IPCheck.ing](/developer/configuration/features-tied-to-ipcheck-ing.md). Most new tools should omit this field.
{% endhint %}
{% endstep %}

{% step %}

### Add the copy — in all four locales

Every string you referenced needs an entry in **`en.json`, `zh.json`, `fr.json`, and `ru.json`** under `frontend/locales/`. All four, in the same change. This is not a follow-up task.

Two places to edit per locale — your tool's own namespace:

{% code title="frontend/locales/en.json" %}

```json
"certcheck": {
  "Title": "Certificate Check",
  "Note": "Look up the TLS certificate of any domain: issuer, validity window, and subject alternative names.",
  "Note2": "Enter a domain to start the check:",
  "Placeholder": "example.com",
  "fetchError": "Unable to fetch certificate details"
}
```

{% endcode %}

…and the card description in the shared `advancedtools` namespace:

{% code title="frontend/locales/en.json" %}

```json
"advancedtools": {
  "CertCheck": "Inspect a domain's TLS certificate"
}
```

{% endcode %}

The namespace normally matches the slug (`macchecker`, `dnsresolver`, `censorshipcheck`). Keep the key names identical across all four files — only the values change.

Details on loading, sub-packs, and the fallback chain: [i18n](/developer/development/i18n.md).
{% endstep %}

{% step %}

### Add a changelog entry

Append your entry to the **last** version block in `frontend/data/changelog.json` — the file runs oldest-first and the UI renders it reversed.

{% code title="frontend/data/changelog.json" %}

```json
{
  "type": "add",
  "change": {
    "en": "New Certificate Check tool: inspect any domain's TLS certificate",
    "zh": "新增证书检查工具：查看任意域名的 TLS 证书",
    "fr": "Nouvel outil de vérification de certificat : inspectez le certificat TLS de n'importe quel domaine",
    "ru": "Новый инструмент проверки сертификатов: просмотр TLS-сертификата любого домена"
  }
}
```

{% endcode %}

`type` must be one of `add`, `improve`, or `fix`. All four locale strings must be present and non-empty — `tests/changelog.test.js` fails the build otherwise.
{% endstep %}

{% step %}

### Write the tests

Two kinds, both in `tests/`.

**Handler smoke tests** go in `tests/api-handlers.test.js`, in a `describe` block next to the others. Assert only on branches that return **before** the first `fetchUpstream` call — the suite never touches a real upstream:

{% code title="tests/api-handlers.test.js" %}

```js
import certCheckHandler from '../api/cert-check.js';

// -- cert-check handler ---------------------------------------------------
// Domain presence/shape is enforced by requireValidDomain middleware
// (tests/guards.test.js); the handler's own pre-fetch branches are the
// method gate and the missing-API-key early return.

describe('cert-check handler', () => {
    it('rejects non-GET with 405 before hitting the upstream', async () => {
        const res = createResponse();
        await certCheckHandler(createRequest({ method: 'POST', query: { domain: 'example.com' } }), res);
        assert.equal(res.statusCode, 405);
        assert.equal(res.body.error, 'Method Not Allowed');
    });

    it('returns 500 when the API key is missing', async () => {
        delete process.env.CERT_API_KEY;
        const res = createResponse();
        await certCheckHandler(createRequest({ query: { domain: 'example.com' } }), res);
        assert.equal(res.statusCode, 500);
        assert.equal(res.body.error, 'API key missing');
    });
});
```

{% endcode %}

The file already provides `createRequest()` / `createResponse()` stubs. If your handler reads a new environment variable, add its name to the `ENV_KEYS` array at the top so the backup/restore hooks cover it.

**Unit specs** cover any pure logic your tool introduces — validators, parsers, transforms, a composable with mockable inputs. Give each its own `tests/<subject>.test.js`. If you added a guard, extend `tests/guards.test.js`.

What does *not* get a test: Vue rendering, real network calls, browser APIs. See [Testing](/developer/development/testing.md).
{% endstep %}

{% step %}

### Run the self-check

```bash
pnpm check
```

Tests plus a production build. It must be green before you open a PR.

Then look at the tool yourself in `pnpm dev` — on the homepage card grid, in the drawer, and at `/tools/certcheck` — because none of that is machine-verifiable. Say so in the PR description.
{% endstep %}
{% endstepper %}

***

## Checklist

| Step                              | File                                               |
| --------------------------------- | -------------------------------------------------- |
| Handler                           | `api/cert-check.js`                                |
| Guard (only if a new param shape) | `common/guards.js`                                 |
| Route                             | `backend-server.js`                                |
| Component                         | `frontend/components/advanced-tools/CertCheck.vue` |
| Registry entry                    | `frontend/data/tools.js`                           |
| Copy × 4                          | `frontend/locales/{en,zh,fr,ru}.json`              |
| Changelog × 4                     | `frontend/data/changelog.json`                     |
| Smoke test                        | `tests/api-handlers.test.js`                       |
| Unit specs                        | `tests/*.test.js`                                  |
| New env var                       | `.env.example`                                     |

## Going further

Two optional systems your tool can plug into, both event-driven — components emit on the `utils/app-events.js` bus and never call the systems directly:

* **Achievements.** Emit a domain event, map it to an achievement slug in `frontend/data/achievement-rules.js`, and add the achievement to `frontend/data/achievements.js`.
* **Shareable diagnostic reports.** A "my network" test emits `<domain>:finished` with its structured result; a builder in `frontend/utils/report-builders.js` normalizes it, and `common/report-schema.js` whitelists the fields. Builders fail soft, so a missing schema entry shows up as a quietly absent field rather than an error — add the builder and the schema entry in the same change.

Both are described in more detail in [Frontend](/developer/architecture/frontend.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/development/adding-a-new-tool.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.
