Short-link API with per-country click stats
A TypeScript Fastify API that shortens URLs, redirects with 302, and counts clicks by country code behind an API key.
typescriptfastifysqlitezodvitestnodejs
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add short-link-api-with-per-country-click-stats-fbd8dfCLAUDE.md# the app
## Purpose
This project is a URL shortener API written in TypeScript. It creates short codes for links, redirects visitors with HTTP 302, and counts clicks per ISO country code. It never stores IP addresses.
## Stack
- Node 22 with TypeScript in strict mode
- Fastify for the HTTP server
- better-sqlite3 for storage
- Zod for input validation
- Vitest for tests
- ESLint and Prettier for style
## Commands
- `npm run dev`: start the server in watch mode
- `npm test`: run the full Vitest suite
- `npm run typecheck`: run TypeScript without emitting files
- `npm run lint`: run ESLint
- `npm run build`: compile to `dist/`
- `npm run smoke`: start the server and run the end-to-end check
Run the `verify` skill before saying any work is done.
Use the `test-runner` subagent for the full test suite so long output stays out of the main session.
## Layout
- `src/app.ts` builds the Fastify instance and registers routes.
- `src/server.ts` starts the app on `PORT` (default 3000).
- `src/routes/` holds one file per resource. Keep handlers thin.
- `src/db.ts` is the only file that touches SQLite.
- `src/geo.ts` wraps the GeoIP lookup behind a `GeoResolver` interface.
- `test/` mirrors `src/`. Route tests live in `test/routes/`.
- `SPEC.md` defines what done means. `PLAN.md` lists the build phases.
- `REVIEW.md` holds review rules. Imported below.
@REVIEW.md
## Rules
- Use parameterized SQL only. Never build SQL with string concatenation.
- Never store, log, or return IP addresses. Store only the two-letter country code, or `ZZ` when unknown.
- Never commit `.env`. Secrets come from environment variables named in `.env.example`.
- Never use `any`. Never add unused exports.
- Keep functions under 40 lines.
- Do not weaken or delete a failing test to make a check pass. Fix the cause.
- Ask before adding any dependency, changing the database schema, or changing a public endpoint path.
- Do not commit unless the user asks. Phase ends use the `commit-commands` plugin.
## Responses
- All responses are JSON.
- Errors use the shape `{error: string, message: string}` with the correct HTTP status code.
- Unknown codes return 404. Duplicate aliases return 409. Missing or wrong API keys return 401.
## Non-default conventions
- Redirects use HTTP 302, not 301. A 301 would be cached by browsers, so later visits would skip the server and never be counted.
- GeoIP failures must never block a redirect. Log the failure without any IP address.
- The API key is compared in constant time.
## Scope
Out of scope for now: user accounts, a web UI, rate limiting, and custom domains. Do not add them without asking.
## Working style
- Read `SPEC.md` and the relevant phase in `PLAN.md` before writing code.
- Make the smallest change that meets the spec.
- After each change, run the checks, read the output, and report the exit codes.
.claude/agents/test-runner.md---
name: test-runner
description: Use to run the test suite and report failures. Returns pass and fail counts, or failing tests with their first error lines, so full test output stays out of the main session.
model: haiku
tools: Bash, Read
maxTurns: 8
---
You run the project's test suite and report only the result.
## Steps
1. Run `npm test` from the project root.
2. If every test passes, reply with the pass and fail counts only. Nothing else.
3. If any test fails, reply with one entry per failing test:
- the test name
- the file it is in
- the first 10 lines of its error message
## Rules
- Never edit files. Read files only when needed to find a test's file path.
- Never print the full test log.
- If `npm test` cannot start (missing dependencies, missing `package.json`), reply with the first 10 lines of the error and stop.
.claude/settings.json{
"permissions": {
"defaultMode": "acceptEdits",
"allow": [
"Bash(npm run *)",
"Bash(npm test)",
"Bash(npx vitest *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)"
],
"ask": [
"Bash(npm install *)",
"Bash(npm publish *)",
"Bash(git push *)",
"Bash(git commit *)"
],
"deny": [
"Bash(rm -rf *)",
"Bash(git push --force *)",
"Read(./.env)",
"Read(./.env.*)",
"Bash(curl *)",
"Bash(wget *)"
],
"blockWritesOutsideWorkingDirectories": true
}
}
.claude/skills/add-endpoint/SKILL.md---
name: add-endpoint
argument-hint: "<METHOD> <path>, for example: GET /api/links/:code/stats"
description: Use when adding or changing an HTTP route. Follows the same test-first steps every time so new routes match SPEC.md and the project layout.
---
# Add endpoint
Add or change one HTTP route using the same steps each time.
## Steps
1. Read SPEC.md. Confirm the route is in it with the same method, path, and response shape. If it is missing or the spec differs, ask the user before continuing.
2. Write the Vitest test first in `test/routes/`, mirroring the route file name. Include one success case and one failure case. Run it and confirm it fails for the expected reason.
3. Add a Zod schema for the request body, query, or params.
4. Implement the route in `src/routes/<resource>.ts`. Keep the handler thin: validate with the schema, call a function, return JSON. Errors use `{error, message}` with the correct status code.
5. Put any SQL in `src/db.ts`, using placeholders only. Routes must not run SQL directly.
6. Register the route in `src/app.ts`.
7. Run the verify skill and report the result.
## Rules
- Do not change existing public paths or response shapes without asking first.
- Do not add a dependency without asking first.
- Never log or store IP addresses.
- Keep each handler under 40 lines.
- Do not commit. The user commits at the end of each phase.
.claude/skills/verify/SKILL.md---
name: verify
description: Use after any code change and before reporting work as done. Runs the project's real checks in order and stops at the first failure.
allowed-tools: Bash(npm run *), Bash(npm test), Bash(npx vitest *)
---
# Verify
Prove a change works by running the real project checks. Do not claim success from reading the code.
## Steps
Run these in order from the project root. Stop at the first failure.
1. `npm run typecheck`
2. `npm run lint`
3. `npm test` (delegate to the test-runner subagent so the full log stays out of the main session)
4. `npm run build`
5. `npm run smoke`
Before step 5, check that `.env` exists and sets both `API_KEY` and `GEOIP_DB_PATH`. If either is missing, report which one is missing, say that smoke cannot run, and stop. Do not create `.env` or invent values for it.
## On failure
- Show the failing command and its exit code.
- Show only the first relevant error lines, not the whole log.
- Name the file most likely responsible.
- Do not guess a fix and then claim it works. If the cause is clear, fix it, then rerun the whole skill from step 1, because a fix can break an earlier step.
## On success
Report each command and its exit code in a table:
| Command | Exit code |
|---|---|
| npm run typecheck | 0 |
| npm run lint | 0 |
| npm test | 0 |
| npm run build | 0 |
| npm run smoke | 0 |
Add one line saying whether all checks passed.
INSTALL.md# the app: setup guide
## What you will have at the end
A working local API that shortens links, redirects them with HTTP 302, and counts clicks by country. It has automated tests. Claude Code is configured to build it in five phases, with checks at each step.
You do not need to write code yourself. You will paste prompts and review what Claude reports.
## Step 1: Install Node 22
Go to nodejs.org and install the version marked 22. The API runs on Node 22, and Claude Code uses Node to run its checks.
Confirm the install:
```
node --version
```
The output should start with `v22`. Close and reopen your terminal if the command is not found.
## Step 2: Install Claude Code
```
npm install -g @anthropic-ai/claude-code
claude --version
```
The first command installs the Claude Code command-line tool. The second confirms it is available.
## Step 3: Create the project folder
```
mkdir the-app && cd the-app
git init
```
The folder holds the project. `git init` starts version tracking so you can see and undo every change Claude makes.
## Step 4: Copy the project files
Copy these into the project root:
- CLAUDE.md
- SPEC.md
- PLAN.md
- REVIEW.md
- INSTALL.md
- The `.claude` folder
CLAUDE.md is read automatically each session. The `.claude` folder holds project permissions, skills, and a test subagent.
## Step 5: Start Claude Code and sign in
```
claude
```
Inside Claude Code, run:
```
/login
```
Follow the prompts to sign in with your account. This connects Claude Code to your plan.
## Step 6: Install the plugins
Run each command inside Claude Code. Each plugin adds one capability.
```
/plugin install code-review@claude-plugins-official
```
Reviews your changes for bugs and security problems before you commit. It uses the rules in REVIEW.md.
```
/plugin install commit-commands@claude-plugins-official
```
Gives you clean commit messages. Each phase ends with a suggested commit. You decide whether to commit.
```
/plugin install security-guidance@claude-plugins-official
```
Flags common web security mistakes, such as injection and leaked secrets. This matters because the API holds an API key.
## Step 7: Get the GeoIP database
The API looks up each visitor's country in a free MaxMind file.
1. Create a free account at maxmind.com.
2. Generate a license key in your account settings. It will look like YOUR_MAXMIND_LICENSE_KEY.
3. Download GeoLite2-Country. Do this in your browser, not through Claude.
4. Create a `data` folder in the project and move the file there, named `GeoLite2-Country.mmdb`.
The final path is `data/GeoLite2-Country.mmdb`. The `data/` folder is ignored by git so the database is never committed. Add it to `.gitignore` if it is not already there:
```
echo "data/" >> .gitignore
```
## Step 8: Create your .env file
```
cp .env.example .env
```
Open `.env` in a text editor. Set these two values:
```
API_KEY=replace-with-your-own-long-random-value
GEOIP_DB_PATH=./data/GeoLite2-Country.mmdb
```
To generate a strong key, run this in a separate terminal:
```
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
Paste the output into `API_KEY`. Never paste this key into a chat, a commit, or a shared file. Claude is blocked from reading `.env` on purpose.
## Step 9: Paste the Phase 1 prompt
Inside Claude Code, run:
```
/clear
```
Then paste the Phase 1 `/goal` prompt from PLAN.md and press Enter.
## How /goal prompts work
Each phase in PLAN.md has a `/goal` prompt in a fenced block. A `/goal` prompt tells Claude:
- the end state to reach,
- the exact command that proves it,
- the constraints from CLAUDE.md,
- a turn limit, after which Claude stops and reports what is blocking it.
Claude keeps working until a small checker model confirms the end state is met. The checker only sees what Claude has shown in the conversation, so Claude must run the checks and show the output.
To check progress while a phase runs, type:
```
/goal status
```
To stop a phase early, type `/goal clear`.
## What the five phases do
1. **Scaffold.** Project setup, strict TypeScript, linting, tests, and a health check.
2. **Links.** Database schema, creating short links, codes, alias conflicts, and API key checks.
3. **Redirect and clicks.** The 302 redirect, click recording, and the country lookup.
4. **Stats.** Click totals by country, sorted from highest to lowest.
5. **Ship.** A smoke test that runs the whole flow, a Dockerfile, and setup docs.
After each phase passes, run `/clear`, then paste the next phase's prompt from PLAN.md. Clearing keeps the conversation short and cheap.
## Permission prompts
The project sets file edits to go through automatically. Commands follow lists in `.claude/settings.json`:
- **Runs without asking:** `npm run` scripts, `npm test`, `npx vitest`, and read-only git commands such as `git status`, `git diff`, and `git log`.
- **Asks first:** `npm install`, `npm publish`, `git push`, and `git commit`.
- **Always blocked:** `rm -rf`, force pushes, reading `.env`, `curl`, and `wget`.
If a prompt appears that you do not understand, answer no. Then ask Claude: "Explain why you want to run this command." A prompt about `npm install` is normal when a phase adds a dependency. Approve it only if the dependency is on the approved list in CLAUDE.md.
## Troubleshooting
**Wrong Node version.** Run `node --version`. If it does not start with `v22`, reinstall Node 22 from nodejs.org, then close and reopen your terminal. Tests will fail on other versions.
**ZZ appears for every country.** ZZ means the lookup could not find a country. The usual cause is a wrong `GEOIP_DB_PATH` or a missing file. Check that `data/GeoLite2-Country.mmdb` exists and that `.env` has the same path. Then restart the server. The redirect still works while this happens, so the problem shows only in the stats.
**A permission prompt you do not understand.** See the section above. When in doubt, deny and ask Claude to explain.
## Glossary
- **CLAUDE.md:** A file of project instructions that Claude reads at the start of every session.
- **Skill:** A reusable recipe stored in `.claude/skills/`. Claude loads it when the task matches, for example `verify` or `add-endpoint`.
- **Subagent:** A helper Claude can hand a task to. It works in its own context, so long output stays out of your main session. The `test-runner` subagent runs the tests.
- **/goal:** A command that keeps Claude working toward a stated end state until a checker confirms it is met.
- **/clear:** Empties the conversation so the next task starts fresh.
- **/compact:** Shortens a long conversation while keeping the important context.
PLAN.md# Build plan
This plan has five phases.
Each phase ends with a measurable check.
Run one phase at a time.
## How to run a phase
1. Run `/clear` so the session starts fresh.
2. Paste the phase's `/goal` prompt.
3. Let Claude work until the goal is met or it stops at the turn limit.
4. If it stops, read the blocker it reports and fix it yourself or ask Claude to fix it.
5. When the check passes, review the diff and commit using the suggestion at the end of the phase.
Check progress at any time with `/goal status`.
Stop a goal with `/goal clear`.
---
## Phase 1: Scaffold
**Goal:** Create the project skeleton with a passing health check.
**End state:**
- `package.json`, `tsconfig.json` with `strict: true`, ESLint and Prettier configs, and Vitest configured.
- `src/app.ts` exports a Fastify instance.
- `src/server.ts` starts the app on `PORT` from the environment, default 3000.
- `GET /health` returns `{status: 'ok'}`.
- `test/health.test.ts` passes.
**Check:** Run `npm run typecheck`, `npm run lint`, and `npm test`.
All three must exit 0.
The final message must paste the three exit codes.
**Start-here prompt:**
```
/goal Phase 1 Scaffold from PLAN.md is complete.
End state: package.json, tsconfig.json with strict true, ESLint and Prettier configs, Vitest configured, src/app.ts exporting a Fastify instance, src/server.ts starting it on PORT (default 3000), GET /health returning {status: 'ok'}, and a passing test in test/health.test.ts.
Check: run `npm run typecheck`, `npm run lint`, and `npm test`. All three must exit 0. The final message must paste their exit codes.
Constraints from CLAUDE.md: TypeScript strict with no any, ESM imports, no new dependencies beyond Fastify, Vitest, ESLint, Prettier, TypeScript, Zod, and better-sqlite3 unless you ask first. Do not create database code yet. Do not commit. Use the test-runner subagent for the test run. Read SPEC.md and PLAN.md first. If a check fails, fix the cause and rerun. Do not weaken tests.
Stop after 30 turns and report what is blocking, with the last error lines.
```
**Commit suggestion:** After the check passes, run `/commit-commands:commit` and review the message. Do not commit automatically.
---
## Phase 2: Links
**Goal:** Create links with validation, codes, alias conflicts, and API key auth.
**End state:**
- SQLite schema for `links` is created on startup.
- `POST /api/links` validates input with Zod.
- Codes are 7 characters when no alias is given.
- A taken alias returns `409`.
- A missing or wrong `X-API-Key` returns `401`.
- Error responses use `{error, message}`.
**Check:** Tests cover a valid request, an invalid request, a duplicate alias, and a missing key.
Run `npm run typecheck`, `npm run lint`, and `npm test`.
All must exit 0.
The final message must paste the exit codes and the names of the four test cases.
**Prompt:**
```
/goal Phase 2 Links from PLAN.md is complete.
End state: the links table is created on startup; POST /api/links validates input with Zod, generates a 7-character code when no alias is given, returns 409 for a taken alias, and returns 401 for a missing or wrong X-API-Key. Error responses use {error, message}.
Check: tests cover a valid request, an invalid request, a duplicate alias, and a missing key. Run `npm run typecheck`, `npm run lint`, and `npm test`; all must exit 0. The final message must paste the exit codes and the names of those four test cases.
Constraints from CLAUDE.md: parameterized SQL only, all queries in src/db.ts, routes thin in src/routes/, no IP addresses stored or logged, no new dependencies unless you ask first, do not change the database schema beyond what SPEC.md lists without asking. Do not commit. Use the test-runner subagent for the test run. Read SPEC.md first. If a check fails, fix the cause and rerun. Do not weaken tests.
Stop after 40 turns and report what is blocking, with the last error lines.
```
**Commit suggestion:** After the check passes, run `/commit-commands:commit` and review the message. Do not commit automatically.
---
## Phase 3: Redirect and clicks
**Goal:** Redirect short links with 302 and record one click per visit.
**End state:**
- `GET /:code` returns `302` to the original URL.
- Each visit inserts one row in `clicks`.
- `GeoResolver` interface exists.
- A MaxMind implementation reads `GEOIP_DB_PATH`.
- A fake resolver is used in tests.
- Unknown codes return `404` with JSON.
- A resolver that throws still redirects.
**Check:** Tests show a click row with the correct country code.
Tests show that a resolver that throws still produces a `302`.
Run `npm run typecheck`, `npm run lint`, and `npm test`; all must exit 0.
The final message must paste the exit codes.
**Prompt:**
```
/goal Phase 3 Redirect and clicks from PLAN.md is complete.
End state: GET /:code returns 302 to the original URL and inserts one row in the clicks table per visit. A GeoResolver interface exists with a MaxMind implementation that reads GEOIP_DB_PATH and a fake used in tests. Unknown codes return 404 JSON. A resolver that throws still redirects.
Check: tests show a click row with the correct country code, and a test where the resolver throws still gets a 302. Run `npm run typecheck`, `npm run lint`, and `npm test`; all must exit 0. The final message must paste the exit codes.
Constraints from CLAUDE.md: parameterized SQL only, all queries in src/db.ts, GeoIP lookup only in src/geo.ts, never store or log IP addresses, country is two letters or ZZ, use the fake GeoResolver in tests instead of the real database file, no new dependencies unless you ask first. Do not commit. Use the test-runner subagent for the test run. Read SPEC.md first. If a check fails, fix the cause and rerun. Do not weaken tests.
Stop after 40 turns and report what is blocking, with the last error lines.
```
**Commit suggestion:** After the check passes, run `/commit-commands:commit` and review the message. Do not commit automatically.
---
## Phase 4: Stats
**Goal:** Report total clicks and clicks per country for each link.
**End state:**
- `GET /api/links/:code/stats` requires `X-API-Key`.
- It returns `{code, total, byCountry}`.
- `byCountry` is sorted by count, highest first.
- Unknown codes return `404` with JSON.
**Check:** A test seeds clicks from three countries and asserts the exact count for each.
Run `npm run typecheck`, `npm run lint`, and `npm test`; all must exit 0.
The final message must paste the exit codes and the asserted counts.
**Prompt:**
```
/goal Phase 4 Stats from PLAN.md is complete.
End state: GET /api/links/:code/stats requires X-API-Key and returns {code, total, byCountry} with byCountry sorted by count descending. Unknown codes return 404 JSON.
Check: a test seeds clicks from three countries and asserts the exact count for each country and the total. Run `npm run typecheck`, `npm run lint`, and `npm test`; all must exit 0. The final message must paste the exit codes and the asserted counts.
Constraints from CLAUDE.md: parameterized SQL only, aggregation queries in src/db.ts, routes thin, no IP addresses in responses, no new dependencies unless you ask first. Do not commit. Use the test-runner subagent for the test run. Read SPEC.md first. If a check fails, fix the cause and rerun. Do not weaken tests.
Stop after 40 turns and report what is blocking, with the last error lines.
```
**Commit suggestion:** After the check passes, run `/commit-commands:commit` and review the message. Do not commit automatically.
---
## Phase 5: Ship
**Goal:** Make the project runnable as a container and verifiable end to end.
**End state:**
- `npm run smoke` starts the server, creates a link, follows the redirect, and reads the stats.
- A `Dockerfile` builds and runs the server.
- `.env.example` lists `API_KEY` and `GEOIP_DB_PATH` with placeholder values only.
- The setup section in `INSTALL.md` matches the final commands.
**Check:** Run `npm run build` and `npm run smoke`.
Both must exit 0.
`npm run smoke` needs a `.env` file with `API_KEY` and `GEOIP_DB_PATH`. If it is missing, stop and report that. Do not invent values.
The final message must paste both exit codes.
**Prompt:**
```
/goal Phase 5 Ship from PLAN.md is complete.
End state: an npm run smoke script starts the server, creates a link, follows the redirect, and reads stats; a Dockerfile exists; .env.example lists API_KEY and GEOIP_DB_PATH with placeholder values only; the setup section of INSTALL.md matches the final commands.
Check: run `npm run build` and `npm run smoke`. Both must exit 0. The final message must paste both exit codes. If .env is missing or lacks API_KEY or GEOIP_DB_PATH, stop and report that instead of inventing values.
Constraints from CLAUDE.md: never commit .env, secrets come from environment variables, no IP addresses in logs or the database, no new dependencies unless you ask first. Do not commit. Use the test-runner subagent for the test run. Read SPEC.md first. If a check fails, fix the cause and rerun. Do not weaken tests.
Stop after 40 turns and report what is blocking, with the last error lines.
```
**Commit suggestion:** After the check passes, run `/commit-commands:commit` and review the message. Do not commit automatically.
REVIEW.md# Review checklist
Use this list to review every change in this project. Report findings as BLOCKER, WARN, or NIT.
## Security
- The API key is compared in constant time, never with `===`.
- No secrets appear in code, tests, logs, or committed files.
- SQL uses placeholders only. No user input is concatenated into a query.
- URL validation rejects every scheme except http and https.
- URL validation rejects localhost and private IP literals.
- No user input reaches a shell command.
## Privacy
- No IP address is written to disk, logs, or responses.
- The country field holds only a two-letter ISO code or ZZ.
- Log lines never include the client address or request headers.
## Behavior
- Redirects use HTTP 302, not 301 or 307.
- A GeoIP failure never blocks a redirect.
- Unknown codes return 404 with `{error, message}` JSON.
- Duplicate aliases return 409 with the same error shape.
- Response field names match SPEC.md exactly.
## Tests
- Every new route has at least one success test and one failure test.
- Tests use the fake GeoResolver, never the real GeoLite2 file.
- Existing assertions are not weakened, skipped, or deleted to make a change pass.
## Code
- Strict TypeScript with no `any` and no casts that bypass the type checker.
- No unused exports.
- Functions stay under 40 lines.
- Route files stay thin: validate input, call db.ts, shape the response.
- All queries live in src/db.ts.
- New dependencies need the user's approval first.
## Output format
- List each finding as `BLOCKER`, `WARN`, or `NIT`, then `file:line`, then one line of description.
- End with one line: `Verdict: approve` or `Verdict: changes needed`.
SPEC.md# the app spec
## Purpose
A small HTTP API that shortens URLs and counts clicks per country.
The project is done when every requirement below works and has tests.
## Error format
All error responses use this JSON shape with the correct HTTP status:
```json
{"error": "not_found", "message": "No link with that code."}
```
## Endpoints
### POST /api/links
- Header: `X-API-Key` (required).
- Body: `{url, alias?}`.
- `url` must be http or https.
- `url` must be at most 2048 characters.
- `url` must not point at localhost or a private IP literal.
- If `alias` is omitted, generate a 7-character code.
- Success: `201` with `{code, short_url}`.
- Alias already taken: `409`.
- Invalid body or URL: `400`.
- Missing or wrong key: `401`.
### GET /:code
- Looks up the link and records one click row.
- Redirects with `302` to the original URL.
- Unknown code: `404` with JSON.
- A failed GeoIP lookup must never block the redirect.
### GET /api/links/:code/stats
- Header: `X-API-Key` (required).
- Success: `200` with `{code, total, byCountry}`.
- Example: `{code, total: 16, byCountry: {US: 12, DE: 3, ZZ: 1}}`.
- `byCountry` is sorted by count, highest first.
- Unknown code: `404` with JSON.
- Missing or wrong key: `401`.
### GET /health
- No authentication.
- Returns `{status: 'ok'}`.
## Data model
Table `links`:
- `code` TEXT PRIMARY KEY
- `url` TEXT NOT NULL
- `created_at` TEXT NOT NULL
Table `clicks`:
- `id` INTEGER PRIMARY KEY AUTOINCREMENT
- `code` TEXT NOT NULL REFERENCES `links(code)`
- `country` TEXT NOT NULL
- `clicked_at` TEXT NOT NULL
Schema is created on startup.
## Country detection
- A `GeoResolver` interface reads the client IP.
- The MaxMind implementation reads a GeoLite2-Country database file.
- The file path comes from the `GEOIP_DB_PATH` environment variable.
- The resolver returns an ISO 3166 alpha-2 country code.
- Unknown or missing lookups return `ZZ`.
- Tests use a fake resolver, never the real database file.
## Authentication
- The expected key comes from the `API_KEY` environment variable.
- Compare keys with a constant-time comparison.
- Missing or wrong key returns `401`.
## Privacy
- Never store, log, or return IP addresses.
- The only location value saved is the two-letter country code or `ZZ`.
## Acceptance
- Every endpoint is covered by Vitest tests.
- `npm run smoke` passes against a running server.
- No IP addresses appear in the database or the logs.
## Out of scope
- User accounts
- Expiring links
- QR codes
- A dashboard
- Multi-region deployment