Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions api.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ are called out explicitly below.
| `GET` | `/api/keys/:id` | Get one accessible key. Bearer auth. |
| `PATCH` | `/api/keys/:id` | Update memo, response, expiry, dedupe, monitor mode/window, `disabled`, or replace destinations. Bearer auth. |
| `DELETE` | `/api/keys/:id` | Hard-delete a key and cascading hits/notifications. Bearer auth. |
| `GET` | `/api/keys/:id/hits?limit=&cursor=` | Paginated hit log for one key. Bearer auth. |
| `GET` | `/api/hits/recent?since=<iso>&cursor=<iso>&key_id=<id>&limit=<n>` | Recent hit feed across accessible keys, used by CLI watch mode. Bearer auth. |
| `GET` | `/api/keys/:id/hits?limit=&cursor=` | Paginated hit log for one key. Bearer auth. Each hit carries a `notifications` array; see [notification rows](#notification-rows-in-hit-listings). |
| `GET` | `/api/hits/recent?since=<iso>&cursor=<iso>&key_id=<id>&limit=<n>` | Recent hit feed across accessible keys, used by CLI watch mode. Bearer or session auth. Same `notifications` shape as above. |
| `GET` | `/api/keys/:id/download?format=<format>` | Download a generated artifact. Bearer or session auth. Formats: `docx`, `xlsx`, `pptx`, `pdf`, `folder`, `nfc-label`, `apple-wallet`, `svg`, `html`, `md`, `eml`, `ics`, `vcf`, `rtf`, `cookies`, `bookmarks`, `env`, `aws-credentials`, `netrc`, `kubeconfig`, `ovpn`, `rdp`. See [file keys](/file-keys). |
| `POST` | `/api/keys/bulk-download` | Zip one generated artifact per key. Bearer or session auth. Body: `{ ids, format }`, max 50 ids; returns a `.zip` with one artifact (or per-key folder) per key. Ids not visible to the caller are silently skipped, not rejected. |
| `POST` | `/api/keys/device-bundle` | Package an already-minted device suite into an installable zip. Bearer or session auth. Body: `{ device, os, vectors }`, max 20 vectors; returns the install zip, or a JSON file map with `?format=json`. Backs the dashboard's device page and `mantis device new --bundle`. |
Expand All @@ -49,10 +49,10 @@ are called out explicitly below.
| `POST` | `/api/keys/:id/destinations/:destinationId/signing-secret` | Reveal a webhook destination's plaintext HMAC signing secret. Bearer or session auth. Audited. |
| `POST` | `/api/keys/:id/destinations/:destinationId/rotate-secret` | Rotate a webhook destination's HMAC signing secret and return the new secret once. Bearer or session auth. Audited. |
| `GET` | `/api/api-keys` | List API keys. Bearer auth. Hashes are never returned; non-admin keys see only themselves. Each row includes its `scope`. |
| `POST` | `/api/api-keys` | Mint a new API key. Bearer auth. Body: `{ name, is_admin?, scope? }`; plaintext key returned once. Only admins can mint admin keys. `scope` is `full` (default) or `enroll` — see [key scope](#api-key-scope-full-vs-enroll). |
| `POST` | `/api/api-keys` | Mint a new API key. Bearer auth, **admin-only** (`403` for non-admin keys — rows carry no lineage, so a non-admin that could mint siblings would outlive its own revocation). Body: `{ name, is_admin?, scope? }`; plaintext key returned once. `scope` is `full` (default) or `enroll` — see [key scope](#api-key-scope-full-vs-enroll). |
| `DELETE` | `/api/api-keys/:id` | Revoke an API key. Bearer auth. Self-revoke is allowed; revoking others requires admin. |
| `GET` | `/api/device-profiles` | The device-profile / vector catalog used by `mantis device`. Bearer or session auth. |
| `GET` | `/api/audit?limit=&cursor=&since=&event_type=&actor=` | Admin-only audit log. Bearer or session auth. |
| `GET` | `/api/audit?limit=&cursor=&since=&event_type=&actor=` | Admin-only audit log. Bearer or session auth. `actor` must be a full UUID (`422` otherwise). |
| `GET` `HEAD` | `/api/health` | **Public unless gated by your proxy.** Liveness + `SELECT 1` readiness. 200 = app and DB ok, 503 = DB failure. |
| `GET` `POST` | `/api/cron/notifications?max=<n>` | Notification retry and retention worker endpoint for serverless deployments. Requires `Authorization: Bearer $CRON_SECRET`; returns 401 if `CRON_SECRET` is unset. |
| `GET` `HEAD` | `/status/:public_id` | **Public.** Uptime-monitor status endpoint. 200 = ok, 503 = tripped, 404 = not monitored / disabled / expired / unknown. Does not record a hit. |
Expand Down Expand Up @@ -89,7 +89,7 @@ Every API key carries a `scope`, orthogonal to `is_admin`:

Enroll keys are the intended credential for MDM / fleet provisioning: you embed one on every managed machine and accept that a curious user will extract it. An extracted enroll key cannot read hit history, alert routing or signing secrets, and cannot enumerate or list keys — but it is not inert, so size the blast radius before you embed one:

- **It can confirm and retrieve any key whose `external_id` it guesses.** A `POST /api/keys` that collides with an existing `external_id` returns that key's trigger URL, memo, `public_id` and expiry (`"reused": true`, HTTP `200`) — see [Idempotent creation](#idempotent-creation) — regardless of which API key created it. `mantis device` derives `external_id`s deterministically as `mantis:device:<os>:<normalized-name>:<slug>`, so an attacker who knows your naming convention can guess a machine's ids and read back that machine's canary URLs, which is exactly what lets an intruder route around the tripwires. Each such claim is recorded in the audit log as `key.claimed`.
- **It can recover the trigger URL of any key whose `external_id` it guesses.** A `POST /api/keys` that collides with an existing `external_id` returns that key's trigger URL, `public_id` and expiry (`"reused": true`, HTTP `200`) even when a different API key created it — the memo is `null` in that case and alert routing is never included; see [Idempotent creation](#idempotent-creation). This is what lets a re-imaged machine, or a rotated enroll key, find its canary again, and it is the one thing an extracted enroll key can do beyond creating keys. `mantis device` derives `external_id`s deterministically as `mantis:device:<os>:<normalized-name>:<slug>`, so an attacker who knows your naming convention can guess a machine's ids and read back that machine's canary URLs, which is exactly what lets an intruder route around the tripwires. Each cross-key claim is recorded in the audit log as `key.claimed` with `cross_key: true` — watch for a burst of them. (A full-scope key that did not create the key gets `409` instead and learns nothing.)
- **It can supply `destinations` on creation**, and Mantis fires the activation ping synchronously — so the key can make your instance POST to an attacker-chosen HTTP(S) endpoint (private, loopback and metadata addresses are rejected unless `ALLOW_PRIVATE_WEBHOOKS=1`) or, if `SMTP_URL` is set, send it mail.

See the Kandji recipe in the product repo's `deploy/kandji/`.
Expand All @@ -107,10 +107,35 @@ constraint treats NULLs as distinct).

This is the mechanism the fleet-enrollment flow relies on — one key per machine
serial, so re-running enrollment on a reimaged machine reuses its key instead of
littering the list. Enroll-scoped callers (and callers claiming another creator's
`external_id`) get a reduced response shape — trigger URL and identity only, no
alert routing or signing secrets. A claim that races a concurrent delete returns
`409 conflict`; retry.
littering the list. What a repeat POST returns depends on who is asking:

- **the key's creator, or an admin** — the key as they could read it anyway
(full shape for full keys; the reduced shape below for enroll keys);
- **an enrollment-scoped key that did not create it** — the reduced shape:
trigger URL, `public_id`, `external_id`, expiry and `disabled`, with `memo`
set to `null`. No alert routing, no signing secrets. Audited as
`key.claimed` with `cross_key: true`;
- **any other full-scope key** — `409 conflict` with no key details, audited
as `key.claimed` with `denied: true`. If you rotate the full-scope key that
ran a pre-provisioning script, re-run it with an admin key.

A claim that races a concurrent delete also returns `409 conflict`; retry.

## Notification rows in hit listings

Every hit returned by `/api/keys/:id/hits` and `/api/hits/recent` carries a
`notifications` array — one row per destination the hit fanned out to, with
`channel`, `status`, `attempts`, `max_attempts`, `next_attempt_at`,
`succeeded_at`, `last_error`, `target` and `destination_scope`.

`destination_scope` is `key` for a destination attached to the key itself,
`global` for an instance-wide [global destination](/configuration#global-notification-destinations),
and `unknown` when the destination has since been deleted. **`target` is `null`
unless the caller may see it**: admins always may; a non-admin key owner sees
only the targets of the key's own destinations. Global-destination targets are
Slack / Discord / Teams / Home Assistant webhook URLs configured by an admin —
they are credentials, so a non-admin never receives them. The dashboard hit
feed and `mantis hits` render a placeholder for redacted rows.

## Response kinds for the trigger endpoint

Expand Down
16 changes: 16 additions & 0 deletions changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ description: "Published Mantis releases — CLI and server — what changed and
to confirm CLI/server compatibility, and see [Updating](/updating) for the per-component
update commands.
</Note>
## Server v0.2.0

5 September 2026

Security hardening release following the pre-launch audit (#86). Hit listings no longer expose admin-configured global webhook URLs to non-admin key owners, `external_id` claims by unrelated full keys are refused, installer templates neutralise hostile memos, API-key minting is admin-only, and `MANTIS_PUBLIC_PATH` finally routes.

[Full release notes →](https://github.com/privacykey/mantis/releases/tag/v0.2.0)

## CLI v0.2.1

5 September 2026

Standalone CLI binaries for macOS and Linux on arm64 / x86_64. Each binary is built on its own native runner so the bundled native modules (`@napi-rs/keyring`) match the target.

[Full release notes →](https://github.com/privacykey/mantis/releases/tag/cli-v0.2.1)

## CLI v0.2.0

8 August 2026
Expand Down
2 changes: 1 addition & 1 deletion configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ These are the variables operators usually need to understand.
## URLs

- `PUBLIC_BASE_URL` — public origin used when Mantis generates trigger, status, and Wallet callback URLs. Defaults to `http://localhost:3000`.
- `MANTIS_PUBLIC_PATH` — trigger path prefix, default `/c`. Changing this changes generated URLs and the public-only host allowlist; if you choose a different path, put a reverse proxy in front that rewrites it to `/c/<id>` on the app.
- `MANTIS_PUBLIC_PATH` — trigger path prefix, default `/c`. Changing this changes generated URLs and the public-only host allowlist; the app rewrites `<prefix>/<id>` onto its trigger handler itself, so no reverse-proxy rewrite is needed (server ≥ 0.2.0 — earlier releases needed one). Responses under a custom prefix also carry the dashboard's security headers (`X-Frame-Options: DENY`, CSP), which only matters if you embed an `html` response kind in an iframe.

## Boot and runtime

Expand Down
16 changes: 10 additions & 6 deletions single-user.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ sidebarTitle: "Single-user model"
Mantis is single-user out of the box. The first API key minted on a fresh
deploy is automatically `is_admin = true` (the bootstrap path sets the flag).
That key is the operator: it sees and manages every key on the instance.
Additional API keys created later default to **non-admin** — they can only
see and manage the keys they created themselves.
Additional API keys are minted by an admin (`POST /api/api-keys` is
admin-only) and default to **non-admin** — they can only see and manage the
keys they created themselves, and their hit history shows the key's own
notification targets but not the admin's global destinations.

If you only ever mint one API key for your deploy you'll never notice the
distinction; the schema is shaped that way so it's easy to later promote a
Expand All @@ -28,9 +30,11 @@ destination-secret / wallet-config events across the instance.
`403` on every other route — it cannot list keys, read hit history, read alert
routing or signing secrets, or log in to the dashboard. The one thing it can
read back is a key it can name: re-posting an `external_id` that already exists
returns that key — trigger URL, memo, `public_id`, expiry — no matter which API
key created it. So an enroll key is safe to embed on managed machines only to
the extent your `external_id`s are hard to guess; `mantis device` derives them
deterministically from the machine name. `is_admin` and `enroll`
returns that key's trigger URL, `public_id` and expiry (the memo is `null` and
alert routing is never included) even when another API key created it, and the
claim is audited as `key.claimed` with `cross_key: true`. So an enroll key is
safe to embed on managed machines only to the extent your `external_id`s are
hard to guess; `mantis device` derives them deterministically from the machine
name. A full-scope key that did not create the key gets `409` instead. `is_admin` and `enroll`
are mutually exclusive. See [key scope in the HTTP
API](/api#api-key-scope-full-vs-enroll).
Loading