diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..f73c41f
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,80 @@
+# Contributing
+
+## Package manager
+
+This repo is a pnpm workspace ([`pnpm-workspace.yaml`](./pnpm-workspace.yaml)).
+pnpm is the only supported package manager — the exact version is pinned in
+`packageManager` in [`package.json`](./package.json), and CI installs pnpm from
+that field rather than a hard-coded version. Node comes from
+[`.nvmrc`](./.nvmrc).
+
+## Run from source
+
+For contributors hacking on the web dashboard / server:
+
+```bash
+pnpm install
+cp .env.example .env # set DATABASE_URL and generate MANTIS_API_KEY_PEPPER
+pnpm run db:migrate
+pnpm dev
+```
+
+Generate the pepper with `openssl rand -base64 32` and add it as
+`MANTIS_API_KEY_PEPPER` in `.env`.
+
+Optional: `pnpm run setup:hooks` installs the repo's pre-commit hook
+([`scripts/install-hooks.sh`](./scripts/install-hooks.sh)).
+
+## What CI runs
+
+Everything below is taken from
+[`.github/workflows/ci.yml`](./.github/workflows/ci.yml), which runs on pushes
+to `main`, on pull requests, and on manual dispatch. Each job starts with
+`pnpm install --frozen-lockfile`.
+
+| Job | Command |
+| --- | --- |
+| typecheck (server + cli + edge) | `pnpm run check` |
+| unit + integration tests | `pnpm run db:migrate`, `pnpm test`, `pnpm run test:integration`, `pnpm run test:tier2` |
+| next build | `pnpm run build` |
+| CLI build artifact | `pnpm --filter @mantis/cli run build`, then `node cli/dist/index.js --version` |
+| dependency audit | `pnpm audit --prod --audit-level=high` |
+
+Notes on the test tiers:
+
+- `pnpm test` — Vitest unit suite ([`vitest.config.ts`](./vitest.config.ts)).
+- `pnpm run test:integration` — full-stack tests that exercise the real route
+ handlers against a live Postgres
+ ([`vitest.integration.config.ts`](./vitest.integration.config.ts)). The
+ suite's `globalSetup` re-applies migrations idempotently. Locally,
+ `pnpm run test:integration:db` starts a database for you via
+ [`scripts/test-integration.sh`](./scripts/test-integration.sh).
+- `pnpm run test:tier2` — end-to-end against a production `next build` and the
+ standalone server entrypoint over real HTTP
+ ([`vitest.tier2.config.ts`](./vitest.tier2.config.ts),
+ [`scripts/test-tier2.sh`](./scripts/test-tier2.sh)). It covers the two things
+ handler-level tests cannot see: the proxy host-split as applied by the runtime
+ matcher, and the wire-level `Set-Cookie` `Secure` attribute. Set
+ `MANTIS_TIER2_USE_EXISTING_DB=1` to reuse a database you already have running.
+
+The audit job fails the build on a high or critical advisory in production
+dependencies. `pnpm-workspace.yaml` carries overrides that patch known
+transitive CVEs; moderate advisories are tracked by Dependabot rather than
+blocking.
+
+## Benchmarks
+
+```bash
+pnpm run bench # CLI only
+pnpm run bench:all # CLI + edge + server
+```
+
+See [`bench/README.md`](./bench/README.md) for what each harness measures and
+the recorded baselines.
+
+## Versioning
+
+The server, the CLI, the edge worker and the LAN helper are versioned
+independently and each has its own `package.json` version. CLI releases are cut
+by [`.github/workflows/cli-release.yml`](./.github/workflows/cli-release.yml),
+which also regenerates the Homebrew formula in `privacykey/homebrew-tap`.
diff --git a/README.md b/README.md
index 7311823..5484814 100644
--- a/README.md
+++ b/README.md
@@ -2,41 +2,46 @@
+mantis
+
+Self-hostable canary key service. API-first.
+
-
-
-
-
+
+
+
+
+
> [!WARNING]
-> **Beta.** APIs, DB schema, and CLI flags may still change before v1.0. Components are versioned independently — CLI `v0.1.6`, full server `v0.1.1`, edge `v0.1.3` at time of writing. Pin a release tag for stability.
+> **Pre-1.0 — no stable release yet.** Anything can change in any release, including a patch: APIs, CLI flags, config keys, file formats, and data already on disk. Keep your own backups.
+> **Project status.** The badge above is generated from [the privacykey status list](https://github.com/privacykey/.github/blob/main/STATUS.md), which says what I promise for this project and every other one.
+
+
+> [!CAUTION]
+> **Serve it over HTTPS, never plain HTTP.** Mantis authenticates with an API key sent as a bearer token and a session cookie — over plain HTTP on a routable address both travel in cleartext and can be sniffed. Put it behind a tunnel (the `tailscale` / `cloudflared` compose profiles terminate TLS for you) or a TLS reverse proxy.
-Self-hostable mantis key service. API-first.
+---
You mint a unique URL; when something fetches it, you get notified through the destinations you configure. Useful for honeypot files on shared drives, fake credentials in `.env`s, SSH-login alarms on jump boxes, detecting site clones, or any "did someone touch the thing they shouldn't" tripwire.
-Highlights:
+There are two ways to run it. The full **server** (`docker compose`, Postgres-backed) gives you the web dashboard, the notification queue, file and host-event keys, and hit history. The **edge** variant ([`mantis-edge/`](./mantis-edge/README.md)) is a Cloudflare Worker that decrypts URLs at the edge with no database to host. Use it when you only need fire-and-forget hit alerts and want zero infrastructure.
+
+Components are versioned independently: the server, the CLI, and the edge worker each carry their own version and release on their own cadence.
+
+## What it does
-- **File keys** in 10 formats (`.docx` / `.xlsx` / `.pptx` / `.pdf` / `.svg` / `.html` / `.md` / `.eml` / `.ics` / `.vcf`) plus a 9-file honey-directory `.zip` and Apple Wallet `.pkpass` passes (install / uninstall / fetch callbacks each fire the key)
+- **File keys** in 10 formats (`.docx` / `.xlsx` / `.pptx` / `.pdf` / `.svg` / `.html` / `.md` / `.eml` / `.ics` / `.vcf`) plus a honey-directory `.zip` and Apple Wallet `.pkpass` passes (install / uninstall / fetch callbacks each fire the key)
- **Host-event installers** — shell, sudo, login, boot, wake, network — for macOS, Linux, and Windows, with parsed `X-Mantis-*` context including SSH client IP
-- **Web canaries** — CSS-background + JS clone-detector — plus NFC NDEF tag URLs and printable QR/NFC sticker labels
+- **Web canaries** — CSS-background and JS clone-detector — plus NFC NDEF tag URLs and printable QR/NFC sticker labels
- **Smart-home triggers** via Home Assistant, Scrypted, and an optional LAN watcher
-- **Smart-home actions** — `home_assistant` destination posts to a HA webhook automation, so a hit can flip a switch (e.g. cut a VLAN via the OPNsense integration), fire a scene, or push a phone notification (`mantis install --type homeassistant-receiver` prints a ready-to-paste automation skeleton)
+- **Smart-home actions** — the `home_assistant` destination posts to a HA webhook automation, so a hit can flip a switch, fire a scene, or push a phone notification; `mantis install --type homeassistant-receiver` prints the automation skeleton
- **Direct notification destinations** — webhook, email, Slack, Discord, Teams — with a Postgres-backed retry queue and per-key dedup
-- **Optional stateless variant** ([`mantis-edge/`](./mantis-edge/README.md)) — a Cloudflare Worker that decrypts URLs at the edge, no DB to host
-- **Uptime Kuma integration** — per-key status URL flips on hit, watched by Kuma for 80+ notification channel fan-out
+- **Uptime Kuma integration** — a per-key status URL flips on hit, so Kuma can fan out to its own notification channels
+- **Fleet provisioning** — idempotent key creation keyed on an `external_id`, plus create-only enrollment API keys safe to embed in MDM scripts. See [`docs/FLEET-PROVISIONING.md`](./docs/FLEET-PROVISIONING.md)
-## Contents
-
-- [Getting started](#getting-started) — [stateful server](#quickstart-stateful-server) or [stateless edge](#quickstart-no-server--edge)?
-- [Run from source](#run-from-source) — for contributors
-- [Components](#components) — in-repo docs for each part
-- [Docs](#docs) — full prose documentation
-
-## Getting started
-
-**Stateful server or stateless edge?** Run the full **server** (`docker compose`, Postgres-backed) when you want the web dashboard, notification queue, file/host-event keys, and history. Run the **edge** ([`mantis-edge/`](./mantis-edge/README.md)) — a Cloudflare Worker that decrypts URLs at the edge with no DB to host — when you only need fire-and-forget hit alerts and want zero infrastructure.
+## Get it
The fastest path either way is the guided CLI: `mantis init` asks server-or-edge and walks you through login and your first key.
@@ -45,119 +50,44 @@ brew install privacykey/tap/mantis
mantis init
```
-### Quickstart (stateful server)
+To stand the server up yourself:
```bash
git clone https://github.com/privacykey/mantis && cd mantis
./scripts/setup.sh # creates .env with a random DB password + API-key pepper
docker compose up -d
-# Wait for the boot banner, then read the one-time bootstrap admin key
-docker compose logs -f mantis | grep -m1 -A1 "bootstrap API key"
-```
-
-`setup.sh` is idempotent — re-running it leaves existing secrets untouched. Postgres is never published to the host (it sits on an internal-only docker network), and the DB password is the single value you set in `.env`; the app's `DATABASE_URL` is derived from it.
-
-The `mantis_live_...` value printed above is the **bootstrap admin key** — it is both your CLI token and your dashboard login. To know it up front instead, pre-set `BOOTSTRAP_API_KEY=mantis_live_...` in `.env` before the first boot.
-
-Open and paste that same key to sign in to the web dashboard. Then log in the CLI and mint a key:
-
-```bash
-mantis --key mantis_live_... login --url http://localhost:3000
-mantis new "first mantis" -w http://localhost:3000/inbox/demo
-```
-
-This is fine for evaluation but **don't rely on a laptop deploy for canaries that need to fire when you're away from your machine.** For a real public-reachable deploy — Tailscale Funnel, Cloudflare Tunnel, Railway, Fly.io, or Render — see **[deployment options](https://github.com/privacykey/mantis-docs/blob/main/deployment/README.md)**.
-
-For Fly.io specifically, one command provisions the app, a Managed Postgres
-cluster, the secrets and the first admin key:
-
-```bash
-bash deploy/fly-launch.sh --app my-mantis --region iad
```
-Add `--dry-run` to see every command it would run first. See
-[`deploy/fly.toml.example`](./deploy/fly.toml.example) for the config it
-generates, and [`.github/workflows/fly-deploy.yml`](./.github/workflows/fly-deploy.yml)
-to make later pushes deploy themselves.
+`setup.sh` is idempotent — re-running it leaves existing secrets untouched. Postgres is never published to the host; it sits on an internal-only docker network.
-> **Serve it over HTTPS, never plain HTTP.** Mantis authenticates with an API key sent as a bearer token and a session cookie — over plain HTTP on a routable address both travel in cleartext and can be sniffed. Put it behind a tunnel (the `tailscale` / `cloudflared` compose profiles terminate TLS for you) or a TLS reverse proxy. The compose setup keeps Postgres on an internal-only network with no published port, so the database is never reachable from the host or LAN.
+Full walkthrough, including reading the bootstrap admin key, the one-command Fly.io launch and the stateless edge path: [`docs/GETTING-STARTED.md`](./docs/GETTING-STARTED.md).
-### Quickstart (no server / edge)
+This is fine for evaluation, but **don't rely on a laptop deploy for canaries that need to fire when you're away from your machine.**
-No DB to host: deploy the Cloudflare Worker, then mint URLs that decrypt at the edge.
+Every component — CLI, edge worker, LAN helper, benchmarks, deploy assets — has its own reference, indexed at the end of [`docs/GETTING-STARTED.md`](./docs/GETTING-STARTED.md).
-```bash
-mantis edge keygen # generate the AES key
-mantis edge set-key # link the deployed Worker
-mantis edge mint # interactive wizard for a stateless URL
-```
-
-To arm a whole machine at once — one URL per host alarm (login, sudo, wake,
-boot, network) plus an install bundle directory:
-
-```bash
-mantis edge device --os macos --name web01 --webhook --bundle ./web01
-```
+## Docs
-Two server-backed properties don't cross over, and the command says so on every
-run: without a database there is no idempotent re-mint (re-running issues a
-fresh set of URLs), and the worker can't remember the last hit, so vectors that
-dedupe server-side — network attach — are chattier at the edge. The stateful
-equivalent is `mantis device new`, which keeps both.
+The prose documentation lives in [`privacykey/docs-mantis`](https://github.com/privacykey/docs-mantis). It is a Mintlify source and the rendered site is not live yet, so these link to the Markdown on GitHub.
-See **[`mantis-edge/README.md`](./mantis-edge/README.md)** for Worker deploy and full `mantis edge` usage.
+- [Getting started](https://github.com/privacykey/docs-mantis/blob/main/getting-started.md) — five steps from `brew install` to first key
+- [Use cases](https://github.com/privacykey/docs-mantis/blob/main/use-cases.md) — defensive, detective, operational and adversarial patterns
+- [HTTP API](https://github.com/privacykey/docs-mantis/blob/main/api.md) — endpoints, response kinds, webhook payload shape
+- [File keys](https://github.com/privacykey/docs-mantis/blob/main/file-keys.md), [host-event keys](https://github.com/privacykey/docs-mantis/blob/main/host-events.md), [deployment](https://github.com/privacykey/docs-mantis/blob/main/deployment/README.md), [updating](https://github.com/privacykey/docs-mantis/blob/main/updating.md)
-## Run from source
+## Contributing
-For contributors hacking on the web dashboard / server:
+The repo is a pnpm workspace and pnpm is the only supported package manager — the version is pinned in `packageManager` and CI reads it from there. Node comes from [`.nvmrc`](./.nvmrc).
```bash
-pnpm install
-cp .env.example .env # set DATABASE_URL and generate MANTIS_API_KEY_PEPPER
-pnpm run db:migrate
-pnpm dev
+pnpm install --frozen-lockfile
+pnpm run check # typecheck: server + CLI + edge + iot-helper
+pnpm test # unit tests
+pnpm run build # next build
```
-Generate the pepper with `openssl rand -base64 32` and add it as `MANTIS_API_KEY_PEPPER` in `.env`.
-
-## Components
-
-Each part of the repo has its own reference:
-
-- **CLI** — [`cli/README.md`](./cli/README.md) (full reference) and [`cli/COMMAND_MAP.md`](./cli/COMMAND_MAP.md) (command map)
-- **Edge worker** — [`mantis-edge/README.md`](./mantis-edge/README.md)
-- **IoT / LAN helper** — [`iot-helper/README.md`](./iot-helper/README.md)
-- **Benchmarks** — [`bench/README.md`](./bench/README.md)
-- **Deploy assets** — [`deploy/`](./deploy/) (one-command Fly.io launch, Render example)
-- **MDM fleet canaries** — [`deploy/kandji/`](./deploy/kandji/) (one key per managed Mac, terminal-open alerts)
-
-## Fleet / MDM provisioning
-
-`POST /api/keys` is idempotent when you pass an `external_id` (e.g. a machine
-serial): the first call mints the key, every re-run returns the same one
-(`200` + `"reused": true`), so MDM scripts can enroll on every check-in
-without minting duplicates. Pair it with an **enrollment-scoped API key**
-(`POST /api/api-keys` with `"scope": "enroll"`) — a create-only credential
-that's safe to embed in fleet scripts: if extracted from a device it cannot
-list, read, disable, or delete keys, read hits, or log in to the dashboard.
-
-[`deploy/kandji/`](./deploy/kandji/README.md) has a ready-made Kandji Custom
-Script that gives every Mac its own canary and pings it whenever an
-interactive terminal opens, plus a central pre-provisioning script driven by
-the Kandji API.
-
-## Docs
-
-Full documentation: **[github.com/privacykey/mantis-docs](https://github.com/privacykey/mantis-docs)**.
-
-Common starting points:
-
-- [Getting started](https://github.com/privacykey/mantis-docs/blob/main/getting-started.md) — five steps from `brew install` to first key
-- [Use cases](https://github.com/privacykey/mantis-docs/blob/main/use-cases.md) — defensive / detective / operational / adversarial patterns
-- [HTTP API](https://github.com/privacykey/mantis-docs/blob/main/api.md) — endpoints, response kinds, webhook payload shape
-- [File keys](https://github.com/privacykey/mantis-docs/blob/main/file-keys.md) and [host-event keys](https://github.com/privacykey/mantis-docs/blob/main/host-events.md)
-- [Updating](https://github.com/privacykey/mantis-docs/blob/main/updating.md) — update commands per component
+CI also runs the integration and tier-2 suites against a real Postgres, builds the CLI, and audits production dependencies. [`CONTRIBUTING.md`](./CONTRIBUTING.md) has local setup and how to reproduce each job.
-## License
+## Licence
-MIT License. See [`LICENSE`](./LICENSE).
+MIT. See [`LICENSE`](./LICENSE).
diff --git a/docs/FLEET-PROVISIONING.md b/docs/FLEET-PROVISIONING.md
new file mode 100644
index 0000000..d3fe4c8
--- /dev/null
+++ b/docs/FLEET-PROVISIONING.md
@@ -0,0 +1,17 @@
+# Fleet / MDM provisioning
+
+Moved out of the top-level README. The content is unchanged.
+
+`POST /api/keys` is idempotent when you pass an `external_id` (e.g. a machine
+serial): the first call mints the key, every re-run returns the same one
+(`200` + `"reused": true`), so MDM scripts can enroll on every check-in
+without minting duplicates.
+
+Pair it with an **enrollment-scoped API key** (`POST /api/api-keys` with
+`"scope": "enroll"`) — a create-only credential that's safe to embed in fleet
+scripts: if extracted from a device it cannot list, read, disable, or delete
+keys, read hits, or log in to the dashboard.
+
+[`deploy/kandji/`](../deploy/kandji/README.md) has a ready-made Kandji Custom
+Script that gives every Mac its own canary and pings it whenever an interactive
+terminal opens, plus a central pre-provisioning script driven by the Kandji API.
diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md
new file mode 100644
index 0000000..a6f00fe
--- /dev/null
+++ b/docs/GETTING-STARTED.md
@@ -0,0 +1,117 @@
+# Getting started
+
+Moved out of the top-level README so it stays short. Everything here was in the
+README before; nothing has been added.
+
+**Stateful server or stateless edge?** Run the full **server** (`docker compose`,
+Postgres-backed) when you want the web dashboard, notification queue,
+file/host-event keys, and history. Run the **edge**
+([`mantis-edge/`](../mantis-edge/README.md)) — a Cloudflare Worker that decrypts
+URLs at the edge with no DB to host — when you only need fire-and-forget hit
+alerts and want zero infrastructure.
+
+The fastest path either way is the guided CLI: `mantis init` asks server-or-edge
+and walks you through login and your first key.
+
+```bash
+brew install privacykey/tap/mantis
+mantis init
+```
+
+## Quickstart (stateful server)
+
+```bash
+git clone https://github.com/privacykey/mantis && cd mantis
+./scripts/setup.sh # creates .env with a random DB password + API-key pepper
+docker compose up -d
+# Wait for the boot banner, then read the one-time bootstrap admin key
+docker compose logs -f mantis | grep -m1 -A1 "bootstrap API key"
+```
+
+`setup.sh` is idempotent — re-running it leaves existing secrets untouched.
+Postgres is never published to the host (it sits on an internal-only docker
+network), and the DB password is the single value you set in `.env`; the app's
+`DATABASE_URL` is derived from it.
+
+The `mantis_live_...` value printed above is the **bootstrap admin key** — it is
+both your CLI token and your dashboard login. To know it up front instead,
+pre-set `BOOTSTRAP_API_KEY=mantis_live_...` in `.env` before the first boot.
+
+Open and paste that same key to sign in to the web
+dashboard. Then log in the CLI and mint a key:
+
+```bash
+mantis --key mantis_live_... login --url http://localhost:3000
+mantis new "first mantis" -w http://localhost:3000/inbox/demo
+```
+
+This is fine for evaluation but **don't rely on a laptop deploy for canaries
+that need to fire when you're away from your machine.** For a real
+public-reachable deploy — Tailscale Funnel, Cloudflare Tunnel, Railway, Fly.io,
+or Render — see
+[deployment options](https://github.com/privacykey/docs-mantis/blob/main/deployment/README.md).
+
+> [!CAUTION]
+> **Serve it over HTTPS, never plain HTTP.** Mantis authenticates with an API
+> key sent as a bearer token and a session cookie — over plain HTTP on a
+> routable address both travel in cleartext and can be sniffed. Put it behind a
+> tunnel (the `tailscale` / `cloudflared` compose profiles terminate TLS for
+> you) or a TLS reverse proxy. The compose setup keeps Postgres on an
+> internal-only network with no published port, so the database is never
+> reachable from the host or LAN.
+
+### Fly.io in one command
+
+One command provisions the app, a Managed Postgres cluster, the secrets and the
+first admin key:
+
+```bash
+bash deploy/fly-launch.sh --app my-mantis --region iad
+```
+
+Add `--dry-run` to see every command it would run first. See
+[`deploy/fly.toml.example`](../deploy/fly.toml.example) for the config it
+generates, and
+[`.github/workflows/fly-deploy.yml`](../.github/workflows/fly-deploy.yml) to
+make later pushes deploy themselves.
+
+## Quickstart (no server / edge)
+
+No DB to host: deploy the Cloudflare Worker, then mint URLs that decrypt at the
+edge.
+
+```bash
+mantis edge keygen # generate the AES key
+mantis edge set-key # link the deployed Worker
+mantis edge mint # interactive wizard for a stateless URL
+```
+
+To arm a whole machine at once — one URL per host alarm (login, sudo, wake,
+boot, network) plus an install bundle directory:
+
+```bash
+mantis edge device --os macos --name web01 --webhook --bundle ./web01
+```
+
+Two server-backed properties don't cross over, and the command says so on every
+run: without a database there is no idempotent re-mint (re-running issues a
+fresh set of URLs), and the worker can't remember the last hit, so vectors that
+dedupe server-side — network attach — are chattier at the edge. The stateful
+equivalent is `mantis device new`, which keeps both.
+
+See [`mantis-edge/README.md`](../mantis-edge/README.md) for Worker deploy and
+full `mantis edge` usage.
+
+## Components
+
+Each part of the repo has its own reference:
+
+- **CLI** — [`cli/README.md`](../cli/README.md) (full reference) and
+ [`cli/COMMAND_MAP.md`](../cli/COMMAND_MAP.md) (command map)
+- **Edge worker** — [`mantis-edge/README.md`](../mantis-edge/README.md)
+- **IoT / LAN helper** — [`iot-helper/README.md`](../iot-helper/README.md)
+- **Benchmarks** — [`bench/README.md`](../bench/README.md)
+- **Deploy assets** — [`deploy/`](../deploy/) (one-command Fly.io launch, Render
+ example)
+- **MDM fleet canaries** — [`deploy/kandji/`](../deploy/kandji/README.md) (one
+ key per managed Mac, terminal-open alerts)